Merge pull request 'anthropic model-access: manager + consumer modules' (#16) from feat/anthropic-module into main
This commit was merged in pull request #16.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// The consumer's scheduled run: take the ACCESS token the mesh delivered and write it where the
|
||||
// Claude CLI reads it, access-token-only (novox/hq ADR 0050). The refresh token is never here to
|
||||
// strip — the manager holds it, and a holder's delivery has only ever been the access token.
|
||||
//
|
||||
// What the host delivers, per the manifest:
|
||||
// secrets.model-access -> a file holding the sealed-then-unsealed ACCESS token (the host opened it
|
||||
// with this node's private key; this process reads plaintext).
|
||||
// binds.model-access -> a JSON file of the non-secret facts the licence serves (which licence,
|
||||
// model, and — when the control plane carries them — grant expiry/scopes).
|
||||
//
|
||||
// Runs as `mesh-tools run` (no broker) on a schedule, so it is idempotent: same token in, same file
|
||||
// out.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { deliver, type DeliveredGrant } from "../credentials.js";
|
||||
import { readAccountUuid, check } from "../identity.js";
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`${name} is not set — the consumer runtime was deployed without it`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Read optional non-secret grant metadata (expiry, scopes, subscription) from the bound facts file. */
|
||||
function readBoundMeta(path: string | undefined): Partial<DeliveredGrant> {
|
||||
if (!path) return {};
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
return {
|
||||
expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : null,
|
||||
refreshTokenExpiresAt: typeof raw.refreshTokenExpiresAt === "number" ? raw.refreshTokenExpiresAt : null,
|
||||
scopes: Array.isArray(raw.scopes) ? (raw.scopes as string[]) : null,
|
||||
subscriptionType: typeof raw.subscriptionType === "string" ? raw.subscriptionType : null,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const accessToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
|
||||
if (!accessToken) {
|
||||
// Nothing was delivered — which reads exactly like a credential that never arrived, so it is
|
||||
// said rather than written as an empty file the CLI would take for a login it should not do.
|
||||
throw new Error("[anthropic-consumer] the delivered access token is empty; nothing was written");
|
||||
}
|
||||
|
||||
const meta = readBoundMeta(process.env.MESH_MODEL_ACCESS_BIND_FILE);
|
||||
const grant: DeliveredGrant = { accessToken, ...meta };
|
||||
|
||||
const target = process.env.MESH_CLAUDE_CREDENTIALS_FILE ?? `${homedir()}/.claude/.credentials.json`;
|
||||
deliver(target, grant);
|
||||
console.error(`[anthropic-consumer] wrote an access-token-only credential to ${target}`);
|
||||
|
||||
// The mis-binding guard, best-effort and fail-closed. The expected account uuid is not yet plumbed
|
||||
// (identity.ts TODO), so this reports what it can see rather than acting on it — it never delivers
|
||||
// to a wrong account because it never learns one to deliver to.
|
||||
const identityFile = process.env.MESH_CLAUDE_IDENTITY_FILE ?? `${homedir()}/.claude.json`;
|
||||
const found = readAccountUuid(identityFile);
|
||||
const expected = process.env.MESH_MODEL_ACCESS_ACCOUNT_UUID ?? null;
|
||||
const verdict = check(found, expected);
|
||||
if (verdict.state === "wrong-account") {
|
||||
throw new Error(
|
||||
`[anthropic-consumer] the CLI is logged in as ${verdict.found}, not the licensed ${verdict.expected}; refusing`,
|
||||
);
|
||||
}
|
||||
console.error(`[anthropic-consumer] identity check: ${verdict.state}`);
|
||||
}
|
||||
|
||||
function homedir(): string {
|
||||
return process.env.HOME ?? "/root";
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,82 @@
|
||||
// Writing the access token where the Claude CLI reads it — the consumer half of model-access
|
||||
// (novox/hq ADR 0050). A node holds an ACCESS token and nothing else: it cannot rotate, so it is
|
||||
// never given a refresh token, and this enforces that on every write.
|
||||
//
|
||||
// The file shape and the strip are ported byte-exact from the mature implementation (see the port
|
||||
// map): `~/.claude/.credentials.json` → `{ claudeAiOauth: { accessToken, expiresAt,
|
||||
// refreshTokenExpiresAt?, scopes?, subscriptionType? } }`, and the refresh token is deleted, not
|
||||
// merely omitted, so a full grant left by an interactive login is stripped back to access-only.
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
/** The access-token-only grant the mesh delivered — what the manager submitted, minus the refresh. */
|
||||
export interface DeliveredGrant {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAt?: number | null;
|
||||
readonly refreshTokenExpiresAt?: number | null;
|
||||
readonly scopes?: string[] | null;
|
||||
readonly subscriptionType?: string | null;
|
||||
}
|
||||
|
||||
interface ClaudeOauth {
|
||||
accessToken?: string;
|
||||
expiresAt?: number;
|
||||
refreshTokenExpiresAt?: number;
|
||||
scopes?: string[];
|
||||
subscriptionType?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
interface Credentials {
|
||||
claudeAiOauth?: ClaudeOauth;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Read the existing credentials file, or an empty object if there is none or it is unreadable. */
|
||||
function readLocal(path: string): Credentials {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay the delivered grant onto whatever is on disk, then STRIP the refresh token — the node
|
||||
* carve-out. Returns the object to write, so the strip is testable without touching a file.
|
||||
*/
|
||||
export function applyGrant(local: Credentials, grant: DeliveredGrant): Credentials {
|
||||
const oauth = local.claudeAiOauth ?? {};
|
||||
const next: Credentials = {
|
||||
...local,
|
||||
claudeAiOauth: {
|
||||
...oauth,
|
||||
accessToken: grant.accessToken,
|
||||
...(grant.expiresAt != null ? { expiresAt: grant.expiresAt } : {}),
|
||||
...(grant.refreshTokenExpiresAt != null
|
||||
? { refreshTokenExpiresAt: grant.refreshTokenExpiresAt }
|
||||
: {}),
|
||||
...(grant.scopes ? { scopes: grant.scopes } : {}),
|
||||
...(grant.subscriptionType ? { subscriptionType: grant.subscriptionType } : {}),
|
||||
},
|
||||
};
|
||||
// A node NEVER holds a refresh token: delete it, so a full grant on disk is reduced to access-only.
|
||||
delete next.claudeAiOauth!.refreshToken;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Atomic write-then-rename at 0600 — a partial credentials file must never be read as a whole one. */
|
||||
export function writeCredentials(path: string, creds: Credentials): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Read, overlay, strip, write — the whole consumer credential update, in one call. */
|
||||
export function deliver(path: string, grant: DeliveredGrant): Credentials {
|
||||
const next = applyGrant(readLocal(path), grant);
|
||||
writeCredentials(path, next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the
|
||||
// token or any API — it lives in a sibling CLI state file, `~/.claude.json` →
|
||||
// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the
|
||||
// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence
|
||||
// account refuses rather than guesses, because delivering an access token to the wrong account is the
|
||||
// exact fault this exists to catch.
|
||||
//
|
||||
// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded
|
||||
// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does
|
||||
// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied,
|
||||
// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export type IdentityVerdict =
|
||||
| { state: "verified"; accountUuid: string }
|
||||
| { state: "no-identity-file" }
|
||||
| { state: "licence-not-adopted" }
|
||||
| { state: "wrong-account"; found: string; expected: string };
|
||||
|
||||
interface ClaudeJson {
|
||||
oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string };
|
||||
}
|
||||
|
||||
/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */
|
||||
export function readAccountUuid(path: string): string | null {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson;
|
||||
return raw.oauthAccount?.accountUuid ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its
|
||||
* inputs so the fail-closed logic is tested without a filesystem.
|
||||
*
|
||||
* TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the
|
||||
* control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the
|
||||
* port map's five states. Until then only the two safe verdicts are reachable: verified when an
|
||||
* expected uuid is provided and matches, refuse otherwise.
|
||||
*/
|
||||
export function check(found: string | null, expected: string | null): IdentityVerdict {
|
||||
if (found === null) return { state: "no-identity-file" };
|
||||
if (!expected) return { state: "licence-not-adopted" };
|
||||
if (found === expected) return { state: "verified", accountUuid: found };
|
||||
return { state: "wrong-account", found, expected };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"module": "anthropic-consumer",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"model-access"
|
||||
],
|
||||
"binds": {
|
||||
"model-access": "/var/lib/anthropic-consumer/model.json"
|
||||
},
|
||||
"secrets": {
|
||||
"model-access": "/var/lib/anthropic-consumer/access-token"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/anthropic-consumer/broker"
|
||||
},
|
||||
"emits": [
|
||||
"module.anthropic-consumer.usage.session"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/anthropic-consumer",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/anthropic-consumer",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "claude-home",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/anthropic-consumer/claude",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "out",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/anthropic-consumer/out",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "apply",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-consumer-apply",
|
||||
"image": "mesh-runtime-anthropic-consumer@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-consumer/dist/apply/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/anthropic-consumer:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
||||
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
|
||||
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "usage",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-consumer-usage",
|
||||
"image": "mesh-runtime-anthropic-consumer@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-consumer/dist/usage/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh/anthropic-consumer/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/anthropic-consumer:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
|
||||
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
|
||||
"MESH_TOOLS_MAIN": "/app/dist/main.js"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-anthropic-consumer",
|
||||
"version": "0.1.0",
|
||||
"description": "anthropic-consumer — the consumer side of model-access (ADR 0050): writes the delivered access token to ~/.claude/.credentials.json (access-token-only) and reports session-grain usage from the CLI transcripts (ADR 0054).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation
|
||||
// reads `~/.claude/projects/<projDir>/<sessionId>.jsonl` and sums the token counts each assistant
|
||||
// message reports; this ports the token extraction and DROPS the per-message account-attribution
|
||||
// timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so
|
||||
// there is nothing to attribute per message.
|
||||
//
|
||||
// The fields are ported from the port map: assistant lines carry
|
||||
// `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`,
|
||||
// `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`.
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { createReadStream } from "node:fs";
|
||||
|
||||
/** One session's totals — the session-grain usage row ADR 0054 fixes. */
|
||||
export interface SessionUsage {
|
||||
sessionId: string;
|
||||
model: string | null;
|
||||
gitBranch: string | null;
|
||||
cwd: string | null;
|
||||
turns: number;
|
||||
inputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
costUSD: number;
|
||||
startedAt: string | null;
|
||||
lastActive: string | null;
|
||||
}
|
||||
|
||||
interface Line {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
cwd?: string;
|
||||
gitBranch?: string;
|
||||
costUSD?: number;
|
||||
message?: {
|
||||
model?: string;
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function empty(sessionId: string): SessionUsage {
|
||||
return {
|
||||
sessionId,
|
||||
model: null,
|
||||
gitBranch: null,
|
||||
cwd: null,
|
||||
turns: 0,
|
||||
inputTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
outputTokens: 0,
|
||||
costUSD: 0,
|
||||
startedAt: null,
|
||||
lastActive: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */
|
||||
export function foldLine(acc: SessionUsage, raw: string): SessionUsage {
|
||||
const line = parse(raw);
|
||||
if (!line) return acc;
|
||||
|
||||
if (line.timestamp) {
|
||||
if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp;
|
||||
if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp;
|
||||
}
|
||||
if (line.type === "user") {
|
||||
if (line.cwd) acc.cwd = line.cwd;
|
||||
if (line.gitBranch) acc.gitBranch = line.gitBranch;
|
||||
}
|
||||
if (line.type === "assistant") {
|
||||
acc.turns += 1;
|
||||
const u = line.message?.usage ?? {};
|
||||
acc.inputTokens += u.input_tokens ?? 0;
|
||||
acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
|
||||
acc.cacheReadTokens += u.cache_read_input_tokens ?? 0;
|
||||
acc.outputTokens += u.output_tokens ?? 0;
|
||||
acc.costUSD += line.costUSD ?? 0;
|
||||
if (!acc.model && line.message?.model) acc.model = line.message.model;
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function parse(raw: string): Line | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as Line;
|
||||
} catch {
|
||||
// A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and
|
||||
// a half-written last line is ordinary.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */
|
||||
export function foldTranscript(sessionId: string, text: string): SessionUsage {
|
||||
return text.split("\n").reduce(foldLine, empty(sessionId));
|
||||
}
|
||||
|
||||
/** Stream one `<sessionId>.jsonl` file line by line, so a large transcript never loads whole. */
|
||||
export async function readSessionFile(path: string, sessionId: string): Promise<SessionUsage> {
|
||||
const acc = empty(sessionId);
|
||||
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
||||
for await (const line of rl) foldLine(acc, line);
|
||||
return acc;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"credentials.ts",
|
||||
"transcript.ts",
|
||||
"identity.ts",
|
||||
"apply/index.ts",
|
||||
"usage/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Session-grain usage emission (novox/hq ADR 0054). On a schedule, read every transcript under
|
||||
// `~/.claude/projects/*/<sessionId>.jsonl`, sum its tokens, and emit one session-grain usage event
|
||||
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
|
||||
// attribution is done — just the totals (port map "don't-map" #3).
|
||||
//
|
||||
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
|
||||
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
|
||||
|
||||
import { readdirSync, statSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import { readSessionFile, type SessionUsage } from "../transcript.js";
|
||||
|
||||
function projectsDir(): string {
|
||||
return process.env.MESH_CLAUDE_PROJECTS_DIR ?? `${process.env.HOME ?? "/root"}/.claude/projects`;
|
||||
}
|
||||
|
||||
/** Every `<sessionId>.jsonl` under the projects tree, with the project directory it sits in. */
|
||||
function transcripts(root: string): { path: string; sessionId: string }[] {
|
||||
const found: { path: string; sessionId: string }[] = [];
|
||||
let projects: string[];
|
||||
try {
|
||||
projects = readdirSync(root);
|
||||
} catch {
|
||||
return found; // no projects yet is not a failure — there is simply nothing to report.
|
||||
}
|
||||
for (const proj of projects) {
|
||||
const dir = join(root, proj);
|
||||
let entries: string[];
|
||||
try {
|
||||
if (!statSync(dir).isDirectory()) continue;
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of entries) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
found.push({ path: join(dir, file), sessionId: file.replace(/\.jsonl$/, "") });
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const module = process.env.MESH_MODULE ?? "anthropic-consumer";
|
||||
const node = process.env.MESH_NODE ?? "unknown";
|
||||
|
||||
const readings: SessionUsage[] = [];
|
||||
for (const t of transcripts(projectsDir())) {
|
||||
try {
|
||||
readings.push(await readSessionFile(t.path, t.sessionId));
|
||||
} catch (err) {
|
||||
console.error(`[anthropic-consumer] could not read ${t.path}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of readings) {
|
||||
await emitUsage({ grain: "session", node, module, ...r });
|
||||
}
|
||||
|
||||
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
|
||||
atomicWrite(process.env.MESH_ANTHROPIC_USAGE_OUT, JSON.stringify(readings, null, 2));
|
||||
}
|
||||
console.error(`[anthropic-consumer] reported ${readings.length} session(s)`);
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, content, { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
|
||||
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
||||
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", (err) => {
|
||||
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,35 @@
|
||||
// Adoption: the ONE time an operator's refresh token enters the mesh, and it enters already sealed.
|
||||
//
|
||||
// The refresh token is read here, on the MANAGER NODE, sealed to that node's PUBLIC sealing key, and
|
||||
// only the sealed box leaves this process (novox/hq ADR 0050). The control plane stores that box via
|
||||
// `licence set-grant` without ever seeing the refresh token in the clear — the same bound every
|
||||
// delivery keeps. This is the counterpart to `refresh/index.js`: adoption seals the first box, refresh
|
||||
// re-seals a rotated one; both use the very anonymous box (`crypto_box_seal`) the mesh seals every
|
||||
// credential with, so the HOST unseals the stored box to mount the cleartext back — this module is
|
||||
// never given a private key and opens nothing.
|
||||
//
|
||||
// MESH_ANTHROPIC_ADOPT_TOKEN_FILE the operator's refresh token, read once and never written out
|
||||
// MESH_MODEL_ACCESS_BIND_FILE the manager holder's bound facts, carrying manager_public_key
|
||||
// MESH_ANTHROPIC_GRANT_OUT where the sealed box is written, for `licence set-grant`
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { seal } from "../sealedbox.js";
|
||||
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`${name} is not set — adoption needs it`);
|
||||
return v;
|
||||
}
|
||||
|
||||
const refreshToken = readFileSync(required("MESH_ANTHROPIC_ADOPT_TOKEN_FILE"), "utf8").trim();
|
||||
if (!refreshToken) throw new Error("[anthropic-manager] there is no refresh token to adopt");
|
||||
|
||||
// The node's PUBLIC sealing key, delivered by the mesh in the manager holder's bound facts. Public,
|
||||
// so it is safe to hand a module; the private half stays with the host, which is what opens the box.
|
||||
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
|
||||
|
||||
const sealed = seal(new Uint8Array(Buffer.from(refreshToken, "utf8")), nodePub);
|
||||
writeSealedGrant(required("MESH_ANTHROPIC_GRANT_OUT"), sealed, nodePub);
|
||||
console.error("[anthropic-manager] sealed the refresh token to this node's key; only the host opens it");
|
||||
@@ -0,0 +1,135 @@
|
||||
// The only file that talks to Anthropic — the vendor half of the refreshable-grant adapter
|
||||
// (novox/hq ADR 0050). Isolated exactly as cloudflare-dns isolates its registrar call, so the
|
||||
// vendor is swappable and the one place a token endpoint is reached is auditable.
|
||||
//
|
||||
// Two endpoints, and they are different hosts (port-map "don't-map" #1): the TOKEN host mints a new
|
||||
// access token from the refresh token; the USAGE host reports utilisation against an access token.
|
||||
|
||||
/** The token endpoint, overridable so the lab can point the whole flow at a stub without a vendor. */
|
||||
export function tokenEndpoint(env = process.env): string {
|
||||
return env.MESH_ANTHROPIC_TOKEN_ENDPOINT ?? "https://platform.claude.com/v1/oauth/token";
|
||||
}
|
||||
|
||||
/** The usage endpoint, likewise overridable for the lab. */
|
||||
export function usageEndpoint(env = process.env): string {
|
||||
return env.MESH_ANTHROPIC_USAGE_ENDPOINT ?? "https://api.anthropic.com/api/oauth/usage";
|
||||
}
|
||||
|
||||
// The OAuth client id is a hard-won constant, ported byte-exact from the mature implementation: a
|
||||
// metadata URL in its place yields 400. It is not a secret (it identifies the public Claude Code
|
||||
// client), so it lives in code.
|
||||
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
|
||||
/** The vendor's token response, snake_case as the wire has it. */
|
||||
export interface RefreshedGrant {
|
||||
readonly access_token?: string;
|
||||
readonly refresh_token?: string;
|
||||
readonly expires_in?: number;
|
||||
readonly refresh_token_expires_in?: number;
|
||||
readonly scopes?: string[];
|
||||
readonly subscription_type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a refresh token for a fresh grant. Returns null on any non-ok response, surfacing the
|
||||
* OAuth error body (invalid_grant/invalid_client/…) — the difference between "the token is dead" and
|
||||
* "the endpoint was unreachable", which a bare status hides.
|
||||
*/
|
||||
export async function refreshGrant(
|
||||
refreshToken: string,
|
||||
env = process.env,
|
||||
): Promise<RefreshedGrant | null> {
|
||||
const resp = await fetch(tokenEndpoint(env), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "<unreadable>");
|
||||
console.error(
|
||||
`[anthropic-manager] token refresh failed: ${resp.status} ${resp.statusText} — ${body.slice(0, 400)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as RefreshedGrant;
|
||||
}
|
||||
|
||||
/** The vendor's usage response — utilisation percentages against several windows. */
|
||||
export interface UsageLimits {
|
||||
readonly five_hour?: { utilization: number; resets_at?: string };
|
||||
readonly seven_day?: { utilization: number; resets_at?: string };
|
||||
readonly seven_day_sonnet?: { utilization: number; resets_at?: string };
|
||||
readonly seven_day_opus?: { utilization: number; resets_at?: string };
|
||||
readonly extra_usage?: { utilization: number };
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read utilisation for an access token. Never refreshes here (a 401 is just reported): a second
|
||||
* refresh source racing the first is the fault the mature implementation warns against.
|
||||
*/
|
||||
export async function readUsage(accessToken: string, env = process.env): Promise<UsageLimits | null> {
|
||||
const resp = await fetch(usageEndpoint(env), {
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
console.error(`[anthropic-manager] usage endpoint returned ${resp.status}: ${body.slice(0, 200)}`);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as UsageLimits;
|
||||
}
|
||||
|
||||
/** The licence-grain reading ADR 0054 fixes, flattened from the vendor's windows. */
|
||||
export interface UsageReading {
|
||||
readonly sessionPct: number | null;
|
||||
readonly sessionResetsAt: string | null;
|
||||
readonly weeklyPct: number | null;
|
||||
readonly sonnetPct: number | null;
|
||||
readonly extraPct: number | null;
|
||||
readonly raw: UsageLimits;
|
||||
}
|
||||
|
||||
export function flattenUsage(u: UsageLimits): UsageReading {
|
||||
return {
|
||||
sessionPct: u.five_hour?.utilization ?? null,
|
||||
sessionResetsAt: u.five_hour?.resets_at ?? null,
|
||||
weeklyPct: u.seven_day?.utilization ?? null,
|
||||
sonnetPct: u.seven_day_sonnet?.utilization ?? null,
|
||||
extraPct: u.extra_usage?.utilization ?? null,
|
||||
raw: u,
|
||||
};
|
||||
}
|
||||
|
||||
/** The access-token-only grant a holder is delivered — the port-map credential-file shape's fields. */
|
||||
export interface AccessGrant {
|
||||
readonly accessToken: string;
|
||||
readonly expiresAt: number | null;
|
||||
readonly refreshTokenExpiresAt: number | null;
|
||||
readonly scopes: string[] | null;
|
||||
readonly subscriptionType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a vendor refresh into what the manager submits: the access-token-only grant for holders, and
|
||||
* the rotated refresh token if the vendor sent one. Never clobbers a good grant from an empty
|
||||
* response — no access_token means the caller keeps what it had.
|
||||
*/
|
||||
export function grantFromRefresh(r: RefreshedGrant, nowMs: number): { access: AccessGrant; rotatedRefresh: string | null } | null {
|
||||
if (!r.access_token) return null;
|
||||
return {
|
||||
access: {
|
||||
accessToken: r.access_token,
|
||||
expiresAt: typeof r.expires_in === "number" ? nowMs + r.expires_in * 1000 : null,
|
||||
refreshTokenExpiresAt:
|
||||
typeof r.refresh_token_expires_in === "number" ? nowMs + r.refresh_token_expires_in * 1000 : null,
|
||||
scopes: r.scopes ?? null,
|
||||
subscriptionType: r.subscription_type ?? null,
|
||||
},
|
||||
rotatedRefresh: r.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and
|
||||
// writing a sealed refresh token in the wire shape mesh-control reads.
|
||||
//
|
||||
// **The public key is delivered, not derived.** The manager module holds no node key of its own
|
||||
// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it
|
||||
// needs the node's PUBLIC sealing key, and mesh-control puts that in the manager holder's bound facts
|
||||
// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every
|
||||
// rotation read it from there.
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
/** The manager node's public sealing key, from the bound facts file the mesh delivers. */
|
||||
export function managerPublicKey(boundFile: string): string {
|
||||
const raw = JSON.parse(readFileSync(boundFile, "utf8")) as { serves?: Record<string, unknown> };
|
||||
const key = raw.serves?.["manager_public_key"];
|
||||
if (typeof key !== "string" || key === "") {
|
||||
throw new Error(
|
||||
"the bound facts carry no manager_public_key — this node is not the licence's manager, or " +
|
||||
"the manager holder has not been delivered yet",
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-control reads. */
|
||||
export function writeSealedGrant(path: string, sealed: string, managerKey: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify({ sealed, manager_key: managerKey }), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"module": "anthropic-manager",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"model-access"
|
||||
],
|
||||
"binds": {
|
||||
"model-access": "/var/lib/mesh/anthropic-manager/model.json"
|
||||
},
|
||||
"secrets": {
|
||||
"model-access": "/var/lib/mesh/anthropic-manager/refresh-token"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/anthropic-manager/broker"
|
||||
},
|
||||
"emits": [
|
||||
"module.anthropic-manager.usage.read"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/anthropic-manager",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "out",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/anthropic-manager/out",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "refresh",
|
||||
"type": "container",
|
||||
"name": "mesh-anthropic-manager-refresh",
|
||||
"image": "mesh-runtime-anthropic-manager@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"network": "host",
|
||||
"schedule": "*/5 * * * *",
|
||||
"args": [
|
||||
"run",
|
||||
"/app/modules/anthropic-manager/dist/refresh/index.js"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/mesh/anthropic-manager/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/anthropic-manager:/run/state"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_ANTHROPIC_LICENCE": "personal",
|
||||
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/refresh-token",
|
||||
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
||||
"MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token",
|
||||
"MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json",
|
||||
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json",
|
||||
"MESH_TOOLS_MAIN": "/app/dist/main.js"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@novox/module-anthropic-manager",
|
||||
"version": "0.1.0",
|
||||
"description": "anthropic-manager — the manager side of the model-access refreshable-grant (ADR 0050): opens the refresh token on the manager node alone, refreshes it against Anthropic's OAuth endpoint, and submits back only the access token and the re-sealed refresh envelope.",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"tweetnacl-sealedbox-js": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// The manager's scheduled run (novox/hq ADR 0050/0053). It is the whole of the carve-out in one
|
||||
// place, and it runs on the MANAGER NODE, never in the control plane:
|
||||
//
|
||||
// 1. read the refresh token as CLEARTEXT — the host unsealed the stored box with THIS node's private
|
||||
// key and mounted it at the module's bound secret path, exactly as it delivers any credential.
|
||||
// This module holds no node key and opens nothing itself;
|
||||
// 2. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
|
||||
// refresh token);
|
||||
// 3. if the vendor rotated the refresh token, SEAL the new one to this node's PUBLIC sealing key
|
||||
// (delivered in the bound facts) with the same anonymous box the mesh seals every credential with;
|
||||
// 4. hand the control plane back ONLY the access token in the clear + the opaque re-sealed box —
|
||||
// never the refresh token — which it seals per consumer holder and stores;
|
||||
// 5. poll usage with the fresh access token and record the licence-grain reading.
|
||||
//
|
||||
// mesh-control receives the products of steps 3–4 through `licence submit-refresh` (access token +
|
||||
// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to
|
||||
// be opened here at all — the host did that.
|
||||
//
|
||||
// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the
|
||||
// host mounts; the submit itself (the transport to mesh-control) is done by the caller invoking
|
||||
// `mesh-control licence submit-refresh`. In the lab that caller is the scenario; in production it is
|
||||
// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED
|
||||
// — because a cross-node authenticated command surface is out of this module's scope.
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { seal } from "../sealedbox.js";
|
||||
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
|
||||
import { refreshGrant, grantFromRefresh, readUsage, flattenUsage } from "../client.js";
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`${name} is not set — the manager runtime was deployed without it`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function atomicWrite(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, content, { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
|
||||
|
||||
// Step 1: the refresh token as cleartext, unsealed and mounted by the HOST. No open here.
|
||||
const refreshToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
|
||||
if (!refreshToken) {
|
||||
// Nothing was delivered — the manager has not adopted a refresh token yet, or the push has not
|
||||
// landed. Said rather than treated as an empty token the vendor would reject obscurely.
|
||||
throw new Error("[anthropic-manager] no refresh token was delivered; adopt one first");
|
||||
}
|
||||
|
||||
// The node's PUBLIC sealing key, to re-seal a rotated refresh token. Public, delivered in the facts.
|
||||
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
|
||||
|
||||
// Step 2: the vendor call.
|
||||
const refreshed = await refreshGrant(refreshToken);
|
||||
if (!refreshed) {
|
||||
// A dead endpoint or a rejected token: nothing to publish, and we do not clobber a good grant.
|
||||
throw new Error(`[anthropic-manager] the refresh of ${licence} produced no grant`);
|
||||
}
|
||||
const grant = grantFromRefresh(refreshed, Date.now());
|
||||
if (!grant) {
|
||||
throw new Error(`[anthropic-manager] the refresh of ${licence} returned no access token`);
|
||||
}
|
||||
|
||||
// Step 3: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
|
||||
if (grant.rotatedRefresh) {
|
||||
const sealed = seal(new Uint8Array(Buffer.from(grant.rotatedRefresh, "utf8")), nodePub);
|
||||
if (process.env.MESH_ANTHROPIC_GRANT_OUT) {
|
||||
writeSealedGrant(process.env.MESH_ANTHROPIC_GRANT_OUT, sealed, nodePub);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: the access token in the clear, for the control plane to seal per consumer holder. This is
|
||||
// all it ever receives that is not ciphertext.
|
||||
atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken);
|
||||
|
||||
console.error(
|
||||
`[anthropic-manager] refreshed ${licence}: access token minted` +
|
||||
(grant.rotatedRefresh ? ", refresh token rotated and re-sealed" : ", refresh token unchanged"),
|
||||
);
|
||||
|
||||
// Step 5: licence-grain usage, best-effort — a usage read failing must not fail the refresh.
|
||||
try {
|
||||
const usage = await readUsage(grant.access.accessToken);
|
||||
if (usage) {
|
||||
const reading = flattenUsage(usage);
|
||||
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
|
||||
atomicWrite(
|
||||
process.env.MESH_ANTHROPIC_USAGE_OUT,
|
||||
JSON.stringify({ licence, grain: "licence", ...reading }),
|
||||
);
|
||||
}
|
||||
await emitUsage({ licence, grain: "licence", ...reading });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[anthropic-manager] usage poll for ${licence} failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a usage event best-effort by shelling out to the sibling mesh-tools `emit` primitive, which
|
||||
* is the one path that wires a broker from a run-once/scheduled step (which itself connects none).
|
||||
* A broker hiccup must never fail a refresh that already happened.
|
||||
*/
|
||||
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
||||
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn(process.execPath, [main, "emit", "module.anthropic-manager.usage.read", JSON.stringify(body)], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("exit", () => resolve());
|
||||
child.on("error", (err) => {
|
||||
console.error(`[anthropic-manager] could not emit usage: ${err}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,57 @@
|
||||
// A NaCl `crypto_box_seal`, byte-compatible with Go's `box.SealAnonymous`, over the audited
|
||||
// `tweetnacl-sealedbox-js`.
|
||||
//
|
||||
// **Why this file exists, and why it is exactly this.** novox/hq ADR 0050's refreshable-grant
|
||||
// carve-out delivers the refresh token to the manager module the way the mesh delivers every other
|
||||
// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host
|
||||
// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host
|
||||
// internal/identity/sealing.go), and mesh-control seals with `box.SealAnonymous`
|
||||
// (mesh-control internal/secrets/seal.go). Both are NaCl `crypto_box_seal`:
|
||||
//
|
||||
// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret)
|
||||
// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed )
|
||||
//
|
||||
// When the vendor rotates the refresh token, the manager module must store the new one back the
|
||||
// same way — sealed to the manager node's own sealing key — so mesh-control keeps it without ever
|
||||
// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens
|
||||
// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format
|
||||
// Go's `Open` accepts, or the host would refuse the delivery.
|
||||
//
|
||||
// **The crypto is not ours.** `tweetnacl-sealedbox-js` is `crypto_box_seal` built on the audited
|
||||
// TweetNaCl (`tweetnacl`) and blakejs — the same construction, and the same libraries, the mesh used
|
||||
// to validate this seal during Phase C. It generates the ephemeral X25519 key pair, derives the
|
||||
// nonce as `blake2b(ephemeralPub ‖ recipientPub, 24)`, and produces `ephemeralPub ‖ box`. That is
|
||||
// exactly what Go's `box.OpenAnonymous` opens: the wire format is unchanged from the hand-transcribed
|
||||
// version this replaces — only the implementation is now a maintained, reviewed dependency rather
|
||||
// than a copy of TweetNaCl and blakejs carried inline. The module runtime image bundles it
|
||||
// (package.json dependencies; novox/hq ADR 0052).
|
||||
//
|
||||
// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go
|
||||
// (mesh-control internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this
|
||||
// `seal()`. A drift between this seal and Go's box surfaces there as a seal Go cannot open, not as a
|
||||
// refresh token silently mangled in production.
|
||||
//
|
||||
// This module SEALS only. It never opens — opening is the host's job, with the node private key the
|
||||
// module is deliberately never given.
|
||||
|
||||
// A default import, not `{ seal }`: the library is a CommonJS UMD bundle, and Node's ESM loader
|
||||
// cannot statically see its named exports — only its default, which is the whole module object.
|
||||
import sealedbox from "tweetnacl-sealedbox-js";
|
||||
|
||||
const RAW_KEY_LEN = 32;
|
||||
|
||||
/**
|
||||
* Seal a value to a node's public sealing key, producing what Go's `box.OpenAnonymous` opens.
|
||||
*
|
||||
* @param value the plaintext (e.g. a rotated refresh token)
|
||||
* @param recipientPublicB64 the node's raw 32-byte X25519 public key, standard base64
|
||||
* @returns standard-base64( ephemeralPub ‖ box )
|
||||
*/
|
||||
export function seal(value: Uint8Array, recipientPublicB64: string): string {
|
||||
const recipientPub = Buffer.from(recipientPublicB64, "base64");
|
||||
if (recipientPub.length !== RAW_KEY_LEN) {
|
||||
throw new Error(`a sealing public key is 32 bytes, not ${recipientPub.length}`);
|
||||
}
|
||||
const sealed = sealedbox.seal(value, new Uint8Array(recipientPub));
|
||||
return Buffer.from(sealed).toString("base64");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { grantFromRefresh, flattenUsage } from "../client.ts";
|
||||
|
||||
test("an empty refresh response never clobbers a good grant", () => {
|
||||
assert.equal(grantFromRefresh({}, 1000), null);
|
||||
});
|
||||
|
||||
test("a refresh with an access token yields an access-token-only grant and epoch expiry", () => {
|
||||
const out = grantFromRefresh(
|
||||
{ access_token: "at-new", expires_in: 3600, refresh_token: "rt-rotated", subscription_type: "pro" },
|
||||
1_000_000,
|
||||
);
|
||||
assert.ok(out);
|
||||
assert.equal(out!.access.accessToken, "at-new");
|
||||
assert.equal(out!.access.expiresAt, 1_000_000 + 3600 * 1000);
|
||||
assert.equal(out!.access.subscriptionType, "pro");
|
||||
// The rotated refresh token is reported separately, for the manager to re-seal — never put in the
|
||||
// holder grant.
|
||||
assert.equal(out!.rotatedRefresh, "rt-rotated");
|
||||
assert.ok(!("refreshToken" in (out!.access as object)));
|
||||
});
|
||||
|
||||
test("a refresh that did not rotate the refresh token reports none to re-seal", () => {
|
||||
const out = grantFromRefresh({ access_token: "at-new" }, 0);
|
||||
assert.ok(out);
|
||||
assert.equal(out!.rotatedRefresh, null);
|
||||
});
|
||||
|
||||
test("usage flattens the vendor windows to the ADR 0054 grain", () => {
|
||||
const r = flattenUsage({
|
||||
five_hour: { utilization: 42, resets_at: "2026-01-01T00:00:00Z" },
|
||||
seven_day: { utilization: 10 },
|
||||
seven_day_sonnet: { utilization: 5 },
|
||||
extra_usage: { utilization: 1 },
|
||||
});
|
||||
assert.equal(r.sessionPct, 42);
|
||||
assert.equal(r.sessionResetsAt, "2026-01-01T00:00:00Z");
|
||||
assert.equal(r.weeklyPct, 10);
|
||||
assert.equal(r.sonnetPct, 5);
|
||||
assert.equal(r.extraPct, 1);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
import { seal } from "../sealedbox.ts";
|
||||
|
||||
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal
|
||||
// and mesh-control's secrets.Seal/Open) is a cross-language test in mesh-control
|
||||
// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced.
|
||||
// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is
|
||||
// randomised so a rotation that changed nothing looks nothing like one that changed everything.
|
||||
|
||||
/** A node public key as the mesh records it: raw 32-byte X25519, standard base64. */
|
||||
function aNodePublicKey(): string {
|
||||
const kp = generateKeyPairSync("x25519");
|
||||
const x = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x;
|
||||
return Buffer.from(x, "base64url").toString("base64");
|
||||
}
|
||||
|
||||
test("a seal has the crypto_box_seal shape: ephemeralPub(32) + tag(16) + ciphertext(len)", () => {
|
||||
const pub = aNodePublicKey();
|
||||
const msg = Buffer.from("rt-a-refresh-token", "utf8");
|
||||
const blob = Buffer.from(seal(new Uint8Array(msg), pub), "base64");
|
||||
// 32 (ephemeral public key) + 16 (Poly1305 tag) + message length.
|
||||
assert.equal(blob.length, 32 + 16 + msg.length);
|
||||
});
|
||||
|
||||
test("two seals of the same value differ — a fresh ephemeral key each time", () => {
|
||||
const pub = aNodePublicKey();
|
||||
const msg = new Uint8Array(Buffer.from("rt-a-refresh-token", "utf8"));
|
||||
assert.notEqual(seal(msg, pub), seal(msg, pub));
|
||||
});
|
||||
|
||||
test("a public key that is not 32 bytes is refused before anything is sealed", () => {
|
||||
assert.throws(() => seal(new Uint8Array([1, 2, 3]), Buffer.from("short").toString("base64")));
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"tweetnacl-sealedbox-js.d.ts",
|
||||
"sealedbox.ts",
|
||||
"grantfile.ts",
|
||||
"client.ts",
|
||||
"adopt/index.ts",
|
||||
"refresh/index.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Ambient types for `tweetnacl-sealedbox-js` (crypto_box_seal), which ships without its own.
|
||||
// The library is a small UMD bundle over `tweetnacl` and `blakejs`; only `seal` is used here.
|
||||
declare module "tweetnacl-sealedbox-js" {
|
||||
/** crypto_box_seal: returns ephemeralPub(32) ‖ box, sealed to `recipientPublicKey`. */
|
||||
export function seal(message: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array;
|
||||
/** crypto_box_seal_open: returns the plaintext, or null if it does not open. */
|
||||
export function open(
|
||||
sealed: Uint8Array,
|
||||
recipientPublicKey: Uint8Array,
|
||||
recipientSecretKey: Uint8Array,
|
||||
): Uint8Array | null;
|
||||
export const overheadLength: number;
|
||||
}
|
||||
Reference in New Issue
Block a user