From ea1e321d86629b6c2982248a8f134d60dee91821 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 23 Jun 2026 13:45:28 -0700 Subject: [PATCH] Fix Daytona resource overrides for image-backed sandboxes (#8564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Remote sandbox providers are part of the execution environment layer that lets agents run outside the local host. > - The Daytona sandbox-provider plugin exposes CPU, memory, disk, and GPU settings so operators can request larger execution sandboxes. > - The plugin was forwarding resource settings through snapshot/default creation, but Daytona rejects resource overrides on that path. > - Image-backed Daytona creation does support resource settings, so Paperclip should only send those values when an image is configured. > - This pull request makes the Daytona contract explicit in validation and runtime acquisition. > - The benefit is that operators get a clear Paperclip error for unsupported snapshot/default resource configs, while image-backed sandboxes still receive the requested allocation. ## Linked Issues or Issue Description No public GitHub issue exists. ### What happened? Daytona sandbox environments can be configured with CPU/memory/disk/GPU resource settings, but snapshot/default Daytona sandbox creation rejects those resource overrides. Paperclip could pass unsupported resource fields through to Daytona and surface an opaque provider error. ### Expected behavior Paperclip should only send resource fields on Daytona creation paths that support them, and it should reject unsupported resource configurations before creating a sandbox. ### Steps to reproduce 1. Configure a Daytona sandbox environment with resource values such as `cpu: 4` and `memory: 4`. 2. Leave the environment on snapshot/default creation by not setting an image. 3. Acquire a Daytona sandbox lease. 4. Observe Daytona reject the resource override on the snapshot/default path. ### Paperclip version or commit Reproduced against the current Daytona sandbox-provider plugin behavior before this PR. This PR fixes the contract in the plugin code and tests. ### Deployment mode Local authenticated Paperclip instance using the Daytona sandbox provider. Additional scope: - Daytona provider only. - No schema changes. - No non-Daytona provider changes. Related search performed: - `gh search issues "Daytona resources snapshot repo:paperclipai/paperclip" --limit 10` - `gh search prs "Daytona resources snapshot repo:paperclipai/paperclip" --limit 10` ## What Changed - Restrict Daytona `resources` create params to image-backed sandbox creation. - Add validation/runtime guard for resource settings without an image. - Keep image-backed resource metadata and reusable-lease sentinel behavior covered by tests. - Update Daytona plugin tests for image-backed resources and snapshot/default rejection. ## Verification - `pnpm -C packages/plugins/sandbox-providers/daytona test` - `pnpm -C packages/plugins/sandbox-providers/daytona build` - Manual Daytona SDK probe in a local authenticated instance: image-backed creation with `daytonaio/sandbox:0.8.0` returned a 4 CPU / 4 GiB sandbox and deleted cleanly. - Greptile Review: 5/5, no inline comments. ## Risks - Existing Daytona environments that set resource values while relying on snapshot/default creation will now fail validation/acquisition with a clear error instead of calling Daytona and failing there. - Operators who need custom resource sizes should use image-backed creation or a provider-side resource-sized snapshot workflow. - Low migration risk: no database schema changes and no changes to non-Daytona providers. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 coding agent. Exact context window is not exposed in this environment. Tool-enabled workflow with shell, GitHub CLI, database inspection, and local test/build execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude --- .../daytona/src/plugin.test.ts | 144 ++++++++++++++++++ .../sandbox-providers/daytona/src/plugin.ts | 33 +++- 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 1620ca96f3f..f594a07e272 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -40,6 +40,7 @@ function createMockSandbox(overrides: { start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), recover: vi.fn().mockResolvedValue(undefined), + resize: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(undefined), fs: { createFolder: vi.fn().mockResolvedValue(undefined), @@ -209,6 +210,149 @@ describe("Daytona sandbox provider plugin", () => { ); }); + it("passes configured resources to Daytona for image-based creation", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + agentId: "agent-1", + executionWorkspaceId: "workspace-1", + adapterType: "codex_local", + config: { + image: "node:20", + cpu: 4, + memory: 8, + disk: 20, + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(mockCreate).toHaveBeenCalledTimes(1); + const [createParams] = mockCreate.mock.calls[0] as [Record]; + expect(createParams).toMatchObject({ + image: "node:20", + resources: { cpu: 4, memory: 8, disk: 20, gpu: undefined }, + }); + expect(createParams).not.toHaveProperty("snapshot"); + expect(sandbox.resize).not.toHaveBeenCalled(); + }); + + it("rejects resource settings for snapshot-backed creation", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const result = await plugin.definition.onEnvironmentValidateConfig?.({ + driverKey: "daytona", + config: { + snapshot: "base-snapshot", + cpu: 4, + memory: 8, + disk: 20, + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(result).toEqual({ + ok: false, + errors: [ + "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU.", + ], + }); + }); + + it("rejects resource settings for default snapshot creation before creating a sandbox", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + + await expect(plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + agentId: "agent-1", + executionWorkspaceId: "workspace-1", + adapterType: "codex_local", + config: { + cpu: 4, + memory: 4, + timeoutMs: 300000, + reuseLease: false, + }, + })).rejects.toThrow( + "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU.", + ); + + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it("records requested resources in lease metadata", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValue(sandbox); + + const lease = await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + agentId: "agent-1", + executionWorkspaceId: "workspace-1", + adapterType: "codex_local", + config: { + image: "daytonaio/sandbox:0.8.0", + cpu: 4, + memory: 8, + timeoutMs: 300000, + reuseLease: true, + }, + }); + + expect(lease?.metadata).toMatchObject({ cpu: 4, memory: 8 }); + expect(lease?.metadata).not.toHaveProperty("disk"); + expect(lease?.metadata).not.toHaveProperty("gpu"); + }); + + it("changes reusable-lease sentinel identity when resources change", async () => { + process.env.DAYTONA_API_KEY = "host-key"; + + const acquireWithCpu = async (cpu: number): Promise => { + const sandbox = createMockSandbox(); + mockCreate.mockResolvedValueOnce(sandbox); + await plugin.definition.onEnvironmentAcquireLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + runId: "run-1", + agentId: "agent-1", + executionWorkspaceId: "workspace-1", + adapterType: "codex_local", + config: { + image: "daytonaio/sandbox:0.8.0", + cpu, + timeoutMs: 300000, + reuseLease: true, + }, + }); + const uploadCall = sandbox.fs.uploadFile.mock.calls.find( + (call) => typeof call[1] === "string" && call[1].endsWith("reusable-sandbox-lease.json"), + ) as [Buffer, string, number] | undefined; + expect(uploadCall).toBeTruthy(); + const parsed = JSON.parse((uploadCall as [Buffer, string, number])[0].toString("utf8")) as { token: string }; + return parsed.token; + }; + + const tokenCpu1 = await acquireWithCpu(1); + const tokenCpu4 = await acquireWithCpu(4); + + expect(tokenCpu1).toBeTruthy(); + expect(tokenCpu4).toBeTruthy(); + expect(tokenCpu1).not.toEqual(tokenCpu4); + }); + it("deletes the sandbox if lease setup throws after sandbox creation", async () => { process.env.DAYTONA_API_KEY = "host-key"; const sandbox = createMockSandbox(); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index cc5b294c1e0..fb367debe2f 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -145,6 +145,15 @@ function buildCreateParams( }; } +function hasResourceRequest(config: DaytonaDriverConfig): boolean { + return config.cpu != null || config.memory != null || config.disk != null || config.gpu != null; +} + +function validateResourceRequest(config: DaytonaDriverConfig): string | null { + if (!hasResourceRequest(config) || config.image) return null; + return "Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU."; +} + function buildSandboxLabels(input: { companyId: string; environmentId: string; @@ -250,6 +259,13 @@ function workspaceSentinelToken(input: { image: input.config.image, snapshot: input.config.snapshot, target: input.config.target, + // Include resource-shaping inputs so changing the requested allocation + // expires old reusable leases and forces a fresh sandbox instead of + // reusing a previously provisioned (e.g. one-CPU) sandbox. + cpu: input.config.cpu, + memory: input.config.memory, + disk: input.config.disk, + gpu: input.config.gpu, })) .digest("hex"); } @@ -349,6 +365,12 @@ function leaseMetadata(input: { reuseLease: input.config.reuseLease, remoteCwd: input.remoteCwd, resumedLease: input.resumedLease, + // Record the resources Paperclip attempted to request so future diagnosis + // can compare requested allocation against what Daytona provisioned. + ...(input.config.cpu != null ? { cpu: input.config.cpu } : {}), + ...(input.config.memory != null ? { memory: input.config.memory } : {}), + ...(input.config.disk != null ? { disk: input.config.disk } : {}), + ...(input.config.gpu != null ? { gpu: input.config.gpu } : {}), ...(input.workspaceSentinel ? { workspaceSentinel: input.workspaceSentinel } : {}), }; } @@ -414,6 +436,10 @@ async function createSandbox( params: PluginEnvironmentAcquireLeaseParams | PluginEnvironmentProbeParams, config: DaytonaDriverConfig, ): Promise { + const resourceRequestError = validateResourceRequest(config); + if (resourceRequestError) { + throw new Error(resourceRequestError); + } const client = createDaytonaClient(config); const createParams = buildCreateParams(config, buildSandboxLabels({ companyId: params.companyId, @@ -421,9 +447,10 @@ async function createSandbox( runId: "runId" in params ? params.runId : undefined, reuseLease: config.reuseLease, })); - return await client.create(createParams, { + const sandbox = await client.create(createParams, { timeout: toTimeoutSeconds(config.timeoutMs), }); + return sandbox; } async function getSandbox(config: DaytonaDriverConfig, sandboxId: string): Promise { @@ -546,6 +573,10 @@ const plugin = definePlugin({ if (!config.apiKey && !(process.env.DAYTONA_API_KEY?.trim())) { errors.push("Daytona sandbox environments require an API key in config or DAYTONA_API_KEY."); } + const resourceRequestError = validateResourceRequest(config); + if (resourceRequestError) { + errors.push(resourceRequestError); + } for (const [key, value] of Object.entries({ cpu: config.cpu, memory: config.memory,