diff --git a/packages/junior/src/chat/agent-dispatch/runner.ts b/packages/junior/src/chat/agent-dispatch/runner.ts index 767eeb640..cf8068cd5 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; + sandboxRef?: SandboxRef; threadId: string; }): Promise { await persistThreadStateById(args.threadId, { artifacts: args.artifacts, conversation: args.conversation, - sandboxId: args.sandboxId, - sandboxDependencyProfileHash: args.sandboxDependencyProfileHash, + sandboxRef: args.sandboxRef, }); } @@ -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 sandboxRef = getPersistedSandboxState(persisted); const channelConfiguration = getChannelConfigurationServiceById( dispatch.destination.channelId, ); @@ -401,24 +394,19 @@ export async function runAgentDispatchSlice( }, state: { artifactState: artifacts, - sandbox: { - sandboxId, - sandboxDependencyProfileHash, - }, + sandboxRef, }, delivery: { onAssistantMessage: deliverAssistantMessage, }, durability: { - onSandboxAcquired: async (sandbox) => { - sandboxId = sandbox.sandboxId; - sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash; + onSandboxRefChanged: async (nextSandboxRef) => { + sandboxRef = nextSandboxRef; await persistRuntimePatch({ threadId: conversationId, conversation, artifacts, - sandboxId, - sandboxDependencyProfileHash, + sandboxRef, }); }, onArtifactStateUpdated: async (nextArtifacts) => { @@ -427,8 +415,7 @@ export async function runAgentDispatchSlice( threadId: conversationId, conversation, artifacts, - sandboxId, - sandboxDependencyProfileHash, + sandboxRef, }); }, }, @@ -503,9 +490,7 @@ export async function runAgentDispatchSlice( threadId: conversationId, conversation, artifacts: nextArtifacts, - sandboxId: reply.sandboxId ?? sandboxId, - sandboxDependencyProfileHash: - reply.sandboxDependencyProfileHash ?? sandboxDependencyProfileHash, + 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 89e51b3ab..3b6fabd2f 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.sandboxRef; let mcpToolManager: McpToolManager | undefined; let connectedMcpProviders = new Set(); let turnUsage: AgentTurnUsage | undefined; @@ -327,11 +325,6 @@ async function executeAgentRunInPrivacyContext( await recordConnectedMcpProvider(provider); } }; - const getSandboxMetadata = () => ({ - sandboxId: lastKnownSandboxId, - sandboxDependencyProfileHash: lastKnownSandboxDependencyProfileHash, - }); - try { const projection = await openConversationProjection({ conversationId }); activeModelProfile = projection.modelProfile; @@ -648,10 +641,8 @@ async function executeAgentRunInPrivacyContext( generatedFiles, invokedSkill, observers, - onSandboxMetadataChanged: (sandbox) => { - lastKnownSandboxId = sandbox.sandboxId; - lastKnownSandboxDependencyProfileHash = - sandbox.sandboxDependencyProfileHash; + onSandboxRefChanged: (sandboxRef) => { + lastKnownSandboxRef = sandboxRef; }, policy, preAgentPromptMessages, @@ -671,7 +662,6 @@ async function executeAgentRunInPrivacyContext( userInput, }); mcpToolManager = wiring.mcpToolManager; - const sandboxExecutor = wiring.sandboxExecutor; const getPendingAuthPause = wiring.getPendingAuthPause; const toolsAfterHandoff = wiring.agentTools; @@ -1195,8 +1185,7 @@ async function executeAgentRunInPrivacyContext( userInput, artifactStatePatch, toolCalls, - sandboxId: sandboxExecutor.getSandboxId(), - sandboxDependencyProfileHash: sandboxExecutor.getDependencyProfileHash(), + sandboxRef: wiring.getSandboxRef(), piMessages: [...agent.state.messages], durationMs: Date.now() - replyStartedAtMs, generatedFileCount: generatedFiles.length, @@ -1275,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 737bffee9..546a03934 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; - }; + sandboxRef?: SandboxRef; } /** @@ -169,7 +166,7 @@ export interface AgentRunDurability { recordPendingAuth?: ( pendingAuth: ConversationPendingAuthState | undefined, ) => void | Promise; - onSandboxAcquired?: (sandbox: SandboxAcquiredState) => 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 566275800..111180cf4 100644 --- a/packages/junior/src/chat/agent/sandbox.ts +++ b/packages/junior/src/chat/agent/sandbox.ts @@ -1,81 +1,108 @@ /** - * Run-scoped sandbox workspace. + * Agent-facing sandbox adaptation. * - * 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. + * 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 { logInfo, type LogContext } from "@/chat/logging"; -import type { SandboxExecutor } from "@/chat/sandbox/sandbox"; +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 { 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"; -/** Create a lazy-boot workspace port bound to the run's sandbox executor. */ -export function createLazySandboxWorkspace(args: { - executor: SandboxExecutor; - spanContext: LogContext; -}): SandboxWorkspace { - let sandboxPromise: Promise | undefined; - let sandboxPromiseId: string | undefined; - const clearSandboxPromise = (): void => { - sandboxPromise = undefined; - sandboxPromiseId = undefined; - }; - const getSandbox = (reason: { - trigger: string; - path?: string; - cmd?: string; - cwd?: string; - }): Promise => { - const currentSandboxId = args.executor.getSandboxId(); - if ( - sandboxPromise && - sandboxPromiseId && - currentSandboxId !== sandboxPromiseId - ) { - clearSandboxPromise(); - } +export interface AgentSandboxOptions { + sandboxRef?: SandboxRef; + skills: SkillMetadata[]; + traceContext: LogContext; + tracePropagation?: SandboxEgressTracePropagationConfig; + credentialEgress?: CredentialContext; + actor?: Actor; + channelConfiguration?: ChannelConfigurationService; + configurationValues: Record; + getActiveSkill(): Skill | null; + prepareSandbox(workspace: SandboxWorkspace): void | Promise; + onSandboxRefChanged(sandboxRef: SandboxRef): void; + persistSandboxRef?(sandboxRef: SandboxRef): void | Promise; +} - 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", - ); - sandboxPromise = args.executor - .createSandbox() - .then((sandbox) => { - sandboxPromiseId = sandbox.sandboxId; - return sandbox; - }) - .catch((error) => { - clearSandboxPromise(); - throw error; - }); - } - return sandboxPromise; - }; +export interface AgentSandbox { + readonly tools: SandboxTools; + readonly workspace: SandboxWorkspace; + sandboxRef(): 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({ + sandboxRef: options.sandboxRef, + skills: options.skills, + referenceFiles: listReferenceFiles(), + traceContext: options.traceContext, + tracePropagation: options.tracePropagation, + credentialEgress: options.credentialEgress, + prepare: options.prepareSandbox, + onSandboxRefChanged: async (sandboxRef) => { + options.onSandboxRefChanged(sandboxRef); + await options.persistSandboxRef?.(sandboxRef); + }, + }); return { - readFileToBuffer: async (input) => - ( - await getSandbox({ - trigger: "workspace.readFileToBuffer", - path: input.path, - }) - ).readFileToBuffer(input), - runCommand: async (input) => - ( - await getSandbox({ - trigger: "workspace.runCommand", - cmd: input.cmd, - cwd: input.cwd, - }) - ).runCommand(input), + workspace: sandbox.workspace, + sandboxRef: sandbox.sandboxRef, + 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 5443f5848..f8c3f0c3a 100644 --- a/packages/junior/src/chat/agent/tools.ts +++ b/packages/junior/src/chat/agent/tools.ts @@ -2,16 +2,15 @@ * 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. */ 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,10 +29,7 @@ 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 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"; @@ -42,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"; @@ -54,10 +50,8 @@ 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"; import { credentialUserSubjectId } from "@/chat/credentials/context"; interface ToolWiringArgs { @@ -75,10 +69,7 @@ interface ToolWiringArgs { generatedFiles: FileUpload[]; invokedSkill: SkillMetadata | null; observers: AgentRunObservers; - onSandboxMetadataChanged: (sandbox: { - sandboxId?: string; - sandboxDependencyProfileHash?: string; - }) => void; + onSandboxRefChanged: (sandboxRef: SandboxRef) => void; policy: AgentRunPolicy; preAgentPromptMessages: () => PiMessage[]; priorPiMessages: PiMessage[] | undefined; @@ -103,7 +94,7 @@ export interface ToolWiring { getPendingAuthPause: () => AuthorizationPauseError | undefined; mcpToolManager: McpToolManager; pluginHooks: PluginHookRunner; - sandboxExecutor: SandboxExecutor; + getSandboxRef: () => SandboxRef | undefined; toolGuidance: Array<{ name: string; promptGuidelines: AnyToolDefinition["promptGuidelines"]; @@ -125,46 +116,19 @@ export async function wireAgentTools( actor: args.currentActor, actors: args.currentActors, }); - const sandboxExecutor = createSandboxExecutor({ - sandboxId: args.state.sandbox?.sandboxId, - sandboxDependencyProfileHash: - args.state.sandbox?.sandboxDependencyProfileHash, + const agentSandbox = createAgentSandbox({ + sandboxRef: args.state.sandboxRef, + skills: args.availableSkills, 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); - }, - 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 }; - }, - }); - sandboxExecutor.configureSkills(args.availableSkills); - sandboxExecutor.configureReferenceFiles(listReferenceFiles()); - const sandbox = createLazySandboxWorkspace({ - executor: sandboxExecutor, - spanContext: args.spanContext, + actor: args.currentActor, + channelConfiguration: args.policy.channelConfiguration, + configurationValues: args.configurationValues, + getActiveSkill: () => args.skillSandbox.getActiveSkill(), + prepareSandbox: pluginHooks.prepareSandbox, + onSandboxRefChanged: args.onSandboxRefChanged, + persistSandboxRef: args.durability.onSandboxRefChanged, }); const slackDestination = @@ -256,7 +220,7 @@ export async function wireAgentTools( }, }), mcpToolManager, - sandbox, + workspace: agentSandbox.workspace, surface: args.surface, ...(args.requestHandoff ? { handoff: args.requestHandoff } : {}), }; @@ -290,10 +254,7 @@ export async function wireAgentTools( loadableSkills, { writeGeneratedArtifacts: async (files) => { - const refs = await writeSandboxGeneratedArtifacts( - await sandboxExecutor.createSandbox(), - files, - ); + const refs = await agentSandbox.writeGeneratedArtifacts(files); args.generatedFiles.push(...files); return refs; }, @@ -430,7 +391,7 @@ export async function wireAgentTools( args.skillSandbox, args.spanContext, args.observers.onStatus, - sandboxExecutor, + agentSandbox.tools, pluginAuth, onToolCall, pluginHooks, @@ -450,7 +411,7 @@ export async function wireAgentTools( getPendingAuthPause, mcpToolManager, pluginHooks, - sandboxExecutor, + getSandboxRef: agentSandbox.sandboxRef, toolGuidance, toolRuntimeContext, }; diff --git a/packages/junior/src/chat/local/runner.ts b/packages/junior/src/chat/local/runner.ts index 8ed6a33d1..61836e369 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 sandboxRef = getPersistedSandboxState(persisted); const initialArtifacts = artifacts; - const initialSandboxId = sandboxId; - const initialSandboxDependencyProfileHash = sandboxDependencyProfileHash; + const initialSandboxRef = sandboxRef; const turnId = localTurnId(); const userMessageId = `${turnId}:user`; @@ -293,10 +291,7 @@ export async function runLocalAgentTurn( }, state: { artifactState: artifacts, - sandbox: { - sandboxId, - sandboxDependencyProfileHash, - }, + sandboxRef, }, observers: { onStatus: async (status) => { @@ -318,18 +313,15 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts, conversation, - sandboxId, - sandboxDependencyProfileHash, + sandboxRef, }); }, - onSandboxAcquired: async (sandbox) => { - sandboxId = sandbox.sandboxId; - sandboxDependencyProfileHash = sandbox.sandboxDependencyProfileHash; + onSandboxRefChanged: async (nextSandboxRef) => { + sandboxRef = nextSandboxRef; await persistThreadStateById(input.conversationId, { artifacts, conversation, - sandboxId, - sandboxDependencyProfileHash, + sandboxRef, }); }, }, @@ -384,8 +376,7 @@ export async function runLocalAgentTurn( await persistThreadStateById(input.conversationId, { artifacts: initialArtifacts, conversation, - sandboxId: initialSandboxId ?? "", - sandboxDependencyProfileHash: initialSandboxDependencyProfileHash ?? "", + sandboxRef: initialSandboxRef ?? 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, + 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 2d12ae26a..56beb1318 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, + 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: SandboxInstance): Promise; + prepareSandbox(workspace: SandboxWorkspace): Promise; } let registeredPlugins: PluginRegistration[] = []; @@ -1005,27 +1005,23 @@ function normalizeEnv(value: unknown): Record { return env; } -function createSandboxCapability(sandbox: SandboxInstance): 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 [stdout, stderr] = await Promise.all([ - result.stdout(), - result.stderr(), - ]); + const result = await workspace.runCommand(input); return { exitCode: result.exitCode, - stdout, - stderr, + stdout: result.stdout, + stderr: result.stderr, }; }, 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 763417423..b81cf1dc9 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, + sandboxRef: args.reply.sandboxRef, }; } diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 11680b085..3fcbcdd97 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, - }, + sandboxRef: preparedState.sandboxRef, }, 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 (sandboxRef) => { await persistThreadState(thread, { - sandboxId: sandbox.sandboxId, - sandboxDependencyProfileHash: - sandbox.sandboxDependencyProfileHash, + 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 2d88e0f77..4a5db4e17 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 (sandboxRef) => { await persistThreadStateById(threadId, { - sandboxId: sandbox.sandboxId, - sandboxDependencyProfileHash: sandbox.sandboxDependencyProfileHash, + sandboxRef, }); - await replyContext.durability?.onSandboxAcquired?.(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 841ee5267..3f60b0b0f 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; + sandboxRef?: SandboxRef | null; } function threadStateKey(threadId: string): string { @@ -42,12 +37,10 @@ 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) { + if (patch.sandboxRef !== undefined) { + payload.app_sandbox_id = patch.sandboxRef?.id ?? ""; payload.app_sandbox_dependency_profile_hash = - patch.sandboxDependencyProfileHash; + patch.sandboxRef?.profileHash ?? ""; } return payload; } @@ -73,12 +66,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..c431d7fbe 100644 --- a/packages/junior/src/chat/runtime/turn-preparation.ts +++ b/packages/junior/src/chat/runtime/turn-preparation.ts @@ -12,9 +12,9 @@ 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"; import { coerceThreadArtifactsState, type ThreadArtifactsState, @@ -30,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, @@ -52,8 +55,7 @@ export interface PreparedTurnState { channelConfiguration?: ChannelConfigurationService; conversation: ThreadConversationState; conversationContext?: string; - sandboxId?: string; - sandboxDependencyProfileHash?: string; + sandboxRef?: SandboxRef; userMessageAlreadyReplied?: boolean; userMessageId?: string; } @@ -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 sandboxRef = getPersistedSandboxState(existingState ?? {}); const artifacts = coerceThreadArtifactsState(existingState); const conversation = coerceThreadConversationState(existingState); const conversationId = args.context.threadId ?? args.context.runId; @@ -275,8 +267,7 @@ export function createPrepareTurnState(deps: PrepareTurnStateDeps) { configuration, channelConfiguration, conversation, - sandboxId: existingSandboxId, - sandboxDependencyProfileHash: existingSandboxDependencyProfileHash, + sandboxRef, conversationContext, userMessageAlreadyReplied, userMessageId, diff --git a/packages/junior/src/chat/sandbox/README.md b/packages/junior/src/chat/sandbox/README.md index 08d4fc120..7b1b509ae 100644 --- a/packages/junior/src/chat/sandbox/README.md +++ b/packages/junior/src/chat/sandbox/README.md @@ -8,8 +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 68ec7cff4..d889a166b 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)) { @@ -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 563934c90..16f1096eb 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[]; @@ -220,14 +220,14 @@ 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}`); } async function tryRun( - sandbox: SandboxInstance, + sandbox: SandboxSession, params: { cmd: string; args?: string[]; @@ -239,12 +239,12 @@ 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" }; } -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( @@ -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"}`, @@ -432,7 +432,7 @@ async function installRuntimeDependencies( } async function runRuntimePostinstall( - sandbox: SandboxInstance, + sandbox: SandboxSession, commands: PluginRuntimePostinstallCommand[], ): Promise { if (commands.length === 0) { @@ -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}`); } @@ -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 bbc7897fa..7743c4fa1 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -16,24 +16,27 @@ 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 { isSandboxCommandStreamInterruptedError, + isSandboxUnavailableError, 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 { SandboxInstance } 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"; @@ -51,48 +54,44 @@ 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 SandboxAccess { + readonly tools: SandboxTools; + readonly workspace: SandboxWorkspace; + sandboxRef(): SandboxRef | undefined; } -export interface SandboxAcquiredState { - sandboxId: string; - sandboxDependencyProfileHash?: string; +export interface SandboxOptions { + sandboxRef?: SandboxRef; + skills: SkillMetadata[]; + referenceFiles: string[]; + timeoutMs?: number; + traceContext?: LogContext; + tracePropagation?: SandboxEgressTracePropagationConfig; + credentialEgress?: CredentialContext; + prepare?: (workspace: SandboxWorkspace) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } -export interface SandboxExecutor { - configureSkills(skills: SkillMetadata[]): void; - configureReferenceFiles(files: string[]): void; - getSandboxId(): string | undefined; - getDependencyProfileHash(): string | undefined; - canExecute(toolName: string): boolean; - createSandbox(): Promise; - execute( - params: SandboxExecutionInput, - ): Promise>; - dispose(): Promise; +/** Create the safe expected tool error for an unavailable sandbox operation. */ +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([ @@ -117,95 +116,16 @@ 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, - 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): SandboxAccess { + 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, @@ -231,31 +151,28 @@ export function createSandboxExecutor(options?: { }); return token; }; - const sessionManager = createSandboxSessionManager({ - sandboxId: options?.sandboxId, - sandboxDependencyProfileHash: options?.sandboxDependencyProfileHash, - timeoutMs: options?.timeoutMs, + const runtime = createSandboxRuntime({ + sandboxRef: options.sandboxRef, + skills: options.skills, + referenceFiles: options.referenceFiles, + timeoutMs: options.timeoutMs, traceContext, - commandEnv: credentialEgress - ? async () => await resolveSandboxCommandEnvironment() - : undefined, + commandEnv: credentialEgress ? resolveSandboxCommandEnvironment : undefined, createNetworkPolicy: credentialEgress || hasTracePropagationDomains - ? (egressId, traceHeaders) => + ? (sessionId, traceHeaders) => buildSandboxEgressNetworkPolicy({ ...(credentialEgress - ? { credentialToken: sandboxEgressCredentialTokenFor(egressId) } + ? { + credentialToken: sandboxEgressCredentialTokenFor(sessionId), + } : {}), traceConfig: tracePropagation, traceHeaders, }) : undefined, - onSandboxPrepare: async (sandbox) => { - await options?.agentHooks?.prepareSandbox(sandbox); - }, - onSandboxAcquired: async (sandbox) => { - await options?.onSandboxAcquired?.(sandbox); - }, + onSandboxPrepare: options.prepare, + onSandboxRefChanged: options.onSandboxRefChanged, }); const withSandboxSpan = ( @@ -273,7 +190,7 @@ export function createSandboxExecutor(options?: { callback: () => Promise, ): Promise => withSandboxSpan(name, op, attributes, async () => { - await sessionManager.refreshNetworkPolicy(getTracePropagationHeaders()); + await runtime.refreshNetworkPolicy(getTracePropagationHeaders()); return await callback(); }); @@ -281,7 +198,7 @@ export function createSandboxExecutor(options?: { trigger: string, details: Record = {}, ): void => { - if (sessionManager.getSandboxId()) { + if (runtime.sandboxRef()) { return; } @@ -300,15 +217,15 @@ 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 activeEgressId = sessionManager.getSandboxEgressId(); - await clearSandboxEgressSignals(activeEgressId); + const { bash: executeBash, sessionId: activeSessionId } = + await runtime.tools(); + await clearSandboxEgressSignals(activeSessionId); const result = await withSandboxToolSpan( "bash", "process.exec", @@ -359,32 +276,30 @@ 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); - - 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, - }; + await consumeSandboxEgressPermissionDeniedSignal(activeSessionId); + + 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"); @@ -392,10 +307,10 @@ export function createSandboxExecutor(options?: { const offset = positiveInteger(rawInput.offset); const limit = positiveInteger(rawInput.limit); - if (!sessionManager.getSandboxId()) { + if (!runtime.sandboxRef()) { 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"); @@ -406,14 +321,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; @@ -425,8 +338,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", @@ -461,12 +373,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"); @@ -476,8 +388,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", @@ -495,24 +406,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"); @@ -524,7 +433,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", @@ -543,12 +452,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"); @@ -557,7 +466,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", @@ -584,12 +493,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"); @@ -597,7 +506,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", @@ -616,15 +525,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", @@ -640,12 +549,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" @@ -656,19 +563,13 @@ 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 }; - } - } - 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,39 +594,62 @@ export function createSandboxExecutor(options?: { return await executeWriteFileTool(rawInput); } } catch (error) { - if (!isSandboxCommandStreamInterruptedError(error)) { - throw error; + if (isSandboxUnavailableError(error)) { + // Do not replay an operation that may already have produced side effects. + throw createSandboxUnavailableToolError(params.toolName, error); + } + 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 }, + ); } - return { result: sandboxStreamInterruptedResult(params.toolName) as T }; + throw error; } 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(); - }, - 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(); + sandboxRef() { + return runtime.sandboxRef(); }, }; } diff --git a/packages/junior/src/chat/sandbox/session.ts b/packages/junior/src/chat/sandbox/session.ts index 825bf007c..504d8dc92 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,7 @@ import { import { getVercelSandboxCredentials } from "@/chat/sandbox/credentials"; import { isAlreadyExistsError, - isSandboxCommandStreamInterruptedError, + isSandboxMissingError, isSandboxUnavailableError, isSnapshottingError, wrapSandboxSetupError, @@ -28,13 +27,14 @@ 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"; +import type { SandboxRef } from "@/chat/sandbox/ref"; const DEFAULT_MAX_OUTPUT_LENGTH = 30_000; const DEFAULT_BASH_COMMAND_TIMEOUT_MS = 5 * 60 * 1000; @@ -51,6 +51,7 @@ interface SandboxCredentials { } interface SandboxToolExecutors { + sessionId: string; bash: (input: { command: string; env?: Record; @@ -73,20 +74,16 @@ interface SandboxToolExecutors { fs: SandboxFileSystem; } -function createBashToolSandboxAdapter(sandbox: SandboxInstance) { +function createBashToolSandboxAdapter(sandbox: SandboxSession) { return { async executeCommand(command: string) { const result = await sandbox.runCommand({ 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, }; }, @@ -108,16 +105,33 @@ function createBashToolSandboxAdapter(sandbox: SandboxInstance) { }; } -interface SandboxSessionManager { - configureSkills(skills: SkillMetadata[]): void; - configureReferenceFiles(files: string[]): void; - getSandboxId(): string | undefined; - getSandboxEgressId(): string | undefined; - getDependencyProfileHash(): string | undefined; - createSandbox(): Promise; - ensureToolExecutors(): Promise; +interface SandboxRuntime { + sandboxRef(): SandboxRef | undefined; + acquire(): Promise; + tools(): Promise; refreshNetworkPolicy(traceHeaders?: TracePropagationHeaders): Promise; - dispose(): Promise; +} + +interface ActiveSandbox { + session: SandboxSession; + toolExecutors?: SandboxToolExecutors; + loadingToolExecutors?: Promise; + networkPolicyKey?: string; +} + +interface SandboxRuntimeOptions { + sandboxRef?: SandboxRef; + skills: SkillMetadata[]; + referenceFiles: string[]; + timeoutMs?: number; + traceContext?: LogContext; + commandEnv?: () => Promise>; + createNetworkPolicy?: ( + egressId: string, + traceHeaders?: TracePropagationHeaders, + ) => NetworkPolicy | undefined; + onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; + onSandboxRefChanged?: (sandboxRef: SandboxRef) => void | Promise; } function truncateOutput( @@ -142,23 +156,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; @@ -177,39 +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: SandboxInstance) => void | Promise; - onSandboxAcquired?: (sandbox: { - sandboxId: string; - sandboxDependencyProfileHash?: string; - }) => void | Promise; -}): SandboxSessionManager { - let sandbox: SandboxInstance | null = null; - let sandboxIdHint = options?.sandboxId; - let availableSkills: SkillMetadata[] = []; - let availableReferenceFiles: string[] = []; - let toolExecutors: SandboxToolExecutors | undefined; - let loadingToolExecutors: Promise | undefined; - let appliedNetworkPolicyKey: string | undefined; - let preparedSandboxId: string | undefined; - let acquiringSandbox: Promise | undefined; - - const timeoutMs = options?.timeoutMs ?? 1000 * 60 * 30; - const traceContext = options?.traceContext ?? {}; +/** Own sandbox acquisition, preparation, and session-scoped tool caching. */ +export function createSandboxRuntime( + options: SandboxRuntimeOptions, +): 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; + + 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, @@ -218,15 +199,25 @@ export function createSandboxSessionManager(options?: { callback: () => Promise, ): Promise => withSpan(name, op, traceContext, callback, attributes); - const clearSession = (): void => { - sandbox = null; - sandboxIdHint = undefined; - toolExecutors = undefined; - loadingToolExecutors = undefined; - appliedNetworkPolicyKey = undefined; - preparedSandboxId = undefined; + /** Drop unavailable live state while retaining the persisted hint for lazy reacquisition. */ + const invalidateSession = (sessionId?: string): void => { + if ( + sessionId && + activeSandbox && + activeSandbox.session.sessionId !== sessionId + ) { + return; + } + activeSandbox = null; }; + const adaptSandbox = ( + vercelSandbox: Parameters[0], + ): SandboxSession => + createSandboxSession(vercelSandbox, { + onUnavailable: invalidateSession, + }); + const createSandboxName = (): string => `${SANDBOX_NAME_PREFIX}${randomUUID()}`; @@ -235,23 +226,32 @@ 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 rememberSandbox = async ( - nextSandbox: SandboxInstance, - ): Promise => { - sandbox = nextSandbox; - sandboxIdHint = nextSandbox.sandboxId; - toolExecutors = undefined; - loadingToolExecutors = undefined; - const acquired = { - sandboxId: sandboxIdHint, - ...(dependencyProfileHash - ? { sandboxDependencyProfileHash: dependencyProfileHash } - : {}), + const reportSandboxRef = async ( + nextSandbox: SandboxSession, + ): Promise => { + const nextRef: SandboxRef = { + id: nextSandbox.sandboxId, + ...(dependencyProfileHash ? { profileHash: dependencyProfileHash } : {}), }; - await options?.onSandboxAcquired?.(acquired); + sandboxRef = nextRef; + if ( + reportedSandboxRef?.id === nextRef.id && + reportedSandboxRef.profileHash === nextRef.profileHash + ) { + return; + } + await options.onSandboxRefChanged?.(nextRef); + reportedSandboxRef = nextRef; + }; + + const rememberSandbox = ( + nextSandbox: SandboxSession, + networkPolicyKey?: string, + ): SandboxSession => { + activeSandbox = { session: nextSandbox, networkPolicyKey }; return nextSandbox; }; @@ -259,7 +259,7 @@ export function createSandboxSessionManager(options?: { throw wrapSandboxSetupError(error); }; - const syncSkills = async (targetSandbox: SandboxInstance): Promise => { + const syncSkills = async (targetSandbox: SandboxSession): Promise => { await syncSkillsToSandbox({ sandbox: targetSandbox, skills: availableSkills, @@ -269,30 +269,28 @@ export function createSandboxSessionManager(options?: { }; const prepareSandbox = async ( - targetSandbox: SandboxInstance, + 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: SandboxInstance, + targetSandbox: SandboxSession, traceHeaders?: TracePropagationHeaders, - ): Promise => { - const networkPolicy = options?.createNetworkPolicy?.( - targetSandbox.sandboxEgressId, + ): 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( @@ -306,19 +304,19 @@ export function createSandboxSessionManager(options?: { await targetSandbox.update({ networkPolicy }); }, ); - appliedNetworkPolicyKey = networkPolicyKey; + if (active) { + active.networkPolicyKey = networkPolicyKey; + } + return networkPolicyKey; }; - const ensureSandboxReachable = async ( - targetSandbox: SandboxInstance, - 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 { @@ -332,39 +330,18 @@ 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, 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 @@ -416,13 +393,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, @@ -467,12 +444,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", @@ -500,21 +477,24 @@ export function createSandboxSessionManager(options?: { return failSetup(error); } + await reportSandboxRef(createdSandbox); + + let networkPolicyKey: string | undefined; try { - await applyNetworkPolicy(createdSandbox); + networkPolicyKey = await applyNetworkPolicy(createdSandbox); await prepareSandbox(createdSandbox); } catch (error) { return failSetup(error); } - return await rememberSandbox(createdSandbox); + return rememberSandbox(createdSandbox, networkPolicyKey); }; const discardHintIfProfileChanged = (): void => { if ( - sandbox || - !sandboxIdHint || - dependencyProfileHash === options?.sandboxDependencyProfileHash + activeSandbox || + !sandboxRef || + dependencyProfileHash === sandboxRef.profileHash ) { return; } @@ -522,10 +502,9 @@ export function createSandboxSessionManager(options?: { setSpanAttributes({ "app.sandbox.reused": false, "app.sandbox.recreate.reason": "dependency_profile_mismatch", - ...(options?.sandboxDependencyProfileHash + ...(sandboxRef.profileHash ? { - "app.sandbox.previous_profile_hash": - options.sandboxDependencyProfileHash, + "app.sandbox.previous_profile_hash": sandboxRef.profileHash, } : {}), ...(dependencyProfileHash @@ -536,10 +515,9 @@ export function createSandboxSessionManager(options?: { "sandbox_hint_discarded_profile_mismatch", traceContext, { - ...(options?.sandboxDependencyProfileHash + ...(sandboxRef.profileHash ? { - "app.sandbox.previous_profile_hash": - options.sandboxDependencyProfileHash, + "app.sandbox.previous_profile_hash": sandboxRef.profileHash, } : {}), ...(dependencyProfileHash @@ -548,34 +526,20 @@ export function createSandboxSessionManager(options?: { }, "Dependency profile changed; discarding sandbox hint and creating fresh session", ); - sandboxIdHint = undefined; + sandboxRef = undefined; }; - 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)) { - return await recreateUnavailableSandbox("memory"); - } - return failSetup(error); - } + const tryReuseCachedSandbox = async (): Promise => { + return activeSandbox?.session ?? null; }; - const tryRestoreHintedSandbox = async (): Promise => { - if (!sandboxIdHint) { + const tryRestoreHintedSandbox = async (): Promise => { + const ref = sandboxRef; + if (!ref) { return null; } - let hintedSandbox: SandboxInstance | null = null; + let hintedSandbox: SandboxSession | null = null; try { const sandboxCredentials = getVercelSandboxCredentials(); hintedSandbox = await withSandboxSpan( @@ -586,46 +550,46 @@ export function createSandboxSessionManager(options?: { "app.sandbox.source": "id_hint", }, async () => - createSandboxInstance( + adaptSandbox( await Sandbox.get({ - name: sandboxIdHint as string, + name: ref.id, resume: true, ...(sandboxCredentials ?? {}), } as Parameters[0]), ), ); } 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 (isSandboxMissingError(error)) { + sandboxRef = undefined; + return null; + } + if (isSandboxUnavailableError(error)) { + invalidateSession(); + throw error; + } + throw new Error("sandbox restore failed", { cause: error }); } + let networkPolicyKey: string | undefined; try { - await applyNetworkPolicy(hintedSandbox); + await reportSandboxRef(hintedSandbox); + networkPolicyKey = await applyNetworkPolicy(hintedSandbox); await prepareSandbox(hintedSandbox); - return await rememberSandbox(hintedSandbox); + return rememberSandbox(hintedSandbox, networkPolicyKey); } catch (error) { if (isSandboxUnavailableError(error)) { - return await recreateUnavailableSandbox("id_hint"); + throw error; } return failSetup(error); } }; - const acquireSandbox = async (): Promise => { + const acquireSandbox = async (): Promise => { return await withSandboxSpan( "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, @@ -648,7 +612,7 @@ export function createSandboxSessionManager(options?: { ); }; - const getOrAcquireSandbox = async (): Promise => { + const getOrAcquireSandbox = async (): Promise => { if (acquiringSandbox) { return await acquiringSandbox; } @@ -684,8 +648,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 { @@ -698,7 +662,7 @@ export function createSandboxSessionManager(options?: { }; const extendKeepAlive = async ( - activeSandbox: SandboxInstance, + activeSandbox: SandboxSession, ): Promise => { const keepAliveMs = parseKeepAliveMs(); if (keepAliveMs === 0) { @@ -716,13 +680,16 @@ export function createSandboxSessionManager(options?: { await activeSandbox.extendTimeout(keepAliveMs); }, ); - } catch { - // Best effort keepalive. + } catch (error) { + if (isSandboxUnavailableError(error)) { + throw error; + } + // Non-lifecycle keepalive failures are best effort. } }; const buildToolExecutors = async ( - sandboxInstance: SandboxInstance, + sandboxInstance: SandboxSession, ): Promise => { const toolkit = await withSandboxSpan( "sandbox.bash_tool.init", @@ -745,6 +712,7 @@ export function createSandboxSessionManager(options?: { } return { + sessionId: sandboxInstance.sessionId, bash: async (input) => { let timedOut = false; let aborted = false; @@ -807,9 +775,6 @@ export function createSandboxSessionManager(options?: { if (aborted || input.signal?.aborted) { return getCommandAbortedResult(); } - if (isSandboxCommandStreamInterruptedError(error)) { - return getCommandStreamInterruptedResult(); - } throw error; } finally { if (timeoutId) { @@ -834,87 +799,63 @@ export function createSandboxSessionManager(options?: { }; }; - const ensureReadySandbox = async (): Promise => { + const ensureReadySandbox = async (): Promise => { const activeSandbox = await getOrAcquireSandbox(); + await probeSession(activeSandbox); await extendKeepAlive(activeSandbox); return activeSandbox; }; const loadToolExecutors = async ( - activeSandbox: SandboxInstance, + 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; + sandboxRef() { + return sandboxRef ? { ...sandboxRef } : undefined; }, - getSandboxEgressId() { - return sandbox?.sandboxEgressId; - }, - getDependencyProfileHash() { - return dependencyProfileHash; - }, - 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/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 d262933ef..1405c6003 100644 --- a/packages/junior/src/chat/sandbox/workspace.ts +++ b/packages/junior/src/chat/sandbox/workspace.ts @@ -1,9 +1,14 @@ -import type { NetworkPolicy, Sandbox as VercelSandbox } from "@vercel/sandbox"; +import { + FileSystem, + type NetworkPolicy, + type Sandbox as VercelSandbox, +} from "@vercel/sandbox"; +import { isSandboxUnavailableError } from "@/chat/sandbox/errors"; export interface SandboxCommandResult { exitCode: number; - stderr(): Promise; - stdout(): Promise; + stderr: string; + stdout: string; } export interface SandboxCommandInput { @@ -39,60 +44,99 @@ export interface SandboxWorkspace { path: string; }): Promise; runCommand(input: SandboxCommandInput): Promise; + writeFiles( + files: Array<{ + content: string | Uint8Array; + mode?: number; + path: string; + }>, + ): 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; snapshot(): Promise<{ snapshotId: string }>; stop(): Promise; update(params: { networkPolicy?: NetworkPolicy }): Promise; - writeFiles( - files: Array<{ - content: string | Uint8Array; - mode?: number; - path: string; - }>, - ): 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, - 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, + sessionId: session.sessionId, + fs, extendTimeout(duration) { - return sandbox.extendTimeout(duration); + return run(async () => await session.extendTimeout(duration)); }, mkDir(path) { - return sandbox.mkDir(path); + return run(async () => await session.mkDir(path)); }, readFileToBuffer(input) { - return sandbox.readFileToBuffer(input); + return run(async () => await session.readFileToBuffer(input)); }, - runCommand(input) { - return sandbox.runCommand(input); + 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, + stderr, + }; }, - async snapshot() { - const snapshot = await sandbox.snapshot(); - return { snapshotId: snapshot.snapshotId }; + snapshot() { + return run(async () => { + const snapshot = await session.snapshot(); + return { snapshotId: snapshot.snapshotId }; + }); }, stop() { - return sandbox.stop(); + return run(async () => await session.stop()); }, update(params) { - return sandbox.update(params); + return run(async () => await session.update(params)); }, writeFiles(files) { - return sandbox.writeFiles(files); + return run(async () => await session.writeFiles(files)); }, }; } diff --git a/packages/junior/src/chat/services/turn-result.ts b/packages/junior/src/chat/services/turn-result.ts index a0a307cf1..6e89fe26e 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; + sandboxRef?: SandboxRef; piMessages?: PiMessage[]; diagnostics: AgentTurnDiagnostics; } @@ -124,8 +124,7 @@ export interface TurnResultInput { userInput: string; artifactStatePatch: Partial; toolCalls: string[]; - sandboxId?: string; - sandboxDependencyProfileHash?: string; + sandboxRef?: SandboxRef; piMessages?: PiMessage[]; durationMs?: number; generatedFileCount: number; @@ -188,8 +187,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { newMessages, artifactStatePatch, toolCalls, - sandboxId, - sandboxDependencyProfileHash, + sandboxRef, durationMs, shouldTrace, spanContext, @@ -342,8 +340,7 @@ export function buildTurnResult(input: TurnResultInput): AgentRunResult { Object.keys(artifactStatePatch).length > 0 ? artifactStatePatch : undefined, - sandboxId, - sandboxDependencyProfileHash, + sandboxRef, 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/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 34d17ba83..5317f73f3 100644 --- a/packages/junior/src/chat/tools/sandbox/file-uploads.ts +++ b/packages/junior/src/chat/tools/sandbox/file-uploads.ts @@ -159,18 +159,18 @@ 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], }); if (result.exitCode !== 0) { return undefined; } - const value = (await result.stdout()).trim(); + const value = result.stdout.trim(); return value || undefined; } catch { return undefined; @@ -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/runtime/generated-artifacts.ts b/packages/junior/src/chat/tools/sandbox/generated-artifacts.ts similarity index 83% rename from packages/junior/src/chat/runtime/generated-artifacts.ts rename to packages/junior/src/chat/tools/sandbox/generated-artifacts.ts index a99405c9b..48fe7ad7f 100644 --- a/packages/junior/src/chat/runtime/generated-artifacts.ts +++ b/packages/junior/src/chat/tools/sandbox/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,16 +34,16 @@ 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], }); if (mkdir.exitCode !== 0) { throw new Error( - `failed to create generated artifact directory: ${await mkdir.stderr()}`, + `failed to create generated artifact directory: ${mkdir.stderr}`, ); } @@ -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/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 new file mode 100644 index 000000000..0b854d760 --- /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/tools/sandbox/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(), + onSandboxRefChanged: vi.fn(), + ...overrides, + }; +} + +describe("createAgentSandbox", () => { + let capturedOptions: SandboxOptions; + + beforeEach(() => { + vi.clearAllMocks(); + createSandboxMock.mockImplementation((input: SandboxOptions) => { + capturedOptions = input; + return { + workspace, + sandboxRef: () => input.sandboxRef, + tools: { + supports: () => true, + execute: executeSandboxToolMock, + }, + }; + }); + executeCustomCommandMock.mockResolvedValue({ handled: false }); + }); + + it("updates the run reference before awaiting durable persistence", async () => { + const callOrder: string[] = []; + createAgentSandbox( + options({ + onSandboxRefChanged: () => { + callOrder.push("run"); + }, + persistSandboxRef: async () => { + callOrder.push("durable"); + }, + }), + ); + + await capturedOptions.onSandboxRefChanged?.({ 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/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/unit/misc/bash-tool-sandbox-adapter.test.ts b/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts similarity index 62% 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 index 83ccad898..b822b3bae 100644 --- a/packages/junior/tests/unit/misc/bash-tool-sandbox-adapter.test.ts +++ b/packages/junior/tests/component/misc/bash-tool-sandbox-adapter.test.ts @@ -5,6 +5,7 @@ const { sandboxGetMock } = vi.hoisted(() => ({ })); vi.mock("@vercel/sandbox", () => ({ + FileSystem: class {}, Sandbox: { get: sandboxGetMock, }, @@ -14,18 +15,14 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ getRuntimeDependencyProfileHash: () => "current-profile", })); -import { createSandboxSessionManager } from "@/chat/sandbox/session"; +import { createSandboxRuntime } 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: {}, }; } @@ -51,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({ + sandboxRef: { + 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/runtime/runtime-dependency-snapshots.test.ts b/packages/junior/tests/component/misc/runtime-dependency-snapshots.test.ts similarity index 97% rename from packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts rename to packages/junior/tests/component/misc/runtime-dependency-snapshots.test.ts index 559a791a3..4a8fa4083 100644 --- a/packages/junior/tests/unit/runtime/runtime-dependency-snapshots.test.ts +++ b/packages/junior/tests/component/misc/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, }; } diff --git a/packages/junior/tests/component/misc/sandbox-executor.test.ts b/packages/junior/tests/component/misc/sandbox-executor.test.ts index 5b18737fd..8e25c1883 100644 --- a/packages/junior/tests/component/misc/sandbox-executor.test.ts +++ b/packages/junior/tests/component/misc/sandbox-executor.test.ts @@ -3,7 +3,8 @@ 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"; +import type { SkillMetadata } from "@/chat/skills"; type StructuredSandboxResult = { details: Record; @@ -15,6 +16,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, @@ -94,19 +134,161 @@ vi.mock("@/chat/sandbox/runtime-dependency-snapshots", () => ({ getRuntimeDependencyProfileHash: getRuntimeDependencyProfileHashMock, })); -import { createSandboxExecutor } from "@/chat/sandbox/sandbox"; +import { createSandbox } from "@/chat/sandbox/sandbox"; import { parseSandboxEgressCredentialToken, SANDBOX_EGRESS_PROXY_PATH, setSandboxEgressAuthRequiredSignal, setSandboxEgressPermissionDeniedSignal, } from "@/chat/sandbox/egress/session"; -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(workspace: SandboxWorkspace): Promise; + }; + onSandboxPrepare?: (sandbox: SandboxSession) => void | Promise; + onSandboxAcquired?: (sandbox: { + sandboxId: string; + sandboxDependencyProfileHash?: string; + }) => void | Promise; + createNetworkPolicy?: (...args: any[]) => any; + commandEnv?: () => Promise>; +} + +function createTestSandboxRuntime(options: SandboxFixtureOptions = {}) { + let skills: SkillMetadata[] = []; + let referenceFiles: string[] = []; + let runtime: ReturnType | undefined; + const getRuntime = () => + (runtime ??= createSandboxRuntime({ + sandboxRef: 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, + onSandboxRefChanged: 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().sandboxRef()?.id, + getDependencyProfileHash: () => getRuntime().sandboxRef()?.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({ + sandboxRef: 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, + onSandboxRefChanged: 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().sandboxRef()?.id, + getDependencyProfileHash: () => getAccess().sandboxRef()?.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 }> => { + return { result: (await getAccess().tools.execute(params)) as T }; + }, + }; +} + 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 +313,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,12 +419,17 @@ 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, + workspace: SandboxSession | SandboxWorkspace, sandbox: MockSandbox, ): Promise { - expect(workspace.sandboxId).toBe(sandbox.name); - expect(workspace.sandboxEgressId).toBe(`${sandbox.name}_session`); const fileBuffer = Buffer.from("workspace file"); const commandResult = { exitCode: 0, @@ -236,9 +446,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); + expect(delegatedResult.stdout).toBe("stdout"); + expect(delegatedResult.stderr).toBe("stderr"); expect(sandbox.runCommand).toHaveBeenCalledWith({ cmd: "pwd", args: ["-P"], @@ -246,7 +461,7 @@ async function expectWorkspaceToDelegate( }); } -describe("createSandboxExecutor", () => { +describe("createTestSandbox", () => { beforeEach(() => { sandboxGetMock.mockReset(); sandboxCreateMock.mockReset(); @@ -278,7 +493,37 @@ describe("createSandboxExecutor", () => { delete process.env.JUNIOR_SECRET; }); - it("recreates a sandbox when sandboxId hint points to a stopped sandbox", async () => { + it("preserves an unopened sandbox reference without rewriting its profile", () => { + getRuntimeDependencyProfileHashMock.mockReturnValue("current-profile"); + const sandbox = createSandbox({ + sandboxRef: { id: "sbx_existing", profileHash: "persisted-profile" }, + skills: [], + referenceFiles: [], + }); + + expect(sandbox.sandboxRef()).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(); + const workspace = createSandboxSession({ + ...sandbox, + runCommand: resumingRunCommand, + } as never); + + await workspace.runCommand({ cmd: "gh", args: ["pr", "create"] }); + + expect(sandbox.session.runCommand).toHaveBeenCalledTimes(1); + expect(resumingRunCommand).not.toHaveBeenCalled(); + }); + + it("fails a stopped hinted session and reacquires it on a later call", async () => { const stoppedSandbox = makeSandbox("sbx_stopped", { mkDirError: createApiError( 410, @@ -287,65 +532,114 @@ 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" }); + const executor = createTestSandbox({ sandboxId: "sbx_stopped" }); executor.configureSkills([]); + await expect(executor.createSandbox()).rejects.toBeInstanceOf( + ToolInputError, + ); 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("reports acquired sandbox metadata immediately after fresh sandbox boot", async () => { - const freshSandbox = makeSandbox("sbx_fresh"); - const onSandboxAcquired = vi.fn(); - sandboxCreateMock.mockResolvedValue(freshSandbox); - - const executor = createSandboxExecutor({ - onSandboxAcquired, + 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 = createTestSandbox(); executor.configureSkills([]); - await executor.createSandbox(); - await executor.createSandbox(); + await expect(executor.createSandbox()).rejects.toBeInstanceOf( + ToolInputError, + ); + const sandbox = await executor.createSandbox(); - expect(onSandboxAcquired).toHaveBeenCalledTimes(1); - expect(onSandboxAcquired).toHaveBeenCalledWith({ - sandboxId: "sbx_fresh", + await expectWorkspaceToDelegate(sandbox, recoveredSandbox); + expect(sandboxCreateMock).toHaveBeenCalledTimes(1); + expect(sandboxGetMock).toHaveBeenCalledWith({ + name: "sbx_fresh_stopped", + resume: true, }); }); - it("prepares a cached sandbox only once", async () => { - const freshSandbox = makeSandbox("sbx_fresh"); - const onSandboxPrepare = vi.fn(); + 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 manager = createSandboxSessionManager({ - onSandboxPrepare, + const executor = createTestSandbox({ + onSandboxAcquired: async () => { + callOrder.push("reference"); + }, + agentHooks: { + beforeToolExecute: vi.fn(), + prepareSandbox: vi.fn(async () => { + callOrder.push("prepare"); + throw unavailable; + }), + }, }); - manager.configureSkills([]); - - await manager.createSandbox(); - await manager.createSandbox(); + executor.configureSkills([]); - expect(onSandboxPrepare).toHaveBeenCalledTimes(1); - expect(onSandboxPrepare).toHaveBeenCalledWith( - expect.objectContaining({ - sandboxId: "sbx_fresh", - }), + await expect(executor.createSandbox()).rejects.toBeInstanceOf( + ToolInputError, ); + + expect(callOrder).toEqual(["reference", "prepare"]); + expect(executor.getSandboxId()).toBe("sbx_prepare_failure"); + }); + + 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 executor = createTestSandbox({ onSandboxAcquired }); + executor.configureSkills([]); + + await expect(executor.createSandbox()).rejects.toBe(persistenceError); + await expect(executor.createSandbox()).resolves.toBeDefined(); + + 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 () => { @@ -370,7 +664,7 @@ describe("createSandboxExecutor", () => { markPrepareStarted(); await prepareReleased; }); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ onSandboxPrepare, }); manager.configureSkills([]); @@ -393,48 +687,77 @@ describe("createSandboxExecutor", () => { expect(vi.mocked(createBashTool)).toHaveBeenCalledTimes(1); }); - it("reports acquired sandbox metadata when restoring from a sandbox id hint", async () => { - const restoredSandbox = makeSandbox("sbx_restored"); - const onSandboxAcquired = vi.fn(); - sandboxGetMock.mockResolvedValue(restoredSandbox); + 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 executor = createSandboxExecutor({ - sandboxId: "sbx_restored", - onSandboxAcquired, + const manager = createTestSandboxRuntime(); + manager.configureSkills([]); + const staleSession = await manager.createSandbox(); + const staleLoad = manager.ensureToolExecutors(); + await vi.waitFor(() => { + expect(vi.mocked(createBashTool)).toHaveBeenCalledTimes(1); }); - executor.configureSkills([]); - await executor.createSandbox(); + staleSandbox.readFileToBuffer.mockRejectedValueOnce(unavailable); + await expect( + staleSession.readFileToBuffer({ path: "/tmp/invalidate" }), + ).rejects.toBe(unavailable); - expect(onSandboxAcquired).toHaveBeenCalledTimes(1); - expect(onSandboxAcquired).toHaveBeenCalledWith({ - sandboxId: "sbx_restored", - }); + 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("refreshes network policy when restoring from a sandbox id hint", async () => { + it("does not report a reference change when restoring the same sandbox", async () => { const restoredSandbox = makeSandbox("sbx_restored"); - const networkPolicy = { - allow: { - "*": [], - "api.example.com": [ - { - forwardURL: "https://junior.example.com/api/internal/proxy", - }, - ], - }, - }; + const onSandboxAcquired = vi.fn(); sandboxGetMock.mockResolvedValue(restoredSandbox); - const manager = createSandboxSessionManager({ + const executor = createTestSandbox({ sandboxId: "sbx_restored", - createNetworkPolicy: vi.fn(() => networkPolicy), + onSandboxAcquired, }); - manager.configureSkills([]); + executor.configureSkills([]); - await manager.createSandbox(); + await executor.createSandbox(); - expect(restoredSandbox.update).toHaveBeenCalledWith({ networkPolicy }); + expect(onSandboxAcquired).not.toHaveBeenCalled(); }); it("keeps restored sandbox policy tracking tied to the applied policy", async () => { @@ -465,14 +788,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, @@ -483,7 +806,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"; @@ -498,7 +821,7 @@ describe("createSandboxExecutor", () => { }, })); - const manager = createSandboxSessionManager({ createNetworkPolicy }); + const manager = createTestSandboxRuntime({ createNetworkPolicy }); manager.configureSkills([]); await manager.createSandbox(); @@ -523,25 +846,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(); + await manager.refreshNetworkPolicy(); - expect(sandbox.update).toHaveBeenCalledTimes(3); + expect(sandbox.update).toHaveBeenCalledTimes(2); expect(sandbox.update).toHaveBeenLastCalledWith({ networkPolicy: { allow: { @@ -549,7 +859,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 +872,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 = createTestSandbox(); 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", @@ -601,7 +896,7 @@ describe("createSandboxExecutor", () => { getRuntimeDependencyProfileHashMock.mockReturnValue("current-profile"); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_old", sandboxDependencyProfileHash: "old-profile", }); @@ -626,7 +921,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( @@ -635,6 +930,55 @@ 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 = createTestSandbox({ 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.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"; @@ -643,7 +987,7 @@ describe("createSandboxExecutor", () => { const freshSandbox = makeSandbox("sbx_oidc"); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.createSandbox(); @@ -659,7 +1003,7 @@ describe("createSandboxExecutor", () => { const freshSandbox = makeSandbox("sbx_resources"); sandboxCreateMock.mockResolvedValue(freshSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.createSandbox(); @@ -681,7 +1025,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ sandboxId: "sbx_bash" }); + const executor = createTestSandbox({ sandboxId: "sbx_bash" }); executor.configureSkills([]); await executor.execute({ @@ -727,7 +1071,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({ @@ -766,7 +1110,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(); @@ -814,7 +1158,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_bash_ambiguous_mutation", }); executor.configureSkills([]); @@ -866,7 +1210,7 @@ describe("createSandboxExecutor", () => { GIT_AUTHOR_NAME: "second-bot", }); - const manager = createSandboxSessionManager({ + const manager = createTestSandboxRuntime({ sandboxId: "sbx_dynamic_env", commandEnv, }); @@ -912,7 +1256,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_authorize_credentials", credentialEgress: { actor: { type: "user", userId: "U123" }, @@ -993,7 +1337,7 @@ describe("createSandboxExecutor", () => { }, ); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_stale_auth_signal", }); executor.configureSkills([]); @@ -1043,7 +1387,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_fresh_auth_signal", }); executor.configureSkills([]); @@ -1105,7 +1449,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_pipe_masked_auth_signal", }); executor.configureSkills([]); @@ -1168,7 +1512,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_permission_signal", }); executor.configureSkills([]); @@ -1236,7 +1580,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_mixed_auth_signal", }); executor.configureSkills([]); @@ -1286,7 +1630,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_authorize_system_credentials", credentialEgress: { actor: { platform: "system", name: "scheduler" }, @@ -1319,7 +1663,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_registered_credentials", credentialEgress: { actor: { type: "user", userId: "U123" }, @@ -1349,7 +1693,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); @@ -1361,27 +1705,29 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ + const executor = createTestSandbox({ sandboxId: "sbx_stream_interrupted", }); 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); @@ -1392,111 +1738,244 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); - const response = await executor.execute({ - toolName: "findFiles", - input: { pattern: "*.ts" }, - }); + const error = await executor + .execute({ + toolName: "findFiles", + input: { pattern: "*.ts" }, + }) + .catch((caught: unknown) => caught); - 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", - }, + expect(error).toBeInstanceOf(ToolInputError); + expect(error).toMatchObject({ + message: expect.stringContaining( + "The sandbox command stream was interrupted during findFiles", + ), }); }); - it("recognizes stream interruptions wrapped by writeFile errors", async () => { - const sandbox = makeSandbox("sbx_write_file_interrupted"); - const writeFileExecute = vi.fn(async () => { - throw createStreamInterruptedError(); - }); - sandboxCreateMock.mockResolvedValueOnce(sandbox); - vi.mocked(createBashTool).mockResolvedValueOnce({ + 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: writeFileExecute }, + writeFile: { execute: vi.fn(async () => ({ success: true })) }, }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox({ + onSandboxAcquired: vi.fn(), + }); executor.configureSkills([]); - const response = await executor.execute({ - toolName: "writeFile", - input: { path: "file.ts", content: "new content" }, + 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(response.result).toMatchObject({ - details: { - ok: false, - status: "error", - error: { - kind: "stream_interrupted", - }, - tool: "writeFile", + 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 = createTestSandbox({ + sandboxId: staleSandbox.name, + }); + executor.configureSkills([]); + + const staleOperation = executor.execute({ + toolName: "findFiles", + input: { pattern: "*.ts" }, }); + await staleOperationStarted; + + 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("routes matching bash commands through custom command handler", async () => { - const sandbox = makeSandbox("sbx_custom"); - sandboxGetMock.mockResolvedValue(sandbox); + it("does not replay unavailable command output and recovers later", async () => { + const unavailableError = createClosedStreamError(); + const sandbox = makeSandbox("sbx_closed_bash"); + 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: "" })) }, 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 = createSandboxExecutor({ - sandboxId: "sbx_custom", - runBashCustomCommand, + const executor = createTestSandbox({ + sandboxId: sandbox.name, }); executor.configureSkills([]); - const response = await executor.execute({ - toolName: "bash", - input: { - command: "jr-rpc config get github.repo", + 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(); + + 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); + }); - 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("recognizes stream interruptions wrapped by writeFile errors", async () => { + const sandbox = makeSandbox("sbx_write_file_interrupted"); + const writeFileExecute = vi.fn(async () => { + throw createStreamInterruptedError(); + }); + sandboxCreateMock.mockResolvedValueOnce(sandbox); + vi.mocked(createBashTool).mockResolvedValueOnce({ + tools: { + readFile: { execute: vi.fn(async () => ({ content: "" })) }, + writeFile: { execute: writeFileExecute }, + }, + } as never); + + const executor = createTestSandbox(); + executor.configureSkills([]); + + const error = await executor + .execute({ + toolName: "writeFile", + input: { path: "file.ts", content: "new content" }, + }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ToolInputError); + expect(error).toMatchObject({ + message: expect.stringContaining( + "The sandbox command stream was interrupted during writeFile", + ), }); }); @@ -1510,7 +1989,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -1536,7 +2015,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -1557,6 +2036,47 @@ describe("createSandboxExecutor", () => { 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); @@ -1567,7 +2087,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -1586,7 +2106,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 +2120,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: "" })) }, @@ -1625,7 +2142,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await executor.execute({ @@ -1636,14 +2153,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 +2184,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 () => { @@ -1665,7 +2202,7 @@ describe("createSandboxExecutor", () => { "utf8", ); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([ { name: "demo-skill", @@ -1711,7 +2248,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([ { name: "demo-skill", @@ -1757,7 +2294,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const response = await executor.execute({ @@ -1797,7 +2334,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await expect( @@ -1823,7 +2360,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 +2368,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", ); }), }, @@ -1842,7 +2379,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); await expect( @@ -1852,7 +2389,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 () => { @@ -1874,7 +2411,7 @@ describe("createSandboxExecutor", () => { }, } as never); - const executor = createSandboxExecutor({ sandboxId: "sbx_existing" }); + const executor = createTestSandbox({ sandboxId: "sbx_existing" }); executor.configureSkills([ { name: "demo-skill", @@ -1921,7 +2458,7 @@ describe("createSandboxExecutor", () => { }); sandboxCreateMock.mockResolvedValue(snapshotSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const sandbox = await executor.createSandbox(); @@ -1963,7 +2500,7 @@ describe("createSandboxExecutor", () => { (error: unknown) => error === missingError, ); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const sandbox = await executor.createSandbox(); @@ -2006,7 +2543,7 @@ describe("createSandboxExecutor", () => { .mockRejectedValueOnce(snapshottingError) .mockResolvedValueOnce(snapshotSandbox); - const executor = createSandboxExecutor(); + const executor = createTestSandbox(); executor.configureSkills([]); const sandbox = await executor.createSandbox(); @@ -2058,7 +2595,7 @@ describe("createSandboxExecutor", () => { }, })); - const manager = createSandboxSessionManager({ createNetworkPolicy }); + const manager = createTestSandboxRuntime({ createNetworkPolicy }); manager.configureSkills([]); await manager.createSandbox(); @@ -2111,7 +2648,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..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,9 +37,9 @@ describe("executeAgentRun error path", () => { input: { messageText: "hello" }, routing: { destination: LOCAL_DESTINATION, source: LOCAL_SOURCE }, state: { - sandbox: { - sandboxId: "sb-123", - sandboxDependencyProfileHash: "hash-abc", + sandboxRef: { + id: "sb-123", + profileHash: "hash-abc", }, }, }); @@ -51,8 +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!.sandboxId).toBe("sb-123"); - expect(reply!.sandboxDependencyProfileHash).toBe("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/unit/runtime/agent-run-mcp-progressive-loading.test.ts b/packages/junior/tests/component/runtime/agent-run-mcp-progressive-loading.test.ts similarity index 98% 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 index 6563e7a01..c55c437d2 100644 --- 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 @@ -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"); + }, + }, + sandboxRef: () => ({ id: "sandbox-test", profileHash: "hash-test" }), }), })); 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 78% 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 5038184a1..eacef30c1 100644 --- a/packages/junior/tests/unit/runtime/agent-run-lazy-sandbox.test.ts +++ b/packages/junior/tests/component/runtime/agent-run-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: { + onSandboxRefChanged?: (sandboxRef: { + id: string; + profileHash?: string; }) => void | Promise; }) => { + const acquire = async () => { + createSandboxCallCount.value += 1; + await options.onSandboxRefChanged?.({ + 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: () => + sandboxRef: () => 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, }; }, })); @@ -379,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); @@ -392,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 ?? {}), @@ -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.sandboxRef).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.sandboxRef).toBeUndefined(); expect(reply.diagnostics.toolCalls).toEqual(["loadSkill"]); expect(selectedThinkingLevels.value).toEqual(["medium"]); }); @@ -500,36 +496,28 @@ describe("executeAgentRun lazy sandbox boot", () => { expect(selectedThinkingLevels.value).toEqual(["high"]); }); - it("retains sandbox reuse metadata after lazy boot on error turns", async () => { + it("retains and reports the sandbox reference 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.sandboxId).toBe("sandbox-test"); - expect(reply.sandboxDependencyProfileHash).toBe("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(createSandboxCallCount.value).toBe(1); + expect(reply.sandboxRef).toEqual({ + id: "sandbox-test", + profileHash: "hash-test", + }); + expect(onSandboxRefChanged).toHaveBeenCalledTimes(1); + expect(onSandboxRefChanged).toHaveBeenCalledWith({ + id: "sandbox-test", + profileHash: "hash-test", }); }); }); 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..06b20cf7a --- /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, { + sandboxRef: { id: "sandbox-old", profileHash: "profile-old" }, + }); + + await persistThreadStateById(conversationId, { + sandboxRef: { 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, { + sandboxRef: { id: "sandbox-old", profileHash: "profile-old" }, + }); + + await persistThreadStateById(conversationId, { sandboxRef: null }); + + const state = await getPersistedThreadState(conversationId); + expect(state).toMatchObject({ + app_sandbox_id: "", + app_sandbox_dependency_profile_hash: "", + }); + expect(getPersistedSandboxState(state)).toBeUndefined(); + }); +}); 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/component/runtime/generated-artifacts.test.ts b/packages/junior/tests/component/tools/sandbox/generated-artifacts.test.ts similarity index 90% rename from packages/junior/tests/component/runtime/generated-artifacts.test.ts rename to packages/junior/tests/component/tools/sandbox/generated-artifacts.test.ts index 7869e751f..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"; @@ -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; } @@ -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,11 +81,11 @@ describe("writeSandboxGeneratedArtifacts", () => { }); it("fails before returning refs when the artifact directory cannot be created", async () => { - const sandbox = { + const workspace = { runCommand: async () => commandResult({ exitCode: 1, - stderr: async () => "permission denied", + stderr: "permission denied", }), writeFiles: async () => { throw new Error("writeFiles should not run"); @@ -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/integration/agent-continue-slack.test.ts b/packages/junior/tests/integration/agent-continue-slack.test.ts index 4b64f5860..3fdf0a309 100644 --- a/packages/junior/tests/integration/agent-continue-slack.test.ts +++ b/packages/junior/tests/integration/agent-continue-slack.test.ts @@ -61,16 +61,17 @@ 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, }; } /** 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" || @@ -89,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, @@ -294,10 +295,7 @@ describe("agent continuation Slack integration", () => { toolChannelId: "C999", }), state: expect.objectContaining({ - sandbox: expect.objectContaining({ - sandboxId: undefined, - sandboxDependencyProfileHash: undefined, - }), + sandboxRef: 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..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,8 +213,7 @@ describe("mcp oauth callback slack integration", () => { artifactStatePatch: { lastCanvasUrl: "https://example.com/canvas", }, - sandboxId: "sandbox-1", - sandboxDependencyProfileHash: "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 6d998dfc3..f29f5ea25 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: "", - }, - }; + }; + }, }, + 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 23bb37c56..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 @@ -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"); + }, + }, + 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 1722b69f2..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 @@ -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"); + }, + }, + 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 c0bbd6fa3..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 @@ -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, + 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-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/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/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 f04bf6e2d..366a8a954 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 ""; @@ -117,12 +117,8 @@ function fakeSandbox( async runCommand() { return { exitCode: 0, - async stdout() { - return ""; - }, - async stderr() { - return ""; - }, + stdout: "", + stderr: "", }; }, async snapshot() { @@ -428,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"); @@ -470,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); @@ -519,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(); @@ -552,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 { @@ -585,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(); @@ -1223,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; @@ -1260,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({ @@ -1280,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); @@ -1297,7 +1293,7 @@ describe("getPluginTools channel resolution", () => { }, egress: TEST_EGRESS, actor: TEST_ACTOR, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.slack?.credentialSubject).toMatchObject({ @@ -1317,7 +1313,7 @@ describe("getPluginTools channel resolution", () => { }, egress: TEST_EGRESS, actor: TEST_ACTOR, - sandbox: {} as any, + workspace: {} as any, }); expect(ctx.slack?.credentialSubject).toBeUndefined(); @@ -1329,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, }, ); 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" }); }); });