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`);