From f91b134a204414d7d548e5a829e30b8613149ca3 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 14 Sep 2026 01:42:42 -0700 Subject: [PATCH 1/2] fix(sandbox): renew workspace credentials before reuse --- create-agent-app/template-chat/_package.json | 2 +- create-agent-app/template/_package.json | 2 +- examples/chat-app.md | 19 ++++ package.json | 4 +- pnpm-lock.yaml | 16 ++-- src/sandbox/index.test.ts | 95 ++++++++++++++++++++ src/sandbox/index.ts | 50 ++++++----- 7 files changed, 154 insertions(+), 34 deletions(-) diff --git a/create-agent-app/template-chat/_package.json b/create-agent-app/template-chat/_package.json index 9e62d7ac..e1507eba 100644 --- a/create-agent-app/template-chat/_package.json +++ b/create-agent-app/template-chat/_package.json @@ -23,7 +23,7 @@ "@tangle-network/agent-gateway": "0.10.0", "@tangle-network/agent-interface": "2.6.0", "@tangle-network/agent-runtime": "0.222.1", - "@tangle-network/sandbox": "0.39.2", + "@tangle-network/sandbox": "0.39.4", "better-auth": "^1.7.2", "drizzle-orm": "^0.45.2", "hono": "^4.13.5", diff --git a/create-agent-app/template/_package.json b/create-agent-app/template/_package.json index a6bce740..b2f4e7fc 100644 --- a/create-agent-app/template/_package.json +++ b/create-agent-app/template/_package.json @@ -32,7 +32,7 @@ "@tangle-network/agent-interface": "2.6.0", "@tangle-network/agent-knowledge": "15.0.3", "@tangle-network/agent-runtime": "0.222.1", - "@tangle-network/sandbox": "0.39.2", + "@tangle-network/sandbox": "0.39.4", "@types/node": "^22.20.1", "typescript": "^7.0.2", "viem": "^2.0.0", diff --git a/examples/chat-app.md b/examples/chat-app.md index 2bce285d..48c3809e 100644 --- a/examples/chat-app.md +++ b/examples/chat-app.md @@ -529,3 +529,22 @@ The dispatched profile records the effective model and harness, without copying Products with their own SDK transport can call `resolveSandboxPromptBackend` directly. It accepts only profile, provider, and preparation configuration; it does not require provisioning or storage adapters. Use its returned backend for both request-size checks and dispatch, preserving product-specific recovery and persistence. + +## Renew workspace credentials + +Put expiring application credentials in `SandboxRuntimeConfig.runtimeEnv`, rather than creation-only `env` or a custom bootstrap hook. +The shell resolves them on creation and before each reuse, resume, or recovery, even when liveness is cached. + +```ts +runtimeEnv: async ({ workspaceId }) => ({ + APP_TOOL_BEARER: await mintWorkspaceToolToken(workspaceId), +}), +``` + +The product owns `mintWorkspaceToolToken` and throws when required credentials are unavailable. +Profiles reference `APP_TOOL_BEARER` by name; credential values stay outside profile material. +Values have workspace-wide authority, so this callback cannot carry per-user or per-request secrets. +Static process configuration remains in `env`; Sandbox owns managed model and Hub credentials. +On existing sandboxes, the shell awaits the SDK's `setRuntimeEnv` acknowledgement before bootstrap or dispatch. +An update failure preserves the sandbox and prevents the turn from starting. +Deploy the matching Sandbox runtime before adopting this callback; a package upgrade cannot update already-running sidecars. diff --git a/package.json b/package.json index 0d5c06d3..f9204393 100644 --- a/package.json +++ b/package.json @@ -543,7 +543,7 @@ "@tangle-network/agent-profile-materialize": "0.19.0", "@tangle-network/agent-runtime": "0.222.1", "@tangle-network/brand": "1.5.0", - "@tangle-network/sandbox": "0.39.2", + "@tangle-network/sandbox": "0.39.4", "@tangle-network/sandbox-ui": "0.113.3", "@tangle-network/ui": "^11.8.0", "@testing-library/dom": "^10.4.1", @@ -603,7 +603,7 @@ "@tangle-network/agent-profile-materialize": ">=0.19.0 <0.20.0", "@tangle-network/agent-runtime": ">=0.222.1 <0.223.0", "@tangle-network/brand": ">=1.5.0", - "@tangle-network/sandbox": ">=0.38.2 <0.40.0", + "@tangle-network/sandbox": ">=0.39.4 <0.40.0", "@tangle-network/sandbox-ui": ">=0.113.3 <0.114.0", "@tangle-network/ui": ">=11.6.0 <12.0.0", "@tiptap/core": ">=3.28.0 <4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fe73598..be9ccd03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,13 +57,13 @@ importers: version: 0.19.0(@tangle-network/agent-interface@2.6.0) '@tangle-network/agent-runtime': specifier: 0.222.1 - version: 0.222.1(@tangle-network/agent-eval@0.180.0)(@tangle-network/agent-interface@2.6.0)(@tangle-network/sandbox@0.39.2(viem@2.56.0(typescript@7.0.2)(zod@4.4.3))) + version: 0.222.1(@tangle-network/agent-eval@0.180.0)(@tangle-network/agent-interface@2.6.0)(@tangle-network/sandbox@0.39.4(viem@2.56.0(typescript@7.0.2)(zod@4.4.3))) '@tangle-network/brand': specifier: 1.5.0 version: 1.5.0(react@19.2.8) '@tangle-network/sandbox': - specifier: 0.39.2 - version: 0.39.2(viem@2.56.0(typescript@7.0.2)(zod@4.4.3)) + specifier: 0.39.4 + version: 0.39.4(viem@2.56.0(typescript@7.0.2)(zod@4.4.3)) '@tangle-network/sandbox-ui': specifier: 0.113.3 version: 0.113.3(d27cb959a9c7462878f04dcd035c9dc3) @@ -2305,8 +2305,8 @@ packages: yjs: optional: true - '@tangle-network/sandbox@0.39.2': - resolution: {integrity: sha512-SgyeB7X2hHYQmHTJOCYBqpIKJ0GON0r6vT7/YGBtyYuG4XSLG61fplZ41VfBUsajGTaNkDyhdKlrCBWqrfB18Q==} + '@tangle-network/sandbox@0.39.4': + resolution: {integrity: sha512-yabZjharkZUaNZrgbrzofciv8Y1iI/I3M9/boozwk3ReuZwDIqYoRhF5QXXZNcFhHeaRCzsUCbyFFIVZ+RbnMw==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.30.0 @@ -7086,7 +7086,7 @@ snapshots: dependencies: '@tangle-network/agent-interface': 2.6.0 - '@tangle-network/agent-runtime@0.222.1(@tangle-network/agent-eval@0.180.0)(@tangle-network/agent-interface@2.6.0)(@tangle-network/sandbox@0.39.2(viem@2.56.0(typescript@7.0.2)(zod@4.4.3)))': + '@tangle-network/agent-runtime@0.222.1(@tangle-network/agent-eval@0.180.0)(@tangle-network/agent-interface@2.6.0)(@tangle-network/sandbox@0.39.4(viem@2.56.0(typescript@7.0.2)(zod@4.4.3)))': dependencies: '@tangle-network/agent-core': 0.9.6 '@tangle-network/agent-eval': 0.180.0 @@ -7094,7 +7094,7 @@ snapshots: '@tangle-network/agent-knowledge': 15.0.3(@tangle-network/agent-eval@0.180.0)(@tangle-network/agent-interface@2.6.0) '@tangle-network/agent-profile-materialize': 0.19.0(@tangle-network/agent-interface@2.6.0) '@tangle-network/agent-trace-contract': 1.0.2 - '@tangle-network/sandbox': 0.39.2(viem@2.56.0(typescript@7.0.2)(zod@4.4.3)) + '@tangle-network/sandbox': 0.39.4(viem@2.56.0(typescript@7.0.2)(zod@4.4.3)) tar-stream: 3.2.1 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -7140,7 +7140,7 @@ snapshots: - '@types/react' - '@types/react-dom' - '@tangle-network/sandbox@0.39.2(viem@2.56.0(typescript@7.0.2)(zod@4.4.3))': + '@tangle-network/sandbox@0.39.4(viem@2.56.0(typescript@7.0.2)(zod@4.4.3))': dependencies: '@tangle-network/agent-core': 0.9.6 '@tangle-network/agent-interface': 2.6.0 diff --git a/src/sandbox/index.test.ts b/src/sandbox/index.test.ts index b2e3b18e..d77c39c3 100644 --- a/src/sandbox/index.test.ts +++ b/src/sandbox/index.test.ts @@ -162,6 +162,101 @@ beforeEach(() => { secretsDeleteMock.mockReset() }) +describe('workspace runtime environment renewal', () => { + const scope = { workspaceId: 'w1', userId: 'u1', harness: 'opencode' as const } + + it('resolves runtime credentials once into the creation environment', async () => { + listMock.mockResolvedValue([]) + const box = fakeBox({ setRuntimeEnv: vi.fn() }) + createMock.mockResolvedValue(box) + const runtimeEnv = vi.fn().mockResolvedValue({ APP_TOKEN: 'first-token' }) + const shell = shellFor({ apiKey: 'k', baseUrl: 'u' }, { runtimeEnv }) + + await expect(ensureWorkspaceSandbox(shell, scope)).resolves.toBe(box) + expect(runtimeEnv).toHaveBeenCalledExactlyOnceWith({ workspaceId: 'w1', userId: 'u1' }) + expect(createMock.mock.calls[0]![0].env).toEqual({ WORKSPACE_ID: 'w1', APP_TOKEN: 'first-token' }) + expect(box.setRuntimeEnv).not.toHaveBeenCalled() + }) + + it('renews before bootstrap on every reuse, including a cached liveness check', async () => { + const setRuntimeEnv = vi.fn().mockResolvedValue(undefined) + const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'alive\n' }) + const box = fakeBox({ setRuntimeEnv, exec }) + listMock.mockResolvedValue([box]) + const runtimeEnv = vi.fn() + .mockResolvedValueOnce({ APP_TOKEN: 'renewed-1' }) + .mockResolvedValueOnce({ APP_TOKEN: 'renewed-2' }) + const bootstrap = vi.fn(async () => { + expect(setRuntimeEnv).toHaveBeenCalledTimes(bootstrap.mock.calls.length) + return { succeeded: true as const, value: undefined } + }) + const env = vi.fn().mockResolvedValue({ WORKSPACE_ID: 'w1' }) + const shell = shellFor({ apiKey: 'k', baseUrl: 'u' }, { env, runtimeEnv, bootstrap, livenessProbe: {} }) + + await ensureWorkspaceSandbox(shell, scope) + await ensureWorkspaceSandbox(shell, scope) + expect(setRuntimeEnv.mock.calls).toEqual([[{ APP_TOKEN: 'renewed-1' }], [{ APP_TOKEN: 'renewed-2' }]]) + expect(env).not.toHaveBeenCalled() + expect(exec).toHaveBeenCalledOnce() + expect(createMock).not.toHaveBeenCalled() + }) + + it('renews the retained sandbox after resuming it', async () => { + const box = fakeBox({ setRuntimeEnv: vi.fn().mockResolvedValue(undefined) }) + listMock.mockResolvedValueOnce([]).mockResolvedValueOnce([box]) + const shell = shellFor({ apiKey: 'k', baseUrl: 'u' }, { + runtimeEnv: async () => ({ APP_TOKEN: 'after-resume' }), + }) + + await expect(ensureWorkspaceSandbox(shell, scope)).resolves.toBe(box) + expect(box.resume).toHaveBeenCalledOnce() + expect(box.setRuntimeEnv).toHaveBeenCalledExactlyOnceWith({ APP_TOKEN: 'after-resume' }) + expect(box.delete).not.toHaveBeenCalled() + expect(createMock).not.toHaveBeenCalled() + }) + + it('preserves the sandbox and refuses bootstrap when renewal is rejected', async () => { + const failure = Object.assign(new Error('App environment endpoint unavailable'), { + status: 404, endpoint: '/config/config/app-env', + }) + const box = fakeBox({ setRuntimeEnv: vi.fn().mockRejectedValue(failure) }) + listMock.mockResolvedValue([box]) + const bootstrap = vi.fn() + const shell = shellFor({ apiKey: 'k', baseUrl: 'u' }, { + runtimeEnv: async () => ({ APP_TOKEN: 'renewed' }), bootstrap, + }) + + await expect(ensureWorkspaceSandbox(shell, scope)).rejects.toBe(failure) + expect(bootstrap).not.toHaveBeenCalled() + expect(box.delete).not.toHaveBeenCalled() + expect(box.stop).not.toHaveBeenCalled() + expect(createMock).not.toHaveBeenCalled() + }) + + it('refuses reuse when credentials cannot be minted', async () => { + const box = fakeBox({ setRuntimeEnv: vi.fn() }) + listMock.mockResolvedValue([box]) + const failure = new Error('Credential issuer unavailable') + const shell = shellFor({ apiKey: 'k', baseUrl: 'u' }, { + runtimeEnv: async () => { throw failure }, + }) + + await expect(ensureWorkspaceSandbox(shell, scope)).rejects.toBe(failure) + expect(box.setRuntimeEnv).not.toHaveBeenCalled() + expect(box.delete).not.toHaveBeenCalled() + expect(createMock).not.toHaveBeenCalled() + }) + + it('does not send an empty runtime environment to the SDK', async () => { + const box = fakeBox({ setRuntimeEnv: vi.fn() }) + listMock.mockResolvedValue([box]) + const runtimeEnv = vi.fn().mockResolvedValue({}) + await ensureWorkspaceSandbox(shellFor({ apiKey: 'k', baseUrl: 'u' }, { runtimeEnv }), scope) + expect(runtimeEnv).toHaveBeenCalledOnce() + expect(box.setRuntimeEnv).not.toHaveBeenCalled() + }) +}) + describe('getClient credential-fingerprint cache', () => { it('reuses one client for the same apiKey+baseUrl', () => { const shell = shellFor({ apiKey: 'k1', baseUrl: 'https://s' }) diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 863e2e64..22c1e6b5 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -382,26 +382,19 @@ export interface SandboxRuntimeConfig { metadata: (harness: Harness) => Record connectedIntegrationIds: (workspaceId: string) => Promise /** - * Raw box environment written once at sandbox CREATION — deliberately plain - * strings, and the one place a credential value legitimately lives. - * - * This is the private side of the tagged-config contract, not profile - * material: an `AgentProfileMcpServer` may only carry a `secret-ref` naming a - * key, and the sandbox resolves that key against THIS map (or against the - * platform secret store fed by {@link SandboxRuntimeConfig.secrets}). So a - * `tokenEnvKey` passed to `buildAppToolMcpServers` must name a variable this - * seam places, or the reference resolves to nothing. - * - * It is NOT widened to tagged values: the sandbox SDK's create payload types - * `env` as `Record`, and {@link assertEnvWithinLimits} - * measures those bytes against the kernel's per-entry `MAX_ARG_STRLEN`. - * Tagging it would make the box env reference itself. - * - * Values are workspace-wide and fixed for the box's lifetime, so a per-user - * or per-resource credential cannot be placed here — see - * `unresolvableSurfaceCredential` in `../tools/mcp`. + * Creation-only environment. Use runtimeEnv for expiring app credentials. + * Values remain private; profiles reference their names through secret-ref. + * Workspace-wide values cannot carry per-user or per-resource authority. */ env: (ctx: SandboxBuildContext) => Promise> + /** + * Workspace app credentials resolved on creation and before every reuse, + * resume, or recovery. Fresh values override env in the creation payload. + * The SDK updates retained runtimes before bootstrap and refuses runtime-managed keys. + * Throw when required credentials cannot be minted; renewal failure preserves + * the sandbox and prevents dispatch. Requires the matching Sandbox runtime. + */ + runtimeEnv?: (scope: SandboxScope) => Promise> files: (ctx: SandboxBuildContext) => Promise secrets: (workspaceId: string) => Promise profile: (options: ProfileComposeOptions) => AgentProfile @@ -2068,9 +2061,16 @@ export async function peekWorkspaceSandbox( return { status: 'running', box: match } } -// The shared tail for handing back an existing (reused/resumed/recovered) box: -// materialize deferred profile files, then run the product bootstrap. One -// implementation so the reuse, resume, and recovery paths cannot drift. +async function resolveWorkspaceRuntimeEnv( + shell: SandboxRuntimeConfig, + scope: SandboxScope, +): Promise> { + const env = await shell.runtimeEnv?.(scope) ?? {} + assertEnvWithinLimits(env) + return env +} + +// All retained-box paths renew credentials before the product bootstrap. async function finalizeExistingBox( shell: SandboxRuntimeConfig, client: Sandbox, @@ -2097,6 +2097,10 @@ async function finalizeExistingBox( throw deferredProfileWriteFailed(stage, name, written.error) } const finalBox = written.value + const runtimeEnv = await resolveWorkspaceRuntimeEnv(shell, scope) + if (Object.keys(runtimeEnv).length > 0) { + await finalBox.setRuntimeEnv(runtimeEnv) + } if (shell.bootstrap) { const boot = await shell.bootstrap(finalBox, scope) if (!boot.succeeded) { @@ -2451,11 +2455,13 @@ async function provisionWorkspaceSandbox( connectedIntegrationIds, ...(userId ? { userId } : {}), } - const [secrets, env, files] = await Promise.all([ + const [secrets, creationEnv, runtimeEnv, files] = await Promise.all([ shell.secrets(workspaceId), shell.env(buildCtx), + resolveWorkspaceRuntimeEnv(shell, scope), shell.files(buildCtx), ]) + const env = { ...creationEnv, ...runtimeEnv } const fullProfile = shell.profile({ extraFiles: files, harness }) // When deferring, strip inline files from the create payload and write them // into the box after it reaches running. Keeps the provision body under the From b17f12d1233641f4252996d00d7a4386daa9154a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 14 Sep 2026 01:52:14 -0700 Subject: [PATCH 2/2] docs(sandbox): document runtime credential renewal --- src/runtime/surface-profile.ts | 8 +++--- src/sandbox/index.ts | 6 +++-- src/tools/auth.test.ts | 7 +++-- src/tools/auth.ts | 15 ++++++----- src/tools/mcp.ts | 49 ++++++++++++++++++---------------- 5 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/runtime/surface-profile.ts b/src/runtime/surface-profile.ts index f9e5ea0a..1969c31a 100644 --- a/src/runtime/surface-profile.ts +++ b/src/runtime/surface-profile.ts @@ -27,10 +27,10 @@ * contract (`agent-interface` 0.38.0) the `Authorization` header is a * `secret-ref` naming a box-environment variable, which the sandbox resolves * privately; `build()` supplies that key NAME from server configuration and the - * product writes the value into `SandboxRuntimeConfig.env` at box creation. The - * value must be deterministic for the box's lifetime (an HMAC over the - * workspace id, e.g. `createCapabilityToken` in ../tools), because a freshly - * random per-request mint would never match what the box already carries. + * product writes the value through `SandboxRuntimeConfig.env` at creation or + * refreshes it through `SandboxRuntimeConfig.runtimeEnv` before retained-box + * bootstrap. The value remains workspace-scoped because every member and turn + * shares the box; runtime renewal does not create a per-user secret channel. */ import type { AppToolMcpServer } from '../tools/mcp' diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 22c1e6b5..dc63eb3e 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -575,8 +575,10 @@ export interface BuildAppToolMcpServersOptions { * `BuildHttpMcpServerOptions.tokenEnvKey` in `../tools/mcp`. * * The key must name a variable the box carries — placed by - * {@link SandboxRuntimeConfig.env} at creation, or injected from the platform - * secret store via {@link SandboxRuntimeConfig.secrets}. + * {@link SandboxRuntimeConfig.env} at creation, refreshed by + * {@link SandboxRuntimeConfig.runtimeEnv} before retained-box bootstrap, or + * injected from the platform secret store via + * {@link SandboxRuntimeConfig.secrets}. */ tokenEnvKey: string ctx: AppToolContext diff --git a/src/tools/auth.test.ts b/src/tools/auth.test.ts index ad4bf19b..1fc68006 100644 --- a/src/tools/auth.test.ts +++ b/src/tools/auth.test.ts @@ -30,10 +30,9 @@ describe('authenticateToolRequest — capability subject', () => { }) // The reason this option exists. A token that must live in the BOX - // environment cannot be per-user: that environment is workspace-wide and - // written once at box creation, so a per-user token cannot be delivered — and - // one written there anyway is readable by every member of the workspace's - // box, so it was never per-user in the first place. + // environment cannot be per-user: that environment is workspace-wide; + // `env` writes at creation and `runtimeEnv` refreshes retained boxes, but a + // value written there is readable by every member of the workspace's box. it('verifies against the workspace when the product says the token is workspace-bound', async () => { const verifyToken = vi.fn().mockResolvedValue(true) const result = await authenticateToolRequest(toolRequest(fullHeaders()), { diff --git a/src/tools/auth.ts b/src/tools/auth.ts index 05af7489..46e9d10a 100644 --- a/src/tools/auth.ts +++ b/src/tools/auth.ts @@ -24,13 +24,14 @@ export const DEFAULT_HEADER_NAMES: ToolHeaderNames = { * `'userId'` (default) is right when the product mints a token per user and can * deliver it per turn. * - * `'workspaceId'` is the only workable choice when the token has to survive in - * the BOX ENVIRONMENT. Since agent-interface 0.38 a credential may reach a - * profile only as a reference the sandbox resolves from that environment, and - * the environment is workspace-wide and written once at box creation. A - * per-user token therefore cannot be delivered at all — and a per-user token - * that IS written there was never per-user in any meaningful sense, because - * every member of that workspace's box can read it. + * `'workspaceId'` is the only workable choice when the token lives in the BOX + * ENVIRONMENT. Since agent-interface 0.38 a credential may reach a profile + * only as a reference the sandbox resolves from that environment. `env` writes + * at creation and `runtimeEnv` can refresh retained boxes, but the environment + * remains workspace-wide. A per-user token therefore cannot be delivered as a + * safe box credential — and a per-user token that IS written there was never + * per-user in any meaningful sense, because every member of that workspace's + * box can read it. * * Binding the bearer to the workspace does NOT collapse the identity: the user * header is still required, still server-set, still returned on `ctx`, and is diff --git a/src/tools/mcp.ts b/src/tools/mcp.ts index 7a02dac5..68bd698e 100644 --- a/src/tools/mcp.ts +++ b/src/tools/mcp.ts @@ -23,16 +23,17 @@ * name, and a reference to a key nothing places fails the turn at * materialization rather than running credential-less. The box env is what * `SandboxRuntimeConfig.env` (and the platform secret store via - * `SandboxRuntimeConfig.secrets`) writes at sandbox creation, so the key must - * name a variable one of those places — and it must therefore be a real - * environment-variable name, which this module enforces. + * `SandboxRuntimeConfig.secrets`) writes at sandbox creation. + * `SandboxRuntimeConfig.runtimeEnv` can also supply expiring workspace + * credentials at creation and refresh them on retained boxes before + * bootstrap. The key must name a variable one of those places — and it must + * therefore be a real environment-variable name, which this module enforces. * - * A per-request credential is only referenceable when the value written at box - * creation is byte-identical to the one every later turn would mint (a - * deterministic derivation such as an HMAC over the workspace id). A token - * scoped narrower than the box — per-user, per-document — cannot be referenced - * at all; {@link unresolvableSurfaceCredential} names that blocker instead of - * emitting a reference that resolves to nothing. + * A workspace credential may be renewed through `runtimeEnv`; it still has + * workspace-wide authority because every member and turn shares the box. A + * token scoped narrower than the box — per-user, per-document — cannot be + * referenced safely; {@link unresolvableSurfaceCredential} names that blocker + * instead of emitting a reference that resolves to nothing. */ import { agentProfileMcpServerSchema, @@ -124,10 +125,11 @@ function assertProfileMcpServer(server: T, label: st * Refuse to mount a surface whose credential is scoped narrower than the box. * * A per-user or per-resource capability token is minted per request. The box - * environment is written once at sandbox creation and shared by every turn and - * every member of the workspace, so such a token can neither be placed there - * ahead of time nor referenced from a per-turn profile. Widening the channel to - * a workspace-bound token is not a substitute when the route authenticates the + * environment is shared by every turn and every member of the workspace; + * `env` writes at creation and `runtimeEnv` refreshes workspace-scoped values + * on retained boxes. Neither path provides a per-turn secret channel, so a + * narrower token cannot be referenced safely. Widening the channel to a + * workspace-bound token is not a substitute when the route authenticates the * CALLER: the agent can read its own box env, so it could forge that identity. * * Mounting the surface anyway would emit a plain-string `Authorization` header @@ -140,7 +142,7 @@ export function unresolvableSurfaceCredential(surface: string): never { throw new Error( `The ${surface} MCP surface cannot be mounted: its capability token is scoped to a single ` + 'user and resource, and an AgentProfile may only reference a credential the sandbox can ' + - 'resolve from the box environment, which is workspace-wide and fixed at sandbox creation. ' + + 'resolve from the box environment, which is workspace-wide even when runtimeEnv refreshes it. ' + 'Mounting it needs a per-session secret channel on the sandbox API, or a route that ' + 'authenticates the workspace rather than the caller.', ) @@ -159,12 +161,12 @@ export interface BuildHttpMcpServerOptions { * privately and the profile carries only the name. * * The key MUST name a variable the box actually carries (placed by - * `SandboxRuntimeConfig.env` at creation, or injected from the platform - * secret store via `SandboxRuntimeConfig.secrets`), and the value written - * there must be the token this route will accept for every turn — which in - * practice means a deterministic derivation (e.g. an HMAC over the workspace - * id), not a freshly-random per-request mint. A token scoped narrower than - * the box is not referenceable: see {@link unresolvableSurfaceCredential}. + * `SandboxRuntimeConfig.env` at creation, refreshed by + * `SandboxRuntimeConfig.runtimeEnv` before retained-box bootstrap, or + * injected from the platform secret store via + * `SandboxRuntimeConfig.secrets`). The value must be a workspace-scoped token + * this route accepts for the active box; a per-user or per-resource token is + * not referenceable: see {@link unresolvableSurfaceCredential}. */ tokenEnvKey: string ctx: AppToolContext @@ -225,9 +227,10 @@ export interface ScopedMcpServerEntryOptions { * {@link BuildHttpMcpServerOptions.tokenEnvKey}. * * A per-(user, resource) token cannot satisfy this: the box environment is - * workspace-wide and fixed at sandbox creation. A product whose channel needs - * one calls {@link unresolvableSurfaceCredential} rather than mounting an - * entry that cannot resolve. + * workspace-wide; `env` writes at creation and `runtimeEnv` refreshes + * workspace-scoped values on retained boxes. A product whose channel needs + * a narrower token calls {@link unresolvableSurfaceCredential} rather than + * mounting an entry that cannot resolve safely. */ tokenEnvKey: string /** Override the channel's default tool-server description. */