From 87c482013065a1c21ef5c229670fa656fc929478 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 02:47:50 +0200 Subject: [PATCH] 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);