diff --git a/apps/worker/src/automation-sandbox.test.ts b/apps/worker/src/automation-sandbox.test.ts index 80c7195b..bfc237d7 100644 --- a/apps/worker/src/automation-sandbox.test.ts +++ b/apps/worker/src/automation-sandbox.test.ts @@ -558,7 +558,9 @@ describe("fresh automation sandbox", () => { await runInFreshAutomationSandbox({ ...input, keepPaused: true, resumeState: { sandboxId: "sandbox-1" } }, dependencies); - expect(client.resume).toHaveBeenCalledWith({ sandboxId: "sandbox-1" }); + // The saved state leaves out the API key; the resumed session's file + // transfers need it. + expect(client.resume).toHaveBeenCalledWith({ apiKey: "daytona-test", sandboxId: "sandbox-1" }); expect(dependencies.createSession).not.toHaveBeenCalled(); expect(dependencies.configure).not.toHaveBeenCalled(); expect(dependencies.prepare).not.toHaveBeenCalled(); diff --git a/apps/worker/src/automation-sandbox.ts b/apps/worker/src/automation-sandbox.ts index bb041080..df72dd53 100644 --- a/apps/worker/src/automation-sandbox.ts +++ b/apps/worker/src/automation-sandbox.ts @@ -19,6 +19,7 @@ import { maxDaytonaSandboxBaseNameLength, prepareDaytonaSandbox, replaceDaytonaSandboxSecrets, + resumeDaytonaSandbox, sandboxesLeftAfterFailedCreation, type DaytonaSandboxSecretMount, } from "./sandbox.js"; @@ -265,7 +266,7 @@ export async function runInFreshAutomationSandbox( if (input.resumeState) { try { session = await abortable( - client.resume(await client.deserializeSessionState(input.resumeState)), + resumeDaytonaSandbox(client, input.resumeState, input.config), input.signal, ); // Daytona recreates a sandbox it no longer has; that one needs setup. diff --git a/apps/worker/src/investigate.ts b/apps/worker/src/investigate.ts index 8e372308..fde06172 100644 --- a/apps/worker/src/investigate.ts +++ b/apps/worker/src/investigate.ts @@ -90,6 +90,7 @@ import { createDaytonaSandboxSession, prepareDaytonaSandbox, pauseDaytonaSandbox, + resumeDaytonaSandbox, } from "./sandbox.js"; import { startSandboxMeter, type SandboxMeter } from "./sandbox-metering.js"; import { agentUsageIsBillable, recordAgentRunUsage } from "./agent-usage.js"; @@ -967,9 +968,7 @@ export async function runInvestigationAgent( const persistedState = sessionRuntime?.sandboxSessionState; if (threadMode && persistedState) { try { - session = await client.resume( - await client.deserializeSessionState(persistedState), - ); + session = await resumeDaytonaSandbox(client, persistedState, config); } catch (error) { // Daytona deletes a paused thread sandbox after a day; the thread // continues in a fresh one. diff --git a/apps/worker/src/sandbox.test.ts b/apps/worker/src/sandbox.test.ts index 97e7ebf2..c081f3d7 100644 --- a/apps/worker/src/sandbox.test.ts +++ b/apps/worker/src/sandbox.test.ts @@ -1,4 +1,7 @@ -import type { DaytonaSandboxSession } from "@openai/agents-extensions/sandbox/daytona"; +import type { + DaytonaSandboxClient, + DaytonaSandboxSession, +} from "@openai/agents-extensions/sandbox/daytona"; import { describe, expect, it, vi } from "vitest"; import { closeDaytonaSandbox, @@ -10,6 +13,7 @@ import { prepareDaytonaPatchSandbox, prepareDaytonaSandbox, replaceDaytonaSandboxSecrets, + resumeDaytonaSandbox, sandboxDeletedAfterFailedCreation, sandboxesLeftAfterFailedCreation, } from "./sandbox.js"; @@ -776,6 +780,31 @@ describe("Daytona sandbox cleanup", () => { }); }); +describe("Daytona sandbox resume", () => { + it("gives the resumed session the API key its saved state left out", async () => { + const session = { state: { sandboxId: "sandbox-1" } }; + const client = { + deserializeSessionState: vi.fn(async (state: Record) => ({ + ...state, + apiKey: undefined, + })), + resume: vi.fn().mockResolvedValue(session), + }; + + await expect( + resumeDaytonaSandbox( + client as unknown as DaytonaSandboxClient, + { sandboxId: "sandbox-1" }, + { daytonaApiKey: "daytona-key" }, + ), + ).resolves.toBe(session); + expect(client.resume).toHaveBeenCalledWith({ + apiKey: "daytona-key", + sandboxId: "sandbox-1", + }); + }); +}); + describe("Daytona thread sandbox pause", () => { it("stops the sandbox through the session", async () => { const harness = cleanupHarness(); diff --git a/apps/worker/src/sandbox.ts b/apps/worker/src/sandbox.ts index 3c436ec4..6beb68f1 100644 --- a/apps/worker/src/sandbox.ts +++ b/apps/worker/src/sandbox.ts @@ -497,6 +497,17 @@ export async function replaceDaytonaSandboxSecrets( } } +// A paused sandbox's saved state leaves out the API key. The resumed +// session gets it back, because its file transfers read it from the state. +export async function resumeDaytonaSandbox( + client: DaytonaSandboxClient, + persistedState: Record, + config: DaytonaClientConfig, +): Promise { + const state = await client.deserializeSessionState(persistedState); + return client.resume({ ...state, apiKey: config.daytonaApiKey }); +} + // A thread sandbox that fails to stop keeps running until Daytona's // auto-stop, and the next turn resumes or replaces it. The failure is // reported without failing the turn that already has its answer.