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,39 @@
|
||||
// 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 at rest to that node's own key, and
|
||||
// only the sealed envelope leaves this process (novox/hq ADR 0050, Phase C). The control plane stores
|
||||
// that envelope via `licence set-grant` without ever seeing the refresh token in the clear — the same
|
||||
// bound every refresh keeps. This is the counterpart to `refresh/index.js`: adoption seals the first
|
||||
// envelope, refresh opens and re-seals it.
|
||||
//
|
||||
// MESH_ANTHROPIC_REFRESH_TOKEN_FILE the operator's refresh token, read once and never written out
|
||||
// MESH_NODE_SEALING_PUBLIC_FILE the manager node's public sealing key (base64 raw X25519)
|
||||
// MESH_ANTHROPIC_GRANT_OUT where the sealed envelope is written, for `licence set-grant`
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { sealAtRest } from "../atrest.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_REFRESH_TOKEN_FILE"), "utf8").trim();
|
||||
if (!refreshToken) throw new Error("[anthropic-manager] there is no refresh token to adopt");
|
||||
|
||||
const nodePub = readFileSync(required("MESH_NODE_SEALING_PUBLIC_FILE"), "utf8").trim();
|
||||
const envelope = sealAtRest(refreshToken, nodePub);
|
||||
|
||||
const out = required("MESH_ANTHROPIC_GRANT_OUT");
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
const tmp = `${out}.tmp`;
|
||||
writeFileSync(
|
||||
tmp,
|
||||
JSON.stringify({ token: envelope.token, wrapped_key: envelope.wrappedKey, manager_key: envelope.managerKey }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
renameSync(tmp, out);
|
||||
console.error("[anthropic-manager] sealed the refresh token at rest; only this node's key opens it");
|
||||
@@ -0,0 +1,174 @@
|
||||
// The refresh token, encrypted at rest so ONE node — the manager — can read it back, and nothing
|
||||
// else can: not the control plane, not a copy of its database, not another node.
|
||||
//
|
||||
// **Why this file exists at all.** novox/hq ADR 0050 draws one bounded carve-out in the mesh's "the
|
||||
// control plane cannot read what it stores" guarantee: a refreshable-grant credential (Anthropic's
|
||||
// subscription OAuth) must be rotated centrally, and rotating it means SOME node reads the refresh
|
||||
// token back, every cycle. The ADR names exactly one such node — the *manager* — and this is the
|
||||
// mechanism by which it, and only it, reads that token. mesh-control (the control plane) holds the
|
||||
// output of this as three opaque strings and never runs the open: it has no key that could.
|
||||
//
|
||||
// **The construction (ECIES over the node's own sealing key).** Envelope encryption:
|
||||
// - a fresh random 32-byte data key encrypts the refresh token with AES-256-GCM (`token`);
|
||||
// - that data key is wrapped to the manager node's X25519 sealing key — the same key pair the
|
||||
// host already holds for the node — via an ephemeral-static ECDH → HKDF-SHA256 → AES-256-GCM
|
||||
// (`wrappedKey`, carrying the ephemeral public key in front);
|
||||
// - `managerKey` is the node public key the data key was wrapped to, kept so a node that has since
|
||||
// rotated its key learns it can no longer open this, rather than discovering it as a decrypt
|
||||
// that fails.
|
||||
// Recovering the refresh token needs the node's X25519 *private* half, which never leaves that
|
||||
// machine. A copy of the control plane's database is a directory of ciphertexts and wrapped keys
|
||||
// with nothing to open either.
|
||||
//
|
||||
// **On format.** This is the manager module's own at-rest format, distinct from mesh-control's Go
|
||||
// `secrets.AtRest` (which is NaCl secretbox + sealed box). That is deliberate and safe: on the
|
||||
// Anthropic path the manager module is the ONLY component that seals or opens the envelope — it
|
||||
// seals at adoption, it opens and re-seals every refresh — and mesh-control stores the three parts
|
||||
// as opaque strings it never interprets. The two never have to agree byte-for-byte because the
|
||||
// bytes never cross the language boundary in an opened form. (If an operator-facing adopt path in
|
||||
// mesh-control ever needed to produce the first envelope, the two would have to be unified — a NaCl
|
||||
// port in TS, or a Go manager runtime. Flagged, not silently assumed.)
|
||||
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
diffieHellman,
|
||||
generateKeyPairSync,
|
||||
hkdfSync,
|
||||
randomBytes,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
|
||||
/** The three opaque parts mesh-control stores and forwards, and nothing else. */
|
||||
export interface Envelope {
|
||||
/** base64( iv ‖ tag ‖ AES-256-GCM(dataKey, refreshToken) ). */
|
||||
readonly token: string;
|
||||
/** base64( ephemeralPub(32) ‖ iv ‖ tag ‖ AES-256-GCM(kek, dataKey) ). */
|
||||
readonly wrappedKey: string;
|
||||
/** base64 of the node's raw 32-byte X25519 public key the data key was wrapped to. */
|
||||
readonly managerKey: string;
|
||||
}
|
||||
|
||||
const INFO = Buffer.from("mesh-atrest-v1");
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
const RAW_KEY_LEN = 32;
|
||||
|
||||
/** Import a node's raw 32-byte X25519 public key (standard base64, as the mesh records it). */
|
||||
function importPublic(rawBase64: string): KeyObject {
|
||||
const raw = Buffer.from(rawBase64, "base64");
|
||||
if (raw.length !== RAW_KEY_LEN) {
|
||||
throw new Error(`a sealing public key is 32 bytes, not ${raw.length}`);
|
||||
}
|
||||
return createPublicKey({
|
||||
key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") },
|
||||
format: "jwk",
|
||||
});
|
||||
}
|
||||
|
||||
/** Import a node's raw 32-byte X25519 private key together with its public half. */
|
||||
function importPrivate(rawPrivB64: string, rawPubB64: string): KeyObject {
|
||||
const priv = Buffer.from(rawPrivB64, "base64");
|
||||
const pub = Buffer.from(rawPubB64, "base64");
|
||||
if (priv.length !== RAW_KEY_LEN) {
|
||||
throw new Error(`a sealing private key is 32 bytes, not ${priv.length}`);
|
||||
}
|
||||
return createPrivateKey({
|
||||
key: { kty: "OKP", crv: "X25519", x: pub.toString("base64url"), d: priv.toString("base64url") },
|
||||
format: "jwk",
|
||||
});
|
||||
}
|
||||
|
||||
/** The raw 32-byte public key of an X25519 KeyObject. */
|
||||
function rawPublic(key: KeyObject): Buffer {
|
||||
const jwk = key.export({ format: "jwk" }) as { x?: string };
|
||||
if (!jwk.x) throw new Error("a public key had no point");
|
||||
return Buffer.from(jwk.x, "base64url");
|
||||
}
|
||||
|
||||
/** Bind the wrapping key to both the ephemeral and the recipient public key, as a sealed box does. */
|
||||
function deriveKek(shared: Buffer, ephemeralPub: Buffer, recipientPub: Buffer): Buffer {
|
||||
const salt = Buffer.concat([ephemeralPub, recipientPub]);
|
||||
return Buffer.from(hkdfSync("sha256", shared, salt, INFO, 32));
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal a refresh token so only the holder of managerPublicKey's private half can read it.
|
||||
*
|
||||
* A fresh data key and ephemeral key each time, so two envelopes of the same token look nothing
|
||||
* alike — a rotation that changed nothing is indistinguishable from one that changed everything.
|
||||
*/
|
||||
export function sealAtRest(refreshToken: string, managerPublicKeyB64: string): Envelope {
|
||||
if (!refreshToken) throw new Error("there is nothing to seal");
|
||||
const recipient = importPublic(managerPublicKeyB64);
|
||||
const recipientPub = rawPublic(recipient);
|
||||
|
||||
// Ephemeral-static ECDH: a throwaway key pair whose public half rides in the envelope.
|
||||
const eph = generateKeyPairSync("x25519");
|
||||
const ephemeralPub = rawPublic(eph.publicKey);
|
||||
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
|
||||
const kek = deriveKek(shared, ephemeralPub, recipientPub);
|
||||
|
||||
const dataKey = randomBytes(32);
|
||||
|
||||
const wrappedKey = Buffer.concat([ephemeralPub, aesSeal(kek, dataKey)]);
|
||||
const token = aesSeal(dataKey, Buffer.from(refreshToken, "utf8"));
|
||||
|
||||
return {
|
||||
token: token.toString("base64"),
|
||||
wrappedKey: wrappedKey.toString("base64"),
|
||||
managerKey: managerPublicKeyB64,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the refresh token, given the manager node's own key pair. This is the one place a refresh
|
||||
* token is in the clear, and it runs only on the manager node.
|
||||
*/
|
||||
export function openAtRest(env: Envelope, managerPublicKeyB64: string, managerPrivateKeyB64: string): string {
|
||||
const priv = importPrivate(managerPrivateKeyB64, managerPublicKeyB64);
|
||||
const recipientPub = Buffer.from(managerPublicKeyB64, "base64");
|
||||
|
||||
const wrapped = Buffer.from(env.wrappedKey, "base64");
|
||||
if (wrapped.length < RAW_KEY_LEN + IV_LEN + TAG_LEN) {
|
||||
throw new Error("the wrapped key is too short to hold what it must");
|
||||
}
|
||||
const ephemeralPub = wrapped.subarray(0, RAW_KEY_LEN);
|
||||
const wrappedRest = wrapped.subarray(RAW_KEY_LEN);
|
||||
|
||||
const ephemeralKey = importPublic(ephemeralPub.toString("base64"));
|
||||
const shared = diffieHellman({ privateKey: priv, publicKey: ephemeralKey });
|
||||
const kek = deriveKek(shared, ephemeralPub, recipientPub);
|
||||
|
||||
let dataKey: Buffer;
|
||||
try {
|
||||
dataKey = aesOpen(kek, wrappedRest);
|
||||
} catch {
|
||||
throw new Error("this refresh token was not wrapped to this manager's key");
|
||||
}
|
||||
if (dataKey.length !== 32) throw new Error("the wrapped data key is the wrong length");
|
||||
|
||||
const refresh = aesOpen(dataKey, Buffer.from(env.token, "base64"));
|
||||
return refresh.toString("utf8");
|
||||
}
|
||||
|
||||
// --- AES-256-GCM helpers: output/consume iv ‖ tag ‖ ciphertext ---
|
||||
|
||||
function aesSeal(key: Buffer, plaintext: Buffer): Buffer {
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ct]);
|
||||
}
|
||||
|
||||
function aesOpen(key: Buffer, blob: Buffer): Buffer {
|
||||
const iv = blob.subarray(0, IV_LEN);
|
||||
const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ct = blob.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
}
|
||||
@@ -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,68 @@
|
||||
{
|
||||
"module": "anthropic-manager",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"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": "keys",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/anthropic-manager/keys",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "out",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/anthropic-manager/out",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/anthropic-manager/config.json",
|
||||
"merge": "json",
|
||||
"content": "{}",
|
||||
"mode": "0600"
|
||||
},
|
||||
{
|
||||
"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_ANTHROPIC_GRANT_FILE": "/run/state/grant.json",
|
||||
"MESH_NODE_SEALING_PUBLIC_FILE": "/run/state/keys/sealing.pub",
|
||||
"MESH_NODE_SEALING_PRIVATE_FILE": "/run/state/keys/sealing.priv",
|
||||
"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,14 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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 opaque refresh-token envelope the control plane forwarded (it cannot open it);
|
||||
// 2. open it HERE with the node's own sealing key — the one moment a refresh token is in the clear,
|
||||
// on the one node the ADR permits it;
|
||||
// 3. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
|
||||
// refresh token);
|
||||
// 4. re-seal the rotated refresh token at rest (still openable by this node alone);
|
||||
// 5. hand the control plane back ONLY the access token in the clear + the opaque re-sealed
|
||||
// envelope — never the refresh token — which it seals per holder and stores;
|
||||
// 6. poll usage with the fresh access token and record the licence-grain reading.
|
||||
//
|
||||
// mesh-control receives the products of steps 5–6 through `licence submit-refresh` (access token +
|
||||
// opaque envelope). The refresh token never leaves this process except as ciphertext.
|
||||
//
|
||||
// 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 { openAtRest, sealAtRest, type Envelope } from "../atrest.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 readTrimmed(path: string): string {
|
||||
return readFileSync(path, "utf8").trim();
|
||||
}
|
||||
|
||||
/** Accept an envelope in either the wire (snake_case) or internal (camelCase) shape. */
|
||||
function readEnvelope(path: string): Envelope {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, string>;
|
||||
const token = raw.token ?? "";
|
||||
const wrappedKey = raw.wrappedKey ?? raw.wrapped_key ?? "";
|
||||
const managerKey = raw.managerKey ?? raw.manager_key ?? "";
|
||||
if (!token || !wrappedKey || !managerKey) {
|
||||
throw new Error("the refresh-token envelope is missing one of token/wrapped_key/manager_key");
|
||||
}
|
||||
return { token, wrappedKey, managerKey };
|
||||
}
|
||||
|
||||
/** Write the envelope in the wire (snake_case) shape mesh-control's `submit-refresh` reads. */
|
||||
function writeEnvelope(path: string, env: Envelope): void {
|
||||
atomicWrite(
|
||||
path,
|
||||
JSON.stringify({ token: env.token, wrapped_key: env.wrappedKey, manager_key: env.managerKey }),
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
const envelope = readEnvelope(required("MESH_ANTHROPIC_GRANT_FILE"));
|
||||
const nodePub = readTrimmed(required("MESH_NODE_SEALING_PUBLIC_FILE"));
|
||||
const nodePriv = readTrimmed(required("MESH_NODE_SEALING_PRIVATE_FILE"));
|
||||
|
||||
// Step 2: the one open, on the manager node.
|
||||
const refreshToken = openAtRest(envelope, nodePub, nodePriv);
|
||||
|
||||
// Step 3: 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 4: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
|
||||
if (grant.rotatedRefresh) {
|
||||
const rotated = sealAtRest(grant.rotatedRefresh, nodePub);
|
||||
if (process.env.MESH_ANTHROPIC_GRANT_OUT) {
|
||||
writeEnvelope(process.env.MESH_ANTHROPIC_GRANT_OUT, rotated);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: the access token in the clear, for the control plane to seal per 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 6: 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,47 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
import { sealAtRest, openAtRest, type Envelope } from "../atrest.ts";
|
||||
|
||||
/** A node key pair as the mesh records it: raw 32-byte X25519 keys, standard base64. */
|
||||
function nodeKeys(): { pub: string; priv: string } {
|
||||
const kp = generateKeyPairSync("x25519");
|
||||
const pub = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x;
|
||||
const priv = (kp.privateKey.export({ format: "jwk" }) as { d: string }).d;
|
||||
// JWK is base64url; the mesh records standard base64 of the same 32 bytes.
|
||||
const std = (b64url: string) => Buffer.from(b64url, "base64url").toString("base64");
|
||||
return { pub: std(pub), priv: std(priv) };
|
||||
}
|
||||
|
||||
test("the manager seals a refresh token and reads it back with its own key", () => {
|
||||
const { pub, priv } = nodeKeys();
|
||||
const env = sealAtRest("rt-the-refresh-token", pub);
|
||||
assert.equal(env.managerKey, pub);
|
||||
// Nothing in the envelope is the refresh token in the clear.
|
||||
assert.doesNotMatch(env.token, /rt-the-refresh-token/);
|
||||
assert.doesNotMatch(env.wrappedKey, /rt-the-refresh-token/);
|
||||
assert.equal(openAtRest(env, pub, priv), "rt-the-refresh-token");
|
||||
});
|
||||
|
||||
test("a node that is not the manager cannot open the envelope", () => {
|
||||
const manager = nodeKeys();
|
||||
const other = nodeKeys();
|
||||
const env = sealAtRest("rt-secret", manager.pub);
|
||||
assert.throws(() => openAtRest(env, other.pub, other.priv));
|
||||
});
|
||||
|
||||
test("two seals of the same token look nothing alike", () => {
|
||||
const { pub } = nodeKeys();
|
||||
const a = sealAtRest("rt-secret", pub);
|
||||
const b = sealAtRest("rt-secret", pub);
|
||||
assert.notEqual(a.token, b.token);
|
||||
assert.notEqual(a.wrappedKey, b.wrappedKey);
|
||||
});
|
||||
|
||||
test("a tampered envelope is refused, not silently mis-opened", () => {
|
||||
const { pub, priv } = nodeKeys();
|
||||
const env = sealAtRest("rt-secret", pub);
|
||||
const flipped: Envelope = { ...env, token: Buffer.from(env.token, "base64").reverse().toString("base64") };
|
||||
assert.throws(() => openAtRest(flipped, pub, priv));
|
||||
});
|
||||
@@ -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,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"atrest.ts",
|
||||
"client.ts",
|
||||
"adopt/index.ts",
|
||||
"refresh/index.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user