From 6be507256574855e58ec49821083fe071fe66b16 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 01:00:37 +0200 Subject: [PATCH 1/3] anthropic-bed: prove model-access refreshes on the manager node A lab bed for Phase C of model-access (ADR 0050), OAuth endpoint stubbed. It drives the real runtime images through the whole flow: the manager seals a refresh token at rest and opens it on the manager node alone, mesh-control is handed only the access token and an opaque re-sealed envelope via licence submit-refresh, and the consumer writes an access-token-only credential. Asserts the refresh token -- original and rotated -- is nowhere on the consuming node and only ciphertext in the control plane's database. build-module-runtime.sh also compiles adopt/refresh/apply/usage entrypoints. Stubbed and flagged: the vendor endpoint, the manager node's private key (mounted; a host capability to deliver it does not exist today), and the submit transport (the test invokes the CLI on the manager's output). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- scenarios/anthropic-bed.yml | 45 +++ scripts/build-module-runtime.sh | 12 +- test/integration/anthropic-bed.test.ts | 391 +++++++++++++++++++++++++ 3 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 scenarios/anthropic-bed.yml create mode 100644 test/integration/anthropic-bed.test.ts diff --git a/scenarios/anthropic-bed.yml b/scenarios/anthropic-bed.yml new file mode 100644 index 0000000..d525f21 --- /dev/null +++ b/scenarios/anthropic-bed.yml @@ -0,0 +1,45 @@ +# One machine that becomes a mesh, then plays out the whole model-access refreshable-grant flow for +# Anthropic (novox/hq ADR 0050, Phase C) with the vendor's OAuth endpoint STUBBED — no real Anthropic +# is reached. The bed proves the one property the carve-out rests on: the refresh token is opened only +# on the manager node, the control plane seals and delivers only the ACCESS token, and a consuming +# node writes an access-token-only credential and is never given a refresh token. +# +# The flow the test drives (OAuth stubbed, so it is the FLOW that is proven, not the vendor): +# manager opens the at-rest envelope on the manager node -> calls the stub token endpoint -> +# submits back only { access token, re-sealed refresh envelope } -> mesh-control seals the access +# token per holder -> the consumer runtime writes ~/.claude/.credentials.json, access-token-only. +# +# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock +# Build BOTH runtime images into the local daemon first (the scenario stocks and serves them by +# digest, which is where the host pulls them from): +# scripts/build-module-runtime.sh anthropic-manager /tmp/anthropic-manager.tar +# scripts/build-module-runtime.sh anthropic-consumer /tmp/anthropic-consumer.tar +# (the tar output is incidental — the build also tags the image into the local docker daemon, which +# is what raise() stocks.) The stub OAuth endpoint is a tiny node server the test runs from the +# manager runtime image itself, so no extra image is needed. +scenario: anthropic-bed + +segments: + hosting: + kind: public + cidr: [192.0.2.0/24] + +machines: + anchor: + at: { segment: hosting, address: [192.0.2.10] } + inbound: allow + memory: 3GiB + cpus: 2 + +images: + # The first-node substrate: store, broker, control. + - postgres:17-alpine + - cloudamqp/lavinmq:latest + - mesh-control:development + # The two model-access runtimes, built by scripts/build-module-runtime.sh into the local daemon and + # stocked into the scenario's own registry, which is where the host pulls them from. + - mesh-runtime-anthropic-manager:development + - mesh-runtime-anthropic-consumer:development + +place: + all: [host, runtime] diff --git a/scripts/build-module-runtime.sh b/scripts/build-module-runtime.sh index ecc87d0..33cfdf9 100755 --- a/scripts/build-module-runtime.sh +++ b/scripts/build-module-runtime.sh @@ -20,8 +20,16 @@ BASE="${RUNTIME_BASE:-node:22-bookworm-slim}" ( cd "$MESH_SDK" && npm run build >/dev/null ) ( cd "$MESH_TOOLS" && npm run build >/dev/null ) -# Compile whichever of the module's entrypoints exist. -SRCS=(); for f in client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts; do [ -f "$MOD/$f" ] && SRCS+=("$f"); done +# Compile whichever of the module's entrypoints exist. Besides the serve-time entrypoints (tools, +# events, provisioner) and the run-once bootstrap, a module may carry scheduled/one-shot entrypoints +# it names in a `schedule`/`run-once` container's args (novox/hq ADR 0052/0053) — refresh/apply/usage +# for the anthropic model-access modules. tsc pulls in their imports, so leaf files they use are +# compiled with them. +SRCS=(); for f in \ + client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \ + adopt/index.ts refresh/index.ts apply/index.ts usage/index.ts; do + [ -f "$MOD/$f" ] && SRCS+=("$f") +done TSC="$MESH_SDK/node_modules/.bin/tsc"; ( cd "$MOD" && "$TSC" "${SRCS[@]}" --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist >/dev/null ) STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT diff --git a/test/integration/anthropic-bed.test.ts b/test/integration/anthropic-bed.test.ts new file mode 100644 index 0000000..26c31fd --- /dev/null +++ b/test/integration/anthropic-bed.test.ts @@ -0,0 +1,391 @@ +/** + * The mesh plays out the model-access refreshable-grant flow for Anthropic end to end, with the + * vendor's OAuth endpoint STUBBED (novox/hq ADR 0050, Phase C). It proves the one property the + * carve-out rests on, and the reviewer will scrutinise it: the refresh token is opened ONLY on the + * manager node, the control plane is handed only the ACCESS token in the clear (plus an opaque + * re-sealed refresh envelope), and a consuming node writes an access-token-only credential and is + * never delivered a refresh token — nowhere on the machine, nowhere in the control plane's database. + * + * The flow, driven deterministically (the runtime images are the real ones the host pulled; the + * OAuth endpoint is a tiny node stub the test runs from the same image, so no vendor is reached): + * 1. adopt: the manager runtime seals a refresh token at rest to a node key pair (the seal runs on + * the manager node; the plaintext never leaves it). The sealed envelope is stored via + * `licence set-grant` — the control plane stores ciphertext it cannot open. + * 2. refresh: the manager runtime OPENS the envelope with the node's own key, calls the stub token + * endpoint, and writes out ONLY { access token, re-sealed refresh envelope }. + * 3. submit: `licence submit-refresh` hands the control plane those two things — never the refresh + * token in the clear — and it seals the access token to the consumer holder. + * 4. deliver: a push delivers the sealed access token to the consumer; the consumer runtime writes + * ~/.claude/.credentials.json, access-token-only. + * + * STUBBED, and flagged in the report: (a) the vendor OAuth endpoint (a node stub); (b) the manager + * node's private sealing key, mounted as a test key pair — production needs a host capability to + * place the node private key where a manager module reads it, which mesh-host does not have today; + * (c) the submit transport (the test invokes `mesh-control licence submit-refresh` on the manager's + * output, standing in for the authenticated cross-node call a manager node would make). + * + * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock + * Build both runtime images into the local daemon first: + * scripts/build-module-runtime.sh anthropic-manager /tmp/anthropic-manager.tar + * scripts/build-module-runtime.sh anthropic-consumer /tmp/anthropic-consumer.tar + */ + +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { generateKeyPairSync } from "node:crypto"; +import { loadScenario } from "../../src/declaration/parse.ts"; +import { raise } from "../../src/lifecycle/raise.ts"; +import { destroy, exec } from "../../src/lifecycle/operate.ts"; +import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts"; +import { labIsUsable, destroyAll } from "./harness.ts"; + +const capability = await labIsUsable(); +const binary = hostBinaryPath(); +const bundle = process.env["MESH_LAB_BUNDLE"] ?? ""; + +const skip = !capability.usable + ? `lab not usable: ${capability.why}` + : !binary || !existsSync(binary) + ? "MESH_LAB_HOST_BINARY is not set to a built mesh-host" + : !bundle || !existsSync(bundle) + ? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)" + : false; + +const SCENARIO = "anthropic-bed"; +const MACHINE = "anchor"; + +// The fake tokens the flow moves. The whole test is: the second reaches the consumer, the first never +// does. +const REFRESH_TOKEN = "rt-lab-refresh-must-never-be-delivered"; +const ACCESS_TOKEN = "at-lab-access-token-minted-by-the-stub"; +const ROTATED_REFRESH = "rt-lab-rotated-still-must-never-be-delivered"; + +let instanceId = ""; +let stocked: string[] = []; + +function quote(s: string): string { + return `'${s.replaceAll("'", `'\\''`)}'`; +} + +async function on(command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> { + const { stdout } = await exec(instanceId, MACHINE, [ + "sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`, + ], timeoutMs); + const marker = stdout.lastIndexOf("__exit="); + if (marker < 0) return { out: stdout, ok: false }; + return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" }; +} + +async function must(command: string, timeoutMs?: number): Promise { + const { out, ok } = await on(command, timeoutMs); + if (!ok) throw new Error(`${MACHINE}: ${command}\n${out}`); + return out; +} + +async function mesh(command: string, timeoutMs?: number): Promise { + return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); +} + +async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { + return on(`docker exec mesh-control /mesh-control ${command}`); +} + +function pinned(repository: string): string { + const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository); + assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`); + return found; +} + +function bundleFor(images: string[]): string { + let text = readFileSync(bundle, "utf8"); + for (const ref of images) { + const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@")); + const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\."); + text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref); + } + return text; +} + +function tokenFrom(said: string): string { + const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" ")); + assert.ok(found, `no token in:\n${said}`); + return found; +} + +/** A node key pair as the mesh records it: raw 32-byte X25519 keys, standard base64. */ +function nodeKeyPair(): { pub: string; priv: string } { + const kp = generateKeyPairSync("x25519"); + const std = (b64url: string) => Buffer.from(b64url, "base64url").toString("base64"); + return { + pub: std((kp.publicKey.export({ format: "jwk" }) as { x: string }).x), + priv: std((kp.privateKey.export({ format: "jwk" }) as { d: string }).d), + }; +} + +/** The local image id the host pulled for a runtime, so the test can drive it deterministically. */ +async function imageId(substr: string): Promise { + const out = (await must(`docker images --no-trunc --format '{{.ID}} {{.Repository}}' | grep ${quote(substr)} | head -1`)).trim(); + const id = out.split(/\s+/)[0] ?? ""; + assert.ok(id.startsWith("sha256:") || id.length > 0, `no local image matched ${substr}:\n${out}`); + return id; +} + +async function settled(withinMs = 600_000): Promise { + const until = Date.now() + withinMs; + let last = ""; + while (Date.now() < until) { + const asked = await meshTry(`status --json`); + if (asked.ok) { + try { + const state = JSON.parse(asked.out) as { + wrong: { node: string; outcome: string }[]; + waiting: { node: string }[]; + reported: { node: string; outcome: string; current: boolean }[]; + }; + const bad = state.wrong.find((w) => w.node === MACHINE); + if (bad) throw new Error(`${MACHINE} did not apply what it was sent: ${bad.outcome}\n${asked.out}`); + const word = state.reported.find((r) => r.node === MACHINE); + if (!state.waiting.some((w) => w.node === MACHINE) && word?.outcome === "applied" && word.current) return; + last = asked.out; + } catch (err) { + if (err instanceof Error && err.message.includes("did not apply")) throw err; + last = asked.out; + } + } + await new Promise((r) => setTimeout(r, 5000)); + } + throw new Error(`${MACHINE} never caught up within ${Math.round(withinMs / 1000)}s. Last:\n${last}`); +} + +before(async () => { + if (skip) return; + + const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), { + onProgress: (m) => console.log(`raise: ${m}`), + }); + instanceId = raised.instanceId; + stocked = raised.images; + + await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000); + const up = await must(`docker ps --format '{{.Names}}'`); + for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) { + assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`); + } + + await mesh(`node add ${MACHINE}`); + const token = tokenFrom(await mesh(`token issue --node ${MACHINE}`)); + await must(`${HOST_PATH} enrol --token ${quote(token)}`); + await must(`nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`); +}, { timeout: 1_800_000 }); + +after(async () => { + if (instanceId) await destroy(instanceId); + await destroyAll(`${SCENARIO}-`); +}, { timeout: 600_000 }); + +test("model access refreshes on the manager node and delivers only the access token, never the refresh token", { + skip, timeout: 1_500_000, +}, async () => { + const managerImage = pinned("mesh-runtime-anthropic-manager"); + const consumerImage = pinned("mesh-runtime-anthropic-consumer"); + + // --- the licence, its manager, and the consumer holder ------------------------------------------ + await mesh(`licence add anthropic personal --serves '{"model":"a-model"}'`); + await mesh(`licence manager personal ${MACHINE}`); + await mesh(`licence use personal ${MACHINE} anthropic-consumer`); + + // --- both runtimes are placed so the host pulls their images (which the test then drives) -------- + // Inline manifests, env pointed at the stub, mirroring the committed module.json. The scheduled + // containers install as present state (ADR 0053); the test drives the entrypoints directly for a + // deterministic flow rather than waiting on cron. + const managerManifest = JSON.stringify({ + module: "anthropic-manager", + version: "1", + "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: "refresh", type: "container", name: "mesh-anthropic-manager-refresh", + image: managerImage, network: "host", schedule: "*/9 * * * *", + args: ["run", "/app/modules/anthropic-manager/dist/refresh/index.js"], + volumes: ["/var/lib/mesh/anthropic-manager:/run/state"], + env: { + MESH_ANTHROPIC_LICENCE: "personal", + MESH_ANTHROPIC_TOKEN_ENDPOINT: "http://127.0.0.1:9099/token", + MESH_ANTHROPIC_USAGE_ENDPOINT: "http://127.0.0.1:9099/usage", + 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", + }, + }, + ], + }); + const consumerManifest = JSON.stringify({ + module: "anthropic-consumer", + version: "1", + 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: "apply", type: "container", name: "mesh-anthropic-consumer-apply", + image: consumerImage, network: "host", schedule: "*/9 * * * *", + 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", + }, + }, + ], + }); + + for (const [name, body] of [["anthropic-manager", managerManifest], ["anthropic-consumer", consumerManifest]] as const) { + await must(`printf %s ${quote(body)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); + await mesh(`module add /${name}.json`); + await mesh(`module issue ${name} --node ${MACHINE}`); + await mesh(`assign ${MACHINE} ${name}`); + } + await mesh(`push ${MACHINE}`); + await settled(); + + // The images the host pulled to run the scheduled containers are now local; drive them directly. + const managerId = await imageId("anthropic-manager"); + const consumerId = await imageId("anthropic-consumer"); + + // --- the stubbed node private key, mounted (FLAGGED) -------------------------------------------- + // Production needs a host capability to place the node private key where the manager reads it; + // mesh-host has none today. Here the test mounts a generated key pair as that key. + const keys = nodeKeyPair(); + await must(`printf %s ${quote(keys.pub)} > /var/lib/mesh/anthropic-manager/keys/sealing.pub`); + await must(`printf %s ${quote(keys.priv)} > /var/lib/mesh/anthropic-manager/keys/sealing.priv`); + + // --- the OAuth stub: a tiny node server run from the manager image itself ----------------------- + const stub = [ + "const http=require('http');", + "http.createServer((req,res)=>{let b='';req.on('data',c=>b+=c);req.on('end',()=>{", + " if(req.url.startsWith('/token')){res.setHeader('content-type','application/json');", + ` res.end(JSON.stringify({access_token:${JSON.stringify(ACCESS_TOKEN)},refresh_token:${JSON.stringify(ROTATED_REFRESH)},expires_in:3600,refresh_token_expires_in:2592000,subscription_type:'pro'}));return;}`, + " if(req.url.startsWith('/usage')){res.setHeader('content-type','application/json');", + " res.end(JSON.stringify({five_hour:{utilization:12,resets_at:'2026-01-01T00:00:00Z'},seven_day:{utilization:3}}));return;}", + " res.statusCode=404;res.end('no');});}).listen(9099,'127.0.0.1',()=>console.log('stub up'));", + ].join("\n"); + await must(`printf %s ${quote(stub)} > /var/lib/mesh/anthropic-manager/stub.js`); + await must(`docker rm -f oauth-stub 2>/dev/null; docker run -d --name oauth-stub --network host --entrypoint node -v /var/lib/mesh/anthropic-manager/stub.js:/run/stub.js:ro ${managerId} /run/stub.js`); + // Give it a moment to bind. + await must(`for i in $(seq 1 20); do curl -s -X POST http://127.0.0.1:9099/token >/dev/null && break; sleep 1; done`); + + // --- 1. adopt: seal the refresh token at rest, on the manager node ------------------------------ + await must(`printf %s ${quote(REFRESH_TOKEN)} > /var/lib/mesh/anthropic-manager/refresh-token`); + await must( + `docker run --rm --network host -v /var/lib/mesh/anthropic-manager:/run/state ` + + `-e MESH_ANTHROPIC_REFRESH_TOKEN_FILE=/run/state/refresh-token ` + + `-e MESH_NODE_SEALING_PUBLIC_FILE=/run/state/keys/sealing.pub ` + + `-e MESH_ANTHROPIC_GRANT_OUT=/run/state/grant.json ` + + `${managerId} run /app/modules/anthropic-manager/dist/adopt/index.js`, + ); + const envelope = await must(`cat /var/lib/mesh/anthropic-manager/grant.json`); + assert.doesNotMatch(envelope, new RegExp(REFRESH_TOKEN), + `the adopted envelope holds the refresh token in the clear:\n${envelope}`); + + // Store the sealed envelope in the control plane — which never sees the refresh token. + await must(`docker cp /var/lib/mesh/anthropic-manager/grant.json mesh-control:/grant.json`); + await mesh(`licence set-grant personal --file /grant.json`); + + // --- 2. refresh: open on the manager node, call the stub, write access token + re-sealed refresh - + await must( + `docker run --rm --network host -v /var/lib/mesh/anthropic-manager:/run/state ` + + `-e MESH_ANTHROPIC_LICENCE=personal ` + + `-e MESH_ANTHROPIC_TOKEN_ENDPOINT=http://127.0.0.1:9099/token ` + + `-e MESH_ANTHROPIC_USAGE_ENDPOINT=http://127.0.0.1:9099/usage ` + + `-e MESH_ANTHROPIC_GRANT_FILE=/run/state/grant.json ` + + `-e MESH_NODE_SEALING_PUBLIC_FILE=/run/state/keys/sealing.pub ` + + `-e MESH_NODE_SEALING_PRIVATE_FILE=/run/state/keys/sealing.priv ` + + `-e MESH_ANTHROPIC_ACCESS_OUT=/run/state/out/access-token ` + + `-e MESH_ANTHROPIC_GRANT_OUT=/run/state/out/grant.json ` + + `-e MESH_ANTHROPIC_USAGE_OUT=/run/state/out/usage.json ` + + `${managerId} run /app/modules/anthropic-manager/dist/refresh/index.js`, + ); + const producedAccess = (await must(`cat /var/lib/mesh/anthropic-manager/out/access-token`)).trim(); + assert.equal(producedAccess, ACCESS_TOKEN, "the manager did not mint the stub's access token"); + const newEnvelope = await must(`cat /var/lib/mesh/anthropic-manager/out/grant.json`); + assert.doesNotMatch(newEnvelope, new RegExp(ROTATED_REFRESH), + `the re-sealed envelope holds the rotated refresh token in the clear:\n${newEnvelope}`); + // Licence-grain usage was read and recorded. + const usage = await must(`cat /var/lib/mesh/anthropic-manager/out/usage.json`); + assert.match(usage, /"sessionPct":12/, `the licence-grain usage reading is wrong:\n${usage}`); + + // --- 3. submit: the control plane is handed only the access token + opaque envelope ------------- + await must(`docker cp /var/lib/mesh/anthropic-manager/out/access-token mesh-control:/access-token`); + await must(`docker cp /var/lib/mesh/anthropic-manager/out/grant.json mesh-control:/new-grant.json`); + const submitted = await mesh(`licence submit-refresh personal --access-file /access-token --grant-file /new-grant.json`); + assert.match(submitted, /sealed to 1 holder/, submitted); + + // --- 4. deliver: the consumer gets the sealed access token and writes the credential ------------- + await mesh(`push ${MACHINE}`); + await settled(); + + // Drive the consumer's apply once the token has been delivered to its secret path. + let delivered = false; + for (let i = 0; i < 20 && !delivered; i++) { + delivered = (await on(`test -s /var/lib/anthropic-consumer/access-token`)).ok; + if (!delivered) await new Promise((r) => setTimeout(r, 3000)); + } + assert.ok(delivered, "the sealed access token was never delivered to the consumer's secret path"); + await must( + `docker run --rm --network host -v /var/lib/anthropic-consumer:/run/state ` + + `-e MESH_MODEL_ACCESS_SECRET_FILE=/run/state/access-token ` + + `-e MESH_MODEL_ACCESS_BIND_FILE=/run/state/model.json ` + + `-e MESH_CLAUDE_CREDENTIALS_FILE=/run/state/claude/.credentials.json ` + + `-e MESH_CLAUDE_IDENTITY_FILE=/run/state/claude/.claude.json ` + + `${consumerId} run /app/modules/anthropic-consumer/dist/apply/index.js`, + ); + + // --- the invariant, asserted from every angle --------------------------------------------------- + const creds = await must(`cat /var/lib/anthropic-consumer/claude/.credentials.json`); + assert.match(creds, new RegExp(ACCESS_TOKEN), `the access token did not reach the credential file:\n${creds}`); + const parsed = JSON.parse(creds) as { claudeAiOauth?: { accessToken?: string; refreshToken?: string } }; + assert.equal(parsed.claudeAiOauth?.accessToken, ACCESS_TOKEN); + assert.ok(!parsed.claudeAiOauth?.refreshToken, "the consumer was given a refresh token"); + + // The refresh token — original or rotated — is nowhere on the consuming node. + for (const secret of [REFRESH_TOKEN, ROTATED_REFRESH]) { + const found = await on(`grep -rq ${quote(secret)} /var/lib/anthropic-consumer`); + assert.ok(!found.ok, `a refresh token is on the consuming node under /var/lib/anthropic-consumer`); + } + + // The control plane's own database holds the refresh token only as ciphertext. + const grantRow = await must( + `docker exec mesh-store psql -U postgres -d licences -qAt -c "select token, wrapped_key from refresh_grant where licence='personal'"`, + ); + assert.ok(grantRow.trim().length > 0, "no refresh grant was stored"); + for (const secret of [REFRESH_TOKEN, ROTATED_REFRESH]) { + assert.doesNotMatch(grantRow, new RegExp(secret), + `the refresh token is in the control plane's database in the clear:\n${grantRow}`); + } + // And the holder's sealed column carries the access token's seal, never a refresh token. + const holder = await must( + `docker exec mesh-store psql -U postgres -d licences -qAt -c "select coalesce(sealed,'') from licence_holder where licence='personal'"`, + ); + assert.ok(holder.trim().length > 0, "nothing was sealed to the holder"); + for (const secret of [REFRESH_TOKEN, ROTATED_REFRESH]) { + assert.doesNotMatch(holder, new RegExp(secret), "a refresh token is in the holder row"); + } + + await must(`docker rm -f oauth-stub 2>/dev/null || true`); +}); From 71bea08f3b13ec8650fbac132d0c4bf0b10132f3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 01:55:28 +0200 Subject: [PATCH 2/3] anthropic-bed: prove the host unseals the refresh token, no node-key stub The bed follows the reworked flow: the manager module seals the refresh token to the node's PUBLIC key, the HOST unseals it and mounts the cleartext at the manager's bound path, and the refresh reads that cleartext -- no fake node key pair is mounted any more, the host uses its own real sealing key. - the manager is a model-access holder deployed first, so its bound facts (carrying the node public key) are delivered; the consumer is added only once an access token exists to seal. - adopt reads the node public key from the bound facts; the test asserts the host mounts the cleartext refresh token for the manager, and that it reaches nowhere on the consuming node. - the refresh_grant assertion reads { sealed, manager_key }. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- scenarios/anthropic-bed.yml | 16 +- test/integration/anthropic-bed.test.ts | 283 +++++++++++++------------ 2 files changed, 160 insertions(+), 139 deletions(-) diff --git a/scenarios/anthropic-bed.yml b/scenarios/anthropic-bed.yml index d525f21..2ba5c94 100644 --- a/scenarios/anthropic-bed.yml +++ b/scenarios/anthropic-bed.yml @@ -1,13 +1,15 @@ # One machine that becomes a mesh, then plays out the whole model-access refreshable-grant flow for -# Anthropic (novox/hq ADR 0050, Phase C) with the vendor's OAuth endpoint STUBBED — no real Anthropic -# is reached. The bed proves the one property the carve-out rests on: the refresh token is opened only -# on the manager node, the control plane seals and delivers only the ACCESS token, and a consuming -# node writes an access-token-only credential and is never given a refresh token. +# Anthropic (novox/hq ADR 0050) with the vendor's OAuth endpoint STUBBED — no real Anthropic is +# reached. The bed proves the one property the carve-out rests on: the refresh token is delivered ONLY +# to the manager node — as an ordinary sealed credential the HOST unseals — the control plane seals and +# delivers only the ACCESS token, and a consuming node writes an access-token-only credential and is +# never given a refresh token. # # The flow the test drives (OAuth stubbed, so it is the FLOW that is proven, not the vendor): -# manager opens the at-rest envelope on the manager node -> calls the stub token endpoint -> -# submits back only { access token, re-sealed refresh envelope } -> mesh-control seals the access -# token per holder -> the consumer runtime writes ~/.claude/.credentials.json, access-token-only. +# the manager module seals the refresh token to the node's PUBLIC key -> the host unseals it and +# mounts the cleartext at the manager's bound path -> the manager calls the stub token endpoint -> +# submits back only { access token, re-sealed box } -> mesh-control seals the access token per +# consumer holder -> the consumer runtime writes ~/.claude/.credentials.json, access-token-only. # # MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock # Build BOTH runtime images into the local daemon first (the scenario stocks and serves them by diff --git a/test/integration/anthropic-bed.test.ts b/test/integration/anthropic-bed.test.ts index 26c31fd..18dbaa7 100644 --- a/test/integration/anthropic-bed.test.ts +++ b/test/integration/anthropic-bed.test.ts @@ -1,28 +1,40 @@ /** * The mesh plays out the model-access refreshable-grant flow for Anthropic end to end, with the - * vendor's OAuth endpoint STUBBED (novox/hq ADR 0050, Phase C). It proves the one property the - * carve-out rests on, and the reviewer will scrutinise it: the refresh token is opened ONLY on the - * manager node, the control plane is handed only the ACCESS token in the clear (plus an opaque - * re-sealed refresh envelope), and a consuming node writes an access-token-only credential and is - * never delivered a refresh token — nowhere on the machine, nowhere in the control plane's database. + * vendor's OAuth endpoint STUBBED (novox/hq ADR 0050). It proves the one property the carve-out rests + * on, and the reviewer will scrutinise it: the refresh token is delivered ONLY to the manager node — + * as an ordinary sealed credential the HOST unseals — the control plane is handed only the ACCESS + * token in the clear (plus an opaque re-sealed refresh box), and a consuming node writes an + * access-token-only credential and is never delivered a refresh token — nowhere on the machine, + * nowhere in the control plane's database. * - * The flow, driven deterministically (the runtime images are the real ones the host pulled; the - * OAuth endpoint is a tiny node stub the test runs from the same image, so no vendor is reached): - * 1. adopt: the manager runtime seals a refresh token at rest to a node key pair (the seal runs on - * the manager node; the plaintext never leaves it). The sealed envelope is stored via - * `licence set-grant` — the control plane stores ciphertext it cannot open. - * 2. refresh: the manager runtime OPENS the envelope with the node's own key, calls the stub token - * endpoint, and writes out ONLY { access token, re-sealed refresh envelope }. - * 3. submit: `licence submit-refresh` hands the control plane those two things — never the refresh + * **What changed from the earlier cut, and why this is simpler.** The refresh token no longer rides a + * bespoke at-rest envelope the module opens with a node private key the mesh must somehow place — a + * module is never given a node's private key, so that path could not exist. It rides the ORDINARY + * sealed-delivery path instead: mesh-control (via the manager module at adoption) seals it to the + * manager node's PUBLIC key, and the HOST unseals it with that node's real private key and mounts the + * cleartext at the manager module's bound secret path — exactly as a consumer's db password arrives. + * So there is no fake node key pair mounted here any more; the host's own real sealing key does the + * unsealing, and the manager module does no crypto beyond re-sealing a rotated token to the same + * public key. + * + * The flow, driven deterministically (the runtime images are the real ones the host pulled; the OAuth + * endpoint is a tiny node stub run from the same image, so no vendor is reached): + * 1. deploy the manager and adopt: the manager holder is delivered its bound facts (carrying the + * node's PUBLIC sealing key). The manager runtime seals the operator's refresh token to that key + * and hands the box to `licence set-grant` — the control plane stores ciphertext it cannot open. + * 2. the host unseals: a push delivers the sealed refresh token to the manager, and the host mounts + * the cleartext at the manager module's secret path — the one place a refresh token is readable. + * 3. refresh: the manager runtime reads that cleartext, calls the stub token endpoint, re-seals a + * rotated refresh token to the node's public key, and writes out ONLY { access token, sealed box }. + * 4. submit: `licence submit-refresh` hands the control plane those two things — never the refresh * token in the clear — and it seals the access token to the consumer holder. - * 4. deliver: a push delivers the sealed access token to the consumer; the consumer runtime writes + * 5. deliver: a push delivers the sealed access token to the consumer; the consumer runtime writes * ~/.claude/.credentials.json, access-token-only. * - * STUBBED, and flagged in the report: (a) the vendor OAuth endpoint (a node stub); (b) the manager - * node's private sealing key, mounted as a test key pair — production needs a host capability to - * place the node private key where a manager module reads it, which mesh-host does not have today; - * (c) the submit transport (the test invokes `mesh-control licence submit-refresh` on the manager's - * output, standing in for the authenticated cross-node call a manager node would make). + * STUBBED, and flagged in the report: (a) the vendor OAuth endpoint (a node stub); (b) the submit + * transport (the test invokes `mesh-control licence submit-refresh` on the manager's output, standing + * in for the authenticated cross-node call a manager node would make). The node-private-key stub of + * the earlier cut is GONE — the host uses its own real key. * * MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock * Build both runtime images into the local daemon first: @@ -33,7 +45,6 @@ import { test, before, after } from "node:test"; import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; -import { generateKeyPairSync } from "node:crypto"; import { loadScenario } from "../../src/declaration/parse.ts"; import { raise } from "../../src/lifecycle/raise.ts"; import { destroy, exec } from "../../src/lifecycle/operate.ts"; @@ -56,10 +67,10 @@ const SCENARIO = "anthropic-bed"; const MACHINE = "anchor"; // The fake tokens the flow moves. The whole test is: the second reaches the consumer, the first never -// does. -const REFRESH_TOKEN = "rt-lab-refresh-must-never-be-delivered"; +// does — except on the manager node, which is allowed to read it. +const REFRESH_TOKEN = "rt-lab-refresh-only-the-manager-may-read"; const ACCESS_TOKEN = "at-lab-access-token-minted-by-the-stub"; -const ROTATED_REFRESH = "rt-lab-rotated-still-must-never-be-delivered"; +const ROTATED_REFRESH = "rt-lab-rotated-still-only-the-manager"; let instanceId = ""; let stocked: string[] = []; @@ -113,16 +124,6 @@ function tokenFrom(said: string): string { return found; } -/** A node key pair as the mesh records it: raw 32-byte X25519 keys, standard base64. */ -function nodeKeyPair(): { pub: string; priv: string } { - const kp = generateKeyPairSync("x25519"); - const std = (b64url: string) => Buffer.from(b64url, "base64url").toString("base64"); - return { - pub: std((kp.publicKey.export({ format: "jwk" }) as { x: string }).x), - priv: std((kp.privateKey.export({ format: "jwk" }) as { d: string }).d), - }; -} - /** The local image id the host pulled for a runtime, so the test can drive it deterministically. */ async function imageId(substr: string): Promise { const out = (await must(`docker images --no-trunc --format '{{.ID}} {{.Repository}}' | grep ${quote(substr)} | head -1`)).trim(); @@ -191,23 +192,29 @@ test("model access refreshes on the manager node and delivers only the access to const managerImage = pinned("mesh-runtime-anthropic-manager"); const consumerImage = pinned("mesh-runtime-anthropic-consumer"); - // --- the licence, its manager, and the consumer holder ------------------------------------------ + // --- the licence, and the manager as its holder ------------------------------------------------ + // The manager module is a HOLDER, named the licence's manager. It is delivered the refresh token; + // its bound facts carry the node's PUBLIC sealing key, which is what adoption seals to. The consumer + // holder is added later — only once an access token exists to seal to it — because a holder with no + // credential yet cannot have a declaration built for it. await mesh(`licence add anthropic personal --serves '{"model":"a-model"}'`); - await mesh(`licence manager personal ${MACHINE}`); - await mesh(`licence use personal ${MACHINE} anthropic-consumer`); + await mesh(`licence manager personal ${MACHINE} anthropic-manager`); + await mesh(`licence use personal ${MACHINE} anthropic-manager`); - // --- both runtimes are placed so the host pulls their images (which the test then drives) -------- - // Inline manifests, env pointed at the stub, mirroring the committed module.json. The scheduled - // containers install as present state (ADR 0053); the test drives the entrypoints directly for a - // deterministic flow rather than waiting on cron. + // --- the manager module, deployed so the host delivers its bound facts -------------------------- + // Inline manifest mirroring the committed module.json: model-access holder, refresh token bound as a + // sealed secret, no node-key mount. The scheduled container installs as present state (ADR 0053); + // the test drives adopt/refresh directly for a deterministic flow rather than waiting on cron. const managerManifest = JSON.stringify({ module: "anthropic-manager", version: "1", + 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: "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: "refresh", type: "container", name: "mesh-anthropic-manager-refresh", @@ -218,9 +225,8 @@ test("model access refreshes on the manager node and delivers only the access to MESH_ANTHROPIC_LICENCE: "personal", MESH_ANTHROPIC_TOKEN_ENDPOINT: "http://127.0.0.1:9099/token", MESH_ANTHROPIC_USAGE_ENDPOINT: "http://127.0.0.1:9099/usage", - 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_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", @@ -228,6 +234,96 @@ test("model access refreshes on the manager node and delivers only the access to }, ], }); + await must(`printf %s ${quote(managerManifest)} > /tmp/anthropic-manager.json && docker cp /tmp/anthropic-manager.json mesh-control:/anthropic-manager.json`); + await mesh(`module add /anthropic-manager.json`); + await mesh(`module issue anthropic-manager --node ${MACHINE}`); + await mesh(`assign ${MACHINE} anthropic-manager`); + await mesh(`push ${MACHINE}`); + await settled(); + + // The image the host pulled for the manager runtime is now local; drive it directly. + const managerId = await imageId("anthropic-manager"); + + // The host delivered the manager's bound facts, carrying the node's PUBLIC sealing key. + const facts = await must(`cat /var/lib/mesh/anthropic-manager/model.json`); + assert.match(facts, /"manager_public_key"\s*:/, `the manager was not delivered its node public key:\n${facts}`); + + // --- the OAuth stub: a tiny node server run from the manager image itself ----------------------- + const stub = [ + "const http=require('http');", + "http.createServer((req,res)=>{let b='';req.on('data',c=>b+=c);req.on('end',()=>{", + " if(req.url.startsWith('/token')){res.setHeader('content-type','application/json');", + ` res.end(JSON.stringify({access_token:${JSON.stringify(ACCESS_TOKEN)},refresh_token:${JSON.stringify(ROTATED_REFRESH)},expires_in:3600,refresh_token_expires_in:2592000,subscription_type:'pro'}));return;}`, + " if(req.url.startsWith('/usage')){res.setHeader('content-type','application/json');", + " res.end(JSON.stringify({five_hour:{utilization:12,resets_at:'2026-01-01T00:00:00Z'},seven_day:{utilization:3}}));return;}", + " res.statusCode=404;res.end('no');});}).listen(9099,'127.0.0.1',()=>console.log('stub up'));", + ].join("\n"); + await must(`printf %s ${quote(stub)} > /var/lib/mesh/anthropic-manager/stub.js`); + await must(`docker rm -f oauth-stub 2>/dev/null; docker run -d --name oauth-stub --network host --entrypoint node -v /var/lib/mesh/anthropic-manager/stub.js:/run/stub.js:ro ${managerId} /run/stub.js`); + await must(`for i in $(seq 1 20); do curl -s -X POST http://127.0.0.1:9099/token >/dev/null && break; sleep 1; done`); + + // --- 1. adopt: seal the operator's refresh token to THIS node's public key, on the manager node -- + // adopt reads the node public key from the delivered bound facts (never a private key), seals, and + // hands out only the box. + await must(`printf %s ${quote(REFRESH_TOKEN)} > /var/lib/mesh/anthropic-manager/adopt-token`); + await must( + `docker run --rm --network host -v /var/lib/mesh/anthropic-manager:/run/state ` + + `-e MESH_ANTHROPIC_ADOPT_TOKEN_FILE=/run/state/adopt-token ` + + `-e MESH_MODEL_ACCESS_BIND_FILE=/run/state/model.json ` + + `-e MESH_ANTHROPIC_GRANT_OUT=/run/state/out/grant.json ` + + `${managerId} run /app/modules/anthropic-manager/dist/adopt/index.js`, + ); + const sealedGrant = await must(`cat /var/lib/mesh/anthropic-manager/out/grant.json`); + assert.doesNotMatch(sealedGrant, new RegExp(REFRESH_TOKEN), + `the adopted box holds the refresh token in the clear:\n${sealedGrant}`); + assert.match(sealedGrant, /"sealed"\s*:/, `the adopted grant is not a sealed box:\n${sealedGrant}`); + + // Store the sealed box in the control plane — which never sees the refresh token. + await must(`docker cp /var/lib/mesh/anthropic-manager/out/grant.json mesh-control:/grant.json`); + await mesh(`licence set-grant personal --file /grant.json`); + + // --- 2. the host unseals: a push mounts the cleartext refresh token at the manager's secret path -- + await mesh(`push ${MACHINE}`); + await settled(); + let mounted = false; + for (let i = 0; i < 20 && !mounted; i++) { + mounted = (await on(`test -s /var/lib/mesh/anthropic-manager/refresh-token`)).ok; + if (!mounted) await new Promise((r) => setTimeout(r, 3000)); + } + assert.ok(mounted, "the host never unsealed and mounted the refresh token for the manager"); + const mountedToken = (await must(`cat /var/lib/mesh/anthropic-manager/refresh-token`)).trim(); + assert.equal(mountedToken, REFRESH_TOKEN, "the host mounted the wrong cleartext refresh token"); + + // --- 3. refresh: read the cleartext, call the stub, re-seal a rotated token, write out ---------- + await must( + `docker run --rm --network host -v /var/lib/mesh/anthropic-manager:/run/state ` + + `-e MESH_ANTHROPIC_LICENCE=personal ` + + `-e MESH_ANTHROPIC_TOKEN_ENDPOINT=http://127.0.0.1:9099/token ` + + `-e MESH_ANTHROPIC_USAGE_ENDPOINT=http://127.0.0.1:9099/usage ` + + `-e MESH_MODEL_ACCESS_SECRET_FILE=/run/state/refresh-token ` + + `-e MESH_MODEL_ACCESS_BIND_FILE=/run/state/model.json ` + + `-e MESH_ANTHROPIC_ACCESS_OUT=/run/state/out/access-token ` + + `-e MESH_ANTHROPIC_GRANT_OUT=/run/state/out/new-grant.json ` + + `-e MESH_ANTHROPIC_USAGE_OUT=/run/state/out/usage.json ` + + `${managerId} run /app/modules/anthropic-manager/dist/refresh/index.js`, + ); + const producedAccess = (await must(`cat /var/lib/mesh/anthropic-manager/out/access-token`)).trim(); + assert.equal(producedAccess, ACCESS_TOKEN, "the manager did not mint the stub's access token"); + const newBox = await must(`cat /var/lib/mesh/anthropic-manager/out/new-grant.json`); + assert.doesNotMatch(newBox, new RegExp(ROTATED_REFRESH), + `the re-sealed box holds the rotated refresh token in the clear:\n${newBox}`); + const usage = await must(`cat /var/lib/mesh/anthropic-manager/out/usage.json`); + assert.match(usage, /"sessionPct":12/, `the licence-grain usage reading is wrong:\n${usage}`); + + // --- 4. submit: the control plane is handed only the access token + opaque box ------------------- + // The consumer is put on the licence now — an access token exists to seal to it. + await mesh(`licence use personal ${MACHINE} anthropic-consumer`); + await must(`docker cp /var/lib/mesh/anthropic-manager/out/access-token mesh-control:/access-token`); + await must(`docker cp /var/lib/mesh/anthropic-manager/out/new-grant.json mesh-control:/new-grant.json`); + const submitted = await mesh(`licence submit-refresh personal --access-file /access-token --grant-file /new-grant.json`); + assert.match(submitted, /sealed to 1 holder/, submitted); + + // --- 5. deliver: deploy the consumer and push; it gets the sealed access token ------------------- const consumerManifest = JSON.stringify({ module: "anthropic-consumer", version: "1", @@ -254,93 +350,15 @@ test("model access refreshes on the manager node and delivers only the access to }, ], }); - - for (const [name, body] of [["anthropic-manager", managerManifest], ["anthropic-consumer", consumerManifest]] as const) { - await must(`printf %s ${quote(body)} > /tmp/${name}.json && docker cp /tmp/${name}.json mesh-control:/${name}.json`); - await mesh(`module add /${name}.json`); - await mesh(`module issue ${name} --node ${MACHINE}`); - await mesh(`assign ${MACHINE} ${name}`); - } + await must(`printf %s ${quote(consumerManifest)} > /tmp/anthropic-consumer.json && docker cp /tmp/anthropic-consumer.json mesh-control:/anthropic-consumer.json`); + await mesh(`module add /anthropic-consumer.json`); + await mesh(`module issue anthropic-consumer --node ${MACHINE}`); + await mesh(`assign ${MACHINE} anthropic-consumer`); await mesh(`push ${MACHINE}`); await settled(); - // The images the host pulled to run the scheduled containers are now local; drive them directly. - const managerId = await imageId("anthropic-manager"); const consumerId = await imageId("anthropic-consumer"); - // --- the stubbed node private key, mounted (FLAGGED) -------------------------------------------- - // Production needs a host capability to place the node private key where the manager reads it; - // mesh-host has none today. Here the test mounts a generated key pair as that key. - const keys = nodeKeyPair(); - await must(`printf %s ${quote(keys.pub)} > /var/lib/mesh/anthropic-manager/keys/sealing.pub`); - await must(`printf %s ${quote(keys.priv)} > /var/lib/mesh/anthropic-manager/keys/sealing.priv`); - - // --- the OAuth stub: a tiny node server run from the manager image itself ----------------------- - const stub = [ - "const http=require('http');", - "http.createServer((req,res)=>{let b='';req.on('data',c=>b+=c);req.on('end',()=>{", - " if(req.url.startsWith('/token')){res.setHeader('content-type','application/json');", - ` res.end(JSON.stringify({access_token:${JSON.stringify(ACCESS_TOKEN)},refresh_token:${JSON.stringify(ROTATED_REFRESH)},expires_in:3600,refresh_token_expires_in:2592000,subscription_type:'pro'}));return;}`, - " if(req.url.startsWith('/usage')){res.setHeader('content-type','application/json');", - " res.end(JSON.stringify({five_hour:{utilization:12,resets_at:'2026-01-01T00:00:00Z'},seven_day:{utilization:3}}));return;}", - " res.statusCode=404;res.end('no');});}).listen(9099,'127.0.0.1',()=>console.log('stub up'));", - ].join("\n"); - await must(`printf %s ${quote(stub)} > /var/lib/mesh/anthropic-manager/stub.js`); - await must(`docker rm -f oauth-stub 2>/dev/null; docker run -d --name oauth-stub --network host --entrypoint node -v /var/lib/mesh/anthropic-manager/stub.js:/run/stub.js:ro ${managerId} /run/stub.js`); - // Give it a moment to bind. - await must(`for i in $(seq 1 20); do curl -s -X POST http://127.0.0.1:9099/token >/dev/null && break; sleep 1; done`); - - // --- 1. adopt: seal the refresh token at rest, on the manager node ------------------------------ - await must(`printf %s ${quote(REFRESH_TOKEN)} > /var/lib/mesh/anthropic-manager/refresh-token`); - await must( - `docker run --rm --network host -v /var/lib/mesh/anthropic-manager:/run/state ` + - `-e MESH_ANTHROPIC_REFRESH_TOKEN_FILE=/run/state/refresh-token ` + - `-e MESH_NODE_SEALING_PUBLIC_FILE=/run/state/keys/sealing.pub ` + - `-e MESH_ANTHROPIC_GRANT_OUT=/run/state/grant.json ` + - `${managerId} run /app/modules/anthropic-manager/dist/adopt/index.js`, - ); - const envelope = await must(`cat /var/lib/mesh/anthropic-manager/grant.json`); - assert.doesNotMatch(envelope, new RegExp(REFRESH_TOKEN), - `the adopted envelope holds the refresh token in the clear:\n${envelope}`); - - // Store the sealed envelope in the control plane — which never sees the refresh token. - await must(`docker cp /var/lib/mesh/anthropic-manager/grant.json mesh-control:/grant.json`); - await mesh(`licence set-grant personal --file /grant.json`); - - // --- 2. refresh: open on the manager node, call the stub, write access token + re-sealed refresh - - await must( - `docker run --rm --network host -v /var/lib/mesh/anthropic-manager:/run/state ` + - `-e MESH_ANTHROPIC_LICENCE=personal ` + - `-e MESH_ANTHROPIC_TOKEN_ENDPOINT=http://127.0.0.1:9099/token ` + - `-e MESH_ANTHROPIC_USAGE_ENDPOINT=http://127.0.0.1:9099/usage ` + - `-e MESH_ANTHROPIC_GRANT_FILE=/run/state/grant.json ` + - `-e MESH_NODE_SEALING_PUBLIC_FILE=/run/state/keys/sealing.pub ` + - `-e MESH_NODE_SEALING_PRIVATE_FILE=/run/state/keys/sealing.priv ` + - `-e MESH_ANTHROPIC_ACCESS_OUT=/run/state/out/access-token ` + - `-e MESH_ANTHROPIC_GRANT_OUT=/run/state/out/grant.json ` + - `-e MESH_ANTHROPIC_USAGE_OUT=/run/state/out/usage.json ` + - `${managerId} run /app/modules/anthropic-manager/dist/refresh/index.js`, - ); - const producedAccess = (await must(`cat /var/lib/mesh/anthropic-manager/out/access-token`)).trim(); - assert.equal(producedAccess, ACCESS_TOKEN, "the manager did not mint the stub's access token"); - const newEnvelope = await must(`cat /var/lib/mesh/anthropic-manager/out/grant.json`); - assert.doesNotMatch(newEnvelope, new RegExp(ROTATED_REFRESH), - `the re-sealed envelope holds the rotated refresh token in the clear:\n${newEnvelope}`); - // Licence-grain usage was read and recorded. - const usage = await must(`cat /var/lib/mesh/anthropic-manager/out/usage.json`); - assert.match(usage, /"sessionPct":12/, `the licence-grain usage reading is wrong:\n${usage}`); - - // --- 3. submit: the control plane is handed only the access token + opaque envelope ------------- - await must(`docker cp /var/lib/mesh/anthropic-manager/out/access-token mesh-control:/access-token`); - await must(`docker cp /var/lib/mesh/anthropic-manager/out/grant.json mesh-control:/new-grant.json`); - const submitted = await mesh(`licence submit-refresh personal --access-file /access-token --grant-file /new-grant.json`); - assert.match(submitted, /sealed to 1 holder/, submitted); - - // --- 4. deliver: the consumer gets the sealed access token and writes the credential ------------- - await mesh(`push ${MACHINE}`); - await settled(); - - // Drive the consumer's apply once the token has been delivered to its secret path. let delivered = false; for (let i = 0; i < 20 && !delivered; i++) { delivered = (await on(`test -s /var/lib/anthropic-consumer/access-token`)).ok; @@ -363,7 +381,8 @@ test("model access refreshes on the manager node and delivers only the access to assert.equal(parsed.claudeAiOauth?.accessToken, ACCESS_TOKEN); assert.ok(!parsed.claudeAiOauth?.refreshToken, "the consumer was given a refresh token"); - // The refresh token — original or rotated — is nowhere on the consuming node. + // The refresh token — original or rotated — is nowhere on the CONSUMING node's tree. (It IS on the + // manager's, as cleartext the host mounted for it — that is the one node allowed to read it.) for (const secret of [REFRESH_TOKEN, ROTATED_REFRESH]) { const found = await on(`grep -rq ${quote(secret)} /var/lib/anthropic-consumer`); assert.ok(!found.ok, `a refresh token is on the consuming node under /var/lib/anthropic-consumer`); @@ -371,20 +390,20 @@ test("model access refreshes on the manager node and delivers only the access to // The control plane's own database holds the refresh token only as ciphertext. const grantRow = await must( - `docker exec mesh-store psql -U postgres -d licences -qAt -c "select token, wrapped_key from refresh_grant where licence='personal'"`, + `docker exec mesh-store psql -U postgres -d licences -qAt -c "select sealed, manager_key from refresh_grant where licence='personal'"`, ); assert.ok(grantRow.trim().length > 0, "no refresh grant was stored"); for (const secret of [REFRESH_TOKEN, ROTATED_REFRESH]) { assert.doesNotMatch(grantRow, new RegExp(secret), `the refresh token is in the control plane's database in the clear:\n${grantRow}`); } - // And the holder's sealed column carries the access token's seal, never a refresh token. + // And the CONSUMER holder's sealed column carries the access token's seal, never a refresh token. const holder = await must( - `docker exec mesh-store psql -U postgres -d licences -qAt -c "select coalesce(sealed,'') from licence_holder where licence='personal'"`, + `docker exec mesh-store psql -U postgres -d licences -qAt -c "select coalesce(sealed,'') from licence_holder where licence='personal' and module='anthropic-consumer'"`, ); - assert.ok(holder.trim().length > 0, "nothing was sealed to the holder"); + assert.ok(holder.trim().length > 0, "nothing was sealed to the consumer holder"); for (const secret of [REFRESH_TOKEN, ROTATED_REFRESH]) { - assert.doesNotMatch(holder, new RegExp(secret), "a refresh token is in the holder row"); + assert.doesNotMatch(holder, new RegExp(secret), "a refresh token is in the consumer holder row"); } await must(`docker rm -f oauth-stub 2>/dev/null || true`); From 87c482013065a1c21ef5c229670fa656fc929478 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 02:47:50 +0200 Subject: [PATCH 3/3] anthropic bed: package a module's own npm deps, stage grant files readably Two harness fixes the green end-to-end run needed: - build-module-runtime.sh installs a module's non-@novox runtime deps under /app/modules//node_modules, so a module can carry a private dependency (the anthropic-manager seals with tweetnacl-sealedbox-js). The shared tree still answers @novox/* and common packages. A no-op for modules that declare none. - stageIntoControl chmods the manager's 0600 adopt/refresh outputs to 0644 on the anchor host before docker cp, so the distroless mesh-control (non-root, no chmod) can read the staged file. What is staged is a sealed box or the access token, never a cleartext refresh token. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- scripts/build-module-runtime.sh | 12 ++++++++++++ test/integration/anthropic-bed.test.ts | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/build-module-runtime.sh b/scripts/build-module-runtime.sh index 33cfdf9..4ef8978 100755 --- a/scripts/build-module-runtime.sh +++ b/scripts/build-module-runtime.sh @@ -38,6 +38,18 @@ cp -rL "$MESH_TOOLS/node_modules" "$STAGE/node_modules" mkdir -p "$STAGE/modules/$MODULE"; cp -r "$MOD/dist" "$STAGE/modules/$MODULE/dist" cp "$MESH_TOOLS/package.json" "$STAGE/package.json" +# A module may declare its own third-party runtime deps (the anthropic-manager seals with +# tweetnacl-sealedbox-js). The shared node_modules copied above carries the common packages and +# @novox/* — but not a module's private deps. Install those under the module itself, so Node +# resolves them from /app/modules//node_modules and still falls back to the shared tree +# at /app/node_modules for @novox/* and everything common. Modules with no non-@novox deps are a +# no-op. (@novox/* are workspace deps with no registry to fetch from, so they are excluded here.) +MOD_DEPS="$(node -e 'const d=(require("'"$MOD"'/package.json").dependencies)||{};process.stdout.write(Object.keys(d).filter(k=>!k.startsWith("@novox/")).map(k=>k+"@"+d[k]).join(" "))')" +if [ -n "$MOD_DEPS" ]; then + # shellcheck disable=SC2086 + npm install --prefix "$STAGE/modules/$MODULE" --omit=dev --no-save --no-package-lock --ignore-scripts $MOD_DEPS >/dev/null +fi + # The entrypoints the runtime loads: tools, events and (a provider's) provisioner, whichever exist. ENTRIES=""; for e in tools/index.js index.js provisioner/index.js; do [ -f "$STAGE/modules/$MODULE/dist/$e" ] && ENTRIES="${ENTRIES:+$ENTRIES,}/app/modules/$MODULE/dist/$e" diff --git a/test/integration/anthropic-bed.test.ts b/test/integration/anthropic-bed.test.ts index 18dbaa7..4c85c42 100644 --- a/test/integration/anthropic-bed.test.ts +++ b/test/integration/anthropic-bed.test.ts @@ -98,6 +98,17 @@ async function mesh(command: string, timeoutMs?: number): Promise { return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs); } +// The manager's adopt/refresh runtime writes its outputs as root, mode 0600 (secret files). To hand +// one to `mesh-control` — whose process runs as a non-root user — the test relaxes the mode on the +// anchor host (where `must` is root) and then copies it in: `docker cp` preserves the source mode, so +// the file lands 0644 and mesh-control (a distroless image with no `chmod` of its own) can read it. +// What is staged this way is a sealed box or the access token, never a cleartext refresh token, so a +// world-readable copy discloses nothing the control plane does not already hold. In production the +// operator who ran adopt owns the file and this does not arise. +async function stageIntoControl(hostPath: string, dest: string): Promise { + await must(`chmod 0644 ${hostPath} && docker cp ${hostPath} mesh-control:${dest}`); +} + async function meshTry(command: string): Promise<{ out: string; ok: boolean }> { return on(`docker exec mesh-control /mesh-control ${command}`); } @@ -279,7 +290,7 @@ test("model access refreshes on the manager node and delivers only the access to assert.match(sealedGrant, /"sealed"\s*:/, `the adopted grant is not a sealed box:\n${sealedGrant}`); // Store the sealed box in the control plane — which never sees the refresh token. - await must(`docker cp /var/lib/mesh/anthropic-manager/out/grant.json mesh-control:/grant.json`); + await stageIntoControl(`/var/lib/mesh/anthropic-manager/out/grant.json`, `/grant.json`); await mesh(`licence set-grant personal --file /grant.json`); // --- 2. the host unseals: a push mounts the cleartext refresh token at the manager's secret path -- @@ -318,8 +329,8 @@ test("model access refreshes on the manager node and delivers only the access to // --- 4. submit: the control plane is handed only the access token + opaque box ------------------- // The consumer is put on the licence now — an access token exists to seal to it. await mesh(`licence use personal ${MACHINE} anthropic-consumer`); - await must(`docker cp /var/lib/mesh/anthropic-manager/out/access-token mesh-control:/access-token`); - await must(`docker cp /var/lib/mesh/anthropic-manager/out/new-grant.json mesh-control:/new-grant.json`); + await stageIntoControl(`/var/lib/mesh/anthropic-manager/out/access-token`, `/access-token`); + await stageIntoControl(`/var/lib/mesh/anthropic-manager/out/new-grant.json`, `/new-grant.json`); const submitted = await mesh(`licence submit-refresh personal --access-file /access-token --grant-file /new-grant.json`); assert.match(submitted, /sealed to 1 holder/, submitted);