Fix Daytona resource overrides for image-backed sandboxes (#8564)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandClaude authored and GitHub committed 2026-06-23 13:45:28 -07:00
1 parent 8bfe5a4791
commit ea1e321d86
2 files changed
+176 -1

No files matched your search

@@ -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<string, unknown>];
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<string> => {
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();
@@ -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<Sandbox> {
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<Sandbox> {
@@ -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,