From 1dbf01fd7a31fc5b0fae01fefcaf891398bb60fb Mon Sep 17 00:00:00 2001 From: Arseniy Shishaev <35579920+arseniycodes@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:22:35 +0200 Subject: [PATCH] Give a resumed sandbox its Daytona API key back A paused sandbox's saved state leaves out the API key. Resuming it did not put the key back, so repository archive uploads and file downloads in the resumed session failed with "Daytona API key is unavailable". That broke every pull request review turn that resumed a sandbox: the checkout of the pull request failed, so the agent could not push its fix. Both runtimes now resume through resumeDaytonaSandbox, which sets the key from the worker's configuration. --- apps/worker/src/automation-sandbox.test.ts | 4 ++- apps/worker/src/automation-sandbox.ts | 3 ++- apps/worker/src/investigate.ts | 5 ++-- apps/worker/src/sandbox.test.ts | 31 +++++++++++++++++++++- apps/worker/src/sandbox.ts | 11 ++++++++ 5 files changed, 48 insertions(+), 6 deletions(-) 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.