From c9a3a351aef60771d42ba43fa4735a426613270d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 22 Jul 2026 21:43:56 -0700 Subject: [PATCH 01/10] fix(sandbox): Recover cleanly from unavailable sessions Fail the current operation as a safe tool error without replaying it, then lazily reacquire the disposable sandbox on a later operation. Pin SDK work to the acquired session and scope invalidation to its session identity. Refs #966 --- packages/junior/src/chat/agent/sandbox.ts | 103 ++- packages/junior/src/chat/sandbox/README.md | 2 + packages/junior/src/chat/sandbox/sandbox.ts | 52 +- packages/junior/src/chat/sandbox/session.ts | 185 +++-- packages/junior/src/chat/sandbox/workspace.ts | 34 +- .../component/misc/sandbox-executor.test.ts | 725 ++++++++++++++---- 6 files changed, 850 insertions(+), 251 deletions(-) diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 566275800..5c1b2e76f 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -6,31 +6,52 @@ * and rebinding when the executor's sandbox identity changes mid-run. */ import { logInfo, type LogContext } from "@/chat/logging"; -import type { SandboxExecutor } from "@/chat/sandbox/sandbox"; +import { + createSandboxUnavailableToolError, + type SandboxExecutor, +} from "@/chat/sandbox/sandbox"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; +type LazySandboxExecutor = Pick< + SandboxExecutor, + | "createSandbox" + | "getSandboxId" + | "getSandboxEgressId" + | "invalidateIfUnavailable" +>; + /** Create a lazy-boot workspace port bound to the run's sandbox executor. */ export function createLazySandboxWorkspace(args: { - executor: SandboxExecutor; + executor: LazySandboxExecutor; spanContext: LogContext; }): SandboxWorkspace { - let sandboxPromise: Promise | undefined; - let sandboxPromiseId: string | undefined; - const clearSandboxPromise = (): void => { + interface SandboxBinding { + workspace: SandboxWorkspace; + sandboxId: string; + sandboxEgressId: string | undefined; + } + + let sandboxPromise: Promise | undefined; + let sandboxBinding: SandboxBinding | undefined; + const clearSandboxPromise = (expected?: Promise): void => { + if (expected && sandboxPromise !== expected) { + return; + } sandboxPromise = undefined; - sandboxPromiseId = undefined; + sandboxBinding = undefined; }; const getSandbox = (reason: { trigger: string; path?: string; cmd?: string; cwd?: string; - }): Promise => { + }): Promise => { const currentSandboxId = args.executor.getSandboxId(); + const currentSandboxEgressId = args.executor.getSandboxEgressId(); if ( - sandboxPromise && - sandboxPromiseId && - currentSandboxId !== sandboxPromiseId + sandboxBinding && + (currentSandboxId !== sandboxBinding.sandboxId || + currentSandboxEgressId !== sandboxBinding.sandboxEgressId) ) { clearSandboxPromise(); } @@ -47,35 +68,71 @@ export function createLazySandboxWorkspace(args: { }, "Lazy sandbox boot requested", ); - sandboxPromise = args.executor + const nextSandboxPromise = args.executor .createSandbox() - .then((sandbox) => { - sandboxPromiseId = sandbox.sandboxId; - return sandbox; + .then((workspace) => { + const binding = { + workspace, + sandboxId: workspace.sandboxId, + sandboxEgressId: workspace.sandboxEgressId, + }; + if (sandboxPromise === nextSandboxPromise) { + sandboxBinding = binding; + } + return binding; }) .catch((error) => { - clearSandboxPromise(); + clearSandboxPromise(nextSandboxPromise); throw error; }); + sandboxPromise = nextSandboxPromise; } return sandboxPromise; }; + /** Fail the current operation and discard the pinned workspace after lifecycle loss. */ + const runWithSandbox = async ( + reason: { + trigger: string; + path?: string; + cmd?: string; + cwd?: string; + }, + operation: (sandbox: SandboxWorkspace) => Promise, + ): Promise => { + const activeSandboxPromise = getSandbox(reason); + let binding: SandboxBinding | undefined; + try { + binding = await activeSandboxPromise; + return await operation(binding.workspace); + } catch (error) { + if ( + args.executor.invalidateIfUnavailable(error, binding?.sandboxEgressId) + ) { + clearSandboxPromise(activeSandboxPromise); + throw createSandboxUnavailableToolError(reason.trigger, error); + } + throw error; + } + }; + return { readFileToBuffer: async (input) => - ( - await getSandbox({ + await runWithSandbox( + { trigger: "workspace.readFileToBuffer", path: input.path, - }) - ).readFileToBuffer(input), + }, + async (sandbox) => await sandbox.readFileToBuffer(input), + ), runCommand: async (input) => - ( - await getSandbox({ + await runWithSandbox( + { trigger: "workspace.runCommand", cmd: input.cmd, cwd: input.cwd, - }) - ).runCommand(input), + }, + async (sandbox) => await sandbox.runCommand(input), + ), }; } diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 08d4fc120..c773ca161 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -10,6 +10,8 @@ traffic through verified host egress. conversation or run. - Persist only the sandbox identifier and durable artifact metadata needed to resume work; do not treat the sandbox filesystem as product storage. +- An unavailable session fails the current operation without replay, retains + its sandbox identifier, and reacquires a session only on a later operation. - Commands are non-interactive and bounded by runtime deadlines. - Generated files become shareable only after artifact validation and destination-aware delivery planning. diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index bbc7897fa..db29cd2b0 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -86,15 +86,29 @@ export interface SandboxExecutor { configureSkills(skills: SkillMetadata[]): void; configureReferenceFiles(files: string[]): void; getSandboxId(): string | undefined; + getSandboxEgressId(): string | undefined; getDependencyProfileHash(): string | undefined; canExecute(toolName: string): boolean; createSandbox(): Promise; + /** Drop unavailable live state so a later operation can reacquire the sandbox. */ + invalidateIfUnavailable(error: unknown, sandboxEgressId?: string): boolean; execute( params: SandboxExecutionInput, ): Promise>; dispose(): Promise; } +/** Create the safe expected tool error for an unavailable sandbox operation. */ +export function createSandboxUnavailableToolError( + operation: string, + cause: unknown, +): ToolInputError { + return new ToolInputError( + `The temporary sandbox became unavailable during ${operation}, so the operation did not complete reliably. The next sandbox operation will use a fresh session. It may have produced side effects; retry only if it is safe.`, + { cause }, + ); +} + const SANDBOX_TOOL_NAMES = new Set([ "bash", "readFile", @@ -117,20 +131,6 @@ function parseEnv(raw: unknown): Record | undefined { ); } -function sandboxStreamInterruptedResult(toolName: string) { - return makeStructuredToolResult({ - ok: false, - status: "error", - target: toolName, - error: { - kind: "stream_interrupted", - message: `Sandbox command stream was interrupted during ${toolName}. The operation did not complete reliably. It may have produced side effects; inspect the workspace or retry only if it is safe.`, - retryable: true, - }, - tool: toolName, - }); -} - function bashToolResult(params: BashCustomCommandResult) { return makeStructuredToolResult({ ok: params.ok, @@ -665,10 +665,13 @@ export function createSandboxExecutor(options?: { return { result: bashToolResult(custom.result) as T }; } } - return await executeBashTool(rawInput, bashCommand, params.signal); } try { + if (bashCommand !== undefined) { + return await executeBashTool(rawInput, bashCommand, params.signal); + } + if (params.toolName === "readFile") { return await executeReadFileTool(rawInput); } @@ -693,10 +696,17 @@ export function createSandboxExecutor(options?: { return await executeWriteFileTool(rawInput); } } catch (error) { - if (!isSandboxCommandStreamInterruptedError(error)) { - throw error; + if (sessionManager.invalidateIfUnavailable(error)) { + // Do not replay an operation that may already have produced side effects. + throw createSandboxUnavailableToolError(params.toolName, error); } - return { result: sandboxStreamInterruptedResult(params.toolName) as T }; + if (isSandboxCommandStreamInterruptedError(error)) { + throw new ToolInputError( + `The sandbox command stream was interrupted during ${params.toolName}, so the operation did not complete reliably. It may have produced side effects; inspect the workspace or retry only if it is safe.`, + { cause: error }, + ); + } + throw error; } throw new Error(`unsupported sandbox tool: ${params.toolName}`); @@ -714,6 +724,9 @@ export function createSandboxExecutor(options?: { getSandboxId() { return sessionManager.getSandboxId(); }, + getSandboxEgressId() { + return sessionManager.getSandboxEgressId(); + }, getDependencyProfileHash() { return sessionManager.getDependencyProfileHash(); }, @@ -723,6 +736,9 @@ export function createSandboxExecutor(options?: { async createSandbox() { return await sessionManager.createSandbox(); }, + invalidateIfUnavailable(error, sandboxEgressId) { + return sessionManager.invalidateIfUnavailable(error, sandboxEgressId); + }, execute, async dispose() { await sessionManager.dispose(); diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 825bf007c..2e675af8c 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -3,7 +3,6 @@ import { Sandbox, type NetworkPolicy } from "@vercel/sandbox"; import { createBashTool } from "bash-tool"; import { logInfo, - logWarn, setSpanAttributes, withSpan, type LogContext, @@ -12,7 +11,6 @@ import { import { getVercelSandboxCredentials } from "@/chat/sandbox/credentials"; import { isAlreadyExistsError, - isSandboxCommandStreamInterruptedError, isSandboxUnavailableError, isSnapshottingError, wrapSandboxSetupError, @@ -73,6 +71,48 @@ interface SandboxToolExecutors { fs: SandboxFileSystem; } +/** Preserve which pinned session produced a lifecycle failure. */ +class SandboxSessionUnavailableError extends Error { + readonly sandboxEgressId: string; + + constructor(sandboxEgressId: string, cause: unknown) { + super("sandbox session unavailable", { cause }); + this.name = "SandboxSessionUnavailableError"; + this.sandboxEgressId = sandboxEgressId; + } +} + +/** Read the originating session identity from a wrapped unavailable error. */ +function getUnavailableSandboxEgressId(error: unknown): string | undefined { + const seen = new Set(); + let current: unknown = error; + while (current && !seen.has(current)) { + if (current instanceof SandboxSessionUnavailableError) { + return current.sandboxEgressId; + } + seen.add(current); + current = + typeof current === "object" + ? (current as { cause?: unknown }).cause + : undefined; + } + return undefined; +} + +async function bindUnavailableErrorToSandbox( + sandboxEgressId: string, + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + if (!isSandboxUnavailableError(error)) { + throw error; + } + throw new SandboxSessionUnavailableError(sandboxEgressId, error); + } +} + function createBashToolSandboxAdapter(sandbox: SandboxInstance) { return { async executeCommand(command: string) { @@ -116,6 +156,8 @@ interface SandboxSessionManager { getDependencyProfileHash(): string | undefined; createSandbox(): Promise; ensureToolExecutors(): Promise; + /** Drop unavailable live state while retaining the hint; true means the current operation must fail. */ + invalidateIfUnavailable(error: unknown, sandboxEgressId?: string): boolean; refreshNetworkPolicy(traceHeaders?: TracePropagationHeaders): Promise; dispose(): Promise; } @@ -142,23 +184,6 @@ function parseKeepAliveMs(): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } -function getCommandStreamInterruptedResult(): { - stdout: string; - stderr: string; - exitCode: number; - stdoutTruncated: boolean; - stderrTruncated: boolean; -} { - return { - stdout: "", - stderr: - "Command stream ended before the command finished. The command may still have produced side effects; inspect the workspace or rerun only if it is safe.", - exitCode: 125, - stdoutTruncated: false, - stderrTruncated: false, - }; -} - function getCommandAbortedResult(): { stdout: string; stderr: string; @@ -196,6 +221,7 @@ export function createSandboxSessionManager(options?: { }): SandboxSessionManager { let sandbox: SandboxInstance | null = null; let sandboxIdHint = options?.sandboxId; + let sandboxDependencyProfileHashHint = options?.sandboxDependencyProfileHash; let availableSkills: SkillMetadata[] = []; let availableReferenceFiles: string[] = []; let toolExecutors: SandboxToolExecutors | undefined; @@ -218,9 +244,9 @@ export function createSandboxSessionManager(options?: { callback: () => Promise, ): Promise => withSpan(name, op, traceContext, callback, attributes); - const clearSession = (): void => { + /** Drop unavailable live state while retaining the persisted hint for lazy reacquisition. */ + const invalidateSession = (): void => { sandbox = null; - sandboxIdHint = undefined; toolExecutors = undefined; loadingToolExecutors = undefined; appliedNetworkPolicyKey = undefined; @@ -238,15 +264,20 @@ export function createSandboxSessionManager(options?: { return options?.createNetworkPolicy?.(sandboxName); }; + const retainSandboxHint = (nextSandbox: SandboxInstance): void => { + sandboxIdHint = nextSandbox.sandboxId; + sandboxDependencyProfileHashHint = dependencyProfileHash; + }; + const rememberSandbox = async ( nextSandbox: SandboxInstance, ): Promise => { sandbox = nextSandbox; - sandboxIdHint = nextSandbox.sandboxId; + retainSandboxHint(nextSandbox); toolExecutors = undefined; loadingToolExecutors = undefined; const acquired = { - sandboxId: sandboxIdHint, + sandboxId: nextSandbox.sandboxId, ...(dependencyProfileHash ? { sandboxDependencyProfileHash: dependencyProfileHash } : {}), @@ -259,6 +290,10 @@ export function createSandboxSessionManager(options?: { throw wrapSandboxSetupError(error); }; + const failRestore = (error: unknown): never => { + throw new Error("sandbox restore failed", { cause: error }); + }; + const syncSkills = async (targetSandbox: SandboxInstance): Promise => { await syncSkillsToSandbox({ sandbox: targetSandbox, @@ -332,27 +367,6 @@ export function createSandboxSessionManager(options?: { ); }; - const recreateUnavailableSandbox = async ( - source: "memory" | "id_hint", - ): Promise => { - setSpanAttributes({ - "app.sandbox.recovery.attempted": true, - "app.sandbox.recovery.source": source, - }); - logWarn( - "sandbox_unavailable_recreating", - traceContext, - { "app.sandbox.recovery.source": source }, - "Sandbox unavailable; recreating", - ); - clearSession(); - const replacement = await createFreshSandbox(); - setSpanAttributes({ - "app.sandbox.recovery.succeeded": true, - }); - return replacement; - }; - const createSandboxFromSnapshot = async ( snapshotId: string, sandboxCredentials: SandboxCredentials | undefined, @@ -504,6 +518,9 @@ export function createSandboxSessionManager(options?: { await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { + if (isSandboxUnavailableError(error)) { + retainSandboxHint(createdSandbox); + } return failSetup(error); } @@ -514,7 +531,7 @@ export function createSandboxSessionManager(options?: { if ( sandbox || !sandboxIdHint || - dependencyProfileHash === options?.sandboxDependencyProfileHash + dependencyProfileHash === sandboxDependencyProfileHashHint ) { return; } @@ -522,10 +539,10 @@ export function createSandboxSessionManager(options?: { setSpanAttributes({ "app.sandbox.reused": false, "app.sandbox.recreate.reason": "dependency_profile_mismatch", - ...(options?.sandboxDependencyProfileHash + ...(sandboxDependencyProfileHashHint ? { "app.sandbox.previous_profile_hash": - options.sandboxDependencyProfileHash, + sandboxDependencyProfileHashHint, } : {}), ...(dependencyProfileHash @@ -536,10 +553,10 @@ export function createSandboxSessionManager(options?: { "sandbox_hint_discarded_profile_mismatch", traceContext, { - ...(options?.sandboxDependencyProfileHash + ...(sandboxDependencyProfileHashHint ? { "app.sandbox.previous_profile_hash": - options.sandboxDependencyProfileHash, + sandboxDependencyProfileHashHint, } : {}), ...(dependencyProfileHash @@ -549,6 +566,7 @@ export function createSandboxSessionManager(options?: { "Dependency profile changed; discarding sandbox hint and creating fresh session", ); sandboxIdHint = undefined; + sandboxDependencyProfileHashHint = undefined; }; const tryReuseCachedSandbox = async (): Promise => { @@ -564,7 +582,8 @@ export function createSandboxSessionManager(options?: { return cachedSandbox; } catch (error) { if (isSandboxUnavailableError(error)) { - return await recreateUnavailableSandbox("memory"); + invalidateSession(); + throw error; } return failSetup(error); } @@ -595,17 +614,11 @@ export function createSandboxSessionManager(options?: { ), ); } catch (error) { - logWarn( - "sandbox_restore_hint_failed", - traceContext, - { - "app.sandbox.hint_id": sandboxIdHint, - "app.sandbox.error": - error instanceof Error ? error.message : String(error), - }, - "Failed to restore sandbox from hint; will create fresh session", - ); - return null; + if (isSandboxUnavailableError(error)) { + invalidateSession(); + throw error; + } + return failRestore(error); } try { @@ -614,7 +627,8 @@ export function createSandboxSessionManager(options?: { return await rememberSandbox(hintedSandbox); } catch (error) { if (isSandboxUnavailableError(error)) { - return await recreateUnavailableSandbox("id_hint"); + invalidateSession(); + throw error; } return failSetup(error); } @@ -807,9 +821,6 @@ export function createSandboxSessionManager(options?: { if (aborted || input.signal?.aborted) { return getCommandAbortedResult(); } - if (isSandboxCommandStreamInterruptedError(error)) { - return getCommandStreamInterruptedResult(); - } throw error; } finally { if (timeoutId) { @@ -852,8 +863,32 @@ export function createSandboxSessionManager(options?: { const nextToolExecutors = buildToolExecutors(activeSandbox).then( (executors) => { - toolExecutors = executors; - return executors; + const bind = (operation: () => Promise): Promise => + bindUnavailableErrorToSandbox( + activeSandbox.sandboxEgressId, + operation, + ); + const boundExecutors: SandboxToolExecutors = { + bash: async (input) => await bind(async () => executors.bash(input)), + readFile: async (input) => + await bind(async () => executors.readFile(input)), + writeFile: async (input) => + await bind(async () => executors.writeFile(input)), + fs: { + readFile: async (filePath, options) => + await bind(async () => executors.fs.readFile(filePath, options)), + writeFile: async (filePath, content, options) => + await bind(async () => + executors.fs.writeFile(filePath, content, options), + ), + readdir: async (filePath) => + await bind(async () => executors.fs.readdir(filePath)), + stat: async (filePath) => + await bind(async () => executors.fs.stat(filePath)), + }, + }; + toolExecutors = boundExecutors; + return boundExecutors; }, ); loadingToolExecutors = nextToolExecutors; @@ -888,6 +923,22 @@ export function createSandboxSessionManager(options?: { async ensureToolExecutors() { return await loadToolExecutors(await ensureReadySandbox()); }, + invalidateIfUnavailable(error, sandboxEgressId) { + if (!isSandboxUnavailableError(error)) { + return false; + } + const unavailableEgressId = + getUnavailableSandboxEgressId(error) ?? sandboxEgressId; + if ( + unavailableEgressId && + sandbox && + sandbox.sandboxEgressId !== unavailableEgressId + ) { + return true; + } + invalidateSession(); + return true; + }, async refreshNetworkPolicy(traceHeaders) { const activeSandbox = sandbox; if (!activeSandbox) { diff --git a/packages/junior/src/chat/sandbox/workspace.ts b/packages/junior/src/chat/sandbox/workspace.ts index d262933ef..1dbdb9f73 100644 --- a/packages/junior/src/chat/sandbox/workspace.ts +++ b/packages/junior/src/chat/sandbox/workspace.ts @@ -1,4 +1,8 @@ -import type { NetworkPolicy, Sandbox as VercelSandbox } from "@vercel/sandbox"; +import { + FileSystem, + type NetworkPolicy, + type Sandbox as VercelSandbox, +} from "@vercel/sandbox"; export interface SandboxCommandResult { exitCode: number; @@ -61,38 +65,38 @@ export interface SandboxInstance extends SandboxWorkspace { /** Adapt the Vercel SDK object once so the rest of Junior sees one sandbox contract. */ export function createSandboxInstance(sandbox: VercelSandbox): SandboxInstance { + // Pin operations to the acquired VM session. The Sandbox convenience methods + // may resume and replay an operation after a lifecycle failure. + const session = sandbox.currentSession(); + return { sandboxId: sandbox.name, - get sandboxEgressId() { - // Vercel Sandbox v2 names the persistent sandbox separately from the - // running VM session identified by firewall proxy OIDC tokens. - return sandbox.currentSession().sessionId; - }, - fs: sandbox.fs as SandboxFileSystem, + sandboxEgressId: session.sessionId, + fs: new FileSystem(session) as SandboxFileSystem, extendTimeout(duration) { - return sandbox.extendTimeout(duration); + return session.extendTimeout(duration); }, mkDir(path) { - return sandbox.mkDir(path); + return session.mkDir(path); }, readFileToBuffer(input) { - return sandbox.readFileToBuffer(input); + return session.readFileToBuffer(input); }, runCommand(input) { - return sandbox.runCommand(input); + return session.runCommand(input); }, async snapshot() { - const snapshot = await sandbox.snapshot(); + const snapshot = await session.snapshot(); return { snapshotId: snapshot.snapshotId }; }, stop() { - return sandbox.stop(); + return session.stop(); }, update(params) { - return sandbox.update(params); + return session.update(params); }, writeFiles(files) { - return sandbox.writeFiles(files); + return session.writeFiles(files); }, }; } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 5b18737fd..c0f3bc2ed 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -15,6 +15,45 @@ const { sandboxGetMock, sandboxCreateMock } = vi.hoisted(() => ({ })); vi.mock("@vercel/sandbox", () => ({ + FileSystem: class { + private readonly fs: { + readFile( + filePath: string, + options: { encoding: BufferEncoding }, + ): Promise; + writeFile( + filePath: string, + content: string, + options?: { encoding?: BufferEncoding }, + ): Promise; + readdir(filePath: string): Promise; + stat(filePath: string): Promise<{ isDirectory(): boolean }>; + }; + + constructor(session: { fs: MockSandbox["fs"] }) { + this.fs = session.fs as unknown as typeof this.fs; + } + + readFile(filePath: string, options: { encoding: BufferEncoding }) { + return this.fs.readFile(filePath, options); + } + + writeFile( + filePath: string, + content: string, + options?: { encoding?: BufferEncoding }, + ) { + return this.fs.writeFile(filePath, content, options); + } + + readdir(filePath: string) { + return this.fs.readdir(filePath); + } + + stat(filePath: string) { + return this.fs.stat(filePath); + } + }, Sandbox: { get: sandboxGetMock, create: sandboxCreateMock, @@ -101,12 +140,27 @@ import { setSandboxEgressAuthRequiredSignal, setSandboxEgressPermissionDeniedSignal, } from "@/chat/sandbox/egress/session"; +import { createLazySandboxWorkspace } from "@/chat/agent/sandbox"; import { createSandboxSessionManager } from "@/chat/sandbox/session"; +import { createSandboxInstance } from "@/chat/sandbox/workspace"; import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { createBashTool } from "bash-tool"; interface MockSandbox { name: string; + session: { + sessionId: string; + fs: MockSandbox["fs"]; + mkDir: MockSandbox["mkDir"]; + writeFiles: MockSandbox["writeFiles"]; + readFileToBuffer: MockSandbox["readFileToBuffer"]; + runCommand: MockSandbox["runCommand"]; + stop: MockSandbox["stop"]; + extendTimeout: MockSandbox["extendTimeout"]; + snapshot: MockSandbox["snapshot"]; + update: MockSandbox["update"]; + }; currentSession: ReturnType; fs: { readFile: ReturnType; @@ -131,35 +185,58 @@ function makeSandbox( writeFilesError?: unknown; } = {}, ): MockSandbox { + const fs = { + readFile: vi.fn(async () => ""), + writeFile: vi.fn(async () => {}), + readdir: vi.fn(async () => []), + stat: vi.fn(async () => ({ isDirectory: () => false })), + }; + const mkDir = vi.fn(async () => { + if (options.mkDirError) { + throw options.mkDirError; + } + }); + const writeFiles = vi.fn(async () => { + if (options.writeFilesError) { + throw options.writeFilesError; + } + }); + const readFileToBuffer = vi.fn(async () => Buffer.from("")); + const runCommand = vi.fn(async () => ({ + exitCode: 0, + stdout: async () => "", + stderr: async () => "", + })); + const stop = vi.fn(async () => {}); + const extendTimeout = vi.fn(async () => {}); + const snapshot = vi.fn(async () => ({ snapshotId: "snap_test" })); + const update = vi.fn(async () => {}); + const session = { + sessionId: `${name}_session`, + fs, + mkDir, + writeFiles, + readFileToBuffer, + runCommand, + stop, + extendTimeout, + snapshot, + update, + }; + return { name, - currentSession: vi.fn(() => ({ sessionId: `${name}_session` })), - fs: { - readFile: vi.fn(async () => ""), - writeFile: vi.fn(async () => {}), - readdir: vi.fn(async () => []), - stat: vi.fn(async () => ({ isDirectory: () => false })), - }, - mkDir: vi.fn(async () => { - if (options.mkDirError) { - throw options.mkDirError; - } - }), - writeFiles: vi.fn(async () => { - if (options.writeFilesError) { - throw options.writeFilesError; - } - }), - readFileToBuffer: vi.fn(async () => Buffer.from("")), - runCommand: vi.fn(async () => ({ - exitCode: 0, - stdout: async () => "", - stderr: async () => "", - })), - stop: vi.fn(async () => {}), - extendTimeout: vi.fn(async () => {}), - snapshot: vi.fn(async () => ({ snapshotId: "snap_test" })), - update: vi.fn(async () => {}), + session, + currentSession: vi.fn(() => session), + fs, + mkDir, + writeFiles, + readFileToBuffer, + runCommand, + stop, + extendTimeout, + snapshot, + update, }; } @@ -214,6 +291,13 @@ function createStreamInterruptedError(): Error { }); } +function createClosedStreamError(): Error { + return Object.assign( + new Error("Sandbox stream was closed and is not accepting commands."), + { name: "StreamError" }, + ); +} + async function expectWorkspaceToDelegate( workspace: SandboxInstance, sandbox: MockSandbox, @@ -278,7 +362,196 @@ describe("createSandboxExecutor", () => { delete process.env.JUNIOR_SECRET; }); - it("recreates a sandbox when sandboxId hint points to a stopped sandbox", async () => { + it("pins workspace commands to the acquired session without SDK replay", async () => { + const sandbox = makeSandbox("sbx_pinned_session"); + const resumingRunCommand = vi.fn(); + const workspace = createSandboxInstance({ + ...sandbox, + runCommand: resumingRunCommand, + } as never); + + await workspace.runCommand({ cmd: "gh", args: ["pr", "create"] }); + + expect(sandbox.session.runCommand).toHaveBeenCalledTimes(1); + expect(resumingRunCommand).not.toHaveBeenCalled(); + }); + + it("rebinds the lazy workspace after an unavailable session", async () => { + const unavailable = createClosedStreamError(); + const firstWorkspace = { + sandboxId: "sbx_lazy_recovery", + sandboxEgressId: "session_lazy_recovery", + readFileToBuffer: vi.fn(async () => { + throw unavailable; + }), + runCommand: vi.fn(), + }; + const recoveredWorkspace = { + sandboxId: "sbx_lazy_recovery", + sandboxEgressId: "session_lazy_recovered", + readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), + runCommand: vi.fn(), + }; + const createSandbox = vi + .fn() + .mockResolvedValueOnce(firstWorkspace) + .mockResolvedValueOnce(recoveredWorkspace); + const invalidateIfUnavailable = vi.fn(() => true); + const workspace = createLazySandboxWorkspace({ + executor: { + createSandbox, + getSandboxId: () => "sbx_lazy_recovery", + getSandboxEgressId: () => undefined, + invalidateIfUnavailable, + } as never, + spanContext: {}, + }); + + const error = await workspace + .readFileToBuffer({ path: "/tmp/result.txt" }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ToolInputError); + expect(error).toMatchObject({ cause: unavailable }); + await expect( + workspace.readFileToBuffer({ path: "/tmp/result.txt" }), + ).resolves.toEqual(Buffer.from("recovered")); + + expect(invalidateIfUnavailable).toHaveBeenCalledWith( + unavailable, + "session_lazy_recovery", + ); + expect(createSandbox).toHaveBeenCalledTimes(2); + }); + + it("rebinds the lazy workspace after executor-side invalidation", async () => { + let sessionId: string | undefined; + const firstWorkspace = { + sandboxId: "sbx_lazy_external", + sandboxEgressId: "session_1", + readFileToBuffer: vi.fn(async () => Buffer.from("first")), + runCommand: vi.fn(), + }; + const recoveredWorkspace = { + sandboxId: "sbx_lazy_external", + sandboxEgressId: "session_2", + readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), + runCommand: vi.fn(), + }; + const createSandbox = vi + .fn() + .mockImplementationOnce(async () => { + sessionId = "session_1"; + return firstWorkspace; + }) + .mockImplementationOnce(async () => { + sessionId = "session_2"; + return recoveredWorkspace; + }); + const workspace = createLazySandboxWorkspace({ + executor: { + createSandbox, + getSandboxId: () => "sbx_lazy_external", + getSandboxEgressId: () => sessionId, + invalidateIfUnavailable: vi.fn(() => false), + } as never, + spanContext: {}, + }); + + await expect( + workspace.readFileToBuffer({ path: "/tmp/result.txt" }), + ).resolves.toEqual(Buffer.from("first")); + + sessionId = undefined; + + await expect( + workspace.readFileToBuffer({ path: "/tmp/result.txt" }), + ).resolves.toEqual(Buffer.from("recovered")); + expect(createSandbox).toHaveBeenCalledTimes(2); + }); + + it("does not let a late lazy-workspace failure discard the recovered workspace", async () => { + const unavailable = createClosedStreamError(); + let sessionId: string | undefined; + let rejectStaleOperation: () => void = () => {}; + let markStaleOperationStarted: () => void = () => {}; + const staleOperationStarted = new Promise((resolve) => { + markStaleOperationStarted = resolve; + }); + const staleOperation = new Promise((_resolve, reject) => { + rejectStaleOperation = () => reject(unavailable); + }); + const staleWorkspace = { + sandboxId: "sbx_lazy_parallel", + sandboxEgressId: "session_1", + readFileToBuffer: vi + .fn() + .mockImplementationOnce(async () => { + markStaleOperationStarted(); + return await staleOperation; + }) + .mockRejectedValueOnce(unavailable), + runCommand: vi.fn(), + }; + const recoveredWorkspace = { + sandboxId: "sbx_lazy_parallel", + sandboxEgressId: "session_2", + readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), + runCommand: vi.fn(), + }; + const createSandbox = vi + .fn() + .mockImplementationOnce(async () => { + sessionId = "session_1"; + return staleWorkspace; + }) + .mockImplementationOnce(async () => { + sessionId = "session_2"; + return recoveredWorkspace; + }); + const invalidateIfUnavailable = vi.fn( + (_error: unknown, unavailableSessionId?: string) => { + if (sessionId === unavailableSessionId) { + sessionId = undefined; + } + return true; + }, + ); + const workspace = createLazySandboxWorkspace({ + executor: { + createSandbox, + getSandboxId: () => "sbx_lazy_parallel", + getSandboxEgressId: () => sessionId, + invalidateIfUnavailable, + } as never, + spanContext: {}, + }); + + const lateFailure = workspace.readFileToBuffer({ + path: "/tmp/stale.txt", + }); + await staleOperationStarted; + await expect( + workspace.readFileToBuffer({ path: "/tmp/closed.txt" }), + ).rejects.toBeInstanceOf(ToolInputError); + await expect( + workspace.readFileToBuffer({ path: "/tmp/recovered.txt" }), + ).resolves.toEqual(Buffer.from("recovered")); + + rejectStaleOperation(); + await expect(lateFailure).rejects.toBeInstanceOf(ToolInputError); + await expect( + workspace.readFileToBuffer({ path: "/tmp/still-recovered.txt" }), + ).resolves.toEqual(Buffer.from("recovered")); + + expect(createSandbox).toHaveBeenCalledTimes(2); + expect(invalidateIfUnavailable).toHaveBeenNthCalledWith( + 2, + unavailable, + "session_1", + ); + }); + + it("fails a stopped hinted session and reacquires it on a later call", async () => { const stoppedSandbox = makeSandbox("sbx_stopped", { mkDirError: createApiError( 410, @@ -287,25 +560,56 @@ describe("createSandboxExecutor", () => { "Sandbox has stopped execution and is no longer available", ), }); - const freshSandbox = makeSandbox("sbx_fresh"); + const recoveredSandbox = makeSandbox("sbx_stopped"); - sandboxGetMock.mockResolvedValue(stoppedSandbox); - sandboxCreateMock.mockResolvedValue(freshSandbox); + sandboxGetMock + .mockResolvedValueOnce(stoppedSandbox) + .mockResolvedValueOnce(recoveredSandbox); const executor = createSandboxExecutor({ sandboxId: "sbx_stopped" }); executor.configureSkills([]); + await expect(executor.createSandbox()).rejects.toThrow("sandbox_stopped"); const sandbox = await executor.createSandbox(); - await expectWorkspaceToDelegate(sandbox, freshSandbox); + await expectWorkspaceToDelegate(sandbox, recoveredSandbox); expect(sandboxGetMock).toHaveBeenCalledWith({ name: "sbx_stopped", resume: true, }); - expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledTimes(2); + expect(sandboxCreateMock).not.toHaveBeenCalled(); expect(stoppedSandbox.mkDir).toHaveBeenCalled(); - expect(freshSandbox.mkDir).toHaveBeenCalled(); - expect(executor.getSandboxId()).toBe("sbx_fresh"); + expect(recoveredSandbox.mkDir).toHaveBeenCalled(); + expect(executor.getSandboxId()).toBe("sbx_stopped"); + }); + + it("retains a fresh sandbox hint when its setup session becomes unavailable", async () => { + const stoppedSandbox = makeSandbox("sbx_fresh_stopped", { + mkDirError: createApiError( + 410, + "Gone", + "sandbox_stopped", + "Sandbox has stopped execution and is no longer available", + ), + }); + const recoveredSandbox = makeSandbox("sbx_fresh_stopped"); + getRuntimeDependencyProfileHashMock.mockReturnValue("profile-v1"); + sandboxCreateMock.mockResolvedValueOnce(stoppedSandbox); + sandboxGetMock.mockResolvedValueOnce(recoveredSandbox); + + const executor = createSandboxExecutor(); + executor.configureSkills([]); + + await expect(executor.createSandbox()).rejects.toThrow("sandbox_stopped"); + const sandbox = await executor.createSandbox(); + + await expectWorkspaceToDelegate(sandbox, recoveredSandbox); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledWith({ + name: "sbx_fresh_stopped", + resume: true, + }); }); it("reports acquired sandbox metadata immediately after fresh sandbox boot", async () => { @@ -483,7 +787,7 @@ describe("createSandboxExecutor", () => { expect(createNetworkPolicy).toHaveBeenCalledTimes(2); }); - it("refreshes changed network policy when reusing a cached sandbox", async () => { + it("keeps network policy tied to the acquired session", async () => { const sandbox = makeSandbox("sbx_cached_policy"); sandboxCreateMock.mockResolvedValue(sandbox); let providerDomain = "api.first.example"; @@ -523,25 +827,12 @@ describe("createSandboxExecutor", () => { }); await manager.createSandbox(); - expect(sandbox.update).toHaveBeenCalledTimes(2); - expect(sandbox.update).toHaveBeenLastCalledWith({ - networkPolicy: { - allow: { - "*": [], - "api.first.example": [ - { - forwardURL: - "https://junior.example.com/api/internal/sandbox-egress/sbx_cached_policy_resumed_session", - }, - ], - }, - }, - }); + expect(sandbox.update).toHaveBeenCalledTimes(1); providerDomain = "api.second.example"; await manager.createSandbox(); - expect(sandbox.update).toHaveBeenCalledTimes(3); + expect(sandbox.update).toHaveBeenCalledTimes(2); expect(sandbox.update).toHaveBeenLastCalledWith({ networkPolicy: { allow: { @@ -549,7 +840,7 @@ describe("createSandboxExecutor", () => { "api.second.example": [ { forwardURL: - "https://junior.example.com/api/internal/sandbox-egress/sbx_cached_policy_resumed_session", + "https://junior.example.com/api/internal/sandbox-egress/sbx_cached_policy_session", }, ], }, @@ -562,31 +853,16 @@ describe("createSandboxExecutor", () => { process.env.VERCEL_TEAM_ID = "team_123"; process.env.VERCEL_PROJECT_ID = "prj_123"; - const stoppedSandbox = makeSandbox("sbx_stopped", { - mkDirError: createApiError( - 410, - "Gone", - "sandbox_stopped", - "Sandbox has stopped execution and is no longer available", - ), - }); const freshSandbox = makeSandbox("sbx_fresh"); - sandboxGetMock.mockResolvedValue(stoppedSandbox); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor({ sandboxId: "sbx_stopped" }); + const executor = createSandboxExecutor(); executor.configureSkills([]); await executor.createSandbox(); - expect(sandboxGetMock).toHaveBeenCalledWith({ - name: "sbx_stopped", - resume: true, - token: "sandbox-token", - teamId: "team_123", - projectId: "prj_123", - }); + expect(sandboxGetMock).not.toHaveBeenCalled(); expect(sandboxCreateMock).toHaveBeenCalledWith({ timeout: 1000 * 60 * 30, runtime: "node22", @@ -635,6 +911,26 @@ describe("createSandboxExecutor", () => { expect(sandboxCreateMock).not.toHaveBeenCalled(); }); + it("does not replace a hinted sandbox after an unrelated restore failure", async () => { + sandboxGetMock.mockRejectedValueOnce( + createApiError( + 500, + "Internal Server Error", + "sandbox_api_error", + "Sandbox API failed", + ), + ); + + const executor = createSandboxExecutor({ sandboxId: "sbx_restore_error" }); + executor.configureSkills([]); + + const error = await executor.createSandbox().catch((caught) => caught); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ message: "sandbox restore failed" }); + expect(sandboxCreateMock).not.toHaveBeenCalled(); + }); + it("defers to SDK OIDC resolution when VERCEL_OIDC_TOKEN is set without explicit credentials", async () => { process.env.VERCEL_OIDC_TOKEN = "oidc-jwt-token"; process.env.VERCEL_TEAM_ID = "team_123"; @@ -1349,7 +1645,7 @@ describe("createSandboxExecutor", () => { expect(invocation.args?.[1]).toContain("echo local-only"); }); - it("returns a failed bash result when the command stream ends without a status", async () => { + it("returns a tool error when the bash command stream ends without a status", async () => { const streamError = createStreamInterruptedError(); const sandbox = makeSandbox("sbx_stream_interrupted"); sandbox.runCommand.mockRejectedValueOnce(streamError); @@ -1366,22 +1662,24 @@ describe("createSandboxExecutor", () => { }); executor.configureSkills([]); - const response = await executor.execute({ - toolName: "bash", - input: { - command: "pnpm test", - }, - }); + const error = await executor + .execute({ + toolName: "bash", + input: { + command: "pnpm test", + }, + }) + .catch((caught: unknown) => caught); - expect(response.result.details).toMatchObject({ - ok: false, - exit_code: 125, - stderr: - "Command stream ended before the command finished. The command may still have produced side effects; inspect the workspace or rerun only if it is safe.", + expect(error).toBeInstanceOf(ToolInputError); + expect(error).toMatchObject({ + message: expect.stringContaining( + "The sandbox command stream was interrupted during bash", + ), }); }); - it("returns structured file-tool results when sandbox command streams end", async () => { + it("returns tool errors when file-tool command streams end", async () => { const sandbox = makeSandbox("sbx_find_files_interrupted"); sandbox.fs.stat.mockRejectedValueOnce(createStreamInterruptedError()); sandboxCreateMock.mockResolvedValueOnce(sandbox); @@ -1395,24 +1693,180 @@ describe("createSandboxExecutor", () => { const executor = createSandboxExecutor(); executor.configureSkills([]); - const response = await executor.execute({ + const error = await executor + .execute({ + toolName: "findFiles", + input: { pattern: "*.ts" }, + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ToolInputError); + expect(error).toMatchObject({ + message: expect.stringContaining( + "The sandbox command stream was interrupted during findFiles", + ), + }); + }); + + it("invalidates an unavailable sandbox and lets a later tool call recover", async () => { + const closedSandbox = makeSandbox("sbx_closed_file_tools"); + const recoveredSandbox = makeSandbox("sbx_closed_file_tools"); + closedSandbox.fs.stat.mockRejectedValueOnce(createClosedStreamError()); + recoveredSandbox.fs.stat.mockResolvedValue({ isDirectory: () => true }); + recoveredSandbox.fs.readdir.mockResolvedValue([]); + getRuntimeDependencyProfileHashMock.mockReturnValue("profile-v1"); + sandboxCreateMock.mockResolvedValueOnce(closedSandbox); + sandboxGetMock.mockResolvedValueOnce(recoveredSandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + } as never); + + const executor = createSandboxExecutor({ + onSandboxAcquired: vi.fn(), + }); + executor.configureSkills([]); + + const unavailable = await executor + .execute({ + toolName: "grep", + input: { pattern: "needle" }, + }) + .catch((error: unknown) => error); + + expect(unavailable).toBeInstanceOf(ToolInputError); + expect(unavailable).toMatchObject({ + message: expect.stringContaining( + "The temporary sandbox became unavailable during grep", + ), + }); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).not.toHaveBeenCalled(); + + const recovered = await executor.execute({ + toolName: "grep", + input: { pattern: "needle" }, + }); + + expect(recovered.result.details).toMatchObject({ + ok: true, + status: "success", + data: { match_count: 0 }, + }); + expect(sandboxGetMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledWith({ + name: closedSandbox.name, + resume: true, + }); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + }); + + it("does not let a late stale-session failure invalidate the recovered session", async () => { + const unavailable = createClosedStreamError(); + const staleSandbox = makeSandbox("sbx_parallel_recovery"); + const recoveredSandbox = makeSandbox("sbx_parallel_recovery"); + recoveredSandbox.session.sessionId = + "sbx_parallel_recovery_recovered_session"; + let rejectStaleOperation: () => void = () => {}; + let markStaleOperationStarted: () => void = () => {}; + const staleOperationStarted = new Promise((resolve) => { + markStaleOperationStarted = resolve; + }); + staleSandbox.fs.stat + .mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + rejectStaleOperation = () => reject(unavailable); + markStaleOperationStarted(); + }), + ) + .mockRejectedValueOnce(unavailable); + recoveredSandbox.fs.stat.mockResolvedValue({ + isDirectory: () => true, + }); + recoveredSandbox.fs.readdir.mockResolvedValue([]); + sandboxGetMock + .mockResolvedValueOnce(staleSandbox) + .mockResolvedValueOnce(recoveredSandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + } as never); + + const executor = createSandboxExecutor({ + sandboxId: staleSandbox.name, + }); + executor.configureSkills([]); + + const staleOperation = executor.execute({ toolName: "findFiles", input: { pattern: "*.ts" }, }); + await staleOperationStarted; - expect(response.result).toMatchObject({ - details: { - ok: false, - status: "error", - error: { - kind: "stream_interrupted", - message: expect.stringContaining( - "Sandbox command stream was interrupted during findFiles", - ), - }, - tool: "findFiles", + const invalidatingError = await executor + .execute({ + toolName: "findFiles", + input: { pattern: "*.ts" }, + }) + .catch((error: unknown) => error); + expect(invalidatingError).toBeInstanceOf(ToolInputError); + + await expect( + executor.execute({ + toolName: "findFiles", + input: { pattern: "*.ts" }, + }), + ).resolves.toBeDefined(); + + rejectStaleOperation(); + await expect(staleOperation).rejects.toBeInstanceOf(ToolInputError); + + await expect( + executor.execute({ + toolName: "findFiles", + input: { pattern: "*.ts" }, + }), + ).resolves.toBeDefined(); + expect(sandboxGetMock).toHaveBeenCalledTimes(2); + }); + + it("returns a tool error instead of replaying unavailable bash commands", async () => { + const sandbox = makeSandbox("sbx_closed_bash"); + sandbox.runCommand.mockRejectedValueOnce(createClosedStreamError()); + sandboxGetMock.mockResolvedValueOnce(sandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, }, + } as never); + + const executor = createSandboxExecutor({ + sandboxId: sandbox.name, + }); + executor.configureSkills([]); + + const unavailable = await executor + .execute({ + toolName: "bash", + input: { command: "gh pr create" }, + }) + .catch((error: unknown) => error); + + expect(unavailable).toBeInstanceOf(ToolInputError); + expect(unavailable).toMatchObject({ + message: expect.stringContaining( + "The temporary sandbox became unavailable during bash", + ), }); + expect(sandbox.runCommand).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledTimes(1); + expect(sandboxCreateMock).not.toHaveBeenCalled(); }); it("recognizes stream interruptions wrapped by writeFile errors", async () => { @@ -1431,20 +1885,18 @@ describe("createSandboxExecutor", () => { const executor = createSandboxExecutor(); executor.configureSkills([]); - const response = await executor.execute({ - toolName: "writeFile", - input: { path: "file.ts", content: "new content" }, - }); + const error = await executor + .execute({ + toolName: "writeFile", + input: { path: "file.ts", content: "new content" }, + }) + .catch((caught: unknown) => caught); - expect(response.result).toMatchObject({ - details: { - ok: false, - status: "error", - error: { - kind: "stream_interrupted", - }, - tool: "writeFile", - }, + expect(error).toBeInstanceOf(ToolInputError); + expect(error).toMatchObject({ + message: expect.stringContaining( + "The sandbox command stream was interrupted during writeFile", + ), }); }); @@ -1586,7 +2038,7 @@ describe("createSandboxExecutor", () => { expect(sandbox.writeFiles).toHaveBeenCalledTimes(1); }); - it("recreates cached sandboxes before reusing cached tool executors", async () => { + it("fails a stopped cached session and reacquires it on a later tool call", async () => { const stoppedSandboxError = createApiError( 410, "Gone", @@ -1600,24 +2052,21 @@ describe("createSandboxExecutor", () => { throw stoppedSandboxError; } }); - firstSandbox.runCommand - .mockResolvedValueOnce({ - exitCode: 0, - stdout: async () => "first\n", - stderr: async () => "", - }) - .mockRejectedValueOnce(new Error("expired sandbox should not be reused")); + firstSandbox.runCommand.mockResolvedValueOnce({ + exitCode: 0, + stdout: async () => "first\n", + stderr: async () => "", + }); - const secondSandbox = makeSandbox("sbx_cached_second"); + const secondSandbox = makeSandbox("sbx_cached_first"); secondSandbox.runCommand.mockResolvedValueOnce({ exitCode: 0, stdout: async () => "second\n", stderr: async () => "", }); - sandboxCreateMock - .mockResolvedValueOnce(firstSandbox) - .mockResolvedValueOnce(secondSandbox); + sandboxCreateMock.mockResolvedValueOnce(firstSandbox); + sandboxGetMock.mockResolvedValueOnce(secondSandbox); vi.mocked(createBashTool).mockResolvedValue({ tools: { readFile: { execute: vi.fn(async () => ({ content: "" })) }, @@ -1636,14 +2085,30 @@ describe("createSandboxExecutor", () => { }); stopCachedSandbox = true; - const response = await executor.execute({ + const interrupted = await executor + .execute({ + toolName: "bash", + input: { + command: "echo second", + }, + }) + .catch((error: unknown) => error); + + expect(interrupted).toBeInstanceOf(ToolInputError); + expect(interrupted).toMatchObject({ + message: expect.stringContaining( + "The temporary sandbox became unavailable during bash", + ), + }); + + const recovered = await executor.execute({ toolName: "bash", input: { command: "echo second", }, }); - expect(response.result.details).toMatchObject({ + expect(recovered.result.details).toMatchObject({ ok: true, stdout: "second\n", exit_code: 0, @@ -1651,7 +2116,11 @@ describe("createSandboxExecutor", () => { expect(firstSandbox.writeFiles).toHaveBeenCalledTimes(1); expect(firstSandbox.runCommand).toHaveBeenCalledTimes(1); expect(secondSandbox.runCommand).toHaveBeenCalledTimes(1); - expect(sandboxCreateMock).toHaveBeenCalledTimes(2); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledWith({ + name: "sbx_cached_first", + resume: true, + }); }); it("reads virtual skill files without booting a sandbox before sandbox state exists", async () => { @@ -1823,7 +2292,7 @@ describe("createSandboxExecutor", () => { }); }); - it("keeps sandbox API failures as readFile errors", async () => { + it("keeps non-lifecycle sandbox API failures as readFile errors", async () => { const sandbox = makeSandbox("sbx_read_file_api_error"); sandboxCreateMock.mockResolvedValue(sandbox); vi.mocked(createBashTool).mockResolvedValue({ @@ -1831,10 +2300,10 @@ describe("createSandboxExecutor", () => { readFile: { execute: vi.fn(async () => { throw createApiError( - 410, - "Gone", - "sandbox_stopped", - "Sandbox has stopped execution and is no longer available", + 500, + "Internal Server Error", + "sandbox_api_error", + "Sandbox API failed", ); }), }, @@ -1852,7 +2321,7 @@ describe("createSandboxExecutor", () => { path: "missing.ts", }, }), - ).rejects.toThrow("Status code 410 is not ok"); + ).rejects.toThrow("Status code 500 is not ok"); }); it("reads virtual skill files from sandbox when a sandbox id hint exists", async () => { From 342d4dcbb14e3bfe75866d766a31f04d0d1ad083 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 22 Jul 2026 22:06:50 -0700 Subject: [PATCH 02/10] test(sandbox): Mock pinned sessions in unit fixtures Expose FileSystem and session-level operations in Vercel Sandbox mocks so unit coverage exercises the pinned-session adapter. --- .../misc/bash-tool-sandbox-adapter.test.ts | 47 ++++++++++++++----- .../runtime-dependency-snapshots.test.ts | 31 +++++++----- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts b/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts index 83ccad898..5210361de 100644 --- a/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts +++ b/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts @@ -5,6 +5,7 @@ const { sandboxGetMock } = vi.hoisted(() => ({ })); vi.mock("@vercel/sandbox", () => ({ + FileSystem: class {}, Sandbox: { get: sandboxGetMock, }, @@ -17,15 +18,11 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ import { createSandboxSessionManager } from "@/chat/sandbox/session"; function makeSandbox() { - return { - name: "sbx_adapter_contract", - currentSession: vi.fn(() => ({ - sessionId: "sbx_adapter_contract_session", - })), - mkDir: vi.fn(async () => {}), - writeFiles: vi.fn(async () => {}), - readFileToBuffer: vi.fn(async () => Buffer.from("file content")), - runCommand: vi.fn(async (params: { cmd: string; args?: string[] }) => ({ + const mkDir = vi.fn(async () => {}); + const writeFiles = vi.fn(async () => {}); + const readFileToBuffer = vi.fn(async () => Buffer.from("file content")); + const runCommand = vi.fn( + async (params: { cmd: string; args?: string[] }) => ({ exitCode: 0, stdout: async () => params.cmd === "bash" && @@ -34,11 +31,35 @@ function makeSandbox() { ? "grep\nsed\ncat\n" : "command stdout", stderr: async () => "", + }), + ); + const stop = vi.fn(async () => {}); + const extendTimeout = vi.fn(async () => {}); + const snapshot = vi.fn(async () => ({ + snapshotId: "snap_adapter_contract", + })); + const update = vi.fn(async () => {}); + return { + name: "sbx_adapter_contract", + currentSession: vi.fn(() => ({ + sessionId: "sbx_adapter_contract_session", + mkDir, + writeFiles, + readFileToBuffer, + runCommand, + stop, + extendTimeout, + snapshot, + update, })), - stop: vi.fn(async () => {}), - extendTimeout: vi.fn(async () => {}), - snapshot: vi.fn(async () => ({ snapshotId: "snap_adapter_contract" })), - update: vi.fn(async () => {}), + mkDir, + writeFiles, + readFileToBuffer, + runCommand, + stop, + extendTimeout, + snapshot, + update, fs: {}, }; } diff --git a/packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts b/packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts index 559a791a3..4a8fa4083 100644 --- a/packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts +++ b/packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts @@ -21,6 +21,7 @@ const { withSpanMock } = vi.hoisted(() => ({ })); vi.mock("@vercel/sandbox", () => ({ + FileSystem: class {}, Sandbox: { create: sandboxCreateMock, }, @@ -76,19 +77,27 @@ function makeSandbox( stderr: () => Promise; }>, ) { + const runCommand = vi.fn( + runCommandImpl ?? + (async () => ({ + exitCode: 0, + stdout: async () => "", + stderr: async () => "", + })), + ); + const snapshot = vi.fn(async () => ({ snapshotId })); + const stop = vi.fn(async () => {}); return { name: `sbx_${snapshotId}`, - currentSession: vi.fn(() => ({ sessionId: `sbx_${snapshotId}_session` })), - runCommand: vi.fn( - runCommandImpl ?? - (async () => ({ - exitCode: 0, - stdout: async () => "", - stderr: async () => "", - })), - ), - snapshot: vi.fn(async () => ({ snapshotId })), - stop: vi.fn(async () => {}), + currentSession: vi.fn(() => ({ + sessionId: `sbx_${snapshotId}_session`, + runCommand, + snapshot, + stop, + })), + runCommand, + snapshot, + stop, }; } From ab6e4e6647aed14491248f42a4b3203a29969b1b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 08:18:23 -0700 Subject: [PATCH 03/10] ref(sandbox): Simplify unavailable session boundary Let each pinned session invalidate manager state directly when Vercel reports a lifecycle failure. Keep tool boundaries responsible only for safe error translation, and use session terminology outside the egress layer. --- packages/junior/src/chat/agent/sandbox.ts | 18 +- .../junior/src/chat/plugins/agent-hooks.ts | 6 +- packages/junior/src/chat/sandbox/errors.ts | 2 +- .../sandbox/runtime-dependency-snapshots.ts | 16 +- packages/junior/src/chat/sandbox/sandbox.ts | 32 ++-- packages/junior/src/chat/sandbox/session.ts | 170 +++++------------- .../junior/src/chat/sandbox/skill-sync.ts | 4 +- packages/junior/src/chat/sandbox/workspace.ts | 70 ++++++-- .../component/misc/sandbox-executor.test.ts | 125 ++++++++----- .../tests/unit/plugins/agent-hooks.test.ts | 6 +- 10 files changed, 218 insertions(+), 231 deletions(-) diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 5c1b2e76f..88ad9d00e 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -10,14 +10,12 @@ import { createSandboxUnavailableToolError, type SandboxExecutor, } from "@/chat/sandbox/sandbox"; +import { isSandboxUnavailableError } from "@/chat/sandbox/errors"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; type LazySandboxExecutor = Pick< SandboxExecutor, - | "createSandbox" - | "getSandboxId" - | "getSandboxEgressId" - | "invalidateIfUnavailable" + "createSandbox" | "getSandboxId" | "getSessionId" >; /** Create a lazy-boot workspace port bound to the run's sandbox executor. */ @@ -28,7 +26,7 @@ export function createLazySandboxWorkspace(args: { interface SandboxBinding { workspace: SandboxWorkspace; sandboxId: string; - sandboxEgressId: string | undefined; + sessionId: string; } let sandboxPromise: Promise | undefined; @@ -47,11 +45,11 @@ export function createLazySandboxWorkspace(args: { cwd?: string; }): Promise => { const currentSandboxId = args.executor.getSandboxId(); - const currentSandboxEgressId = args.executor.getSandboxEgressId(); + const currentSessionId = args.executor.getSessionId(); if ( sandboxBinding && (currentSandboxId !== sandboxBinding.sandboxId || - currentSandboxEgressId !== sandboxBinding.sandboxEgressId) + currentSessionId !== sandboxBinding.sessionId) ) { clearSandboxPromise(); } @@ -74,7 +72,7 @@ export function createLazySandboxWorkspace(args: { const binding = { workspace, sandboxId: workspace.sandboxId, - sandboxEgressId: workspace.sandboxEgressId, + sessionId: workspace.sessionId, }; if (sandboxPromise === nextSandboxPromise) { sandboxBinding = binding; @@ -106,9 +104,7 @@ export function createLazySandboxWorkspace(args: { binding = await activeSandboxPromise; return await operation(binding.workspace); } catch (error) { - if ( - args.executor.invalidateIfUnavailable(error, binding?.sandboxEgressId) - ) { + if (isSandboxUnavailableError(error)) { clearSandboxPromise(activeSandboxPromise); throw createSandboxUnavailableToolError(reason.trigger, error); } diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 2d12ae26a..33ce8253f 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -31,7 +31,7 @@ import { getSlackToolContext } from "@/chat/slack/tools/context"; import type { ToolRuntimeContext } from "@/chat/tools/types"; import type { SandboxCommandInput, - SandboxInstance, + SandboxSession, } from "@/chat/sandbox/workspace"; import { createSlackDirectCredentialSubject } from "@/chat/credentials/subject"; import { resolveChannelCapabilities } from "@/chat/slack/tools/channel-capabilities"; @@ -67,7 +67,7 @@ export interface PluginApiRouteRegistration { export interface PluginHookRunner { beforeToolExecute(input: ToolHookInput): Promise; - prepareSandbox(sandbox: SandboxInstance): Promise; + prepareSandbox(sandbox: SandboxSession): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1005,7 +1005,7 @@ function normalizeEnv(value: unknown): Record { return env; } -function createSandboxCapability(sandbox: SandboxInstance): PluginSandbox { +function createSandboxCapability(sandbox: SandboxSession): PluginSandbox { return { root: SANDBOX_WORKSPACE_ROOT, juniorRoot: `${SANDBOX_WORKSPACE_ROOT}/.junior`, diff --git a/packages/junior/src/chat/sandbox/errors.ts b/packages/junior/src/chat/sandbox/errors.ts index 68ec7cff4..92f405fe4 100644 --- a/packages/junior/src/chat/sandbox/errors.ts +++ b/packages/junior/src/chat/sandbox/errors.ts @@ -88,7 +88,7 @@ export function isAlreadyExistsError(error: unknown): boolean { ); } -/** Detect when a cached sandbox can no longer be reused and must be recreated. */ +/** Detect when the current sandbox session is unavailable and must be reacquired. */ export function isSandboxUnavailableError(error: unknown): boolean { return findInErrorChain(error, (candidate) => { if (isInvalidSandboxSessionError(candidate)) { diff --git a/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts b/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts index 563934c90..99c8a8f0b 100644 --- a/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts +++ b/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts @@ -6,8 +6,8 @@ import { GLOBAL_RUNTIME_DEPENDENCIES } from "@/chat/sandbox/runtime-dependencies import { runNonInteractiveCommand } from "@/chat/sandbox/noninteractive-command"; import { getVercelSandboxCredentials } from "@/chat/sandbox/credentials"; import { - createSandboxInstance, - type SandboxInstance, + createSandboxSession, + type SandboxSession, } from "@/chat/sandbox/workspace"; import type { PluginRuntimeDependency, @@ -207,7 +207,7 @@ async function withSnapshotSpan( } async function runOrThrow( - sandbox: SandboxInstance, + sandbox: SandboxSession, params: { cmd: string; args?: string[]; @@ -227,7 +227,7 @@ async function runOrThrow( } async function tryRun( - sandbox: SandboxInstance, + sandbox: SandboxSession, params: { cmd: string; args?: string[]; @@ -244,7 +244,7 @@ async function tryRun( return { ok: false, detail: stderr || stdout || "command failed" }; } -async function installGhCliViaDnf(sandbox: SandboxInstance): Promise { +async function installGhCliViaDnf(sandbox: SandboxSession): Promise { const direct = await tryRun(sandbox, { cmd: "dnf", args: ["install", "-y", "gh"], @@ -317,7 +317,7 @@ function runtimeDependencyFilePath(url: string, sha256: string): string { } async function installRuntimeDependencies( - sandbox: SandboxInstance, + sandbox: SandboxSession, deps: PluginRuntimeDependency[], ): Promise { const systemDeps = deps.filter( @@ -432,7 +432,7 @@ async function installRuntimeDependencies( } async function runRuntimePostinstall( - sandbox: SandboxInstance, + sandbox: SandboxSession, commands: PluginRuntimePostinstallCommand[], ): Promise { if (commands.length === 0) { @@ -482,7 +482,7 @@ async function createDependencySnapshot( async () => { const sandboxCredentials = getVercelSandboxCredentials(); const resources = getSandboxResources(); - const sandbox = createSandboxInstance( + const sandbox = createSandboxSession( await Sandbox.create({ timeout: timeoutMs, runtime, diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index db29cd2b0..125d44a4c 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -23,6 +23,7 @@ import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/ import type { CredentialContext } from "@/chat/credentials/context"; import { isSandboxCommandStreamInterruptedError, + isSandboxUnavailableError, throwSandboxOperationError, } from "@/chat/sandbox/errors"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; @@ -33,7 +34,7 @@ import { resolveHostDataPath, resolveHostSkillPath, } from "@/chat/sandbox/skill-sync"; -import type { SandboxInstance } from "@/chat/sandbox/workspace"; +import type { SandboxSession } from "@/chat/sandbox/workspace"; import type { SkillMetadata } from "@/chat/skills"; import { editFile } from "@/chat/tools/sandbox/edit-file"; import { findFiles } from "@/chat/tools/sandbox/find-files"; @@ -86,12 +87,10 @@ export interface SandboxExecutor { configureSkills(skills: SkillMetadata[]): void; configureReferenceFiles(files: string[]): void; getSandboxId(): string | undefined; - getSandboxEgressId(): string | undefined; + getSessionId(): string | undefined; getDependencyProfileHash(): string | undefined; canExecute(toolName: string): boolean; - createSandbox(): Promise; - /** Drop unavailable live state so a later operation can reacquire the sandbox. */ - invalidateIfUnavailable(error: unknown, sandboxEgressId?: string): boolean; + createSandbox(): Promise; execute( params: SandboxExecutionInput, ): Promise>; @@ -241,10 +240,12 @@ export function createSandboxExecutor(options?: { : undefined, createNetworkPolicy: credentialEgress || hasTracePropagationDomains - ? (egressId, traceHeaders) => + ? (sessionId, traceHeaders) => buildSandboxEgressNetworkPolicy({ ...(credentialEgress - ? { credentialToken: sandboxEgressCredentialTokenFor(egressId) } + ? { + credentialToken: sandboxEgressCredentialTokenFor(sessionId), + } : {}), traceConfig: tracePropagation, traceHeaders, @@ -307,8 +308,8 @@ export function createSandboxExecutor(options?: { "app.sandbox.command_length": command.length, }); const executeBash = (await sessionManager.ensureToolExecutors()).bash; - const activeEgressId = sessionManager.getSandboxEgressId(); - await clearSandboxEgressSignals(activeEgressId); + const activeSessionId = sessionManager.getSessionId(); + await clearSandboxEgressSignals(activeSessionId); const result = await withSandboxToolSpan( "bash", "process.exec", @@ -359,9 +360,9 @@ export function createSandboxExecutor(options?: { // and `clearSandboxEgressSignals` runs before each execution to prevent // cross-command leakage. const authRequired = - await consumeSandboxEgressAuthRequiredSignal(activeEgressId); + await consumeSandboxEgressAuthRequiredSignal(activeSessionId); const permissionDenied = - await consumeSandboxEgressPermissionDeniedSignal(activeEgressId); + await consumeSandboxEgressPermissionDeniedSignal(activeSessionId); return { result: bashToolResult({ @@ -696,7 +697,7 @@ export function createSandboxExecutor(options?: { return await executeWriteFileTool(rawInput); } } catch (error) { - if (sessionManager.invalidateIfUnavailable(error)) { + if (isSandboxUnavailableError(error)) { // Do not replay an operation that may already have produced side effects. throw createSandboxUnavailableToolError(params.toolName, error); } @@ -724,8 +725,8 @@ export function createSandboxExecutor(options?: { getSandboxId() { return sessionManager.getSandboxId(); }, - getSandboxEgressId() { - return sessionManager.getSandboxEgressId(); + getSessionId() { + return sessionManager.getSessionId(); }, getDependencyProfileHash() { return sessionManager.getDependencyProfileHash(); @@ -736,9 +737,6 @@ export function createSandboxExecutor(options?: { async createSandbox() { return await sessionManager.createSandbox(); }, - invalidateIfUnavailable(error, sandboxEgressId) { - return sessionManager.invalidateIfUnavailable(error, sandboxEgressId); - }, execute, async dispose() { await sessionManager.dispose(); diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 2e675af8c..51c501aa5 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -26,10 +26,10 @@ import { } from "@/chat/sandbox/runtime-dependency-snapshots"; import { syncSkillsToSandbox } from "@/chat/sandbox/skill-sync"; import { - createSandboxInstance, + createSandboxSession, type SandboxCommandResult, type SandboxFileSystem, - type SandboxInstance, + type SandboxSession, } from "@/chat/sandbox/workspace"; import { sleep } from "@/chat/sleep"; import type { SkillMetadata } from "@/chat/skills"; @@ -71,49 +71,7 @@ interface SandboxToolExecutors { fs: SandboxFileSystem; } -/** Preserve which pinned session produced a lifecycle failure. */ -class SandboxSessionUnavailableError extends Error { - readonly sandboxEgressId: string; - - constructor(sandboxEgressId: string, cause: unknown) { - super("sandbox session unavailable", { cause }); - this.name = "SandboxSessionUnavailableError"; - this.sandboxEgressId = sandboxEgressId; - } -} - -/** Read the originating session identity from a wrapped unavailable error. */ -function getUnavailableSandboxEgressId(error: unknown): string | undefined { - const seen = new Set(); - let current: unknown = error; - while (current && !seen.has(current)) { - if (current instanceof SandboxSessionUnavailableError) { - return current.sandboxEgressId; - } - seen.add(current); - current = - typeof current === "object" - ? (current as { cause?: unknown }).cause - : undefined; - } - return undefined; -} - -async function bindUnavailableErrorToSandbox( - sandboxEgressId: string, - operation: () => Promise, -): Promise { - try { - return await operation(); - } catch (error) { - if (!isSandboxUnavailableError(error)) { - throw error; - } - throw new SandboxSessionUnavailableError(sandboxEgressId, error); - } -} - -function createBashToolSandboxAdapter(sandbox: SandboxInstance) { +function createBashToolSandboxAdapter(sandbox: SandboxSession) { return { async executeCommand(command: string) { const result = await sandbox.runCommand({ @@ -152,12 +110,10 @@ interface SandboxSessionManager { configureSkills(skills: SkillMetadata[]): void; configureReferenceFiles(files: string[]): void; getSandboxId(): string | undefined; - getSandboxEgressId(): string | undefined; + getSessionId(): string | undefined; getDependencyProfileHash(): string | undefined; - createSandbox(): Promise; + createSandbox(): Promise; ensureToolExecutors(): Promise; - /** Drop unavailable live state while retaining the hint; true means the current operation must fail. */ - invalidateIfUnavailable(error: unknown, sandboxEgressId?: string): boolean; refreshNetworkPolicy(traceHeaders?: TracePropagationHeaders): Promise; dispose(): Promise; } @@ -213,13 +169,13 @@ export function createSandboxSessionManager(options?: { egressId: string, traceHeaders?: TracePropagationHeaders, ) => NetworkPolicy | undefined; - onSandboxPrepare?: (sandbox: SandboxInstance) => void | Promise; + onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; onSandboxAcquired?: (sandbox: { sandboxId: string; sandboxDependencyProfileHash?: string; }) => void | Promise; }): SandboxSessionManager { - let sandbox: SandboxInstance | null = null; + let sandbox: SandboxSession | null = null; let sandboxIdHint = options?.sandboxId; let sandboxDependencyProfileHashHint = options?.sandboxDependencyProfileHash; let availableSkills: SkillMetadata[] = []; @@ -228,7 +184,7 @@ export function createSandboxSessionManager(options?: { let loadingToolExecutors: Promise | undefined; let appliedNetworkPolicyKey: string | undefined; let preparedSandboxId: string | undefined; - let acquiringSandbox: Promise | undefined; + let acquiringSandbox: Promise | undefined; const timeoutMs = options?.timeoutMs ?? 1000 * 60 * 30; const traceContext = options?.traceContext ?? {}; @@ -245,7 +201,10 @@ export function createSandboxSessionManager(options?: { ): Promise => withSpan(name, op, traceContext, callback, attributes); /** Drop unavailable live state while retaining the persisted hint for lazy reacquisition. */ - const invalidateSession = (): void => { + const invalidateSession = (sessionId?: string): void => { + if (sessionId && sandbox && sandbox.sessionId !== sessionId) { + return; + } sandbox = null; toolExecutors = undefined; loadingToolExecutors = undefined; @@ -253,6 +212,13 @@ export function createSandboxSessionManager(options?: { preparedSandboxId = undefined; }; + const adaptSandbox = ( + vercelSandbox: Parameters[0], + ): SandboxSession => + createSandboxSession(vercelSandbox, { + onUnavailable: invalidateSession, + }); + const createSandboxName = (): string => `${SANDBOX_NAME_PREFIX}${randomUUID()}`; @@ -264,14 +230,14 @@ export function createSandboxSessionManager(options?: { return options?.createNetworkPolicy?.(sandboxName); }; - const retainSandboxHint = (nextSandbox: SandboxInstance): void => { + const retainSandboxHint = (nextSandbox: SandboxSession): void => { sandboxIdHint = nextSandbox.sandboxId; sandboxDependencyProfileHashHint = dependencyProfileHash; }; const rememberSandbox = async ( - nextSandbox: SandboxInstance, - ): Promise => { + nextSandbox: SandboxSession, + ): Promise => { sandbox = nextSandbox; retainSandboxHint(nextSandbox); toolExecutors = undefined; @@ -294,7 +260,7 @@ export function createSandboxSessionManager(options?: { throw new Error("sandbox restore failed", { cause: error }); }; - const syncSkills = async (targetSandbox: SandboxInstance): Promise => { + const syncSkills = async (targetSandbox: SandboxSession): Promise => { await syncSkillsToSandbox({ sandbox: targetSandbox, skills: availableSkills, @@ -304,7 +270,7 @@ export function createSandboxSessionManager(options?: { }; const prepareSandbox = async ( - targetSandbox: SandboxInstance, + targetSandbox: SandboxSession, ): Promise => { if (preparedSandboxId === targetSandbox.sandboxId) { return; @@ -315,11 +281,11 @@ export function createSandboxSessionManager(options?: { }; const applyNetworkPolicy = async ( - targetSandbox: SandboxInstance, + targetSandbox: SandboxSession, traceHeaders?: TracePropagationHeaders, ): Promise => { const networkPolicy = options?.createNetworkPolicy?.( - targetSandbox.sandboxEgressId, + targetSandbox.sessionId, traceHeaders, ); if (!networkPolicy) { @@ -345,7 +311,7 @@ export function createSandboxSessionManager(options?: { }; const ensureSandboxReachable = async ( - targetSandbox: SandboxInstance, + targetSandbox: SandboxSession, source: "memory" | "id_hint", ): Promise => { await withSandboxSpan( @@ -371,14 +337,14 @@ export function createSandboxSessionManager(options?: { snapshotId: string, sandboxCredentials: SandboxCredentials | undefined, initialSandboxName: string, - ): Promise => { + ): Promise => { const resources = getSandboxResources(); for (let attempt = 0; attempt < SNAPSHOT_BOOT_RETRY_COUNT; attempt += 1) { const sandboxName = attempt === 0 ? initialSandboxName : createSandboxName(); const networkPolicy = preflightNetworkPolicy(sandboxName); try { - return createSandboxInstance( + return adaptSandbox( await Sandbox.create({ timeout: timeoutMs, ...(networkPolicy @@ -430,13 +396,13 @@ export function createSandboxSessionManager(options?: { snapshot: RuntimeDependencySnapshot; sandboxCredentials: SandboxCredentials | undefined; sandboxName: string; - }): Promise => { + }): Promise => { const { runtime, snapshot, sandboxCredentials, sandboxName } = params; if (!snapshot.snapshotId) { const networkPolicy = preflightNetworkPolicy(sandboxName); const resources = getSandboxResources(); - return createSandboxInstance( + return adaptSandbox( await Sandbox.create({ timeout: timeoutMs, runtime, @@ -481,12 +447,12 @@ export function createSandboxSessionManager(options?: { } }; - const createFreshSandbox = async (): Promise => { + const createFreshSandbox = async (): Promise => { const runtime = SANDBOX_RUNTIME; const sandboxCredentials = getVercelSandboxCredentials(); const sandboxName = createSandboxName(); - let createdSandbox: SandboxInstance; + let createdSandbox: SandboxSession; try { createdSandbox = await withSandboxSpan( "sandbox.create", @@ -569,7 +535,7 @@ export function createSandboxSessionManager(options?: { sandboxDependencyProfileHashHint = undefined; }; - const tryReuseCachedSandbox = async (): Promise => { + const tryReuseCachedSandbox = async (): Promise => { const cachedSandbox = sandbox; if (!cachedSandbox) { return null; @@ -582,19 +548,18 @@ export function createSandboxSessionManager(options?: { return cachedSandbox; } catch (error) { if (isSandboxUnavailableError(error)) { - invalidateSession(); throw error; } return failSetup(error); } }; - const tryRestoreHintedSandbox = async (): Promise => { + const tryRestoreHintedSandbox = async (): Promise => { if (!sandboxIdHint) { return null; } - let hintedSandbox: SandboxInstance | null = null; + let hintedSandbox: SandboxSession | null = null; try { const sandboxCredentials = getVercelSandboxCredentials(); hintedSandbox = await withSandboxSpan( @@ -605,7 +570,7 @@ export function createSandboxSessionManager(options?: { "app.sandbox.source": "id_hint", }, async () => - createSandboxInstance( + adaptSandbox( await Sandbox.get({ name: sandboxIdHint as string, resume: true, @@ -627,14 +592,13 @@ export function createSandboxSessionManager(options?: { return await rememberSandbox(hintedSandbox); } catch (error) { if (isSandboxUnavailableError(error)) { - invalidateSession(); throw error; } return failSetup(error); } }; - const acquireSandbox = async (): Promise => { + const acquireSandbox = async (): Promise => { return await withSandboxSpan( "sandbox.acquire", "sandbox.acquire", @@ -662,7 +626,7 @@ export function createSandboxSessionManager(options?: { ); }; - const getOrAcquireSandbox = async (): Promise => { + const getOrAcquireSandbox = async (): Promise => { if (acquiringSandbox) { return await acquiringSandbox; } @@ -712,7 +676,7 @@ export function createSandboxSessionManager(options?: { }; const extendKeepAlive = async ( - activeSandbox: SandboxInstance, + activeSandbox: SandboxSession, ): Promise => { const keepAliveMs = parseKeepAliveMs(); if (keepAliveMs === 0) { @@ -736,7 +700,7 @@ export function createSandboxSessionManager(options?: { }; const buildToolExecutors = async ( - sandboxInstance: SandboxInstance, + sandboxInstance: SandboxSession, ): Promise => { const toolkit = await withSandboxSpan( "sandbox.bash_tool.init", @@ -845,14 +809,14 @@ export function createSandboxSessionManager(options?: { }; }; - const ensureReadySandbox = async (): Promise => { + const ensureReadySandbox = async (): Promise => { const activeSandbox = await getOrAcquireSandbox(); await extendKeepAlive(activeSandbox); return activeSandbox; }; const loadToolExecutors = async ( - activeSandbox: SandboxInstance, + activeSandbox: SandboxSession, ): Promise => { if (toolExecutors) { return toolExecutors; @@ -863,32 +827,8 @@ export function createSandboxSessionManager(options?: { const nextToolExecutors = buildToolExecutors(activeSandbox).then( (executors) => { - const bind = (operation: () => Promise): Promise => - bindUnavailableErrorToSandbox( - activeSandbox.sandboxEgressId, - operation, - ); - const boundExecutors: SandboxToolExecutors = { - bash: async (input) => await bind(async () => executors.bash(input)), - readFile: async (input) => - await bind(async () => executors.readFile(input)), - writeFile: async (input) => - await bind(async () => executors.writeFile(input)), - fs: { - readFile: async (filePath, options) => - await bind(async () => executors.fs.readFile(filePath, options)), - writeFile: async (filePath, content, options) => - await bind(async () => - executors.fs.writeFile(filePath, content, options), - ), - readdir: async (filePath) => - await bind(async () => executors.fs.readdir(filePath)), - stat: async (filePath) => - await bind(async () => executors.fs.stat(filePath)), - }, - }; - toolExecutors = boundExecutors; - return boundExecutors; + toolExecutors = executors; + return executors; }, ); loadingToolExecutors = nextToolExecutors; @@ -911,8 +851,8 @@ export function createSandboxSessionManager(options?: { getSandboxId() { return sandbox ? sandbox.sandboxId : sandboxIdHint; }, - getSandboxEgressId() { - return sandbox?.sandboxEgressId; + getSessionId() { + return sandbox?.sessionId; }, getDependencyProfileHash() { return dependencyProfileHash; @@ -923,22 +863,6 @@ export function createSandboxSessionManager(options?: { async ensureToolExecutors() { return await loadToolExecutors(await ensureReadySandbox()); }, - invalidateIfUnavailable(error, sandboxEgressId) { - if (!isSandboxUnavailableError(error)) { - return false; - } - const unavailableEgressId = - getUnavailableSandboxEgressId(error) ?? sandboxEgressId; - if ( - unavailableEgressId && - sandbox && - sandbox.sandboxEgressId !== unavailableEgressId - ) { - return true; - } - invalidateSession(); - return true; - }, async refreshNetworkPolicy(traceHeaders) { const activeSandbox = sandbox; if (!activeSandbox) { diff --git a/packages/junior/src/chat/sandbox/skill-sync.ts b/packages/junior/src/chat/sandbox/skill-sync.ts index 1e6acbfbe..85856ee3b 100644 --- a/packages/junior/src/chat/sandbox/skill-sync.ts +++ b/packages/junior/src/chat/sandbox/skill-sync.ts @@ -10,7 +10,7 @@ import { isAlreadyExistsError, throwSandboxOperationError, } from "@/chat/sandbox/errors"; -import type { SandboxInstance } from "@/chat/sandbox/workspace"; +import type { SandboxSession } from "@/chat/sandbox/workspace"; import type { SkillMetadata } from "@/chat/skills"; interface SkillSyncFile { @@ -197,7 +197,7 @@ export function isHostFileMissingError(error: unknown): boolean { /** Copy the current skill set and reference files into a sandbox. */ export async function syncSkillsToSandbox(params: { - sandbox: SandboxInstance; + sandbox: SandboxSession; skills: SkillMetadata[]; referenceFiles?: string[]; withSpan: ( diff --git a/packages/junior/src/chat/sandbox/workspace.ts b/packages/junior/src/chat/sandbox/workspace.ts index 1dbdb9f73..b2e629a80 100644 --- a/packages/junior/src/chat/sandbox/workspace.ts +++ b/packages/junior/src/chat/sandbox/workspace.ts @@ -3,6 +3,7 @@ import { type NetworkPolicy, type Sandbox as VercelSandbox, } from "@vercel/sandbox"; +import { isSandboxUnavailableError } from "@/chat/sandbox/errors"; export interface SandboxCommandResult { exitCode: number; @@ -45,9 +46,9 @@ export interface SandboxWorkspace { runCommand(input: SandboxCommandInput): Promise; } -export interface SandboxInstance extends SandboxWorkspace { +export interface SandboxSession extends SandboxWorkspace { readonly sandboxId: string; - readonly sandboxEgressId: string; + readonly sessionId: string; readonly fs: SandboxFileSystem; extendTimeout(duration: number): Promise; mkDir(path: string): Promise; @@ -63,40 +64,75 @@ export interface SandboxInstance extends SandboxWorkspace { ): Promise; } -/** Adapt the Vercel SDK object once so the rest of Junior sees one sandbox contract. */ -export function createSandboxInstance(sandbox: VercelSandbox): SandboxInstance { +/** Pin Junior's sandbox contract to one Vercel session without SDK replay. */ +export function createSandboxSession( + sandbox: VercelSandbox, + options?: { + onUnavailable?: (sessionId: string) => void; + }, +): SandboxSession { // Pin operations to the acquired VM session. The Sandbox convenience methods // may resume and replay an operation after a lifecycle failure. const session = sandbox.currentSession(); + const fileSystem = new FileSystem(session); + const run = async (operation: () => Promise): Promise => { + try { + return await operation(); + } catch (error) { + if (isSandboxUnavailableError(error)) { + options?.onUnavailable?.(session.sessionId); + } + throw error; + } + }; + const fs: SandboxFileSystem = { + readFile: async (filePath, readOptions) => + await run(async () => await fileSystem.readFile(filePath, readOptions)), + writeFile: async (filePath, content, writeOptions) => + await run( + async () => await fileSystem.writeFile(filePath, content, writeOptions), + ), + readdir: async (filePath) => + await run(async () => await fileSystem.readdir(filePath)), + stat: async (filePath) => + await run(async () => await fileSystem.stat(filePath)), + }; return { sandboxId: sandbox.name, - sandboxEgressId: session.sessionId, - fs: new FileSystem(session) as SandboxFileSystem, + sessionId: session.sessionId, + fs, extendTimeout(duration) { - return session.extendTimeout(duration); + return run(async () => await session.extendTimeout(duration)); }, mkDir(path) { - return session.mkDir(path); + return run(async () => await session.mkDir(path)); }, readFileToBuffer(input) { - return session.readFileToBuffer(input); + return run(async () => await session.readFileToBuffer(input)); }, - runCommand(input) { - return session.runCommand(input); + async runCommand(input) { + const result = await run(async () => await session.runCommand(input)); + return { + exitCode: result.exitCode, + stdout: async () => await run(async () => await result.stdout()), + stderr: async () => await run(async () => await result.stderr()), + }; }, - async snapshot() { - const snapshot = await session.snapshot(); - return { snapshotId: snapshot.snapshotId }; + snapshot() { + return run(async () => { + const snapshot = await session.snapshot(); + return { snapshotId: snapshot.snapshotId }; + }); }, stop() { - return session.stop(); + return run(async () => await session.stop()); }, update(params) { - return session.update(params); + return run(async () => await session.update(params)); }, writeFiles(files) { - return session.writeFiles(files); + return run(async () => await session.writeFiles(files)); }, }; } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index c0f3bc2ed..b8c3bb403 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SANDBOX_WORKSPACE_ROOT, sandboxSkillDir } from "@/chat/sandbox/paths"; -import type { SandboxInstance } from "@/chat/sandbox/workspace"; +import type { SandboxSession } from "@/chat/sandbox/workspace"; type StructuredSandboxResult = { details: Record; @@ -142,7 +142,7 @@ import { } from "@/chat/sandbox/egress/session"; import { createLazySandboxWorkspace } from "@/chat/agent/sandbox"; import { createSandboxSessionManager } from "@/chat/sandbox/session"; -import { createSandboxInstance } from "@/chat/sandbox/workspace"; +import { createSandboxSession } from "@/chat/sandbox/workspace"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { createBashTool } from "bash-tool"; @@ -299,11 +299,11 @@ function createClosedStreamError(): Error { } async function expectWorkspaceToDelegate( - workspace: SandboxInstance, + workspace: SandboxSession, sandbox: MockSandbox, ): Promise { expect(workspace.sandboxId).toBe(sandbox.name); - expect(workspace.sandboxEgressId).toBe(`${sandbox.name}_session`); + expect(workspace.sessionId).toBe(`${sandbox.name}_session`); const fileBuffer = Buffer.from("workspace file"); const commandResult = { exitCode: 0, @@ -320,9 +320,14 @@ async function expectWorkspaceToDelegate( }); sandbox.runCommand.mockResolvedValueOnce(commandResult); - await expect( - workspace.runCommand({ cmd: "pwd", args: ["-P"], cwd: "/tmp" }), - ).resolves.toBe(commandResult); + const delegatedResult = await workspace.runCommand({ + cmd: "pwd", + args: ["-P"], + cwd: "/tmp", + }); + expect(delegatedResult.exitCode).toBe(commandResult.exitCode); + await expect(delegatedResult.stdout()).resolves.toBe("stdout"); + await expect(delegatedResult.stderr()).resolves.toBe("stderr"); expect(sandbox.runCommand).toHaveBeenCalledWith({ cmd: "pwd", args: ["-P"], @@ -365,7 +370,7 @@ describe("createSandboxExecutor", () => { it("pins workspace commands to the acquired session without SDK replay", async () => { const sandbox = makeSandbox("sbx_pinned_session"); const resumingRunCommand = vi.fn(); - const workspace = createSandboxInstance({ + const workspace = createSandboxSession({ ...sandbox, runCommand: resumingRunCommand, } as never); @@ -378,31 +383,37 @@ describe("createSandboxExecutor", () => { it("rebinds the lazy workspace after an unavailable session", async () => { const unavailable = createClosedStreamError(); + let sessionId: string | undefined; const firstWorkspace = { sandboxId: "sbx_lazy_recovery", - sandboxEgressId: "session_lazy_recovery", + sessionId: "session_lazy_recovery", readFileToBuffer: vi.fn(async () => { + sessionId = undefined; throw unavailable; }), runCommand: vi.fn(), }; const recoveredWorkspace = { sandboxId: "sbx_lazy_recovery", - sandboxEgressId: "session_lazy_recovered", + sessionId: "session_lazy_recovered", readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), runCommand: vi.fn(), }; const createSandbox = vi .fn() - .mockResolvedValueOnce(firstWorkspace) - .mockResolvedValueOnce(recoveredWorkspace); - const invalidateIfUnavailable = vi.fn(() => true); + .mockImplementationOnce(async () => { + sessionId = firstWorkspace.sessionId; + return firstWorkspace; + }) + .mockImplementationOnce(async () => { + sessionId = recoveredWorkspace.sessionId; + return recoveredWorkspace; + }); const workspace = createLazySandboxWorkspace({ executor: { createSandbox, getSandboxId: () => "sbx_lazy_recovery", - getSandboxEgressId: () => undefined, - invalidateIfUnavailable, + getSessionId: () => sessionId, } as never, spanContext: {}, }); @@ -416,24 +427,20 @@ describe("createSandboxExecutor", () => { workspace.readFileToBuffer({ path: "/tmp/result.txt" }), ).resolves.toEqual(Buffer.from("recovered")); - expect(invalidateIfUnavailable).toHaveBeenCalledWith( - unavailable, - "session_lazy_recovery", - ); expect(createSandbox).toHaveBeenCalledTimes(2); }); - it("rebinds the lazy workspace after executor-side invalidation", async () => { + it("rebinds the lazy workspace after session invalidation", async () => { let sessionId: string | undefined; const firstWorkspace = { sandboxId: "sbx_lazy_external", - sandboxEgressId: "session_1", + sessionId: "session_1", readFileToBuffer: vi.fn(async () => Buffer.from("first")), runCommand: vi.fn(), }; const recoveredWorkspace = { sandboxId: "sbx_lazy_external", - sandboxEgressId: "session_2", + sessionId: "session_2", readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), runCommand: vi.fn(), }; @@ -451,8 +458,7 @@ describe("createSandboxExecutor", () => { executor: { createSandbox, getSandboxId: () => "sbx_lazy_external", - getSandboxEgressId: () => sessionId, - invalidateIfUnavailable: vi.fn(() => false), + getSessionId: () => sessionId, } as never, spanContext: {}, }); @@ -480,21 +486,31 @@ describe("createSandboxExecutor", () => { const staleOperation = new Promise((_resolve, reject) => { rejectStaleOperation = () => reject(unavailable); }); + const failUnavailable = (): never => { + if (sessionId === "session_1") { + sessionId = undefined; + } + throw unavailable; + }; const staleWorkspace = { sandboxId: "sbx_lazy_parallel", - sandboxEgressId: "session_1", + sessionId: "session_1", readFileToBuffer: vi .fn() .mockImplementationOnce(async () => { markStaleOperationStarted(); - return await staleOperation; + try { + return await staleOperation; + } catch { + return failUnavailable(); + } }) - .mockRejectedValueOnce(unavailable), + .mockImplementationOnce(async () => failUnavailable()), runCommand: vi.fn(), }; const recoveredWorkspace = { sandboxId: "sbx_lazy_parallel", - sandboxEgressId: "session_2", + sessionId: "session_2", readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), runCommand: vi.fn(), }; @@ -508,20 +524,11 @@ describe("createSandboxExecutor", () => { sessionId = "session_2"; return recoveredWorkspace; }); - const invalidateIfUnavailable = vi.fn( - (_error: unknown, unavailableSessionId?: string) => { - if (sessionId === unavailableSessionId) { - sessionId = undefined; - } - return true; - }, - ); const workspace = createLazySandboxWorkspace({ executor: { createSandbox, getSandboxId: () => "sbx_lazy_parallel", - getSandboxEgressId: () => sessionId, - invalidateIfUnavailable, + getSessionId: () => sessionId, } as never, spanContext: {}, }); @@ -544,11 +551,6 @@ describe("createSandboxExecutor", () => { ).resolves.toEqual(Buffer.from("recovered")); expect(createSandbox).toHaveBeenCalledTimes(2); - expect(invalidateIfUnavailable).toHaveBeenNthCalledWith( - 2, - unavailable, - "session_1", - ); }); it("fails a stopped hinted session and reacquires it on a later call", async () => { @@ -1835,10 +1837,25 @@ describe("createSandboxExecutor", () => { expect(sandboxGetMock).toHaveBeenCalledTimes(2); }); - it("returns a tool error instead of replaying unavailable bash commands", async () => { + it("does not replay unavailable command output and recovers later", async () => { + const unavailableError = createClosedStreamError(); const sandbox = makeSandbox("sbx_closed_bash"); - sandbox.runCommand.mockRejectedValueOnce(createClosedStreamError()); - sandboxGetMock.mockResolvedValueOnce(sandbox); + sandbox.runCommand.mockResolvedValueOnce({ + exitCode: 0, + stdout: async () => { + throw unavailableError; + }, + stderr: async () => "", + }); + const recoveredSandbox = makeSandbox("sbx_closed_bash"); + recoveredSandbox.runCommand.mockResolvedValueOnce({ + exitCode: 0, + stdout: async () => "recovered\n", + stderr: async () => "", + }); + sandboxGetMock + .mockResolvedValueOnce(sandbox) + .mockResolvedValueOnce(recoveredSandbox); vi.mocked(createBashTool).mockResolvedValue({ tools: { readFile: { execute: vi.fn(async () => ({ content: "" })) }, @@ -1867,6 +1884,22 @@ describe("createSandboxExecutor", () => { expect(sandbox.runCommand).toHaveBeenCalledTimes(1); expect(sandboxGetMock).toHaveBeenCalledTimes(1); expect(sandboxCreateMock).not.toHaveBeenCalled(); + + await expect( + executor.execute({ + toolName: "bash", + input: { command: "git status --short" }, + }), + ).resolves.toMatchObject({ + result: { + details: { + stdout: "recovered\n", + }, + }, + }); + expect(sandbox.runCommand).toHaveBeenCalledTimes(1); + expect(recoveredSandbox.runCommand).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledTimes(2); }); it("recognizes stream interruptions wrapped by writeFile errors", async () => { diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index f04bf6e2d..9010cbd19 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -21,7 +21,7 @@ import { } from "@/chat/plugins/agent-hooks"; import { createTools } from "@/chat/tools"; import type { ToolRuntimeContext } from "@/chat/tools/types"; -import type { SandboxInstance } from "@/chat/sandbox/workspace"; +import type { SandboxSession } from "@/chat/sandbox/workspace"; const demoToolResultSchema = pluginToolResultSchema.extend({ ok: z.literal(true), @@ -93,10 +93,10 @@ function slackSource(channelId: string) { function fakeSandbox( writes: Array<{ content: string | Uint8Array; path: string }>, -): SandboxInstance { +): SandboxSession { return { sandboxId: "sandbox-agent-hooks", - sandboxEgressId: "session-agent-hooks", + sessionId: "session-agent-hooks", fs: { async readFile() { return ""; From 5071579faade7cec35b34ea3e18f4eb0ea0ebb37 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 12:54:35 -0700 Subject: [PATCH 04/10] fix(sandbox): Simplify unavailable session recovery Expose role-shaped workspace, tool, and durable reference projections while keeping Vercel sessions private to the sandbox boundary. Persist replacement references before preparation, pin tool caches and egress metadata to one session, and fail unavailable operations without replay before later reacquisition. Refs #966 --- .../junior/src/chat/agent-dispatch/runner.ts | 37 +- packages/junior/src/chat/agent/index.ts | 20 +- packages/junior/src/chat/agent/request.ts | 9 +- packages/junior/src/chat/agent/sandbox.ts | 134 ---- packages/junior/src/chat/agent/tools.ts | 100 +-- packages/junior/src/chat/local/runner.ts | 29 +- .../junior/src/chat/plugins/agent-hooks.ts | 14 +- .../src/chat/runtime/delivered-turn-state.ts | 3 +- .../src/chat/runtime/generated-artifacts.ts | 2 +- .../junior/src/chat/runtime/reply-executor.ts | 12 +- .../junior/src/chat/runtime/slack-resume.ts | 7 +- .../junior/src/chat/runtime/thread-state.ts | 38 +- .../src/chat/runtime/turn-preparation.ts | 14 +- packages/junior/src/chat/sandbox/README.md | 15 +- .../junior/src/chat/sandbox/command-result.ts | 73 +++ packages/junior/src/chat/sandbox/errors.ts | 14 + packages/junior/src/chat/sandbox/ref.ts | 5 + .../sandbox/runtime-dependency-snapshots.ts | 16 +- packages/junior/src/chat/sandbox/sandbox.ts | 354 ++++------- packages/junior/src/chat/sandbox/session.ts | 305 +++++---- packages/junior/src/chat/sandbox/workspace.ts | 26 +- .../junior/src/chat/services/turn-result.ts | 13 +- .../src/chat/tool-support/normalize-result.ts | 14 +- .../src/chat/tool-support/pi-tool-adapter.ts | 10 +- .../chat/tool-support/structured-result.ts | 2 +- .../src/chat/tools/sandbox/file-uploads.ts | 2 +- .../component/misc/sandbox-executor.test.ts | 582 ++++++++++-------- .../runtime/agent-run-error-path.test.ts | 7 +- .../runtime/generated-artifacts.test.ts | 6 +- .../tool-support/pi-tool-adapter.test.ts | 98 ++- .../integration/agent-continue-slack.test.ts | 10 +- .../integration/local-agent-runner.test.ts | 8 +- .../mcp-oauth-callback-slack.test.ts | 3 +- .../plugin-auth-runtime-slack.test.ts | 38 +- .../runtime/agent-run-agent-continue.test.ts | 21 +- .../runtime/agent-run-provider-retry.test.ts | 21 +- ...gent-run-tool-timeout-continuation.test.ts | 104 ++-- .../integration/slack-channel-tools.test.ts | 5 +- .../misc/bash-tool-sandbox-adapter.test.ts | 14 +- .../unit/misc/noninteractive-command.test.ts | 4 +- .../tests/unit/plugins/agent-hooks.test.ts | 8 +- .../runtime/agent-run-lazy-sandbox.test.ts | 156 +++-- .../agent-run-mcp-progressive-loading.test.ts | 22 +- .../tool-support/normalize-result.test.ts | 65 +- 44 files changed, 1164 insertions(+), 1276 deletions(-) delete mode 100644 packages/junior/src/chat/agent/sandbox.ts create mode 100644 packages/junior/src/chat/sandbox/command-result.ts create mode 100644 packages/junior/src/chat/sandbox/ref.ts diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts index 767eeb640..c46e5eaf6 100644 --- a/packages/junior/src/chat/agent-dispatch/runner.ts +++ b/packages/junior/src/chat/agent-dispatch/runner.ts @@ -35,10 +35,12 @@ import { } from "@/chat/state/artifacts"; import { getChannelConfigurationServiceById, + getPersistedSandboxState, getPersistedThreadState, mergeArtifactsState, persistThreadStateById, } from "@/chat/runtime/thread-state"; +import type { SandboxRef } from "@/chat/sandbox/ref"; import { getStateAdapter } from "@/chat/state/adapter"; import { sendSlackReply } from "@/chat/slack/reply"; import { finalizeFailedTurnReplyWithEvent } from "@/chat/services/turn-failure-response"; @@ -110,15 +112,13 @@ function upsertDispatchUserMessage(args: { async function persistRuntimePatch(args: { artifacts?: ThreadArtifactsState; conversation: ThreadConversationState; - sandboxDependencyProfileHash?: string; - sandboxId?: string; + sandbox?: SandboxRef; threadId: string; }): Promise { await persistThreadStateById(args.threadId, { artifacts: args.artifacts, conversation: args.conversation, - sandboxId: args.sandboxId, - sandboxDependencyProfileHash: args.sandboxDependencyProfileHash, + sandbox: args.sandbox, }); } @@ -281,14 +281,7 @@ export async function runAgentDispatchSlice( } let artifacts = coerceThreadArtifactsState(persisted); - let sandboxId = - typeof persisted.app_sandbox_id === "string" - ? persisted.app_sandbox_id - : undefined; - let sandboxDependencyProfileHash = - typeof persisted.app_sandbox_dependency_profile_hash === "string" - ? persisted.app_sandbox_dependency_profile_hash - : undefined; + let sandbox = getPersistedSandboxState(persisted); const channelConfiguration = getChannelConfigurationServiceById( dispatch.destination.channelId, ); @@ -401,24 +394,19 @@ export async function runAgentDispatchSlice( }, state: { artifactState: artifacts, - sandbox: { - sandboxId, - sandboxDependencyProfileHash, - }, + sandbox, }, delivery: { onAssistantMessage: deliverAssistantMessage, }, durability: { - onSandboxAcquired: async (sandbox) => { - sandboxId = sandbox.sandboxId; - sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash; + onSandboxRefChanged: async (nextSandbox) => { + sandbox = nextSandbox; await persistRuntimePatch({ threadId: conversationId, conversation, artifacts, - sandboxId, - sandboxDependencyProfileHash, + sandbox, }); }, onArtifactStateUpdated: async (nextArtifacts) => { @@ -427,8 +415,7 @@ export async function runAgentDispatchSlice( threadId: conversationId, conversation, artifacts, - sandboxId, - sandboxDependencyProfileHash, + sandbox, }); }, }, @@ -503,9 +490,7 @@ export async function runAgentDispatchSlice( threadId: conversationId, conversation, artifacts: nextArtifacts, - sandboxId: reply.sandboxId ?? sandboxId, - sandboxDependencyProfileHash: - reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash, + sandbox: reply.sandbox ?? sandbox, }); }); statePersisted = true; diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 89e51b3ab..232a3e53b 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -265,9 +265,7 @@ async function executeAgentRunInPrivacyContext( const turnTimeoutBudgetMs = Math.max(0, turnDeadlineAtMs - replyStartedAtMs); let resume: ResumeState | undefined; - let lastKnownSandboxId: string | undefined = state.sandbox?.sandboxId; - let lastKnownSandboxDependencyProfileHash: string | undefined = - state.sandbox?.sandboxDependencyProfileHash; + let lastKnownSandboxRef = state.sandbox; let mcpToolManager: McpToolManager | undefined; let connectedMcpProviders = new Set(); let turnUsage: AgentTurnUsage | undefined; @@ -327,10 +325,7 @@ async function executeAgentRunInPrivacyContext( await recordConnectedMcpProvider(provider); } }; - const getSandboxMetadata = () => ({ - sandboxId: lastKnownSandboxId, - sandboxDependencyProfileHash: lastKnownSandboxDependencyProfileHash, - }); + const getSandboxMetadata = () => ({ sandbox: lastKnownSandboxRef }); try { const projection = await openConversationProjection({ conversationId }); @@ -648,10 +643,8 @@ async function executeAgentRunInPrivacyContext( generatedFiles, invokedSkill, observers, - onSandboxMetadataChanged: (sandbox) => { - lastKnownSandboxId = sandbox.sandboxId; - lastKnownSandboxDependencyProfileHash = - sandbox.sandboxDependencyProfileHash; + onSandboxRefChanged: (sandbox) => { + lastKnownSandboxRef = sandbox; }, policy, preAgentPromptMessages, @@ -671,7 +664,7 @@ async function executeAgentRunInPrivacyContext( userInput, }); mcpToolManager = wiring.mcpToolManager; - const sandboxExecutor = wiring.sandboxExecutor; + const getSandboxRef = wiring.getSandboxRef; const getPendingAuthPause = wiring.getPendingAuthPause; const toolsAfterHandoff = wiring.agentTools; @@ -1195,8 +1188,7 @@ async function executeAgentRunInPrivacyContext( userInput, artifactStatePatch, toolCalls, - sandboxId: sandboxExecutor.getSandboxId(), - sandboxDependencyProfileHash: sandboxExecutor.getDependencyProfileHash(), + sandbox: getSandboxRef(), piMessages: [...agent.state.messages], durationMs: Date.now() - replyStartedAtMs, generatedFileCount: generatedFiles.length, diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index 737bffee9..f79855e2e 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -17,7 +17,7 @@ import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import type { CredentialContext } from "@/chat/credentials/context"; import type { PiMessage } from "@/chat/pi/messages"; import type { Actor } from "@/chat/actor"; -import type { SandboxAcquiredState } from "@/chat/sandbox/sandbox"; +import type { SandboxRef } from "@/chat/sandbox/ref"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; import type { AuthorizationFlowMode } from "@/chat/services/auth-pause"; import type { AssistantStatusSpec } from "@/chat/slack/assistant-thread/status"; @@ -120,10 +120,7 @@ export interface AgentRunState { artifactState?: ThreadArtifactsState; pendingAuth?: ConversationPendingAuthState; /** Persisted sandbox reuse state from prior slices of this conversation. */ - sandbox?: { - sandboxId?: string; - sandboxDependencyProfileHash?: string; - }; + sandbox?: SandboxRef; } /** @@ -169,7 +166,7 @@ export interface AgentRunDurability { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; - onSandboxAcquired?: (sandbox: SandboxAcquiredState) => void | Promise; + onSandboxRefChanged?: (sandbox: SandboxRef) => void | Promise; onArtifactStateUpdated?: ( artifactState: ThreadArtifactsState, ) => void | Promise; diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts deleted file mode 100644 index 88ad9d00e..000000000 --- a/packages/junior/src/chat/agent/sandbox.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Run-scoped sandbox workspace. - * - * Exposes a stable workspace port to tools while deferring the actual - * sandbox boot until a tool first touches the filesystem or runs a command, - * and rebinding when the executor's sandbox identity changes mid-run. - */ -import { logInfo, type LogContext } from "@/chat/logging"; -import { - createSandboxUnavailableToolError, - type SandboxExecutor, -} from "@/chat/sandbox/sandbox"; -import { isSandboxUnavailableError } from "@/chat/sandbox/errors"; -import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; - -type LazySandboxExecutor = Pick< - SandboxExecutor, - "createSandbox" | "getSandboxId" | "getSessionId" ->; - -/** Create a lazy-boot workspace port bound to the run's sandbox executor. */ -export function createLazySandboxWorkspace(args: { - executor: LazySandboxExecutor; - spanContext: LogContext; -}): SandboxWorkspace { - interface SandboxBinding { - workspace: SandboxWorkspace; - sandboxId: string; - sessionId: string; - } - - let sandboxPromise: Promise | undefined; - let sandboxBinding: SandboxBinding | undefined; - const clearSandboxPromise = (expected?: Promise): void => { - if (expected && sandboxPromise !== expected) { - return; - } - sandboxPromise = undefined; - sandboxBinding = undefined; - }; - const getSandbox = (reason: { - trigger: string; - path?: string; - cmd?: string; - cwd?: string; - }): Promise => { - const currentSandboxId = args.executor.getSandboxId(); - const currentSessionId = args.executor.getSessionId(); - if ( - sandboxBinding && - (currentSandboxId !== sandboxBinding.sandboxId || - currentSessionId !== sandboxBinding.sessionId) - ) { - clearSandboxPromise(); - } - - if (!sandboxPromise) { - logInfo( - "sandbox_boot_requested", - args.spanContext, - { - "app.sandbox.boot.trigger": reason.trigger, - ...(reason.path ? { "file.path": reason.path } : {}), - ...(reason.cmd ? { "process.executable.name": reason.cmd } : {}), - ...(reason.cwd ? { "file.directory": reason.cwd } : {}), - }, - "Lazy sandbox boot requested", - ); - const nextSandboxPromise = args.executor - .createSandbox() - .then((workspace) => { - const binding = { - workspace, - sandboxId: workspace.sandboxId, - sessionId: workspace.sessionId, - }; - if (sandboxPromise === nextSandboxPromise) { - sandboxBinding = binding; - } - return binding; - }) - .catch((error) => { - clearSandboxPromise(nextSandboxPromise); - throw error; - }); - sandboxPromise = nextSandboxPromise; - } - return sandboxPromise; - }; - - /** Fail the current operation and discard the pinned workspace after lifecycle loss. */ - const runWithSandbox = async ( - reason: { - trigger: string; - path?: string; - cmd?: string; - cwd?: string; - }, - operation: (sandbox: SandboxWorkspace) => Promise, - ): Promise => { - const activeSandboxPromise = getSandbox(reason); - let binding: SandboxBinding | undefined; - try { - binding = await activeSandboxPromise; - return await operation(binding.workspace); - } catch (error) { - if (isSandboxUnavailableError(error)) { - clearSandboxPromise(activeSandboxPromise); - throw createSandboxUnavailableToolError(reason.trigger, error); - } - throw error; - } - }; - - return { - readFileToBuffer: async (input) => - await runWithSandbox( - { - trigger: "workspace.readFileToBuffer", - path: input.path, - }, - async (sandbox) => await sandbox.readFileToBuffer(input), - ), - runCommand: async (input) => - await runWithSandbox( - { - trigger: "workspace.runCommand", - cmd: input.cmd, - cwd: input.cwd, - }, - async (sandbox) => await sandbox.runCommand(input), - ), - }; -} diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 5443f5848..0151bf29a 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -2,7 +2,7 @@ * Run tool wiring. * * Builds everything the agent can act through for one run slice: the sandbox - * executor and lazy workspace, MCP and plugin auth orchestration, MCP + * access, MCP and plugin auth orchestration, MCP * provider restoration from durable history, and the Pi-facing tool surfaces * (main-agent tools plus runtime control tools). Auth pauses raised while * restoring providers are thrown here so the run parks before prompting. @@ -30,10 +30,9 @@ import { } from "@/chat/tool-support/skill/mcp-tool-summary"; import { createPiAgentTools } from "@/chat/tool-support/pi-tool-adapter"; import { planToolExposure } from "@/chat/tool-exposure"; -import { - createSandboxExecutor, - type SandboxExecutor, -} from "@/chat/sandbox/sandbox"; +import { createSandbox, type SandboxTools } from "@/chat/sandbox/sandbox"; +import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; +import type { SandboxRef } from "@/chat/sandbox/ref"; import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration"; import { createPluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; import { createPluginEgress } from "@/chat/egress/plugin"; @@ -54,7 +53,6 @@ import { type AgentRunRouting, type AgentRunState, } from "@/chat/agent/request"; -import { createLazySandboxWorkspace } from "@/chat/agent/sandbox"; import { upsertActiveSkill } from "@/chat/agent/skills"; import type { ResumeState } from "@/chat/agent/resume"; import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts"; @@ -75,10 +73,7 @@ interface ToolWiringArgs { generatedFiles: FileUpload[]; invokedSkill: SkillMetadata | null; observers: AgentRunObservers; - onSandboxMetadataChanged: (sandbox: { - sandboxId?: string; - sandboxDependencyProfileHash?: string; - }) => void; + onSandboxRefChanged: (sandbox: SandboxRef) => void; policy: AgentRunPolicy; preAgentPromptMessages: () => PiMessage[]; priorPiMessages: PiMessage[] | undefined; @@ -103,7 +98,7 @@ export interface ToolWiring { getPendingAuthPause: () => AuthorizationPauseError | undefined; mcpToolManager: McpToolManager; pluginHooks: PluginHookRunner; - sandboxExecutor: SandboxExecutor; + getSandboxRef: () => SandboxRef | undefined; toolGuidance: Array<{ name: string; promptGuidelines: AnyToolDefinition["promptGuidelines"]; @@ -125,47 +120,52 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); - const sandboxExecutor = createSandboxExecutor({ - sandboxId: args.state.sandbox?.sandboxId, - sandboxDependencyProfileHash: - args.state.sandbox?.sandboxDependencyProfileHash, + const sandbox = createSandbox({ + ref: args.state.sandbox, + skills: args.availableSkills, + referenceFiles: listReferenceFiles(), traceContext: args.spanContext, tracePropagation: args.policy.sandboxTracePropagation, credentialEgress: args.routing.credentialContext, - agentHooks: pluginHooks, - onSandboxAcquired: async (sandbox) => { - args.onSandboxMetadataChanged({ - sandboxId: sandbox.sandboxId, - sandboxDependencyProfileHash: sandbox.sandboxDependencyProfileHash, - }); - await args.durability.onSandboxAcquired?.(sandbox); + prepare: async (workspace) => { + await pluginHooks.prepareSandbox(workspace); }, - runBashCustomCommand: async (command) => { - const result = await maybeExecuteJrRpcCustomCommand(command, { - activeSkill: args.skillSandbox.getActiveSkill(), - channelConfiguration: args.policy.channelConfiguration, - actorId: isUserActor(args.currentActor) - ? args.currentActor.userId - : undefined, - onConfigurationValueChanged: (key, value) => { - if (value === undefined) { - delete args.configurationValues[key]; - return; - } - args.configurationValues[key] = value; - }, - }); - return result.handled - ? { handled: true, result: result.result } - : { handled: false }; + onRefChanged: async (ref) => { + args.onSandboxRefChanged(ref); + await args.durability.onSandboxRefChanged?.(ref); }, }); - sandboxExecutor.configureSkills(args.availableSkills); - sandboxExecutor.configureReferenceFiles(listReferenceFiles()); - const sandbox = createLazySandboxWorkspace({ - executor: sandboxExecutor, - spanContext: args.spanContext, - }); + const sandboxTools: SandboxTools = { + supports: sandbox.tools.supports, + async execute(params) { + const command = + params.toolName === "bash" + ? String( + (params.input as { command?: unknown })?.command ?? "", + ).trim() + : undefined; + if (command) { + const result = await maybeExecuteJrRpcCustomCommand(command, { + activeSkill: args.skillSandbox.getActiveSkill(), + channelConfiguration: args.policy.channelConfiguration, + actorId: isUserActor(args.currentActor) + ? args.currentActor.userId + : undefined, + onConfigurationValueChanged: (key, value) => { + if (value === undefined) { + delete args.configurationValues[key]; + return; + } + args.configurationValues[key] = value; + }, + }); + if (result.handled) { + return formatSandboxCommandResult(result.result); + } + } + return await sandbox.tools.execute(params); + }, + }; const slackDestination = args.routing.destination.platform === "slack" @@ -256,7 +256,7 @@ export async function wireAgentTools( }, }), mcpToolManager, - sandbox, + sandbox: sandbox.workspace, surface: args.surface, ...(args.requestHandoff ? { handoff: args.requestHandoff } : {}), }; @@ -291,7 +291,7 @@ export async function wireAgentTools( { writeGeneratedArtifacts: async (files) => { const refs = await writeSandboxGeneratedArtifacts( - await sandboxExecutor.createSandbox(), + sandbox.workspace, files, ); args.generatedFiles.push(...files); @@ -430,7 +430,7 @@ export async function wireAgentTools( args.skillSandbox, args.spanContext, args.observers.onStatus, - sandboxExecutor, + sandboxTools, pluginAuth, onToolCall, pluginHooks, @@ -450,7 +450,7 @@ export async function wireAgentTools( getPendingAuthPause, mcpToolManager, pluginHooks, - sandboxExecutor, + getSandboxRef: sandbox.ref, toolGuidance, toolRuntimeContext, }; diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 8ed6a33d1..83e1ad0a6 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -173,11 +173,9 @@ export async function runLocalAgentTurn( conversationId: input.conversationId, }); let artifacts = coerceThreadArtifactsState(persisted); - let { sandboxId, sandboxDependencyProfileHash } = - getPersistedSandboxState(persisted); + let sandbox = getPersistedSandboxState(persisted); const initialArtifacts = artifacts; - const initialSandboxId = sandboxId; - const initialSandboxDependencyProfileHash = sandboxDependencyProfileHash; + const initialSandbox = sandbox; const turnId = localTurnId(); const userMessageId = `${turnId}:user`; @@ -293,10 +291,7 @@ export async function runLocalAgentTurn( }, state: { artifactState: artifacts, - sandbox: { - sandboxId, - sandboxDependencyProfileHash, - }, + sandbox, }, observers: { onStatus: async (status) => { @@ -318,18 +313,15 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts, conversation, - sandboxId, - sandboxDependencyProfileHash, + sandbox, }); }, - onSandboxAcquired: async (sandbox) => { - sandboxId = sandbox.sandboxId; - sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash; + onSandboxRefChanged: async (nextSandbox) => { + sandbox = nextSandbox; await persistThreadStateById(input.conversationId, { artifacts, conversation, - sandboxId, - sandboxDependencyProfileHash, + sandbox, }); }, }, @@ -384,8 +376,7 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts: initialArtifacts, conversation, - sandboxId: initialSandboxId ?? "", - sandboxDependencyProfileHash: initialSandboxDependencyProfileHash ?? "", + sandbox: initialSandbox ?? null, }); } catch (persistenceError) { const persistenceEventId = captureLocalBoundaryFailure({ @@ -421,9 +412,7 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts: completedState.artifacts ?? artifacts, conversation: completedState.conversation, - sandboxId: reply.sandboxId ?? sandboxId, - sandboxDependencyProfileHash: - reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash, + sandbox: reply.sandbox ?? sandbox, }); if (reply.piMessages?.length) { // Destination acceptance is the completion boundary: this first commits diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 33ce8253f..9158add96 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -31,7 +31,7 @@ import { getSlackToolContext } from "@/chat/slack/tools/context"; import type { ToolRuntimeContext } from "@/chat/tools/types"; import type { SandboxCommandInput, - SandboxSession, + SandboxWorkspace, } from "@/chat/sandbox/workspace"; import { createSlackDirectCredentialSubject } from "@/chat/credentials/subject"; import { resolveChannelCapabilities } from "@/chat/slack/tools/channel-capabilities"; @@ -67,7 +67,7 @@ export interface PluginApiRouteRegistration { export interface PluginHookRunner { beforeToolExecute(input: ToolHookInput): Promise; - prepareSandbox(sandbox: SandboxSession): Promise; + prepareSandbox(sandbox: SandboxWorkspace): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1005,7 +1005,7 @@ function normalizeEnv(value: unknown): Record { return env; } -function createSandboxCapability(sandbox: SandboxSession): PluginSandbox { +function createSandboxCapability(sandbox: SandboxWorkspace): PluginSandbox { return { root: SANDBOX_WORKSPACE_ROOT, juniorRoot: `${SANDBOX_WORKSPACE_ROOT}/.junior`, @@ -1014,14 +1014,10 @@ function createSandboxCapability(sandbox: SandboxSession): PluginSandbox { }, async run(input: SandboxCommandInput) { const result = await sandbox.runCommand(input); - const [stdout, stderr] = await Promise.all([ - result.stdout(), - result.stderr(), - ]); return { exitCode: result.exitCode, - stdout, - stderr, + stdout: result.stdout, + stderr: result.stderr, }; }, async writeFile(input) { diff --git a/packages/junior/src/chat/runtime/delivered-turn-state.ts b/packages/junior/src/chat/runtime/delivered-turn-state.ts index 763417423..577fe0d14 100644 --- a/packages/junior/src/chat/runtime/delivered-turn-state.ts +++ b/packages/junior/src/chat/runtime/delivered-turn-state.ts @@ -46,7 +46,6 @@ export function buildDeliveredTurnStatePatch(args: { return { artifacts, conversation, - sandboxId: args.reply.sandboxId, - sandboxDependencyProfileHash: args.reply.sandboxDependencyProfileHash, + sandbox: args.reply.sandbox, }; } diff --git a/packages/junior/src/chat/runtime/generated-artifacts.ts b/packages/junior/src/chat/runtime/generated-artifacts.ts index a99405c9b..ea1b99155 100644 --- a/packages/junior/src/chat/runtime/generated-artifacts.ts +++ b/packages/junior/src/chat/runtime/generated-artifacts.ts @@ -43,7 +43,7 @@ export async function writeSandboxGeneratedArtifacts( }); if (mkdir.exitCode !== 0) { throw new Error( - `failed to create generated artifact directory: ${await mkdir.stderr()}`, + `failed to create generated artifact directory: ${mkdir.stderr}`, ); } diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 11680b085..36651c2ed 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -1313,11 +1313,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { state: { artifactState: preparedState.artifacts, pendingAuth: preparedState.conversation.processing.pendingAuth, - sandbox: { - sandboxId: preparedState.sandboxId, - sandboxDependencyProfileHash: - preparedState.sandboxDependencyProfileHash, - }, + sandbox: preparedState.sandbox, }, observers: { onStatus: (nextStatus) => status.update(nextStatus), @@ -1330,11 +1326,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { onInputCommitted: options.ack, drainSteeringMessages, shouldYield: options.shouldYield, - onSandboxAcquired: async (sandbox) => { + onSandboxRefChanged: async (sandbox) => { await persistThreadState(thread, { - sandboxId: sandbox.sandboxId, - sandboxDependencyProfileHash: - sandbox.sandboxDependencyProfileHash, + sandbox, }); }, onArtifactStateUpdated: async (artifacts) => { diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index 2d88e0f77..758f4465c 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -387,12 +387,11 @@ function createResumeReplyContext( delivery, durability: { ...replyContext.durability, - onSandboxAcquired: async (sandbox) => { + onSandboxRefChanged: async (sandbox) => { await persistThreadStateById(threadId, { - sandboxId: sandbox.sandboxId, - sandboxDependencyProfileHash: sandbox.sandboxDependencyProfileHash, + sandbox, }); - await replyContext.durability?.onSandboxAcquired?.(sandbox); + await replyContext.durability?.onSandboxRefChanged?.(sandbox); }, onArtifactStateUpdated: async (artifacts) => { await persistThreadStateById(threadId, { artifacts }); diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index 841ee5267..4ec9d4bef 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -11,17 +11,12 @@ import { } from "@/chat/state/artifacts"; import { getStateAdapter } from "@/chat/state/adapter"; import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; +import type { SandboxRef } from "@/chat/sandbox/ref"; export interface ThreadStatePatch { artifacts?: ThreadArtifactsState; conversation?: ThreadConversationState; - sandboxId?: string; - sandboxDependencyProfileHash?: string; -} - -export interface PersistedSandboxState { - sandboxDependencyProfileHash?: string; - sandboxId?: string; + sandbox?: SandboxRef | null; } function threadStateKey(threadId: string): string { @@ -42,12 +37,14 @@ function buildThreadStatePayload( if (patch.conversation) { Object.assign(payload, buildConversationStatePatch(patch.conversation)); } - if (patch.sandboxId !== undefined) { - payload.app_sandbox_id = patch.sandboxId; - } - if (patch.sandboxDependencyProfileHash !== undefined) { - payload.app_sandbox_dependency_profile_hash = - patch.sandboxDependencyProfileHash; + if (patch.sandbox === null) { + payload.app_sandbox_id = ""; + payload.app_sandbox_dependency_profile_hash = ""; + } else if (patch.sandbox) { + payload.app_sandbox_id = patch.sandbox.id; + if (patch.sandbox.profileHash !== undefined) { + payload.app_sandbox_dependency_profile_hash = patch.sandbox.profileHash; + } } return payload; } @@ -73,12 +70,17 @@ export function mergeArtifactsState( /** Extract persisted sandbox metadata from thread state payload. */ export function getPersistedSandboxState( state: Record, -): PersistedSandboxState { +): SandboxRef | undefined { + const id = toOptionalString(state.app_sandbox_id); + if (!id) { + return undefined; + } + const profileHash = toOptionalString( + state.app_sandbox_dependency_profile_hash, + ); return { - sandboxId: toOptionalString(state.app_sandbox_id), - sandboxDependencyProfileHash: toOptionalString( - state.app_sandbox_dependency_profile_hash, - ), + id, + ...(profileHash ? { profileHash } : {}), }; } diff --git a/packages/junior/src/chat/runtime/turn-preparation.ts b/packages/junior/src/chat/runtime/turn-preparation.ts index 82ece91ad..fdb2d284f 100644 --- a/packages/junior/src/chat/runtime/turn-preparation.ts +++ b/packages/junior/src/chat/runtime/turn-preparation.ts @@ -15,6 +15,7 @@ import type { import { toOptionalString } from "@/chat/coerce"; import { setSpanAttributes } from "@/chat/logging"; import { getThreadTs } from "@/chat/runtime/thread-context"; +import type { SandboxRef } from "@/chat/sandbox/ref"; import { coerceThreadArtifactsState, type ThreadArtifactsState, @@ -52,8 +53,7 @@ export interface PreparedTurnState { channelConfiguration?: ChannelConfigurationService; conversation: ThreadConversationState; conversationContext?: string; - sandboxId?: string; - sandboxDependencyProfileHash?: string; + sandbox?: SandboxRef; userMessageAlreadyReplied?: boolean; userMessageId?: string; } @@ -275,8 +275,14 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { configuration, channelConfiguration, conversation, - sandboxId: existingSandboxId, - sandboxDependencyProfileHash: existingSandboxDependencyProfileHash, + sandbox: existingSandboxId + ? { + id: existingSandboxId, + ...(existingSandboxDependencyProfileHash + ? { profileHash: existingSandboxDependencyProfileHash } + : {}), + } + : undefined, conversationContext, userMessageAlreadyReplied, userMessageId, diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index c773ca161..7b1b509ae 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -8,10 +8,21 @@ traffic through verified host egress. - Sandboxes are ephemeral execution environments associated with a durable conversation or run. -- Persist only the sandbox identifier and durable artifact metadata needed to - resume work; do not treat the sandbox filesystem as product storage. +- Runtime state persists only an opaque `SandboxRef` (`id` and dependency + profile hash). The provider adapter maps that reference to Vercel's named + sandbox API; callers do not depend on provider names or VM session ids. +- Each agent run creates lazy sandbox access from the persisted reference. + `workspace` serves non-sandbox tools and generated artifacts, while `tools` + serves the Pi sandbox tool adapter. The live provider session stays private + to this module. - An unavailable session fails the current operation without replay, retains its sandbox identifier, and reacquires a session only on a later operation. +- New or replacement references are persisted before session preparation can + perform further asynchronous work. Reacquiring a VM session for the same + reference does not rewrite durable state. +- Agent runs do not stop sandboxes when they finish. Explicit temporary owners, + such as dependency snapshot creation, own their own stop lifecycle. +- Do not treat the sandbox filesystem as product storage. - Commands are non-interactive and bounded by runtime deadlines. - Generated files become shareable only after artifact validation and destination-aware delivery planning. diff --git a/packages/junior/src/chat/sandbox/command-result.ts b/packages/junior/src/chat/sandbox/command-result.ts new file mode 100644 index 000000000..a4a55e8ff --- /dev/null +++ b/packages/junior/src/chat/sandbox/command-result.ts @@ -0,0 +1,73 @@ +import type { + SandboxEgressAuthRequiredSignal, + SandboxEgressPermissionDeniedSignal, +} from "@/chat/sandbox/egress/session"; +import { makeStructuredToolResult } from "@/chat/tool-support/structured-result"; + +export interface SandboxCommandOutcome { + ok: boolean; + command: string; + cwd: string; + exit_code: number; + signal: null; + timed_out: boolean; + aborted?: boolean; + stdout: string; + stderr: string; + stdout_truncated: boolean; + stderr_truncated: boolean; + auth_required?: SandboxEgressAuthRequiredSignal; + permission_denied?: SandboxEgressPermissionDeniedSignal; +} + +/** Format one shell command outcome as Junior's structured tool result. */ +export function formatSandboxCommandResult(params: SandboxCommandOutcome) { + return makeStructuredToolResult({ + ok: params.ok, + status: params.ok ? "success" : "error", + target: params.command, + data: { + command: params.command, + cwd: params.cwd, + exit_code: params.exit_code, + signal: params.signal, + timed_out: params.timed_out, + aborted: Boolean(params.aborted), + stdout: params.stdout, + stderr: params.stderr, + stdout_truncated: params.stdout_truncated, + stderr_truncated: params.stderr_truncated, + }, + truncated: params.stdout_truncated || params.stderr_truncated, + ...(!params.ok + ? { + error: { + kind: params.aborted + ? "outcome_unknown" + : params.timed_out + ? "timeout" + : "nonzero_exit", + message: params.aborted + ? "Command was interrupted before its outcome was confirmed. It may have produced side effects; reconcile external state before retrying or reporting failure." + : params.stderr.trim() || + `Command exited with code ${params.exit_code}`, + retryable: params.timed_out, + }, + } + : {}), + command: params.command, + cwd: params.cwd, + exit_code: params.exit_code, + signal: params.signal, + timed_out: params.timed_out, + aborted: Boolean(params.aborted), + stdout: params.stdout, + stderr: params.stderr, + stdout_truncated: params.stdout_truncated, + stderr_truncated: params.stderr_truncated, + ...(params.auth_required ? { auth_required: params.auth_required } : {}), + ...(params.permission_denied + ? { permission_denied: params.permission_denied } + : {}), + }); +} diff --git a/packages/junior/src/chat/sandbox/errors.ts b/packages/junior/src/chat/sandbox/errors.ts index 92f405fe4..d889a166b 100644 --- a/packages/junior/src/chat/sandbox/errors.ts +++ b/packages/junior/src/chat/sandbox/errors.ts @@ -108,6 +108,20 @@ export function isSandboxUnavailableError(error: unknown): boolean { }); } +/** Detect a durable sandbox reference that can no longer be restored. */ +export function isSandboxMissingError(error: unknown): boolean { + return findInErrorChain(error, (candidate) => { + const details = getSandboxErrorDetails(candidate); + const searchable = + `${details.searchableText} ${details.summary}`.toLowerCase(); + return ( + searchable.includes("status=404") || + searchable.includes("status code 404") || + searchable.includes("snapshot_not_found") + ); + }); +} + /** Detect transient snapshot boot races so sandbox creation can retry. */ export function isSnapshottingError(error: unknown): boolean { return findInErrorChain(error, (candidate) => { diff --git a/packages/junior/src/chat/sandbox/ref.ts b/packages/junior/src/chat/sandbox/ref.ts new file mode 100644 index 000000000..b04a37a83 --- /dev/null +++ b/packages/junior/src/chat/sandbox/ref.ts @@ -0,0 +1,5 @@ +/** Durable opaque handle used to reopen a conversation's sandbox. */ +export interface SandboxRef { + id: string; + profileHash?: string; +} diff --git a/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts b/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts index 99c8a8f0b..16f1096eb 100644 --- a/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts +++ b/packages/junior/src/chat/sandbox/runtime-dependency-snapshots.ts @@ -220,8 +220,8 @@ async function runOrThrow( return; } - const stderr = (await result.stderr()).trim(); - const stdout = (await result.stdout()).trim(); + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); const detail = stderr || stdout || "command failed"; throw new Error(`${label} failed: ${detail}`); } @@ -239,8 +239,8 @@ async function tryRun( return { ok: true }; } - const stderr = (await result.stderr()).trim(); - const stdout = (await result.stdout()).trim(); + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); return { ok: false, detail: stderr || stdout || "command failed" }; } @@ -355,8 +355,8 @@ async function installRuntimeDependencies( cmd: "sha256sum", args: [rpmPath], }); - const checksumStdout = (await checksumResult.stdout()).trim(); - const checksumStderr = (await checksumResult.stderr()).trim(); + const checksumStdout = checksumResult.stdout.trim(); + const checksumStderr = checksumResult.stderr.trim(); if (checksumResult.exitCode !== 0) { throw new Error( `sha256sum failed: ${checksumStderr || checksumStdout || "command failed"}`, @@ -458,8 +458,8 @@ async function runRuntimePostinstall( continue; } - const stderr = (await result.stderr()).trim(); - const stdout = (await result.stdout()).trim(); + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); const detail = stderr || stdout || "command failed"; throw new Error(`runtime-postinstall ${command.cmd} failed: ${detail}`); } diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 125d44a4c..9598b0ede 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -16,9 +16,8 @@ import { consumeSandboxEgressAuthRequiredSignal, consumeSandboxEgressPermissionDeniedSignal, createSandboxEgressCredentialToken, - type SandboxEgressAuthRequiredSignal, - type SandboxEgressPermissionDeniedSignal, } from "@/chat/sandbox/egress/session"; +import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; import type { CredentialContext } from "@/chat/credentials/context"; import { @@ -27,14 +26,17 @@ import { throwSandboxOperationError, } from "@/chat/sandbox/errors"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; -import { createSandboxSessionManager } from "@/chat/sandbox/session"; -import type { PluginHookRunner } from "@/chat/plugins/agent-hooks"; +import { createSandboxRuntime } from "@/chat/sandbox/session"; import { isHostFileMissingError, resolveHostDataPath, resolveHostSkillPath, } from "@/chat/sandbox/skill-sync"; -import type { SandboxSession } from "@/chat/sandbox/workspace"; +import type { SandboxRef } from "@/chat/sandbox/ref"; +import type { + SandboxSession, + SandboxWorkspace, +} from "@/chat/sandbox/workspace"; import type { SkillMetadata } from "@/chat/skills"; import { editFile } from "@/chat/tools/sandbox/edit-file"; import { findFiles } from "@/chat/tools/sandbox/find-files"; @@ -52,53 +54,37 @@ import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { makeStructuredToolResult } from "@/chat/tool-support/structured-result"; // Policies: policies/security.md and policies/observability.md. -interface SandboxExecutionInput { +export interface SandboxToolCall { toolName: string; input: unknown; signal?: AbortSignal; } -export interface SandboxExecutionEnvelope { - result: T; +export interface SandboxTools { + supports(toolName: string): boolean; + execute(params: SandboxToolCall): Promise; } -export interface BashCustomCommandResult { - ok: boolean; - command: string; - cwd: string; - exit_code: number; - signal: null; - timed_out: boolean; - aborted?: boolean; - stdout: string; - stderr: string; - stdout_truncated: boolean; - stderr_truncated: boolean; - auth_required?: SandboxEgressAuthRequiredSignal; - permission_denied?: SandboxEgressPermissionDeniedSignal; +export interface Sandbox { + readonly tools: SandboxTools; + readonly workspace: SandboxWorkspace; + ref(): SandboxRef | undefined; } -export interface SandboxAcquiredState { - sandboxId: string; - sandboxDependencyProfileHash?: string; -} - -export interface SandboxExecutor { - configureSkills(skills: SkillMetadata[]): void; - configureReferenceFiles(files: string[]): void; - getSandboxId(): string | undefined; - getSessionId(): string | undefined; - getDependencyProfileHash(): string | undefined; - canExecute(toolName: string): boolean; - createSandbox(): Promise; - execute( - params: SandboxExecutionInput, - ): Promise>; - dispose(): Promise; +export interface SandboxOptions { + ref?: SandboxRef; + skills: SkillMetadata[]; + referenceFiles: string[]; + timeoutMs?: number; + traceContext?: LogContext; + tracePropagation?: SandboxEgressTracePropagationConfig; + credentialEgress?: CredentialContext; + prepare?: (workspace: SandboxWorkspace) => void | Promise; + onRefChanged?: (ref: SandboxRef) => void | Promise; } /** Create the safe expected tool error for an unavailable sandbox operation. */ -export function createSandboxUnavailableToolError( +function createSandboxUnavailableToolError( operation: string, cause: unknown, ): ToolInputError { @@ -130,81 +116,16 @@ function parseEnv(raw: unknown): Record | undefined { ); } -function bashToolResult(params: BashCustomCommandResult) { - return makeStructuredToolResult({ - ok: params.ok, - status: params.ok ? "success" : "error", - target: params.command, - data: { - command: params.command, - cwd: params.cwd, - exit_code: params.exit_code, - signal: params.signal, - timed_out: params.timed_out, - aborted: Boolean(params.aborted), - stdout: params.stdout, - stderr: params.stderr, - stdout_truncated: params.stdout_truncated, - stderr_truncated: params.stderr_truncated, - }, - truncated: params.stdout_truncated || params.stderr_truncated, - ...(!params.ok - ? { - error: { - kind: params.aborted - ? "outcome_unknown" - : params.timed_out - ? "timeout" - : "nonzero_exit", - message: params.aborted - ? "Command was interrupted before its outcome was confirmed. It may have produced side effects; reconcile external state before retrying or reporting failure." - : params.stderr.trim() || - `Command exited with code ${params.exit_code}`, - retryable: params.timed_out, - }, - } - : {}), - command: params.command, - cwd: params.cwd, - exit_code: params.exit_code, - signal: params.signal, - timed_out: params.timed_out, - aborted: Boolean(params.aborted), - stdout: params.stdout, - stderr: params.stderr, - stdout_truncated: params.stdout_truncated, - stderr_truncated: params.stderr_truncated, - ...(params.auth_required ? { auth_required: params.auth_required } : {}), - ...(params.permission_denied - ? { permission_denied: params.permission_denied } - : {}), - }); -} - -/** Create one sandbox-backed tool executor facade for the current turn. */ -export function createSandboxExecutor(options?: { - sandboxId?: string; - sandboxDependencyProfileHash?: string; - timeoutMs?: number; - traceContext?: LogContext; - tracePropagation?: SandboxEgressTracePropagationConfig; - credentialEgress?: CredentialContext; - agentHooks?: PluginHookRunner; - onSandboxAcquired?: (sandbox: SandboxAcquiredState) => void | Promise; - runBashCustomCommand?: ( - command: string, - ) => Promise<{ handled: boolean; result?: BashCustomCommandResult }>; -}): SandboxExecutor { - let availableSkills: SkillMetadata[] = []; - let referenceFiles: string[] = []; - const traceContext = options?.traceContext ?? {}; - const tracePropagation = options?.tracePropagation; +/** Create lazy run-scoped access to a conversation's durable sandbox. */ +export function createSandbox(options: SandboxOptions): Sandbox { + const traceContext = options.traceContext ?? {}; + const tracePropagation = options.tracePropagation; const hasTracePropagationDomains = (tracePropagation?.domains?.length ?? 0) > 0; - const credentialEgress = options?.credentialEgress; + const credentialEgress = options.credentialEgress; const sandboxEgressTokenTtlMs = Math.max( 1, - options?.timeoutMs ?? 1000 * 60 * 30, + options.timeoutMs ?? 1000 * 60 * 30, ); const sandboxEgressCredentialTokens = new Map< string, @@ -230,10 +151,11 @@ export function createSandboxExecutor(options?: { }); return token; }; - const sessionManager = createSandboxSessionManager({ - sandboxId: options?.sandboxId, - sandboxDependencyProfileHash: options?.sandboxDependencyProfileHash, - timeoutMs: options?.timeoutMs, + const runtime = createSandboxRuntime({ + ref: options.ref, + skills: options.skills, + referenceFiles: options.referenceFiles, + timeoutMs: options.timeoutMs, traceContext, commandEnv: credentialEgress ? async () => await resolveSandboxCommandEnvironment() @@ -252,10 +174,10 @@ export function createSandboxExecutor(options?: { }) : undefined, onSandboxPrepare: async (sandbox) => { - await options?.agentHooks?.prepareSandbox(sandbox); + await options.prepare?.(sandbox); }, - onSandboxAcquired: async (sandbox) => { - await options?.onSandboxAcquired?.(sandbox); + onRefChanged: async (ref) => { + await options.onRefChanged?.(ref); }, }); @@ -274,7 +196,7 @@ export function createSandboxExecutor(options?: { callback: () => Promise, ): Promise => withSandboxSpan(name, op, attributes, async () => { - await sessionManager.refreshNetworkPolicy(getTracePropagationHeaders()); + await runtime.refreshNetworkPolicy(getTracePropagationHeaders()); return await callback(); }); @@ -282,7 +204,7 @@ export function createSandboxExecutor(options?: { trigger: string, details: Record = {}, ): void => { - if (sessionManager.getSandboxId()) { + if (runtime.ref()) { return; } @@ -301,14 +223,14 @@ export function createSandboxExecutor(options?: { rawInput: Record, command: string, signal?: AbortSignal, - ): Promise> => { + ): Promise => { const env = parseEnv(rawInput.env); const timeoutMs = positiveInteger(rawInput.timeoutMs); logSandboxBootRequest("tool.bash", { "app.sandbox.command_length": command.length, }); - const executeBash = (await sessionManager.ensureToolExecutors()).bash; - const activeSessionId = sessionManager.getSessionId(); + const { bash: executeBash, sessionId: activeSessionId } = + await runtime.tools(); await clearSandboxEgressSignals(activeSessionId); const result = await withSandboxToolSpan( "bash", @@ -364,28 +286,26 @@ export function createSandboxExecutor(options?: { const permissionDenied = await consumeSandboxEgressPermissionDeniedSignal(activeSessionId); - return { - result: bashToolResult({ - ok: result.exitCode === 0, - command, - cwd: SANDBOX_WORKSPACE_ROOT, - exit_code: result.exitCode, - signal: null, - timed_out: Boolean(result.timedOut), - aborted: Boolean(result.aborted), - stdout: result.stdout, - stderr: result.stderr, - stdout_truncated: result.stdoutTruncated, - stderr_truncated: result.stderrTruncated, - ...(authRequired ? { auth_required: authRequired } : {}), - ...(permissionDenied ? { permission_denied: permissionDenied } : {}), - }) as T, - }; + return formatSandboxCommandResult({ + ok: result.exitCode === 0, + command, + cwd: SANDBOX_WORKSPACE_ROOT, + exit_code: result.exitCode, + signal: null, + timed_out: Boolean(result.timedOut), + aborted: Boolean(result.aborted), + stdout: result.stdout, + stderr: result.stderr, + stdout_truncated: result.stdoutTruncated, + stderr_truncated: result.stderrTruncated, + ...(authRequired ? { auth_required: authRequired } : {}), + ...(permissionDenied ? { permission_denied: permissionDenied } : {}), + }) as T; }; const executeReadFileTool = async ( rawInput: Record, - ): Promise> => { + ): Promise => { const filePath = String(rawInput.path ?? "").trim(); if (!filePath) { throw new ToolInputError("path is required"); @@ -393,10 +313,10 @@ export function createSandboxExecutor(options?: { const offset = positiveInteger(rawInput.offset); const limit = positiveInteger(rawInput.limit); - if (!sessionManager.getSandboxId()) { + if (!runtime.ref()) { const hostPath = - resolveHostSkillPath(availableSkills, filePath) ?? - resolveHostDataPath(referenceFiles, filePath); + resolveHostSkillPath(options.skills, filePath) ?? + resolveHostDataPath(options.referenceFiles, filePath); if (hostPath) { try { const content = await fs.readFile(hostPath, "utf8"); @@ -407,14 +327,12 @@ export function createSandboxExecutor(options?: { "app.skill.virtual_read": true, }); setSpanStatus("ok"); - return { - result: sliceFileContent({ - content, - path: filePath, - offset, - limit, - }) as T, - }; + return sliceFileContent({ + content, + path: filePath, + offset, + limit, + }) as T; } catch (error) { if (!isHostFileMissingError(error)) { throw error; @@ -426,8 +344,7 @@ export function createSandboxExecutor(options?: { logSandboxBootRequest("tool.readFile", { "file.path": filePath, }); - const executeReadFile = (await sessionManager.ensureToolExecutors()) - .readFile; + const executeReadFile = (await runtime.tools()).readFile; const result = await withSandboxToolSpan( "sandbox.readFile", "sandbox.fs.read", @@ -462,12 +379,12 @@ export function createSandboxExecutor(options?: { }, ); - return { result: result as T }; + return result as T; }; const executeWriteFileTool = async ( rawInput: Record, - ): Promise> => { + ): Promise => { const filePath = String(rawInput.path ?? "").trim(); if (!filePath) { throw new ToolInputError("path is required"); @@ -477,8 +394,7 @@ export function createSandboxExecutor(options?: { logSandboxBootRequest("tool.writeFile", { "file.path": filePath, }); - const executeWriteFile = (await sessionManager.ensureToolExecutors()) - .writeFile; + const executeWriteFile = (await runtime.tools()).writeFile; await withSandboxToolSpan( "sandbox.writeFile", "sandbox.fs.write", @@ -496,24 +412,22 @@ export function createSandboxExecutor(options?: { }, ); - return { - result: makeStructuredToolResult({ - ok: true, - status: "success", - target: filePath, - data: { - bytes_written: Buffer.byteLength(content, "utf8"), - path: filePath, - }, - path: filePath, + return makeStructuredToolResult({ + ok: true, + status: "success", + target: filePath, + data: { bytes_written: Buffer.byteLength(content, "utf8"), - }) as T, - }; + path: filePath, + }, + path: filePath, + bytes_written: Buffer.byteLength(content, "utf8"), + }) as T; }; const executeEditFileTool = async ( rawInput: Record, - ): Promise> => { + ): Promise => { const filePath = String(rawInput.path ?? "").trim(); if (!filePath) { throw new ToolInputError("path is required"); @@ -525,7 +439,7 @@ export function createSandboxExecutor(options?: { logSandboxBootRequest("tool.editFile", { "file.path": filePath, }); - const executors = await sessionManager.ensureToolExecutors(); + const executors = await runtime.tools(); const result = await withSandboxToolSpan( "sandbox.editFile", "sandbox.fs.edit", @@ -544,12 +458,12 @@ export function createSandboxExecutor(options?: { }, ); - return { result: result as T }; + return result as T; }; const executeGrepTool = async ( rawInput: Record, - ): Promise> => { + ): Promise => { const pattern = String(rawInput.pattern ?? ""); if (!pattern) { throw new ToolInputError("pattern is required"); @@ -558,7 +472,7 @@ export function createSandboxExecutor(options?: { logSandboxBootRequest("tool.grep"); const contextLines = positiveInteger(rawInput.context); const limit = positiveInteger(rawInput.limit); - const executors = await sessionManager.ensureToolExecutors(); + const executors = await runtime.tools(); const result = await withSandboxToolSpan( "sandbox.grep", "sandbox.fs.search", @@ -585,12 +499,12 @@ export function createSandboxExecutor(options?: { }, ); - return { result: result as T }; + return result as T; }; const executeFindFilesTool = async ( rawInput: Record, - ): Promise> => { + ): Promise => { const pattern = String(rawInput.pattern ?? ""); if (!pattern) { throw new ToolInputError("pattern is required"); @@ -598,7 +512,7 @@ export function createSandboxExecutor(options?: { logSandboxBootRequest("tool.findFiles"); const limit = positiveInteger(rawInput.limit); - const executors = await sessionManager.ensureToolExecutors(); + const executors = await runtime.tools(); const result = await withSandboxToolSpan( "sandbox.findFiles", "sandbox.fs.find", @@ -617,15 +531,15 @@ export function createSandboxExecutor(options?: { }, ); - return { result: result as T }; + return result as T; }; const executeListDirTool = async ( rawInput: Record, - ): Promise> => { + ): Promise => { logSandboxBootRequest("tool.listDir"); const limit = positiveInteger(rawInput.limit); - const executors = await sessionManager.ensureToolExecutors(); + const executors = await runtime.tools(); const result = await withSandboxToolSpan( "sandbox.listDir", "sandbox.fs.list", @@ -641,12 +555,10 @@ export function createSandboxExecutor(options?: { }, ); - return { result: result as T }; + return result as T; }; - const execute = async ( - params: SandboxExecutionInput, - ): Promise> => { + const execute = async (params: SandboxToolCall): Promise => { const rawInput = (params.input ?? {}) as Record; const bashCommand = params.toolName === "bash" @@ -657,15 +569,6 @@ export function createSandboxExecutor(options?: { if (!bashCommand) { throw new ToolInputError("command is required"); } - if (options?.runBashCustomCommand) { - const custom = await options.runBashCustomCommand(bashCommand); - if (custom.handled) { - if (!custom.result) { - throw new Error("Custom bash command handler returned no result."); - } - return { result: bashToolResult(custom.result) as T }; - } - } } try { @@ -713,33 +616,46 @@ export function createSandboxExecutor(options?: { throw new Error(`unsupported sandbox tool: ${params.toolName}`); }; + const runWorkspaceOperation = async ( + operation: string, + callback: (sandbox: SandboxSession) => Promise, + ): Promise => { + try { + return await callback(await runtime.acquire()); + } catch (error) { + if (isSandboxUnavailableError(error)) { + throw createSandboxUnavailableToolError(operation, error); + } + throw error; + } + }; + const workspace: SandboxWorkspace = { + readFileToBuffer: async (input) => + await runWorkspaceOperation( + "workspace.readFileToBuffer", + async (sandbox) => await sandbox.readFileToBuffer(input), + ), + runCommand: async (input) => + await runWorkspaceOperation( + "workspace.runCommand", + async (sandbox) => await sandbox.runCommand(input), + ), + writeFiles: async (files) => + await runWorkspaceOperation("workspace.writeFiles", async (sandbox) => { + await sandbox.writeFiles(files); + }), + }; + return { - configureSkills(skills: SkillMetadata[]) { - availableSkills = [...skills]; - sessionManager.configureSkills(skills); - }, - configureReferenceFiles(files: string[]) { - referenceFiles = [...files]; - sessionManager.configureReferenceFiles(files); - }, - getSandboxId() { - return sessionManager.getSandboxId(); - }, - getSessionId() { - return sessionManager.getSessionId(); - }, - getDependencyProfileHash() { - return sessionManager.getDependencyProfileHash(); - }, - canExecute(toolName: string) { - return SANDBOX_TOOL_NAMES.has(toolName); - }, - async createSandbox() { - return await sessionManager.createSandbox(); + workspace, + tools: { + supports(toolName: string) { + return SANDBOX_TOOL_NAMES.has(toolName); + }, + execute, }, - execute, - async dispose() { - await sessionManager.dispose(); + ref() { + return runtime.ref(); }, }; } diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 51c501aa5..00e576292 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -11,6 +11,7 @@ import { import { getVercelSandboxCredentials } from "@/chat/sandbox/credentials"; import { isAlreadyExistsError, + isSandboxMissingError, isSandboxUnavailableError, isSnapshottingError, wrapSandboxSetupError, @@ -33,6 +34,7 @@ import { } from "@/chat/sandbox/workspace"; import { sleep } from "@/chat/sleep"; import type { SkillMetadata } from "@/chat/skills"; +import type { SandboxRef } from "@/chat/sandbox/ref"; const DEFAULT_MAX_OUTPUT_LENGTH = 30_000; const DEFAULT_BASH_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; @@ -49,6 +51,7 @@ interface SandboxCredentials { } interface SandboxToolExecutors { + sessionId: string; bash: (input: { command: string; env?: Record; @@ -78,13 +81,9 @@ function createBashToolSandboxAdapter(sandbox: SandboxSession) { cmd: "bash", args: ["-c", command], }); - const [stdout, stderr] = await Promise.all([ - result.stdout(), - result.stderr(), - ]); return { - stdout, - stderr, + stdout: result.stdout, + stderr: result.stderr, exitCode: result.exitCode, }; }, @@ -106,16 +105,33 @@ function createBashToolSandboxAdapter(sandbox: SandboxSession) { }; } -interface SandboxSessionManager { - configureSkills(skills: SkillMetadata[]): void; - configureReferenceFiles(files: string[]): void; - getSandboxId(): string | undefined; - getSessionId(): string | undefined; - getDependencyProfileHash(): string | undefined; - createSandbox(): Promise; - ensureToolExecutors(): Promise; +interface SandboxRuntime { + ref(): SandboxRef | undefined; + acquire(): Promise; + tools(): Promise; refreshNetworkPolicy(traceHeaders?: TracePropagationHeaders): Promise; - dispose(): Promise; +} + +interface ActiveSandbox { + session: SandboxSession; + toolExecutors?: SandboxToolExecutors; + loadingToolExecutors?: Promise; + networkPolicyKey?: string; +} + +interface SandboxRuntimeOptions { + ref?: SandboxRef; + skills: SkillMetadata[]; + referenceFiles: string[]; + timeoutMs?: number; + traceContext?: LogContext; + commandEnv?: () => Promise>; + createNetworkPolicy?: ( + egressId: string, + traceHeaders?: TracePropagationHeaders, + ) => NetworkPolicy | undefined; + onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; + onRefChanged?: (ref: SandboxRef) => void | Promise; } function truncateOutput( @@ -158,40 +174,23 @@ function getCommandAbortedResult(): { }; } -/** Manage sandbox lifecycle, sync, keepalive, and tool executor caching for one executor instance. */ -export function createSandboxSessionManager(options?: { - sandboxId?: string; - sandboxDependencyProfileHash?: string; - timeoutMs?: number; - traceContext?: LogContext; - commandEnv?: () => Promise>; - createNetworkPolicy?: ( - egressId: string, - traceHeaders?: TracePropagationHeaders, - ) => NetworkPolicy | undefined; - onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; - onSandboxAcquired?: (sandbox: { - sandboxId: string; - sandboxDependencyProfileHash?: string; - }) => void | Promise; -}): SandboxSessionManager { - let sandbox: SandboxSession | null = null; - let sandboxIdHint = options?.sandboxId; - let sandboxDependencyProfileHashHint = options?.sandboxDependencyProfileHash; - let availableSkills: SkillMetadata[] = []; - let availableReferenceFiles: string[] = []; - let toolExecutors: SandboxToolExecutors | undefined; - let loadingToolExecutors: Promise | undefined; - let appliedNetworkPolicyKey: string | undefined; - let preparedSandboxId: string | undefined; +/** Own sandbox acquisition, preparation, and session-scoped tool caching. */ +export function createSandboxRuntime( + options: SandboxRuntimeOptions, +): SandboxRuntime { + let activeSandbox: ActiveSandbox | null = null; + let sandboxIdHint = options.ref?.id; + let sandboxDependencyProfileHashHint = options.ref?.profileHash; + const availableSkills = [...options.skills]; + const availableReferenceFiles = [...options.referenceFiles]; let acquiringSandbox: Promise | undefined; - const timeoutMs = options?.timeoutMs ?? 1000 * 60 * 30; - const traceContext = options?.traceContext ?? {}; + const timeoutMs = options.timeoutMs ?? 1000 * 60 * 30; + const traceContext = options.traceContext ?? {}; const dependencyProfileHash = getRuntimeDependencyProfileHash(SANDBOX_RUNTIME); const resolveCommandEnv = - options?.commandEnv ?? (async () => ({}) as Record); + options.commandEnv ?? (async () => ({}) as Record); const withSandboxSpan = ( name: string, @@ -202,14 +201,14 @@ export function createSandboxSessionManager(options?: { /** Drop unavailable live state while retaining the persisted hint for lazy reacquisition. */ const invalidateSession = (sessionId?: string): void => { - if (sessionId && sandbox && sandbox.sessionId !== sessionId) { + if ( + sessionId && + activeSandbox && + activeSandbox.session.sessionId !== sessionId + ) { return; } - sandbox = null; - toolExecutors = undefined; - loadingToolExecutors = undefined; - appliedNetworkPolicyKey = undefined; - preparedSandboxId = undefined; + activeSandbox = null; }; const adaptSandbox = ( @@ -227,7 +226,7 @@ export function createSandboxSessionManager(options?: { ): NetworkPolicy | undefined => { // Build once before boot so missing proxy config fails before sandbox work. // The final route is rebound to the Vercel session id after creation. - return options?.createNetworkPolicy?.(sandboxName); + return options.createNetworkPolicy?.(sandboxName); }; const retainSandboxHint = (nextSandbox: SandboxSession): void => { @@ -235,20 +234,30 @@ export function createSandboxSessionManager(options?: { sandboxDependencyProfileHashHint = dependencyProfileHash; }; - const rememberSandbox = async ( + const reportSandboxHint = async ( nextSandbox: SandboxSession, - ): Promise => { - sandbox = nextSandbox; + ): Promise => { + const previousSandboxId = sandboxIdHint; + const previousProfileHash = sandboxDependencyProfileHashHint; + retainSandboxHint(nextSandbox); + if ( + previousSandboxId === sandboxIdHint && + previousProfileHash === sandboxDependencyProfileHashHint + ) { + return; + } + await options.onRefChanged?.({ + id: nextSandbox.sandboxId, + ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), + }); + }; + + const rememberSandbox = ( + nextSandbox: SandboxSession, + networkPolicyKey?: string, + ): SandboxSession => { + activeSandbox = { session: nextSandbox, networkPolicyKey }; retainSandboxHint(nextSandbox); - toolExecutors = undefined; - loadingToolExecutors = undefined; - const acquired = { - sandboxId: nextSandbox.sandboxId, - ...(dependencyProfileHash - ? { sandboxDependencyProfileHash: dependencyProfileHash } - : {}), - }; - await options?.onSandboxAcquired?.(acquired); return nextSandbox; }; @@ -256,10 +265,6 @@ export function createSandboxSessionManager(options?: { throw wrapSandboxSetupError(error); }; - const failRestore = (error: unknown): never => { - throw new Error("sandbox restore failed", { cause: error }); - }; - const syncSkills = async (targetSandbox: SandboxSession): Promise => { await syncSkillsToSandbox({ sandbox: targetSandbox, @@ -272,28 +277,26 @@ export function createSandboxSessionManager(options?: { const prepareSandbox = async ( targetSandbox: SandboxSession, ): Promise => { - if (preparedSandboxId === targetSandbox.sandboxId) { - return; - } await syncSkills(targetSandbox); - await options?.onSandboxPrepare?.(targetSandbox); - preparedSandboxId = targetSandbox.sandboxId; + await options.onSandboxPrepare?.(targetSandbox); }; const applyNetworkPolicy = async ( targetSandbox: SandboxSession, traceHeaders?: TracePropagationHeaders, - ): Promise => { - const networkPolicy = options?.createNetworkPolicy?.( + ): Promise => { + const networkPolicy = options.createNetworkPolicy?.( targetSandbox.sessionId, traceHeaders, ); if (!networkPolicy) { - return; + return undefined; } const networkPolicyKey = JSON.stringify(networkPolicy); - if (appliedNetworkPolicyKey === networkPolicyKey) { - return; + const active = + activeSandbox?.session === targetSandbox ? activeSandbox : undefined; + if (active?.networkPolicyKey === networkPolicyKey) { + return networkPolicyKey; } await withSandboxSpan( @@ -307,19 +310,19 @@ export function createSandboxSessionManager(options?: { await targetSandbox.update({ networkPolicy }); }, ); - appliedNetworkPolicyKey = networkPolicyKey; + if (active) { + active.networkPolicyKey = networkPolicyKey; + } + return networkPolicyKey; }; - const ensureSandboxReachable = async ( - targetSandbox: SandboxSession, - source: "memory" | "id_hint", - ): Promise => { + const probeSession = async (targetSandbox: SandboxSession): Promise => { await withSandboxSpan( "sandbox.reuse_probe", "sandbox.acquire.probe", { "app.sandbox.reused": true, - "app.sandbox.source": source, + "app.sandbox.source": "memory", }, async () => { try { @@ -480,8 +483,11 @@ export function createSandboxSessionManager(options?: { return failSetup(error); } + await reportSandboxHint(createdSandbox); + + let networkPolicyKey: string | undefined; try { - await applyNetworkPolicy(createdSandbox); + networkPolicyKey = await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { if (isSandboxUnavailableError(error)) { @@ -490,12 +496,12 @@ export function createSandboxSessionManager(options?: { return failSetup(error); } - return await rememberSandbox(createdSandbox); + return rememberSandbox(createdSandbox, networkPolicyKey); }; const discardHintIfProfileChanged = (): void => { if ( - sandbox || + activeSandbox || !sandboxIdHint || dependencyProfileHash === sandboxDependencyProfileHashHint ) { @@ -536,22 +542,7 @@ export function createSandboxSessionManager(options?: { }; const tryReuseCachedSandbox = async (): Promise => { - const cachedSandbox = sandbox; - if (!cachedSandbox) { - return null; - } - - try { - await ensureSandboxReachable(cachedSandbox, "memory"); - await applyNetworkPolicy(cachedSandbox); - await prepareSandbox(cachedSandbox); - return cachedSandbox; - } catch (error) { - if (isSandboxUnavailableError(error)) { - throw error; - } - return failSetup(error); - } + return activeSandbox?.session ?? null; }; const tryRestoreHintedSandbox = async (): Promise => { @@ -579,17 +570,23 @@ export function createSandboxSessionManager(options?: { ), ); } catch (error) { + if (isSandboxMissingError(error)) { + sandboxIdHint = undefined; + sandboxDependencyProfileHashHint = undefined; + return null; + } if (isSandboxUnavailableError(error)) { invalidateSession(); throw error; } - return failRestore(error); + throw new Error("sandbox restore failed", { cause: error }); } + let networkPolicyKey: string | undefined; try { - await applyNetworkPolicy(hintedSandbox); + networkPolicyKey = await applyNetworkPolicy(hintedSandbox); await prepareSandbox(hintedSandbox); - return await rememberSandbox(hintedSandbox); + return rememberSandbox(hintedSandbox, networkPolicyKey); } catch (error) { if (isSandboxUnavailableError(error)) { throw error; @@ -662,8 +659,8 @@ export function createSandboxSessionManager(options?: { stderrTruncated: boolean; }> => { const boundedOutputLength = getMaxOutputLength(); - const stdoutRaw = await commandResult.stdout(); - const stderrRaw = await commandResult.stderr(); + const stdoutRaw = commandResult.stdout; + const stderrRaw = commandResult.stderr; const stdout = truncateOutput(stdoutRaw, boundedOutputLength); const stderr = truncateOutput(stderrRaw, boundedOutputLength); return { @@ -723,6 +720,7 @@ export function createSandboxSessionManager(options?: { } return { + sessionId: sandboxInstance.sessionId, bash: async (input) => { let timedOut = false; let aborted = false; @@ -811,85 +809,70 @@ export function createSandboxSessionManager(options?: { const ensureReadySandbox = async (): Promise => { const activeSandbox = await getOrAcquireSandbox(); + await probeSession(activeSandbox); await extendKeepAlive(activeSandbox); return activeSandbox; }; const loadToolExecutors = async ( - activeSandbox: SandboxSession, + session: SandboxSession, ): Promise => { - if (toolExecutors) { - return toolExecutors; + const active = activeSandbox; + if (!active || active.session !== session) { + throw new Error("sandbox session changed before tool initialization"); + } + if (active.toolExecutors) { + return active.toolExecutors; } - if (loadingToolExecutors) { - return await loadingToolExecutors; + if (active.loadingToolExecutors) { + return await active.loadingToolExecutors; } - const nextToolExecutors = buildToolExecutors(activeSandbox).then( - (executors) => { - toolExecutors = executors; - return executors; - }, - ); - loadingToolExecutors = nextToolExecutors; + let nextToolExecutors: Promise; + nextToolExecutors = buildToolExecutors(session).then((executors) => { + if ( + activeSandbox === active && + active.loadingToolExecutors === nextToolExecutors + ) { + active.toolExecutors = executors; + } + return executors; + }); + active.loadingToolExecutors = nextToolExecutors; try { return await nextToolExecutors; } finally { - if (loadingToolExecutors === nextToolExecutors) { - loadingToolExecutors = undefined; + if (active.loadingToolExecutors === nextToolExecutors) { + active.loadingToolExecutors = undefined; } } }; return { - configureSkills(skills: SkillMetadata[]) { - availableSkills = [...skills]; - }, - configureReferenceFiles(files: string[]) { - availableReferenceFiles = [...files]; - }, - getSandboxId() { - return sandbox ? sandbox.sandboxId : sandboxIdHint; - }, - getSessionId() { - return sandbox?.sessionId; - }, - getDependencyProfileHash() { - return dependencyProfileHash; + ref() { + const id = activeSandbox?.session.sandboxId ?? sandboxIdHint; + if (!id) { + return undefined; + } + return { + id, + ...(sandboxDependencyProfileHashHint + ? { profileHash: sandboxDependencyProfileHashHint } + : {}), + }; }, - async createSandbox() { + async acquire() { return await getOrAcquireSandbox(); }, - async ensureToolExecutors() { + async tools() { return await loadToolExecutors(await ensureReadySandbox()); }, async refreshNetworkPolicy(traceHeaders) { - const activeSandbox = sandbox; - if (!activeSandbox) { + const active = activeSandbox; + if (!active) { return; } - await applyNetworkPolicy(activeSandbox, traceHeaders); - }, - async dispose() { - const activeSandbox = sandbox; - if (!activeSandbox) { - return; - } - - await withSandboxSpan( - "sandbox.stop", - "sandbox.stop", - { - "app.sandbox.stop.blocking": true, - }, - async () => { - await activeSandbox.stop(); - }, - ); - - sandbox = null; - toolExecutors = undefined; - loadingToolExecutors = undefined; + await applyNetworkPolicy(active.session, traceHeaders); }, }; } diff --git a/packages/junior/src/chat/sandbox/workspace.ts b/packages/junior/src/chat/sandbox/workspace.ts index b2e629a80..1405c6003 100644 --- a/packages/junior/src/chat/sandbox/workspace.ts +++ b/packages/junior/src/chat/sandbox/workspace.ts @@ -7,8 +7,8 @@ import { isSandboxUnavailableError } from "@/chat/sandbox/errors"; export interface SandboxCommandResult { exitCode: number; - stderr(): Promise; - stdout(): Promise; + stderr: string; + stdout: string; } export interface SandboxCommandInput { @@ -44,6 +44,13 @@ export interface SandboxWorkspace { path: string; }): Promise; runCommand(input: SandboxCommandInput): Promise; + writeFiles( + files: Array<{ + content: string | Uint8Array; + mode?: number; + path: string; + }>, + ): Promise; } export interface SandboxSession extends SandboxWorkspace { @@ -55,13 +62,6 @@ export interface SandboxSession extends SandboxWorkspace { snapshot(): Promise<{ snapshotId: string }>; stop(): Promise; update(params: { networkPolicy?: NetworkPolicy }): Promise; - writeFiles( - files: Array<{ - content: string | Uint8Array; - mode?: number; - path: string; - }>, - ): Promise; } /** Pin Junior's sandbox contract to one Vercel session without SDK replay. */ @@ -113,10 +113,14 @@ export function createSandboxSession( }, async runCommand(input) { const result = await run(async () => await session.runCommand(input)); + const [stdout, stderr] = await Promise.all([ + run(async () => await result.stdout()), + run(async () => await result.stderr()), + ]); return { exitCode: result.exitCode, - stdout: async () => await run(async () => await result.stdout()), - stderr: async () => await run(async () => await result.stderr()), + stdout, + stderr, }; }, snapshot() { diff --git a/packages/junior/src/chat/services/turn-result.ts b/packages/junior/src/chat/services/turn-result.ts index a0a307cf1..dcd8cda73 100644 --- a/packages/junior/src/chat/services/turn-result.ts +++ b/packages/junior/src/chat/services/turn-result.ts @@ -5,6 +5,7 @@ import type { PiMessage } from "@/chat/pi/messages"; import type { TurnRoute } from "@/chat/services/turn-router"; import type { AgentTurnUsage } from "@/chat/usage"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; +import type { SandboxRef } from "@/chat/sandbox/ref"; import { extractAssistantText, getTerminalAssistantMessages, @@ -113,8 +114,7 @@ export interface AgentRunResult { /** Sanitized terminal text for diagnostics and failure fallback, not success delivery. */ text: string; artifactStatePatch?: Partial; - sandboxId?: string; - sandboxDependencyProfileHash?: string; + sandbox?: SandboxRef; piMessages?: PiMessage[]; diagnostics: AgentTurnDiagnostics; } @@ -124,8 +124,7 @@ export interface TurnResultInput { userInput: string; artifactStatePatch: Partial; toolCalls: string[]; - sandboxId?: string; - sandboxDependencyProfileHash?: string; + sandbox?: SandboxRef; piMessages?: PiMessage[]; durationMs?: number; generatedFileCount: number; @@ -188,8 +187,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { newMessages, artifactStatePatch, toolCalls, - sandboxId, - sandboxDependencyProfileHash, + sandbox, durationMs, shouldTrace, spanContext, @@ -342,8 +340,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { Object.keys(artifactStatePatch).length > 0 ? artifactStatePatch : undefined, - sandboxId, - sandboxDependencyProfileHash, + sandbox, piMessages: input.piMessages, diagnostics: resolvedDiagnostics, }; diff --git a/packages/junior/src/chat/tool-support/normalize-result.ts b/packages/junior/src/chat/tool-support/normalize-result.ts index feb019524..3be0fce20 100644 --- a/packages/junior/src/chat/tool-support/normalize-result.ts +++ b/packages/junior/src/chat/tool-support/normalize-result.ts @@ -179,15 +179,6 @@ function upstreamPermissionDeniedText(value: unknown): string | undefined { ].join("\n"); } -function unwrapSandboxResult(result: unknown, isSandboxResult: boolean) { - return isSandboxResult && - result && - typeof result === "object" && - "result" in result - ? (result as { result: unknown }).result - : result; -} - function normalizeDetails( details: unknown, options: { requireStructuredResult?: boolean; toolName?: string }, @@ -209,13 +200,12 @@ function normalizeDetails( }; } -/** Unwrap sandbox envelope and detect structured results. */ +/** Normalize raw tool output into Pi content and structured details. */ export function normalizeToolResult( result: unknown, - isSandboxResult: boolean, options: { requireStructuredResult?: boolean; toolName?: string } = {}, ): { content: Array; details: unknown } { - const unwrapped = unwrapSandboxResult(result, isSandboxResult); + const unwrapped = result; if (isStructuredToolExecutionResult(unwrapped)) { const normalized = normalizeDetails(unwrapped.details, options); diff --git a/packages/junior/src/chat/tool-support/pi-tool-adapter.ts b/packages/junior/src/chat/tool-support/pi-tool-adapter.ts index 282feef6d..c4e785639 100644 --- a/packages/junior/src/chat/tool-support/pi-tool-adapter.ts +++ b/packages/junior/src/chat/tool-support/pi-tool-adapter.ts @@ -19,7 +19,7 @@ import { import type { PluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; import { buildReportedProgressStatus } from "@/chat/runtime/report-progress"; import type { AssistantStatusSpec } from "@/chat/slack/assistant-thread/status"; -import type { SandboxExecutor } from "@/chat/sandbox/sandbox"; +import type { SandboxTools } from "@/chat/sandbox/sandbox"; import type { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; import type { AnyToolDefinition } from "@/chat/tools/definition"; import type { ToolExecutionReport } from "@/chat/tool-support/tool-execution-report"; @@ -48,7 +48,7 @@ export function createPiAgentTools( sandbox: SkillSandbox, spanContext: LogContext, onStatus?: (status: AssistantStatusSpec) => void | Promise, - sandboxExecutor?: SandboxExecutor, + sandboxTools?: SandboxTools, pluginAuthOrchestration?: PluginAuthOrchestration, onToolCall?: ( toolCallId: string, @@ -151,9 +151,9 @@ export function createPiAgentTools( const toolInput = beforeTool.input; await onToolCall?.(toolCallId, toolName, toolInput); const sandboxInput = buildSandboxInput(toolName, toolInput); - const isSandbox = Boolean(sandboxExecutor?.canExecute(toolName)); + const isSandbox = Boolean(sandboxTools?.supports(toolName)); const result = isSandbox - ? await sandboxExecutor!.execute({ + ? await sandboxTools!.execute({ toolName, input: sandboxInput, ...(signal ? { signal } : {}), @@ -165,7 +165,7 @@ export function createPiAgentTools( toolCallId, }); - const normalized = normalizeToolResult(result, isSandbox, { + const normalized = normalizeToolResult(result, { requireStructuredResult: Boolean(toolDef.outputSchema), toolName, }); diff --git a/packages/junior/src/chat/tool-support/structured-result.ts b/packages/junior/src/chat/tool-support/structured-result.ts index db8c73c4d..dcffda1dd 100644 --- a/packages/junior/src/chat/tool-support/structured-result.ts +++ b/packages/junior/src/chat/tool-support/structured-result.ts @@ -87,7 +87,7 @@ export function injectContinuationToolName( }); } -interface JuniorTextToolResultEnvelope< +export interface JuniorTextToolResultEnvelope< TDetails extends JuniorToolResult = JuniorToolResult, > { content: [TextContent]; diff --git a/packages/junior/src/chat/tools/sandbox/file-uploads.ts b/packages/junior/src/chat/tools/sandbox/file-uploads.ts index 34d17ba83..e4c3016ed 100644 --- a/packages/junior/src/chat/tools/sandbox/file-uploads.ts +++ b/packages/junior/src/chat/tools/sandbox/file-uploads.ts @@ -170,7 +170,7 @@ async function detectMimeType( if (result.exitCode !== 0) { return undefined; } - const value = (await result.stdout()).trim(); + const value = result.stdout.trim(); return value || undefined; } catch { return undefined; diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index b8c3bb403..40e73dabd 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SANDBOX_WORKSPACE_ROOT, sandboxSkillDir } from "@/chat/sandbox/paths"; import type { SandboxSession } from "@/chat/sandbox/workspace"; +import type { SkillMetadata } from "@/chat/skills"; type StructuredSandboxResult = { details: Record; @@ -133,20 +134,164 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ getRuntimeDependencyProfileHash: getRuntimeDependencyProfileHashMock, })); -import { createSandboxExecutor } from "@/chat/sandbox/sandbox"; +import { createSandbox } from "@/chat/sandbox/sandbox"; +import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; import { parseSandboxEgressCredentialToken, SANDBOX_EGRESS_PROXY_PATH, setSandboxEgressAuthRequiredSignal, setSandboxEgressPermissionDeniedSignal, } from "@/chat/sandbox/egress/session"; -import { createLazySandboxWorkspace } from "@/chat/agent/sandbox"; -import { createSandboxSessionManager } from "@/chat/sandbox/session"; +import { createSandboxRuntime } from "@/chat/sandbox/session"; import { createSandboxSession } from "@/chat/sandbox/workspace"; +import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { createBashTool } from "bash-tool"; +interface SandboxFixtureOptions { + sandboxId?: string; + sandboxDependencyProfileHash?: string; + timeoutMs?: number; + traceContext?: Record; + tracePropagation?: any; + credentialEgress?: any; + agentHooks?: { + beforeToolExecute: ReturnType; + prepareSandbox(sandbox: SandboxWorkspace): Promise; + }; + onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; + onSandboxAcquired?: (sandbox: { + sandboxId: string; + sandboxDependencyProfileHash?: string; + }) => void | Promise; + createNetworkPolicy?: (...args: any[]) => any; + commandEnv?: () => Promise>; + runBashCustomCommand?: (command: string) => Promise<{ + handled: boolean; + result?: Parameters[0]; + }>; +} + +function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { + let skills: SkillMetadata[] = []; + let referenceFiles: string[] = []; + let runtime: ReturnType | undefined; + const getRuntime = () => + (runtime ??= createSandboxRuntime({ + ref: options.sandboxId + ? { + id: options.sandboxId, + ...(options.sandboxDependencyProfileHash + ? { profileHash: options.sandboxDependencyProfileHash } + : {}), + } + : undefined, + skills, + referenceFiles, + timeoutMs: options.timeoutMs, + traceContext: options.traceContext, + commandEnv: options.commandEnv, + createNetworkPolicy: options.createNetworkPolicy, + onSandboxPrepare: options.onSandboxPrepare, + onRefChanged: async (ref) => { + await options.onSandboxAcquired?.({ + sandboxId: ref.id, + ...(ref.profileHash + ? { sandboxDependencyProfileHash: ref.profileHash } + : {}), + }); + }, + })); + return { + configureSkills(nextSkills: SkillMetadata[]) { + skills = [...nextSkills]; + }, + configureReferenceFiles(nextFiles: string[]) { + referenceFiles = [...nextFiles]; + }, + getSandboxId: () => getRuntime().ref()?.id, + getDependencyProfileHash: () => getRuntime().ref()?.profileHash, + createSandbox: async () => await getRuntime().acquire(), + ensureToolExecutors: async () => await getRuntime().tools(), + refreshNetworkPolicy: async ( + ...args: Parameters< + ReturnType["refreshNetworkPolicy"] + > + ) => await getRuntime().refreshNetworkPolicy(...args), + }; +} + +function createTestSandbox(options: SandboxFixtureOptions = {}) { + let skills: SkillMetadata[] = []; + let referenceFiles: string[] = []; + let access: ReturnType | undefined; + const getAccess = () => + (access ??= createSandbox({ + ref: options.sandboxId + ? { + id: options.sandboxId, + ...(options.sandboxDependencyProfileHash + ? { profileHash: options.sandboxDependencyProfileHash } + : {}), + } + : undefined, + skills, + referenceFiles, + timeoutMs: options.timeoutMs, + traceContext: options.traceContext, + tracePropagation: options.tracePropagation, + credentialEgress: options.credentialEgress, + prepare: options.agentHooks + ? async (workspace) => + await options.agentHooks?.prepareSandbox(workspace) + : undefined, + onRefChanged: async (ref) => { + await options.onSandboxAcquired?.({ + sandboxId: ref.id, + ...(ref.profileHash + ? { sandboxDependencyProfileHash: ref.profileHash } + : {}), + }); + }, + })); + return { + configureSkills(nextSkills: SkillMetadata[]) { + skills = [...nextSkills]; + }, + configureReferenceFiles(nextFiles: string[]) { + referenceFiles = [...nextFiles]; + }, + getSandboxId: () => getAccess().ref()?.id, + getDependencyProfileHash: () => getAccess().ref()?.profileHash, + canExecute: (toolName: string) => getAccess().tools.supports(toolName), + createSandbox: async () => { + const sandbox = getAccess(); + await sandbox.workspace.writeFiles([]); + return sandbox.workspace; + }, + execute: async (params: { + toolName: string; + input: unknown; + signal?: AbortSignal; + }): Promise<{ result: T }> => { + const command = + params.toolName === "bash" + ? String( + (params.input as { command?: unknown })?.command ?? "", + ).trim() + : undefined; + if (command && options.runBashCustomCommand) { + const custom = await options.runBashCustomCommand(command); + if (custom.handled && custom.result) { + return { result: formatSandboxCommandResult(custom.result) as T }; + } + } + return { result: (await getAccess().tools.execute(params)) as T }; + }, + }; +} + interface MockSandbox { name: string; session: { @@ -299,11 +444,9 @@ function createClosedStreamError(): Error { } async function expectWorkspaceToDelegate( - workspace: SandboxSession, + workspace: SandboxSession | SandboxWorkspace, sandbox: MockSandbox, ): Promise { - expect(workspace.sandboxId).toBe(sandbox.name); - expect(workspace.sessionId).toBe(`${sandbox.name}_session`); const fileBuffer = Buffer.from("workspace file"); const commandResult = { exitCode: 0, @@ -326,8 +469,8 @@ async function expectWorkspaceToDelegate( cwd: "/tmp", }); expect(delegatedResult.exitCode).toBe(commandResult.exitCode); - await expect(delegatedResult.stdout()).resolves.toBe("stdout"); - await expect(delegatedResult.stderr()).resolves.toBe("stderr"); + expect(delegatedResult.stdout).toBe("stdout"); + expect(delegatedResult.stderr).toBe("stderr"); expect(sandbox.runCommand).toHaveBeenCalledWith({ cmd: "pwd", args: ["-P"], @@ -335,7 +478,7 @@ async function expectWorkspaceToDelegate( }); } -describe("createSandboxExecutor", () => { +describe("createTestSandbox", () => { beforeEach(() => { sandboxGetMock.mockReset(); sandboxCreateMock.mockReset(); @@ -367,6 +510,22 @@ describe("createSandboxExecutor", () => { delete process.env.JUNIOR_SECRET; }); + it("preserves an unopened sandbox reference without rewriting its profile", () => { + getRuntimeDependencyProfileHashMock.mockReturnValue("current-profile"); + const sandbox = createSandbox({ + ref: { id: "sbx_existing", profileHash: "persisted-profile" }, + skills: [], + referenceFiles: [], + }); + + expect(sandbox.ref()).toEqual({ + id: "sbx_existing", + profileHash: "persisted-profile", + }); + expect(sandboxGetMock).not.toHaveBeenCalled(); + expect(sandboxCreateMock).not.toHaveBeenCalled(); + }); + it("pins workspace commands to the acquired session without SDK replay", async () => { const sandbox = makeSandbox("sbx_pinned_session"); const resumingRunCommand = vi.fn(); @@ -381,178 +540,6 @@ describe("createSandboxExecutor", () => { expect(resumingRunCommand).not.toHaveBeenCalled(); }); - it("rebinds the lazy workspace after an unavailable session", async () => { - const unavailable = createClosedStreamError(); - let sessionId: string | undefined; - const firstWorkspace = { - sandboxId: "sbx_lazy_recovery", - sessionId: "session_lazy_recovery", - readFileToBuffer: vi.fn(async () => { - sessionId = undefined; - throw unavailable; - }), - runCommand: vi.fn(), - }; - const recoveredWorkspace = { - sandboxId: "sbx_lazy_recovery", - sessionId: "session_lazy_recovered", - readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), - runCommand: vi.fn(), - }; - const createSandbox = vi - .fn() - .mockImplementationOnce(async () => { - sessionId = firstWorkspace.sessionId; - return firstWorkspace; - }) - .mockImplementationOnce(async () => { - sessionId = recoveredWorkspace.sessionId; - return recoveredWorkspace; - }); - const workspace = createLazySandboxWorkspace({ - executor: { - createSandbox, - getSandboxId: () => "sbx_lazy_recovery", - getSessionId: () => sessionId, - } as never, - spanContext: {}, - }); - - const error = await workspace - .readFileToBuffer({ path: "/tmp/result.txt" }) - .catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(ToolInputError); - expect(error).toMatchObject({ cause: unavailable }); - await expect( - workspace.readFileToBuffer({ path: "/tmp/result.txt" }), - ).resolves.toEqual(Buffer.from("recovered")); - - expect(createSandbox).toHaveBeenCalledTimes(2); - }); - - it("rebinds the lazy workspace after session invalidation", async () => { - let sessionId: string | undefined; - const firstWorkspace = { - sandboxId: "sbx_lazy_external", - sessionId: "session_1", - readFileToBuffer: vi.fn(async () => Buffer.from("first")), - runCommand: vi.fn(), - }; - const recoveredWorkspace = { - sandboxId: "sbx_lazy_external", - sessionId: "session_2", - readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), - runCommand: vi.fn(), - }; - const createSandbox = vi - .fn() - .mockImplementationOnce(async () => { - sessionId = "session_1"; - return firstWorkspace; - }) - .mockImplementationOnce(async () => { - sessionId = "session_2"; - return recoveredWorkspace; - }); - const workspace = createLazySandboxWorkspace({ - executor: { - createSandbox, - getSandboxId: () => "sbx_lazy_external", - getSessionId: () => sessionId, - } as never, - spanContext: {}, - }); - - await expect( - workspace.readFileToBuffer({ path: "/tmp/result.txt" }), - ).resolves.toEqual(Buffer.from("first")); - - sessionId = undefined; - - await expect( - workspace.readFileToBuffer({ path: "/tmp/result.txt" }), - ).resolves.toEqual(Buffer.from("recovered")); - expect(createSandbox).toHaveBeenCalledTimes(2); - }); - - it("does not let a late lazy-workspace failure discard the recovered workspace", async () => { - const unavailable = createClosedStreamError(); - let sessionId: string | undefined; - let rejectStaleOperation: () => void = () => {}; - let markStaleOperationStarted: () => void = () => {}; - const staleOperationStarted = new Promise((resolve) => { - markStaleOperationStarted = resolve; - }); - const staleOperation = new Promise((_resolve, reject) => { - rejectStaleOperation = () => reject(unavailable); - }); - const failUnavailable = (): never => { - if (sessionId === "session_1") { - sessionId = undefined; - } - throw unavailable; - }; - const staleWorkspace = { - sandboxId: "sbx_lazy_parallel", - sessionId: "session_1", - readFileToBuffer: vi - .fn() - .mockImplementationOnce(async () => { - markStaleOperationStarted(); - try { - return await staleOperation; - } catch { - return failUnavailable(); - } - }) - .mockImplementationOnce(async () => failUnavailable()), - runCommand: vi.fn(), - }; - const recoveredWorkspace = { - sandboxId: "sbx_lazy_parallel", - sessionId: "session_2", - readFileToBuffer: vi.fn(async () => Buffer.from("recovered")), - runCommand: vi.fn(), - }; - const createSandbox = vi - .fn() - .mockImplementationOnce(async () => { - sessionId = "session_1"; - return staleWorkspace; - }) - .mockImplementationOnce(async () => { - sessionId = "session_2"; - return recoveredWorkspace; - }); - const workspace = createLazySandboxWorkspace({ - executor: { - createSandbox, - getSandboxId: () => "sbx_lazy_parallel", - getSessionId: () => sessionId, - } as never, - spanContext: {}, - }); - - const lateFailure = workspace.readFileToBuffer({ - path: "/tmp/stale.txt", - }); - await staleOperationStarted; - await expect( - workspace.readFileToBuffer({ path: "/tmp/closed.txt" }), - ).rejects.toBeInstanceOf(ToolInputError); - await expect( - workspace.readFileToBuffer({ path: "/tmp/recovered.txt" }), - ).resolves.toEqual(Buffer.from("recovered")); - - rejectStaleOperation(); - await expect(lateFailure).rejects.toBeInstanceOf(ToolInputError); - await expect( - workspace.readFileToBuffer({ path: "/tmp/still-recovered.txt" }), - ).resolves.toEqual(Buffer.from("recovered")); - - expect(createSandbox).toHaveBeenCalledTimes(2); - }); - it("fails a stopped hinted session and reacquires it on a later call", async () => { const stoppedSandbox = makeSandbox("sbx_stopped", { mkDirError: createApiError( @@ -568,10 +555,12 @@ describe("createSandboxExecutor", () => { .mockResolvedValueOnce(stoppedSandbox) .mockResolvedValueOnce(recoveredSandbox); - const executor = createSandboxExecutor({ sandboxId: "sbx_stopped" }); + const executor = createTestSandbox({ sandboxId: "sbx_stopped" }); executor.configureSkills([]); - await expect(executor.createSandbox()).rejects.toThrow("sandbox_stopped"); + await expect(executor.createSandbox()).rejects.toBeInstanceOf( + ToolInputError, + ); const sandbox = await executor.createSandbox(); await expectWorkspaceToDelegate(sandbox, recoveredSandbox); @@ -600,10 +589,12 @@ describe("createSandboxExecutor", () => { sandboxCreateMock.mockResolvedValueOnce(stoppedSandbox); sandboxGetMock.mockResolvedValueOnce(recoveredSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); - await expect(executor.createSandbox()).rejects.toThrow("sandbox_stopped"); + await expect(executor.createSandbox()).rejects.toBeInstanceOf( + ToolInputError, + ); const sandbox = await executor.createSandbox(); await expectWorkspaceToDelegate(sandbox, recoveredSandbox); @@ -619,7 +610,7 @@ describe("createSandboxExecutor", () => { const onSandboxAcquired = vi.fn(); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ onSandboxAcquired, }); executor.configureSkills([]); @@ -633,12 +624,40 @@ describe("createSandboxExecutor", () => { }); }); + it("reports a fresh sandbox reference before session preparation can fail", async () => { + const unavailable = createClosedStreamError(); + const freshSandbox = makeSandbox("sbx_prepare_failure"); + const callOrder: string[] = []; + sandboxCreateMock.mockResolvedValue(freshSandbox); + + const executor = createTestSandbox({ + onSandboxAcquired: async () => { + callOrder.push("reference"); + }, + agentHooks: { + beforeToolExecute: vi.fn(), + prepareSandbox: vi.fn(async () => { + callOrder.push("prepare"); + throw unavailable; + }), + }, + }); + executor.configureSkills([]); + + await expect(executor.createSandbox()).rejects.toBeInstanceOf( + ToolInputError, + ); + + expect(callOrder).toEqual(["reference", "prepare"]); + expect(executor.getSandboxId()).toBe("sbx_prepare_failure"); + }); + it("prepares a cached sandbox only once", async () => { const freshSandbox = makeSandbox("sbx_fresh"); const onSandboxPrepare = vi.fn(); sandboxCreateMock.mockResolvedValue(freshSandbox); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ onSandboxPrepare, }); manager.configureSkills([]); @@ -676,7 +695,7 @@ describe("createSandboxExecutor", () => { markPrepareStarted(); await prepareReleased; }); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ onSandboxPrepare, }); manager.configureSkills([]); @@ -699,12 +718,69 @@ describe("createSandboxExecutor", () => { expect(vi.mocked(createBashTool)).toHaveBeenCalledTimes(1); }); - it("reports acquired sandbox metadata when restoring from a sandbox id hint", async () => { + it("does not let a stale tool build replace the recovered session tools", async () => { + const unavailable = createClosedStreamError(); + const staleSandbox = makeSandbox("sbx_stale_tool_build"); + const recoveredSandbox = makeSandbox("sbx_stale_tool_build"); + recoveredSandbox.session.sessionId = "sbx_stale_tool_build_recovered"; + sandboxCreateMock.mockResolvedValueOnce(staleSandbox); + sandboxGetMock.mockResolvedValueOnce(recoveredSandbox); + + let resolveStaleTools: ( + value: Awaited>, + ) => void = () => {}; + const staleTools = { + tools: { + readFile: { execute: vi.fn(async () => ({ content: "stale" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + }; + const recoveredTools = { + tools: { + readFile: { execute: vi.fn(async () => ({ content: "recovered" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + }; + vi.mocked(createBashTool) + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveStaleTools = resolve; + }), + ) + .mockResolvedValueOnce(recoveredTools as never); + + const manager = createTestSandboxRuntime(); + manager.configureSkills([]); + const staleSession = await manager.createSandbox(); + const staleLoad = manager.ensureToolExecutors(); + await vi.waitFor(() => { + expect(vi.mocked(createBashTool)).toHaveBeenCalledTimes(1); + }); + + staleSandbox.readFileToBuffer.mockRejectedValueOnce(unavailable); + await expect( + staleSession.readFileToBuffer({ path: "/tmp/invalidate" }), + ).rejects.toBe(unavailable); + + const recovered = await manager.ensureToolExecutors(); + await expect( + recovered.readFile({ path: "recovered.txt" }), + ).resolves.toEqual({ content: "recovered" }); + + resolveStaleTools(staleTools as never); + await staleLoad; + + const stillRecovered = await manager.ensureToolExecutors(); + expect(stillRecovered).toBe(recovered); + }); + + it("does not report a reference change when restoring the same sandbox", async () => { const restoredSandbox = makeSandbox("sbx_restored"); const onSandboxAcquired = vi.fn(); sandboxGetMock.mockResolvedValue(restoredSandbox); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_restored", onSandboxAcquired, }); @@ -712,10 +788,7 @@ describe("createSandboxExecutor", () => { await executor.createSandbox(); - expect(onSandboxAcquired).toHaveBeenCalledTimes(1); - expect(onSandboxAcquired).toHaveBeenCalledWith({ - sandboxId: "sbx_restored", - }); + expect(onSandboxAcquired).not.toHaveBeenCalled(); }); it("refreshes network policy when restoring from a sandbox id hint", async () => { @@ -732,7 +805,7 @@ describe("createSandboxExecutor", () => { }; sandboxGetMock.mockResolvedValue(restoredSandbox); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ sandboxId: "sbx_restored", createNetworkPolicy: vi.fn(() => networkPolicy), }); @@ -771,14 +844,14 @@ describe("createSandboxExecutor", () => { .mockReturnValueOnce(secondPolicy); sandboxGetMock.mockResolvedValue(restoredSandbox); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ sandboxId: "sbx_restored_policy", createNetworkPolicy, }); manager.configureSkills([]); await manager.createSandbox(); - await manager.createSandbox(); + await manager.refreshNetworkPolicy(); expect(restoredSandbox.update).toHaveBeenNthCalledWith(1, { networkPolicy: firstPolicy, @@ -804,7 +877,7 @@ describe("createSandboxExecutor", () => { }, })); - const manager = createSandboxSessionManager({ createNetworkPolicy }); + const manager = createTestSandboxRuntime({ createNetworkPolicy }); manager.configureSkills([]); await manager.createSandbox(); @@ -832,7 +905,7 @@ describe("createSandboxExecutor", () => { expect(sandbox.update).toHaveBeenCalledTimes(1); providerDomain = "api.second.example"; - await manager.createSandbox(); + await manager.refreshNetworkPolicy(); expect(sandbox.update).toHaveBeenCalledTimes(2); expect(sandbox.update).toHaveBeenLastCalledWith({ @@ -859,7 +932,7 @@ describe("createSandboxExecutor", () => { sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.createSandbox(); @@ -879,7 +952,7 @@ describe("createSandboxExecutor", () => { getRuntimeDependencyProfileHashMock.mockReturnValue("current-profile"); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_old", sandboxDependencyProfileHash: "old-profile", }); @@ -904,7 +977,7 @@ describe("createSandboxExecutor", () => { sandboxGetMock.mockResolvedValue(forbiddenSandbox); - const executor = createSandboxExecutor({ sandboxId: "sbx_forbidden" }); + const executor = createTestSandbox({ sandboxId: "sbx_forbidden" }); executor.configureSkills([]); await expect(executor.createSandbox()).rejects.toThrow( @@ -923,7 +996,7 @@ describe("createSandboxExecutor", () => { ), ); - const executor = createSandboxExecutor({ sandboxId: "sbx_restore_error" }); + const executor = createTestSandbox({ sandboxId: "sbx_restore_error" }); executor.configureSkills([]); const error = await executor.createSandbox().catch((caught) => caught); @@ -933,6 +1006,35 @@ describe("createSandboxExecutor", () => { expect(sandboxCreateMock).not.toHaveBeenCalled(); }); + it.each([ + createApiError(404, "Not Found", "not_found", "Sandbox was not found"), + createApiError( + 410, + "Gone", + "snapshot_not_found", + "The sandbox snapshot was not found", + ), + ])("replaces a permanently missing sandbox reference", async (missing) => { + const freshSandbox = makeSandbox("sbx_replacement"); + const onSandboxAcquired = vi.fn(); + sandboxGetMock.mockRejectedValueOnce(missing); + sandboxCreateMock.mockResolvedValueOnce(freshSandbox); + + const executor = createTestSandbox({ + sandboxId: "sbx_missing", + onSandboxAcquired, + }); + executor.configureSkills([]); + + await executor.createSandbox(); + + expect(executor.getSandboxId()).toBe("sbx_replacement"); + expect(onSandboxAcquired).toHaveBeenCalledWith({ + sandboxId: "sbx_replacement", + }); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + }); + it("defers to SDK OIDC resolution when VERCEL_OIDC_TOKEN is set without explicit credentials", async () => { process.env.VERCEL_OIDC_TOKEN = "oidc-jwt-token"; process.env.VERCEL_TEAM_ID = "team_123"; @@ -941,7 +1043,7 @@ describe("createSandboxExecutor", () => { const freshSandbox = makeSandbox("sbx_oidc"); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.createSandbox(); @@ -957,7 +1059,7 @@ describe("createSandboxExecutor", () => { const freshSandbox = makeSandbox("sbx_resources"); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.createSandbox(); @@ -979,7 +1081,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ sandboxId: "sbx_bash" }); + const executor = createTestSandbox({ sandboxId: "sbx_bash" }); executor.configureSkills([]); await executor.execute({ @@ -1025,7 +1127,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ sandboxId: "sbx_bash_timeout" }); + const executor = createTestSandbox({ sandboxId: "sbx_bash_timeout" }); executor.configureSkills([]); const responsePromise = executor.execute({ @@ -1064,7 +1166,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ sandboxId: "sbx_bash_abort" }); + const executor = createTestSandbox({ sandboxId: "sbx_bash_abort" }); executor.configureSkills([]); const abortController = new AbortController(); @@ -1112,7 +1214,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_bash_ambiguous_mutation", }); executor.configureSkills([]); @@ -1164,7 +1266,7 @@ describe("createSandboxExecutor", () => { GIT_AUTHOR_NAME: "second-bot", }); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ sandboxId: "sbx_dynamic_env", commandEnv, }); @@ -1210,7 +1312,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_authorize_credentials", credentialEgress: { actor: { type: "user", userId: "U123" }, @@ -1291,7 +1393,7 @@ describe("createSandboxExecutor", () => { }, ); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_stale_auth_signal", }); executor.configureSkills([]); @@ -1341,7 +1443,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_fresh_auth_signal", }); executor.configureSkills([]); @@ -1403,7 +1505,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_pipe_masked_auth_signal", }); executor.configureSkills([]); @@ -1466,7 +1568,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_permission_signal", }); executor.configureSkills([]); @@ -1534,7 +1636,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_mixed_auth_signal", }); executor.configureSkills([]); @@ -1584,7 +1686,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_authorize_system_credentials", credentialEgress: { actor: { platform: "system", name: "scheduler" }, @@ -1617,7 +1719,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_registered_credentials", credentialEgress: { actor: { type: "user", userId: "U123" }, @@ -1659,7 +1761,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_stream_interrupted", }); executor.configureSkills([]); @@ -1692,7 +1794,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const error = await executor @@ -1726,7 +1828,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ onSandboxAcquired: vi.fn(), }); executor.configureSkills([]); @@ -1799,7 +1901,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: staleSandbox.name, }); executor.configureSkills([]); @@ -1863,7 +1965,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: sandbox.name, }); executor.configureSkills([]); @@ -1915,7 +2017,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const error = await executor @@ -1962,7 +2064,7 @@ describe("createSandboxExecutor", () => { : { handled: false }, ); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_custom", runBashCustomCommand, }); @@ -1995,7 +2097,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -2021,7 +2123,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -2052,7 +2154,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -2107,7 +2209,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -2167,7 +2269,7 @@ describe("createSandboxExecutor", () => { "utf8", ); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([ { name: "demo-skill", @@ -2213,7 +2315,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([ { name: "demo-skill", @@ -2259,7 +2361,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const response = await executor.execute({ @@ -2299,7 +2401,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await expect( @@ -2344,7 +2446,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await expect( @@ -2376,7 +2478,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ sandboxId: "sbx_existing" }); + const executor = createTestSandbox({ sandboxId: "sbx_existing" }); executor.configureSkills([ { name: "demo-skill", @@ -2423,7 +2525,7 @@ describe("createSandboxExecutor", () => { }); sandboxCreateMock.mockResolvedValue(snapshotSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const sandbox = await executor.createSandbox(); @@ -2465,7 +2567,7 @@ describe("createSandboxExecutor", () => { (error: unknown) => error === missingError, ); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const sandbox = await executor.createSandbox(); @@ -2508,7 +2610,7 @@ describe("createSandboxExecutor", () => { .mockRejectedValueOnce(snapshottingError) .mockResolvedValueOnce(snapshotSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const sandbox = await executor.createSandbox(); @@ -2560,7 +2662,7 @@ describe("createSandboxExecutor", () => { }, })); - const manager = createSandboxSessionManager({ createNetworkPolicy }); + const manager = createTestSandboxRuntime({ createNetworkPolicy }); manager.configureSkills([]); await manager.createSandbox(); @@ -2613,7 +2715,7 @@ describe("createSandboxExecutor", () => { new Error("lock timeout"), ); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await expect(executor.createSandbox()).rejects.toThrow( diff --git a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts index 5eca29f06..368331e3a 100644 --- a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts @@ -38,8 +38,8 @@ describe("executeAgentRun error path", () => { routing: { destination: LOCAL_DESTINATION, source: LOCAL_SOURCE }, state: { sandbox: { - sandboxId: "sb-123", - sandboxDependencyProfileHash: "hash-abc", + id: "sb-123", + profileHash: "hash-abc", }, }, }); @@ -51,8 +51,7 @@ describe("executeAgentRun error path", () => { expect(reply!.text).toBe(""); expect(reply!.diagnostics.errorMessage).toBe("discover failed"); expect(reply!.diagnostics.assistantMessageCount).toBe(0); - expect(reply!.sandboxId).toBe("sb-123"); - expect(reply!.sandboxDependencyProfileHash).toBe("hash-abc"); + expect(reply!.sandbox).toEqual({ id: "sb-123", profileHash: "hash-abc" }); expect(reply!.diagnostics.outcome).toBe("provider_error"); expect(reply!.diagnostics.modelId).toBe("openai/gpt-5.4"); expect(reply!.diagnostics.reasoningLevel).toBeUndefined(); diff --git a/packages/junior/tests/component/runtime/generated-artifacts.test.ts b/packages/junior/tests/component/runtime/generated-artifacts.test.ts index 7869e751f..740f28a6a 100644 --- a/packages/junior/tests/component/runtime/generated-artifacts.test.ts +++ b/packages/junior/tests/component/runtime/generated-artifacts.test.ts @@ -9,8 +9,8 @@ const GENERATED_IMAGE_PATH = `${SANDBOX_ARTIFACTS_DIR}/generated.png`; function commandResult(overrides: Partial = {}) { return { exitCode: 0, - stdout: async () => "", - stderr: async () => "", + stdout: "", + stderr: "", ...overrides, } satisfies SandboxCommandResult; } @@ -85,7 +85,7 @@ describe("writeSandboxGeneratedArtifacts", () => { runCommand: async () => commandResult({ exitCode: 1, - stderr: async () => "permission denied", + stderr: "permission denied", }), writeFiles: async () => { throw new Error("writeFiles should not run"); diff --git a/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts b/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts index 0e88ecfb4..10067b301 100644 --- a/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts +++ b/packages/junior/tests/component/tool-support/pi-tool-adapter.test.ts @@ -91,20 +91,18 @@ describe("Pi tool adapter", () => { it("executes sandbox bash without host credential injection", async () => { const sandbox = new SkillSandbox([githubSkill], [githubSkill]); const sandboxExecutor = { - canExecute: (toolName: string) => toolName === "bash", + supports: (toolName: string) => toolName === "bash", execute: vi.fn(async ({ input }) => ({ - result: { - ok: true, - command: (input as Record).command, - cwd: "/vercel/sandbox", - exit_code: 0, - signal: null, - timed_out: false, - stdout: "ok", - stderr: "", - stdout_truncated: false, - stderr_truncated: false, - }, + ok: true, + command: (input as Record).command, + cwd: "/vercel/sandbox", + exit_code: 0, + signal: null, + timed_out: false, + stdout: "ok", + stderr: "", + stdout_truncated: false, + stderr_truncated: false, })), } as any; @@ -142,20 +140,18 @@ describe("Pi tool adapter", () => { const sandbox = new SkillSandbox([], []); const abortController = new AbortController(); const sandboxExecutor = { - canExecute: (toolName: string) => toolName === "bash", + supports: (toolName: string) => toolName === "bash", execute: vi.fn(async () => ({ - result: { - ok: true, - command: "sleep 60", - cwd: "/vercel/sandbox", - exit_code: 0, - signal: null, - timed_out: false, - stdout: "", - stderr: "", - stdout_truncated: false, - stderr_truncated: false, - }, + ok: true, + command: "sleep 60", + cwd: "/vercel/sandbox", + exit_code: 0, + signal: null, + timed_out: false, + stdout: "", + stderr: "", + stdout_truncated: false, + stderr_truncated: false, })), } as any; @@ -371,21 +367,19 @@ describe("Pi tool adapter", () => { createdAtMs: Date.now(), }; const sandboxExecutor = { - canExecute: (toolName: string) => toolName === "bash", + supports: (toolName: string) => toolName === "bash", execute: vi.fn(async () => ({ - result: { - ok: false, - command: "gh issue view 123", - cwd: "/vercel/sandbox", - exit_code: 1, - signal: null, - timed_out: false, - stdout: "", - stderr: "bad credentials", - stdout_truncated: false, - stderr_truncated: false, - auth_required: authRequired, - }, + ok: false, + command: "gh issue view 123", + cwd: "/vercel/sandbox", + exit_code: 1, + signal: null, + timed_out: false, + stdout: "", + stderr: "bad credentials", + stdout_truncated: false, + stderr_truncated: false, + auth_required: authRequired, })), } as any; @@ -425,20 +419,18 @@ describe("Pi tool adapter", () => { }), } as any; const sandboxExecutor = { - canExecute: (toolName: string) => toolName === "bash", + supports: (toolName: string) => toolName === "bash", execute: vi.fn(async () => ({ - result: { - ok: false, - command: "gh issue view 123", - cwd: "/vercel/sandbox", - exit_code: 1, - signal: null, - timed_out: false, - stdout: "", - stderr: "bad credentials", - stdout_truncated: false, - stderr_truncated: false, - }, + ok: false, + command: "gh issue view 123", + cwd: "/vercel/sandbox", + exit_code: 1, + signal: null, + timed_out: false, + stdout: "", + stderr: "bad credentials", + stdout_truncated: false, + stderr_truncated: false, })), } as any; diff --git a/packages/junior/tests/integration/agent-continue-slack.test.ts b/packages/junior/tests/integration/agent-continue-slack.test.ts index 4b64f5860..beecc0b59 100644 --- a/packages/junior/tests/integration/agent-continue-slack.test.ts +++ b/packages/junior/tests/integration/agent-continue-slack.test.ts @@ -61,9 +61,10 @@ function createSandbox(files: Record): SandboxWorkspace { readFileToBuffer: async ({ path }) => files[path] ?? null, runCommand: async () => ({ exitCode: 0, - stdout: async () => "image/png\n", - stderr: async () => "", + stdout: "image/png\n", + stderr: "", }), + writeFiles: async () => undefined, }; } @@ -294,10 +295,7 @@ describe("agent continuation Slack integration", () => { toolChannelId: "C999", }), state: expect.objectContaining({ - sandbox: expect.objectContaining({ - sandboxId: undefined, - sandboxDependencyProfileHash: undefined, - }), + sandbox: undefined, }), }), ); diff --git a/packages/junior/tests/integration/local-agent-runner.test.ts b/packages/junior/tests/integration/local-agent-runner.test.ts index eba4fcfdb..7667c41fe 100644 --- a/packages/junior/tests/integration/local-agent-runner.test.ts +++ b/packages/junior/tests/integration/local-agent-runner.test.ts @@ -1130,9 +1130,9 @@ describe("local agent runner", () => { lastCanvasId: "canvas-undelivered", lastCanvasUrl: "https://example.invalid/canvas", }); - await context.onSandboxAcquired?.({ - sandboxDependencyProfileHash: "profile-undelivered", - sandboxId: "sandbox-undelivered", + await context.onSandboxRefChanged?.({ + id: "sandbox-undelivered", + profileHash: "profile-undelivered", }); await deliverAssistantText(request, "not delivered"); return completedAgentRun(successReply("not delivered")); @@ -1159,7 +1159,7 @@ describe("local agent runner", () => { ); const state = await getPersistedThreadState(conversationId!); expect(coerceThreadArtifactsState(state).lastCanvasId).toBeUndefined(); - expect(getPersistedSandboxState(state)).toEqual({}); + expect(getPersistedSandboxState(state)).toBeUndefined(); const visible = coerceThreadConversationState(state); await hydrateConversationMessages({ conversation: visible, diff --git a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts index aaa276fac..ea4dfd81a 100644 --- a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts +++ b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts @@ -213,8 +213,7 @@ describe("mcp oauth callback slack integration", () => { artifactStatePatch: { lastCanvasUrl: "https://example.com/canvas", }, - sandboxId: "sandbox-1", - sandboxDependencyProfileHash: "hash-1", + sandbox: { id: "sandbox-1", profileHash: "hash-1" }, diagnostics: makeDiagnostics(), }); }); diff --git a/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts b/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts index 6d998dfc3..ccd3d87ae 100644 --- a/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts +++ b/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts @@ -37,19 +37,28 @@ vi.mock("@/chat/sandbox/sandbox", async () => { ); return { ...actual, - createSandboxExecutor: () => ({ - configureReferenceFiles() {}, - configureSkills() {}, - getDependencyProfileHash: () => undefined, - getSandboxId: () => undefined, - canExecute: (toolName: string) => toolName === "bash", - createSandbox: async () => { - throw new Error("sandbox should not be acquired for auth signal test"); + createSandbox: () => ({ + workspace: { + readFileToBuffer: async () => { + throw new Error( + "sandbox should not be acquired for auth signal test", + ); + }, + runCommand: async () => { + throw new Error( + "sandbox should not be acquired for auth signal test", + ); + }, + writeFiles: async () => { + throw new Error( + "sandbox should not be acquired for auth signal test", + ); + }, }, - dispose: async () => undefined, - execute: async () => { - return { - result: { + tools: { + supports: (toolName: string) => toolName === "bash", + execute: async () => { + return { ok: false, status: "error", auth_required: { @@ -71,9 +80,10 @@ vi.mock("@/chat/sandbox/sandbox", async () => { exit_code: 1, stderr: "auth required", stdout: "", - }, - }; + }; + }, }, + ref: () => undefined, }), }; }); diff --git a/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts b/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts index 23bb37c56..d617e42e9 100644 --- a/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts @@ -240,24 +240,23 @@ vi.mock("@/chat/runtime/dev-agent-trace", () => ({ })); vi.mock("@/chat/sandbox/sandbox", () => ({ - createSandboxExecutor: () => ({ - configureSkills: () => undefined, - configureReferenceFiles: () => undefined, - createSandbox: async () => ({ + createSandbox: () => ({ + workspace: { readFileToBuffer: async () => Buffer.from("", "utf8"), runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0, }), - }), - canExecute: () => false, - execute: async () => { - throw new Error("sandbox executor should not execute in this test"); + writeFiles: async () => undefined, }, - getSandboxId: () => undefined, - getDependencyProfileHash: () => undefined, - dispose: async () => undefined, + tools: { + supports: () => false, + execute: async () => { + throw new Error("sandbox executor should not execute in this test"); + }, + }, + ref: () => undefined, }), })); diff --git a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts index 1722b69f2..a62b678d0 100644 --- a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts @@ -333,24 +333,23 @@ vi.mock("@/chat/runtime/dev-agent-trace", () => ({ })); vi.mock("@/chat/sandbox/sandbox", () => ({ - createSandboxExecutor: () => ({ - configureSkills: () => undefined, - configureReferenceFiles: () => undefined, - createSandbox: async () => ({ + createSandbox: () => ({ + workspace: { readFileToBuffer: async () => Buffer.from("", "utf8"), runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0, }), - }), - canExecute: () => false, - execute: async () => { - throw new Error("sandbox executor should not execute in this test"); + writeFiles: async () => undefined, }, - getSandboxId: () => undefined, - getDependencyProfileHash: () => undefined, - dispose: async () => undefined, + tools: { + supports: () => false, + execute: async () => { + throw new Error("sandbox executor should not execute in this test"); + }, + }, + ref: () => undefined, }), })); diff --git a/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts b/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts index c0bbd6fa3..f48b9be6d 100644 --- a/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts @@ -136,26 +136,25 @@ vi.mock("@/chat/pi/traced-stream", () => ({ })); vi.mock("@/chat/sandbox/sandbox", () => ({ - createSandboxExecutor: () => ({ - configureSkills: () => undefined, - configureReferenceFiles: () => undefined, - createSandbox: async () => ({ + createSandbox: () => ({ + workspace: { readFileToBuffer: async () => Buffer.from("", "utf8"), runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }), - }), - canExecute: (toolName: string) => toolName === "bash", - execute: async ({ - input, - signal, - }: { - input: { command?: string }; - signal?: AbortSignal; - }) => { - const command = input.command ?? ""; - observations.toolExecutions.push(command); - if (observations.toolExecutions.length > 1) { - return { - result: { + writeFiles: async () => undefined, + }, + tools: { + supports: (toolName: string) => toolName === "bash", + execute: async ({ + input, + signal, + }: { + input: { command?: string }; + signal?: AbortSignal; + }) => { + const command = input.command ?? ""; + observations.toolExecutions.push(command); + if (observations.toolExecutions.length > 1) { + return { content: [ { type: "text", @@ -175,49 +174,46 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ stdout: "targeted Cloudflare test passed", exit_code: 0, }, - }, - }; - } - observations.toolStarted = true; - await new Promise((resolve) => { - const abort = () => { - observations.toolAborted = true; - resolve(); - }; - if (signal?.aborted) { - abort(); - return; + }; } - signal?.addEventListener("abort", abort, { once: true }); - }); - const details = { - ok: false, - status: "error", - target: "run-the-targeted-cloudflare-test", - data: { + observations.toolStarted = true; + await new Promise((resolve) => { + const abort = () => { + observations.toolAborted = true; + resolve(); + }; + if (signal?.aborted) { + abort(); + return; + } + signal?.addEventListener("abort", abort, { once: true }); + }); + const details = { + ok: false, + status: "error", + target: "run-the-targeted-cloudflare-test", + data: { + aborted: true, + exit_code: 130, + stderr: "Command aborted because the agent turn was cancelled.", + }, + error: { + kind: "outcome_unknown", + message: + "Command was interrupted before its outcome was confirmed.", + retryable: false, + }, aborted: true, exit_code: 130, stderr: "Command aborted because the agent turn was cancelled.", - }, - error: { - kind: "outcome_unknown", - message: "Command was interrupted before its outcome was confirmed.", - retryable: false, - }, - aborted: true, - exit_code: 130, - stderr: "Command aborted because the agent turn was cancelled.", - }; - return { - result: { + }; + return { content: [{ type: "text", text: JSON.stringify(details) }], details, - }, - }; + }; + }, }, - getSandboxId: () => undefined, - getDependencyProfileHash: () => undefined, - dispose: async () => undefined, + ref: () => undefined, }), })); diff --git a/packages/junior/tests/integration/slack-channel-tools.test.ts b/packages/junior/tests/integration/slack-channel-tools.test.ts index 7e366ee54..38f6488a4 100644 --- a/packages/junior/tests/integration/slack-channel-tools.test.ts +++ b/packages/junior/tests/integration/slack-channel-tools.test.ts @@ -130,9 +130,10 @@ function createSandbox(files: Record = {}): SandboxWorkspace { readFileToBuffer: async ({ path }) => files[path] ?? null, runCommand: async () => ({ exitCode: 0, - stdout: async () => "text/plain\n", - stderr: async () => "", + stdout: "text/plain\n", + stderr: "", }), + writeFiles: async () => undefined, }; } diff --git a/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts b/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts index 5210361de..098d3707c 100644 --- a/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts +++ b/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts @@ -15,7 +15,7 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ getRuntimeDependencyProfileHash: () => "current-profile", })); -import { createSandboxSessionManager } from "@/chat/sandbox/session"; +import { createSandboxRuntime } from "@/chat/sandbox/session"; function makeSandbox() { const mkDir = vi.fn(async () => {}); @@ -72,12 +72,16 @@ describe("bash-tool sandbox adapter", () => { it("lets real bash-tool initialize against Vercel Sandbox v2 shape", async () => { const sandbox = makeSandbox(); sandboxGetMock.mockResolvedValue(sandbox); - const manager = createSandboxSessionManager({ - sandboxId: "sbx_adapter_contract", - sandboxDependencyProfileHash: "current-profile", + const manager = createSandboxRuntime({ + ref: { + id: "sbx_adapter_contract", + profileHash: "current-profile", + }, + skills: [], + referenceFiles: [], }); - const executors = await manager.ensureToolExecutors(); + const executors = await manager.tools(); expect(sandbox.runCommand).toHaveBeenCalledWith({ cmd: "bash", diff --git a/packages/junior/tests/unit/misc/noninteractive-command.test.ts b/packages/junior/tests/unit/misc/noninteractive-command.test.ts index 2fd30b198..3fba77f19 100644 --- a/packages/junior/tests/unit/misc/noninteractive-command.test.ts +++ b/packages/junior/tests/unit/misc/noninteractive-command.test.ts @@ -32,8 +32,8 @@ describe("non-interactive shell commands", () => { it("wraps argv commands in a non-interactive bash invocation", async () => { const runCommand = vi.fn(async () => ({ exitCode: 0, - stdout: async () => "ok", - stderr: async () => "", + stdout: "ok", + stderr: "", })); await runNonInteractiveCommand( diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 9010cbd19..386fee2f7 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -117,12 +117,8 @@ function fakeSandbox( async runCommand() { return { exitCode: 0, - async stdout() { - return ""; - }, - async stderr() { - return ""; - }, + stdout: "", + stderr: "", }; }, async snapshot() { diff --git a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts b/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts index 5038184a1..4a09d7308 100644 --- a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts +++ b/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts @@ -280,74 +280,68 @@ vi.mock("@/chat/skills", () => { }); vi.mock("@/chat/sandbox/sandbox", () => ({ - createSandboxExecutor: (options?: { - onSandboxAcquired?: (sandbox: { - sandboxId: string; - sandboxDependencyProfileHash?: string; + createSandbox: (options: { + onRefChanged?: (sandbox: { + id: string; + profileHash?: string; }) => void | Promise; }) => { + const acquire = async () => { + createSandboxCallCount.value += 1; + await options.onRefChanged?.({ + id: + activeSandboxVersion.value === 1 + ? "sandbox-test" + : `sandbox-test-${activeSandboxVersion.value}`, + profileHash: "hash-test", + }); + }; return { - configureSkills: () => undefined, - configureReferenceFiles: () => undefined, - createSandbox: async () => { - createSandboxCallCount.value += 1; - await options?.onSandboxAcquired?.({ - sandboxId: - activeSandboxVersion.value === 1 - ? "sandbox-test" - : `sandbox-test-${activeSandboxVersion.value}`, - sandboxDependencyProfileHash: "hash-test", - }); - return { - sandboxId: - activeSandboxVersion.value === 1 - ? "sandbox-test" - : `sandbox-test-${activeSandboxVersion.value}`, - readFileToBuffer: async () => { - return Buffer.from( - [ - "---", - "name: demo-skill", - "description: Demo skill", - "---", - "", - "Skill instructions", - ].join("\n"), - "utf8", - ); - }, - runCommand: async () => ({ + workspace: { + readFileToBuffer: async () => { + await acquire(); + return Buffer.from( + [ + "---", + "name: demo-skill", + "description: Demo skill", + "---", + "", + "Skill instructions", + ].join("\n"), + "utf8", + ); + }, + runCommand: async () => { + await acquire(); + return { exitCode: 0, - stdout: async () => "text/plain\n", - stderr: async () => "", - }), - }; + stdout: "text/plain\n", + stderr: "", + }; + }, + writeFiles: async () => { + await acquire(); + }, }, - canExecute: (toolName: string) => - agentMode.value === "bashThenError" && toolName === "bash", - execute: async ({ toolName }: { toolName: string; input: unknown }) => { - if (toolName !== "bash") { - throw new Error( - "sandbox executor should not handle tools in this test", - ); - } + tools: { + supports: (toolName: string) => + agentMode.value === "bashThenError" && toolName === "bash", + execute: async ({ toolName }: { toolName: string; input: unknown }) => { + if (toolName !== "bash") { + throw new Error( + "sandbox executor should not handle tools in this test", + ); + } - if (agentMode.value !== "bashThenError") { - throw new Error( - "sandbox executor should not handle tools in this test", - ); - } + if (agentMode.value !== "bashThenError") { + throw new Error( + "sandbox executor should not handle tools in this test", + ); + } - createSandboxCallCount.value += 1; - await options?.onSandboxAcquired?.({ - sandboxId: - activeSandboxVersion.value === 1 - ? "sandbox-test" - : `sandbox-test-${activeSandboxVersion.value}`, - sandboxDependencyProfileHash: "hash-test", - }); - return { - result: { + await acquire(); + return { ok: true, status: "success", command: "pwd", @@ -359,17 +353,19 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ stderr: "", stdout_truncated: false, stderr_truncated: false, - }, - }; + }; + }, }, - getSandboxId: () => + ref: () => createSandboxCallCount.value > 0 - ? activeSandboxVersion.value === 1 - ? "sandbox-test" - : `sandbox-test-${activeSandboxVersion.value}` + ? { + id: + activeSandboxVersion.value === 1 + ? "sandbox-test" + : `sandbox-test-${activeSandboxVersion.value}`, + profileHash: "hash-test", + } : undefined, - getDependencyProfileHash: () => "hash-test", - dispose: async () => undefined, }; }, })); @@ -422,7 +418,7 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(reply.text).toBe("Plain reply."); expect(createSandboxCallCount.value).toBe(0); - expect(reply.sandboxId).toBeUndefined(); + expect(reply.sandbox).toBeUndefined(); expect(reply.diagnostics.toolCalls).toEqual([]); expect(selectedThinkingLevels.value).toEqual(["off"]); }); @@ -434,7 +430,7 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(reply.text).toBe("Loaded demo skill."); expect(createSandboxCallCount.value).toBe(0); - expect(reply.sandboxId).toBeUndefined(); + expect(reply.sandbox).toBeUndefined(); expect(reply.diagnostics.toolCalls).toEqual(["loadSkill"]); expect(selectedThinkingLevels.value).toEqual(["medium"]); }); @@ -509,27 +505,29 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(reply.text).toBe(""); expect(reply.diagnostics.errorMessage).toContain("agent exploded"); expect(createSandboxCallCount.value).toBe(1); - expect(reply.sandboxId).toBe("sandbox-test"); - expect(reply.sandboxDependencyProfileHash).toBe("hash-test"); + expect(reply.sandbox).toEqual({ + id: "sandbox-test", + profileHash: "hash-test", + }); }); it("reports sandbox metadata as soon as lazy boot succeeds on error turns", async () => { agentMode.value = "bashThenError"; - const onSandboxAcquired = vi.fn(); + const onSandboxRefChanged = vi.fn(); const reply = await generateLocalReply("run pwd", { durability: { - onSandboxAcquired, + onSandboxRefChanged, }, }); // Raw exception text stays in diagnostics; it is never reply text. expect(reply.text).toBe(""); expect(reply.diagnostics.errorMessage).toContain("agent exploded"); - expect(onSandboxAcquired).toHaveBeenCalledTimes(1); - expect(onSandboxAcquired).toHaveBeenCalledWith({ - sandboxId: "sandbox-test", - sandboxDependencyProfileHash: "hash-test", + expect(onSandboxRefChanged).toHaveBeenCalledTimes(1); + expect(onSandboxRefChanged).toHaveBeenCalledWith({ + id: "sandbox-test", + profileHash: "hash-test", }); }); }); diff --git a/packages/junior/tests/unit/runtime/agent-run-mcp-progressive-loading.test.ts b/packages/junior/tests/unit/runtime/agent-run-mcp-progressive-loading.test.ts index 6563e7a01..f3853723f 100644 --- a/packages/junior/tests/unit/runtime/agent-run-mcp-progressive-loading.test.ts +++ b/packages/junior/tests/unit/runtime/agent-run-mcp-progressive-loading.test.ts @@ -427,10 +427,8 @@ vi.mock("@/chat/capabilities/jr-rpc-command", () => ({ })); vi.mock("@/chat/sandbox/sandbox", () => ({ - createSandboxExecutor: () => ({ - configureSkills: () => undefined, - configureReferenceFiles: () => undefined, - createSandbox: async () => ({ + createSandbox: () => ({ + workspace: { readFileToBuffer: async () => Buffer.from( [ @@ -443,14 +441,16 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ ].join("\n"), "utf8", ), - }), - canExecute: () => false, - execute: async () => { - throw new Error("sandbox executor should not handle mocked tools"); + runCommand: async () => ({ exitCode: 0, stdout: "", stderr: "" }), + writeFiles: async () => undefined, }, - getSandboxId: () => "sandbox-test", - getDependencyProfileHash: () => "hash-test", - dispose: async () => undefined, + tools: { + supports: () => false, + execute: async () => { + throw new Error("sandbox executor should not handle mocked tools"); + }, + }, + ref: () => ({ id: "sandbox-test", profileHash: "hash-test" }), }), })); diff --git a/packages/junior/tests/unit/tool-support/normalize-result.test.ts b/packages/junior/tests/unit/tool-support/normalize-result.test.ts index 66f7cdde1..3e82a65c1 100644 --- a/packages/junior/tests/unit/tool-support/normalize-result.test.ts +++ b/packages/junior/tests/unit/tool-support/normalize-result.test.ts @@ -2,14 +2,8 @@ import { describe, expect, it } from "vitest"; import { normalizeToolResult } from "@/chat/tool-support/normalize-result"; describe("normalizeToolResult", () => { - it("unwraps sandbox envelope", () => { - const result = normalizeToolResult({ result: "hello" }, true); - expect(result.details).toBe("hello"); - expect(result.content).toEqual([{ type: "text", text: "hello" }]); - }); - - it("does not unwrap non-sandbox result", () => { - const result = normalizeToolResult({ result: "hello" }, false); + it("keeps ordinary object results intact", () => { + const result = normalizeToolResult({ result: "hello" }); expect(result.details).toEqual({ result: "hello" }); }); @@ -18,33 +12,21 @@ describe("normalizeToolResult", () => { content: [{ type: "text" as const, text: "ok" }], details: { foo: 1 }, }; - const result = normalizeToolResult(structured, false); - expect(result).toBe(structured); - }); - - it("passes through structured result from sandbox envelope", () => { - const structured = { - content: [{ type: "text" as const, text: "ok" }], - details: { foo: 1 }, - }; - const result = normalizeToolResult({ result: structured }, true); + const result = normalizeToolResult(structured); expect(result).toBe(structured); }); it("normalizes native content results with generic success details", () => { - const result = normalizeToolResult( - { - content: [ - { type: "text" as const, text: "image generated" }, - { - type: "image" as const, - data: "base64-image", - mimeType: "image/png", - }, - ], - }, - false, - ); + const result = normalizeToolResult({ + content: [ + { type: "text" as const, text: "image generated" }, + { + type: "image" as const, + data: "base64-image", + mimeType: "image/png", + }, + ], + }); expect(result).toEqual({ content: [ @@ -65,7 +47,6 @@ describe("normalizeToolResult", () => { { content: [{ type: "text" as const, text: "native content" }], }, - false, { requireStructuredResult: true }, ), ).toThrow( @@ -78,7 +59,7 @@ describe("normalizeToolResult", () => { content: [{ type: "text" as const, text: "ok" }], details: { ok: true, status: "success" }, }; - const result = normalizeToolResult({ result: structured }, true, { + const result = normalizeToolResult(structured, { requireStructuredResult: true, }); expect(result.details).toEqual({ ok: true, status: "success" }); @@ -90,14 +71,14 @@ describe("normalizeToolResult", () => { details: { ok: true }, }; expect(() => - normalizeToolResult({ result: structured }, true, { + normalizeToolResult(structured, { requireStructuredResult: true, }), ).toThrow("Invalid option"); }); it("serializes object to JSON text", () => { - const result = normalizeToolResult({ key: "value" }, false); + const result = normalizeToolResult({ key: "value" }); expect(result.content[0]).toEqual({ type: "text", text: '{"key":"value"}', @@ -116,7 +97,6 @@ describe("normalizeToolResult", () => { reason: "more content", }, }, - false, { toolName: "renamedReadFile" }, ); @@ -168,7 +148,7 @@ describe("normalizeToolResult", () => { }, }; - const result = normalizeToolResult({ result: details }, true); + const result = normalizeToolResult(details); expect(result.details).toBe(details); expect(result.content[0]).toMatchObject({ @@ -225,12 +205,9 @@ describe("normalizeToolResult", () => { }; const result = normalizeToolResult( { - result: { - content: [{ type: "text", text: JSON.stringify(details) }], - details, - }, + content: [{ type: "text", text: JSON.stringify(details) }], + details, }, - true, { requireStructuredResult: true }, ); @@ -247,12 +224,12 @@ describe("normalizeToolResult", () => { }); it("handles string result directly", () => { - const result = normalizeToolResult("plain text", false); + const result = normalizeToolResult("plain text"); expect(result.content[0]).toEqual({ type: "text", text: "plain text" }); }); it("handles null result", () => { - const result = normalizeToolResult(null, false); + const result = normalizeToolResult(null); expect(result.content[0]).toEqual({ type: "text", text: "null" }); }); }); From 728691e274d460495d3bab65b6b672782cead92a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 13:46:08 -0700 Subject: [PATCH 05/10] test(sandbox): Reclassify runtime coverage Move cross-module sandbox adapter and agent-run coverage to the component layer, and combine duplicate error-turn reference assertions. --- .../misc/bash-tool-sandbox-adapter.test.ts | 0 .../runtime-dependency-snapshots.test.ts | 0 .../agent-run-mcp-progressive-loading.test.ts | 0 .../runtime/agent-run-sandbox.test.ts} | 26 ++++++------------- 4 files changed, 8 insertions(+), 18 deletions(-) rename packages/junior/tests/{unit => component}/misc/bash-tool-sandbox-adapter.test.ts (100%) rename packages/junior/tests/{unit/runtime => component/misc}/runtime-dependency-snapshots.test.ts (100%) rename packages/junior/tests/{unit => component}/runtime/agent-run-mcp-progressive-loading.test.ts (100%) rename packages/junior/tests/{unit/runtime/agent-run-lazy-sandbox.test.ts => component/runtime/agent-run-sandbox.test.ts} (96%) diff --git a/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts b/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts similarity index 100% rename from packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts rename to packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts diff --git a/packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts b/packages/junior/tests/component/misc/runtime-dependency-snapshots.test.ts similarity index 100% rename from packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts rename to packages/junior/tests/component/misc/runtime-dependency-snapshots.test.ts diff --git a/packages/junior/tests/unit/runtime/agent-run-mcp-progressive-loading.test.ts b/packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts similarity index 100% rename from packages/junior/tests/unit/runtime/agent-run-mcp-progressive-loading.test.ts rename to packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts diff --git a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts b/packages/junior/tests/component/runtime/agent-run-sandbox.test.ts similarity index 96% rename from packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts rename to packages/junior/tests/component/runtime/agent-run-sandbox.test.ts index 4a09d7308..63af38526 100644 --- a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-sandbox.test.ts @@ -375,7 +375,7 @@ import type { AgentRunRequest } from "@/chat/agent/request"; const LOCAL_DESTINATION = { platform: "local" as const, - conversationId: "local:test:agent-run-lazy-sandbox", + conversationId: "local:test:agent-run-sandbox", }; const LOCAL_SOURCE = createLocalSource(LOCAL_DESTINATION.conversationId); @@ -388,7 +388,7 @@ async function generateLocalReply( const outcome = await executeAgentRun({ ...context, conversationId: context.conversationId ?? LOCAL_DESTINATION.conversationId, - turnId: context.turnId ?? "turn-agent-run-lazy-sandbox", + turnId: context.turnId ?? "turn-agent-run-sandbox", input: { messageText: message, ...(context.input ?? {}), @@ -496,22 +496,7 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(selectedThinkingLevels.value).toEqual(["high"]); }); - it("retains sandbox reuse metadata after lazy boot on error turns", async () => { - agentMode.value = "bashThenError"; - - const reply = await generateLocalReply("run pwd"); - - // Raw exception text stays in diagnostics; it is never reply text. - expect(reply.text).toBe(""); - expect(reply.diagnostics.errorMessage).toContain("agent exploded"); - expect(createSandboxCallCount.value).toBe(1); - expect(reply.sandbox).toEqual({ - id: "sandbox-test", - profileHash: "hash-test", - }); - }); - - it("reports sandbox metadata as soon as lazy boot succeeds on error turns", async () => { + it("retains and reports the sandbox reference after lazy boot on error turns", async () => { agentMode.value = "bashThenError"; const onSandboxRefChanged = vi.fn(); @@ -524,6 +509,11 @@ describe("executeAgentRun lazy sandbox boot", () => { // Raw exception text stays in diagnostics; it is never reply text. expect(reply.text).toBe(""); expect(reply.diagnostics.errorMessage).toContain("agent exploded"); + expect(createSandboxCallCount.value).toBe(1); + expect(reply.sandbox).toEqual({ + id: "sandbox-test", + profileHash: "hash-test", + }); expect(onSandboxRefChanged).toHaveBeenCalledTimes(1); expect(onSandboxRefChanged).toHaveBeenCalledWith({ id: "sandbox-test", From 3b70b451dcc04e572ad467c4dde14a61ace1c971 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 13:56:24 -0700 Subject: [PATCH 06/10] ref(agent): Extract sandbox tool wiring Keep provider lifecycle in the sandbox module while moving agent-specific preparation, durable reference propagation, custom command routing, and artifact materialization behind one focused adapter. --- packages/junior/src/chat/agent/sandbox.ts | 108 ++++++++++++++ packages/junior/src/chat/agent/tools.ts | 63 ++------ .../tests/component/agent/sandbox.test.ts | 136 ++++++++++++++++++ .../component/misc/sandbox-executor.test.ts | 69 --------- 4 files changed, 256 insertions(+), 120 deletions(-) create mode 100644 packages/junior/src/chat/agent/sandbox.ts create mode 100644 packages/junior/tests/component/agent/sandbox.test.ts diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts new file mode 100644 index 000000000..277bc43ac --- /dev/null +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -0,0 +1,108 @@ +/** + * Agent-facing sandbox adaptation. + * + * The sandbox module owns provider lifecycle and recovery. This module adds + * agent-specific preparation, durable reference propagation, custom command + * routing, and generated-artifact materialization. + */ +import type { FileUpload } from "chat"; +import { isUserActor, type Actor } from "@/chat/actor"; +import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; +import type { ChannelConfigurationService } from "@/chat/configuration/types"; +import type { CredentialContext } from "@/chat/credentials/context"; +import { listReferenceFiles } from "@/chat/discovery"; +import type { LogContext } from "@/chat/logging"; +import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts"; +import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; +import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; +import type { SandboxRef } from "@/chat/sandbox/ref"; +import { createSandbox, type SandboxTools } from "@/chat/sandbox/sandbox"; +import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; +import type { Skill, SkillMetadata } from "@/chat/skills"; +import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; + +export interface AgentSandboxOptions { + ref?: SandboxRef; + skills: SkillMetadata[]; + traceContext: LogContext; + tracePropagation?: SandboxEgressTracePropagationConfig; + credentialEgress?: CredentialContext; + actor?: Actor; + channelConfiguration?: ChannelConfigurationService; + configurationValues: Record; + getActiveSkill(): Skill | null; + prepareSandbox(workspace: SandboxWorkspace): void | Promise; + onRefChanged(ref: SandboxRef): void; + persistRef?(ref: SandboxRef): void | Promise; +} + +export interface AgentSandbox { + readonly tools: SandboxTools; + readonly workspace: SandboxWorkspace; + ref(): SandboxRef | undefined; + writeGeneratedArtifacts( + files: FileUpload[], + ): Promise; +} + +function bashCommand(input: unknown): string | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return undefined; + } + const command = (input as { command?: unknown }).command; + return typeof command === "string" && command.trim() + ? command.trim() + : undefined; +} + +/** Create run-scoped sandbox capabilities adapted to the current agent context. */ +export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { + const sandbox = createSandbox({ + ref: options.ref, + skills: options.skills, + referenceFiles: listReferenceFiles(), + traceContext: options.traceContext, + tracePropagation: options.tracePropagation, + credentialEgress: options.credentialEgress, + prepare: options.prepareSandbox, + onRefChanged: async (ref) => { + options.onRefChanged(ref); + await options.persistRef?.(ref); + }, + }); + + return { + workspace: sandbox.workspace, + ref: sandbox.ref, + tools: { + supports: sandbox.tools.supports, + async execute(params) { + const command = + params.toolName === "bash" ? bashCommand(params.input) : undefined; + if (command) { + const result = await maybeExecuteJrRpcCustomCommand(command, { + activeSkill: options.getActiveSkill(), + channelConfiguration: options.channelConfiguration, + actorId: isUserActor(options.actor) + ? options.actor.userId + : undefined, + onConfigurationValueChanged: (key, value) => { + if (value === undefined) { + delete options.configurationValues[key]; + return; + } + options.configurationValues[key] = value; + }, + }); + if (result.handled) { + return formatSandboxCommandResult(result.result); + } + } + return await sandbox.tools.execute(params); + }, + }, + async writeGeneratedArtifacts(files) { + return await writeSandboxGeneratedArtifacts(sandbox.workspace, files); + }, + }; +} diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index 0151bf29a..cc1fa6ff7 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -9,9 +9,8 @@ */ import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { FileUpload } from "chat"; -import { listReferenceFiles } from "@/chat/discovery"; -import { maybeExecuteJrRpcCustomCommand } from "@/chat/capabilities/jr-rpc-command"; import { createUserTokenStore } from "@/chat/capabilities/factory"; +import { createAgentSandbox } from "@/chat/agent/sandbox"; import { SkillSandbox } from "@/chat/sandbox/skill-sandbox"; import type { Skill, SkillMetadata } from "@/chat/skills"; import { @@ -30,8 +29,6 @@ import { } from "@/chat/tool-support/skill/mcp-tool-summary"; import { createPiAgentTools } from "@/chat/tool-support/pi-tool-adapter"; import { planToolExposure } from "@/chat/tool-exposure"; -import { createSandbox, type SandboxTools } from "@/chat/sandbox/sandbox"; -import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; import type { SandboxRef } from "@/chat/sandbox/ref"; import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration"; import { createPluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; @@ -41,7 +38,7 @@ import type { LogContext } from "@/chat/logging"; import { logWarn } from "@/chat/logging"; import type { ConversationPrivacy } from "@/chat/conversation-privacy"; import { mergeArtifactsState } from "@/chat/runtime/thread-state"; -import { isUserActor, type Actor } from "@/chat/actor"; +import type { Actor } from "@/chat/actor"; import type { ThreadArtifactsState } from "@/chat/state/artifacts"; import type { AuthorizationPauseError } from "@/chat/services/auth-pause"; import type { AgentTurnSurface } from "@/chat/state/turn-session"; @@ -55,7 +52,6 @@ import { } from "@/chat/agent/request"; import { upsertActiveSkill } from "@/chat/agent/skills"; import type { ResumeState } from "@/chat/agent/resume"; -import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts"; import { credentialUserSubjectId } from "@/chat/credentials/context"; interface ToolWiringArgs { @@ -120,52 +116,20 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); - const sandbox = createSandbox({ + const sandbox = createAgentSandbox({ ref: args.state.sandbox, skills: args.availableSkills, - referenceFiles: listReferenceFiles(), traceContext: args.spanContext, tracePropagation: args.policy.sandboxTracePropagation, credentialEgress: args.routing.credentialContext, - prepare: async (workspace) => { - await pluginHooks.prepareSandbox(workspace); - }, - onRefChanged: async (ref) => { - args.onSandboxRefChanged(ref); - await args.durability.onSandboxRefChanged?.(ref); - }, + actor: args.currentActor, + channelConfiguration: args.policy.channelConfiguration, + configurationValues: args.configurationValues, + getActiveSkill: () => args.skillSandbox.getActiveSkill(), + prepareSandbox: pluginHooks.prepareSandbox, + onRefChanged: args.onSandboxRefChanged, + persistRef: args.durability.onSandboxRefChanged, }); - const sandboxTools: SandboxTools = { - supports: sandbox.tools.supports, - async execute(params) { - const command = - params.toolName === "bash" - ? String( - (params.input as { command?: unknown })?.command ?? "", - ).trim() - : undefined; - if (command) { - const result = await maybeExecuteJrRpcCustomCommand(command, { - activeSkill: args.skillSandbox.getActiveSkill(), - channelConfiguration: args.policy.channelConfiguration, - actorId: isUserActor(args.currentActor) - ? args.currentActor.userId - : undefined, - onConfigurationValueChanged: (key, value) => { - if (value === undefined) { - delete args.configurationValues[key]; - return; - } - args.configurationValues[key] = value; - }, - }); - if (result.handled) { - return formatSandboxCommandResult(result.result); - } - } - return await sandbox.tools.execute(params); - }, - }; const slackDestination = args.routing.destination.platform === "slack" @@ -290,10 +254,7 @@ export async function wireAgentTools( loadableSkills, { writeGeneratedArtifacts: async (files) => { - const refs = await writeSandboxGeneratedArtifacts( - sandbox.workspace, - files, - ); + const refs = await sandbox.writeGeneratedArtifacts(files); args.generatedFiles.push(...files); return refs; }, @@ -430,7 +391,7 @@ export async function wireAgentTools( args.skillSandbox, args.spanContext, args.observers.onStatus, - sandboxTools, + sandbox.tools, pluginAuth, onToolCall, pluginHooks, diff --git a/packages/junior/tests/component/agent/sandbox.test.ts b/packages/junior/tests/component/agent/sandbox.test.ts new file mode 100644 index 000000000..9953edcb3 --- /dev/null +++ b/packages/junior/tests/component/agent/sandbox.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SandboxOptions } from "@/chat/sandbox/sandbox"; + +const { + createSandboxMock, + executeSandboxToolMock, + executeCustomCommandMock, + writeGeneratedArtifactsMock, +} = vi.hoisted(() => ({ + createSandboxMock: vi.fn(), + executeSandboxToolMock: vi.fn(), + executeCustomCommandMock: vi.fn(), + writeGeneratedArtifactsMock: vi.fn(), +})); + +vi.mock("@/chat/sandbox/sandbox", () => ({ + createSandbox: createSandboxMock, +})); + +vi.mock("@/chat/capabilities/jr-rpc-command", () => ({ + maybeExecuteJrRpcCustomCommand: executeCustomCommandMock, +})); + +vi.mock("@/chat/discovery", () => ({ + listReferenceFiles: () => ["/tmp/reference.md"], +})); + +vi.mock("@/chat/runtime/generated-artifacts", () => ({ + writeSandboxGeneratedArtifacts: writeGeneratedArtifactsMock, +})); + +import { + createAgentSandbox, + type AgentSandboxOptions, +} from "@/chat/agent/sandbox"; + +const workspace = { + readFileToBuffer: vi.fn(), + runCommand: vi.fn(), + writeFiles: vi.fn(), +}; + +function options( + overrides: Partial = {}, +): AgentSandboxOptions { + return { + skills: [], + traceContext: {}, + configurationValues: {}, + getActiveSkill: () => null, + prepareSandbox: vi.fn(), + onRefChanged: vi.fn(), + ...overrides, + }; +} + +describe("createAgentSandbox", () => { + let capturedOptions: SandboxOptions; + + beforeEach(() => { + vi.clearAllMocks(); + createSandboxMock.mockImplementation((input: SandboxOptions) => { + capturedOptions = input; + return { + workspace, + ref: () => input.ref, + tools: { + supports: () => true, + execute: executeSandboxToolMock, + }, + }; + }); + executeCustomCommandMock.mockResolvedValue({ handled: false }); + }); + + it("updates the run reference before awaiting durable persistence", async () => { + const callOrder: string[] = []; + createAgentSandbox( + options({ + onRefChanged: () => { + callOrder.push("run"); + }, + persistRef: async () => { + callOrder.push("durable"); + }, + }), + ); + + await capturedOptions.onRefChanged?.({ id: "sandbox-1" }); + + expect(callOrder).toEqual(["run", "durable"]); + expect(capturedOptions.referenceFiles).toEqual(["/tmp/reference.md"]); + }); + + it("handles jr-rpc commands without touching the provider sandbox", async () => { + executeCustomCommandMock.mockResolvedValue({ + handled: true, + result: { + ok: true, + command: "jr-rpc config get github.repo", + cwd: "/", + exit_code: 0, + signal: null, + timed_out: false, + stdout: "getsentry/junior\n", + stderr: "", + stdout_truncated: false, + stderr_truncated: false, + }, + }); + const sandbox = createAgentSandbox(options()); + + const result = await sandbox.tools.execute({ + toolName: "bash", + input: { command: "jr-rpc config get github.repo" }, + }); + + expect(executeSandboxToolMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ details: { ok: true, exit_code: 0 } }); + }); + + it("forwards ordinary tools and generated artifacts", async () => { + executeSandboxToolMock.mockResolvedValue({ ok: true }); + writeGeneratedArtifactsMock.mockResolvedValue([{ path: "/tmp/file.txt" }]); + const sandbox = createAgentSandbox(options()); + const call = { toolName: "bash", input: { command: "pwd" } }; + + await expect(sandbox.tools.execute(call)).resolves.toEqual({ ok: true }); + await expect(sandbox.writeGeneratedArtifacts([])).resolves.toEqual([ + { path: "/tmp/file.txt" }, + ]); + + expect(executeSandboxToolMock).toHaveBeenCalledWith(call); + expect(writeGeneratedArtifactsMock).toHaveBeenCalledWith(workspace, []); + }); +}); diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 40e73dabd..3320e44f2 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -135,7 +135,6 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ })); import { createSandbox } from "@/chat/sandbox/sandbox"; -import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; import { parseSandboxEgressCredentialToken, SANDBOX_EGRESS_PROXY_PATH, @@ -167,10 +166,6 @@ interface SandboxFixtureOptions { }) => void | Promise; createNetworkPolicy?: (...args: any[]) => any; commandEnv?: () => Promise>; - runBashCustomCommand?: (command: string) => Promise<{ - handled: boolean; - result?: Parameters[0]; - }>; } function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { @@ -275,18 +270,6 @@ function createTestSandbox(options: SandboxFixtureOptions = {}) { input: unknown; signal?: AbortSignal; }): Promise<{ result: T }> => { - const command = - params.toolName === "bash" - ? String( - (params.input as { command?: unknown })?.command ?? "", - ).trim() - : undefined; - if (command && options.runBashCustomCommand) { - const custom = await options.runBashCustomCommand(command); - if (custom.handled && custom.result) { - return { result: formatSandboxCommandResult(custom.result) as T }; - } - } return { result: (await getAccess().tools.execute(params)) as T }; }, }; @@ -2035,58 +2018,6 @@ describe("createTestSandbox", () => { }); }); - it("routes matching bash commands through custom command handler", async () => { - const sandbox = makeSandbox("sbx_custom"); - sandboxGetMock.mockResolvedValue(sandbox); - vi.mocked(createBashTool).mockResolvedValue({ - tools: { - readFile: { execute: vi.fn(async () => ({ content: "" })) }, - writeFile: { execute: vi.fn(async () => ({ success: true })) }, - }, - } as never); - const runBashCustomCommand = vi.fn(async (command: string) => - command === "jr-rpc config get github.repo" - ? { - handled: true, - result: { - ok: true, - command, - cwd: "/", - exit_code: 0, - signal: null, - timed_out: false, - stdout: "credential_enabled\n", - stderr: "", - stdout_truncated: false, - stderr_truncated: false, - }, - } - : { handled: false }, - ); - - const executor = createTestSandbox({ - sandboxId: "sbx_custom", - runBashCustomCommand, - }); - executor.configureSkills([]); - - const response = await executor.execute({ - toolName: "bash", - input: { - command: "jr-rpc config get github.repo", - }, - }); - - expect(runBashCustomCommand).toHaveBeenCalledWith( - "jr-rpc config get github.repo", - ); - expect(sandbox.runCommand).not.toHaveBeenCalled(); - expect(response.result.details).toMatchObject({ - ok: true, - exit_code: 0, - }); - }); - it("syncs sandbox files once when the first tool call also initializes tool executors", async () => { const sandbox = makeSandbox("sbx_single_sync"); sandboxCreateMock.mockResolvedValue(sandbox); From adaf6e16273630d1e10ff61133bf48484163cf99 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 15:30:38 -0700 Subject: [PATCH 07/10] fix(runtime): Clear stale sandbox profile hashes Always serialize both sandbox state fields when a sandbox patch is present so replacing or removing a reference cannot retain an older dependency profile hash. --- .../junior/src/chat/runtime/thread-state.ts | 12 ++-- .../component/runtime/thread-state.test.ts | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 packages/junior/tests/component/runtime/thread-state.test.ts diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index 4ec9d4bef..f2e95a3ba 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -37,14 +37,10 @@ function buildThreadStatePayload( if (patch.conversation) { Object.assign(payload, buildConversationStatePatch(patch.conversation)); } - if (patch.sandbox === null) { - payload.app_sandbox_id = ""; - payload.app_sandbox_dependency_profile_hash = ""; - } else if (patch.sandbox) { - payload.app_sandbox_id = patch.sandbox.id; - if (patch.sandbox.profileHash !== undefined) { - payload.app_sandbox_dependency_profile_hash = patch.sandbox.profileHash; - } + if (patch.sandbox !== undefined) { + payload.app_sandbox_id = patch.sandbox?.id ?? ""; + payload.app_sandbox_dependency_profile_hash = + patch.sandbox?.profileHash ?? ""; } return payload; } diff --git a/packages/junior/tests/component/runtime/thread-state.test.ts b/packages/junior/tests/component/runtime/thread-state.test.ts new file mode 100644 index 000000000..269bf5814 --- /dev/null +++ b/packages/junior/tests/component/runtime/thread-state.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + getPersistedSandboxState, + getPersistedThreadState, + persistThreadStateById, +} from "@/chat/runtime/thread-state"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; + +const originalStateAdapter = process.env.JUNIOR_STATE_ADAPTER; + +describe("thread sandbox state", () => { + beforeEach(async () => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; + await disconnectStateAdapter(); + }); + + afterEach(async () => { + await disconnectStateAdapter(); + if (originalStateAdapter === undefined) { + delete process.env.JUNIOR_STATE_ADAPTER; + } else { + process.env.JUNIOR_STATE_ADAPTER = originalStateAdapter; + } + }); + + it("clears a stale profile hash when the replacement reference omits one", async () => { + const conversationId = "local:test:thread-sandbox-state"; + await persistThreadStateById(conversationId, { + sandbox: { id: "sandbox-old", profileHash: "profile-old" }, + }); + + await persistThreadStateById(conversationId, { + sandbox: { id: "sandbox-new" }, + }); + + const state = await getPersistedThreadState(conversationId); + expect(state).toMatchObject({ + app_sandbox_id: "sandbox-new", + app_sandbox_dependency_profile_hash: "", + }); + expect(getPersistedSandboxState(state)).toEqual({ id: "sandbox-new" }); + }); + + it("clears both fields when the sandbox reference is removed", async () => { + const conversationId = "local:test:thread-sandbox-clear"; + await persistThreadStateById(conversationId, { + sandbox: { id: "sandbox-old", profileHash: "profile-old" }, + }); + + await persistThreadStateById(conversationId, { sandbox: null }); + + const state = await getPersistedThreadState(conversationId); + expect(state).toMatchObject({ + app_sandbox_id: "", + app_sandbox_dependency_profile_hash: "", + }); + expect(getPersistedSandboxState(state)).toBeUndefined(); + }); +}); From 0c548b75355e293a622ebc404852250c4e595565 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 15:39:09 -0700 Subject: [PATCH 08/10] ref(sandbox): Keep durable reference state atomic Store the sandbox ID and dependency profile hash as one SandboxRef value, and reuse the canonical persisted-state parser during turn preparation. --- .../src/chat/runtime/turn-preparation.ts | 27 ++----- packages/junior/src/chat/sandbox/session.ts | 71 +++++++------------ 2 files changed, 30 insertions(+), 68 deletions(-) diff --git a/packages/junior/src/chat/runtime/turn-preparation.ts b/packages/junior/src/chat/runtime/turn-preparation.ts index fdb2d284f..c77b67c3b 100644 --- a/packages/junior/src/chat/runtime/turn-preparation.ts +++ b/packages/junior/src/chat/runtime/turn-preparation.ts @@ -12,7 +12,6 @@ import type { ConversationMessage, ThreadConversationState, } from "@/chat/state/conversation"; -import { toOptionalString } from "@/chat/coerce"; import { setSpanAttributes } from "@/chat/logging"; import { getThreadTs } from "@/chat/runtime/thread-context"; import type { SandboxRef } from "@/chat/sandbox/ref"; @@ -31,7 +30,10 @@ import { hasPotentialImageAttachment, isVisionEnabled, } from "@/chat/slack/vision-context"; -import { getChannelConfigurationService } from "@/chat/runtime/thread-state"; +import { + getChannelConfigurationService, + getPersistedSandboxState, +} from "@/chat/runtime/thread-state"; import { hydrateConversationMessages, persistConversationMessages, @@ -177,17 +179,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { args: PrepareTurnStateInput, ): Promise { const existingState = await args.thread.state; - const existingSandboxId = existingState - ? toOptionalString( - (existingState as Record).app_sandbox_id, - ) - : undefined; - const existingSandboxDependencyProfileHash = existingState - ? toOptionalString( - (existingState as Record) - .app_sandbox_dependency_profile_hash, - ) - : undefined; + const sandbox = getPersistedSandboxState(existingState ?? {}); const artifacts = coerceThreadArtifactsState(existingState); const conversation = coerceThreadConversationState(existingState); const conversationId = args.context.threadId ?? args.context.runId; @@ -275,14 +267,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { configuration, channelConfiguration, conversation, - sandbox: existingSandboxId - ? { - id: existingSandboxId, - ...(existingSandboxDependencyProfileHash - ? { profileHash: existingSandboxDependencyProfileHash } - : {}), - } - : undefined, + sandbox, conversationContext, userMessageAlreadyReplied, userMessageId, diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 00e576292..6bf073098 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -179,8 +179,7 @@ export function createSandboxRuntime( options: SandboxRuntimeOptions, ): SandboxRuntime { let activeSandbox: ActiveSandbox | null = null; - let sandboxIdHint = options.ref?.id; - let sandboxDependencyProfileHashHint = options.ref?.profileHash; + let sandboxRef = options.ref; const availableSkills = [...options.skills]; const availableReferenceFiles = [...options.referenceFiles]; let acquiringSandbox: Promise | undefined; @@ -229,27 +228,21 @@ export function createSandboxRuntime( return options.createNetworkPolicy?.(sandboxName); }; - const retainSandboxHint = (nextSandbox: SandboxSession): void => { - sandboxIdHint = nextSandbox.sandboxId; - sandboxDependencyProfileHashHint = dependencyProfileHash; - }; - - const reportSandboxHint = async ( + const reportSandboxRef = async ( nextSandbox: SandboxSession, ): Promise => { - const previousSandboxId = sandboxIdHint; - const previousProfileHash = sandboxDependencyProfileHashHint; - retainSandboxHint(nextSandbox); + const nextRef: SandboxRef = { + id: nextSandbox.sandboxId, + ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), + }; if ( - previousSandboxId === sandboxIdHint && - previousProfileHash === sandboxDependencyProfileHashHint + sandboxRef?.id === nextRef.id && + sandboxRef.profileHash === nextRef.profileHash ) { return; } - await options.onRefChanged?.({ - id: nextSandbox.sandboxId, - ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), - }); + sandboxRef = nextRef; + await options.onRefChanged?.(nextRef); }; const rememberSandbox = ( @@ -257,7 +250,6 @@ export function createSandboxRuntime( networkPolicyKey?: string, ): SandboxSession => { activeSandbox = { session: nextSandbox, networkPolicyKey }; - retainSandboxHint(nextSandbox); return nextSandbox; }; @@ -483,16 +475,13 @@ export function createSandboxRuntime( return failSetup(error); } - await reportSandboxHint(createdSandbox); + await reportSandboxRef(createdSandbox); let networkPolicyKey: string | undefined; try { networkPolicyKey = await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { - if (isSandboxUnavailableError(error)) { - retainSandboxHint(createdSandbox); - } return failSetup(error); } @@ -502,8 +491,8 @@ export function createSandboxRuntime( const discardHintIfProfileChanged = (): void => { if ( activeSandbox || - !sandboxIdHint || - dependencyProfileHash === sandboxDependencyProfileHashHint + !sandboxRef || + dependencyProfileHash === sandboxRef.profileHash ) { return; } @@ -511,10 +500,9 @@ export function createSandboxRuntime( setSpanAttributes({ "app.sandbox.reused": false, "app.sandbox.recreate.reason": "dependency_profile_mismatch", - ...(sandboxDependencyProfileHashHint + ...(sandboxRef.profileHash ? { - "app.sandbox.previous_profile_hash": - sandboxDependencyProfileHashHint, + "app.sandbox.previous_profile_hash": sandboxRef.profileHash, } : {}), ...(dependencyProfileHash @@ -525,10 +513,9 @@ export function createSandboxRuntime( "sandbox_hint_discarded_profile_mismatch", traceContext, { - ...(sandboxDependencyProfileHashHint + ...(sandboxRef.profileHash ? { - "app.sandbox.previous_profile_hash": - sandboxDependencyProfileHashHint, + "app.sandbox.previous_profile_hash": sandboxRef.profileHash, } : {}), ...(dependencyProfileHash @@ -537,8 +524,7 @@ export function createSandboxRuntime( }, "Dependency profile changed; discarding sandbox hint and creating fresh session", ); - sandboxIdHint = undefined; - sandboxDependencyProfileHashHint = undefined; + sandboxRef = undefined; }; const tryReuseCachedSandbox = async (): Promise => { @@ -546,7 +532,8 @@ export function createSandboxRuntime( }; const tryRestoreHintedSandbox = async (): Promise => { - if (!sandboxIdHint) { + const ref = sandboxRef; + if (!ref) { return null; } @@ -563,7 +550,7 @@ export function createSandboxRuntime( async () => adaptSandbox( await Sandbox.get({ - name: sandboxIdHint as string, + name: ref.id, resume: true, ...(sandboxCredentials ?? {}), } as Parameters[0]), @@ -571,8 +558,7 @@ export function createSandboxRuntime( ); } catch (error) { if (isSandboxMissingError(error)) { - sandboxIdHint = undefined; - sandboxDependencyProfileHashHint = undefined; + sandboxRef = undefined; return null; } if (isSandboxUnavailableError(error)) { @@ -600,7 +586,7 @@ export function createSandboxRuntime( "sandbox.acquire", "sandbox.acquire", { - "app.sandbox.id_hint_present": Boolean(sandboxIdHint), + "app.sandbox.id_hint_present": Boolean(sandboxRef), "app.sandbox.timeout_ms": timeoutMs, "app.sandbox.runtime": SANDBOX_RUNTIME, "app.sandbox.skills_count": availableSkills.length, @@ -850,16 +836,7 @@ export function createSandboxRuntime( return { ref() { - const id = activeSandbox?.session.sandboxId ?? sandboxIdHint; - if (!id) { - return undefined; - } - return { - id, - ...(sandboxDependencyProfileHashHint - ? { profileHash: sandboxDependencyProfileHashHint } - : {}), - }; + return sandboxRef ? { ...sandboxRef } : undefined; }, async acquire() { return await getOrAcquireSandbox(); From 749c7bddf12f5a6292c02617ecd61a6012db9181 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 15:53:58 -0700 Subject: [PATCH 09/10] ref(sandbox): Clarify reference and workspace names Use sandboxRef for durable identity, workspace for file and command access, and keep live sandbox ownership inside the sandbox adapters. --- .../junior/src/chat/agent-dispatch/runner.ts | 18 +++++++------- packages/junior/src/chat/agent/index.ts | 13 ++++------ packages/junior/src/chat/agent/request.ts | 4 ++-- packages/junior/src/chat/agent/sandbox.ts | 18 +++++++------- packages/junior/src/chat/agent/tools.ts | 18 +++++++------- packages/junior/src/chat/local/runner.ts | 18 +++++++------- .../junior/src/chat/plugins/agent-hooks.ts | 10 ++++---- .../src/chat/runtime/agent-continue-runner.ts | 4 ++-- .../src/chat/runtime/delivered-turn-state.ts | 2 +- .../src/chat/runtime/generated-artifacts.ts | 10 ++++---- .../junior/src/chat/runtime/reply-executor.ts | 6 ++--- .../junior/src/chat/runtime/slack-resume.ts | 6 ++--- .../junior/src/chat/runtime/thread-state.ts | 8 +++---- .../src/chat/runtime/turn-preparation.ts | 6 ++--- packages/junior/src/chat/sandbox/sandbox.ts | 24 +++++++++---------- packages/junior/src/chat/sandbox/session.ts | 12 +++++----- .../junior/src/chat/services/turn-result.ts | 8 +++---- packages/junior/src/chat/tools/index.ts | 2 +- .../src/chat/tools/sandbox/file-uploads.ts | 10 ++++---- packages/junior/src/chat/tools/types.ts | 2 +- .../junior/src/handlers/mcp-oauth-callback.ts | 2 +- .../junior/src/handlers/oauth-callback.ts | 2 +- .../tests/component/agent/sandbox.test.ts | 10 ++++---- .../component/memory-plugin-storage.test.ts | 2 +- .../misc/bash-tool-sandbox-adapter.test.ts | 2 +- .../component/misc/sandbox-executor.test.ts | 22 ++++++++--------- .../runtime/agent-run-error-path.test.ts | 7 ++++-- .../agent-run-mcp-progressive-loading.test.ts | 2 +- .../runtime/agent-run-sandbox.test.ts | 12 +++++----- .../runtime/generated-artifacts.test.ts | 8 +++---- .../component/runtime/thread-state.test.ts | 8 +++---- .../integration/agent-continue-slack.test.ts | 6 ++--- .../mcp-oauth-callback-slack.test.ts | 2 +- .../plugin-auth-runtime-slack.test.ts | 2 +- .../runtime/agent-run-agent-continue.test.ts | 2 +- .../runtime/agent-run-provider-retry.test.ts | 2 +- ...gent-run-tool-timeout-continuation.test.ts | 2 +- .../integration/sandbox-egress-proxy.test.ts | 4 +++- .../integration/slack-schedule-tools.test.ts | 2 +- .../integration/slack-user-lookup.test.ts | 2 +- .../integration/tool-idempotency.test.ts | 2 +- .../tool-support/pi-tool-adapter.test.ts | 2 +- .../tests/unit/plugins/agent-hooks.test.ts | 22 ++++++++--------- .../unit/slack/tool-registration.test.ts | 6 ++--- 44 files changed, 167 insertions(+), 165 deletions(-) diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts index c46e5eaf6..cf8068cd5 100644 --- a/packages/junior/src/chat/agent-dispatch/runner.ts +++ b/packages/junior/src/chat/agent-dispatch/runner.ts @@ -112,13 +112,13 @@ function upsertDispatchUserMessage(args: { async function persistRuntimePatch(args: { artifacts?: ThreadArtifactsState; conversation: ThreadConversationState; - sandbox?: SandboxRef; + sandboxRef?: SandboxRef; threadId: string; }): Promise { await persistThreadStateById(args.threadId, { artifacts: args.artifacts, conversation: args.conversation, - sandbox: args.sandbox, + sandboxRef: args.sandboxRef, }); } @@ -281,7 +281,7 @@ export async function runAgentDispatchSlice( } let artifacts = coerceThreadArtifactsState(persisted); - let sandbox = getPersistedSandboxState(persisted); + let sandboxRef = getPersistedSandboxState(persisted); const channelConfiguration = getChannelConfigurationServiceById( dispatch.destination.channelId, ); @@ -394,19 +394,19 @@ export async function runAgentDispatchSlice( }, state: { artifactState: artifacts, - sandbox, + sandboxRef, }, delivery: { onAssistantMessage: deliverAssistantMessage, }, durability: { - onSandboxRefChanged: async (nextSandbox) => { - sandbox = nextSandbox; + onSandboxRefChanged: async (nextSandboxRef) => { + sandboxRef = nextSandboxRef; await persistRuntimePatch({ threadId: conversationId, conversation, artifacts, - sandbox, + sandboxRef, }); }, onArtifactStateUpdated: async (nextArtifacts) => { @@ -415,7 +415,7 @@ export async function runAgentDispatchSlice( threadId: conversationId, conversation, artifacts, - sandbox, + sandboxRef, }); }, }, @@ -490,7 +490,7 @@ export async function runAgentDispatchSlice( threadId: conversationId, conversation, artifacts: nextArtifacts, - sandbox: reply.sandbox ?? sandbox, + sandboxRef: reply.sandboxRef ?? sandboxRef, }); }); statePersisted = true; diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 232a3e53b..3b6fabd2f 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -265,7 +265,7 @@ async function executeAgentRunInPrivacyContext( const turnTimeoutBudgetMs = Math.max(0, turnDeadlineAtMs - replyStartedAtMs); let resume: ResumeState | undefined; - let lastKnownSandboxRef = state.sandbox; + let lastKnownSandboxRef = state.sandboxRef; let mcpToolManager: McpToolManager | undefined; let connectedMcpProviders = new Set(); let turnUsage: AgentTurnUsage | undefined; @@ -325,8 +325,6 @@ async function executeAgentRunInPrivacyContext( await recordConnectedMcpProvider(provider); } }; - const getSandboxMetadata = () => ({ sandbox: lastKnownSandboxRef }); - try { const projection = await openConversationProjection({ conversationId }); activeModelProfile = projection.modelProfile; @@ -643,8 +641,8 @@ async function executeAgentRunInPrivacyContext( generatedFiles, invokedSkill, observers, - onSandboxRefChanged: (sandbox) => { - lastKnownSandboxRef = sandbox; + onSandboxRefChanged: (sandboxRef) => { + lastKnownSandboxRef = sandboxRef; }, policy, preAgentPromptMessages, @@ -664,7 +662,6 @@ async function executeAgentRunInPrivacyContext( userInput, }); mcpToolManager = wiring.mcpToolManager; - const getSandboxRef = wiring.getSandboxRef; const getPendingAuthPause = wiring.getPendingAuthPause; const toolsAfterHandoff = wiring.agentTools; @@ -1188,7 +1185,7 @@ async function executeAgentRunInPrivacyContext( userInput, artifactStatePatch, toolCalls, - sandbox: getSandboxRef(), + sandboxRef: wiring.getSandboxRef(), piMessages: [...agent.state.messages], durationMs: Date.now() - replyStartedAtMs, generatedFileCount: generatedFiles.length, @@ -1267,7 +1264,7 @@ async function executeAgentRunInPrivacyContext( status: "completed", result: { text: "", - ...getSandboxMetadata(), + sandboxRef: lastKnownSandboxRef, diagnostics: { outcome: "provider_error", modelId: activeModelId, diff --git a/packages/junior/src/chat/agent/request.ts b/packages/junior/src/chat/agent/request.ts index f79855e2e..546a03934 100644 --- a/packages/junior/src/chat/agent/request.ts +++ b/packages/junior/src/chat/agent/request.ts @@ -120,7 +120,7 @@ export interface AgentRunState { artifactState?: ThreadArtifactsState; pendingAuth?: ConversationPendingAuthState; /** Persisted sandbox reuse state from prior slices of this conversation. */ - sandbox?: SandboxRef; + sandboxRef?: SandboxRef; } /** @@ -166,7 +166,7 @@ export interface AgentRunDurability { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; - onSandboxRefChanged?: (sandbox: SandboxRef) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; onArtifactStateUpdated?: ( artifactState: ThreadArtifactsState, ) => void | Promise; diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 277bc43ac..46e0f2f37 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -22,7 +22,7 @@ import type { Skill, SkillMetadata } from "@/chat/skills"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; export interface AgentSandboxOptions { - ref?: SandboxRef; + sandboxRef?: SandboxRef; skills: SkillMetadata[]; traceContext: LogContext; tracePropagation?: SandboxEgressTracePropagationConfig; @@ -32,14 +32,14 @@ export interface AgentSandboxOptions { configurationValues: Record; getActiveSkill(): Skill | null; prepareSandbox(workspace: SandboxWorkspace): void | Promise; - onRefChanged(ref: SandboxRef): void; - persistRef?(ref: SandboxRef): void | Promise; + onSandboxRefChanged(sandboxRef: SandboxRef): void; + persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; } export interface AgentSandbox { readonly tools: SandboxTools; readonly workspace: SandboxWorkspace; - ref(): SandboxRef | undefined; + sandboxRef(): SandboxRef | undefined; writeGeneratedArtifacts( files: FileUpload[], ): Promise; @@ -58,22 +58,22 @@ function bashCommand(input: unknown): string | undefined { /** Create run-scoped sandbox capabilities adapted to the current agent context. */ export function createAgentSandbox(options: AgentSandboxOptions): AgentSandbox { const sandbox = createSandbox({ - ref: options.ref, + sandboxRef: options.sandboxRef, skills: options.skills, referenceFiles: listReferenceFiles(), traceContext: options.traceContext, tracePropagation: options.tracePropagation, credentialEgress: options.credentialEgress, prepare: options.prepareSandbox, - onRefChanged: async (ref) => { - options.onRefChanged(ref); - await options.persistRef?.(ref); + onSandboxRefChanged: async (sandboxRef) => { + options.onSandboxRefChanged(sandboxRef); + await options.persistSandboxRef?.(sandboxRef); }, }); return { workspace: sandbox.workspace, - ref: sandbox.ref, + sandboxRef: sandbox.sandboxRef, tools: { supports: sandbox.tools.supports, async execute(params) { diff --git a/packages/junior/src/chat/agent/tools.ts b/packages/junior/src/chat/agent/tools.ts index cc1fa6ff7..f8c3f0c3a 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -69,7 +69,7 @@ interface ToolWiringArgs { generatedFiles: FileUpload[]; invokedSkill: SkillMetadata | null; observers: AgentRunObservers; - onSandboxRefChanged: (sandbox: SandboxRef) => void; + onSandboxRefChanged: (sandboxRef: SandboxRef) => void; policy: AgentRunPolicy; preAgentPromptMessages: () => PiMessage[]; priorPiMessages: PiMessage[] | undefined; @@ -116,8 +116,8 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); - const sandbox = createAgentSandbox({ - ref: args.state.sandbox, + const agentSandbox = createAgentSandbox({ + sandboxRef: args.state.sandboxRef, skills: args.availableSkills, traceContext: args.spanContext, tracePropagation: args.policy.sandboxTracePropagation, @@ -127,8 +127,8 @@ export async function wireAgentTools( configurationValues: args.configurationValues, getActiveSkill: () => args.skillSandbox.getActiveSkill(), prepareSandbox: pluginHooks.prepareSandbox, - onRefChanged: args.onSandboxRefChanged, - persistRef: args.durability.onSandboxRefChanged, + onSandboxRefChanged: args.onSandboxRefChanged, + persistSandboxRef: args.durability.onSandboxRefChanged, }); const slackDestination = @@ -220,7 +220,7 @@ export async function wireAgentTools( }, }), mcpToolManager, - sandbox: sandbox.workspace, + workspace: agentSandbox.workspace, surface: args.surface, ...(args.requestHandoff ? { handoff: args.requestHandoff } : {}), }; @@ -254,7 +254,7 @@ export async function wireAgentTools( loadableSkills, { writeGeneratedArtifacts: async (files) => { - const refs = await sandbox.writeGeneratedArtifacts(files); + const refs = await agentSandbox.writeGeneratedArtifacts(files); args.generatedFiles.push(...files); return refs; }, @@ -391,7 +391,7 @@ export async function wireAgentTools( args.skillSandbox, args.spanContext, args.observers.onStatus, - sandbox.tools, + agentSandbox.tools, pluginAuth, onToolCall, pluginHooks, @@ -411,7 +411,7 @@ export async function wireAgentTools( getPendingAuthPause, mcpToolManager, pluginHooks, - getSandboxRef: sandbox.ref, + getSandboxRef: agentSandbox.sandboxRef, toolGuidance, toolRuntimeContext, }; diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 83e1ad0a6..61836e369 100644 --- a/packages/junior/src/chat/local/runner.ts +++ b/packages/junior/src/chat/local/runner.ts @@ -173,9 +173,9 @@ export async function runLocalAgentTurn( conversationId: input.conversationId, }); let artifacts = coerceThreadArtifactsState(persisted); - let sandbox = getPersistedSandboxState(persisted); + let sandboxRef = getPersistedSandboxState(persisted); const initialArtifacts = artifacts; - const initialSandbox = sandbox; + const initialSandboxRef = sandboxRef; const turnId = localTurnId(); const userMessageId = `${turnId}:user`; @@ -291,7 +291,7 @@ export async function runLocalAgentTurn( }, state: { artifactState: artifacts, - sandbox, + sandboxRef, }, observers: { onStatus: async (status) => { @@ -313,15 +313,15 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts, conversation, - sandbox, + sandboxRef, }); }, - onSandboxRefChanged: async (nextSandbox) => { - sandbox = nextSandbox; + onSandboxRefChanged: async (nextSandboxRef) => { + sandboxRef = nextSandboxRef; await persistThreadStateById(input.conversationId, { artifacts, conversation, - sandbox, + sandboxRef, }); }, }, @@ -376,7 +376,7 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts: initialArtifacts, conversation, - sandbox: initialSandbox ?? null, + sandboxRef: initialSandboxRef ?? null, }); } catch (persistenceError) { const persistenceEventId = captureLocalBoundaryFailure({ @@ -412,7 +412,7 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts: completedState.artifacts ?? artifacts, conversation: completedState.conversation, - sandbox: reply.sandbox ?? sandbox, + sandboxRef: reply.sandboxRef ?? sandboxRef, }); if (reply.piMessages?.length) { // Destination acceptance is the completion boundary: this first commits diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 9158add96..56beb1318 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -67,7 +67,7 @@ export interface PluginApiRouteRegistration { export interface PluginHookRunner { beforeToolExecute(input: ToolHookInput): Promise; - prepareSandbox(sandbox: SandboxWorkspace): Promise; + prepareSandbox(workspace: SandboxWorkspace): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1005,15 +1005,15 @@ function normalizeEnv(value: unknown): Record { return env; } -function createSandboxCapability(sandbox: SandboxWorkspace): PluginSandbox { +function createSandboxCapability(workspace: SandboxWorkspace): PluginSandbox { return { root: SANDBOX_WORKSPACE_ROOT, juniorRoot: `${SANDBOX_WORKSPACE_ROOT}/.junior`, async readFile(filePath) { - return (await sandbox.readFileToBuffer({ path: filePath })) ?? null; + return (await workspace.readFileToBuffer({ path: filePath })) ?? null; }, async run(input: SandboxCommandInput) { - const result = await sandbox.runCommand(input); + const result = await workspace.runCommand(input); return { exitCode: result.exitCode, stdout: result.stdout, @@ -1021,7 +1021,7 @@ function createSandboxCapability(sandbox: SandboxWorkspace): PluginSandbox { }; }, async writeFile(input) { - await sandbox.writeFiles([ + await workspace.writeFiles([ { path: input.path, content: input.content, diff --git a/packages/junior/src/chat/runtime/agent-continue-runner.ts b/packages/junior/src/chat/runtime/agent-continue-runner.ts index 0b261305c..a9e3ea93d 100644 --- a/packages/junior/src/chat/runtime/agent-continue-runner.ts +++ b/packages/junior/src/chat/runtime/agent-continue-runner.ts @@ -347,7 +347,7 @@ export async function continueSlackAgentRun( const conversationContext = buildConversationContext(conversation, { excludeMessageId: userMessage.id, }); - const sandbox = getPersistedSandboxState(currentState); + const sandboxRef = getPersistedSandboxState(currentState); const destination = requireSlackDestination( payload.destination, "Slack continuation", @@ -428,7 +428,7 @@ export async function continueSlackAgentRun( state: { artifactState: artifacts, pendingAuth: conversation.processing.pendingAuth, - sandbox, + sandboxRef, }, durability: { recordPendingAuth: async (nextPendingAuth) => { diff --git a/packages/junior/src/chat/runtime/delivered-turn-state.ts b/packages/junior/src/chat/runtime/delivered-turn-state.ts index 577fe0d14..b81cf1dc9 100644 --- a/packages/junior/src/chat/runtime/delivered-turn-state.ts +++ b/packages/junior/src/chat/runtime/delivered-turn-state.ts @@ -46,6 +46,6 @@ export function buildDeliveredTurnStatePatch(args: { return { artifacts, conversation, - sandbox: args.reply.sandbox, + sandboxRef: args.reply.sandboxRef, }; } diff --git a/packages/junior/src/chat/runtime/generated-artifacts.ts b/packages/junior/src/chat/runtime/generated-artifacts.ts index ea1b99155..48fe7ad7f 100644 --- a/packages/junior/src/chat/runtime/generated-artifacts.ts +++ b/packages/junior/src/chat/runtime/generated-artifacts.ts @@ -6,8 +6,8 @@ import { } from "@/chat/tools/sandbox/file-uploads"; import type { SandboxCommandResult } from "@/chat/sandbox/workspace"; -/** Sandbox operations needed to make generated artifacts visible to later tools. */ -export interface GeneratedArtifactSandbox { +/** Workspace operations needed to make generated artifacts visible to later tools. */ +export interface GeneratedArtifactWorkspace { runCommand(input: { args?: string[]; cmd: string; @@ -34,10 +34,10 @@ async function fileUploadDataToBuffer( /** Persist generated artifacts into the sandbox before returning model-visible handles. */ export async function writeSandboxGeneratedArtifacts( - sandbox: GeneratedArtifactSandbox, + workspace: GeneratedArtifactWorkspace, files: FileUpload[], ): Promise { - const mkdir = await sandbox.runCommand({ + const mkdir = await workspace.runCommand({ cmd: "mkdir", args: ["-p", SANDBOX_ARTIFACTS_DIR], }); @@ -63,7 +63,7 @@ export async function writeSandboxGeneratedArtifacts( }), ); - await sandbox.writeFiles( + await workspace.writeFiles( artifacts.map((artifact) => ({ content: artifact.content, path: artifact.ref.path, diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 36651c2ed..3fcbcdd97 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -1313,7 +1313,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { state: { artifactState: preparedState.artifacts, pendingAuth: preparedState.conversation.processing.pendingAuth, - sandbox: preparedState.sandbox, + sandboxRef: preparedState.sandboxRef, }, observers: { onStatus: (nextStatus) => status.update(nextStatus), @@ -1326,9 +1326,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { onInputCommitted: options.ack, drainSteeringMessages, shouldYield: options.shouldYield, - onSandboxRefChanged: async (sandbox) => { + onSandboxRefChanged: async (sandboxRef) => { await persistThreadState(thread, { - sandbox, + sandboxRef, }); }, onArtifactStateUpdated: async (artifacts) => { diff --git a/packages/junior/src/chat/runtime/slack-resume.ts b/packages/junior/src/chat/runtime/slack-resume.ts index 758f4465c..4a5db4e17 100644 --- a/packages/junior/src/chat/runtime/slack-resume.ts +++ b/packages/junior/src/chat/runtime/slack-resume.ts @@ -387,11 +387,11 @@ function createResumeReplyContext( delivery, durability: { ...replyContext.durability, - onSandboxRefChanged: async (sandbox) => { + onSandboxRefChanged: async (sandboxRef) => { await persistThreadStateById(threadId, { - sandbox, + sandboxRef, }); - await replyContext.durability?.onSandboxRefChanged?.(sandbox); + await replyContext.durability?.onSandboxRefChanged?.(sandboxRef); }, onArtifactStateUpdated: async (artifacts) => { await persistThreadStateById(threadId, { artifacts }); diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index f2e95a3ba..3f60b0b0f 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -16,7 +16,7 @@ import type { SandboxRef } from "@/chat/sandbox/ref"; export interface ThreadStatePatch { artifacts?: ThreadArtifactsState; conversation?: ThreadConversationState; - sandbox?: SandboxRef | null; + sandboxRef?: SandboxRef | null; } function threadStateKey(threadId: string): string { @@ -37,10 +37,10 @@ function buildThreadStatePayload( if (patch.conversation) { Object.assign(payload, buildConversationStatePatch(patch.conversation)); } - if (patch.sandbox !== undefined) { - payload.app_sandbox_id = patch.sandbox?.id ?? ""; + if (patch.sandboxRef !== undefined) { + payload.app_sandbox_id = patch.sandboxRef?.id ?? ""; payload.app_sandbox_dependency_profile_hash = - patch.sandbox?.profileHash ?? ""; + patch.sandboxRef?.profileHash ?? ""; } return payload; } diff --git a/packages/junior/src/chat/runtime/turn-preparation.ts b/packages/junior/src/chat/runtime/turn-preparation.ts index c77b67c3b..c431d7fbe 100644 --- a/packages/junior/src/chat/runtime/turn-preparation.ts +++ b/packages/junior/src/chat/runtime/turn-preparation.ts @@ -55,7 +55,7 @@ export interface PreparedTurnState { channelConfiguration?: ChannelConfigurationService; conversation: ThreadConversationState; conversationContext?: string; - sandbox?: SandboxRef; + sandboxRef?: SandboxRef; userMessageAlreadyReplied?: boolean; userMessageId?: string; } @@ -179,7 +179,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { args: PrepareTurnStateInput, ): Promise { const existingState = await args.thread.state; - const sandbox = getPersistedSandboxState(existingState ?? {}); + const sandboxRef = getPersistedSandboxState(existingState ?? {}); const artifacts = coerceThreadArtifactsState(existingState); const conversation = coerceThreadConversationState(existingState); const conversationId = args.context.threadId ?? args.context.runId; @@ -267,7 +267,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { configuration, channelConfiguration, conversation, - sandbox, + sandboxRef, conversationContext, userMessageAlreadyReplied, userMessageId, diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 9598b0ede..a917107ba 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -65,14 +65,14 @@ export interface SandboxTools { execute(params: SandboxToolCall): Promise; } -export interface Sandbox { +export interface SandboxAccess { readonly tools: SandboxTools; readonly workspace: SandboxWorkspace; - ref(): SandboxRef | undefined; + sandboxRef(): SandboxRef | undefined; } export interface SandboxOptions { - ref?: SandboxRef; + sandboxRef?: SandboxRef; skills: SkillMetadata[]; referenceFiles: string[]; timeoutMs?: number; @@ -80,7 +80,7 @@ export interface SandboxOptions { tracePropagation?: SandboxEgressTracePropagationConfig; credentialEgress?: CredentialContext; prepare?: (workspace: SandboxWorkspace) => void | Promise; - onRefChanged?: (ref: SandboxRef) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } /** Create the safe expected tool error for an unavailable sandbox operation. */ @@ -117,7 +117,7 @@ function parseEnv(raw: unknown): Record | undefined { } /** Create lazy run-scoped access to a conversation's durable sandbox. */ -export function createSandbox(options: SandboxOptions): Sandbox { +export function createSandbox(options: SandboxOptions): SandboxAccess { const traceContext = options.traceContext ?? {}; const tracePropagation = options.tracePropagation; const hasTracePropagationDomains = @@ -152,7 +152,7 @@ export function createSandbox(options: SandboxOptions): Sandbox { return token; }; const runtime = createSandboxRuntime({ - ref: options.ref, + sandboxRef: options.sandboxRef, skills: options.skills, referenceFiles: options.referenceFiles, timeoutMs: options.timeoutMs, @@ -176,8 +176,8 @@ export function createSandbox(options: SandboxOptions): Sandbox { onSandboxPrepare: async (sandbox) => { await options.prepare?.(sandbox); }, - onRefChanged: async (ref) => { - await options.onRefChanged?.(ref); + onSandboxRefChanged: async (sandboxRef) => { + await options.onSandboxRefChanged?.(sandboxRef); }, }); @@ -204,7 +204,7 @@ export function createSandbox(options: SandboxOptions): Sandbox { trigger: string, details: Record = {}, ): void => { - if (runtime.ref()) { + if (runtime.sandboxRef()) { return; } @@ -313,7 +313,7 @@ export function createSandbox(options: SandboxOptions): Sandbox { const offset = positiveInteger(rawInput.offset); const limit = positiveInteger(rawInput.limit); - if (!runtime.ref()) { + if (!runtime.sandboxRef()) { const hostPath = resolveHostSkillPath(options.skills, filePath) ?? resolveHostDataPath(options.referenceFiles, filePath); @@ -654,8 +654,8 @@ export function createSandbox(options: SandboxOptions): Sandbox { }, execute, }, - ref() { - return runtime.ref(); + sandboxRef() { + return runtime.sandboxRef(); }, }; } diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 6bf073098..f3016170c 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -106,7 +106,7 @@ function createBashToolSandboxAdapter(sandbox: SandboxSession) { } interface SandboxRuntime { - ref(): SandboxRef | undefined; + sandboxRef(): SandboxRef | undefined; acquire(): Promise; tools(): Promise; refreshNetworkPolicy(traceHeaders?: TracePropagationHeaders): Promise; @@ -120,7 +120,7 @@ interface ActiveSandbox { } interface SandboxRuntimeOptions { - ref?: SandboxRef; + sandboxRef?: SandboxRef; skills: SkillMetadata[]; referenceFiles: string[]; timeoutMs?: number; @@ -131,7 +131,7 @@ interface SandboxRuntimeOptions { traceHeaders?: TracePropagationHeaders, ) => NetworkPolicy | undefined; onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; - onRefChanged?: (ref: SandboxRef) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } function truncateOutput( @@ -179,7 +179,7 @@ export function createSandboxRuntime( options: SandboxRuntimeOptions, ): SandboxRuntime { let activeSandbox: ActiveSandbox | null = null; - let sandboxRef = options.ref; + let sandboxRef = options.sandboxRef; const availableSkills = [...options.skills]; const availableReferenceFiles = [...options.referenceFiles]; let acquiringSandbox: Promise | undefined; @@ -242,7 +242,7 @@ export function createSandboxRuntime( return; } sandboxRef = nextRef; - await options.onRefChanged?.(nextRef); + await options.onSandboxRefChanged?.(nextRef); }; const rememberSandbox = ( @@ -835,7 +835,7 @@ export function createSandboxRuntime( }; return { - ref() { + sandboxRef() { return sandboxRef ? { ...sandboxRef } : undefined; }, async acquire() { diff --git a/packages/junior/src/chat/services/turn-result.ts b/packages/junior/src/chat/services/turn-result.ts index dcd8cda73..6e89fe26e 100644 --- a/packages/junior/src/chat/services/turn-result.ts +++ b/packages/junior/src/chat/services/turn-result.ts @@ -114,7 +114,7 @@ export interface AgentRunResult { /** Sanitized terminal text for diagnostics and failure fallback, not success delivery. */ text: string; artifactStatePatch?: Partial; - sandbox?: SandboxRef; + sandboxRef?: SandboxRef; piMessages?: PiMessage[]; diagnostics: AgentTurnDiagnostics; } @@ -124,7 +124,7 @@ export interface TurnResultInput { userInput: string; artifactStatePatch: Partial; toolCalls: string[]; - sandbox?: SandboxRef; + sandboxRef?: SandboxRef; piMessages?: PiMessage[]; durationMs?: number; generatedFileCount: number; @@ -187,7 +187,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { newMessages, artifactStatePatch, toolCalls, - sandbox, + sandboxRef, durationMs, shouldTrace, spanContext, @@ -340,7 +340,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { Object.keys(artifactStatePatch).length > 0 ? artifactStatePatch : undefined, - sandbox, + sandboxRef, piMessages: input.piMessages, diagnostics: resolvedDiagnostics, }; diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index 675e37fed..3ef49542e 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -189,7 +189,7 @@ export function createTools( if (rawChannelCapabilities.canSendFiles) { tools.sendFiles = createSendFilesTool(slackContext, state, (input) => - readSandboxFileUpload(context.sandbox, input), + readSandboxFileUpload(context.workspace, input), ); } diff --git a/packages/junior/src/chat/tools/sandbox/file-uploads.ts b/packages/junior/src/chat/tools/sandbox/file-uploads.ts index e4c3016ed..5317f73f3 100644 --- a/packages/junior/src/chat/tools/sandbox/file-uploads.ts +++ b/packages/junior/src/chat/tools/sandbox/file-uploads.ts @@ -159,11 +159,11 @@ export function inferMimeType( } async function detectMimeType( - sandbox: SandboxWorkspace, + workspace: SandboxWorkspace, targetPath: string, ): Promise { try { - const result = await runNonInteractiveCommand(sandbox, { + const result = await runNonInteractiveCommand(workspace, { cmd: "file", args: ["--mime-type", "-b", targetPath], }); @@ -179,11 +179,11 @@ async function detectMimeType( /** Read and validate one sandbox file for Slack/file reply upload. */ export async function readSandboxFileUpload( - sandbox: SandboxWorkspace, + workspace: SandboxWorkspace, input: SandboxFileMaterializationInput, ): Promise { const targetPath = normalizeSandboxPath(input.path); - const fileBuffer = await sandbox.readFileToBuffer({ path: targetPath }); + const fileBuffer = await workspace.readFileToBuffer({ path: targetPath }); if (!fileBuffer) { throw new SandboxFileNotFoundError(targetPath); } @@ -199,7 +199,7 @@ export async function readSandboxFileUpload( } const resolvedFilename = sanitizeFilename(input.filename, targetPath); - const detectedMimeType = await detectMimeType(sandbox, targetPath); + const detectedMimeType = await detectMimeType(workspace, targetPath); const resolvedMimeType = inferMimeType( resolvedFilename, input.mimeType ?? detectedMimeType, diff --git a/packages/junior/src/chat/tools/types.ts b/packages/junior/src/chat/tools/types.ts index d914912cc..ffb44ca9c 100644 --- a/packages/junior/src/chat/tools/types.ts +++ b/packages/junior/src/chat/tools/types.ts @@ -91,7 +91,7 @@ interface BaseToolRuntimeContext { configuration?: Record; egress: PluginEgress; mcpToolManager?: McpToolManager; - sandbox: SandboxWorkspace; + workspace: SandboxWorkspace; } interface SlackToolRuntimeContext extends BaseToolRuntimeContext { diff --git a/packages/junior/src/handlers/mcp-oauth-callback.ts b/packages/junior/src/handlers/mcp-oauth-callback.ts index 560c1a1e3..8309fd600 100644 --- a/packages/junior/src/handlers/mcp-oauth-callback.ts +++ b/packages/junior/src/handlers/mcp-oauth-callback.ts @@ -396,7 +396,7 @@ async function resumeAuthorizedMcpTurn(args: { state: { artifactState: lockedArtifacts, pendingAuth: lockedPendingAuth, - sandbox: getPersistedSandboxState(lockedState), + sandboxRef: getPersistedSandboxState(lockedState), }, durability: { recordPendingAuth: async (nextPendingAuth) => { diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 956b1dfb5..473f61397 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -419,7 +419,7 @@ async function resumeOAuthSessionRecordTurn( state: { artifactState: lockedArtifacts, pendingAuth: lockedPendingAuth, - sandbox: getPersistedSandboxState(lockedState), + sandboxRef: getPersistedSandboxState(lockedState), }, durability: { recordPendingAuth: async (nextPendingAuth) => { diff --git a/packages/junior/tests/component/agent/sandbox.test.ts b/packages/junior/tests/component/agent/sandbox.test.ts index 9953edcb3..448df1d68 100644 --- a/packages/junior/tests/component/agent/sandbox.test.ts +++ b/packages/junior/tests/component/agent/sandbox.test.ts @@ -49,7 +49,7 @@ function options( configurationValues: {}, getActiveSkill: () => null, prepareSandbox: vi.fn(), - onRefChanged: vi.fn(), + onSandboxRefChanged: vi.fn(), ...overrides, }; } @@ -63,7 +63,7 @@ describe("createAgentSandbox", () => { capturedOptions = input; return { workspace, - ref: () => input.ref, + sandboxRef: () => input.sandboxRef, tools: { supports: () => true, execute: executeSandboxToolMock, @@ -77,16 +77,16 @@ describe("createAgentSandbox", () => { const callOrder: string[] = []; createAgentSandbox( options({ - onRefChanged: () => { + onSandboxRefChanged: () => { callOrder.push("run"); }, - persistRef: async () => { + persistSandboxRef: async () => { callOrder.push("durable"); }, }), ); - await capturedOptions.onRefChanged?.({ id: "sandbox-1" }); + await capturedOptions.onSandboxRefChanged?.({ id: "sandbox-1" }); expect(callOrder).toEqual(["run", "durable"]); expect(capturedOptions.referenceFiles).toEqual(["/tmp/reference.md"]); diff --git a/packages/junior/tests/component/memory-plugin-storage.test.ts b/packages/junior/tests/component/memory-plugin-storage.test.ts index 914a113ce..814a24f75 100644 --- a/packages/junior/tests/component/memory-plugin-storage.test.ts +++ b/packages/junior/tests/component/memory-plugin-storage.test.ts @@ -316,7 +316,7 @@ WHERE table_name = 'junior_memory_memories' }, }, actor, - sandbox: {} as Parameters[0]["sandbox"], + workspace: {} as Parameters[0]["workspace"], source, userText: "remember memory plugin facts", }); diff --git a/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts b/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts index 098d3707c..b822b3bae 100644 --- a/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts +++ b/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts @@ -73,7 +73,7 @@ describe("bash-tool sandbox adapter", () => { const sandbox = makeSandbox(); sandboxGetMock.mockResolvedValue(sandbox); const manager = createSandboxRuntime({ - ref: { + sandboxRef: { id: "sbx_adapter_contract", profileHash: "current-profile", }, diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 3320e44f2..8172a49fb 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -157,7 +157,7 @@ interface SandboxFixtureOptions { credentialEgress?: any; agentHooks?: { beforeToolExecute: ReturnType; - prepareSandbox(sandbox: SandboxWorkspace): Promise; + prepareSandbox(workspace: SandboxWorkspace): Promise; }; onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; onSandboxAcquired?: (sandbox: { @@ -174,7 +174,7 @@ function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { let runtime: ReturnType | undefined; const getRuntime = () => (runtime ??= createSandboxRuntime({ - ref: options.sandboxId + sandboxRef: options.sandboxId ? { id: options.sandboxId, ...(options.sandboxDependencyProfileHash @@ -189,7 +189,7 @@ function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { commandEnv: options.commandEnv, createNetworkPolicy: options.createNetworkPolicy, onSandboxPrepare: options.onSandboxPrepare, - onRefChanged: async (ref) => { + onSandboxRefChanged: async (ref) => { await options.onSandboxAcquired?.({ sandboxId: ref.id, ...(ref.profileHash @@ -205,8 +205,8 @@ function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { configureReferenceFiles(nextFiles: string[]) { referenceFiles = [...nextFiles]; }, - getSandboxId: () => getRuntime().ref()?.id, - getDependencyProfileHash: () => getRuntime().ref()?.profileHash, + getSandboxId: () => getRuntime().sandboxRef()?.id, + getDependencyProfileHash: () => getRuntime().sandboxRef()?.profileHash, createSandbox: async () => await getRuntime().acquire(), ensureToolExecutors: async () => await getRuntime().tools(), refreshNetworkPolicy: async ( @@ -223,7 +223,7 @@ function createTestSandbox(options: SandboxFixtureOptions = {}) { let access: ReturnType | undefined; const getAccess = () => (access ??= createSandbox({ - ref: options.sandboxId + sandboxRef: options.sandboxId ? { id: options.sandboxId, ...(options.sandboxDependencyProfileHash @@ -241,7 +241,7 @@ function createTestSandbox(options: SandboxFixtureOptions = {}) { ? async (workspace) => await options.agentHooks?.prepareSandbox(workspace) : undefined, - onRefChanged: async (ref) => { + onSandboxRefChanged: async (ref) => { await options.onSandboxAcquired?.({ sandboxId: ref.id, ...(ref.profileHash @@ -257,8 +257,8 @@ function createTestSandbox(options: SandboxFixtureOptions = {}) { configureReferenceFiles(nextFiles: string[]) { referenceFiles = [...nextFiles]; }, - getSandboxId: () => getAccess().ref()?.id, - getDependencyProfileHash: () => getAccess().ref()?.profileHash, + getSandboxId: () => getAccess().sandboxRef()?.id, + getDependencyProfileHash: () => getAccess().sandboxRef()?.profileHash, canExecute: (toolName: string) => getAccess().tools.supports(toolName), createSandbox: async () => { const sandbox = getAccess(); @@ -496,12 +496,12 @@ describe("createTestSandbox", () => { it("preserves an unopened sandbox reference without rewriting its profile", () => { getRuntimeDependencyProfileHashMock.mockReturnValue("current-profile"); const sandbox = createSandbox({ - ref: { id: "sbx_existing", profileHash: "persisted-profile" }, + sandboxRef: { id: "sbx_existing", profileHash: "persisted-profile" }, skills: [], referenceFiles: [], }); - expect(sandbox.ref()).toEqual({ + expect(sandbox.sandboxRef()).toEqual({ id: "sbx_existing", profileHash: "persisted-profile", }); diff --git a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts index 368331e3a..3d1442128 100644 --- a/packages/junior/tests/component/runtime/agent-run-error-path.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-error-path.test.ts @@ -37,7 +37,7 @@ describe("executeAgentRun error path", () => { input: { messageText: "hello" }, routing: { destination: LOCAL_DESTINATION, source: LOCAL_SOURCE }, state: { - sandbox: { + sandboxRef: { id: "sb-123", profileHash: "hash-abc", }, @@ -51,7 +51,10 @@ describe("executeAgentRun error path", () => { expect(reply!.text).toBe(""); expect(reply!.diagnostics.errorMessage).toBe("discover failed"); expect(reply!.diagnostics.assistantMessageCount).toBe(0); - expect(reply!.sandbox).toEqual({ id: "sb-123", profileHash: "hash-abc" }); + expect(reply!.sandboxRef).toEqual({ + id: "sb-123", + profileHash: "hash-abc", + }); expect(reply!.diagnostics.outcome).toBe("provider_error"); expect(reply!.diagnostics.modelId).toBe("openai/gpt-5.4"); expect(reply!.diagnostics.reasoningLevel).toBeUndefined(); diff --git a/packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts b/packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts index f3853723f..c55c437d2 100644 --- a/packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts @@ -450,7 +450,7 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ throw new Error("sandbox executor should not handle mocked tools"); }, }, - ref: () => ({ id: "sandbox-test", profileHash: "hash-test" }), + sandboxRef: () => ({ id: "sandbox-test", profileHash: "hash-test" }), }), })); diff --git a/packages/junior/tests/component/runtime/agent-run-sandbox.test.ts b/packages/junior/tests/component/runtime/agent-run-sandbox.test.ts index 63af38526..eacef30c1 100644 --- a/packages/junior/tests/component/runtime/agent-run-sandbox.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-sandbox.test.ts @@ -281,14 +281,14 @@ vi.mock("@/chat/skills", () => { vi.mock("@/chat/sandbox/sandbox", () => ({ createSandbox: (options: { - onRefChanged?: (sandbox: { + onSandboxRefChanged?: (sandboxRef: { id: string; profileHash?: string; }) => void | Promise; }) => { const acquire = async () => { createSandboxCallCount.value += 1; - await options.onRefChanged?.({ + await options.onSandboxRefChanged?.({ id: activeSandboxVersion.value === 1 ? "sandbox-test" @@ -356,7 +356,7 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ }; }, }, - ref: () => + sandboxRef: () => createSandboxCallCount.value > 0 ? { id: @@ -418,7 +418,7 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(reply.text).toBe("Plain reply."); expect(createSandboxCallCount.value).toBe(0); - expect(reply.sandbox).toBeUndefined(); + expect(reply.sandboxRef).toBeUndefined(); expect(reply.diagnostics.toolCalls).toEqual([]); expect(selectedThinkingLevels.value).toEqual(["off"]); }); @@ -430,7 +430,7 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(reply.text).toBe("Loaded demo skill."); expect(createSandboxCallCount.value).toBe(0); - expect(reply.sandbox).toBeUndefined(); + expect(reply.sandboxRef).toBeUndefined(); expect(reply.diagnostics.toolCalls).toEqual(["loadSkill"]); expect(selectedThinkingLevels.value).toEqual(["medium"]); }); @@ -510,7 +510,7 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(reply.text).toBe(""); expect(reply.diagnostics.errorMessage).toContain("agent exploded"); expect(createSandboxCallCount.value).toBe(1); - expect(reply.sandbox).toEqual({ + expect(reply.sandboxRef).toEqual({ id: "sandbox-test", profileHash: "hash-test", }); diff --git a/packages/junior/tests/component/runtime/generated-artifacts.test.ts b/packages/junior/tests/component/runtime/generated-artifacts.test.ts index 740f28a6a..864e9eca2 100644 --- a/packages/junior/tests/component/runtime/generated-artifacts.test.ts +++ b/packages/junior/tests/component/runtime/generated-artifacts.test.ts @@ -21,7 +21,7 @@ function createGeneratedArtifactSandbox() { return { commands, files, - sandbox: { + workspace: { runCommand: async (input: { args?: string[]; cmd: string }) => { commands.push(input); return commandResult(); @@ -58,7 +58,7 @@ describe("writeSandboxGeneratedArtifacts", () => { ]; const refs = await writeSandboxGeneratedArtifacts( - fixture.sandbox, + fixture.workspace, generated, ); @@ -81,7 +81,7 @@ describe("writeSandboxGeneratedArtifacts", () => { }); it("fails before returning refs when the artifact directory cannot be created", async () => { - const sandbox = { + const workspace = { runCommand: async () => commandResult({ exitCode: 1, @@ -93,7 +93,7 @@ describe("writeSandboxGeneratedArtifacts", () => { }; await expect( - writeSandboxGeneratedArtifacts(sandbox, [ + writeSandboxGeneratedArtifacts(workspace, [ { data: Buffer.from("image-bytes"), filename: "generated.png", diff --git a/packages/junior/tests/component/runtime/thread-state.test.ts b/packages/junior/tests/component/runtime/thread-state.test.ts index 269bf5814..06b20cf7a 100644 --- a/packages/junior/tests/component/runtime/thread-state.test.ts +++ b/packages/junior/tests/component/runtime/thread-state.test.ts @@ -26,11 +26,11 @@ describe("thread sandbox state", () => { it("clears a stale profile hash when the replacement reference omits one", async () => { const conversationId = "local:test:thread-sandbox-state"; await persistThreadStateById(conversationId, { - sandbox: { id: "sandbox-old", profileHash: "profile-old" }, + sandboxRef: { id: "sandbox-old", profileHash: "profile-old" }, }); await persistThreadStateById(conversationId, { - sandbox: { id: "sandbox-new" }, + sandboxRef: { id: "sandbox-new" }, }); const state = await getPersistedThreadState(conversationId); @@ -44,10 +44,10 @@ describe("thread sandbox state", () => { it("clears both fields when the sandbox reference is removed", async () => { const conversationId = "local:test:thread-sandbox-clear"; await persistThreadStateById(conversationId, { - sandbox: { id: "sandbox-old", profileHash: "profile-old" }, + sandboxRef: { id: "sandbox-old", profileHash: "profile-old" }, }); - await persistThreadStateById(conversationId, { sandbox: null }); + await persistThreadStateById(conversationId, { sandboxRef: null }); const state = await getPersistedThreadState(conversationId); expect(state).toMatchObject({ diff --git a/packages/junior/tests/integration/agent-continue-slack.test.ts b/packages/junior/tests/integration/agent-continue-slack.test.ts index beecc0b59..3fdf0a309 100644 --- a/packages/junior/tests/integration/agent-continue-slack.test.ts +++ b/packages/junior/tests/integration/agent-continue-slack.test.ts @@ -71,7 +71,7 @@ function createSandbox(files: Record): SandboxWorkspace { /** Build a Slack tool context from the resumed request to exercise continuation file sends. */ function createToolContext( request: AgentRunRequest, - sandbox: SandboxWorkspace, + workspace: SandboxWorkspace, ): ToolRuntimeContext { if ( request.routing.source.platform !== "slack" || @@ -90,7 +90,7 @@ function createToolContext( request.routing.actor?.platform === "slack" ? request.routing.actor : undefined, - sandbox, + workspace, source: request.routing.source, surface: request.routing.surface, userText: request.input.messageText, @@ -295,7 +295,7 @@ describe("agent continuation Slack integration", () => { toolChannelId: "C999", }), state: expect.objectContaining({ - sandbox: undefined, + sandboxRef: undefined, }), }), ); diff --git a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts index ea4dfd81a..c6442d66a 100644 --- a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts +++ b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts @@ -213,7 +213,7 @@ describe("mcp oauth callback slack integration", () => { artifactStatePatch: { lastCanvasUrl: "https://example.com/canvas", }, - sandbox: { id: "sandbox-1", profileHash: "hash-1" }, + sandboxRef: { id: "sandbox-1", profileHash: "hash-1" }, diagnostics: makeDiagnostics(), }); }); diff --git a/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts b/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts index ccd3d87ae..f29f5ea25 100644 --- a/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts +++ b/packages/junior/tests/integration/plugin-auth-runtime-slack.test.ts @@ -83,7 +83,7 @@ vi.mock("@/chat/sandbox/sandbox", async () => { }; }, }, - ref: () => undefined, + sandboxRef: () => undefined, }), }; }); diff --git a/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts b/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts index d617e42e9..5fd5ddf42 100644 --- a/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-agent-continue.test.ts @@ -256,7 +256,7 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ throw new Error("sandbox executor should not execute in this test"); }, }, - ref: () => undefined, + sandboxRef: () => undefined, }), })); diff --git a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts index a62b678d0..95b2ad24f 100644 --- a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts @@ -349,7 +349,7 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ throw new Error("sandbox executor should not execute in this test"); }, }, - ref: () => undefined, + sandboxRef: () => undefined, }), })); diff --git a/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts b/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts index f48b9be6d..6c99459a3 100644 --- a/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-tool-timeout-continuation.test.ts @@ -213,7 +213,7 @@ vi.mock("@/chat/sandbox/sandbox", () => ({ }; }, }, - ref: () => undefined, + sandboxRef: () => undefined, }), })); diff --git a/packages/junior/tests/integration/sandbox-egress-proxy.test.ts b/packages/junior/tests/integration/sandbox-egress-proxy.test.ts index b18f982e6..dabe6df9b 100644 --- a/packages/junior/tests/integration/sandbox-egress-proxy.test.ts +++ b/packages/junior/tests/integration/sandbox-egress-proxy.test.ts @@ -753,7 +753,9 @@ describe("sandbox egress proxy integration", () => { handleAuthRequired: vi.fn(), }, }), - sandbox: {} as Parameters[2]["sandbox"], + workspace: {} as Parameters< + typeof toolsModule.createTools + >[2]["workspace"], source: createLocalSource(conversationId), }, ); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index 39cdbbcfb..89a2e4146 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -1400,7 +1400,7 @@ describe("Slack schedule tool wiring via getPluginTools", () => { return new Response("ok"); }, }, - sandbox: {} as Parameters[0]["sandbox"], + workspace: {} as Parameters[0]["workspace"], }); expect(tools).toHaveProperty("scheduler_slackScheduleCreateTask"); diff --git a/packages/junior/tests/integration/slack-user-lookup.test.ts b/packages/junior/tests/integration/slack-user-lookup.test.ts index a4768845c..ddfb5f121 100644 --- a/packages/junior/tests/integration/slack-user-lookup.test.ts +++ b/packages/junior/tests/integration/slack-user-lookup.test.ts @@ -359,7 +359,7 @@ describe("slackUserLookup", () => { return new Response("ok"); }, }, - sandbox: {} as any, + workspace: {} as any, }, ); diff --git a/packages/junior/tests/integration/tool-idempotency.test.ts b/packages/junior/tests/integration/tool-idempotency.test.ts index 676295475..5fa87ad71 100644 --- a/packages/junior/tests/integration/tool-idempotency.test.ts +++ b/packages/junior/tests/integration/tool-idempotency.test.ts @@ -94,7 +94,7 @@ const LOCAL_CONTEXT = { platform: "local", conversationId: "local:test:tool-idempotency", }, - sandbox: noopSandbox, + workspace: noopSandbox, } as const; async function executeTool(tool: any, input: TInput) { diff --git a/packages/junior/tests/integration/tool-support/pi-tool-adapter.test.ts b/packages/junior/tests/integration/tool-support/pi-tool-adapter.test.ts index 16b8423b3..3c78677f0 100644 --- a/packages/junior/tests/integration/tool-support/pi-tool-adapter.test.ts +++ b/packages/junior/tests/integration/tool-support/pi-tool-adapter.test.ts @@ -40,7 +40,7 @@ function runtimeContext() { destination: LOCAL_DESTINATION, egress: TEST_EGRESS, source: LOCAL_SOURCE, - sandbox: {} as any, + workspace: {} as any, }; } diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index 386fee2f7..366a8a954 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -424,7 +424,7 @@ describe("agent plugin hooks", () => { actor: TEST_ACTOR, egress: TEST_EGRESS, source: SLACK_SOURCE, - sandbox: {} as any, + workspace: {} as any, }); expect(tools).toHaveProperty("agentDemo_demoTool"); @@ -466,7 +466,7 @@ describe("agent plugin hooks", () => { destination: LOCAL_DESTINATION, egress: TEST_EGRESS, source: LOCAL_SOURCE, - sandbox: {} as any, + workspace: {} as any, }); expect(tools.agentDemo_prototypeTool).toBe(prototypeTool); @@ -515,7 +515,7 @@ describe("agent plugin hooks", () => { destination: LOCAL_DESTINATION, egress: TEST_EGRESS, source: LOCAL_SOURCE, - sandbox: {} as any, + workspace: {} as any, }); expect(tools.agentDemo_demoTool).toBeDefined(); @@ -548,7 +548,7 @@ describe("agent plugin hooks", () => { destination: LOCAL_DESTINATION, egress: TEST_EGRESS, source: LOCAL_SOURCE, - sandbox: {} as any, + workspace: {} as any, }), ).toThrow("must be a camelCase identifier"); } finally { @@ -581,7 +581,7 @@ describe("agent plugin hooks", () => { destination: LOCAL_DESTINATION, egress: TEST_EGRESS, source: LOCAL_SOURCE, - sandbox: {} as any, + workspace: {} as any, }, ); expect(tools.loadSkill).toBeDefined(); @@ -1219,7 +1219,7 @@ describe("getPluginTools channel resolution", () => { destination: LOCAL_DESTINATION, egress: TEST_EGRESS, source: LOCAL_SOURCE, - sandbox: {} as any, + workspace: {} as any, }, ) { let captured: ToolRegistrationHookContext | undefined; @@ -1256,7 +1256,7 @@ describe("getPluginTools channel resolution", () => { channelId: "COUT", }, egress: TEST_EGRESS, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.source).toEqual(source); expect(ctx.destination).toEqual({ @@ -1276,7 +1276,7 @@ describe("getPluginTools channel resolution", () => { channelId: "COUT", }, egress: TEST_EGRESS, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.slack?.channelCapabilities.canCreateCanvas).toBe(true); expect(ctx.slack?.channelCapabilities.canAddReactions).toBe(true); @@ -1293,7 +1293,7 @@ describe("getPluginTools channel resolution", () => { }, egress: TEST_EGRESS, actor: TEST_ACTOR, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.slack?.credentialSubject).toMatchObject({ @@ -1313,7 +1313,7 @@ describe("getPluginTools channel resolution", () => { }, egress: TEST_EGRESS, actor: TEST_ACTOR, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.slack?.credentialSubject).toBeUndefined(); @@ -1325,7 +1325,7 @@ describe("getPluginTools channel resolution", () => { destination: SLACK_DESTINATION, egress: TEST_EGRESS, source: SLACK_SOURCE, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.conversationId).toBe("slack:DDM:1780479160.406339"); diff --git a/packages/junior/tests/unit/slack/tool-registration.test.ts b/packages/junior/tests/unit/slack/tool-registration.test.ts index 03f92386b..e1596e4bc 100644 --- a/packages/junior/tests/unit/slack/tool-registration.test.ts +++ b/packages/junior/tests/unit/slack/tool-registration.test.ts @@ -38,7 +38,7 @@ function ctx( }, egress: noopEgress, source: createLocalSource("local:test:tool-registration"), - sandbox: noopSandbox, + workspace: noopSandbox, }; } @@ -56,7 +56,7 @@ function ctx( type: sourceType ?? (channelId.startsWith("C") ? "pub" : "priv"), }), egress: noopEgress, - sandbox: noopSandbox, + workspace: noopSandbox, }; } @@ -228,7 +228,7 @@ describe("Slack tool registration", () => { }, egress: noopEgress, source: createLocalSource("local:test:run-test"), - sandbox: noopSandbox, + workspace: noopSandbox, }, ); From 77acc2a9682d80880335cab2324186caedcc2c6d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 23 Jul 2026 16:03:42 -0700 Subject: [PATCH 10/10] fix(sandbox): Close recovery edge cases Retry durable sandbox reference reporting after transient persistence failures, propagate unavailable keepalive failures through the expected tool-error boundary, and keep generated-artifact helpers in the sandbox tool layer. --- packages/junior/src/chat/agent/sandbox.ts | 2 +- packages/junior/src/chat/sandbox/sandbox.ts | 12 +- packages/junior/src/chat/sandbox/session.ts | 16 ++- .../sandbox}/generated-artifacts.ts | 0 .../tests/component/agent/sandbox.test.ts | 2 +- .../component/misc/sandbox-executor.test.ts | 122 +++++++++--------- .../sandbox}/generated-artifacts.test.ts | 2 +- 7 files changed, 79 insertions(+), 77 deletions(-) rename packages/junior/src/chat/{runtime => tools/sandbox}/generated-artifacts.ts (100%) rename packages/junior/tests/component/{runtime => tools/sandbox}/generated-artifacts.test.ts (96%) diff --git a/packages/junior/src/chat/agent/sandbox.ts b/packages/junior/src/chat/agent/sandbox.ts index 46e0f2f37..111180cf4 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -12,13 +12,13 @@ import type { ChannelConfigurationService } from "@/chat/configuration/types"; import type { CredentialContext } from "@/chat/credentials/context"; import { listReferenceFiles } from "@/chat/discovery"; import type { LogContext } from "@/chat/logging"; -import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts"; import { formatSandboxCommandResult } from "@/chat/sandbox/command-result"; import type { SandboxEgressTracePropagationConfig } from "@/chat/sandbox/egress/tracing"; import type { SandboxRef } from "@/chat/sandbox/ref"; import { createSandbox, type SandboxTools } from "@/chat/sandbox/sandbox"; import type { SandboxWorkspace } from "@/chat/sandbox/workspace"; import type { Skill, SkillMetadata } from "@/chat/skills"; +import { writeSandboxGeneratedArtifacts } from "@/chat/tools/sandbox/generated-artifacts"; import type { GeneratedArtifactFileRef } from "@/chat/tools/sandbox/file-uploads"; export interface AgentSandboxOptions { diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index a917107ba..7743c4fa1 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -157,9 +157,7 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { referenceFiles: options.referenceFiles, timeoutMs: options.timeoutMs, traceContext, - commandEnv: credentialEgress - ? async () => await resolveSandboxCommandEnvironment() - : undefined, + commandEnv: credentialEgress ? resolveSandboxCommandEnvironment : undefined, createNetworkPolicy: credentialEgress || hasTracePropagationDomains ? (sessionId, traceHeaders) => @@ -173,12 +171,8 @@ export function createSandbox(options: SandboxOptions): SandboxAccess { traceHeaders, }) : undefined, - onSandboxPrepare: async (sandbox) => { - await options.prepare?.(sandbox); - }, - onSandboxRefChanged: async (sandboxRef) => { - await options.onSandboxRefChanged?.(sandboxRef); - }, + onSandboxPrepare: options.prepare, + onSandboxRefChanged: options.onSandboxRefChanged, }); const withSandboxSpan = ( diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index f3016170c..504d8dc92 100644 --- a/packages/junior/src/chat/sandbox/session.ts +++ b/packages/junior/src/chat/sandbox/session.ts @@ -180,6 +180,7 @@ export function createSandboxRuntime( ): SandboxRuntime { let activeSandbox: ActiveSandbox | null = null; let sandboxRef = options.sandboxRef; + let reportedSandboxRef = options.sandboxRef; const availableSkills = [...options.skills]; const availableReferenceFiles = [...options.referenceFiles]; let acquiringSandbox: Promise | undefined; @@ -235,14 +236,15 @@ export function createSandboxRuntime( id: nextSandbox.sandboxId, ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), }; + sandboxRef = nextRef; if ( - sandboxRef?.id === nextRef.id && - sandboxRef.profileHash === nextRef.profileHash + reportedSandboxRef?.id === nextRef.id && + reportedSandboxRef.profileHash === nextRef.profileHash ) { return; } - sandboxRef = nextRef; await options.onSandboxRefChanged?.(nextRef); + reportedSandboxRef = nextRef; }; const rememberSandbox = ( @@ -570,6 +572,7 @@ export function createSandboxRuntime( let networkPolicyKey: string | undefined; try { + await reportSandboxRef(hintedSandbox); networkPolicyKey = await applyNetworkPolicy(hintedSandbox); await prepareSandbox(hintedSandbox); return rememberSandbox(hintedSandbox, networkPolicyKey); @@ -677,8 +680,11 @@ export function createSandboxRuntime( await activeSandbox.extendTimeout(keepAliveMs); }, ); - } catch { - // Best effort keepalive. + } catch (error) { + if (isSandboxUnavailableError(error)) { + throw error; + } + // Non-lifecycle keepalive failures are best effort. } }; diff --git a/packages/junior/src/chat/runtime/generated-artifacts.ts b/packages/junior/src/chat/tools/sandbox/generated-artifacts.ts similarity index 100% rename from packages/junior/src/chat/runtime/generated-artifacts.ts rename to packages/junior/src/chat/tools/sandbox/generated-artifacts.ts diff --git a/packages/junior/tests/component/agent/sandbox.test.ts b/packages/junior/tests/component/agent/sandbox.test.ts index 448df1d68..0b854d760 100644 --- a/packages/junior/tests/component/agent/sandbox.test.ts +++ b/packages/junior/tests/component/agent/sandbox.test.ts @@ -25,7 +25,7 @@ vi.mock("@/chat/discovery", () => ({ listReferenceFiles: () => ["/tmp/reference.md"], })); -vi.mock("@/chat/runtime/generated-artifacts", () => ({ +vi.mock("@/chat/tools/sandbox/generated-artifacts", () => ({ writeSandboxGeneratedArtifacts: writeGeneratedArtifactsMock, })); diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 8172a49fb..8e25c1883 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -588,25 +588,6 @@ describe("createTestSandbox", () => { }); }); - it("reports acquired sandbox metadata immediately after fresh sandbox boot", async () => { - const freshSandbox = makeSandbox("sbx_fresh"); - const onSandboxAcquired = vi.fn(); - sandboxCreateMock.mockResolvedValue(freshSandbox); - - const executor = createTestSandbox({ - onSandboxAcquired, - }); - executor.configureSkills([]); - - await executor.createSandbox(); - await executor.createSandbox(); - - expect(onSandboxAcquired).toHaveBeenCalledTimes(1); - expect(onSandboxAcquired).toHaveBeenCalledWith({ - sandboxId: "sbx_fresh", - }); - }); - it("reports a fresh sandbox reference before session preparation can fail", async () => { const unavailable = createClosedStreamError(); const freshSandbox = makeSandbox("sbx_prepare_failure"); @@ -635,25 +616,30 @@ describe("createTestSandbox", () => { expect(executor.getSandboxId()).toBe("sbx_prepare_failure"); }); - it("prepares a cached sandbox only once", async () => { - const freshSandbox = makeSandbox("sbx_fresh"); - const onSandboxPrepare = vi.fn(); - sandboxCreateMock.mockResolvedValue(freshSandbox); + it("retries durable reference reporting after persistence fails", async () => { + const freshSandbox = makeSandbox("sbx_ref_retry"); + const restoredSandbox = makeSandbox("sbx_ref_retry"); + restoredSandbox.session.sessionId = "sbx_ref_retry_restored"; + const persistenceError = new Error("state unavailable"); + const onSandboxAcquired = vi + .fn() + .mockRejectedValueOnce(persistenceError) + .mockResolvedValueOnce(undefined); + sandboxCreateMock.mockResolvedValueOnce(freshSandbox); + sandboxGetMock.mockResolvedValueOnce(restoredSandbox); - const manager = createTestSandboxRuntime({ - onSandboxPrepare, - }); - manager.configureSkills([]); + const executor = createTestSandbox({ onSandboxAcquired }); + executor.configureSkills([]); - await manager.createSandbox(); - await manager.createSandbox(); + await expect(executor.createSandbox()).rejects.toBe(persistenceError); + await expect(executor.createSandbox()).resolves.toBeDefined(); - expect(onSandboxPrepare).toHaveBeenCalledTimes(1); - expect(onSandboxPrepare).toHaveBeenCalledWith( - expect.objectContaining({ - sandboxId: "sbx_fresh", - }), - ); + expect(onSandboxAcquired).toHaveBeenCalledTimes(2); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledWith({ + name: "sbx_ref_retry", + resume: true, + }); }); it("shares in-flight sandbox setup across parallel executor initialization", async () => { @@ -774,31 +760,6 @@ describe("createTestSandbox", () => { expect(onSandboxAcquired).not.toHaveBeenCalled(); }); - it("refreshes network policy when restoring from a sandbox id hint", async () => { - const restoredSandbox = makeSandbox("sbx_restored"); - const networkPolicy = { - allow: { - "*": [], - "api.example.com": [ - { - forwardURL: "https://junior.example.com/api/internal/proxy", - }, - ], - }, - }; - sandboxGetMock.mockResolvedValue(restoredSandbox); - - const manager = createTestSandboxRuntime({ - sandboxId: "sbx_restored", - createNetworkPolicy: vi.fn(() => networkPolicy), - }); - manager.configureSkills([]); - - await manager.createSandbox(); - - expect(restoredSandbox.update).toHaveBeenCalledWith({ networkPolicy }); - }); - it("keeps restored sandbox policy tracking tied to the applied policy", async () => { const restoredSandbox = makeSandbox("sbx_restored_policy"); const firstPolicy = { @@ -2075,6 +2036,47 @@ describe("createTestSandbox", () => { expect(sandbox.extendTimeout).toHaveBeenNthCalledWith(2, 5000); }); + it("fails the current tool when keepalive finds an unavailable session", async () => { + process.env.VERCEL_SANDBOX_KEEPALIVE_MS = "5000"; + const unavailable = createClosedStreamError(); + const firstSandbox = makeSandbox("sbx_keepalive_recovery"); + firstSandbox.extendTimeout.mockRejectedValueOnce(unavailable); + const recoveredSandbox = makeSandbox("sbx_keepalive_recovery"); + recoveredSandbox.session.sessionId = "sbx_keepalive_recovered_session"; + sandboxCreateMock.mockResolvedValueOnce(firstSandbox); + sandboxGetMock.mockResolvedValueOnce(recoveredSandbox); + vi.mocked(createBashTool).mockResolvedValue({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, + }, + } as never); + + const executor = createTestSandbox(); + executor.configureSkills([]); + + await expect( + executor.execute({ + toolName: "bash", + input: { command: "echo first" }, + }), + ).rejects.toMatchObject({ + message: expect.stringContaining( + "The temporary sandbox became unavailable during bash", + ), + cause: unavailable, + }); + await expect( + executor.execute({ + toolName: "bash", + input: { command: "echo second" }, + }), + ).resolves.toBeDefined(); + + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledTimes(1); + }); + it("does not re-sync skills when reusing a cached sandbox", async () => { const sandbox = makeSandbox("sbx_cached_once"); sandboxCreateMock.mockResolvedValue(sandbox); diff --git a/packages/junior/tests/component/runtime/generated-artifacts.test.ts b/packages/junior/tests/component/tools/sandbox/generated-artifacts.test.ts similarity index 96% rename from packages/junior/tests/component/runtime/generated-artifacts.test.ts rename to packages/junior/tests/component/tools/sandbox/generated-artifacts.test.ts index 864e9eca2..2edf4526f 100644 --- a/packages/junior/tests/component/runtime/generated-artifacts.test.ts +++ b/packages/junior/tests/component/tools/sandbox/generated-artifacts.test.ts @@ -1,6 +1,6 @@ import type { FileUpload } from "chat"; import { describe, expect, it } from "vitest"; -import { writeSandboxGeneratedArtifacts } from "@/chat/runtime/generated-artifacts"; +import { writeSandboxGeneratedArtifacts } from "@/chat/tools/sandbox/generated-artifacts"; import { SANDBOX_ARTIFACTS_DIR } from "@/chat/tools/sandbox/file-uploads"; import type { SandboxCommandResult } from "@/chat/sandbox/workspace";