mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
## 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>
770 lines
24 KiB
TypeScript
770 lines
24 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockCreate = vi.hoisted(() => vi.fn());
|
|
const mockGet = vi.hoisted(() => vi.fn());
|
|
const { MockDaytonaNotFoundError, MockDaytonaTimeoutError } = vi.hoisted(() => {
|
|
class MockDaytonaNotFoundError extends Error {}
|
|
class MockDaytonaTimeoutError extends Error {}
|
|
return { MockDaytonaNotFoundError, MockDaytonaTimeoutError };
|
|
});
|
|
|
|
vi.mock("@daytonaio/sdk", () => ({
|
|
Daytona: class MockDaytona {
|
|
create = mockCreate;
|
|
get = mockGet;
|
|
constructor(_config?: unknown) {}
|
|
},
|
|
DaytonaNotFoundError: MockDaytonaNotFoundError,
|
|
DaytonaTimeoutError: MockDaytonaTimeoutError,
|
|
}));
|
|
|
|
import plugin from "./plugin.js";
|
|
import manifest from "./manifest.js";
|
|
|
|
function createMockSandbox(overrides: {
|
|
id?: string;
|
|
name?: string;
|
|
state?: string;
|
|
recoverable?: boolean;
|
|
workDir?: string;
|
|
} = {}) {
|
|
return {
|
|
id: overrides.id ?? "sandbox-123",
|
|
name: overrides.name ?? "paperclip-sandbox",
|
|
state: overrides.state ?? "started",
|
|
recoverable: overrides.recoverable ?? false,
|
|
target: "us",
|
|
errorReason: null,
|
|
getWorkDir: vi.fn().mockResolvedValue(overrides.workDir ?? "/home/daytona"),
|
|
getUserHomeDir: vi.fn().mockResolvedValue("/home/daytona"),
|
|
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),
|
|
uploadFile: vi.fn().mockResolvedValue(undefined),
|
|
deleteFile: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
process: {
|
|
executeCommand: vi.fn().mockResolvedValue({
|
|
exitCode: 0,
|
|
result: "bash",
|
|
artifacts: { stdout: "bash" },
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("Daytona sandbox provider plugin", () => {
|
|
beforeEach(() => {
|
|
mockCreate.mockReset();
|
|
mockGet.mockReset();
|
|
vi.restoreAllMocks();
|
|
delete process.env.DAYTONA_API_KEY;
|
|
});
|
|
|
|
it("declares environment lifecycle handlers", async () => {
|
|
expect(await plugin.definition.onHealth?.()).toEqual({
|
|
status: "ok",
|
|
message: "Daytona sandbox provider plugin healthy",
|
|
});
|
|
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
|
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
|
|
});
|
|
|
|
it("normalizes config and validates the API key fallback", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
|
|
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
|
driverKey: "daytona",
|
|
config: {
|
|
apiKey: " explicit-key ",
|
|
apiUrl: " https://app.daytona.io/api ",
|
|
target: " us ",
|
|
snapshot: " base-snapshot ",
|
|
language: " typescript ",
|
|
timeoutMs: "450000.9",
|
|
autoStopInterval: "15",
|
|
autoArchiveInterval: "60",
|
|
autoDeleteInterval: "-1",
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
normalizedConfig: {
|
|
apiKey: "explicit-key",
|
|
apiUrl: "https://app.daytona.io/api",
|
|
target: "us",
|
|
snapshot: "base-snapshot",
|
|
image: null,
|
|
language: "typescript",
|
|
timeoutMs: 450000,
|
|
cpu: null,
|
|
memory: null,
|
|
disk: null,
|
|
gpu: null,
|
|
autoStopInterval: 15,
|
|
autoArchiveInterval: 60,
|
|
autoDeleteInterval: -1,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects ambiguous or invalid config", async () => {
|
|
await expect(plugin.definition.onEnvironmentValidateConfig?.({
|
|
driverKey: "daytona",
|
|
config: {
|
|
apiUrl: "not-a-url",
|
|
image: "node:20",
|
|
snapshot: "snapshot-a",
|
|
timeoutMs: 0,
|
|
},
|
|
})).resolves.toEqual({
|
|
ok: false,
|
|
errors: [
|
|
"Daytona sandbox environments must set either image or snapshot, not both.",
|
|
"apiUrl must be a valid URL.",
|
|
"timeoutMs must be between 1 and 86400000.",
|
|
"Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.",
|
|
],
|
|
});
|
|
});
|
|
|
|
it("probes by creating and then deleting a sandbox", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox();
|
|
mockCreate.mockResolvedValue(sandbox);
|
|
|
|
const result = await plugin.definition.onEnvironmentProbe?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
config: {
|
|
snapshot: "base-snapshot",
|
|
timeoutMs: 300000,
|
|
reuseLease: false,
|
|
},
|
|
});
|
|
|
|
expect(mockCreate).toHaveBeenCalled();
|
|
expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace", "755");
|
|
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
metadata: {
|
|
provider: "daytona",
|
|
shellCommand: "bash",
|
|
sandboxId: "sandbox-123",
|
|
remoteCwd: "/home/daytona/paperclip-workspace",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("acquires a lease from a created sandbox", 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: "node:20",
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(lease).toMatchObject({
|
|
providerLeaseId: "sandbox-123",
|
|
metadata: {
|
|
provider: "daytona",
|
|
shellCommand: "bash",
|
|
sandboxId: "sandbox-123",
|
|
remoteCwd: "/home/daytona/paperclip-workspace",
|
|
reuseLease: true,
|
|
workspaceSentinel: {
|
|
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
result: "written",
|
|
},
|
|
},
|
|
});
|
|
expect(sandbox.fs.createFolder).toHaveBeenCalledWith(
|
|
"/home/daytona/paperclip-workspace/.paperclip-runtime",
|
|
"755",
|
|
);
|
|
expect(sandbox.fs.uploadFile).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
"/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
300,
|
|
);
|
|
});
|
|
|
|
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();
|
|
sandbox.getWorkDir.mockRejectedValue(new Error("workdir lookup failed"));
|
|
mockCreate.mockResolvedValue(sandbox);
|
|
|
|
await expect(
|
|
plugin.definition.onEnvironmentAcquireLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
runId: "run-1",
|
|
config: {
|
|
image: "node:20",
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
}),
|
|
).rejects.toThrow("workdir lookup failed");
|
|
|
|
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("falls back to sh metadata when bash is not present in the sandbox image", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox();
|
|
sandbox.process.executeCommand.mockResolvedValue({
|
|
exitCode: 0,
|
|
result: "sh",
|
|
artifacts: { stdout: "sh" },
|
|
});
|
|
mockCreate.mockResolvedValue(sandbox);
|
|
|
|
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
runId: "run-1",
|
|
config: {
|
|
image: "busybox:latest",
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(lease).toMatchObject({
|
|
metadata: {
|
|
shellCommand: "sh",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("deletes the sandbox if resume setup throws after the sandbox starts", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox({ id: "sandbox-resume", state: "stopped" });
|
|
sandbox.getWorkDir.mockRejectedValue(new Error("workdir lookup failed"));
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
await expect(
|
|
plugin.definition.onEnvironmentResumeLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-resume",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
}),
|
|
).rejects.toThrow("workdir lookup failed");
|
|
|
|
expect(sandbox.start).toHaveBeenCalled();
|
|
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("marks missing reusable leases as expired on resume", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("missing"));
|
|
|
|
await expect(plugin.definition.onEnvironmentResumeLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-123",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
})).resolves.toEqual({
|
|
providerLeaseId: null,
|
|
metadata: { expired: true },
|
|
});
|
|
});
|
|
|
|
it("resumes a reusable lease when the workspace sentinel matches", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox({ id: "sandbox-reuse", state: "stopped" });
|
|
sandbox.process.executeCommand
|
|
.mockResolvedValueOnce({
|
|
exitCode: 0,
|
|
result: JSON.stringify({ token: "sentinel-token" }),
|
|
artifacts: { stdout: JSON.stringify({ token: "sentinel-token" }) },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
exitCode: 0,
|
|
result: "bash",
|
|
artifacts: { stdout: "bash" },
|
|
});
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-reuse",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
leaseMetadata: {
|
|
workspaceSentinel: {
|
|
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
token: "sentinel-token",
|
|
result: "written",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sandbox.start).toHaveBeenCalledWith(300);
|
|
expect(lease).toMatchObject({
|
|
providerLeaseId: "sandbox-reuse",
|
|
metadata: {
|
|
resumedLease: true,
|
|
workspaceSentinel: {
|
|
result: "matched",
|
|
token: "sentinel-token",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("expires a reusable lease when the workspace sentinel does not match", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox({ id: "sandbox-reuse", state: "stopped" });
|
|
sandbox.process.executeCommand.mockResolvedValueOnce({
|
|
exitCode: 0,
|
|
result: JSON.stringify({ token: "other-token" }),
|
|
artifacts: { stdout: JSON.stringify({ token: "other-token" }) },
|
|
});
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
await expect(plugin.definition.onEnvironmentResumeLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-reuse",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
leaseMetadata: {
|
|
workspaceSentinel: {
|
|
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
token: "sentinel-token",
|
|
result: "written",
|
|
},
|
|
},
|
|
})).resolves.toEqual({
|
|
providerLeaseId: null,
|
|
metadata: {
|
|
expired: true,
|
|
workspaceSentinel: {
|
|
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
token: "sentinel-token",
|
|
result: "mismatch",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("stops reusable leases and deletes ephemeral leases on release", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const reusable = createMockSandbox({ id: "sandbox-reusable" });
|
|
const ephemeral = createMockSandbox({ id: "sandbox-ephemeral" });
|
|
mockGet.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
|
|
|
|
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-reusable",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-ephemeral",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: false,
|
|
},
|
|
});
|
|
|
|
expect(reusable.stop).toHaveBeenCalledWith(300);
|
|
expect(reusable.delete).not.toHaveBeenCalled();
|
|
expect(ephemeral.delete).toHaveBeenCalledWith(300);
|
|
});
|
|
|
|
it("falls back to delete when stopping a reusable lease from an error state fails", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const errored = createMockSandbox({ id: "sandbox-error", state: "error" });
|
|
errored.stop.mockRejectedValueOnce(new Error("stop failed"));
|
|
mockGet.mockResolvedValue(errored);
|
|
|
|
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-error",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(errored.stop).toHaveBeenCalledWith(300);
|
|
expect(errored.delete).toHaveBeenCalledWith(300);
|
|
});
|
|
|
|
it("falls back to delete when stopping a healthy reusable lease fails mid-call", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox({ id: "sandbox-running", state: "started" });
|
|
sandbox.stop.mockRejectedValueOnce(new Error("api timeout"));
|
|
mockGet.mockResolvedValue(sandbox);
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
|
|
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
providerLeaseId: "sandbox-running",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: true,
|
|
},
|
|
});
|
|
|
|
expect(sandbox.stop).toHaveBeenCalledWith(300);
|
|
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it("executes commands one-shot and returns combined output via stdout", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox();
|
|
sandbox.process.executeCommand.mockResolvedValue({
|
|
exitCode: 7,
|
|
result: "stdout\nstderr\n",
|
|
artifacts: { stdout: "stdout\nstderr\n" },
|
|
});
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: false,
|
|
},
|
|
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
command: "printf",
|
|
args: ["hello"],
|
|
cwd: "/workspace",
|
|
env: { FOO: "bar" },
|
|
timeoutMs: 1000,
|
|
});
|
|
|
|
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
const [command, cwdArg, envArg, timeoutArg] = sandbox.process.executeCommand.mock.calls[0] as [string, unknown, unknown, number];
|
|
expect(command).toMatch(/\/etc\/profile/);
|
|
expect(command).toMatch(/"\$HOME\/\.profile"/);
|
|
expect(command).toMatch(/cd '\/workspace'/);
|
|
expect(command).toMatch(/&& env FOO='bar' 'printf' 'hello'$/);
|
|
expect(command).not.toMatch(/(?:^|&& )exec /);
|
|
// cwd/env are baked into the login-shell command itself; we pass undefined
|
|
// to the SDK so it doesn't run the cd before profile sourcing.
|
|
expect(cwdArg).toBeUndefined();
|
|
expect(envArg).toBeUndefined();
|
|
expect(timeoutArg).toBe(1);
|
|
expect(result).toEqual({
|
|
exitCode: 7,
|
|
timedOut: false,
|
|
stdout: "stdout\nstderr\n",
|
|
stderr: "",
|
|
});
|
|
});
|
|
|
|
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox();
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: false,
|
|
},
|
|
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
command: "cat",
|
|
args: [],
|
|
cwd: "/workspace",
|
|
stdin: "input payload",
|
|
timeoutMs: 1000,
|
|
});
|
|
|
|
expect(sandbox.fs.uploadFile).toHaveBeenCalledWith(
|
|
Buffer.from("input payload", "utf8"),
|
|
expect.stringMatching(/^\/tmp\/paperclip-stdin-/),
|
|
1,
|
|
);
|
|
const [command] = sandbox.process.executeCommand.mock.calls[0] as [string];
|
|
expect(command).toMatch(/\/etc\/profile/);
|
|
expect(command).toMatch(/cd '\/workspace'/);
|
|
expect(command).toMatch(/&& 'cat' < '\/tmp\/paperclip-stdin-/);
|
|
expect(command).not.toMatch(/(?:^|&& )exec /);
|
|
expect(sandbox.fs.deleteFile).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
|
|
expect(result).toMatchObject({
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
});
|
|
});
|
|
|
|
it("rejects invalid shell env keys before execution", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox();
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
await expect(plugin.definition.onEnvironmentExecute?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: false,
|
|
},
|
|
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
command: "printf",
|
|
args: ["hello"],
|
|
env: { "BAD-KEY": "bar" },
|
|
})).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
|
|
|
|
expect(sandbox.process.executeCommand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns a timed out execute result when the Daytona SDK times out", async () => {
|
|
process.env.DAYTONA_API_KEY = "host-key";
|
|
const sandbox = createMockSandbox();
|
|
sandbox.process.executeCommand.mockRejectedValue(new MockDaytonaTimeoutError("command timed out"));
|
|
mockGet.mockResolvedValue(sandbox);
|
|
|
|
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
driverKey: "daytona",
|
|
companyId: "company-1",
|
|
environmentId: "env-1",
|
|
config: {
|
|
timeoutMs: 300000,
|
|
reuseLease: false,
|
|
},
|
|
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
command: "sleep",
|
|
args: ["60"],
|
|
cwd: "/workspace",
|
|
timeoutMs: 1000,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
exitCode: null,
|
|
timedOut: true,
|
|
stdout: "",
|
|
stderr: "command timed out\n",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("daytona manifest memory config", () => {
|
|
const memorySchema = (
|
|
manifest.environmentDrivers?.[0]?.configSchema as {
|
|
properties?: Record<string, { type?: string; enum?: unknown[] }>;
|
|
required?: string[];
|
|
}
|
|
);
|
|
|
|
it("offers memory as a fixed dropdown of supported sandbox sizes", () => {
|
|
expect(memorySchema.properties?.memory?.enum).toEqual([1, 2, 4, 8]);
|
|
});
|
|
|
|
it("excludes 0 — an invalid Daytona memory configuration", () => {
|
|
expect(memorySchema.properties?.memory?.enum).not.toContain(0);
|
|
});
|
|
|
|
it("keeps memory optional so the blank/default selection stays valid", () => {
|
|
expect(memorySchema.required ?? []).not.toContain("memory");
|
|
});
|
|
});
|