Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion create-agent-app/template-chat/_package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion create-agent-app/template/_package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions examples/chat-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions src/runtime/surface-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
95 changes: 95 additions & 0 deletions src/sandbox/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
56 changes: 32 additions & 24 deletions src/sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,26 +382,19 @@ export interface SandboxRuntimeConfig {
metadata: (harness: Harness) => Record<string, unknown>
connectedIntegrationIds: (workspaceId: string) => Promise<string[]>
/**
* 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<string, string>`, 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<Record<string, string>>
/**
* 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<Record<string, string>>
files: (ctx: SandboxBuildContext) => Promise<AgentProfileFileMount[]>
secrets: (workspaceId: string) => Promise<string[]>
profile: (options: ProfileComposeOptions) => AgentProfile
Expand Down Expand Up @@ -582,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
Expand Down Expand Up @@ -2068,9 +2063,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<Record<string, string>> {
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,
Expand All @@ -2097,6 +2099,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) {
Expand Down Expand Up @@ -2451,11 +2457,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
Expand Down
7 changes: 3 additions & 4 deletions src/tools/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()), {
Expand Down
15 changes: 8 additions & 7 deletions src/tools/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading