anthropic model-access modules: manager (refreshable-grant) and consumer
Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { applyGrant, deliver } from "../credentials.ts";
|
||||
|
||||
test("applyGrant strips the refresh token a full grant on disk left behind", () => {
|
||||
const local = { claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-must-not-survive" } };
|
||||
const next = applyGrant(local, { accessToken: "at-new", expiresAt: 123 });
|
||||
assert.equal(next.claudeAiOauth!.accessToken, "at-new");
|
||||
assert.equal(next.claudeAiOauth!.expiresAt, 123);
|
||||
assert.ok(!("refreshToken" in next.claudeAiOauth!), "a node held onto a refresh token");
|
||||
});
|
||||
|
||||
test("deliver writes the port-map shape, access-token-only, and never a refresh token", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "anthropic-consumer-"));
|
||||
const path = join(dir, ".credentials.json");
|
||||
// A prior interactive login left a full grant on disk.
|
||||
writeFileSync(path, JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-login" } }));
|
||||
|
||||
deliver(path, { accessToken: "at-delivered", expiresAt: 999, subscriptionType: "max" });
|
||||
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const creds = JSON.parse(raw);
|
||||
assert.equal(creds.claudeAiOauth.accessToken, "at-delivered");
|
||||
assert.equal(creds.claudeAiOauth.expiresAt, 999);
|
||||
assert.equal(creds.claudeAiOauth.subscriptionType, "max");
|
||||
assert.doesNotMatch(raw, /rt-login/, "the refresh token is still on disk");
|
||||
assert.ok(!("refreshToken" in creds.claudeAiOauth));
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { foldTranscript } from "../transcript.ts";
|
||||
|
||||
// A captured-shape transcript: two assistant turns and a user line, exactly the fields the port map
|
||||
// names. Not imagined — the field names match the mature implementation's parse.
|
||||
const TRANSCRIPT = [
|
||||
JSON.stringify({ type: "user", timestamp: "2026-01-01T00:00:00Z", cwd: "/work/app", gitBranch: "main" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
costUSD: 0.01,
|
||||
message: {
|
||||
model: "claude-opus-4-8",
|
||||
usage: { input_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 5, output_tokens: 40 },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
costUSD: 0.02,
|
||||
message: { model: "claude-opus-4-8", usage: { input_tokens: 200, output_tokens: 60 } },
|
||||
}),
|
||||
"", // a half-written trailing line is ordinary and must not be fatal.
|
||||
].join("\n");
|
||||
|
||||
test("a transcript sums per-session token counts, cost, and metadata", () => {
|
||||
const s = foldTranscript("session-abc", TRANSCRIPT);
|
||||
assert.equal(s.sessionId, "session-abc");
|
||||
assert.equal(s.turns, 2);
|
||||
assert.equal(s.inputTokens, 300);
|
||||
assert.equal(s.cacheCreationTokens, 20);
|
||||
assert.equal(s.cacheReadTokens, 5);
|
||||
assert.equal(s.outputTokens, 100);
|
||||
assert.equal(Math.round(s.costUSD * 100) / 100, 0.03);
|
||||
assert.equal(s.model, "claude-opus-4-8");
|
||||
assert.equal(s.gitBranch, "main");
|
||||
assert.equal(s.cwd, "/work/app");
|
||||
assert.equal(s.startedAt, "2026-01-01T00:00:00Z");
|
||||
assert.equal(s.lastActive, "2026-01-01T00:00:02Z");
|
||||
});
|
||||
|
||||
test("a malformed line is skipped, not fatal", () => {
|
||||
const s = foldTranscript("s", 'not json\n{"type":"assistant","message":{"usage":{"output_tokens":7}}}');
|
||||
assert.equal(s.outputTokens, 7);
|
||||
assert.equal(s.turns, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user