@tickernelz/paperclip-pro-plugin-daytona 2026.925.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/duplex-command-stream.d.ts +97 -0
- package/dist/duplex-command-stream.d.ts.map +1 -0
- package/dist/duplex-command-stream.js +205 -0
- package/dist/duplex-command-stream.js.map +1 -0
- package/dist/duplex-command-stream.live.test.d.ts +2 -0
- package/dist/duplex-command-stream.live.test.d.ts.map +1 -0
- package/dist/duplex-command-stream.live.test.js +324 -0
- package/dist/duplex-command-stream.live.test.js.map +1 -0
- package/dist/duplex-command-stream.test.d.ts +2 -0
- package/dist/duplex-command-stream.test.d.ts.map +1 -0
- package/dist/duplex-command-stream.test.js +519 -0
- package/dist/duplex-command-stream.test.js.map +1 -0
- package/dist/file-sync.d.ts +77 -0
- package/dist/file-sync.d.ts.map +1 -0
- package/dist/file-sync.js +1055 -0
- package/dist/file-sync.js.map +1 -0
- package/dist/file-sync.test.d.ts +2 -0
- package/dist/file-sync.test.d.ts.map +1 -0
- package/dist/file-sync.test.js +974 -0
- package/dist/file-sync.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/login-pty.d.ts +162 -0
- package/dist/login-pty.d.ts.map +1 -0
- package/dist/login-pty.js +258 -0
- package/dist/login-pty.js.map +1 -0
- package/dist/login-pty.test.d.ts +2 -0
- package/dist/login-pty.test.d.ts.map +1 -0
- package/dist/login-pty.test.js +319 -0
- package/dist/login-pty.test.js.map +1 -0
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +179 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugin.d.ts +49 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +2563 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugin.test.d.ts +2 -0
- package/dist/plugin.test.d.ts.map +1 -0
- package/dist/plugin.test.js +4701 -0
- package/dist/plugin.test.js.map +1 -0
- package/dist/pty-chunked-input.d.ts +48 -0
- package/dist/pty-chunked-input.d.ts.map +1 -0
- package/dist/pty-chunked-input.js +74 -0
- package/dist/pty-chunked-input.js.map +1 -0
- package/dist/pty-chunked-input.test.d.ts +2 -0
- package/dist/pty-chunked-input.test.d.ts.map +1 -0
- package/dist/pty-chunked-input.test.js +115 -0
- package/dist/pty-chunked-input.test.js.map +1 -0
- package/dist/worker.d.ts +3 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +5 -0
- package/dist/worker.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,4701 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
const mockCreate = vi.hoisted(() => vi.fn());
|
|
7
|
+
const mockGet = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockSnapshotGet = vi.hoisted(() => vi.fn());
|
|
9
|
+
const mockSnapshotDelete = vi.hoisted(() => vi.fn());
|
|
10
|
+
const { MockDaytonaNotFoundError, MockDaytonaTimeoutError } = vi.hoisted(() => {
|
|
11
|
+
class MockDaytonaNotFoundError extends Error {
|
|
12
|
+
}
|
|
13
|
+
class MockDaytonaTimeoutError extends Error {
|
|
14
|
+
}
|
|
15
|
+
return { MockDaytonaNotFoundError, MockDaytonaTimeoutError };
|
|
16
|
+
});
|
|
17
|
+
vi.mock("@daytonaio/sdk", () => ({
|
|
18
|
+
Daytona: class MockDaytona {
|
|
19
|
+
create = mockCreate;
|
|
20
|
+
get = mockGet;
|
|
21
|
+
snapshot = {
|
|
22
|
+
get: mockSnapshotGet,
|
|
23
|
+
delete: mockSnapshotDelete,
|
|
24
|
+
};
|
|
25
|
+
constructor(_config) { }
|
|
26
|
+
},
|
|
27
|
+
DaytonaNotFoundError: MockDaytonaNotFoundError,
|
|
28
|
+
DaytonaTimeoutError: MockDaytonaTimeoutError,
|
|
29
|
+
}));
|
|
30
|
+
import plugin, { setDaytonaTimingClockForTest, setDaytonaHandleFreshnessClockForTest, __resetDaytonaSandboxHandleCacheForTest, __getDaytonaWritableDirsForTest, __setDaytonaPluginContextForTest, } from "./plugin.js";
|
|
31
|
+
import manifest from "./manifest.js";
|
|
32
|
+
import { parseTarVerboseListingLine, splitLinkEntryOnce } from "./file-sync.js";
|
|
33
|
+
function createMockSandbox(overrides = {}) {
|
|
34
|
+
return {
|
|
35
|
+
id: overrides.id ?? "sandbox-123",
|
|
36
|
+
name: overrides.name ?? "paperclip-sandbox",
|
|
37
|
+
state: overrides.state ?? "started",
|
|
38
|
+
recoverable: overrides.recoverable ?? false,
|
|
39
|
+
target: "us",
|
|
40
|
+
errorReason: null,
|
|
41
|
+
// A configured provider TTL populates `autoDestroyAt` after `setTtl` +
|
|
42
|
+
// `refreshData`. The default mock leaves it unset (no TTL configured).
|
|
43
|
+
autoDestroyAt: overrides.autoDestroyAt ?? undefined,
|
|
44
|
+
updatedAt: overrides.updatedAt,
|
|
45
|
+
getWorkDir: vi.fn().mockResolvedValue(overrides.workDir ?? "/home/daytona"),
|
|
46
|
+
getUserHomeDir: vi.fn().mockResolvedValue("/home/daytona"),
|
|
47
|
+
start: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
recover: vi.fn().mockResolvedValue(undefined),
|
|
50
|
+
// Real `refreshData` re-reads live provider state and mutates `state` in
|
|
51
|
+
// place; the default mock leaves state untouched, and tests that exercise a
|
|
52
|
+
// provider-initiated auto-stop override it to flip `state` to "stopped".
|
|
53
|
+
refreshData: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
resize: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
archive: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
setTtl: vi.fn().mockResolvedValue(undefined),
|
|
58
|
+
setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined),
|
|
59
|
+
createSshAccess: vi.fn().mockResolvedValue({
|
|
60
|
+
token: "ssh-token-secret",
|
|
61
|
+
command: "ssh ssh-token-secret@ssh.app.daytona.io",
|
|
62
|
+
}),
|
|
63
|
+
getPreviewLink: vi.fn().mockResolvedValue({
|
|
64
|
+
url: "https://43127-sandbox-123.proxy.daytona.test",
|
|
65
|
+
token: "preview-token-secret",
|
|
66
|
+
}),
|
|
67
|
+
_experimental_createSnapshot: vi.fn().mockResolvedValue(undefined),
|
|
68
|
+
fs: {
|
|
69
|
+
createFolder: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
uploadFile: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
deleteFile: vi.fn().mockResolvedValue(undefined),
|
|
72
|
+
// Native batch file-transfer primitives (Daytona SDK 0.171.0). Extended
|
|
73
|
+
// here so the sync hooks can be exercised without a real SDK install.
|
|
74
|
+
uploadFiles: vi.fn().mockResolvedValue(undefined),
|
|
75
|
+
downloadFiles: vi.fn().mockResolvedValue([]),
|
|
76
|
+
setFilePermissions: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
},
|
|
78
|
+
process: {
|
|
79
|
+
executeCommand: vi.fn().mockResolvedValue({
|
|
80
|
+
exitCode: 0,
|
|
81
|
+
result: "bash",
|
|
82
|
+
artifacts: { stdout: "bash" },
|
|
83
|
+
}),
|
|
84
|
+
// Session API (Daytona SDK 0.203.0). The exec hook opens one session per
|
|
85
|
+
// lease and dispatches every command into it. `executeSessionCommand`
|
|
86
|
+
// returns a `cmdId`; `getSessionCommand` reports the exit code; and
|
|
87
|
+
// `getSessionCommandLogs` returns separated `stdout` and `stderr`.
|
|
88
|
+
createSession: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
executeSessionCommand: vi.fn().mockResolvedValue({ cmdId: "cmd-1" }),
|
|
90
|
+
getSessionCommand: vi.fn().mockResolvedValue({ id: "cmd-1", command: "", exitCode: 0 }),
|
|
91
|
+
getSessionCommandLogs: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
|
92
|
+
deleteSession: vi.fn().mockResolvedValue(undefined),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
describe("Daytona sandbox provider plugin", () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
mockCreate.mockReset();
|
|
99
|
+
mockGet.mockReset();
|
|
100
|
+
mockSnapshotGet.mockReset();
|
|
101
|
+
mockSnapshotDelete.mockReset();
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
delete process.env.DAYTONA_API_KEY;
|
|
104
|
+
// The started-sandbox handle cache is process-scoped; clear it between tests
|
|
105
|
+
// so a handle memoized under a reused composite key never leaks forward.
|
|
106
|
+
__resetDaytonaSandboxHandleCacheForTest();
|
|
107
|
+
});
|
|
108
|
+
it("declares environment lifecycle handlers", async () => {
|
|
109
|
+
expect(await plugin.definition.onHealth?.()).toEqual({
|
|
110
|
+
status: "ok",
|
|
111
|
+
message: "Daytona sandbox provider plugin healthy",
|
|
112
|
+
});
|
|
113
|
+
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
|
|
114
|
+
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
|
|
115
|
+
expect(plugin.definition.onEnvironmentStartInteractiveSetup).toBeTypeOf("function");
|
|
116
|
+
expect(plugin.definition.onEnvironmentCaptureTemplate).toBeTypeOf("function");
|
|
117
|
+
expect(manifest.environmentDrivers?.[0]).toMatchObject({
|
|
118
|
+
supportsInteractiveSetup: true,
|
|
119
|
+
interactiveSetupConnectionTypes: ["ssh"],
|
|
120
|
+
supportsTemplateCapture: true,
|
|
121
|
+
templateRefKind: "snapshot",
|
|
122
|
+
supportsTemplateDelete: true,
|
|
123
|
+
// Daytona streams incremental session output, so it declares the opt-in
|
|
124
|
+
// capability that selects the session-output streaming path.
|
|
125
|
+
sandboxCapabilities: { incrementalSessionOutput: true },
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
it("declares the concurrent-sync-operations capability so the host may parallelize sync operations", () => {
|
|
129
|
+
// Daytona runs file transfers into and out of the sandbox in parallel, so it
|
|
130
|
+
// declares the opt-in capability. The host resolves it `true` only when the
|
|
131
|
+
// worker also verifies both sync verbs, which the sync hooks provide.
|
|
132
|
+
expect(manifest.environmentDrivers?.[0]?.sandboxCapabilities).toMatchObject({
|
|
133
|
+
concurrentSyncOperations: true,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
it("declares the duplex-command-stream capability and the four channel handlers", () => {
|
|
137
|
+
// Daytona carries the callback bridge on one duplex channel, so it declares
|
|
138
|
+
// the opt-in capability. The host resolves it `true` only when the worker also
|
|
139
|
+
// verifies the `duplexChannelOpen` handler, which the four handlers provide.
|
|
140
|
+
expect(manifest.environmentDrivers?.[0]?.sandboxCapabilities).toMatchObject({
|
|
141
|
+
duplexCommandStream: true,
|
|
142
|
+
});
|
|
143
|
+
expect(plugin.definition.onDuplexChannelOpen).toBeTypeOf("function");
|
|
144
|
+
expect(plugin.definition.onDuplexChannelWrite).toBeTypeOf("function");
|
|
145
|
+
expect(plugin.definition.onDuplexChannelStop).toBeTypeOf("function");
|
|
146
|
+
expect(plugin.definition.onDuplexChannelClose).toBeTypeOf("function");
|
|
147
|
+
});
|
|
148
|
+
it("declares and returns private authenticated runner WebSocket ingress", async () => {
|
|
149
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
150
|
+
const sandbox = createMockSandbox();
|
|
151
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
152
|
+
const base = {
|
|
153
|
+
driverKey: "daytona",
|
|
154
|
+
companyId: "company-1",
|
|
155
|
+
environmentId: "env-1",
|
|
156
|
+
config: { image: "node:20", timeoutMs: 300_000, reuseLease: false },
|
|
157
|
+
};
|
|
158
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
159
|
+
...base,
|
|
160
|
+
runId: "00000000-0000-4000-8000-000000000001",
|
|
161
|
+
});
|
|
162
|
+
const endpoint = await plugin.definition.onEnvironmentRunnerIngressEndpoint?.({
|
|
163
|
+
...base,
|
|
164
|
+
lease: lease,
|
|
165
|
+
port: 43_127,
|
|
166
|
+
path: "/api/runner/v1/connect/00000000-0000-4000-8000-000000000001",
|
|
167
|
+
});
|
|
168
|
+
expect(manifest.environmentDrivers?.[0]?.sandboxCapabilities).toMatchObject({
|
|
169
|
+
runnerWebSocketIngress: true,
|
|
170
|
+
});
|
|
171
|
+
expect(endpoint).toMatchObject({
|
|
172
|
+
kind: "authenticated_websocket",
|
|
173
|
+
websocketUrl: "wss://43127-sandbox-123.proxy.daytona.test/api/runner/v1/connect/00000000-0000-4000-8000-000000000001",
|
|
174
|
+
secretHeaders: [
|
|
175
|
+
{ name: "X-Daytona-Preview-Token", value: "preview-token-secret" },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
expect(endpoint?.websocketUrl).not.toContain("preview-token-secret");
|
|
179
|
+
expect(endpoint?.websocketUrl).not.toContain("host-key");
|
|
180
|
+
});
|
|
181
|
+
it("keeps ingress generation independent of token rotation and changes it after a sandbox lifecycle revision", async () => {
|
|
182
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
183
|
+
const sandbox = createMockSandbox({ updatedAt: "2026-08-25T10:00:00.000Z" });
|
|
184
|
+
sandbox.getPreviewLink
|
|
185
|
+
.mockResolvedValueOnce({
|
|
186
|
+
url: "https://43127-sandbox-123.proxy.daytona.test",
|
|
187
|
+
token: "preview-token-1",
|
|
188
|
+
})
|
|
189
|
+
.mockResolvedValueOnce({
|
|
190
|
+
url: "https://43127-sandbox-123.proxy.daytona.test",
|
|
191
|
+
token: "preview-token-2",
|
|
192
|
+
})
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
url: "https://43127-sandbox-123.proxy.daytona.test",
|
|
195
|
+
token: "preview-token-3",
|
|
196
|
+
});
|
|
197
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
198
|
+
const base = {
|
|
199
|
+
driverKey: "daytona",
|
|
200
|
+
companyId: "company-1",
|
|
201
|
+
environmentId: "env-1",
|
|
202
|
+
config: { image: "node:20", timeoutMs: 300_000, reuseLease: false },
|
|
203
|
+
};
|
|
204
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
205
|
+
...base,
|
|
206
|
+
runId: "00000000-0000-4000-8000-000000000001",
|
|
207
|
+
});
|
|
208
|
+
const request = {
|
|
209
|
+
...base,
|
|
210
|
+
lease: lease,
|
|
211
|
+
port: 43_127,
|
|
212
|
+
path: "/api/runner/v1/connect/00000000-0000-4000-8000-000000000001",
|
|
213
|
+
};
|
|
214
|
+
const first = await plugin.definition.onEnvironmentRunnerIngressEndpoint?.(request);
|
|
215
|
+
const second = await plugin.definition.onEnvironmentRunnerIngressEndpoint?.(request);
|
|
216
|
+
expect(second?.generation).toBe(first?.generation);
|
|
217
|
+
expect(second?.secretHeaders).not.toEqual(first?.secretHeaders);
|
|
218
|
+
sandbox.updatedAt = "2026-08-25T10:05:00.000Z";
|
|
219
|
+
const restarted = await plugin.definition.onEnvironmentRunnerIngressEndpoint?.(request);
|
|
220
|
+
expect(restarted?.generation).not.toBe(first?.generation);
|
|
221
|
+
});
|
|
222
|
+
it("bumps the plugin version so the server reconciles the stored manifest", () => {
|
|
223
|
+
// The bundled-plugin boot reconcile refreshes the stored manifest for an
|
|
224
|
+
// existing install only when the version changes. The duplex capability needs
|
|
225
|
+
// the bump to reach an existing install.
|
|
226
|
+
expect(manifest.version).toBe("0.1.7");
|
|
227
|
+
});
|
|
228
|
+
it.each([false, true])("closes duplex routes on lease release even when bridge drain hangs: %s", async (hangDrain) => {
|
|
229
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
230
|
+
// A fake PTY handle records each host write, drives the data stream on demand,
|
|
231
|
+
// and records the kill and the disconnect.
|
|
232
|
+
const inputs = [];
|
|
233
|
+
let killed = 0;
|
|
234
|
+
let disconnected = 0;
|
|
235
|
+
let ptyOnData = null;
|
|
236
|
+
const handle = {
|
|
237
|
+
async waitForConnection() { },
|
|
238
|
+
async sendInput(data) {
|
|
239
|
+
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
240
|
+
},
|
|
241
|
+
wait() {
|
|
242
|
+
return new Promise(() => { });
|
|
243
|
+
},
|
|
244
|
+
async kill() {
|
|
245
|
+
killed += 1;
|
|
246
|
+
},
|
|
247
|
+
async disconnect() {
|
|
248
|
+
disconnected += 1;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
const sandbox = createMockSandbox();
|
|
252
|
+
sandbox.process.createPty = vi.fn(async (options) => {
|
|
253
|
+
ptyOnData = options.onData;
|
|
254
|
+
return handle;
|
|
255
|
+
});
|
|
256
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
257
|
+
// Capture the data and the exit the worker forwards through `ctx.duplexChannel`.
|
|
258
|
+
// `ctx.duplexChannel.data` carries raw bytes; decode each chunk to text so the
|
|
259
|
+
// assertion below reads the plain-text payload.
|
|
260
|
+
const dataChunks = [];
|
|
261
|
+
const restore = __setDaytonaPluginContextForTest({
|
|
262
|
+
duplexChannel: {
|
|
263
|
+
data: (hostRouteId, workerSessionId, chunk) => dataChunks.push({ hostRouteId, workerSessionId, chunk: Buffer.from(chunk).toString("utf8") }),
|
|
264
|
+
exit: () => { },
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
let finishBlocked;
|
|
268
|
+
let blocked;
|
|
269
|
+
try {
|
|
270
|
+
await plugin.definition.onEnvironmentAcquireLease?.({
|
|
271
|
+
driverKey: "daytona",
|
|
272
|
+
companyId: "company-1",
|
|
273
|
+
environmentId: "env-1",
|
|
274
|
+
runId: "run-1",
|
|
275
|
+
agentId: "agent-1",
|
|
276
|
+
executionWorkspaceId: "workspace-1",
|
|
277
|
+
adapterType: "codex_local",
|
|
278
|
+
config: { image: "node:20", timeoutMs: 300000, livenessTimeoutMs: 5, reuseLease: true },
|
|
279
|
+
});
|
|
280
|
+
const open = await plugin.definition.onDuplexChannelOpen?.({
|
|
281
|
+
hostRouteId: "route-1",
|
|
282
|
+
driverKey: "daytona",
|
|
283
|
+
companyId: "company-1",
|
|
284
|
+
environmentId: "env-1",
|
|
285
|
+
providerLeaseId: "sandbox-123",
|
|
286
|
+
command: ["node", "/paperclip/gateway.mjs"],
|
|
287
|
+
});
|
|
288
|
+
expect(open?.workerSessionId).toMatch(/^duplex-/);
|
|
289
|
+
// The open reply echoes the host route id, so the host binds the exact pair.
|
|
290
|
+
expect(open?.hostRouteId).toBe("route-1");
|
|
291
|
+
const workerSessionId = open?.workerSessionId ?? "";
|
|
292
|
+
// The launch wrapper sets raw mode with echo off and redirects diagnostics.
|
|
293
|
+
// It quotes each command argument and the diagnostics path as a shell word.
|
|
294
|
+
expect(inputs[0]).toContain("stty raw -echo");
|
|
295
|
+
expect(inputs[0]).toContain("exec 'node' '/paperclip/gateway.mjs'");
|
|
296
|
+
expect(inputs[0]).toMatch(/2>'\/tmp\/paperclip-duplex-.+\.log'/);
|
|
297
|
+
// A host write on the exact pair reaches the process on the same channel.
|
|
298
|
+
// `data` arrives in the wire-safe base64 form (see `ChannelBytesWireValue`
|
|
299
|
+
// in the plugin SDK's protocol.ts).
|
|
300
|
+
await plugin.definition.onDuplexChannelWrite?.({
|
|
301
|
+
hostRouteId: "route-1",
|
|
302
|
+
workerSessionId,
|
|
303
|
+
data: Buffer.from('{"version":1,"type":"heartbeat"}\n', "utf8").toString("base64"),
|
|
304
|
+
});
|
|
305
|
+
expect(inputs[1]).toBe('{"version":1,"type":"heartbeat"}\n');
|
|
306
|
+
// A write whose pair does not match the bound entry applies no bytes. The
|
|
307
|
+
// worker acts only on the exact live pair.
|
|
308
|
+
const inputsBeforeForeign = inputs.length;
|
|
309
|
+
await plugin.definition.onDuplexChannelWrite?.({
|
|
310
|
+
hostRouteId: "route-foreign",
|
|
311
|
+
workerSessionId,
|
|
312
|
+
data: Buffer.from("foreign\n", "utf8").toString("base64"),
|
|
313
|
+
});
|
|
314
|
+
expect(inputs.length).toBe(inputsBeforeForeign);
|
|
315
|
+
// A stop whose pair does not match the bound entry stops nothing.
|
|
316
|
+
const killedBeforeForeign = killed;
|
|
317
|
+
await plugin.definition.onDuplexChannelStop?.({
|
|
318
|
+
hostRouteId: "route-foreign",
|
|
319
|
+
workerSessionId,
|
|
320
|
+
});
|
|
321
|
+
expect(killed).toBe(killedBeforeForeign);
|
|
322
|
+
// Process output reaches the host as a data notification bound to the exact
|
|
323
|
+
// pair, so it echoes the host route id and the worker session id.
|
|
324
|
+
ptyOnData?.(new TextEncoder().encode('{"version":1,"type":"ready","address":"127.0.0.1:1"}\n'));
|
|
325
|
+
expect(dataChunks).toEqual([
|
|
326
|
+
{
|
|
327
|
+
hostRouteId: "route-1",
|
|
328
|
+
workerSessionId,
|
|
329
|
+
chunk: '{"version":1,"type":"ready","address":"127.0.0.1:1"}\n',
|
|
330
|
+
},
|
|
331
|
+
]);
|
|
332
|
+
if (hangDrain) {
|
|
333
|
+
sandbox.process.executeCommand.mockImplementationOnce(async () => {
|
|
334
|
+
await new Promise(resolve => { finishBlocked = resolve; });
|
|
335
|
+
return { exitCode: 0, result: "done", artifacts: { stdout: "done" } };
|
|
336
|
+
});
|
|
337
|
+
blocked = plugin.definition.onEnvironmentExecute?.({ driverKey: "daytona", companyId: "company-1",
|
|
338
|
+
environmentId: "env-1", config: { timeoutMs: 300000 }, bypassSession: true,
|
|
339
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} }, command: "printf", args: ["done"] });
|
|
340
|
+
await vi.waitFor(() => expect(finishBlocked).toBeTypeOf("function"));
|
|
341
|
+
}
|
|
342
|
+
sandbox.stop.mockImplementation(async () => { expect(disconnected).toBe(1); });
|
|
343
|
+
// Route invalidation must happen before provider stop, including after drain timeout.
|
|
344
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
345
|
+
driverKey: "daytona",
|
|
346
|
+
companyId: "company-1",
|
|
347
|
+
environmentId: "env-1",
|
|
348
|
+
providerLeaseId: "sandbox-123",
|
|
349
|
+
config: { image: "node:20", timeoutMs: 300000, livenessTimeoutMs: 5, reuseLease: true },
|
|
350
|
+
});
|
|
351
|
+
expect(killed).toBeGreaterThanOrEqual(1);
|
|
352
|
+
expect(disconnected).toBe(1);
|
|
353
|
+
// The channel entry is gone, so a later write is a no-op and reaches no
|
|
354
|
+
// process.
|
|
355
|
+
const inputsBefore = inputs.length;
|
|
356
|
+
await plugin.definition.onDuplexChannelWrite?.({
|
|
357
|
+
hostRouteId: "route-1",
|
|
358
|
+
workerSessionId,
|
|
359
|
+
data: Buffer.from("late\n", "utf8").toString("base64"),
|
|
360
|
+
});
|
|
361
|
+
expect(inputs.length).toBe(inputsBefore);
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
finishBlocked?.();
|
|
365
|
+
await blocked;
|
|
366
|
+
restore();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
it("normalizes config and validates the API key fallback", async () => {
|
|
370
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
371
|
+
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
|
372
|
+
driverKey: "daytona",
|
|
373
|
+
config: {
|
|
374
|
+
apiKey: " explicit-key ",
|
|
375
|
+
apiUrl: " https://app.daytona.io/api ",
|
|
376
|
+
target: " us ",
|
|
377
|
+
snapshot: " base-snapshot ",
|
|
378
|
+
language: " typescript ",
|
|
379
|
+
timeoutMs: "450000.9",
|
|
380
|
+
autoStopInterval: "15",
|
|
381
|
+
autoArchiveInterval: "60",
|
|
382
|
+
autoDeleteInterval: "-1",
|
|
383
|
+
reuseLease: true,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
expect(result).toEqual({
|
|
387
|
+
ok: true,
|
|
388
|
+
normalizedConfig: {
|
|
389
|
+
apiKey: "explicit-key",
|
|
390
|
+
apiUrl: "https://app.daytona.io/api",
|
|
391
|
+
target: "us",
|
|
392
|
+
snapshot: "base-snapshot",
|
|
393
|
+
image: null,
|
|
394
|
+
language: "typescript",
|
|
395
|
+
timeoutMs: 450000,
|
|
396
|
+
livenessTimeoutMs: 30000,
|
|
397
|
+
cpu: null,
|
|
398
|
+
memory: null,
|
|
399
|
+
disk: null,
|
|
400
|
+
gpu: null,
|
|
401
|
+
autoStopInterval: 15,
|
|
402
|
+
autoArchiveInterval: 60,
|
|
403
|
+
autoDeleteInterval: -1,
|
|
404
|
+
reuseLease: true,
|
|
405
|
+
archiveOnRelease: false,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
it("applies quota-safety auto-stop/archive/delete defaults when unset", async () => {
|
|
410
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
411
|
+
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
|
412
|
+
driverKey: "daytona",
|
|
413
|
+
config: {
|
|
414
|
+
snapshot: "base-snapshot",
|
|
415
|
+
timeoutMs: 300000,
|
|
416
|
+
reuseLease: true,
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
expect(result).toMatchObject({
|
|
420
|
+
ok: true,
|
|
421
|
+
normalizedConfig: {
|
|
422
|
+
autoStopInterval: 15,
|
|
423
|
+
autoArchiveInterval: 60,
|
|
424
|
+
autoDeleteInterval: 10080,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
it("preserves an explicit 0/-1 to disable auto intervals", async () => {
|
|
429
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
430
|
+
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
|
431
|
+
driverKey: "daytona",
|
|
432
|
+
config: {
|
|
433
|
+
snapshot: "base-snapshot",
|
|
434
|
+
timeoutMs: 300000,
|
|
435
|
+
autoStopInterval: 0,
|
|
436
|
+
autoArchiveInterval: 0,
|
|
437
|
+
autoDeleteInterval: -1,
|
|
438
|
+
reuseLease: true,
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
expect(result).toMatchObject({
|
|
442
|
+
ok: true,
|
|
443
|
+
normalizedConfig: {
|
|
444
|
+
autoStopInterval: 0,
|
|
445
|
+
autoArchiveInterval: 0,
|
|
446
|
+
autoDeleteInterval: -1,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
it("forwards auto-archive/auto-delete defaults to the Daytona create call", async () => {
|
|
451
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
452
|
+
const sandbox = createMockSandbox();
|
|
453
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
454
|
+
await plugin.definition.onEnvironmentAcquireLease?.({
|
|
455
|
+
driverKey: "daytona",
|
|
456
|
+
companyId: "company-1",
|
|
457
|
+
environmentId: "env-1",
|
|
458
|
+
runId: "run-1",
|
|
459
|
+
config: {
|
|
460
|
+
image: "node:20",
|
|
461
|
+
timeoutMs: 300000,
|
|
462
|
+
reuseLease: false,
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
const [createParams] = mockCreate.mock.calls[0];
|
|
466
|
+
expect(createParams).toMatchObject({
|
|
467
|
+
autoStopInterval: 15,
|
|
468
|
+
autoArchiveInterval: 60,
|
|
469
|
+
autoDeleteInterval: 10080,
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
it("rejects ambiguous or invalid config", async () => {
|
|
473
|
+
await expect(plugin.definition.onEnvironmentValidateConfig?.({
|
|
474
|
+
driverKey: "daytona",
|
|
475
|
+
config: {
|
|
476
|
+
apiUrl: "not-a-url",
|
|
477
|
+
image: "node:20",
|
|
478
|
+
snapshot: "snapshot-a",
|
|
479
|
+
timeoutMs: 0,
|
|
480
|
+
},
|
|
481
|
+
})).resolves.toEqual({
|
|
482
|
+
ok: false,
|
|
483
|
+
errors: [
|
|
484
|
+
"Daytona sandbox environments must set either image or snapshot, not both.",
|
|
485
|
+
"apiUrl must be a valid URL.",
|
|
486
|
+
"timeoutMs must be between 1 and 86400000.",
|
|
487
|
+
"Daytona sandbox environments require an API key in config or DAYTONA_API_KEY.",
|
|
488
|
+
],
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
it("probes by creating and then deleting a sandbox", async () => {
|
|
492
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
493
|
+
const sandbox = createMockSandbox();
|
|
494
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
495
|
+
const result = await plugin.definition.onEnvironmentProbe?.({
|
|
496
|
+
driverKey: "daytona",
|
|
497
|
+
companyId: "company-1",
|
|
498
|
+
environmentId: "env-1",
|
|
499
|
+
config: {
|
|
500
|
+
snapshot: "base-snapshot",
|
|
501
|
+
timeoutMs: 300000,
|
|
502
|
+
reuseLease: false,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
expect(mockCreate).toHaveBeenCalled();
|
|
506
|
+
expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace", "755");
|
|
507
|
+
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
|
508
|
+
expect(result).toMatchObject({
|
|
509
|
+
ok: true,
|
|
510
|
+
metadata: {
|
|
511
|
+
provider: "daytona",
|
|
512
|
+
shellCommand: "bash",
|
|
513
|
+
sandboxId: "sandbox-123",
|
|
514
|
+
remoteCwd: "/home/daytona/paperclip-workspace",
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
it("acquires a lease from a created sandbox", async () => {
|
|
519
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
520
|
+
const sandbox = createMockSandbox();
|
|
521
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
522
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
523
|
+
driverKey: "daytona",
|
|
524
|
+
companyId: "company-1",
|
|
525
|
+
environmentId: "env-1",
|
|
526
|
+
runId: "run-1",
|
|
527
|
+
agentId: "agent-1",
|
|
528
|
+
executionWorkspaceId: "workspace-1",
|
|
529
|
+
adapterType: "codex_local",
|
|
530
|
+
config: {
|
|
531
|
+
image: "node:20",
|
|
532
|
+
timeoutMs: 300000,
|
|
533
|
+
reuseLease: true,
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
expect(lease).toMatchObject({
|
|
537
|
+
providerLeaseId: "sandbox-123",
|
|
538
|
+
metadata: {
|
|
539
|
+
provider: "daytona",
|
|
540
|
+
shellCommand: "bash",
|
|
541
|
+
sandboxId: "sandbox-123",
|
|
542
|
+
remoteCwd: "/home/daytona/paperclip-workspace",
|
|
543
|
+
reuseLease: true,
|
|
544
|
+
workspaceSentinel: {
|
|
545
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
546
|
+
result: "written",
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace/.paperclip-runtime", "755");
|
|
551
|
+
expect(sandbox.fs.uploadFile).toHaveBeenCalledWith(expect.any(Buffer), "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json", 300);
|
|
552
|
+
});
|
|
553
|
+
it("does not configure a provider ttl when the acquire carries no requested expiry", async () => {
|
|
554
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
555
|
+
const sandbox = createMockSandbox();
|
|
556
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
557
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
558
|
+
driverKey: "daytona",
|
|
559
|
+
companyId: "company-1",
|
|
560
|
+
environmentId: "env-1",
|
|
561
|
+
runId: "run-1",
|
|
562
|
+
config: { image: "node:20", timeoutMs: 300000, reuseLease: false },
|
|
563
|
+
});
|
|
564
|
+
// A generic caller keeps the current behavior: no provider ttl, no expiry.
|
|
565
|
+
expect(sandbox.setTtl).not.toHaveBeenCalled();
|
|
566
|
+
expect(lease?.expiresAt ?? null).toBeNull();
|
|
567
|
+
});
|
|
568
|
+
it("configures a provider ttl at or before the requested expiry and returns the provider expiry", async () => {
|
|
569
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
570
|
+
const autoDestroyAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
|
571
|
+
const sandbox = createMockSandbox({ autoDestroyAt });
|
|
572
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
573
|
+
const requestedExpiresAt = new Date(Date.now() + 30 * 60_000 + 30_000).toISOString();
|
|
574
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
575
|
+
driverKey: "daytona",
|
|
576
|
+
companyId: "company-1",
|
|
577
|
+
environmentId: "env-1",
|
|
578
|
+
runId: "run-1",
|
|
579
|
+
config: { image: "node:20", timeoutMs: 300000, reuseLease: false },
|
|
580
|
+
requestedExpiresAt,
|
|
581
|
+
});
|
|
582
|
+
// The provider ttl is rounded DOWN to whole minutes, so the destroy time
|
|
583
|
+
// never lands after the requested deadline.
|
|
584
|
+
expect(sandbox.setTtl).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(sandbox.setTtl).toHaveBeenCalledWith(30);
|
|
586
|
+
expect(sandbox.refreshData).toHaveBeenCalled();
|
|
587
|
+
// The lease carries the real provider destroy time as evidence of the bound.
|
|
588
|
+
expect(lease?.expiresAt).toBe(autoDestroyAt);
|
|
589
|
+
});
|
|
590
|
+
it("returns no expiry when the requested deadline is less than one minute away", async () => {
|
|
591
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
592
|
+
const sandbox = createMockSandbox({ autoDestroyAt: "must-not-be-read" });
|
|
593
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
594
|
+
const requestedExpiresAt = new Date(Date.now() + 30_000).toISOString();
|
|
595
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
596
|
+
driverKey: "daytona",
|
|
597
|
+
companyId: "company-1",
|
|
598
|
+
environmentId: "env-1",
|
|
599
|
+
runId: "run-1",
|
|
600
|
+
config: { image: "node:20", timeoutMs: 300000, reuseLease: false },
|
|
601
|
+
requestedExpiresAt,
|
|
602
|
+
});
|
|
603
|
+
// Daytona ttl granularity is one minute, so a nearer deadline maps to no
|
|
604
|
+
// valid provider ttl. The provider grants no expiry and the server fails
|
|
605
|
+
// closed on the null expiry.
|
|
606
|
+
expect(sandbox.setTtl).not.toHaveBeenCalled();
|
|
607
|
+
expect(lease?.expiresAt ?? null).toBeNull();
|
|
608
|
+
});
|
|
609
|
+
it("starts an interactive setup sandbox with redacted metadata and one-time SSH payload", async () => {
|
|
610
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
611
|
+
const sandbox = createMockSandbox();
|
|
612
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
613
|
+
const session = await plugin.definition.onEnvironmentStartInteractiveSetup?.({
|
|
614
|
+
driverKey: "daytona",
|
|
615
|
+
companyId: "company-1",
|
|
616
|
+
environmentId: "env-1",
|
|
617
|
+
sessionId: "setup-1",
|
|
618
|
+
sourceTemplateRef: "existing-secret-snapshot",
|
|
619
|
+
sourceTemplateKind: "snapshot",
|
|
620
|
+
connectionExpiresInMinutes: 30,
|
|
621
|
+
config: {
|
|
622
|
+
image: "node:20",
|
|
623
|
+
timeoutMs: 300000,
|
|
624
|
+
reuseLease: false,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
const [createParams] = mockCreate.mock.calls[0];
|
|
628
|
+
expect(createParams).toMatchObject({
|
|
629
|
+
snapshot: "existing-secret-snapshot",
|
|
630
|
+
labels: {
|
|
631
|
+
"paperclip-provider": "daytona",
|
|
632
|
+
"paperclip-setup-session-id": "setup-1",
|
|
633
|
+
"paperclip-purpose": "interactive_setup",
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
expect(createParams).not.toHaveProperty("image");
|
|
637
|
+
expect(sandbox.createSshAccess).toHaveBeenCalledWith(30);
|
|
638
|
+
expect(session).toMatchObject({
|
|
639
|
+
providerLeaseId: "sandbox-123",
|
|
640
|
+
status: "waiting_for_user",
|
|
641
|
+
connectionSummary: {
|
|
642
|
+
type: "ssh",
|
|
643
|
+
username: "token",
|
|
644
|
+
hostRedacted: true,
|
|
645
|
+
portRedacted: true,
|
|
646
|
+
commandRedacted: true,
|
|
647
|
+
},
|
|
648
|
+
connectionPayload: {
|
|
649
|
+
type: "ssh",
|
|
650
|
+
command: "ssh ssh-token-secret@ssh.app.daytona.io",
|
|
651
|
+
token: "ssh-token-secret",
|
|
652
|
+
},
|
|
653
|
+
metadata: {
|
|
654
|
+
provider: "daytona",
|
|
655
|
+
connectionRedacted: true,
|
|
656
|
+
sourceTemplateRefRedacted: true,
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
expect(JSON.stringify(session?.metadata)).not.toContain("ssh-token-secret");
|
|
660
|
+
expect(JSON.stringify(session?.metadata)).not.toContain("existing-secret-snapshot");
|
|
661
|
+
expect(JSON.stringify(session?.connectionSummary)).not.toContain("ssh-token-secret");
|
|
662
|
+
});
|
|
663
|
+
it("starts interactive setup from an image source when the environment is image-backed", async () => {
|
|
664
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
665
|
+
const sandbox = createMockSandbox();
|
|
666
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
667
|
+
await plugin.definition.onEnvironmentStartInteractiveSetup?.({
|
|
668
|
+
driverKey: "daytona",
|
|
669
|
+
companyId: "company-1",
|
|
670
|
+
environmentId: "env-1",
|
|
671
|
+
sessionId: "setup-image-1",
|
|
672
|
+
sourceTemplateRef: "node:20",
|
|
673
|
+
sourceTemplateKind: "image",
|
|
674
|
+
connectionExpiresInMinutes: 30,
|
|
675
|
+
config: {
|
|
676
|
+
snapshot: "base-snapshot",
|
|
677
|
+
timeoutMs: 300000,
|
|
678
|
+
reuseLease: false,
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
const [createParams] = mockCreate.mock.calls[0];
|
|
682
|
+
expect(createParams).toMatchObject({
|
|
683
|
+
image: "node:20",
|
|
684
|
+
labels: {
|
|
685
|
+
"paperclip-provider": "daytona",
|
|
686
|
+
"paperclip-setup-session-id": "setup-image-1",
|
|
687
|
+
"paperclip-purpose": "interactive_setup",
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
expect(createParams).not.toHaveProperty("snapshot");
|
|
691
|
+
});
|
|
692
|
+
it("cleans up the setup sandbox if SSH access is unsupported", async () => {
|
|
693
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
694
|
+
const sandbox = createMockSandbox();
|
|
695
|
+
delete sandbox.createSshAccess;
|
|
696
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
697
|
+
await expect(plugin.definition.onEnvironmentStartInteractiveSetup?.({
|
|
698
|
+
driverKey: "daytona",
|
|
699
|
+
companyId: "company-1",
|
|
700
|
+
environmentId: "env-1",
|
|
701
|
+
sessionId: "setup-1",
|
|
702
|
+
config: {
|
|
703
|
+
snapshot: "base-snapshot",
|
|
704
|
+
timeoutMs: 300000,
|
|
705
|
+
reuseLease: false,
|
|
706
|
+
},
|
|
707
|
+
})).rejects.toThrow("Sandbox.createSshAccess");
|
|
708
|
+
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
|
709
|
+
});
|
|
710
|
+
it("returns setup status without minting SSH access unless requested", async () => {
|
|
711
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
712
|
+
const sandbox = createMockSandbox({ id: "sandbox-setup" });
|
|
713
|
+
mockGet.mockResolvedValue(sandbox);
|
|
714
|
+
const session = await plugin.definition.onEnvironmentGetInteractiveSetup?.({
|
|
715
|
+
driverKey: "daytona",
|
|
716
|
+
companyId: "company-1",
|
|
717
|
+
environmentId: "env-1",
|
|
718
|
+
providerLeaseId: "sandbox-setup",
|
|
719
|
+
includeConnectionPayload: false,
|
|
720
|
+
config: {
|
|
721
|
+
snapshot: "base-snapshot",
|
|
722
|
+
timeoutMs: 300000,
|
|
723
|
+
reuseLease: false,
|
|
724
|
+
},
|
|
725
|
+
});
|
|
726
|
+
expect(sandbox.createSshAccess).not.toHaveBeenCalled();
|
|
727
|
+
expect(session).toMatchObject({
|
|
728
|
+
providerLeaseId: "sandbox-setup",
|
|
729
|
+
status: "waiting_for_user",
|
|
730
|
+
connectionSummary: {
|
|
731
|
+
type: "ssh",
|
|
732
|
+
hostRedacted: true,
|
|
733
|
+
portRedacted: true,
|
|
734
|
+
},
|
|
735
|
+
connectionPayload: null,
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
it("returns missing setup status when the Daytona sandbox is gone", async () => {
|
|
739
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
740
|
+
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("missing"));
|
|
741
|
+
await expect(plugin.definition.onEnvironmentGetInteractiveSetup?.({
|
|
742
|
+
driverKey: "daytona",
|
|
743
|
+
companyId: "company-1",
|
|
744
|
+
environmentId: "env-1",
|
|
745
|
+
providerLeaseId: "sandbox-missing",
|
|
746
|
+
includeConnectionPayload: true,
|
|
747
|
+
config: {
|
|
748
|
+
snapshot: "base-snapshot",
|
|
749
|
+
timeoutMs: 300000,
|
|
750
|
+
reuseLease: false,
|
|
751
|
+
},
|
|
752
|
+
})).resolves.toEqual({
|
|
753
|
+
providerLeaseId: null,
|
|
754
|
+
status: "missing",
|
|
755
|
+
connectionSummary: null,
|
|
756
|
+
connectionPayload: null,
|
|
757
|
+
metadata: {
|
|
758
|
+
provider: "daytona",
|
|
759
|
+
missing: true,
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
it("captures a Daytona snapshot from a live setup sandbox with redacted metadata", async () => {
|
|
764
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
765
|
+
const sandbox = createMockSandbox({ id: "sandbox-setup" });
|
|
766
|
+
mockGet.mockResolvedValue(sandbox);
|
|
767
|
+
const result = await plugin.definition.onEnvironmentCaptureTemplate?.({
|
|
768
|
+
driverKey: "daytona",
|
|
769
|
+
companyId: "company-1",
|
|
770
|
+
environmentId: "env-1",
|
|
771
|
+
providerLeaseId: "sandbox-setup",
|
|
772
|
+
templateLabel: " Paperclip Env 1 ",
|
|
773
|
+
sourceTemplateRef: "source-secret-snapshot",
|
|
774
|
+
previousTemplateRef: "previous-secret-snapshot",
|
|
775
|
+
timeoutMs: 120000,
|
|
776
|
+
config: {
|
|
777
|
+
snapshot: "base-snapshot",
|
|
778
|
+
timeoutMs: 300000,
|
|
779
|
+
reuseLease: false,
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
expect(sandbox._experimental_createSnapshot).toHaveBeenCalledWith("paperclip-env-1", 120);
|
|
783
|
+
expect(result).toMatchObject({
|
|
784
|
+
templateKind: "snapshot",
|
|
785
|
+
templateRef: "paperclip-env-1",
|
|
786
|
+
metadata: {
|
|
787
|
+
provider: "daytona",
|
|
788
|
+
sandboxId: "sandbox-setup",
|
|
789
|
+
sourceTemplateRefRedacted: true,
|
|
790
|
+
previousTemplateRefRedacted: true,
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
expect(JSON.stringify(result?.metadata)).not.toContain("source-secret-snapshot");
|
|
794
|
+
expect(JSON.stringify(result?.metadata)).not.toContain("previous-secret-snapshot");
|
|
795
|
+
});
|
|
796
|
+
it("cancels an interactive setup sandbox by deleting it", async () => {
|
|
797
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
798
|
+
const sandbox = createMockSandbox({ id: "sandbox-setup" });
|
|
799
|
+
mockGet.mockResolvedValue(sandbox);
|
|
800
|
+
const result = await plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
|
801
|
+
driverKey: "daytona",
|
|
802
|
+
companyId: "company-1",
|
|
803
|
+
environmentId: "env-1",
|
|
804
|
+
providerLeaseId: "sandbox-setup",
|
|
805
|
+
reason: "user_cancelled",
|
|
806
|
+
config: {
|
|
807
|
+
snapshot: "base-snapshot",
|
|
808
|
+
timeoutMs: 300000,
|
|
809
|
+
reuseLease: false,
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
expect(sandbox.delete).toHaveBeenCalledWith(300);
|
|
813
|
+
expect(result).toMatchObject({
|
|
814
|
+
status: "cancelled",
|
|
815
|
+
metadata: {
|
|
816
|
+
provider: "daytona",
|
|
817
|
+
sandboxId: "sandbox-setup",
|
|
818
|
+
reason: "user_cancelled",
|
|
819
|
+
},
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
it("deletes Daytona snapshot templates through the snapshot service", async () => {
|
|
823
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
824
|
+
const snapshot = { name: "captured-template" };
|
|
825
|
+
mockSnapshotGet.mockResolvedValue(snapshot);
|
|
826
|
+
const result = await plugin.definition.onEnvironmentDeleteTemplate?.({
|
|
827
|
+
driverKey: "daytona",
|
|
828
|
+
companyId: "company-1",
|
|
829
|
+
environmentId: "env-1",
|
|
830
|
+
templateRef: "captured-template",
|
|
831
|
+
templateKind: "snapshot",
|
|
832
|
+
reason: "cleanup",
|
|
833
|
+
config: {
|
|
834
|
+
snapshot: "base-snapshot",
|
|
835
|
+
timeoutMs: 300000,
|
|
836
|
+
reuseLease: false,
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
expect(mockSnapshotGet).toHaveBeenCalledWith("captured-template");
|
|
840
|
+
expect(mockSnapshotDelete).toHaveBeenCalledWith(snapshot);
|
|
841
|
+
expect(result).toEqual({
|
|
842
|
+
deleted: true,
|
|
843
|
+
metadata: {
|
|
844
|
+
provider: "daytona",
|
|
845
|
+
templateKind: "snapshot",
|
|
846
|
+
templateRefRedacted: true,
|
|
847
|
+
reason: "cleanup",
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
it("passes configured resources to Daytona for image-based creation", async () => {
|
|
852
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
853
|
+
const sandbox = createMockSandbox();
|
|
854
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
855
|
+
await plugin.definition.onEnvironmentAcquireLease?.({
|
|
856
|
+
driverKey: "daytona",
|
|
857
|
+
companyId: "company-1",
|
|
858
|
+
environmentId: "env-1",
|
|
859
|
+
runId: "run-1",
|
|
860
|
+
agentId: "agent-1",
|
|
861
|
+
executionWorkspaceId: "workspace-1",
|
|
862
|
+
adapterType: "codex_local",
|
|
863
|
+
config: {
|
|
864
|
+
image: "node:20",
|
|
865
|
+
cpu: 4,
|
|
866
|
+
memory: 8,
|
|
867
|
+
disk: 20,
|
|
868
|
+
timeoutMs: 300000,
|
|
869
|
+
reuseLease: true,
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
873
|
+
const [createParams] = mockCreate.mock.calls[0];
|
|
874
|
+
expect(createParams).toMatchObject({
|
|
875
|
+
image: "node:20",
|
|
876
|
+
resources: { cpu: 4, memory: 8, disk: 20, gpu: undefined },
|
|
877
|
+
});
|
|
878
|
+
expect(createParams).not.toHaveProperty("snapshot");
|
|
879
|
+
expect(sandbox.resize).not.toHaveBeenCalled();
|
|
880
|
+
});
|
|
881
|
+
it("drops resource settings for snapshot-backed runtime creation instead of failing", async () => {
|
|
882
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
883
|
+
const sandbox = createMockSandbox();
|
|
884
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
885
|
+
await plugin.definition.onEnvironmentAcquireLease?.({
|
|
886
|
+
driverKey: "daytona",
|
|
887
|
+
companyId: "company-1",
|
|
888
|
+
environmentId: "env-1",
|
|
889
|
+
runId: "run-1",
|
|
890
|
+
agentId: "agent-1",
|
|
891
|
+
executionWorkspaceId: "workspace-1",
|
|
892
|
+
adapterType: "codex_local",
|
|
893
|
+
config: {
|
|
894
|
+
snapshot: "captured-snapshot",
|
|
895
|
+
cpu: 4,
|
|
896
|
+
memory: 8,
|
|
897
|
+
disk: 20,
|
|
898
|
+
timeoutMs: 300000,
|
|
899
|
+
reuseLease: true,
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
903
|
+
const [createParams] = mockCreate.mock.calls[0];
|
|
904
|
+
expect(createParams).toMatchObject({ snapshot: "captured-snapshot" });
|
|
905
|
+
expect(createParams).not.toHaveProperty("resources");
|
|
906
|
+
expect(createParams).not.toHaveProperty("image");
|
|
907
|
+
});
|
|
908
|
+
it("rejects resource settings for snapshot-backed creation", async () => {
|
|
909
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
910
|
+
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
|
911
|
+
driverKey: "daytona",
|
|
912
|
+
config: {
|
|
913
|
+
snapshot: "base-snapshot",
|
|
914
|
+
cpu: 4,
|
|
915
|
+
memory: 8,
|
|
916
|
+
disk: 20,
|
|
917
|
+
timeoutMs: 300000,
|
|
918
|
+
reuseLease: true,
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
expect(result).toEqual({
|
|
922
|
+
ok: false,
|
|
923
|
+
errors: [
|
|
924
|
+
"Daytona resource settings require image-backed sandbox creation; snapshot/default sandbox creation cannot override CPU, memory, disk, or GPU.",
|
|
925
|
+
],
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
it("rejects resource settings for default sandbox creation before creating a sandbox", async () => {
|
|
929
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
930
|
+
await expect(plugin.definition.onEnvironmentAcquireLease?.({
|
|
931
|
+
driverKey: "daytona",
|
|
932
|
+
companyId: "company-1",
|
|
933
|
+
environmentId: "env-1",
|
|
934
|
+
runId: "run-1",
|
|
935
|
+
agentId: "agent-1",
|
|
936
|
+
executionWorkspaceId: "workspace-1",
|
|
937
|
+
adapterType: "codex_local",
|
|
938
|
+
config: {
|
|
939
|
+
cpu: 4,
|
|
940
|
+
memory: 4,
|
|
941
|
+
timeoutMs: 300000,
|
|
942
|
+
reuseLease: false,
|
|
943
|
+
},
|
|
944
|
+
})).rejects.toThrow("Daytona resource settings require image-backed sandbox creation; default sandbox creation cannot override CPU, memory, disk, or GPU.");
|
|
945
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
946
|
+
});
|
|
947
|
+
it("records requested resources in lease metadata", async () => {
|
|
948
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
949
|
+
const sandbox = createMockSandbox();
|
|
950
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
951
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
952
|
+
driverKey: "daytona",
|
|
953
|
+
companyId: "company-1",
|
|
954
|
+
environmentId: "env-1",
|
|
955
|
+
runId: "run-1",
|
|
956
|
+
agentId: "agent-1",
|
|
957
|
+
executionWorkspaceId: "workspace-1",
|
|
958
|
+
adapterType: "codex_local",
|
|
959
|
+
config: {
|
|
960
|
+
image: "daytonaio/sandbox:0.8.0",
|
|
961
|
+
cpu: 4,
|
|
962
|
+
memory: 8,
|
|
963
|
+
timeoutMs: 300000,
|
|
964
|
+
reuseLease: true,
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
expect(lease?.metadata).toMatchObject({ cpu: 4, memory: 8 });
|
|
968
|
+
expect(lease?.metadata).not.toHaveProperty("disk");
|
|
969
|
+
expect(lease?.metadata).not.toHaveProperty("gpu");
|
|
970
|
+
});
|
|
971
|
+
it("changes reusable-lease sentinel identity when resources change", async () => {
|
|
972
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
973
|
+
const acquireWithCpu = async (cpu) => {
|
|
974
|
+
const sandbox = createMockSandbox();
|
|
975
|
+
mockCreate.mockResolvedValueOnce(sandbox);
|
|
976
|
+
await plugin.definition.onEnvironmentAcquireLease?.({
|
|
977
|
+
driverKey: "daytona",
|
|
978
|
+
companyId: "company-1",
|
|
979
|
+
environmentId: "env-1",
|
|
980
|
+
runId: "run-1",
|
|
981
|
+
agentId: "agent-1",
|
|
982
|
+
executionWorkspaceId: "workspace-1",
|
|
983
|
+
adapterType: "codex_local",
|
|
984
|
+
config: {
|
|
985
|
+
image: "daytonaio/sandbox:0.8.0",
|
|
986
|
+
cpu,
|
|
987
|
+
timeoutMs: 300000,
|
|
988
|
+
reuseLease: true,
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
const uploadCall = sandbox.fs.uploadFile.mock.calls.find((call) => typeof call[1] === "string" && call[1].endsWith("reusable-sandbox-lease.json"));
|
|
992
|
+
expect(uploadCall).toBeTruthy();
|
|
993
|
+
const parsed = JSON.parse(uploadCall[0].toString("utf8"));
|
|
994
|
+
return parsed.token;
|
|
995
|
+
};
|
|
996
|
+
const tokenCpu1 = await acquireWithCpu(1);
|
|
997
|
+
const tokenCpu4 = await acquireWithCpu(4);
|
|
998
|
+
expect(tokenCpu1).toBeTruthy();
|
|
999
|
+
expect(tokenCpu4).toBeTruthy();
|
|
1000
|
+
expect(tokenCpu1).not.toEqual(tokenCpu4);
|
|
1001
|
+
});
|
|
1002
|
+
it("deletes the sandbox if lease setup throws after sandbox creation", async () => {
|
|
1003
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1004
|
+
const sandbox = createMockSandbox();
|
|
1005
|
+
sandbox.getWorkDir.mockRejectedValue(new Error("workdir lookup failed"));
|
|
1006
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
1007
|
+
await expect(plugin.definition.onEnvironmentAcquireLease?.({
|
|
1008
|
+
driverKey: "daytona",
|
|
1009
|
+
companyId: "company-1",
|
|
1010
|
+
environmentId: "env-1",
|
|
1011
|
+
runId: "run-1",
|
|
1012
|
+
config: {
|
|
1013
|
+
image: "node:20",
|
|
1014
|
+
timeoutMs: 300000,
|
|
1015
|
+
reuseLease: true,
|
|
1016
|
+
},
|
|
1017
|
+
})).rejects.toThrow("workdir lookup failed");
|
|
1018
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
1019
|
+
});
|
|
1020
|
+
it("falls back to sh metadata when bash is not present in the sandbox image", async () => {
|
|
1021
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1022
|
+
const sandbox = createMockSandbox();
|
|
1023
|
+
sandbox.process.executeCommand.mockResolvedValue({
|
|
1024
|
+
exitCode: 0,
|
|
1025
|
+
result: "sh",
|
|
1026
|
+
artifacts: { stdout: "sh" },
|
|
1027
|
+
});
|
|
1028
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
1029
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
1030
|
+
driverKey: "daytona",
|
|
1031
|
+
companyId: "company-1",
|
|
1032
|
+
environmentId: "env-1",
|
|
1033
|
+
runId: "run-1",
|
|
1034
|
+
config: {
|
|
1035
|
+
image: "busybox:latest",
|
|
1036
|
+
timeoutMs: 300000,
|
|
1037
|
+
reuseLease: true,
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
expect(lease).toMatchObject({
|
|
1041
|
+
metadata: {
|
|
1042
|
+
shellCommand: "sh",
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
it("preserves the sandbox if resume setup throws after the sandbox starts", async () => {
|
|
1047
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1048
|
+
const sandbox = createMockSandbox({ id: "sandbox-resume", state: "stopped" });
|
|
1049
|
+
sandbox.getWorkDir.mockRejectedValue(new Error("workdir lookup failed"));
|
|
1050
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1051
|
+
await expect(plugin.definition.onEnvironmentResumeLease?.({
|
|
1052
|
+
driverKey: "daytona",
|
|
1053
|
+
companyId: "company-1",
|
|
1054
|
+
environmentId: "env-1",
|
|
1055
|
+
providerLeaseId: "sandbox-resume",
|
|
1056
|
+
config: {
|
|
1057
|
+
timeoutMs: 300000,
|
|
1058
|
+
reuseLease: true,
|
|
1059
|
+
},
|
|
1060
|
+
})).rejects.toThrow("workdir lookup failed");
|
|
1061
|
+
expect(sandbox.start).toHaveBeenCalled();
|
|
1062
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1063
|
+
});
|
|
1064
|
+
it("marks missing reusable leases as expired on resume", async () => {
|
|
1065
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1066
|
+
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("missing"));
|
|
1067
|
+
await expect(plugin.definition.onEnvironmentResumeLease?.({
|
|
1068
|
+
driverKey: "daytona",
|
|
1069
|
+
companyId: "company-1",
|
|
1070
|
+
environmentId: "env-1",
|
|
1071
|
+
providerLeaseId: "sandbox-123",
|
|
1072
|
+
config: {
|
|
1073
|
+
timeoutMs: 300000,
|
|
1074
|
+
reuseLease: true,
|
|
1075
|
+
},
|
|
1076
|
+
})).resolves.toEqual({
|
|
1077
|
+
providerLeaseId: null,
|
|
1078
|
+
metadata: { expired: true },
|
|
1079
|
+
});
|
|
1080
|
+
});
|
|
1081
|
+
it("resumes a reusable lease when the workspace sentinel matches", async () => {
|
|
1082
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1083
|
+
const sandbox = createMockSandbox({ id: "sandbox-reuse", state: "stopped" });
|
|
1084
|
+
sandbox.process.executeCommand
|
|
1085
|
+
.mockResolvedValueOnce({
|
|
1086
|
+
exitCode: 0,
|
|
1087
|
+
result: JSON.stringify({ token: "sentinel-token" }),
|
|
1088
|
+
artifacts: { stdout: JSON.stringify({ token: "sentinel-token" }) },
|
|
1089
|
+
})
|
|
1090
|
+
.mockResolvedValueOnce({
|
|
1091
|
+
exitCode: 0,
|
|
1092
|
+
result: "bash",
|
|
1093
|
+
artifacts: { stdout: "bash" },
|
|
1094
|
+
});
|
|
1095
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1096
|
+
const lease = await plugin.definition.onEnvironmentResumeLease?.({
|
|
1097
|
+
driverKey: "daytona",
|
|
1098
|
+
companyId: "company-1",
|
|
1099
|
+
environmentId: "env-1",
|
|
1100
|
+
providerLeaseId: "sandbox-reuse",
|
|
1101
|
+
config: {
|
|
1102
|
+
timeoutMs: 300000,
|
|
1103
|
+
reuseLease: true,
|
|
1104
|
+
},
|
|
1105
|
+
leaseMetadata: {
|
|
1106
|
+
workspaceSentinel: {
|
|
1107
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
1108
|
+
token: "sentinel-token",
|
|
1109
|
+
result: "written",
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
expect(sandbox.start).toHaveBeenCalledWith(300);
|
|
1114
|
+
expect(lease).toMatchObject({
|
|
1115
|
+
providerLeaseId: "sandbox-reuse",
|
|
1116
|
+
metadata: {
|
|
1117
|
+
resumedLease: true,
|
|
1118
|
+
resumedFromState: "stopped",
|
|
1119
|
+
sandboxState: "started",
|
|
1120
|
+
workspaceSentinel: {
|
|
1121
|
+
result: "matched",
|
|
1122
|
+
token: "sentinel-token",
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
});
|
|
1127
|
+
describe("missing-container resume", () => {
|
|
1128
|
+
const sandboxId = "00000000-0000-4000-8000-000000000001";
|
|
1129
|
+
const missing = `not found: failed to inspect sandbox container ${sandboxId}: Error response from daemon: No such container: ${sandboxId}`;
|
|
1130
|
+
const params = {
|
|
1131
|
+
driverKey: "daytona", companyId: "company-1", environmentId: "env-1", providerLeaseId: sandboxId,
|
|
1132
|
+
config: { apiKey: "host-key", timeoutMs: 300000, livenessTimeoutMs: 100, reuseLease: true },
|
|
1133
|
+
leaseMetadata: { workspaceSentinel: { path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json", token: "sentinel-token" } },
|
|
1134
|
+
};
|
|
1135
|
+
const resume = () => plugin.definition.onEnvironmentResumeLease(params);
|
|
1136
|
+
function missingSandbox() {
|
|
1137
|
+
return { ...createMockSandbox({ id: sandboxId, state: "error", recoverable: false }), errorReason: missing };
|
|
1138
|
+
}
|
|
1139
|
+
function allowSentinel(sandbox) {
|
|
1140
|
+
sandbox.process.executeCommand.mockResolvedValueOnce({ exitCode: 0,
|
|
1141
|
+
result: JSON.stringify({ token: "sentinel-token" }), artifacts: { stdout: JSON.stringify({ token: "sentinel-token" }) },
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
it("expires a provider record with a freshly confirmed missing container without replacing it", async () => {
|
|
1145
|
+
const sandbox = missingSandbox();
|
|
1146
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1147
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1148
|
+
await expect(resume()).resolves.toEqual({ providerLeaseId: null, metadata: { expired: true } });
|
|
1149
|
+
}
|
|
1150
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
1151
|
+
expect(sandbox.refreshData).toHaveBeenCalledTimes(2);
|
|
1152
|
+
expect(sandbox.start).not.toHaveBeenCalled();
|
|
1153
|
+
expect(sandbox.recover).not.toHaveBeenCalled();
|
|
1154
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1155
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1156
|
+
});
|
|
1157
|
+
it.each(["sandbox-opaque", "sandbox.with+[literal](characters)"])("matches the opaque sandbox ID literally: %s", async (id) => {
|
|
1158
|
+
const sandbox = { ...missingSandbox(), id, errorReason: missing.replaceAll(sandboxId, id) };
|
|
1159
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1160
|
+
await expect(plugin.definition.onEnvironmentResumeLease({ ...params, providerLeaseId: id }))
|
|
1161
|
+
.resolves.toEqual({ providerLeaseId: null, metadata: { expired: true } });
|
|
1162
|
+
expect(sandbox.refreshData).toHaveBeenCalledOnce();
|
|
1163
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1164
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1165
|
+
});
|
|
1166
|
+
it.each([
|
|
1167
|
+
"not found: provider temporarily unavailable",
|
|
1168
|
+
missing.replace("No such container:", "No such volume:"),
|
|
1169
|
+
missing.replace(/000000000001$/, "000000000002"),
|
|
1170
|
+
missing.replaceAll(sandboxId, "00000000-0000-4000-8000-000000000002"),
|
|
1171
|
+
])("preserves an unexplained unrecoverable error: %s", async (errorReason) => {
|
|
1172
|
+
const sandbox = { ...missingSandbox(), errorReason };
|
|
1173
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1174
|
+
await expect(resume()).rejects.toThrow("unrecoverable error state");
|
|
1175
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1176
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1177
|
+
});
|
|
1178
|
+
it("uses provider recovery when the sandbox is recoverable", async () => {
|
|
1179
|
+
const sandbox = { ...missingSandbox(), recoverable: true };
|
|
1180
|
+
allowSentinel(sandbox);
|
|
1181
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1182
|
+
await expect(resume()).resolves.toMatchObject({ providerLeaseId: sandboxId });
|
|
1183
|
+
expect(sandbox.recover).toHaveBeenCalled();
|
|
1184
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1185
|
+
});
|
|
1186
|
+
it("reuses a sandbox whose fresh state disproves the cached loss", async () => {
|
|
1187
|
+
const sandbox = missingSandbox();
|
|
1188
|
+
sandbox.refreshData.mockImplementation(async () => { sandbox.state = "started"; });
|
|
1189
|
+
allowSentinel(sandbox);
|
|
1190
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1191
|
+
await expect(resume()).resolves.toMatchObject({ providerLeaseId: sandboxId });
|
|
1192
|
+
expect(sandbox.start).not.toHaveBeenCalled();
|
|
1193
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1194
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1195
|
+
});
|
|
1196
|
+
it.each([new MockDaytonaTimeoutError("timed out"), new Error("provider 503")])("preserves the lease if confirmation fails: %s", async (error) => {
|
|
1197
|
+
const sandbox = missingSandbox();
|
|
1198
|
+
sandbox.refreshData.mockRejectedValue(error);
|
|
1199
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1200
|
+
await expect(resume()).rejects.toThrow(error.message);
|
|
1201
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1202
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1203
|
+
});
|
|
1204
|
+
it("preserves an unknown error returned by the fresh provider read", async () => {
|
|
1205
|
+
const sandbox = missingSandbox();
|
|
1206
|
+
sandbox.refreshData.mockImplementation(async () => { sandbox.errorReason = "provider unavailable"; });
|
|
1207
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1208
|
+
await expect(resume()).rejects.toThrow("unrecoverable error state");
|
|
1209
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1210
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1211
|
+
});
|
|
1212
|
+
it("rejects a refreshed handle belonging to a different sandbox", async () => {
|
|
1213
|
+
const sandbox = missingSandbox();
|
|
1214
|
+
sandbox.refreshData.mockImplementation(async () => { sandbox.id = "another-sandbox"; });
|
|
1215
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1216
|
+
await expect(resume()).rejects.toThrow("handle mismatch");
|
|
1217
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1218
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1219
|
+
});
|
|
1220
|
+
it("accepts a typed not-found result while confirming the missing container", async () => {
|
|
1221
|
+
const sandbox = missingSandbox();
|
|
1222
|
+
sandbox.refreshData.mockRejectedValue(new MockDaytonaNotFoundError("missing"));
|
|
1223
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1224
|
+
await expect(resume()).resolves.toEqual({ providerLeaseId: null, metadata: { expired: true } });
|
|
1225
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1226
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1227
|
+
});
|
|
1228
|
+
it("bounds a stalled confirmation and preserves the lease", async () => {
|
|
1229
|
+
vi.useFakeTimers();
|
|
1230
|
+
try {
|
|
1231
|
+
const sandbox = missingSandbox();
|
|
1232
|
+
sandbox.refreshData.mockImplementation(() => new Promise(() => { }));
|
|
1233
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1234
|
+
const result = resume().then(() => null, error => error);
|
|
1235
|
+
await vi.advanceTimersByTimeAsync(101);
|
|
1236
|
+
expect((await result)?.message).toContain("sandbox.refreshData");
|
|
1237
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1238
|
+
expect(mockCreate).not.toHaveBeenCalled();
|
|
1239
|
+
}
|
|
1240
|
+
finally {
|
|
1241
|
+
vi.useRealTimers();
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
it("expires a reusable lease when the workspace sentinel does not match", async () => {
|
|
1246
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1247
|
+
const sandbox = createMockSandbox({ id: "sandbox-reuse", state: "stopped" });
|
|
1248
|
+
sandbox.process.executeCommand.mockResolvedValueOnce({
|
|
1249
|
+
exitCode: 0,
|
|
1250
|
+
result: JSON.stringify({ token: "other-token" }),
|
|
1251
|
+
artifacts: { stdout: JSON.stringify({ token: "other-token" }) },
|
|
1252
|
+
});
|
|
1253
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1254
|
+
await expect(plugin.definition.onEnvironmentResumeLease?.({
|
|
1255
|
+
driverKey: "daytona",
|
|
1256
|
+
companyId: "company-1",
|
|
1257
|
+
environmentId: "env-1",
|
|
1258
|
+
providerLeaseId: "sandbox-reuse",
|
|
1259
|
+
config: {
|
|
1260
|
+
timeoutMs: 300000,
|
|
1261
|
+
reuseLease: true,
|
|
1262
|
+
},
|
|
1263
|
+
leaseMetadata: {
|
|
1264
|
+
workspaceSentinel: {
|
|
1265
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
1266
|
+
token: "sentinel-token",
|
|
1267
|
+
result: "written",
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
})).resolves.toEqual({
|
|
1271
|
+
providerLeaseId: null,
|
|
1272
|
+
metadata: {
|
|
1273
|
+
expired: true,
|
|
1274
|
+
workspaceSentinel: {
|
|
1275
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
1276
|
+
token: "sentinel-token",
|
|
1277
|
+
result: "mismatch",
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
});
|
|
1281
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
1282
|
+
});
|
|
1283
|
+
it("refreshes a cached stopped handle before granting a termination receipt", async () => {
|
|
1284
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1285
|
+
const sandbox = createMockSandbox({ id: "sandbox-resumed", state: "stopped" });
|
|
1286
|
+
sandbox.refreshData.mockImplementation(async () => { sandbox.state = "started"; });
|
|
1287
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1288
|
+
await expect(plugin.definition.onEnvironmentReleaseLease?.({
|
|
1289
|
+
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
|
1290
|
+
providerLeaseId: sandbox.id, config: { reuseLease: true },
|
|
1291
|
+
})).resolves.toEqual({ providerLeaseId: sandbox.id, state: "stopped" });
|
|
1292
|
+
expect(sandbox.refreshData).toHaveBeenCalled();
|
|
1293
|
+
expect(sandbox.stop).toHaveBeenCalled();
|
|
1294
|
+
});
|
|
1295
|
+
it("does not acknowledge termination when both provider stop and delete fail", async () => {
|
|
1296
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1297
|
+
const sandbox = createMockSandbox({ id: "sandbox-failed-stop", state: "started" });
|
|
1298
|
+
sandbox.stop.mockRejectedValueOnce(new Error("stop failed"));
|
|
1299
|
+
sandbox.delete.mockRejectedValueOnce(new Error("delete failed"));
|
|
1300
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1301
|
+
await expect(plugin.definition.onEnvironmentReleaseLease?.({
|
|
1302
|
+
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
|
1303
|
+
providerLeaseId: sandbox.id, config: { reuseLease: true },
|
|
1304
|
+
})).rejects.toThrow("delete failed");
|
|
1305
|
+
});
|
|
1306
|
+
it("stops reusable leases and deletes ephemeral leases on release", async () => {
|
|
1307
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1308
|
+
const reusable = createMockSandbox({ id: "sandbox-reusable" });
|
|
1309
|
+
const ephemeral = createMockSandbox({ id: "sandbox-ephemeral" });
|
|
1310
|
+
mockGet.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
|
|
1311
|
+
const reusableReceipt = await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1312
|
+
driverKey: "daytona",
|
|
1313
|
+
companyId: "company-1",
|
|
1314
|
+
environmentId: "env-1",
|
|
1315
|
+
providerLeaseId: "sandbox-reusable",
|
|
1316
|
+
config: {
|
|
1317
|
+
timeoutMs: 300000,
|
|
1318
|
+
reuseLease: true,
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
const ephemeralReceipt = await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1322
|
+
driverKey: "daytona",
|
|
1323
|
+
companyId: "company-1",
|
|
1324
|
+
environmentId: "env-1",
|
|
1325
|
+
providerLeaseId: "sandbox-ephemeral",
|
|
1326
|
+
config: {
|
|
1327
|
+
timeoutMs: 300000,
|
|
1328
|
+
reuseLease: false,
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1331
|
+
expect(reusableReceipt).toEqual({ providerLeaseId: "sandbox-reusable", state: "stopped" });
|
|
1332
|
+
expect(ephemeralReceipt).toEqual({ providerLeaseId: "sandbox-ephemeral", state: "destroyed" });
|
|
1333
|
+
expect(reusable.stop).toHaveBeenCalledWith(300);
|
|
1334
|
+
expect(reusable.delete).not.toHaveBeenCalled();
|
|
1335
|
+
expect(ephemeral.delete).toHaveBeenCalledWith(300, true);
|
|
1336
|
+
});
|
|
1337
|
+
it("archives instead of deleting when the lease was acquired with archiveOnRelease", async () => {
|
|
1338
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1339
|
+
const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "started" });
|
|
1340
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1341
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1342
|
+
driverKey: "daytona",
|
|
1343
|
+
companyId: "company-1",
|
|
1344
|
+
environmentId: "env-1",
|
|
1345
|
+
providerLeaseId: "sandbox-test-probe",
|
|
1346
|
+
config: {
|
|
1347
|
+
timeoutMs: 300000,
|
|
1348
|
+
reuseLease: false,
|
|
1349
|
+
archiveOnRelease: true,
|
|
1350
|
+
},
|
|
1351
|
+
});
|
|
1352
|
+
expect(sandbox.stop).toHaveBeenCalledWith(300);
|
|
1353
|
+
expect(sandbox.setAutoDeleteInterval).toHaveBeenCalledWith(60);
|
|
1354
|
+
expect(sandbox.archive).toHaveBeenCalled();
|
|
1355
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
1356
|
+
});
|
|
1357
|
+
it("falls back to delete when archiving an archiveOnRelease lease fails", async () => {
|
|
1358
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1359
|
+
const sandbox = createMockSandbox({ id: "sandbox-test-probe", state: "stopped" });
|
|
1360
|
+
sandbox.archive.mockRejectedValueOnce(new Error("archive unsupported"));
|
|
1361
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1362
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1363
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1364
|
+
driverKey: "daytona",
|
|
1365
|
+
companyId: "company-1",
|
|
1366
|
+
environmentId: "env-1",
|
|
1367
|
+
providerLeaseId: "sandbox-test-probe",
|
|
1368
|
+
config: {
|
|
1369
|
+
timeoutMs: 300000,
|
|
1370
|
+
reuseLease: false,
|
|
1371
|
+
archiveOnRelease: true,
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
expect(sandbox.stop).not.toHaveBeenCalled();
|
|
1375
|
+
expect(sandbox.archive).toHaveBeenCalled();
|
|
1376
|
+
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
|
|
1377
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
1378
|
+
});
|
|
1379
|
+
it("falls back to delete when stopping a reusable lease from an error state fails", async () => {
|
|
1380
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1381
|
+
const errored = createMockSandbox({ id: "sandbox-error", state: "error" });
|
|
1382
|
+
errored.stop.mockRejectedValueOnce(new Error("stop failed"));
|
|
1383
|
+
mockGet.mockResolvedValue(errored);
|
|
1384
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1385
|
+
driverKey: "daytona",
|
|
1386
|
+
companyId: "company-1",
|
|
1387
|
+
environmentId: "env-1",
|
|
1388
|
+
providerLeaseId: "sandbox-error",
|
|
1389
|
+
config: {
|
|
1390
|
+
timeoutMs: 300000,
|
|
1391
|
+
reuseLease: true,
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
expect(errored.stop).toHaveBeenCalledWith(300);
|
|
1395
|
+
expect(errored.delete).toHaveBeenCalledWith(300, true);
|
|
1396
|
+
});
|
|
1397
|
+
it("falls back to delete when stopping a healthy reusable lease fails mid-call", async () => {
|
|
1398
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1399
|
+
const sandbox = createMockSandbox({ id: "sandbox-running", state: "started" });
|
|
1400
|
+
sandbox.stop.mockRejectedValueOnce(new Error("api timeout"));
|
|
1401
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1402
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
1403
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1404
|
+
driverKey: "daytona",
|
|
1405
|
+
companyId: "company-1",
|
|
1406
|
+
environmentId: "env-1",
|
|
1407
|
+
providerLeaseId: "sandbox-running",
|
|
1408
|
+
config: {
|
|
1409
|
+
timeoutMs: 300000,
|
|
1410
|
+
reuseLease: true,
|
|
1411
|
+
},
|
|
1412
|
+
});
|
|
1413
|
+
expect(sandbox.stop).toHaveBeenCalledWith(300);
|
|
1414
|
+
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
|
|
1415
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
1416
|
+
});
|
|
1417
|
+
describe("session model lifecycle (per-lease session store)", () => {
|
|
1418
|
+
// A recording plugin tracer that captures every provider span the session
|
|
1419
|
+
// hooks open. It satisfies the structural plugin tracer contract.
|
|
1420
|
+
const makeRecordingTracer = () => {
|
|
1421
|
+
const spans = [];
|
|
1422
|
+
const tracer = {
|
|
1423
|
+
startSpan(name, options) {
|
|
1424
|
+
const span = {
|
|
1425
|
+
name,
|
|
1426
|
+
attributes: { ...(options?.attributes ?? {}) },
|
|
1427
|
+
status: null,
|
|
1428
|
+
ended: false,
|
|
1429
|
+
setAttribute(key, value) {
|
|
1430
|
+
span.attributes[key] = value;
|
|
1431
|
+
},
|
|
1432
|
+
setStatus(status) {
|
|
1433
|
+
span.status = status;
|
|
1434
|
+
},
|
|
1435
|
+
end() {
|
|
1436
|
+
span.ended = true;
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
spans.push(span);
|
|
1440
|
+
return span;
|
|
1441
|
+
},
|
|
1442
|
+
};
|
|
1443
|
+
return { tracer, spans };
|
|
1444
|
+
};
|
|
1445
|
+
const sessionExecParams = (overrides = {}) => ({
|
|
1446
|
+
driverKey: "daytona",
|
|
1447
|
+
companyId: "company-1",
|
|
1448
|
+
environmentId: "env-1",
|
|
1449
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1450
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
1451
|
+
command: "printf",
|
|
1452
|
+
args: ["hello"],
|
|
1453
|
+
cwd: "/workspace",
|
|
1454
|
+
timeoutMs: 1000,
|
|
1455
|
+
...overrides,
|
|
1456
|
+
});
|
|
1457
|
+
it("creates one session on the first execute and reuses it on the next", async () => {
|
|
1458
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1459
|
+
const sandbox = createMockSandbox();
|
|
1460
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1461
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1462
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1463
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1464
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1465
|
+
expect(sessionId).toMatch(/^paperclip-/);
|
|
1466
|
+
});
|
|
1467
|
+
it("opens one session when two first commands overlap", async () => {
|
|
1468
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1469
|
+
const sandbox = createMockSandbox();
|
|
1470
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1471
|
+
// Hold the first session create open, so the second first command reaches
|
|
1472
|
+
// the session store before the first create resolves. Without a single
|
|
1473
|
+
// flight guard, both commands would open a session for one lease and the
|
|
1474
|
+
// first session id would leak.
|
|
1475
|
+
let releaseCreate = () => { };
|
|
1476
|
+
const createGate = new Promise((resolve) => {
|
|
1477
|
+
releaseCreate = resolve;
|
|
1478
|
+
});
|
|
1479
|
+
sandbox.process.createSession.mockImplementationOnce(async () => {
|
|
1480
|
+
await createGate;
|
|
1481
|
+
});
|
|
1482
|
+
const first = plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1483
|
+
const second = plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1484
|
+
// Flush the pending microtasks, so both commands park on the held create.
|
|
1485
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1486
|
+
releaseCreate();
|
|
1487
|
+
await Promise.all([first, second]);
|
|
1488
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1489
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1490
|
+
// Teardown deletes the same single session id, so no session leaks.
|
|
1491
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1492
|
+
driverKey: "daytona",
|
|
1493
|
+
companyId: "company-1",
|
|
1494
|
+
environmentId: "env-1",
|
|
1495
|
+
providerLeaseId: "sandbox-123",
|
|
1496
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1497
|
+
});
|
|
1498
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledTimes(1);
|
|
1499
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId);
|
|
1500
|
+
});
|
|
1501
|
+
it("runs a bypassSession command one-shot and leaves the session closed", async () => {
|
|
1502
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1503
|
+
const sandbox = createMockSandbox();
|
|
1504
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1505
|
+
// The provision command runs before the run opens its trace root, so the
|
|
1506
|
+
// host marks it `bypassSession`. The provider must not open the session for
|
|
1507
|
+
// it, or the `session.open` span loses its run parent.
|
|
1508
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams({ bypassSession: true }));
|
|
1509
|
+
expect(sandbox.process.createSession).not.toHaveBeenCalled();
|
|
1510
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
1511
|
+
});
|
|
1512
|
+
it("opens the session on the first in-run command after a bypassSession command", async () => {
|
|
1513
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1514
|
+
const sandbox = createMockSandbox();
|
|
1515
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1516
|
+
// A bypassSession command runs first (no session), then an in-run command
|
|
1517
|
+
// opens the one session. The session-setup span then parents to the run
|
|
1518
|
+
// trace, and every later in-run command reuses the same session.
|
|
1519
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams({ bypassSession: true }));
|
|
1520
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1521
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1522
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1523
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1524
|
+
expect(sessionId).toMatch(/^paperclip-/);
|
|
1525
|
+
});
|
|
1526
|
+
it("deletes the session and clears the store on release", async () => {
|
|
1527
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1528
|
+
const sandbox = createMockSandbox();
|
|
1529
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1530
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1531
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1532
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1533
|
+
driverKey: "daytona",
|
|
1534
|
+
companyId: "company-1",
|
|
1535
|
+
environmentId: "env-1",
|
|
1536
|
+
providerLeaseId: "sandbox-123",
|
|
1537
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1538
|
+
});
|
|
1539
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId);
|
|
1540
|
+
});
|
|
1541
|
+
it("deletes the session at destroy even when the sandbox delete throws", async () => {
|
|
1542
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1543
|
+
const sandbox = createMockSandbox();
|
|
1544
|
+
sandbox.delete.mockRejectedValueOnce(new Error("delete failed"));
|
|
1545
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1546
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1547
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1548
|
+
await expect(plugin.definition.onEnvironmentDestroyLease?.({
|
|
1549
|
+
driverKey: "daytona",
|
|
1550
|
+
companyId: "company-1",
|
|
1551
|
+
environmentId: "env-1",
|
|
1552
|
+
providerLeaseId: "sandbox-123",
|
|
1553
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1554
|
+
})).rejects.toThrow(/delete failed/);
|
|
1555
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId);
|
|
1556
|
+
});
|
|
1557
|
+
it("deletes the session on interactive-setup cancel", async () => {
|
|
1558
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1559
|
+
const sandbox = createMockSandbox();
|
|
1560
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1561
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1562
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1563
|
+
await plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
|
1564
|
+
driverKey: "daytona",
|
|
1565
|
+
companyId: "company-1",
|
|
1566
|
+
environmentId: "env-1",
|
|
1567
|
+
providerLeaseId: "sandbox-123",
|
|
1568
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1569
|
+
});
|
|
1570
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId);
|
|
1571
|
+
});
|
|
1572
|
+
it("logs loudly and does not throw when the session delete fails", async () => {
|
|
1573
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1574
|
+
const sandbox = createMockSandbox();
|
|
1575
|
+
sandbox.process.deleteSession.mockRejectedValueOnce(new Error("session gone"));
|
|
1576
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1577
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1578
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1579
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1580
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1581
|
+
driverKey: "daytona",
|
|
1582
|
+
companyId: "company-1",
|
|
1583
|
+
environmentId: "env-1",
|
|
1584
|
+
providerLeaseId: "sandbox-123",
|
|
1585
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1586
|
+
});
|
|
1587
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(sessionId));
|
|
1588
|
+
// The rest of teardown still ran: the ephemeral sandbox was deleted.
|
|
1589
|
+
expect(sandbox.delete).toHaveBeenCalledWith(300, true);
|
|
1590
|
+
});
|
|
1591
|
+
it("returns a destroy receipt only after the provider confirms deletion", async () => {
|
|
1592
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1593
|
+
const sandbox = createMockSandbox();
|
|
1594
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1595
|
+
let complete;
|
|
1596
|
+
sandbox.delete.mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
|
|
1597
|
+
const release = plugin.definition.onEnvironmentDestroyLease({ driverKey: "daytona",
|
|
1598
|
+
companyId: "company-1", environmentId: "env-1", providerLeaseId: "sandbox-123",
|
|
1599
|
+
config: { timeoutMs: 300000, reuseLease: false } });
|
|
1600
|
+
let settled = false;
|
|
1601
|
+
void Promise.resolve(release).then(() => { settled = true; });
|
|
1602
|
+
await vi.waitFor(() => expect(sandbox.delete).toHaveBeenCalledWith(300, true));
|
|
1603
|
+
expect(settled).toBe(false);
|
|
1604
|
+
complete();
|
|
1605
|
+
await expect(release).resolves.toEqual({ providerLeaseId: "sandbox-123", state: "destroyed" });
|
|
1606
|
+
});
|
|
1607
|
+
it("clears the session store after delete so no orphan id survives a second teardown", async () => {
|
|
1608
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1609
|
+
const sandbox = createMockSandbox();
|
|
1610
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1611
|
+
const releaseParams = {
|
|
1612
|
+
driverKey: "daytona",
|
|
1613
|
+
companyId: "company-1",
|
|
1614
|
+
environmentId: "env-1",
|
|
1615
|
+
providerLeaseId: "sandbox-123",
|
|
1616
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
1617
|
+
};
|
|
1618
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1619
|
+
await plugin.definition.onEnvironmentReleaseLease?.(releaseParams);
|
|
1620
|
+
// The store is now clear. A second teardown finds no id and does not delete
|
|
1621
|
+
// a session again, which proves no orphan id survived the first delete.
|
|
1622
|
+
await plugin.definition.onEnvironmentReleaseLease?.(releaseParams);
|
|
1623
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledTimes(1);
|
|
1624
|
+
});
|
|
1625
|
+
it("clears the session store when resume restarts a stopped sandbox", async () => {
|
|
1626
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1627
|
+
// A stopped sandbox lost its session shell, so resume must clear the stale
|
|
1628
|
+
// id and the next execute must open a fresh session.
|
|
1629
|
+
const sandbox = createMockSandbox({ state: "stopped" });
|
|
1630
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1631
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1632
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1633
|
+
await plugin.definition.onEnvironmentResumeLease?.({
|
|
1634
|
+
driverKey: "daytona",
|
|
1635
|
+
companyId: "company-1",
|
|
1636
|
+
environmentId: "env-1",
|
|
1637
|
+
providerLeaseId: "sandbox-123",
|
|
1638
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1639
|
+
leaseMetadata: {
|
|
1640
|
+
remoteCwd: "/home/daytona/paperclip-workspace",
|
|
1641
|
+
workspaceSentinel: {
|
|
1642
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
1643
|
+
token: "token-1",
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1648
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(2);
|
|
1649
|
+
});
|
|
1650
|
+
it("keeps the session when resume runs on a still-running sandbox", async () => {
|
|
1651
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1652
|
+
// A running sandbox keeps its live session shell. Resume must not clear the
|
|
1653
|
+
// stored id, or a later command opens a second session and teardown deletes
|
|
1654
|
+
// only one, so the first session leaks until sandbox reaping.
|
|
1655
|
+
const sandbox = createMockSandbox({ state: "started" });
|
|
1656
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1657
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1658
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1659
|
+
const sessionId = sandbox.process.createSession.mock.calls[0][0];
|
|
1660
|
+
await plugin.definition.onEnvironmentResumeLease?.({
|
|
1661
|
+
driverKey: "daytona",
|
|
1662
|
+
companyId: "company-1",
|
|
1663
|
+
environmentId: "env-1",
|
|
1664
|
+
providerLeaseId: "sandbox-123",
|
|
1665
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1666
|
+
leaseMetadata: {
|
|
1667
|
+
remoteCwd: "/home/daytona/paperclip-workspace",
|
|
1668
|
+
workspaceSentinel: {
|
|
1669
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
1670
|
+
token: "token-1",
|
|
1671
|
+
},
|
|
1672
|
+
},
|
|
1673
|
+
});
|
|
1674
|
+
// The next command reuses the one live session, so no second session opens.
|
|
1675
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1676
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1677
|
+
// Teardown deletes the one session id, so no shell leaks.
|
|
1678
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1679
|
+
driverKey: "daytona",
|
|
1680
|
+
companyId: "company-1",
|
|
1681
|
+
environmentId: "env-1",
|
|
1682
|
+
providerLeaseId: "sandbox-123",
|
|
1683
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1684
|
+
});
|
|
1685
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledTimes(1);
|
|
1686
|
+
expect(sandbox.process.deleteSession).toHaveBeenCalledWith(sessionId);
|
|
1687
|
+
});
|
|
1688
|
+
it("emits a session.open span on create and a session.close span on delete", async () => {
|
|
1689
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1690
|
+
const sandbox = createMockSandbox();
|
|
1691
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1692
|
+
const { tracer, spans } = makeRecordingTracer();
|
|
1693
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
1694
|
+
try {
|
|
1695
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1696
|
+
const setup = spans.find((span) => span.name === "session.open");
|
|
1697
|
+
expect(setup).toBeDefined();
|
|
1698
|
+
expect(setup.ended).toBe(true);
|
|
1699
|
+
expect(setup.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
1700
|
+
await plugin.definition.onEnvironmentReleaseLease?.({
|
|
1701
|
+
driverKey: "daytona",
|
|
1702
|
+
companyId: "company-1",
|
|
1703
|
+
environmentId: "env-1",
|
|
1704
|
+
providerLeaseId: "sandbox-123",
|
|
1705
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
1706
|
+
});
|
|
1707
|
+
const teardown = spans.find((span) => span.name === "session.close");
|
|
1708
|
+
expect(teardown).toBeDefined();
|
|
1709
|
+
expect(teardown.ended).toBe(true);
|
|
1710
|
+
expect(teardown.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
restore();
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
it("marks the session.open span failed when the session create throws", async () => {
|
|
1717
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1718
|
+
const sandbox = createMockSandbox();
|
|
1719
|
+
sandbox.process.createSession.mockRejectedValueOnce(new Error("create boom"));
|
|
1720
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1721
|
+
const { tracer, spans } = makeRecordingTracer();
|
|
1722
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
1723
|
+
try {
|
|
1724
|
+
await expect(plugin.definition.onEnvironmentExecute?.(sessionExecParams())).rejects.toThrow(/create boom/);
|
|
1725
|
+
const setup = spans.find((span) => span.name === "session.open");
|
|
1726
|
+
expect(setup).toBeDefined();
|
|
1727
|
+
expect(setup.ended).toBe(true);
|
|
1728
|
+
expect(setup.status?.code).toBe(2);
|
|
1729
|
+
}
|
|
1730
|
+
finally {
|
|
1731
|
+
restore();
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
it("dispatches commands into the session and returns separate stdout and stderr", async () => {
|
|
1735
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1736
|
+
const sandbox = createMockSandbox();
|
|
1737
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 7 });
|
|
1738
|
+
// A session command tries the log stream first: it delivers stdout and
|
|
1739
|
+
// stderr from the callback log form.
|
|
1740
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout, onStderr) => {
|
|
1741
|
+
onStdout?.("out-here");
|
|
1742
|
+
onStderr?.("err-here");
|
|
1743
|
+
});
|
|
1744
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1745
|
+
const result = await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1746
|
+
// The command runs through the session, not the one-shot path.
|
|
1747
|
+
expect(sandbox.process.executeSessionCommand).toHaveBeenCalledTimes(1);
|
|
1748
|
+
expect(sandbox.process.executeCommand).not.toHaveBeenCalled();
|
|
1749
|
+
const [sid, req, timeoutArg] = sandbox.process.executeSessionCommand.mock.calls[0];
|
|
1750
|
+
expect(sid).toMatch(/^paperclip-/);
|
|
1751
|
+
expect(req.runAsync).toBe(true);
|
|
1752
|
+
expect(timeoutArg).toBe(1);
|
|
1753
|
+
// The built command carries the login-shell script and the user command.
|
|
1754
|
+
expect(req.command).toMatch(/&& env .*'printf' 'hello'/);
|
|
1755
|
+
// The session command runs plain: no bwrap wrapper and no su privilege drop.
|
|
1756
|
+
expect(req.command).not.toContain("sudo -n bwrap");
|
|
1757
|
+
expect(req.command).not.toContain("su -s /bin/sh");
|
|
1758
|
+
// True separated streams come from the callback log stream.
|
|
1759
|
+
expect(result).toMatchObject({ exitCode: 7, timedOut: false, stdout: "out-here", stderr: "err-here" });
|
|
1760
|
+
expect(typeof result.metadata?.durationMs).toBe("number");
|
|
1761
|
+
});
|
|
1762
|
+
it("wraps each user command in a subshell so a top-level exit cannot kill the session shell", async () => {
|
|
1763
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1764
|
+
const sandbox = createMockSandbox();
|
|
1765
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1766
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams({ command: "exit", args: ["3"] }));
|
|
1767
|
+
const [, req] = sandbox.process.executeSessionCommand.mock.calls[0];
|
|
1768
|
+
// The whole login-shell script (with the user `exit`) runs inside a
|
|
1769
|
+
// subshell, so a top-level exit ends the subshell, not the session shell.
|
|
1770
|
+
expect(req.command.trimStart()).toMatch(/^\(/);
|
|
1771
|
+
expect(req.command.trimEnd()).toMatch(/\)$/);
|
|
1772
|
+
expect(req.command).toMatch(/'exit' '3'/);
|
|
1773
|
+
});
|
|
1774
|
+
it("reuses the same session (one persistent shell) across commands", async () => {
|
|
1775
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1776
|
+
const sandbox = createMockSandbox();
|
|
1777
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1778
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1779
|
+
await plugin.definition.onEnvironmentExecute?.(sessionExecParams());
|
|
1780
|
+
expect(sandbox.process.createSession).toHaveBeenCalledTimes(1);
|
|
1781
|
+
const firstSid = sandbox.process.executeSessionCommand.mock.calls[0][0];
|
|
1782
|
+
const secondSid = sandbox.process.executeSessionCommand.mock.calls[1][0];
|
|
1783
|
+
expect(firstSid).toBe(secondSid);
|
|
1784
|
+
});
|
|
1785
|
+
it("returns a session timeout on the poll fallback when the command never reports an exit code", async () => {
|
|
1786
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1787
|
+
const sandbox = createMockSandbox();
|
|
1788
|
+
// The log stream fails, so the dispatch falls back to the poll path. The
|
|
1789
|
+
// command stays running there: the exit code never arrives, so the poll
|
|
1790
|
+
// deadline fires.
|
|
1791
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout) => {
|
|
1792
|
+
if (onStdout) {
|
|
1793
|
+
throw new Error("socket error");
|
|
1794
|
+
}
|
|
1795
|
+
return { stdout: "", stderr: "" };
|
|
1796
|
+
});
|
|
1797
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: undefined });
|
|
1798
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1799
|
+
const result = await plugin.definition.onEnvironmentExecute?.(sessionExecParams({ timeoutMs: 1 }));
|
|
1800
|
+
expect(result).toMatchObject({ exitCode: null, timedOut: true });
|
|
1801
|
+
expect(result.stderr).toMatch(/timed out/);
|
|
1802
|
+
});
|
|
1803
|
+
describe("log stream (default)", () => {
|
|
1804
|
+
// A session command tries the log stream first. It streams stdout and
|
|
1805
|
+
// stderr from the callback log form instead of the 50-ms poll, and falls
|
|
1806
|
+
// back to the poll only when the stream fails.
|
|
1807
|
+
const streamExecParams = (overrides = {}) => sessionExecParams(overrides);
|
|
1808
|
+
it("streams ordered stdout and stderr from the callback log form (test_log_stream_delivers_ordered_chunks)", async () => {
|
|
1809
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1810
|
+
const sandbox = createMockSandbox();
|
|
1811
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 0 });
|
|
1812
|
+
// The callback form emits stdout and stderr chunks in order. The plugin
|
|
1813
|
+
// keeps each stream in its own arrival order.
|
|
1814
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout, onStderr) => {
|
|
1815
|
+
onStdout?.("out-1;");
|
|
1816
|
+
onStderr?.("err-1;");
|
|
1817
|
+
onStdout?.("out-2;");
|
|
1818
|
+
onStderr?.("err-2;");
|
|
1819
|
+
});
|
|
1820
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1821
|
+
const result = await plugin.definition.onEnvironmentExecute?.(streamExecParams());
|
|
1822
|
+
// The callback stream form ran (four args), not the 50-ms snapshot poll.
|
|
1823
|
+
expect(sandbox.process.getSessionCommandLogs).toHaveBeenCalledTimes(1);
|
|
1824
|
+
const streamCall = sandbox.process.getSessionCommandLogs.mock.calls[0];
|
|
1825
|
+
expect(typeof streamCall[2]).toBe("function");
|
|
1826
|
+
expect(typeof streamCall[3]).toBe("function");
|
|
1827
|
+
expect(result).toMatchObject({
|
|
1828
|
+
exitCode: 0,
|
|
1829
|
+
timedOut: false,
|
|
1830
|
+
stdout: "out-1;out-2;",
|
|
1831
|
+
stderr: "err-1;err-2;",
|
|
1832
|
+
});
|
|
1833
|
+
expect(typeof result.metadata?.durationMs).toBe("number");
|
|
1834
|
+
});
|
|
1835
|
+
it("reads the exit code once after the stream ends (test_log_stream_reads_exit_code_once_after_stream_end)", async () => {
|
|
1836
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1837
|
+
const sandbox = createMockSandbox();
|
|
1838
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 5 });
|
|
1839
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout) => {
|
|
1840
|
+
onStdout?.("done");
|
|
1841
|
+
});
|
|
1842
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1843
|
+
const result = await plugin.definition.onEnvironmentExecute?.(streamExecParams());
|
|
1844
|
+
// The exit code read runs one time after the stream promise resolves.
|
|
1845
|
+
expect(sandbox.process.getSessionCommand).toHaveBeenCalledTimes(1);
|
|
1846
|
+
expect(result).toMatchObject({ exitCode: 5, timedOut: false, stdout: "done" });
|
|
1847
|
+
});
|
|
1848
|
+
it("falls back to the poll path when the stream promise rejects (test_log_stream_disconnect_rejects_and_falls_back_to_poll)", async () => {
|
|
1849
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1850
|
+
const sandbox = createMockSandbox();
|
|
1851
|
+
// The callback form disconnects and rejects. The snapshot form (no
|
|
1852
|
+
// callbacks) serves the poll fallback read.
|
|
1853
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout) => {
|
|
1854
|
+
if (onStdout) {
|
|
1855
|
+
onStdout("partial");
|
|
1856
|
+
throw new Error("socket error");
|
|
1857
|
+
}
|
|
1858
|
+
return { stdout: "poll-out", stderr: "poll-err" };
|
|
1859
|
+
});
|
|
1860
|
+
// The command still runs to its exit on the server, so the poll reads it.
|
|
1861
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 9 });
|
|
1862
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1863
|
+
const result = await plugin.definition.onEnvironmentExecute?.(streamExecParams());
|
|
1864
|
+
// The poll fallback served the final result, and the command still
|
|
1865
|
+
// yielded its exit code.
|
|
1866
|
+
expect(result).toMatchObject({ exitCode: 9, timedOut: false, stdout: "poll-out", stderr: "poll-err" });
|
|
1867
|
+
// The snapshot form (two args) ran for the fallback read.
|
|
1868
|
+
const snapshotCalls = sandbox.process.getSessionCommandLogs.mock.calls.filter((call) => call[2] === undefined);
|
|
1869
|
+
expect(snapshotCalls.length).toBeGreaterThanOrEqual(1);
|
|
1870
|
+
});
|
|
1871
|
+
it("drops the replayed prefix by byte offset on a reconnect (test_log_stream_reconnect_drops_replayed_prefix_by_byte_offset)", async () => {
|
|
1872
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1873
|
+
const sandbox = createMockSandbox();
|
|
1874
|
+
let attempt = 0;
|
|
1875
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout, onStderr) => {
|
|
1876
|
+
attempt += 1;
|
|
1877
|
+
if (attempt === 1) {
|
|
1878
|
+
// First connection: deliver a prefix, then the socket drops.
|
|
1879
|
+
onStdout?.("AAA");
|
|
1880
|
+
onStderr?.("EEE");
|
|
1881
|
+
throw new Error("socket error");
|
|
1882
|
+
}
|
|
1883
|
+
// Reconnect: Daytona replays the whole log from byte 0, then the new
|
|
1884
|
+
// tail. The plugin must drop the replayed prefix.
|
|
1885
|
+
onStdout?.("AAA");
|
|
1886
|
+
onStdout?.("BBB");
|
|
1887
|
+
onStderr?.("EEE");
|
|
1888
|
+
onStderr?.("FFF");
|
|
1889
|
+
});
|
|
1890
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 0 });
|
|
1891
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1892
|
+
const result = await plugin.definition.onEnvironmentExecute?.(streamExecParams());
|
|
1893
|
+
// The pre-disconnect bytes appear one time, not two.
|
|
1894
|
+
expect(result).toMatchObject({ exitCode: 0, timedOut: false, stdout: "AAABBB", stderr: "EEEFFF" });
|
|
1895
|
+
expect(sandbox.process.getSessionCommandLogs).toHaveBeenCalledTimes(2);
|
|
1896
|
+
});
|
|
1897
|
+
it("emits each new chunk to the host and drops a replayed prefix (test_log_stream_emits_execute_log_per_chunk)", async () => {
|
|
1898
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1899
|
+
const executionLog = vi.fn();
|
|
1900
|
+
const restore = __setDaytonaPluginContextForTest({ execution: { log: executionLog } });
|
|
1901
|
+
try {
|
|
1902
|
+
const sandbox = createMockSandbox();
|
|
1903
|
+
let attempt = 0;
|
|
1904
|
+
sandbox.process.getSessionCommandLogs.mockImplementation(async (_sid, _cmdId, onStdout, onStderr) => {
|
|
1905
|
+
attempt += 1;
|
|
1906
|
+
if (attempt === 1) {
|
|
1907
|
+
onStdout?.("AAA");
|
|
1908
|
+
onStderr?.("EEE");
|
|
1909
|
+
throw new Error("socket error");
|
|
1910
|
+
}
|
|
1911
|
+
// Reconnect replays the whole log from byte 0, then the new tail.
|
|
1912
|
+
onStdout?.("AAA");
|
|
1913
|
+
onStdout?.("BBB");
|
|
1914
|
+
onStderr?.("EEE");
|
|
1915
|
+
onStderr?.("FFF");
|
|
1916
|
+
});
|
|
1917
|
+
sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 0 });
|
|
1918
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1919
|
+
await plugin.definition.onEnvironmentExecute?.(streamExecParams());
|
|
1920
|
+
// Each genuinely new chunk reaches the host exactly once. The replayed
|
|
1921
|
+
// prefix ("AAA"/"EEE") on the reconnect is not re-emitted.
|
|
1922
|
+
expect(executionLog.mock.calls).toEqual([
|
|
1923
|
+
["stdout", "AAA"],
|
|
1924
|
+
["stderr", "EEE"],
|
|
1925
|
+
["stdout", "BBB"],
|
|
1926
|
+
["stderr", "FFF"],
|
|
1927
|
+
]);
|
|
1928
|
+
}
|
|
1929
|
+
finally {
|
|
1930
|
+
restore();
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
});
|
|
1934
|
+
});
|
|
1935
|
+
it("executes commands one-shot and returns combined output via stdout", async () => {
|
|
1936
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1937
|
+
const sandbox = createMockSandbox();
|
|
1938
|
+
sandbox.process.executeCommand.mockResolvedValue({
|
|
1939
|
+
exitCode: 7,
|
|
1940
|
+
result: "stdout\nstderr\n",
|
|
1941
|
+
artifacts: { stdout: "stdout\nstderr\n" },
|
|
1942
|
+
});
|
|
1943
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1944
|
+
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
1945
|
+
driverKey: "daytona",
|
|
1946
|
+
companyId: "company-1",
|
|
1947
|
+
environmentId: "env-1",
|
|
1948
|
+
config: {
|
|
1949
|
+
timeoutMs: 300000,
|
|
1950
|
+
reuseLease: false,
|
|
1951
|
+
},
|
|
1952
|
+
bypassSession: true,
|
|
1953
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
1954
|
+
command: "printf",
|
|
1955
|
+
args: ["hello"],
|
|
1956
|
+
cwd: "/workspace",
|
|
1957
|
+
env: { FOO: "bar" },
|
|
1958
|
+
timeoutMs: 1000,
|
|
1959
|
+
});
|
|
1960
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
1961
|
+
const [command, cwdArg, envArg, timeoutArg] = sandbox.process.executeCommand.mock.calls[0];
|
|
1962
|
+
expect(command).toMatch(/\/etc\/profile/);
|
|
1963
|
+
expect(command).toMatch(/"\$HOME\/\.profile"/);
|
|
1964
|
+
expect(command).not.toMatch(/nvm\.sh/);
|
|
1965
|
+
expect(command).toMatch(/&& cd '\/workspace'/);
|
|
1966
|
+
expect(command).toMatch(/&& env GIT_TERMINAL_PROMPT='0' GCM_INTERACTIVE='Never' GIT_ASKPASS='echo' SSH_ASKPASS='echo' SSH_ASKPASS_REQUIRE='force' FOO='bar' 'printf' 'hello'$/);
|
|
1967
|
+
expect(command).not.toMatch(/(?:^|&& )exec /);
|
|
1968
|
+
// cwd/env are baked into the command itself; we pass undefined to the SDK
|
|
1969
|
+
// so its own cwd argument does not run before the caller env is applied.
|
|
1970
|
+
expect(cwdArg).toBeUndefined();
|
|
1971
|
+
expect(envArg).toBeUndefined();
|
|
1972
|
+
expect(timeoutArg).toBe(1);
|
|
1973
|
+
expect(result).toMatchObject({
|
|
1974
|
+
exitCode: 7,
|
|
1975
|
+
timedOut: false,
|
|
1976
|
+
stdout: "stdout\nstderr\n",
|
|
1977
|
+
stderr: "",
|
|
1978
|
+
});
|
|
1979
|
+
// Provider-boundary timings ride the free-form result metadata (Open Q1).
|
|
1980
|
+
expect(typeof result.metadata?.durationMs).toBe("number");
|
|
1981
|
+
expect(typeof result.metadata?.getDurationMs).toBe("number");
|
|
1982
|
+
});
|
|
1983
|
+
it("reports provider executeCommand and client.get durations via result metadata (injected clock)", async () => {
|
|
1984
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
1985
|
+
const sandbox = createMockSandbox();
|
|
1986
|
+
sandbox.process.executeCommand.mockResolvedValue({
|
|
1987
|
+
exitCode: 0,
|
|
1988
|
+
result: "ok",
|
|
1989
|
+
artifacts: { stdout: "ok" },
|
|
1990
|
+
});
|
|
1991
|
+
mockGet.mockResolvedValue(sandbox);
|
|
1992
|
+
// Deterministic clock: getSandbox spans 40ms, executeCommand spans 600ms.
|
|
1993
|
+
// Call order across the execute path is: getStart, getEnd, execStart, execEnd.
|
|
1994
|
+
const ticks = [1000, 1040, 1040, 1640];
|
|
1995
|
+
let i = 0;
|
|
1996
|
+
const restoreClock = setDaytonaTimingClockForTest(() => ticks[Math.min(i++, ticks.length - 1)]);
|
|
1997
|
+
try {
|
|
1998
|
+
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
1999
|
+
driverKey: "daytona",
|
|
2000
|
+
companyId: "company-1",
|
|
2001
|
+
environmentId: "env-1",
|
|
2002
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2003
|
+
bypassSession: true,
|
|
2004
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2005
|
+
command: "printf",
|
|
2006
|
+
args: ["hello"],
|
|
2007
|
+
cwd: "/workspace",
|
|
2008
|
+
timeoutMs: 1000,
|
|
2009
|
+
});
|
|
2010
|
+
expect(result.metadata).toMatchObject({ durationMs: 600, getDurationMs: 40 });
|
|
2011
|
+
}
|
|
2012
|
+
finally {
|
|
2013
|
+
restoreClock();
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
it("sets metadata.cacheHit false on a client.get miss and true on a warm-handle hit", async () => {
|
|
2017
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2018
|
+
const sandbox = createMockSandbox();
|
|
2019
|
+
sandbox.process.executeCommand.mockResolvedValue({
|
|
2020
|
+
exitCode: 0,
|
|
2021
|
+
result: "ok",
|
|
2022
|
+
artifacts: { stdout: "ok" },
|
|
2023
|
+
});
|
|
2024
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2025
|
+
const execParams = {
|
|
2026
|
+
driverKey: "daytona",
|
|
2027
|
+
companyId: "company-1",
|
|
2028
|
+
environmentId: "env-1",
|
|
2029
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2030
|
+
bypassSession: true,
|
|
2031
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2032
|
+
command: "printf",
|
|
2033
|
+
args: ["hello"],
|
|
2034
|
+
cwd: "/workspace",
|
|
2035
|
+
timeoutMs: 1000,
|
|
2036
|
+
};
|
|
2037
|
+
// First execute: the handle cache is empty, so the lookup calls `client.get`
|
|
2038
|
+
// and reports a miss.
|
|
2039
|
+
const first = await plugin.definition.onEnvironmentExecute?.(execParams);
|
|
2040
|
+
expect(first.metadata).toMatchObject({ cacheHit: false });
|
|
2041
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2042
|
+
// Second execute: the warm handle cache serves the handle, so the lookup
|
|
2043
|
+
// makes no `client.get` round trip and reports a hit.
|
|
2044
|
+
const second = await plugin.definition.onEnvironmentExecute?.(execParams);
|
|
2045
|
+
expect(second.metadata).toMatchObject({ cacheHit: true });
|
|
2046
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2047
|
+
});
|
|
2048
|
+
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
|
|
2049
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2050
|
+
const sandbox = createMockSandbox();
|
|
2051
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2052
|
+
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
2053
|
+
driverKey: "daytona",
|
|
2054
|
+
companyId: "company-1",
|
|
2055
|
+
environmentId: "env-1",
|
|
2056
|
+
config: {
|
|
2057
|
+
timeoutMs: 300000,
|
|
2058
|
+
reuseLease: false,
|
|
2059
|
+
},
|
|
2060
|
+
bypassSession: true,
|
|
2061
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2062
|
+
command: "cat",
|
|
2063
|
+
args: [],
|
|
2064
|
+
cwd: "/workspace",
|
|
2065
|
+
stdin: "input payload",
|
|
2066
|
+
timeoutMs: 1000,
|
|
2067
|
+
});
|
|
2068
|
+
expect(sandbox.fs.uploadFile).toHaveBeenCalledWith(Buffer.from("input payload", "utf8"), expect.stringMatching(/^\/tmp\/paperclip-stdin-/), 1);
|
|
2069
|
+
const [command] = sandbox.process.executeCommand.mock.calls[0];
|
|
2070
|
+
expect(command).toMatch(/\/etc\/profile/);
|
|
2071
|
+
expect(command).not.toMatch(/nvm\.sh/);
|
|
2072
|
+
expect(command).toMatch(/&& cd '\/workspace'/);
|
|
2073
|
+
expect(command).toMatch(/env .* 'cat' < '\/tmp\/paperclip-stdin-/);
|
|
2074
|
+
expect(command).not.toMatch(/(?:^|&& )exec /);
|
|
2075
|
+
expect(sandbox.fs.deleteFile).toHaveBeenCalledWith(expect.stringMatching(/^\/tmp\/paperclip-stdin-/));
|
|
2076
|
+
expect(result).toMatchObject({
|
|
2077
|
+
exitCode: 0,
|
|
2078
|
+
timedOut: false,
|
|
2079
|
+
});
|
|
2080
|
+
});
|
|
2081
|
+
it("runs the one-shot command plain with no bwrap or su wrapper", async () => {
|
|
2082
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2083
|
+
const sandbox = createMockSandbox();
|
|
2084
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2085
|
+
await plugin.definition.onEnvironmentExecute?.({
|
|
2086
|
+
driverKey: "daytona",
|
|
2087
|
+
companyId: "company-1",
|
|
2088
|
+
environmentId: "env-1",
|
|
2089
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2090
|
+
bypassSession: true,
|
|
2091
|
+
lease: { providerLeaseId: "sandbox-123", metadata: { remoteCwd: "/home/daytona/paperclip-workspace" } },
|
|
2092
|
+
command: "printf",
|
|
2093
|
+
args: ["hello"],
|
|
2094
|
+
cwd: "/workspace",
|
|
2095
|
+
timeoutMs: 1000,
|
|
2096
|
+
});
|
|
2097
|
+
const [command] = sandbox.process.executeCommand.mock.calls[0];
|
|
2098
|
+
expect(command).not.toContain("sudo -n bwrap");
|
|
2099
|
+
expect(command).not.toContain("su -s /bin/sh");
|
|
2100
|
+
expect(command).toMatch(/'printf' 'hello'$/);
|
|
2101
|
+
});
|
|
2102
|
+
it("rejects invalid shell env keys before execution", async () => {
|
|
2103
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2104
|
+
const sandbox = createMockSandbox();
|
|
2105
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2106
|
+
await expect(plugin.definition.onEnvironmentExecute?.({
|
|
2107
|
+
driverKey: "daytona",
|
|
2108
|
+
companyId: "company-1",
|
|
2109
|
+
environmentId: "env-1",
|
|
2110
|
+
config: {
|
|
2111
|
+
timeoutMs: 300000,
|
|
2112
|
+
reuseLease: false,
|
|
2113
|
+
},
|
|
2114
|
+
bypassSession: true,
|
|
2115
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2116
|
+
command: "printf",
|
|
2117
|
+
args: ["hello"],
|
|
2118
|
+
env: { "BAD-KEY": "bar" },
|
|
2119
|
+
})).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
|
|
2120
|
+
expect(sandbox.process.executeCommand).not.toHaveBeenCalled();
|
|
2121
|
+
});
|
|
2122
|
+
it("returns a timed out execute result when the Daytona SDK times out", async () => {
|
|
2123
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2124
|
+
const sandbox = createMockSandbox();
|
|
2125
|
+
sandbox.process.executeCommand.mockRejectedValue(new MockDaytonaTimeoutError("command timed out"));
|
|
2126
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2127
|
+
// Injected clock: getStart=0, getEnd=10 (getDurationMs=10), execStart=20,
|
|
2128
|
+
// execEnd/timeout=1520 (durationMs=1500). Deterministic so the timeout path's
|
|
2129
|
+
// provider-exec attribution is asserted exactly.
|
|
2130
|
+
const ticks = [0, 10, 20, 1520];
|
|
2131
|
+
let i = 0;
|
|
2132
|
+
const restoreClock = setDaytonaTimingClockForTest(() => ticks[Math.min(i++, ticks.length - 1)]);
|
|
2133
|
+
let result;
|
|
2134
|
+
try {
|
|
2135
|
+
result = await plugin.definition.onEnvironmentExecute?.({
|
|
2136
|
+
driverKey: "daytona",
|
|
2137
|
+
companyId: "company-1",
|
|
2138
|
+
environmentId: "env-1",
|
|
2139
|
+
config: {
|
|
2140
|
+
timeoutMs: 300000,
|
|
2141
|
+
reuseLease: false,
|
|
2142
|
+
},
|
|
2143
|
+
bypassSession: true,
|
|
2144
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2145
|
+
command: "sleep",
|
|
2146
|
+
args: ["60"],
|
|
2147
|
+
cwd: "/workspace",
|
|
2148
|
+
timeoutMs: 1000,
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
finally {
|
|
2152
|
+
restoreClock();
|
|
2153
|
+
}
|
|
2154
|
+
expect(result).toMatchObject({
|
|
2155
|
+
exitCode: null,
|
|
2156
|
+
timedOut: true,
|
|
2157
|
+
stdout: "",
|
|
2158
|
+
stderr: "command timed out\n",
|
|
2159
|
+
});
|
|
2160
|
+
// The exec reached executeCommand before timing out, so its wall-time is
|
|
2161
|
+
// preserved as durationMs (provider-exec attribution is not dropped on the
|
|
2162
|
+
// timeout path); the getSandbox re-fetch duration is also reported.
|
|
2163
|
+
expect(result.metadata).toMatchObject({ durationMs: 1500, getDurationMs: 10 });
|
|
2164
|
+
});
|
|
2165
|
+
it("injects noninteractive git credential defaults for every one-shot command", async () => {
|
|
2166
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2167
|
+
const sandbox = createMockSandbox();
|
|
2168
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2169
|
+
await plugin.definition.onEnvironmentExecute?.({
|
|
2170
|
+
driverKey: "daytona",
|
|
2171
|
+
companyId: "company-1",
|
|
2172
|
+
environmentId: "env-1",
|
|
2173
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2174
|
+
bypassSession: true,
|
|
2175
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2176
|
+
command: "git",
|
|
2177
|
+
args: ["status"],
|
|
2178
|
+
timeoutMs: 5000,
|
|
2179
|
+
});
|
|
2180
|
+
const [command] = sandbox.process.executeCommand.mock.calls[0];
|
|
2181
|
+
expect(command).toContain("GIT_TERMINAL_PROMPT='0'");
|
|
2182
|
+
expect(command).toContain("GCM_INTERACTIVE='Never'");
|
|
2183
|
+
expect(command).toContain("GIT_ASKPASS='echo'");
|
|
2184
|
+
expect(command).toContain("SSH_ASKPASS='echo'");
|
|
2185
|
+
expect(command).toContain("SSH_ASKPASS_REQUIRE='force'");
|
|
2186
|
+
});
|
|
2187
|
+
it("caps git network commands at 120 s and returns an actionable message on timeout", async () => {
|
|
2188
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2189
|
+
const sandbox = createMockSandbox();
|
|
2190
|
+
sandbox.process.executeCommand.mockRejectedValue(new MockDaytonaTimeoutError("timed out"));
|
|
2191
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2192
|
+
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
2193
|
+
driverKey: "daytona",
|
|
2194
|
+
companyId: "company-1",
|
|
2195
|
+
environmentId: "env-1",
|
|
2196
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2197
|
+
bypassSession: true,
|
|
2198
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2199
|
+
command: "git",
|
|
2200
|
+
args: ["push", "origin", "HEAD"],
|
|
2201
|
+
cwd: "/workspace",
|
|
2202
|
+
timeoutMs: 300000,
|
|
2203
|
+
});
|
|
2204
|
+
const [, , , timeoutArg] = sandbox.process.executeCommand.mock.calls[0];
|
|
2205
|
+
expect(timeoutArg).toBe(120);
|
|
2206
|
+
expect(result).toMatchObject({ exitCode: null, timedOut: true });
|
|
2207
|
+
expect(result?.stderr).toMatch(/unreachable|credentials/i);
|
|
2208
|
+
});
|
|
2209
|
+
// ─── Exec command shape ────────────────────────────────────────────────────
|
|
2210
|
+
// The wrapper sources the login profiles so `node` resolves on the reference
|
|
2211
|
+
// image, then runs the command. It no longer sources `nvm.sh`, while every
|
|
2212
|
+
// other exec surface (env prefix, cwd, quoting, stdin, durationMs) stays
|
|
2213
|
+
// intact.
|
|
2214
|
+
it("test_exec_command_preserves_env_cwd_and_duration", async () => {
|
|
2215
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2216
|
+
const sandbox = createMockSandbox();
|
|
2217
|
+
sandbox.process.executeCommand.mockResolvedValue({
|
|
2218
|
+
exitCode: 0,
|
|
2219
|
+
result: "ok",
|
|
2220
|
+
artifacts: { stdout: "ok" },
|
|
2221
|
+
});
|
|
2222
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2223
|
+
const result = await plugin.definition.onEnvironmentExecute?.({
|
|
2224
|
+
driverKey: "daytona",
|
|
2225
|
+
companyId: "company-1",
|
|
2226
|
+
environmentId: "env-1",
|
|
2227
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2228
|
+
bypassSession: true,
|
|
2229
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2230
|
+
command: "base64",
|
|
2231
|
+
args: ["-d"],
|
|
2232
|
+
cwd: "/workspace",
|
|
2233
|
+
env: { FOO: "bar" },
|
|
2234
|
+
timeoutMs: 1000,
|
|
2235
|
+
});
|
|
2236
|
+
const [command] = sandbox.process.executeCommand.mock.calls[0];
|
|
2237
|
+
// The command sources the login profiles first, then runs the `cd` and the
|
|
2238
|
+
// env prefix with the noninteractive git defaults.
|
|
2239
|
+
expect(command).toMatch(/^if \[ -f \/etc\/profile \]/);
|
|
2240
|
+
expect(command).toMatch(/&& cd '\/workspace' && env /);
|
|
2241
|
+
expect(command).toMatch(/GIT_TERMINAL_PROMPT='0'/);
|
|
2242
|
+
expect(command).toMatch(/FOO='bar' 'base64' '-d'$/);
|
|
2243
|
+
expect(command).toMatch(/\/etc\/profile/);
|
|
2244
|
+
expect(command).not.toMatch(/nvm\.sh/);
|
|
2245
|
+
// durationMs attribution stays intact.
|
|
2246
|
+
expect(typeof result.metadata?.durationMs).toBe("number");
|
|
2247
|
+
});
|
|
2248
|
+
it("test_exec_command_sources_profile_without_nvm", async () => {
|
|
2249
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2250
|
+
const sandbox = createMockSandbox();
|
|
2251
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2252
|
+
// A node-launching exec resolves `node` through the login profiles, which
|
|
2253
|
+
// Daytona's non-login `executeCommand` shell does not source on its own. The
|
|
2254
|
+
// wrapper sources the profiles but no longer sources `nvm.sh`.
|
|
2255
|
+
await plugin.definition.onEnvironmentExecute?.({
|
|
2256
|
+
driverKey: "daytona",
|
|
2257
|
+
companyId: "company-1",
|
|
2258
|
+
environmentId: "env-1",
|
|
2259
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2260
|
+
bypassSession: true,
|
|
2261
|
+
lease: { providerLeaseId: "sandbox-123", metadata: {} },
|
|
2262
|
+
command: "node",
|
|
2263
|
+
args: ["--version"],
|
|
2264
|
+
cwd: "/workspace",
|
|
2265
|
+
timeoutMs: 1000,
|
|
2266
|
+
});
|
|
2267
|
+
const [command] = sandbox.process.executeCommand.mock.calls[0];
|
|
2268
|
+
expect(command).toMatch(/\/etc\/profile/);
|
|
2269
|
+
expect(command).toMatch(/"\$HOME\/\.profile"/);
|
|
2270
|
+
expect(command).not.toMatch(/nvm\.sh/);
|
|
2271
|
+
expect(command).not.toMatch(/NVM_DIR/);
|
|
2272
|
+
});
|
|
2273
|
+
// ─── Per-lease started-sandbox handle cache ────────────────────────────────
|
|
2274
|
+
// These prove the security conditions: single-fetch-per-lease, strict
|
|
2275
|
+
// composite-key isolation (no cross-lease / cross-company / cross-env reuse),
|
|
2276
|
+
// eviction at every teardown, no caching of failed populates, single-flight
|
|
2277
|
+
// concurrency, and sentinel re-verification on a cached resume — plus that a
|
|
2278
|
+
// handle left idle past the provider auto-stop window is refreshed before
|
|
2279
|
+
// reuse so a provider-initiated stop is not hidden behind a stale snapshot.
|
|
2280
|
+
describe("started-sandbox handle cache", () => {
|
|
2281
|
+
function execParams(providerLeaseId, overrides = {}) {
|
|
2282
|
+
return {
|
|
2283
|
+
driverKey: overrides.driverKey ?? "daytona",
|
|
2284
|
+
companyId: overrides.companyId ?? "company-1",
|
|
2285
|
+
environmentId: overrides.environmentId ?? "env-1",
|
|
2286
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2287
|
+
bypassSession: true,
|
|
2288
|
+
lease: { providerLeaseId, metadata: {} },
|
|
2289
|
+
command: "printf",
|
|
2290
|
+
args: ["hi"],
|
|
2291
|
+
timeoutMs: 1000,
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
it("reuses the cached handle across execs on one lease (single client.get)", async () => {
|
|
2295
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2296
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2297
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2298
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2299
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2300
|
+
// Second exec is served from the cache: no second REST re-fetch.
|
|
2301
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2302
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(2);
|
|
2303
|
+
});
|
|
2304
|
+
it("keeps getDurationMs present (≈0) on a cache hit", async () => {
|
|
2305
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2306
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2307
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2308
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2309
|
+
const hit = await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2310
|
+
// The observability contract holds even when the fetch is elided.
|
|
2311
|
+
expect(typeof hit.metadata?.getDurationMs).toBe("number");
|
|
2312
|
+
});
|
|
2313
|
+
it("never serves lease A's handle to lease B (distinct fetch per lease)", async () => {
|
|
2314
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2315
|
+
const sandboxA = createMockSandbox({ id: "lease-a" });
|
|
2316
|
+
const sandboxB = createMockSandbox({ id: "lease-b" });
|
|
2317
|
+
mockGet.mockImplementation(async (id) => (id === "lease-a" ? sandboxA : sandboxB));
|
|
2318
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2319
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-b"));
|
|
2320
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
2321
|
+
expect(sandboxA.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2322
|
+
expect(sandboxB.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2323
|
+
});
|
|
2324
|
+
it("does not share a handle across companies or environments for the same providerLeaseId", async () => {
|
|
2325
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2326
|
+
mockGet.mockImplementation(async () => createMockSandbox({ id: "sandbox-x" }));
|
|
2327
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("sandbox-x", { companyId: "company-1", environmentId: "env-1" }));
|
|
2328
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("sandbox-x", { companyId: "company-2", environmentId: "env-1" }));
|
|
2329
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("sandbox-x", { companyId: "company-1", environmentId: "env-2" }));
|
|
2330
|
+
// Three distinct composite keys → three independent fetches; the bare
|
|
2331
|
+
// providerLeaseId is never a shared cache slot.
|
|
2332
|
+
expect(mockGet).toHaveBeenCalledTimes(3);
|
|
2333
|
+
});
|
|
2334
|
+
it("keeps account credentials and API endpoints isolated for the same sandbox ID", async () => {
|
|
2335
|
+
mockGet.mockImplementation(async () => createMockSandbox({ id: "sandbox-account" }));
|
|
2336
|
+
const params = execParams("sandbox-account");
|
|
2337
|
+
for (const config of [
|
|
2338
|
+
{ apiKey: "account-a", apiUrl: "https://one.daytona.test/api" },
|
|
2339
|
+
{ apiKey: "account-b", apiUrl: "https://one.daytona.test/api" },
|
|
2340
|
+
{ apiKey: "account-a", apiUrl: "https://two.daytona.test/api" },
|
|
2341
|
+
]) {
|
|
2342
|
+
await plugin.definition.onEnvironmentExecute({
|
|
2343
|
+
...params, config: { ...params.config, ...config },
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
expect(mockGet).toHaveBeenCalledTimes(3);
|
|
2347
|
+
});
|
|
2348
|
+
it("rejects a queued execute after release teardown closes the lease", async () => {
|
|
2349
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2350
|
+
mockGet.mockImplementation(async () => createMockSandbox({ id: "lease-a" }));
|
|
2351
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a")); // miss → get #1 (cached)
|
|
2352
|
+
const releasePromise = plugin.definition.onEnvironmentReleaseLease?.({
|
|
2353
|
+
driverKey: "daytona",
|
|
2354
|
+
companyId: "company-1",
|
|
2355
|
+
environmentId: "env-1",
|
|
2356
|
+
providerLeaseId: "lease-a",
|
|
2357
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2358
|
+
});
|
|
2359
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2360
|
+
const queuedExecute = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2361
|
+
await expect(queuedExecute).rejects.toThrow(/no longer active/);
|
|
2362
|
+
await releasePromise;
|
|
2363
|
+
// The tombstone closes the lease, so the queued execute never reacquires
|
|
2364
|
+
// the sandbox after teardown.
|
|
2365
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2366
|
+
});
|
|
2367
|
+
it("rejects an overlapping execute after release teardown closes the lease", async () => {
|
|
2368
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2369
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2370
|
+
let stopStarted = false;
|
|
2371
|
+
let resolveRelease;
|
|
2372
|
+
const releaseGate = new Promise((resolve) => {
|
|
2373
|
+
resolveRelease = resolve;
|
|
2374
|
+
});
|
|
2375
|
+
sandbox.stop.mockImplementation(() => {
|
|
2376
|
+
stopStarted = true;
|
|
2377
|
+
return releaseGate;
|
|
2378
|
+
});
|
|
2379
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2380
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2381
|
+
const releasePromise = plugin.definition.onEnvironmentReleaseLease?.({
|
|
2382
|
+
driverKey: "daytona",
|
|
2383
|
+
companyId: "company-1",
|
|
2384
|
+
environmentId: "env-1",
|
|
2385
|
+
providerLeaseId: "lease-a",
|
|
2386
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2387
|
+
});
|
|
2388
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2389
|
+
expect(stopStarted).toBe(true);
|
|
2390
|
+
const overlappingExec = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2391
|
+
await expect(overlappingExec).rejects.toThrow(/no longer active/);
|
|
2392
|
+
resolveRelease();
|
|
2393
|
+
await releasePromise;
|
|
2394
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2395
|
+
expect(sandbox.stop).toHaveBeenCalledTimes(1);
|
|
2396
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2397
|
+
});
|
|
2398
|
+
it("rejects a late execute after the release tombstone is set", async () => {
|
|
2399
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2400
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2401
|
+
let stopStarted = false;
|
|
2402
|
+
let resolveRelease;
|
|
2403
|
+
const releaseGate = new Promise((resolve) => {
|
|
2404
|
+
resolveRelease = resolve;
|
|
2405
|
+
});
|
|
2406
|
+
sandbox.stop.mockImplementation(() => {
|
|
2407
|
+
stopStarted = true;
|
|
2408
|
+
return releaseGate;
|
|
2409
|
+
});
|
|
2410
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2411
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2412
|
+
const releasePromise = plugin.definition.onEnvironmentReleaseLease?.({
|
|
2413
|
+
driverKey: "daytona",
|
|
2414
|
+
companyId: "company-1",
|
|
2415
|
+
environmentId: "env-1",
|
|
2416
|
+
providerLeaseId: "lease-a",
|
|
2417
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2418
|
+
});
|
|
2419
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2420
|
+
expect(stopStarted).toBe(true);
|
|
2421
|
+
const overlappingExec = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2422
|
+
await expect(overlappingExec).rejects.toThrow(/no longer active/);
|
|
2423
|
+
resolveRelease();
|
|
2424
|
+
await releasePromise;
|
|
2425
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2426
|
+
expect(sandbox.stop).toHaveBeenCalledTimes(1);
|
|
2427
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2428
|
+
});
|
|
2429
|
+
it("cancels active work before waiting for a stalled execute to drain", async () => {
|
|
2430
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2431
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2432
|
+
let release;
|
|
2433
|
+
sandbox.process.executeCommand.mockImplementation(async () => {
|
|
2434
|
+
await new Promise(resolve => { release = resolve; });
|
|
2435
|
+
return { exitCode: 0, result: "", artifacts: { stdout: "" } };
|
|
2436
|
+
});
|
|
2437
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2438
|
+
const execute = plugin.definition.onEnvironmentExecute(execParams("lease-a"));
|
|
2439
|
+
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
|
|
2440
|
+
const cancellation = plugin.definition.onEnvironmentReleaseLease({
|
|
2441
|
+
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
|
2442
|
+
providerLeaseId: "lease-a", config: { timeoutMs: 300000, reuseLease: true },
|
|
2443
|
+
cancelActiveWork: true,
|
|
2444
|
+
});
|
|
2445
|
+
try {
|
|
2446
|
+
await vi.waitFor(() => expect(sandbox.stop).toHaveBeenCalledTimes(1), { timeout: 500 });
|
|
2447
|
+
await expect(cancellation).resolves.toEqual({ providerLeaseId: "lease-a", state: "stopped" });
|
|
2448
|
+
await expect(plugin.definition.onEnvironmentExecute(execParams("lease-a"))).rejects.toThrow(/no longer active/);
|
|
2449
|
+
await expect(plugin.definition.onEnvironmentResumeLease({
|
|
2450
|
+
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
|
2451
|
+
providerLeaseId: "lease-a", config: { timeoutMs: 300000, reuseLease: true },
|
|
2452
|
+
})).rejects.toThrow(/still settling cancelled work/);
|
|
2453
|
+
expect(sandbox.start).not.toHaveBeenCalled();
|
|
2454
|
+
}
|
|
2455
|
+
finally {
|
|
2456
|
+
release();
|
|
2457
|
+
await execute;
|
|
2458
|
+
await cancellation;
|
|
2459
|
+
}
|
|
2460
|
+
});
|
|
2461
|
+
it("does not report termination when the provider rejects Stop", async () => {
|
|
2462
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2463
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2464
|
+
sandbox.stop.mockRejectedValue(new Error("provider unavailable"));
|
|
2465
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2466
|
+
await expect(plugin.definition.onEnvironmentReleaseLease({
|
|
2467
|
+
driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
|
2468
|
+
providerLeaseId: "lease-a", config: { timeoutMs: 300000, reuseLease: true },
|
|
2469
|
+
cancelActiveWork: true,
|
|
2470
|
+
})).rejects.toThrow("provider unavailable");
|
|
2471
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
2472
|
+
await expect(plugin.definition.onEnvironmentExecute(execParams("lease-a"))).rejects.toThrow(/no longer active/);
|
|
2473
|
+
});
|
|
2474
|
+
it.each(["release", "destroy"])("%s terminates through the provider when bridge activity never settles", async (kind) => {
|
|
2475
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2476
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2477
|
+
let finish;
|
|
2478
|
+
sandbox.process.executeCommand.mockImplementation(async () => {
|
|
2479
|
+
await new Promise(resolve => { finish = resolve; });
|
|
2480
|
+
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
|
|
2481
|
+
});
|
|
2482
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2483
|
+
const execute = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2484
|
+
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
|
|
2485
|
+
const params = { driverKey: "daytona", companyId: "company-1", environmentId: "env-1",
|
|
2486
|
+
providerLeaseId: "lease-a", config: { timeoutMs: 300000, livenessTimeoutMs: 5, reuseLease: true } };
|
|
2487
|
+
try {
|
|
2488
|
+
const receipt = kind === "release"
|
|
2489
|
+
? await plugin.definition.onEnvironmentReleaseLease?.(params)
|
|
2490
|
+
: await plugin.definition.onEnvironmentDestroyLease?.(params);
|
|
2491
|
+
expect(receipt).toEqual({ providerLeaseId: "lease-a", state: kind === "release" ? "stopped" : "destroyed" });
|
|
2492
|
+
expect(kind === "release" ? sandbox.stop : sandbox.delete).toHaveBeenCalledTimes(1);
|
|
2493
|
+
await expect(plugin.definition.onEnvironmentExecute?.(execParams("lease-a"))).rejects.toThrow(/no longer active/);
|
|
2494
|
+
}
|
|
2495
|
+
finally {
|
|
2496
|
+
finish();
|
|
2497
|
+
await execute;
|
|
2498
|
+
}
|
|
2499
|
+
});
|
|
2500
|
+
it("waits for an in-flight execute before teardown cleanup starts", async () => {
|
|
2501
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2502
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2503
|
+
let resolveExecute;
|
|
2504
|
+
sandbox.process.executeCommand.mockImplementation(async () => {
|
|
2505
|
+
await new Promise((resolve) => {
|
|
2506
|
+
resolveExecute = resolve;
|
|
2507
|
+
});
|
|
2508
|
+
return {
|
|
2509
|
+
exitCode: 0,
|
|
2510
|
+
result: "bash",
|
|
2511
|
+
artifacts: { stdout: "bash" },
|
|
2512
|
+
};
|
|
2513
|
+
});
|
|
2514
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2515
|
+
const executePromise = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2516
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2517
|
+
const releasePromise = plugin.definition.onEnvironmentReleaseLease?.({
|
|
2518
|
+
driverKey: "daytona",
|
|
2519
|
+
companyId: "company-1",
|
|
2520
|
+
environmentId: "env-1",
|
|
2521
|
+
providerLeaseId: "lease-a",
|
|
2522
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2523
|
+
});
|
|
2524
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2525
|
+
expect(sandbox.stop).not.toHaveBeenCalled();
|
|
2526
|
+
resolveExecute();
|
|
2527
|
+
await Promise.all([executePromise, releasePromise]);
|
|
2528
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2529
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2530
|
+
expect(sandbox.stop).toHaveBeenCalledTimes(1);
|
|
2531
|
+
});
|
|
2532
|
+
it("keeps the teardown gate closed until overlapping teardowns both finish", async () => {
|
|
2533
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2534
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2535
|
+
let stopStarted = false;
|
|
2536
|
+
let deleteStarted = false;
|
|
2537
|
+
let resolveStop;
|
|
2538
|
+
let resolveDelete;
|
|
2539
|
+
const stopGate = new Promise((resolve) => {
|
|
2540
|
+
resolveStop = resolve;
|
|
2541
|
+
});
|
|
2542
|
+
const deleteGate = new Promise((resolve) => {
|
|
2543
|
+
resolveDelete = resolve;
|
|
2544
|
+
});
|
|
2545
|
+
sandbox.stop.mockImplementation(() => {
|
|
2546
|
+
stopStarted = true;
|
|
2547
|
+
return stopGate;
|
|
2548
|
+
});
|
|
2549
|
+
sandbox.delete.mockImplementation(() => {
|
|
2550
|
+
deleteStarted = true;
|
|
2551
|
+
return deleteGate;
|
|
2552
|
+
});
|
|
2553
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2554
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2555
|
+
const releasePromise = plugin.definition.onEnvironmentReleaseLease?.({
|
|
2556
|
+
driverKey: "daytona",
|
|
2557
|
+
companyId: "company-1",
|
|
2558
|
+
environmentId: "env-1",
|
|
2559
|
+
providerLeaseId: "lease-a",
|
|
2560
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2561
|
+
});
|
|
2562
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2563
|
+
expect(stopStarted).toBe(true);
|
|
2564
|
+
const destroyPromise = plugin.definition.onEnvironmentDestroyLease?.({
|
|
2565
|
+
driverKey: "daytona",
|
|
2566
|
+
companyId: "company-1",
|
|
2567
|
+
environmentId: "env-1",
|
|
2568
|
+
providerLeaseId: "lease-a",
|
|
2569
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2570
|
+
});
|
|
2571
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2572
|
+
expect(deleteStarted).toBe(true);
|
|
2573
|
+
resolveDelete();
|
|
2574
|
+
await destroyPromise;
|
|
2575
|
+
const overlappingExec = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2576
|
+
await expect(overlappingExec).rejects.toThrow(/no longer active/);
|
|
2577
|
+
resolveStop();
|
|
2578
|
+
await releasePromise;
|
|
2579
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
2580
|
+
expect(sandbox.stop).toHaveBeenCalledTimes(1);
|
|
2581
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
2582
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2583
|
+
});
|
|
2584
|
+
it("rejects a queued execute after destroy teardown closes the lease", async () => {
|
|
2585
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2586
|
+
mockGet.mockImplementation(async () => createMockSandbox({ id: "lease-a" }));
|
|
2587
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2588
|
+
const destroyPromise = plugin.definition.onEnvironmentDestroyLease?.({
|
|
2589
|
+
driverKey: "daytona",
|
|
2590
|
+
companyId: "company-1",
|
|
2591
|
+
environmentId: "env-1",
|
|
2592
|
+
providerLeaseId: "lease-a",
|
|
2593
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2594
|
+
});
|
|
2595
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2596
|
+
const queuedExecute = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2597
|
+
await expect(queuedExecute).rejects.toThrow(/no longer active/);
|
|
2598
|
+
await destroyPromise;
|
|
2599
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2600
|
+
});
|
|
2601
|
+
it("rejects a queued execute after interactive cancel closes the lease", async () => {
|
|
2602
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2603
|
+
mockGet.mockImplementation(async () => createMockSandbox({ id: "lease-a" }));
|
|
2604
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2605
|
+
const cancelPromise = plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
|
2606
|
+
driverKey: "daytona",
|
|
2607
|
+
companyId: "company-1",
|
|
2608
|
+
environmentId: "env-1",
|
|
2609
|
+
providerLeaseId: "lease-a",
|
|
2610
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2611
|
+
reason: "cancelled",
|
|
2612
|
+
});
|
|
2613
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2614
|
+
const queuedExecute = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2615
|
+
await expect(queuedExecute).rejects.toThrow(/no longer active/);
|
|
2616
|
+
await cancelPromise;
|
|
2617
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2618
|
+
});
|
|
2619
|
+
it("waits for an in-flight execute before interactive cancel cleanup starts", async () => {
|
|
2620
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2621
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2622
|
+
let resolveExecute;
|
|
2623
|
+
sandbox.process.executeCommand.mockImplementation(async () => {
|
|
2624
|
+
await new Promise((resolve) => {
|
|
2625
|
+
resolveExecute = resolve;
|
|
2626
|
+
});
|
|
2627
|
+
return {
|
|
2628
|
+
exitCode: 0,
|
|
2629
|
+
result: "bash",
|
|
2630
|
+
artifacts: { stdout: "bash" },
|
|
2631
|
+
};
|
|
2632
|
+
});
|
|
2633
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2634
|
+
const executePromise = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2635
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2636
|
+
const cancelPromise = plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
|
2637
|
+
driverKey: "daytona",
|
|
2638
|
+
companyId: "company-1",
|
|
2639
|
+
environmentId: "env-1",
|
|
2640
|
+
providerLeaseId: "lease-a",
|
|
2641
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2642
|
+
reason: "cancelled",
|
|
2643
|
+
});
|
|
2644
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2645
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
2646
|
+
resolveExecute();
|
|
2647
|
+
await Promise.all([executePromise, cancelPromise]);
|
|
2648
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2649
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2650
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
2651
|
+
});
|
|
2652
|
+
it("waits for an in-flight syncIn before interactive cancel cleanup starts", async () => {
|
|
2653
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2654
|
+
const hostDir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-cancel-sync-"));
|
|
2655
|
+
const source = path.join(hostDir, "payload.txt");
|
|
2656
|
+
await fs.writeFile(source, "payload");
|
|
2657
|
+
const remoteDir = "/home/daytona/paperclip-workspace";
|
|
2658
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2659
|
+
let resolveUpload;
|
|
2660
|
+
sandbox.fs.uploadFiles.mockImplementation(async () => {
|
|
2661
|
+
await new Promise((resolve) => {
|
|
2662
|
+
resolveUpload = resolve;
|
|
2663
|
+
});
|
|
2664
|
+
});
|
|
2665
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2666
|
+
const syncPromise = plugin.definition.onEnvironmentSyncIn?.({
|
|
2667
|
+
driverKey: "daytona",
|
|
2668
|
+
companyId: "company-1",
|
|
2669
|
+
environmentId: "env-1",
|
|
2670
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2671
|
+
lease: { providerLeaseId: "lease-a", metadata: { remoteCwd: remoteDir } },
|
|
2672
|
+
operations: [
|
|
2673
|
+
{
|
|
2674
|
+
operationId: "sync-op-1",
|
|
2675
|
+
files: [{ sourcePath: source, targetPath: `${remoteDir}/payload.txt`, kind: "file" }],
|
|
2676
|
+
},
|
|
2677
|
+
],
|
|
2678
|
+
});
|
|
2679
|
+
// Let syncIn register on the activity gate and reach the hung upload.
|
|
2680
|
+
// The real `fs.stat` and `mkdir` round trip run before the upload call,
|
|
2681
|
+
// so a fixed tick count can race ahead of them on a slower or busier
|
|
2682
|
+
// host. Poll for the actual upload call instead of guessing a tick
|
|
2683
|
+
// count, so this assertion never fires before syncIn reaches the hang.
|
|
2684
|
+
await vi.waitFor(() => {
|
|
2685
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalled();
|
|
2686
|
+
});
|
|
2687
|
+
const cancelPromise = plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
|
2688
|
+
driverKey: "daytona",
|
|
2689
|
+
companyId: "company-1",
|
|
2690
|
+
environmentId: "env-1",
|
|
2691
|
+
providerLeaseId: "lease-a",
|
|
2692
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2693
|
+
reason: "cancelled",
|
|
2694
|
+
});
|
|
2695
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2696
|
+
// Cancel must drain the active sync before deleting the sandbox out from
|
|
2697
|
+
// under it — the same activity-gate contract the execute path relies on.
|
|
2698
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
2699
|
+
resolveUpload();
|
|
2700
|
+
await Promise.all([syncPromise, cancelPromise]);
|
|
2701
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
|
|
2702
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
2703
|
+
await fs.rm(hostDir, { recursive: true, force: true });
|
|
2704
|
+
});
|
|
2705
|
+
it("rejects a queued execute once interactive cancel tombstones the lease", async () => {
|
|
2706
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2707
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2708
|
+
let resolveFirstExecute;
|
|
2709
|
+
let cancelResolved = false;
|
|
2710
|
+
let queuedExecuteRejected = false;
|
|
2711
|
+
sandbox.process.executeCommand.mockImplementation(async () => {
|
|
2712
|
+
await new Promise((resolve) => {
|
|
2713
|
+
resolveFirstExecute = resolve;
|
|
2714
|
+
});
|
|
2715
|
+
return {
|
|
2716
|
+
exitCode: 0,
|
|
2717
|
+
result: "bash",
|
|
2718
|
+
artifacts: { stdout: "bash" },
|
|
2719
|
+
};
|
|
2720
|
+
});
|
|
2721
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2722
|
+
const firstExecutePromise = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2723
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2724
|
+
const cancelPromise = plugin.definition.onEnvironmentCancelInteractiveSetup?.({
|
|
2725
|
+
driverKey: "daytona",
|
|
2726
|
+
companyId: "company-1",
|
|
2727
|
+
environmentId: "env-1",
|
|
2728
|
+
providerLeaseId: "lease-a",
|
|
2729
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2730
|
+
reason: "cancelled",
|
|
2731
|
+
});
|
|
2732
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2733
|
+
const queuedExecutePromise = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2734
|
+
queuedExecutePromise?.catch(() => {
|
|
2735
|
+
queuedExecuteRejected = true;
|
|
2736
|
+
});
|
|
2737
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2738
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2739
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
2740
|
+
resolveFirstExecute();
|
|
2741
|
+
await cancelPromise.then(() => {
|
|
2742
|
+
cancelResolved = true;
|
|
2743
|
+
});
|
|
2744
|
+
await expect(queuedExecutePromise).rejects.toThrow(/no longer active/);
|
|
2745
|
+
await firstExecutePromise;
|
|
2746
|
+
expect(cancelResolved).toBe(true);
|
|
2747
|
+
expect(queuedExecuteRejected).toBe(true);
|
|
2748
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2749
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
2750
|
+
});
|
|
2751
|
+
it("waits for an in-flight snapshot capture before destroy cleanup starts", async () => {
|
|
2752
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2753
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2754
|
+
let resolveSnapshot;
|
|
2755
|
+
sandbox._experimental_createSnapshot.mockImplementation(async () => {
|
|
2756
|
+
await new Promise((resolve) => {
|
|
2757
|
+
resolveSnapshot = resolve;
|
|
2758
|
+
});
|
|
2759
|
+
});
|
|
2760
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2761
|
+
const capturePromise = plugin.definition.onEnvironmentCaptureTemplate?.({
|
|
2762
|
+
driverKey: "daytona",
|
|
2763
|
+
companyId: "company-1",
|
|
2764
|
+
environmentId: "env-1",
|
|
2765
|
+
providerLeaseId: "lease-a",
|
|
2766
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2767
|
+
templateLabel: "snapshot-check",
|
|
2768
|
+
});
|
|
2769
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2770
|
+
const destroyPromise = plugin.definition.onEnvironmentDestroyLease?.({
|
|
2771
|
+
driverKey: "daytona",
|
|
2772
|
+
companyId: "company-1",
|
|
2773
|
+
environmentId: "env-1",
|
|
2774
|
+
providerLeaseId: "lease-a",
|
|
2775
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
2776
|
+
});
|
|
2777
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2778
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
2779
|
+
resolveSnapshot();
|
|
2780
|
+
await Promise.all([capturePromise, destroyPromise]);
|
|
2781
|
+
expect(sandbox._experimental_createSnapshot).toHaveBeenCalledTimes(1);
|
|
2782
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
2783
|
+
});
|
|
2784
|
+
it("does not cache a failed populate (NotFound) — the next lookup re-fetches", async () => {
|
|
2785
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2786
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2787
|
+
mockGet
|
|
2788
|
+
.mockRejectedValueOnce(new MockDaytonaNotFoundError("missing"))
|
|
2789
|
+
.mockResolvedValue(sandbox);
|
|
2790
|
+
const first = await plugin.definition.onEnvironmentResumeLease?.({
|
|
2791
|
+
driverKey: "daytona",
|
|
2792
|
+
companyId: "company-1",
|
|
2793
|
+
environmentId: "env-1",
|
|
2794
|
+
providerLeaseId: "lease-a",
|
|
2795
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2796
|
+
});
|
|
2797
|
+
expect(first).toEqual({ providerLeaseId: null, metadata: { expired: true } });
|
|
2798
|
+
// The rejected populate must not linger; the exec re-fetches successfully.
|
|
2799
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2800
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
2801
|
+
});
|
|
2802
|
+
it("single-flights concurrent misses on one lease into a single client.get", async () => {
|
|
2803
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2804
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2805
|
+
let resolveGet;
|
|
2806
|
+
mockGet.mockImplementation(() => new Promise((resolve) => { resolveGet = resolve; }));
|
|
2807
|
+
const p1 = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2808
|
+
const p2 = plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2809
|
+
// Let both execs reach the shared in-flight populate before it resolves.
|
|
2810
|
+
await Promise.resolve();
|
|
2811
|
+
resolveGet?.(sandbox);
|
|
2812
|
+
await Promise.all([p1, p2]);
|
|
2813
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2814
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(2);
|
|
2815
|
+
});
|
|
2816
|
+
it("keeps concurrent different-lease populates isolated (no promise crossing)", async () => {
|
|
2817
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2818
|
+
const sandboxA = createMockSandbox({ id: "lease-a" });
|
|
2819
|
+
const sandboxB = createMockSandbox({ id: "lease-b" });
|
|
2820
|
+
mockGet.mockImplementation(async (id) => (id === "lease-a" ? sandboxA : sandboxB));
|
|
2821
|
+
await Promise.all([
|
|
2822
|
+
plugin.definition.onEnvironmentExecute?.(execParams("lease-a")),
|
|
2823
|
+
plugin.definition.onEnvironmentExecute?.(execParams("lease-b")),
|
|
2824
|
+
]);
|
|
2825
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
2826
|
+
expect(mockGet).toHaveBeenCalledWith("lease-a");
|
|
2827
|
+
expect(mockGet).toHaveBeenCalledWith("lease-b");
|
|
2828
|
+
// Each lease executed in its OWN sandbox, never the other's handle.
|
|
2829
|
+
expect(sandboxA.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2830
|
+
expect(sandboxB.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
2831
|
+
});
|
|
2832
|
+
it("re-verifies the workspace sentinel on a cached resume and evicts on mismatch", async () => {
|
|
2833
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2834
|
+
const sandbox = createMockSandbox({ id: "lease-a", state: "started" });
|
|
2835
|
+
// Every executeCommand (exec body + sentinel `cat`) returns a NON-matching token.
|
|
2836
|
+
sandbox.process.executeCommand.mockResolvedValue({
|
|
2837
|
+
exitCode: 0,
|
|
2838
|
+
result: JSON.stringify({ token: "other-token" }),
|
|
2839
|
+
artifacts: { stdout: JSON.stringify({ token: "other-token" }) },
|
|
2840
|
+
});
|
|
2841
|
+
mockGet.mockImplementation(async () => sandbox);
|
|
2842
|
+
// Prime the cache with a successful exec on this lease.
|
|
2843
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2844
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2845
|
+
const sentinelCallsBefore = sandbox.process.executeCommand.mock.calls.length;
|
|
2846
|
+
// Resume hits the cache but MUST still verify the sentinel; mismatch expires.
|
|
2847
|
+
const resumed = await plugin.definition.onEnvironmentResumeLease?.({
|
|
2848
|
+
driverKey: "daytona",
|
|
2849
|
+
companyId: "company-1",
|
|
2850
|
+
environmentId: "env-1",
|
|
2851
|
+
providerLeaseId: "lease-a",
|
|
2852
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
2853
|
+
leaseMetadata: {
|
|
2854
|
+
workspaceSentinel: {
|
|
2855
|
+
path: "/home/daytona/paperclip-workspace/.paperclip-runtime/reusable-sandbox-lease.json",
|
|
2856
|
+
token: "expected-token",
|
|
2857
|
+
result: "written",
|
|
2858
|
+
},
|
|
2859
|
+
},
|
|
2860
|
+
});
|
|
2861
|
+
expect(resumed).toMatchObject({
|
|
2862
|
+
providerLeaseId: null,
|
|
2863
|
+
metadata: { expired: true, workspaceSentinel: { result: "mismatch" } },
|
|
2864
|
+
});
|
|
2865
|
+
// The sentinel `cat` ran on the cached handle — verification was not skipped.
|
|
2866
|
+
expect(sandbox.process.executeCommand.mock.calls.length).toBeGreaterThan(sentinelCallsBefore);
|
|
2867
|
+
// The mismatched entry was evicted, so the next exec re-fetches.
|
|
2868
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2869
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
2870
|
+
});
|
|
2871
|
+
it("refreshes a handle left idle past the auto-stop window and restarts a provider-stopped sandbox", async () => {
|
|
2872
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2873
|
+
const sandbox = createMockSandbox({ id: "lease-a", state: "started" });
|
|
2874
|
+
// Daytona auto-stopped the sandbox while our cached handle sat idle: a live
|
|
2875
|
+
// refresh reveals the true "stopped" state that the cached snapshot hid.
|
|
2876
|
+
sandbox.refreshData.mockImplementation(async () => {
|
|
2877
|
+
sandbox.state = "stopped";
|
|
2878
|
+
});
|
|
2879
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2880
|
+
let nowMs = 1_000_000;
|
|
2881
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
2882
|
+
try {
|
|
2883
|
+
// Prime the cache (single fetch, snapshot "started").
|
|
2884
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2885
|
+
expect(sandbox.refreshData).not.toHaveBeenCalled();
|
|
2886
|
+
expect(sandbox.start).not.toHaveBeenCalled();
|
|
2887
|
+
// Idle past half of the default 15-min auto-stop interval (> 7.5 min).
|
|
2888
|
+
nowMs += 8 * 60_000;
|
|
2889
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2890
|
+
}
|
|
2891
|
+
finally {
|
|
2892
|
+
restoreFreshness();
|
|
2893
|
+
}
|
|
2894
|
+
// The stale handle was refreshed in place (no second REST fetch — the same
|
|
2895
|
+
// authenticated handle), the refresh exposed the stopped state, and the
|
|
2896
|
+
// sandbox was restarted before the exec instead of running against a
|
|
2897
|
+
// stopped sandbox.
|
|
2898
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2899
|
+
expect(sandbox.refreshData).toHaveBeenCalledTimes(1);
|
|
2900
|
+
expect(sandbox.start).toHaveBeenCalledTimes(1);
|
|
2901
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(2);
|
|
2902
|
+
});
|
|
2903
|
+
it("does not refresh a handle reused within the auto-stop window", async () => {
|
|
2904
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2905
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2906
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2907
|
+
let nowMs = 5_000_000;
|
|
2908
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
2909
|
+
try {
|
|
2910
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2911
|
+
// Two more execs, each 6 min after the previous — always inside the
|
|
2912
|
+
// 7.5-min window measured from the last reuse.
|
|
2913
|
+
nowMs += 6 * 60_000;
|
|
2914
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2915
|
+
nowMs += 6 * 60_000;
|
|
2916
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2917
|
+
}
|
|
2918
|
+
finally {
|
|
2919
|
+
restoreFreshness();
|
|
2920
|
+
}
|
|
2921
|
+
// Each reuse resets the freshness marker (an operation follows, resetting
|
|
2922
|
+
// the provider idle clock), so an actively-used lease never pays a refresh.
|
|
2923
|
+
expect(sandbox.refreshData).not.toHaveBeenCalled();
|
|
2924
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2925
|
+
});
|
|
2926
|
+
it("does not advance freshness when an execute fails before succeeding", async () => {
|
|
2927
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2928
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2929
|
+
sandbox.process.executeCommand
|
|
2930
|
+
.mockRejectedValueOnce(new Error("command failed"))
|
|
2931
|
+
.mockResolvedValue({
|
|
2932
|
+
exitCode: 0,
|
|
2933
|
+
result: "bash",
|
|
2934
|
+
artifacts: { stdout: "bash" },
|
|
2935
|
+
});
|
|
2936
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2937
|
+
let nowMs = 7_000_000;
|
|
2938
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
2939
|
+
try {
|
|
2940
|
+
await expect(plugin.definition.onEnvironmentExecute?.(execParams("lease-a"))).rejects.toThrow("command failed");
|
|
2941
|
+
nowMs += 8 * 60_000;
|
|
2942
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2943
|
+
}
|
|
2944
|
+
finally {
|
|
2945
|
+
restoreFreshness();
|
|
2946
|
+
}
|
|
2947
|
+
expect(sandbox.refreshData).toHaveBeenCalledTimes(1);
|
|
2948
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2949
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(2);
|
|
2950
|
+
});
|
|
2951
|
+
it("does not advance freshness when an execute times out before succeeding", async () => {
|
|
2952
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2953
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2954
|
+
sandbox.process.executeCommand
|
|
2955
|
+
.mockRejectedValueOnce(new MockDaytonaTimeoutError("timed out"))
|
|
2956
|
+
.mockResolvedValue({
|
|
2957
|
+
exitCode: 0,
|
|
2958
|
+
result: "bash",
|
|
2959
|
+
artifacts: { stdout: "bash" },
|
|
2960
|
+
});
|
|
2961
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2962
|
+
let nowMs = 8_000_000;
|
|
2963
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
2964
|
+
try {
|
|
2965
|
+
const first = await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2966
|
+
expect(first).toMatchObject({ timedOut: true, exitCode: null });
|
|
2967
|
+
nowMs += 8 * 60_000;
|
|
2968
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
2969
|
+
}
|
|
2970
|
+
finally {
|
|
2971
|
+
restoreFreshness();
|
|
2972
|
+
}
|
|
2973
|
+
expect(sandbox.refreshData).toHaveBeenCalledTimes(1);
|
|
2974
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2975
|
+
expect(sandbox.process.executeCommand).toHaveBeenCalledTimes(2);
|
|
2976
|
+
});
|
|
2977
|
+
it("never refreshes when auto-stop is disabled, even after a long idle gap", async () => {
|
|
2978
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2979
|
+
const sandbox = createMockSandbox({ id: "lease-a" });
|
|
2980
|
+
mockGet.mockResolvedValue(sandbox);
|
|
2981
|
+
const disabledAutoStop = { timeoutMs: 300000, reuseLease: false, autoStopInterval: 0 };
|
|
2982
|
+
let nowMs = 2_000_000;
|
|
2983
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
2984
|
+
try {
|
|
2985
|
+
await plugin.definition.onEnvironmentExecute?.({ ...execParams("lease-a"), config: disabledAutoStop });
|
|
2986
|
+
nowMs += 60 * 60_000; // an hour idle
|
|
2987
|
+
await plugin.definition.onEnvironmentExecute?.({ ...execParams("lease-a"), config: disabledAutoStop });
|
|
2988
|
+
}
|
|
2989
|
+
finally {
|
|
2990
|
+
restoreFreshness();
|
|
2991
|
+
}
|
|
2992
|
+
// Auto-stop off → the provider never stops the sandbox out from under the
|
|
2993
|
+
// handle, so the cached started snapshot is trusted without a refresh.
|
|
2994
|
+
expect(sandbox.refreshData).not.toHaveBeenCalled();
|
|
2995
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
2996
|
+
});
|
|
2997
|
+
it("evicts the handle when a freshness refresh fails so the next lookup re-fetches", async () => {
|
|
2998
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
2999
|
+
const first = createMockSandbox({ id: "lease-a" });
|
|
3000
|
+
first.refreshData.mockRejectedValue(new MockDaytonaNotFoundError("sandbox vanished"));
|
|
3001
|
+
const second = createMockSandbox({ id: "lease-a" });
|
|
3002
|
+
mockGet.mockResolvedValueOnce(first).mockResolvedValue(second);
|
|
3003
|
+
let nowMs = 3_000_000;
|
|
3004
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
3005
|
+
try {
|
|
3006
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a")); // fetch #1 → first
|
|
3007
|
+
nowMs += 8 * 60_000; // idle past the refresh window
|
|
3008
|
+
// The refresh rejects; execute surfaces it (fail closed) and the bad
|
|
3009
|
+
// entry is evicted.
|
|
3010
|
+
await expect(plugin.definition.onEnvironmentExecute?.(execParams("lease-a"))).rejects.toThrow("sandbox vanished");
|
|
3011
|
+
// Evicted → the following exec re-fetches a fresh handle.
|
|
3012
|
+
await plugin.definition.onEnvironmentExecute?.(execParams("lease-a"));
|
|
3013
|
+
}
|
|
3014
|
+
finally {
|
|
3015
|
+
restoreFreshness();
|
|
3016
|
+
}
|
|
3017
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
3018
|
+
expect(second.process.executeCommand).toHaveBeenCalledTimes(1);
|
|
3019
|
+
});
|
|
3020
|
+
it("surfaces a bounded timeout when the freshness refresh never responds", async () => {
|
|
3021
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
3022
|
+
const sandbox = createMockSandbox({ id: "lease-a", state: "started" });
|
|
3023
|
+
// The sandbox connection went silent: `refreshData` never resolves and
|
|
3024
|
+
// never rejects. Without a per-call bound the exec would stall until the
|
|
3025
|
+
// outer RPC ceiling fires.
|
|
3026
|
+
sandbox.refreshData.mockImplementation(() => new Promise(() => { }));
|
|
3027
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3028
|
+
// A short liveness bound keeps the test fast and proves the config path.
|
|
3029
|
+
const config = { timeoutMs: 300000, reuseLease: false, livenessTimeoutMs: 50 };
|
|
3030
|
+
let nowMs = 4_000_000;
|
|
3031
|
+
const restoreFreshness = setDaytonaHandleFreshnessClockForTest(() => nowMs);
|
|
3032
|
+
try {
|
|
3033
|
+
// Prime the cache (single fetch, snapshot "started").
|
|
3034
|
+
await plugin.definition.onEnvironmentExecute?.({ ...execParams("lease-a"), config });
|
|
3035
|
+
// Idle past half of the default 15-min auto-stop interval so the next
|
|
3036
|
+
// lookup refreshes the stale handle.
|
|
3037
|
+
nowMs += 8 * 60_000;
|
|
3038
|
+
await expect(plugin.definition.onEnvironmentExecute?.({ ...execParams("lease-a"), config })).rejects.toThrow(/did not respond within 50 ms/);
|
|
3039
|
+
}
|
|
3040
|
+
finally {
|
|
3041
|
+
restoreFreshness();
|
|
3042
|
+
}
|
|
3043
|
+
expect(sandbox.refreshData).toHaveBeenCalledTimes(1);
|
|
3044
|
+
// The failed refresh evicted the handle, so the next lookup re-fetches.
|
|
3045
|
+
await plugin.definition.onEnvironmentExecute?.({ ...execParams("lease-a"), config });
|
|
3046
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
3047
|
+
});
|
|
3048
|
+
it.each(["workspace", "projectless task"])("realizes a resumed %s lease after the provider fills in an unspecified target", async (scope) => {
|
|
3049
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
3050
|
+
const sandbox = createMockSandbox({ id: "sandbox-default-target" });
|
|
3051
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
3052
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3053
|
+
const base = { driverKey: "daytona", companyId: "company-1", environmentId: "env-1" };
|
|
3054
|
+
const config = { image: "node:20", timeoutMs: 300000, reuseLease: true };
|
|
3055
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease({
|
|
3056
|
+
...base, runId: "run-1", agentId: "agent-1", config,
|
|
3057
|
+
...(scope === "workspace" ? { executionWorkspaceId: "workspace-1" } : { issueId: "task-1" }),
|
|
3058
|
+
});
|
|
3059
|
+
// The host materializes provider metadata into later operation config,
|
|
3060
|
+
// but resumes with the environment's original, target-less config.
|
|
3061
|
+
const realizedConfig = { ...config, ...lease.metadata };
|
|
3062
|
+
expect(realizedConfig).toMatchObject({ target: "us" });
|
|
3063
|
+
await plugin.definition.onEnvironmentRealizeWorkspace({
|
|
3064
|
+
...base, config: realizedConfig, lease,
|
|
3065
|
+
workspace: { remotePath: "/home/daytona/paperclip-workspace" },
|
|
3066
|
+
});
|
|
3067
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
3068
|
+
await plugin.definition.onEnvironmentReleaseLease({
|
|
3069
|
+
...base, config: realizedConfig, providerLeaseId: lease.providerLeaseId,
|
|
3070
|
+
});
|
|
3071
|
+
await expect(plugin.definition.onEnvironmentRealizeWorkspace({
|
|
3072
|
+
...base, config, lease,
|
|
3073
|
+
workspace: { remotePath: "/home/daytona/paperclip-workspace" },
|
|
3074
|
+
})).rejects.toThrow(/no longer active/);
|
|
3075
|
+
await expect(plugin.definition.onEnvironmentRealizeWorkspace({
|
|
3076
|
+
...base, config: realizedConfig, lease,
|
|
3077
|
+
workspace: { remotePath: "/home/daytona/paperclip-workspace" },
|
|
3078
|
+
})).rejects.toThrow(/no longer active/);
|
|
3079
|
+
sandbox.state = "stopped";
|
|
3080
|
+
const sentinel = lease.metadata.workspaceSentinel;
|
|
3081
|
+
expect(sentinel.token).toMatch(/^[a-f0-9]{64}$/);
|
|
3082
|
+
sandbox.process.executeCommand.mockResolvedValueOnce({
|
|
3083
|
+
exitCode: 0, result: JSON.stringify({ token: sentinel.token }),
|
|
3084
|
+
artifacts: { stdout: JSON.stringify({ token: sentinel.token }) },
|
|
3085
|
+
});
|
|
3086
|
+
const resumed = await plugin.definition.onEnvironmentResumeLease({
|
|
3087
|
+
...base, config, providerLeaseId: lease.providerLeaseId, leaseMetadata: lease.metadata,
|
|
3088
|
+
});
|
|
3089
|
+
expect(resumed.metadata).toMatchObject({
|
|
3090
|
+
resumedLease: true, workspaceSentinel: { result: "matched" },
|
|
3091
|
+
});
|
|
3092
|
+
await expect(plugin.definition.onEnvironmentRealizeWorkspace({
|
|
3093
|
+
...base, config: { ...config, ...resumed.metadata }, lease: resumed,
|
|
3094
|
+
workspace: { remotePath: "/home/daytona/paperclip-workspace" },
|
|
3095
|
+
})).resolves.toMatchObject({ cwd: "/home/daytona/paperclip-workspace" });
|
|
3096
|
+
});
|
|
3097
|
+
it("realizes the workspace from the acquire-seeded handle without a client.get", async () => {
|
|
3098
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
3099
|
+
const sandbox = createMockSandbox({ id: "sandbox-seed" });
|
|
3100
|
+
mockCreate.mockResolvedValue(sandbox);
|
|
3101
|
+
const base = { driverKey: "daytona", companyId: "company-1", environmentId: "env-1" };
|
|
3102
|
+
const config = { image: "node:20", timeoutMs: 300000, reuseLease: false };
|
|
3103
|
+
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
|
3104
|
+
...base,
|
|
3105
|
+
runId: "run-1",
|
|
3106
|
+
config,
|
|
3107
|
+
});
|
|
3108
|
+
expect(lease?.providerLeaseId).toBe("sandbox-seed");
|
|
3109
|
+
const realize = await plugin.definition.onEnvironmentRealizeWorkspace?.({
|
|
3110
|
+
...base,
|
|
3111
|
+
lease: { providerLeaseId: lease.providerLeaseId, metadata: lease.metadata },
|
|
3112
|
+
workspace: { remotePath: "/home/daytona/paperclip-workspace" },
|
|
3113
|
+
config,
|
|
3114
|
+
});
|
|
3115
|
+
// Acquire seeded the handle under the exact scope realize reads, so realize
|
|
3116
|
+
// reuses it and never pays a real REST re-fetch.
|
|
3117
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
3118
|
+
expect(sandbox.fs.createFolder).toHaveBeenCalledWith("/home/daytona/paperclip-workspace", "755");
|
|
3119
|
+
expect(realize?.cwd).toBe("/home/daytona/paperclip-workspace");
|
|
3120
|
+
});
|
|
3121
|
+
});
|
|
3122
|
+
});
|
|
3123
|
+
describe("daytona native file-sync hooks", () => {
|
|
3124
|
+
const REMOTE_DIR = "/home/daytona/paperclip-workspace";
|
|
3125
|
+
const tempDirs = [];
|
|
3126
|
+
async function makeHostDir() {
|
|
3127
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-sync-test-"));
|
|
3128
|
+
tempDirs.push(dir);
|
|
3129
|
+
return dir;
|
|
3130
|
+
}
|
|
3131
|
+
function syncLease(overrides = {}) {
|
|
3132
|
+
return {
|
|
3133
|
+
providerLeaseId: "sandbox-123",
|
|
3134
|
+
metadata: { provider: "daytona", remoteCwd: REMOTE_DIR, ...overrides },
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
// A concurrency gate for a fake transfer call (uploadFiles / downloadFiles).
|
|
3138
|
+
// The gate lets two concurrent hook calls both enter the fake, then holds them
|
|
3139
|
+
// there until the test releases them. It records the peak number of calls that
|
|
3140
|
+
// are in the fake at the same time, so a test proves the two calls overlap.
|
|
3141
|
+
//
|
|
3142
|
+
// `expected` is how many calls the test starts. `bothArrived` resolves once
|
|
3143
|
+
// that many calls sit inside the fake at the same moment. `release()` frees
|
|
3144
|
+
// them. `body` is the fake implementation: it marks arrival, waits for the
|
|
3145
|
+
// release, and then runs `onRelease` to produce the fake result.
|
|
3146
|
+
function createTransferGate(expected, onRelease) {
|
|
3147
|
+
let inFlight = 0;
|
|
3148
|
+
let peakInFlight = 0;
|
|
3149
|
+
let signalArrived;
|
|
3150
|
+
const bothArrived = new Promise((resolve) => {
|
|
3151
|
+
signalArrived = resolve;
|
|
3152
|
+
});
|
|
3153
|
+
let signalReleased;
|
|
3154
|
+
const released = new Promise((resolve) => {
|
|
3155
|
+
signalReleased = resolve;
|
|
3156
|
+
});
|
|
3157
|
+
const body = async (...args) => {
|
|
3158
|
+
inFlight += 1;
|
|
3159
|
+
peakInFlight = Math.max(peakInFlight, inFlight);
|
|
3160
|
+
if (inFlight === expected)
|
|
3161
|
+
signalArrived();
|
|
3162
|
+
await released;
|
|
3163
|
+
inFlight -= 1;
|
|
3164
|
+
return onRelease(args);
|
|
3165
|
+
};
|
|
3166
|
+
return {
|
|
3167
|
+
body,
|
|
3168
|
+
bothArrived,
|
|
3169
|
+
release: () => signalReleased(),
|
|
3170
|
+
peak: () => peakInFlight,
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
// Write each download request's snapshot bytes to its host destination and
|
|
3174
|
+
// report success, matching the real batch-download contract the outbound sync
|
|
3175
|
+
// path expects.
|
|
3176
|
+
async function fulfilDownload(args) {
|
|
3177
|
+
const requests = args[0];
|
|
3178
|
+
return Promise.all(requests.map(async (request) => {
|
|
3179
|
+
await fs.writeFile(request.destination, "bytes");
|
|
3180
|
+
return { source: request.source, result: request.destination };
|
|
3181
|
+
}));
|
|
3182
|
+
}
|
|
3183
|
+
function syncInParams(overrides) {
|
|
3184
|
+
return {
|
|
3185
|
+
driverKey: "daytona",
|
|
3186
|
+
companyId: "company-1",
|
|
3187
|
+
environmentId: "env-1",
|
|
3188
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3189
|
+
lease: syncLease(),
|
|
3190
|
+
operations: [
|
|
3191
|
+
{
|
|
3192
|
+
operationId: overrides.operationId,
|
|
3193
|
+
files: [{ sourcePath: overrides.sourcePath, targetPath: overrides.targetPath, kind: "file" }],
|
|
3194
|
+
},
|
|
3195
|
+
],
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
function syncOutParams(overrides) {
|
|
3199
|
+
return {
|
|
3200
|
+
driverKey: "daytona",
|
|
3201
|
+
companyId: "company-1",
|
|
3202
|
+
environmentId: "env-1",
|
|
3203
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3204
|
+
lease: syncLease(),
|
|
3205
|
+
operations: [
|
|
3206
|
+
{
|
|
3207
|
+
operationId: overrides.operationId,
|
|
3208
|
+
files: [{ sourcePath: overrides.sourcePath, targetPath: overrides.targetPath, kind: "file" }],
|
|
3209
|
+
},
|
|
3210
|
+
],
|
|
3211
|
+
};
|
|
3212
|
+
}
|
|
3213
|
+
beforeEach(() => {
|
|
3214
|
+
mockGet.mockReset();
|
|
3215
|
+
process.env.DAYTONA_API_KEY = "host-key";
|
|
3216
|
+
__resetDaytonaSandboxHandleCacheForTest();
|
|
3217
|
+
});
|
|
3218
|
+
afterEach(async () => {
|
|
3219
|
+
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
3220
|
+
});
|
|
3221
|
+
it("declares both sync hooks so the worker advertises the native transport", () => {
|
|
3222
|
+
expect(plugin.definition.onEnvironmentSyncIn).toBeTypeOf("function");
|
|
3223
|
+
expect(plugin.definition.onEnvironmentSyncOut).toBeTypeOf("function");
|
|
3224
|
+
});
|
|
3225
|
+
it("records the writablePath destination of a staging-tar rw mapping, not the staging parent", async () => {
|
|
3226
|
+
const hostDir = await makeHostDir();
|
|
3227
|
+
const source = path.join(hostDir, "workspace.tar");
|
|
3228
|
+
await fs.writeFile(source, "bytes");
|
|
3229
|
+
const sandbox = createMockSandbox();
|
|
3230
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3231
|
+
const params = {
|
|
3232
|
+
driverKey: "daytona",
|
|
3233
|
+
companyId: "company-1",
|
|
3234
|
+
environmentId: "env-1",
|
|
3235
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3236
|
+
lease: syncLease(),
|
|
3237
|
+
operations: [
|
|
3238
|
+
{
|
|
3239
|
+
operationId: "sync-op-rw",
|
|
3240
|
+
files: [
|
|
3241
|
+
{
|
|
3242
|
+
// The mapping uploads a staging tar under the runtime root, and a
|
|
3243
|
+
// post-upload command extracts it into the workspace directory. So
|
|
3244
|
+
// `writablePath` names the real read-write destination.
|
|
3245
|
+
sourcePath: source,
|
|
3246
|
+
targetPath: `${REMOTE_DIR}/.paperclip-runtime/workspace-upload.tar`,
|
|
3247
|
+
kind: "file",
|
|
3248
|
+
access: "rw",
|
|
3249
|
+
writablePath: REMOTE_DIR,
|
|
3250
|
+
},
|
|
3251
|
+
],
|
|
3252
|
+
},
|
|
3253
|
+
],
|
|
3254
|
+
};
|
|
3255
|
+
await plugin.definition.onEnvironmentSyncIn?.(params);
|
|
3256
|
+
// The set holds the extract destination, not the staging archive parent.
|
|
3257
|
+
const recorded = __getDaytonaWritableDirsForTest(params);
|
|
3258
|
+
expect(recorded).toContain(REMOTE_DIR);
|
|
3259
|
+
expect(recorded).not.toContain(`${REMOTE_DIR}/.paperclip-runtime`);
|
|
3260
|
+
});
|
|
3261
|
+
it("falls back to the parent directory of an rw mapping with no writablePath", async () => {
|
|
3262
|
+
const hostDir = await makeHostDir();
|
|
3263
|
+
const source = path.join(hostDir, "in-place.txt");
|
|
3264
|
+
await fs.writeFile(source, "bytes");
|
|
3265
|
+
const sandbox = createMockSandbox();
|
|
3266
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3267
|
+
const params = {
|
|
3268
|
+
driverKey: "daytona",
|
|
3269
|
+
companyId: "company-1",
|
|
3270
|
+
environmentId: "env-1",
|
|
3271
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3272
|
+
lease: syncLease(),
|
|
3273
|
+
operations: [
|
|
3274
|
+
{
|
|
3275
|
+
operationId: "sync-op-rw-inplace",
|
|
3276
|
+
files: [
|
|
3277
|
+
{
|
|
3278
|
+
// No post-upload extract, so the mapping writes `targetPath` in
|
|
3279
|
+
// place and the parent directory is the read-write destination.
|
|
3280
|
+
sourcePath: source,
|
|
3281
|
+
targetPath: `${REMOTE_DIR}/data/in-place.txt`,
|
|
3282
|
+
kind: "file",
|
|
3283
|
+
access: "rw",
|
|
3284
|
+
},
|
|
3285
|
+
],
|
|
3286
|
+
},
|
|
3287
|
+
],
|
|
3288
|
+
};
|
|
3289
|
+
await plugin.definition.onEnvironmentSyncIn?.(params);
|
|
3290
|
+
expect(__getDaytonaWritableDirsForTest(params)).toContain(`${REMOTE_DIR}/data`);
|
|
3291
|
+
});
|
|
3292
|
+
it("skips ro and access-absent sync targets in the advisory writable set", async () => {
|
|
3293
|
+
const hostDir = await makeHostDir();
|
|
3294
|
+
const roSource = path.join(hostDir, "referenced");
|
|
3295
|
+
const defaultSource = path.join(hostDir, "default.tar");
|
|
3296
|
+
await fs.mkdir(roSource, { recursive: true });
|
|
3297
|
+
await fs.writeFile(path.join(roSource, "notes.md"), "reference");
|
|
3298
|
+
await fs.writeFile(defaultSource, "bytes");
|
|
3299
|
+
const sandbox = createMockSandbox();
|
|
3300
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3301
|
+
const params = {
|
|
3302
|
+
driverKey: "daytona",
|
|
3303
|
+
companyId: "company-1",
|
|
3304
|
+
environmentId: "env-1",
|
|
3305
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3306
|
+
lease: syncLease(),
|
|
3307
|
+
operations: [
|
|
3308
|
+
{
|
|
3309
|
+
operationId: "sync-op-ro",
|
|
3310
|
+
files: [
|
|
3311
|
+
{
|
|
3312
|
+
sourcePath: roSource,
|
|
3313
|
+
targetPath: `${REMOTE_DIR}/.paperclip-runtime/project-proj-first`,
|
|
3314
|
+
kind: "directory",
|
|
3315
|
+
access: "ro",
|
|
3316
|
+
},
|
|
3317
|
+
{
|
|
3318
|
+
// An absent `access` defaults to read-only, so it is not recorded.
|
|
3319
|
+
sourcePath: defaultSource,
|
|
3320
|
+
targetPath: `${REMOTE_DIR}/.paperclip-runtime/default-upload.tar`,
|
|
3321
|
+
kind: "file",
|
|
3322
|
+
},
|
|
3323
|
+
],
|
|
3324
|
+
},
|
|
3325
|
+
],
|
|
3326
|
+
};
|
|
3327
|
+
await plugin.definition.onEnvironmentSyncIn?.(params);
|
|
3328
|
+
// Neither the ro directory nor the access-absent file directory is recorded.
|
|
3329
|
+
expect(__getDaytonaWritableDirsForTest(params)).toEqual([]);
|
|
3330
|
+
});
|
|
3331
|
+
it("syncIn coalesces file mappings into one uploadFiles batch to reserved temp destinations, then one batched mv, applying secret mode via setFilePermissions before the rename", async () => {
|
|
3332
|
+
const hostDir = await makeHostDir();
|
|
3333
|
+
const secretSource = path.join(hostDir, "auth.json");
|
|
3334
|
+
const plainSource = path.join(hostDir, "config.txt");
|
|
3335
|
+
await fs.writeFile(secretSource, "credential-material");
|
|
3336
|
+
await fs.writeFile(plainSource, "plain");
|
|
3337
|
+
const sandbox = createMockSandbox();
|
|
3338
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3339
|
+
const result = await plugin.definition.onEnvironmentSyncIn?.({
|
|
3340
|
+
driverKey: "daytona",
|
|
3341
|
+
companyId: "company-1",
|
|
3342
|
+
environmentId: "env-1",
|
|
3343
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3344
|
+
lease: syncLease(),
|
|
3345
|
+
operations: [
|
|
3346
|
+
{
|
|
3347
|
+
operationId: "sync-op-1",
|
|
3348
|
+
files: [
|
|
3349
|
+
{ sourcePath: secretSource, targetPath: `${REMOTE_DIR}/.secret/auth.json`, kind: "file", mode: 0o600 },
|
|
3350
|
+
{ sourcePath: plainSource, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" },
|
|
3351
|
+
],
|
|
3352
|
+
},
|
|
3353
|
+
],
|
|
3354
|
+
});
|
|
3355
|
+
// Exactly one bulk upload for both file mappings.
|
|
3356
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
|
|
3357
|
+
const [uploads] = sandbox.fs.uploadFiles.mock.calls[0];
|
|
3358
|
+
expect(uploads).toHaveLength(2);
|
|
3359
|
+
// String sources stream from the local path; destinations are reserved temps.
|
|
3360
|
+
expect(uploads[0].source).toBe(secretSource);
|
|
3361
|
+
for (const upload of uploads) {
|
|
3362
|
+
expect(path.posix.basename(upload.destination)).toMatch(/^\.paperclip-upload-/);
|
|
3363
|
+
expect(upload.destination).not.toBe(`${REMOTE_DIR}/.secret/auth.json`);
|
|
3364
|
+
// TOCTOU-hardened: the privileged upload destination is a DIRECT child of the
|
|
3365
|
+
// workspace root, never a sibling under the target's (sandbox-swappable)
|
|
3366
|
+
// parent dir, so a parent symlink swap cannot redirect the write out of root.
|
|
3367
|
+
expect(path.posix.dirname(upload.destination)).toBe(REMOTE_DIR);
|
|
3368
|
+
}
|
|
3369
|
+
// Secret mode applied on the TEMP path (before the rename) so the target
|
|
3370
|
+
// never appears at a widened window; applied via setFilePermissions as "600".
|
|
3371
|
+
expect(sandbox.fs.setFilePermissions).toHaveBeenCalledTimes(1);
|
|
3372
|
+
const [permPath, perms] = sandbox.fs.setFilePermissions.mock.calls[0];
|
|
3373
|
+
expect(path.posix.basename(permPath)).toMatch(/^\.paperclip-upload-/);
|
|
3374
|
+
expect(perms).toEqual({ mode: "600" });
|
|
3375
|
+
// The setFilePermissions on the temp precedes the mv that promotes it.
|
|
3376
|
+
const secretTemp = permPath;
|
|
3377
|
+
const mvCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("mv -f"));
|
|
3378
|
+
expect(mvCall).toBeDefined();
|
|
3379
|
+
const mvCommand = String(mvCall?.[0]);
|
|
3380
|
+
// Each promotion is one plain `mv -f <scratch> <target>` command, batched
|
|
3381
|
+
// together in a single sandbox invocation.
|
|
3382
|
+
expect(mvCommand).toContain(secretTemp);
|
|
3383
|
+
expect(mvCommand).toContain(`${REMOTE_DIR}/.secret/auth.json`);
|
|
3384
|
+
// Both temps are promoted (one mv line per rename).
|
|
3385
|
+
expect(mvCommand.match(/mv -f /g)).toHaveLength(2);
|
|
3386
|
+
expect(result).toEqual({
|
|
3387
|
+
operations: [{ operationId: "sync-op-1", filesTransferred: 2, bytesTransferred: "credential-material".length + "plain".length }],
|
|
3388
|
+
});
|
|
3389
|
+
});
|
|
3390
|
+
// A recording tracer that captures every provider span the file sync opens.
|
|
3391
|
+
// It satisfies the structural plugin tracer contract.
|
|
3392
|
+
function createRecordingPluginTracer() {
|
|
3393
|
+
const spans = [];
|
|
3394
|
+
const tracer = {
|
|
3395
|
+
startSpan(name, options) {
|
|
3396
|
+
const span = {
|
|
3397
|
+
name,
|
|
3398
|
+
attributes: { ...(options?.attributes ?? {}) },
|
|
3399
|
+
status: null,
|
|
3400
|
+
ended: false,
|
|
3401
|
+
setAttribute(key, value) {
|
|
3402
|
+
span.attributes[key] = value;
|
|
3403
|
+
},
|
|
3404
|
+
setStatus(status) {
|
|
3405
|
+
span.status = status;
|
|
3406
|
+
},
|
|
3407
|
+
end() {
|
|
3408
|
+
span.ended = true;
|
|
3409
|
+
},
|
|
3410
|
+
};
|
|
3411
|
+
spans.push(span);
|
|
3412
|
+
return span;
|
|
3413
|
+
},
|
|
3414
|
+
};
|
|
3415
|
+
return { tracer, spans };
|
|
3416
|
+
}
|
|
3417
|
+
it("opens a transfer span with the guard round-trip count around the bulk file upload", async () => {
|
|
3418
|
+
const hostDir = await makeHostDir();
|
|
3419
|
+
const source = path.join(hostDir, "config.txt");
|
|
3420
|
+
await fs.writeFile(source, "plain");
|
|
3421
|
+
const sandbox = createMockSandbox();
|
|
3422
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3423
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3424
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3425
|
+
try {
|
|
3426
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3427
|
+
driverKey: "daytona",
|
|
3428
|
+
companyId: "company-1",
|
|
3429
|
+
environmentId: "env-1",
|
|
3430
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3431
|
+
lease: syncLease(),
|
|
3432
|
+
operations: [
|
|
3433
|
+
{
|
|
3434
|
+
operationId: "sync-op-1",
|
|
3435
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
3436
|
+
},
|
|
3437
|
+
],
|
|
3438
|
+
});
|
|
3439
|
+
}
|
|
3440
|
+
finally {
|
|
3441
|
+
restore();
|
|
3442
|
+
}
|
|
3443
|
+
const transfer = spans.find((span) => span.name === "transfer");
|
|
3444
|
+
expect(transfer).toBeDefined();
|
|
3445
|
+
expect(transfer.ended).toBe(true);
|
|
3446
|
+
// One serial guard round trip before the transfer: mkdir (with the zstd probe).
|
|
3447
|
+
expect(transfer.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(1);
|
|
3448
|
+
expect(transfer.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
3449
|
+
expect(typeof transfer.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number");
|
|
3450
|
+
// A bulk file upload builds no host tarball, so it opens no pack span.
|
|
3451
|
+
expect(spans.find((span) => span.name === "pack")).toBeUndefined();
|
|
3452
|
+
});
|
|
3453
|
+
it("marks the inbound transfer span with the inbound direction attribute", async () => {
|
|
3454
|
+
const hostDir = await makeHostDir();
|
|
3455
|
+
const source = path.join(hostDir, "config.txt");
|
|
3456
|
+
await fs.writeFile(source, "plain");
|
|
3457
|
+
const sandbox = createMockSandbox();
|
|
3458
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3459
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3460
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3461
|
+
try {
|
|
3462
|
+
await plugin.definition.onEnvironmentSyncIn?.(syncInParams({
|
|
3463
|
+
operationId: "sync-op-in-dir",
|
|
3464
|
+
sourcePath: source,
|
|
3465
|
+
targetPath: `${REMOTE_DIR}/config.txt`,
|
|
3466
|
+
}));
|
|
3467
|
+
}
|
|
3468
|
+
finally {
|
|
3469
|
+
restore();
|
|
3470
|
+
}
|
|
3471
|
+
// An upload to the sandbox is an inbound transfer.
|
|
3472
|
+
const transfer = spans.find((span) => span.name === "transfer");
|
|
3473
|
+
expect(transfer).toBeDefined();
|
|
3474
|
+
expect(transfer.attributes["paperclip.sandbox.startup.transfer.direction"]).toBe("inbound");
|
|
3475
|
+
});
|
|
3476
|
+
it("marks the outbound transfer span with the outbound direction attribute", async () => {
|
|
3477
|
+
const hostDir = await makeHostDir();
|
|
3478
|
+
const sandbox = createMockSandbox();
|
|
3479
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
3480
|
+
return Promise.all(requests.map(async (req) => {
|
|
3481
|
+
await fs.writeFile(req.destination, "bytes");
|
|
3482
|
+
return { source: req.source, result: req.destination };
|
|
3483
|
+
}));
|
|
3484
|
+
});
|
|
3485
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3486
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3487
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3488
|
+
try {
|
|
3489
|
+
await plugin.definition.onEnvironmentSyncOut?.(syncOutParams({
|
|
3490
|
+
operationId: "sync-op-out-dir",
|
|
3491
|
+
sourcePath: `${REMOTE_DIR}/out/result.txt`,
|
|
3492
|
+
targetPath: path.join(hostDir, "result.txt"),
|
|
3493
|
+
}));
|
|
3494
|
+
}
|
|
3495
|
+
finally {
|
|
3496
|
+
restore();
|
|
3497
|
+
}
|
|
3498
|
+
// A download from the sandbox is an outbound transfer.
|
|
3499
|
+
const transfer = spans.find((span) => span.name === "transfer");
|
|
3500
|
+
expect(transfer).toBeDefined();
|
|
3501
|
+
expect(transfer.attributes["paperclip.sandbox.startup.transfer.direction"]).toBe("outbound");
|
|
3502
|
+
});
|
|
3503
|
+
it("opens a pack span and a transfer span around a directory mapping sync", async () => {
|
|
3504
|
+
const hostDir = await makeHostDir();
|
|
3505
|
+
const sourceDir = path.join(hostDir, "assets");
|
|
3506
|
+
await fs.mkdir(sourceDir, { recursive: true });
|
|
3507
|
+
await fs.writeFile(path.join(sourceDir, "a.txt"), "alpha");
|
|
3508
|
+
const sandbox = createMockSandbox();
|
|
3509
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3510
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3511
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3512
|
+
try {
|
|
3513
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3514
|
+
driverKey: "daytona",
|
|
3515
|
+
companyId: "company-1",
|
|
3516
|
+
environmentId: "env-1",
|
|
3517
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3518
|
+
lease: syncLease(),
|
|
3519
|
+
operations: [
|
|
3520
|
+
{
|
|
3521
|
+
operationId: "sync-op-dir",
|
|
3522
|
+
files: [
|
|
3523
|
+
{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/.paperclip-runtime/assets`, kind: "directory" },
|
|
3524
|
+
],
|
|
3525
|
+
},
|
|
3526
|
+
],
|
|
3527
|
+
});
|
|
3528
|
+
}
|
|
3529
|
+
finally {
|
|
3530
|
+
restore();
|
|
3531
|
+
}
|
|
3532
|
+
const pack = spans.find((span) => span.name === "pack");
|
|
3533
|
+
expect(pack).toBeDefined();
|
|
3534
|
+
expect(pack.ended).toBe(true);
|
|
3535
|
+
expect(typeof pack.attributes["paperclip.sandbox.startup.pack.wall_ms"]).toBe("number");
|
|
3536
|
+
const transfer = spans.find((span) => span.name === "transfer");
|
|
3537
|
+
expect(transfer).toBeDefined();
|
|
3538
|
+
// One serial guard round trip before the transfer: mkdir.
|
|
3539
|
+
expect(transfer.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(1);
|
|
3540
|
+
});
|
|
3541
|
+
it("opens ensureDirectory, transfer, promote spans in call order for a file-mapping sync", async () => {
|
|
3542
|
+
const hostDir = await makeHostDir();
|
|
3543
|
+
const source = path.join(hostDir, "config.txt");
|
|
3544
|
+
await fs.writeFile(source, "plain");
|
|
3545
|
+
const sandbox = createMockSandbox();
|
|
3546
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3547
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3548
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3549
|
+
try {
|
|
3550
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3551
|
+
driverKey: "daytona",
|
|
3552
|
+
companyId: "company-1",
|
|
3553
|
+
environmentId: "env-1",
|
|
3554
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3555
|
+
lease: syncLease(),
|
|
3556
|
+
operations: [
|
|
3557
|
+
{
|
|
3558
|
+
operationId: "sync-op-order",
|
|
3559
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
3560
|
+
},
|
|
3561
|
+
],
|
|
3562
|
+
});
|
|
3563
|
+
}
|
|
3564
|
+
finally {
|
|
3565
|
+
restore();
|
|
3566
|
+
}
|
|
3567
|
+
expect(spans.map((span) => span.name)).toEqual([
|
|
3568
|
+
"ensureDirectory",
|
|
3569
|
+
"transfer",
|
|
3570
|
+
"promote",
|
|
3571
|
+
]);
|
|
3572
|
+
for (const span of spans) {
|
|
3573
|
+
expect(span.ended).toBe(true);
|
|
3574
|
+
expect(span.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
3575
|
+
// A per-round-trip span carries no `*.wall_ms` attribute; the native span
|
|
3576
|
+
// width carries its time. Only `pack` and `transfer` keep a wall_ms value.
|
|
3577
|
+
if (span.name !== "transfer") {
|
|
3578
|
+
expect(span.attributes["paperclip.sandbox.startup.ensureDirectory.wall_ms"]).toBeUndefined();
|
|
3579
|
+
expect(span.attributes["paperclip.sandbox.startup.promote.wall_ms"]).toBeUndefined();
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
});
|
|
3583
|
+
it("opens pack, ensureDirectory, transfer, extractTarball spans in call order for a directory-mapping sync", async () => {
|
|
3584
|
+
const hostDir = await makeHostDir();
|
|
3585
|
+
const sourceDir = path.join(hostDir, "assets");
|
|
3586
|
+
await fs.mkdir(sourceDir, { recursive: true });
|
|
3587
|
+
await fs.writeFile(path.join(sourceDir, "a.txt"), "alpha");
|
|
3588
|
+
const sandbox = createMockSandbox();
|
|
3589
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3590
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3591
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3592
|
+
try {
|
|
3593
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3594
|
+
driverKey: "daytona",
|
|
3595
|
+
companyId: "company-1",
|
|
3596
|
+
environmentId: "env-1",
|
|
3597
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3598
|
+
lease: syncLease(),
|
|
3599
|
+
operations: [
|
|
3600
|
+
{
|
|
3601
|
+
operationId: "sync-op-dir-order",
|
|
3602
|
+
files: [
|
|
3603
|
+
{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/.paperclip-runtime/assets`, kind: "directory" },
|
|
3604
|
+
],
|
|
3605
|
+
},
|
|
3606
|
+
],
|
|
3607
|
+
});
|
|
3608
|
+
}
|
|
3609
|
+
finally {
|
|
3610
|
+
restore();
|
|
3611
|
+
}
|
|
3612
|
+
expect(spans.map((span) => span.name)).toEqual([
|
|
3613
|
+
"pack",
|
|
3614
|
+
"ensureDirectory",
|
|
3615
|
+
"transfer",
|
|
3616
|
+
"extractTarball",
|
|
3617
|
+
]);
|
|
3618
|
+
for (const span of spans) {
|
|
3619
|
+
expect(span.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
3620
|
+
}
|
|
3621
|
+
});
|
|
3622
|
+
it("records a pack span for a traced directory mapping", async () => {
|
|
3623
|
+
const hostDir = await makeHostDir();
|
|
3624
|
+
const sourceDir = path.join(hostDir, "assets");
|
|
3625
|
+
await fs.mkdir(sourceDir, { recursive: true });
|
|
3626
|
+
await fs.writeFile(path.join(sourceDir, "a.txt"), "alpha");
|
|
3627
|
+
const sandbox = createMockSandbox();
|
|
3628
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3629
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3630
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3631
|
+
try {
|
|
3632
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3633
|
+
driverKey: "daytona",
|
|
3634
|
+
companyId: "company-1",
|
|
3635
|
+
environmentId: "env-1",
|
|
3636
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3637
|
+
lease: syncLease(),
|
|
3638
|
+
operations: [
|
|
3639
|
+
{
|
|
3640
|
+
operationId: "sync-op-pack",
|
|
3641
|
+
files: [
|
|
3642
|
+
{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/.paperclip-runtime/assets`, kind: "directory" },
|
|
3643
|
+
],
|
|
3644
|
+
},
|
|
3645
|
+
],
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3648
|
+
finally {
|
|
3649
|
+
restore();
|
|
3650
|
+
}
|
|
3651
|
+
const pack = spans.find((span) => span.name === "pack");
|
|
3652
|
+
expect(pack).toBeDefined();
|
|
3653
|
+
expect(pack.ended).toBe(true);
|
|
3654
|
+
expect(pack.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
3655
|
+
});
|
|
3656
|
+
it("opens a postUploadCommand span for a post-upload command with a working directory", async () => {
|
|
3657
|
+
const hostDir = await makeHostDir();
|
|
3658
|
+
const source = path.join(hostDir, "config.txt");
|
|
3659
|
+
await fs.writeFile(source, "plain");
|
|
3660
|
+
const sandbox = createMockSandbox();
|
|
3661
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3662
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
3663
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
3664
|
+
try {
|
|
3665
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3666
|
+
driverKey: "daytona",
|
|
3667
|
+
companyId: "company-1",
|
|
3668
|
+
environmentId: "env-1",
|
|
3669
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3670
|
+
lease: syncLease(),
|
|
3671
|
+
operations: [
|
|
3672
|
+
{
|
|
3673
|
+
operationId: "sync-op-post",
|
|
3674
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
3675
|
+
postUploadCommands: [{ command: "run-me", cwd: `${REMOTE_DIR}/sub` }],
|
|
3676
|
+
},
|
|
3677
|
+
],
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
finally {
|
|
3681
|
+
restore();
|
|
3682
|
+
}
|
|
3683
|
+
// The full order: the file mapping opens ensureDirectory, transfer, promote;
|
|
3684
|
+
// the post-upload command then opens the postUploadCommand span.
|
|
3685
|
+
expect(spans.map((span) => span.name)).toEqual([
|
|
3686
|
+
"ensureDirectory",
|
|
3687
|
+
"transfer",
|
|
3688
|
+
"promote",
|
|
3689
|
+
"postUploadCommand",
|
|
3690
|
+
]);
|
|
3691
|
+
const provision = spans.find((span) => span.name === "postUploadCommand");
|
|
3692
|
+
expect(provision.ended).toBe(true);
|
|
3693
|
+
expect(provision.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
3694
|
+
});
|
|
3695
|
+
it("gzip-tars a directory mapping host-side honoring excludes and the followSymlinks flag, then extracts it in-sandbox via a single quoted tar command", async () => {
|
|
3696
|
+
const hostDir = await makeHostDir();
|
|
3697
|
+
const sourceDir = path.join(hostDir, "assets");
|
|
3698
|
+
await fs.mkdir(path.join(sourceDir, "keep"), { recursive: true });
|
|
3699
|
+
await fs.writeFile(path.join(sourceDir, "keep", "a.txt"), "alpha");
|
|
3700
|
+
await fs.writeFile(path.join(sourceDir, "skip.log"), "noise");
|
|
3701
|
+
await fs.symlink("keep/a.txt", path.join(sourceDir, "link.txt"));
|
|
3702
|
+
const sandbox = createMockSandbox();
|
|
3703
|
+
// Capture the tar listing inside the upload mock, before withHostTempDir
|
|
3704
|
+
// cleans the host scratch dir.
|
|
3705
|
+
let capturedTarListing = "";
|
|
3706
|
+
sandbox.fs.uploadFiles.mockImplementation(async (uploads) => {
|
|
3707
|
+
capturedTarListing = execFileSync("tar", ["-tvf", uploads[0].source]).toString();
|
|
3708
|
+
});
|
|
3709
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3710
|
+
// Preserve-symlink case (followSymlinks falsy → no -h).
|
|
3711
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3712
|
+
driverKey: "daytona",
|
|
3713
|
+
companyId: "company-1",
|
|
3714
|
+
environmentId: "env-1",
|
|
3715
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3716
|
+
lease: syncLease(),
|
|
3717
|
+
operations: [
|
|
3718
|
+
{
|
|
3719
|
+
operationId: "sync-op-dir",
|
|
3720
|
+
files: [
|
|
3721
|
+
{
|
|
3722
|
+
sourcePath: sourceDir,
|
|
3723
|
+
targetPath: `${REMOTE_DIR}/.paperclip-runtime/assets`,
|
|
3724
|
+
kind: "directory",
|
|
3725
|
+
exclude: ["*.log"],
|
|
3726
|
+
},
|
|
3727
|
+
],
|
|
3728
|
+
},
|
|
3729
|
+
],
|
|
3730
|
+
});
|
|
3731
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
|
|
3732
|
+
const [uploads] = sandbox.fs.uploadFiles.mock.calls[0];
|
|
3733
|
+
expect(uploads).toHaveLength(1);
|
|
3734
|
+
expect(uploads[0].source).toMatch(/\.tar\.gz$/);
|
|
3735
|
+
expect(path.posix.basename(uploads[0].destination)).toMatch(/^\.paperclip-upload-.*\.tar\.gz$/);
|
|
3736
|
+
expect(uploads[0].destination.startsWith(`${REMOTE_DIR}/`)).toBe(true);
|
|
3737
|
+
// Inspect the real host tar: excluded file gone; symlink preserved AS a link.
|
|
3738
|
+
expect(capturedTarListing).toContain("keep/a.txt");
|
|
3739
|
+
expect(capturedTarListing).not.toContain("skip.log");
|
|
3740
|
+
expect(capturedTarListing).toMatch(/link\.txt ->|link\.txt link to/);
|
|
3741
|
+
// The target dir is created by its own mkdir command, before the upload.
|
|
3742
|
+
const mkdirCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("mkdir -p") &&
|
|
3743
|
+
String(cmd).includes(`'${REMOTE_DIR}/.paperclip-runtime/assets'`) &&
|
|
3744
|
+
!String(cmd).includes("tar -xf"));
|
|
3745
|
+
expect(mkdirCall).toBeDefined();
|
|
3746
|
+
const extractCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("tar -xf"));
|
|
3747
|
+
expect(extractCall).toBeDefined();
|
|
3748
|
+
const extractCommand = String(extractCall?.[0]);
|
|
3749
|
+
// The extract is one plain `tar -xf <scratch-tar> -C <target>` command,
|
|
3750
|
+
// followed by removing the scratch tar.
|
|
3751
|
+
expect(extractCommand).toContain(".paperclip-runtime/assets");
|
|
3752
|
+
expect(extractCommand).toContain("tar -xf");
|
|
3753
|
+
expect(extractCommand).toMatch(/rm -f .*\.paperclip-upload-.*\.tar\.gz/);
|
|
3754
|
+
});
|
|
3755
|
+
it("syncIn dereferences symlinks to bytes when followSymlinks is true (tar -h)", async () => {
|
|
3756
|
+
const hostDir = await makeHostDir();
|
|
3757
|
+
const sourceDir = path.join(hostDir, "deref");
|
|
3758
|
+
await fs.mkdir(sourceDir, { recursive: true });
|
|
3759
|
+
await fs.writeFile(path.join(sourceDir, "real.txt"), "payload");
|
|
3760
|
+
await fs.symlink("real.txt", path.join(sourceDir, "alias.txt"));
|
|
3761
|
+
const sandbox = createMockSandbox();
|
|
3762
|
+
let capturedTarListing = "";
|
|
3763
|
+
sandbox.fs.uploadFiles.mockImplementation(async (uploads) => {
|
|
3764
|
+
capturedTarListing = execFileSync("tar", ["-tvf", uploads[0].source]).toString();
|
|
3765
|
+
});
|
|
3766
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3767
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
3768
|
+
driverKey: "daytona",
|
|
3769
|
+
companyId: "company-1",
|
|
3770
|
+
environmentId: "env-1",
|
|
3771
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3772
|
+
lease: syncLease(),
|
|
3773
|
+
operations: [
|
|
3774
|
+
{
|
|
3775
|
+
operationId: "sync-op-deref",
|
|
3776
|
+
files: [{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/deref`, kind: "directory", followSymlinks: true }],
|
|
3777
|
+
},
|
|
3778
|
+
],
|
|
3779
|
+
});
|
|
3780
|
+
// Dereferenced: alias becomes a regular file, not a link.
|
|
3781
|
+
expect(capturedTarListing).not.toMatch(/alias\.txt ->/);
|
|
3782
|
+
expect(capturedTarListing).toContain("alias.txt");
|
|
3783
|
+
});
|
|
3784
|
+
it("syncOut reads all file mappings via one downloadFiles batch, writes each to its host target, and returns per-operation counts", async () => {
|
|
3785
|
+
const hostDir = await makeHostDir();
|
|
3786
|
+
const sandbox = createMockSandbox();
|
|
3787
|
+
// The download reads sandbox-side snapshots (reserved temp names), which are
|
|
3788
|
+
// index-aligned with the file mappings; write payloads in request order.
|
|
3789
|
+
const payloadsInOrder = ["result-bytes", "secret-bytes"];
|
|
3790
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
3791
|
+
return Promise.all(requests.map(async (req, index) => {
|
|
3792
|
+
await fs.writeFile(req.destination, payloadsInOrder[index]);
|
|
3793
|
+
return { source: req.source, result: req.destination };
|
|
3794
|
+
}));
|
|
3795
|
+
});
|
|
3796
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3797
|
+
const resultTarget = path.join(hostDir, "result.txt");
|
|
3798
|
+
const secretTarget = path.join(hostDir, "secret.key");
|
|
3799
|
+
const result = await plugin.definition.onEnvironmentSyncOut?.({
|
|
3800
|
+
driverKey: "daytona",
|
|
3801
|
+
companyId: "company-1",
|
|
3802
|
+
environmentId: "env-1",
|
|
3803
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3804
|
+
lease: syncLease(),
|
|
3805
|
+
operations: [
|
|
3806
|
+
{
|
|
3807
|
+
operationId: "sync-op-out",
|
|
3808
|
+
files: [
|
|
3809
|
+
{ sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: resultTarget, kind: "file" },
|
|
3810
|
+
{ sourcePath: `${REMOTE_DIR}/out/secret.key`, targetPath: secretTarget, kind: "file", mode: 0o600 },
|
|
3811
|
+
],
|
|
3812
|
+
},
|
|
3813
|
+
],
|
|
3814
|
+
});
|
|
3815
|
+
expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(1);
|
|
3816
|
+
const [requests] = sandbox.fs.downloadFiles.mock.calls[0];
|
|
3817
|
+
expect(requests).toHaveLength(2);
|
|
3818
|
+
for (const req of requests) {
|
|
3819
|
+
expect(path.basename(req.destination)).toMatch(/^\.paperclip-upload-/);
|
|
3820
|
+
// TOCTOU-closed: the download reads a reserved snapshot inside the remote
|
|
3821
|
+
// dir, never the mutable original source path.
|
|
3822
|
+
expect(req.source.startsWith(`${REMOTE_DIR}/`)).toBe(true);
|
|
3823
|
+
expect(path.posix.basename(req.source)).toMatch(/^\.paperclip-upload-/);
|
|
3824
|
+
}
|
|
3825
|
+
expect(requests.map((req) => req.source)).not.toContain(`${REMOTE_DIR}/out/result.txt`);
|
|
3826
|
+
expect(requests.map((req) => req.source)).not.toContain(`${REMOTE_DIR}/out/secret.key`);
|
|
3827
|
+
expect(await fs.readFile(resultTarget, "utf8")).toBe("result-bytes");
|
|
3828
|
+
expect(await fs.readFile(secretTarget, "utf8")).toBe("secret-bytes");
|
|
3829
|
+
// Secret lands 0600 on the host target.
|
|
3830
|
+
expect((await fs.stat(secretTarget)).mode & 0o777).toBe(0o600);
|
|
3831
|
+
expect(result).toEqual({
|
|
3832
|
+
operations: [
|
|
3833
|
+
{ operationId: "sync-op-out", filesTransferred: 2, bytesTransferred: "result-bytes".length + "secret-bytes".length },
|
|
3834
|
+
],
|
|
3835
|
+
});
|
|
3836
|
+
});
|
|
3837
|
+
it("classifies a deleted sandbox during syncOut with a stable unrecoverable code", async () => {
|
|
3838
|
+
const hostDir = await makeHostDir();
|
|
3839
|
+
mockGet.mockRejectedValue(new MockDaytonaNotFoundError("provider detail must not escape"));
|
|
3840
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
3841
|
+
driverKey: "daytona",
|
|
3842
|
+
companyId: "company-1",
|
|
3843
|
+
environmentId: "env-1",
|
|
3844
|
+
config: { timeoutMs: 300000, reuseLease: true },
|
|
3845
|
+
lease: syncLease(),
|
|
3846
|
+
operations: [
|
|
3847
|
+
{
|
|
3848
|
+
operationId: "sync-op-missing-sandbox",
|
|
3849
|
+
files: [
|
|
3850
|
+
{
|
|
3851
|
+
sourcePath: `${REMOTE_DIR}/out/result.txt`,
|
|
3852
|
+
targetPath: path.join(hostDir, "result.txt"),
|
|
3853
|
+
kind: "file",
|
|
3854
|
+
},
|
|
3855
|
+
],
|
|
3856
|
+
},
|
|
3857
|
+
],
|
|
3858
|
+
})).rejects.toThrow("daytona_sandbox_not_found");
|
|
3859
|
+
});
|
|
3860
|
+
it("syncOut snapshot guard re-checks the resolved source is a non-symlink regular file immediately before copying (validation→copy TOCTOU)", async () => {
|
|
3861
|
+
const hostDir = await makeHostDir();
|
|
3862
|
+
const sandbox = createMockSandbox();
|
|
3863
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
3864
|
+
return Promise.all(requests.map(async (req) => {
|
|
3865
|
+
await fs.writeFile(req.destination, "bytes");
|
|
3866
|
+
return { source: req.source, result: req.destination };
|
|
3867
|
+
}));
|
|
3868
|
+
});
|
|
3869
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3870
|
+
await plugin.definition.onEnvironmentSyncOut?.({
|
|
3871
|
+
driverKey: "daytona",
|
|
3872
|
+
companyId: "company-1",
|
|
3873
|
+
environmentId: "env-1",
|
|
3874
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3875
|
+
lease: syncLease(),
|
|
3876
|
+
operations: [
|
|
3877
|
+
{
|
|
3878
|
+
operationId: "sync-op-out-nofollow",
|
|
3879
|
+
files: [{ sourcePath: `${REMOTE_DIR}/out/data.txt`, targetPath: path.join(hostDir, "data.txt"), kind: "file" }],
|
|
3880
|
+
},
|
|
3881
|
+
],
|
|
3882
|
+
});
|
|
3883
|
+
// The snapshot guard runs realpath → confine → no-follow re-check → cp, all in
|
|
3884
|
+
// one `sh -c`. The `[ -L ]`/`[ -f ]` re-check must precede the `cp` so a source
|
|
3885
|
+
// the sandbox repointed to a symlink after realpath is refused, not followed.
|
|
3886
|
+
const guardCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("_pc_resolve") && String(cmd).includes("cp --"));
|
|
3887
|
+
expect(guardCall).toBeDefined();
|
|
3888
|
+
const guardCommand = String(guardCall?.[0]);
|
|
3889
|
+
expect(guardCommand).toContain('[ -L "$_pc_real" ]');
|
|
3890
|
+
expect(guardCommand).toContain('[ -f "$_pc_real" ]');
|
|
3891
|
+
const noFollowIdx = guardCommand.indexOf('[ -L "$_pc_real" ]');
|
|
3892
|
+
const copyIdx = guardCommand.indexOf('cp -- "$_pc_real"');
|
|
3893
|
+
expect(noFollowIdx).toBeGreaterThan(-1);
|
|
3894
|
+
expect(copyIdx).toBeGreaterThan(noFollowIdx);
|
|
3895
|
+
});
|
|
3896
|
+
it("syncOut fails loud when any per-file download reports an error, and leaves no target file", async () => {
|
|
3897
|
+
const hostDir = await makeHostDir();
|
|
3898
|
+
const sandbox = createMockSandbox();
|
|
3899
|
+
// Requests read snapshots (index-aligned with mappings); the second mapping
|
|
3900
|
+
// (`missing.txt`) reports a per-file error.
|
|
3901
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
3902
|
+
return requests.map((req, index) => index === 1
|
|
3903
|
+
? { source: req.source, error: "not found", errorDetails: { message: "not found", statusCode: 404 } }
|
|
3904
|
+
: { source: req.source, result: req.destination });
|
|
3905
|
+
});
|
|
3906
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3907
|
+
const okTarget = path.join(hostDir, "ok.txt");
|
|
3908
|
+
const badTarget = path.join(hostDir, "missing.txt");
|
|
3909
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
3910
|
+
driverKey: "daytona",
|
|
3911
|
+
companyId: "company-1",
|
|
3912
|
+
environmentId: "env-1",
|
|
3913
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3914
|
+
lease: syncLease(),
|
|
3915
|
+
operations: [
|
|
3916
|
+
{
|
|
3917
|
+
operationId: "sync-op-err",
|
|
3918
|
+
files: [
|
|
3919
|
+
{ sourcePath: `${REMOTE_DIR}/ok.txt`, targetPath: okTarget, kind: "file" },
|
|
3920
|
+
{ sourcePath: `${REMOTE_DIR}/missing.txt`, targetPath: badTarget, kind: "file" },
|
|
3921
|
+
],
|
|
3922
|
+
},
|
|
3923
|
+
],
|
|
3924
|
+
})).rejects.toThrow(/download failed for .*missing\.txt: not found/);
|
|
3925
|
+
// Fail-loud: no target file is promoted when the batch has any error.
|
|
3926
|
+
await expect(fs.stat(okTarget)).rejects.toThrow();
|
|
3927
|
+
await expect(fs.stat(badTarget)).rejects.toThrow();
|
|
3928
|
+
});
|
|
3929
|
+
it("syncOut rejects an outbound source whose in-sandbox realpath escapes the workspace remote dir, before any download", async () => {
|
|
3930
|
+
const hostDir = await makeHostDir();
|
|
3931
|
+
const sandbox = createMockSandbox();
|
|
3932
|
+
// The in-sandbox realpath guard runs as a single `sh -c` probe; report the
|
|
3933
|
+
// escape exit code (42) for that probe while leaving any other command green,
|
|
3934
|
+
// so the guard is the only thing that can trip this test.
|
|
3935
|
+
sandbox.process.executeCommand.mockImplementation(async (command) => {
|
|
3936
|
+
if (command.includes("_pc_resolve")) {
|
|
3937
|
+
return { exitCode: 42, result: `ESCAPE:${REMOTE_DIR}/out/link.txt`, artifacts: { stdout: "" } };
|
|
3938
|
+
}
|
|
3939
|
+
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
|
|
3940
|
+
});
|
|
3941
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3942
|
+
const target = path.join(hostDir, "link.txt");
|
|
3943
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
3944
|
+
driverKey: "daytona",
|
|
3945
|
+
companyId: "company-1",
|
|
3946
|
+
environmentId: "env-1",
|
|
3947
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3948
|
+
lease: syncLease(),
|
|
3949
|
+
operations: [
|
|
3950
|
+
{
|
|
3951
|
+
operationId: "sync-op-escape-out",
|
|
3952
|
+
files: [{ sourcePath: `${REMOTE_DIR}/out/link.txt`, targetPath: target, kind: "file" }],
|
|
3953
|
+
},
|
|
3954
|
+
],
|
|
3955
|
+
})).rejects.toThrow(/outbound symlink-escape guard command failed \(exit 42\)/);
|
|
3956
|
+
// Fail-closed: the guard trips before any bytes are read, and no target lands.
|
|
3957
|
+
expect(sandbox.fs.downloadFiles).not.toHaveBeenCalled();
|
|
3958
|
+
await expect(fs.stat(target)).rejects.toThrow();
|
|
3959
|
+
});
|
|
3960
|
+
it("syncOut fails closed when the sandbox has no path canonicalizer to resolve the symlink guard", async () => {
|
|
3961
|
+
const hostDir = await makeHostDir();
|
|
3962
|
+
const sandbox = createMockSandbox();
|
|
3963
|
+
// Neither `realpath` nor `readlink` present → the probe exits 40 rather than
|
|
3964
|
+
// silently skipping the guard the host-side string check cannot enforce.
|
|
3965
|
+
sandbox.process.executeCommand.mockImplementation(async (command) => {
|
|
3966
|
+
if (command.includes("_pc_resolve")) {
|
|
3967
|
+
return { exitCode: 40, result: "no path canonicalizer available", artifacts: { stdout: "" } };
|
|
3968
|
+
}
|
|
3969
|
+
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
|
|
3970
|
+
});
|
|
3971
|
+
mockGet.mockResolvedValue(sandbox);
|
|
3972
|
+
const target = path.join(hostDir, "data.txt");
|
|
3973
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
3974
|
+
driverKey: "daytona",
|
|
3975
|
+
companyId: "company-1",
|
|
3976
|
+
environmentId: "env-1",
|
|
3977
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
3978
|
+
lease: syncLease(),
|
|
3979
|
+
operations: [
|
|
3980
|
+
{
|
|
3981
|
+
operationId: "sync-op-no-canon",
|
|
3982
|
+
files: [{ sourcePath: `${REMOTE_DIR}/out/data.txt`, targetPath: target, kind: "file" }],
|
|
3983
|
+
},
|
|
3984
|
+
],
|
|
3985
|
+
})).rejects.toThrow(/outbound symlink-escape guard command failed \(exit 40\)/);
|
|
3986
|
+
expect(sandbox.fs.downloadFiles).not.toHaveBeenCalled();
|
|
3987
|
+
await expect(fs.stat(target)).rejects.toThrow();
|
|
3988
|
+
});
|
|
3989
|
+
it("syncIn sweeps staged temps when the batched rename fails mid-promotion", async () => {
|
|
3990
|
+
const hostDir = await makeHostDir();
|
|
3991
|
+
const source = path.join(hostDir, "config.txt");
|
|
3992
|
+
await fs.writeFile(source, "plain");
|
|
3993
|
+
const sandbox = createMockSandbox();
|
|
3994
|
+
// mkdir + realpath guard succeed; the promoting `mv -f` fails, leaving staged
|
|
3995
|
+
// `.paperclip-upload-*` temps that the error path must sweep with `rm -f`.
|
|
3996
|
+
sandbox.process.executeCommand.mockImplementation(async (command) => {
|
|
3997
|
+
if (command.includes("mv -f")) {
|
|
3998
|
+
return { exitCode: 1, result: "mv: permission denied", artifacts: { stdout: "mv: permission denied" } };
|
|
3999
|
+
}
|
|
4000
|
+
return { exitCode: 0, result: "bash", artifacts: { stdout: "bash" } };
|
|
4001
|
+
});
|
|
4002
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4003
|
+
await expect(plugin.definition.onEnvironmentSyncIn?.({
|
|
4004
|
+
driverKey: "daytona",
|
|
4005
|
+
companyId: "company-1",
|
|
4006
|
+
environmentId: "env-1",
|
|
4007
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4008
|
+
lease: syncLease(),
|
|
4009
|
+
operations: [
|
|
4010
|
+
{
|
|
4011
|
+
operationId: "sync-op-in-rename-fail",
|
|
4012
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
4013
|
+
},
|
|
4014
|
+
],
|
|
4015
|
+
})).rejects.toThrow(/syncIn rename command failed \(exit 1\)/);
|
|
4016
|
+
// The upload happened, so a temp was staged; the error path cleans it up.
|
|
4017
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
|
|
4018
|
+
const cleanupCall = sandbox.process.executeCommand.mock.calls.find(([cmd]) => String(cmd).includes("rm -f") && String(cmd).includes(".paperclip-upload-"));
|
|
4019
|
+
expect(cleanupCall).toBeDefined();
|
|
4020
|
+
});
|
|
4021
|
+
it("syncOut refuses a sandbox-authored tarball whose members escape the extraction dir (path traversal)", async () => {
|
|
4022
|
+
const hostRoot = await makeHostDir();
|
|
4023
|
+
const restored = path.join(hostRoot, "restored");
|
|
4024
|
+
const sandbox = createMockSandbox();
|
|
4025
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4026
|
+
return Promise.all(requests.map(async (req) => {
|
|
4027
|
+
// Craft a tar containing a traversal member `../escape.txt`.
|
|
4028
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-evil-"));
|
|
4029
|
+
tempDirs.push(staging);
|
|
4030
|
+
await fs.mkdir(path.join(staging, "sub"), { recursive: true });
|
|
4031
|
+
await fs.writeFile(path.join(staging, "sub", "escape.txt"), "escape");
|
|
4032
|
+
// GNU spells member-name rewriting --transform; bsdtar (macOS) spells it -s.
|
|
4033
|
+
const gnuTar = execFileSync("tar", ["--version"]).toString().includes("GNU tar");
|
|
4034
|
+
execFileSync("tar", [
|
|
4035
|
+
"-cf",
|
|
4036
|
+
req.destination,
|
|
4037
|
+
"-C",
|
|
4038
|
+
path.join(staging, "sub"),
|
|
4039
|
+
...(gnuTar ? ["--transform", "s,^,../,"] : ["-s", ",^,../,"]),
|
|
4040
|
+
"escape.txt",
|
|
4041
|
+
]);
|
|
4042
|
+
return { source: req.source, result: req.destination };
|
|
4043
|
+
}));
|
|
4044
|
+
});
|
|
4045
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4046
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
4047
|
+
driverKey: "daytona",
|
|
4048
|
+
companyId: "company-1",
|
|
4049
|
+
environmentId: "env-1",
|
|
4050
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4051
|
+
lease: syncLease(),
|
|
4052
|
+
operations: [
|
|
4053
|
+
{
|
|
4054
|
+
operationId: "sync-op-out-traversal",
|
|
4055
|
+
files: [{ sourcePath: `${REMOTE_DIR}/proj`, targetPath: restored, kind: "directory" }],
|
|
4056
|
+
},
|
|
4057
|
+
],
|
|
4058
|
+
})).rejects.toThrow(/escapes the extraction dir/);
|
|
4059
|
+
// The traversal member (`../escape.txt` relative to `restored`) was never
|
|
4060
|
+
// written above the extraction dir.
|
|
4061
|
+
await expect(fs.stat(path.join(hostRoot, "escape.txt"))).rejects.toThrow();
|
|
4062
|
+
});
|
|
4063
|
+
it("syncOut refuses a sandbox-authored tarball carrying a symlink whose target escapes the extraction dir", async () => {
|
|
4064
|
+
const hostRoot = await makeHostDir();
|
|
4065
|
+
const restored = path.join(hostRoot, "restored");
|
|
4066
|
+
const sandbox = createMockSandbox();
|
|
4067
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4068
|
+
return Promise.all(requests.map(async (req) => {
|
|
4069
|
+
// Craft a tar whose sole member is a symlink pointing above the tree.
|
|
4070
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-evil-"));
|
|
4071
|
+
tempDirs.push(staging);
|
|
4072
|
+
await fs.mkdir(path.join(staging, "sub"), { recursive: true });
|
|
4073
|
+
await fs.symlink("../../outside.txt", path.join(staging, "sub", "evil"));
|
|
4074
|
+
execFileSync("tar", ["-cf", req.destination, "-C", path.join(staging, "sub"), "evil"]);
|
|
4075
|
+
return { source: req.source, result: req.destination };
|
|
4076
|
+
}));
|
|
4077
|
+
});
|
|
4078
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4079
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
4080
|
+
driverKey: "daytona",
|
|
4081
|
+
companyId: "company-1",
|
|
4082
|
+
environmentId: "env-1",
|
|
4083
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4084
|
+
lease: syncLease(),
|
|
4085
|
+
operations: [
|
|
4086
|
+
{
|
|
4087
|
+
operationId: "sync-op-out-symlink-escape",
|
|
4088
|
+
files: [{ sourcePath: `${REMOTE_DIR}/proj`, targetPath: restored, kind: "directory" }],
|
|
4089
|
+
},
|
|
4090
|
+
],
|
|
4091
|
+
})).rejects.toThrow(/link whose target escapes the extraction dir/);
|
|
4092
|
+
// Fail-closed: the confinement check runs before extraction touches disk.
|
|
4093
|
+
await expect(fs.stat(restored)).rejects.toThrow();
|
|
4094
|
+
});
|
|
4095
|
+
it("syncOut refuses a symlink whose name embeds the listing delimiter (ambiguous split hides the real target)", async () => {
|
|
4096
|
+
const hostRoot = await makeHostDir();
|
|
4097
|
+
const restored = path.join(hostRoot, "restored");
|
|
4098
|
+
const sandbox = createMockSandbox();
|
|
4099
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4100
|
+
return Promise.all(requests.map(async (req) => {
|
|
4101
|
+
// A symlink literally named "evil -> decoy" with an escaping target
|
|
4102
|
+
// lists as "evil -> decoy -> ../../outside.txt"; splitting at the
|
|
4103
|
+
// first delimiter would validate "decoy -> ../../outside.txt" (which
|
|
4104
|
+
// normalizes in-tree) while tar extracts the real escaping link.
|
|
4105
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-evil-"));
|
|
4106
|
+
tempDirs.push(staging);
|
|
4107
|
+
await fs.mkdir(path.join(staging, "sub"), { recursive: true });
|
|
4108
|
+
await fs.symlink("../../outside.txt", path.join(staging, "sub", "evil -> decoy"));
|
|
4109
|
+
execFileSync("tar", ["-cf", req.destination, "-C", path.join(staging, "sub"), "evil -> decoy"]);
|
|
4110
|
+
return { source: req.source, result: req.destination };
|
|
4111
|
+
}));
|
|
4112
|
+
});
|
|
4113
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4114
|
+
await expect(plugin.definition.onEnvironmentSyncOut?.({
|
|
4115
|
+
driverKey: "daytona",
|
|
4116
|
+
companyId: "company-1",
|
|
4117
|
+
environmentId: "env-1",
|
|
4118
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4119
|
+
lease: syncLease(),
|
|
4120
|
+
operations: [
|
|
4121
|
+
{
|
|
4122
|
+
operationId: "sync-op-out-ambiguous-symlink",
|
|
4123
|
+
files: [{ sourcePath: `${REMOTE_DIR}/proj`, targetPath: restored, kind: "directory" }],
|
|
4124
|
+
},
|
|
4125
|
+
],
|
|
4126
|
+
})).rejects.toThrow(/ambiguous symlink entry/);
|
|
4127
|
+
// Fail-closed: the confinement check runs before extraction touches disk.
|
|
4128
|
+
await expect(fs.stat(restored)).rejects.toThrow();
|
|
4129
|
+
});
|
|
4130
|
+
it("round-trips a directory (syncIn then syncOut) preserving contents, a 0600 file, and a preserved symlink", async () => {
|
|
4131
|
+
const hostRoot = await makeHostDir();
|
|
4132
|
+
const source = path.join(hostRoot, "src");
|
|
4133
|
+
await fs.mkdir(path.join(source, "nested"), { recursive: true });
|
|
4134
|
+
await fs.writeFile(path.join(source, "nested", "data.txt"), "hello world");
|
|
4135
|
+
await fs.writeFile(path.join(source, "secret"), "top-secret");
|
|
4136
|
+
await fs.chmod(path.join(source, "secret"), 0o600);
|
|
4137
|
+
await fs.symlink("nested/data.txt", path.join(source, "shortcut"));
|
|
4138
|
+
// Simulate the sandbox filesystem with a host-side directory the mock tar
|
|
4139
|
+
// commands operate on, so the round-trip exercises real tar create/extract.
|
|
4140
|
+
const sandboxFsRoot = await makeHostDir();
|
|
4141
|
+
const remoteTargetDir = path.join(sandboxFsRoot, "materialized");
|
|
4142
|
+
const sandbox = createMockSandbox();
|
|
4143
|
+
// syncIn: capture the uploaded host tar and extract it into the simulated
|
|
4144
|
+
// sandbox dir, mirroring the in-sandbox `tar -xf`.
|
|
4145
|
+
sandbox.fs.uploadFiles.mockImplementation(async (uploads) => {
|
|
4146
|
+
for (const upload of uploads) {
|
|
4147
|
+
await fs.mkdir(remoteTargetDir, { recursive: true });
|
|
4148
|
+
execFileSync("tar", ["-xpf", upload.source, "-C", remoteTargetDir]);
|
|
4149
|
+
}
|
|
4150
|
+
});
|
|
4151
|
+
// syncOut: build a tar of the simulated sandbox dir and stream it to the
|
|
4152
|
+
// requested host destination, mirroring in-sandbox `tar -c` + downloadFiles.
|
|
4153
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4154
|
+
return Promise.all(requests.map(async (req) => {
|
|
4155
|
+
const entries = (await fs.readdir(remoteTargetDir)).sort();
|
|
4156
|
+
execFileSync("tar", ["-cpf", req.destination, "-C", remoteTargetDir, "--", ...entries]);
|
|
4157
|
+
return { source: req.source, result: req.destination };
|
|
4158
|
+
}));
|
|
4159
|
+
});
|
|
4160
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4161
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
4162
|
+
driverKey: "daytona",
|
|
4163
|
+
companyId: "company-1",
|
|
4164
|
+
environmentId: "env-1",
|
|
4165
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4166
|
+
lease: syncLease(),
|
|
4167
|
+
operations: [
|
|
4168
|
+
{ operationId: "rt-in", files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/proj`, kind: "directory" }] },
|
|
4169
|
+
],
|
|
4170
|
+
});
|
|
4171
|
+
const restored = path.join(hostRoot, "restored");
|
|
4172
|
+
await plugin.definition.onEnvironmentSyncOut?.({
|
|
4173
|
+
driverKey: "daytona",
|
|
4174
|
+
companyId: "company-1",
|
|
4175
|
+
environmentId: "env-1",
|
|
4176
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4177
|
+
lease: syncLease(),
|
|
4178
|
+
operations: [
|
|
4179
|
+
{ operationId: "rt-out", files: [{ sourcePath: `${REMOTE_DIR}/proj`, targetPath: restored, kind: "directory" }] },
|
|
4180
|
+
],
|
|
4181
|
+
});
|
|
4182
|
+
expect(await fs.readFile(path.join(restored, "nested", "data.txt"), "utf8")).toBe("hello world");
|
|
4183
|
+
expect(await fs.readFile(path.join(restored, "secret"), "utf8")).toBe("top-secret");
|
|
4184
|
+
expect((await fs.stat(path.join(restored, "secret"))).mode & 0o777).toBe(0o600);
|
|
4185
|
+
const linkStat = await fs.lstat(path.join(restored, "shortcut"));
|
|
4186
|
+
expect(linkStat.isSymbolicLink()).toBe(true);
|
|
4187
|
+
expect(await fs.readlink(path.join(restored, "shortcut"))).toBe("nested/data.txt");
|
|
4188
|
+
});
|
|
4189
|
+
// -------------------------------------------------------------------------
|
|
4190
|
+
// Post-upload commands (Phase 3 / Security Conditions C1–C4). Daytona runs an
|
|
4191
|
+
// operation's ordered `postUploadCommands` in-sandbox AFTER `uploadFiles`,
|
|
4192
|
+
// fail-fast, with the command `cwd` re-confined under the workspace remote dir.
|
|
4193
|
+
// -------------------------------------------------------------------------
|
|
4194
|
+
it("runs post-upload commands in array order AFTER uploadFiles, each verbatim via the exec seam", async () => {
|
|
4195
|
+
const hostDir = await makeHostDir();
|
|
4196
|
+
const source = path.join(hostDir, "config.txt");
|
|
4197
|
+
await fs.writeFile(source, "plain");
|
|
4198
|
+
const sandbox = createMockSandbox();
|
|
4199
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4200
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
4201
|
+
driverKey: "daytona",
|
|
4202
|
+
companyId: "company-1",
|
|
4203
|
+
environmentId: "env-1",
|
|
4204
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4205
|
+
lease: syncLease(),
|
|
4206
|
+
operations: [
|
|
4207
|
+
{
|
|
4208
|
+
operationId: "op-cmd",
|
|
4209
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
4210
|
+
postUploadCommands: [
|
|
4211
|
+
{ command: "codex-auth-merge --first" },
|
|
4212
|
+
{ command: "chmod 600 config.txt" },
|
|
4213
|
+
],
|
|
4214
|
+
},
|
|
4215
|
+
],
|
|
4216
|
+
});
|
|
4217
|
+
// Both commands ran, VERBATIM (first arg is the exact authored string — the
|
|
4218
|
+
// provider never rewrote/concatenated a shell fragment onto it: C1/C3).
|
|
4219
|
+
const findCall = (cmd) => sandbox.process.executeCommand.mock.calls.find(([c]) => c === cmd);
|
|
4220
|
+
expect(findCall("codex-auth-merge --first")).toBeDefined();
|
|
4221
|
+
expect(findCall("chmod 600 config.txt")).toBeDefined();
|
|
4222
|
+
// Ordered: the first command's exec precedes the second's (C4 array order).
|
|
4223
|
+
const orderOf = (cmd) => {
|
|
4224
|
+
const idx = sandbox.process.executeCommand.mock.calls.findIndex(([c]) => c === cmd);
|
|
4225
|
+
return sandbox.process.executeCommand.mock.invocationCallOrder[idx];
|
|
4226
|
+
};
|
|
4227
|
+
expect(orderOf("codex-auth-merge --first")).toBeLessThan(orderOf("chmod 600 config.txt"));
|
|
4228
|
+
// Upload happened BEFORE the first command.
|
|
4229
|
+
expect(sandbox.fs.uploadFiles.mock.invocationCallOrder[0]).toBeLessThan(orderOf("codex-auth-merge --first"));
|
|
4230
|
+
// Absent `cwd` defaults to the provider-resolved remote dir — never a process
|
|
4231
|
+
// default cwd (C2). The command's structured cwd argument is REMOTE_DIR.
|
|
4232
|
+
expect(findCall("codex-auth-merge --first")?.[1]).toBe(REMOTE_DIR);
|
|
4233
|
+
});
|
|
4234
|
+
it("aborts the operation fail-loud on a non-zero post-upload command exit, skipping the remainder (C4)", async () => {
|
|
4235
|
+
const hostDir = await makeHostDir();
|
|
4236
|
+
const source = path.join(hostDir, "config.txt");
|
|
4237
|
+
await fs.writeFile(source, "plain");
|
|
4238
|
+
const sandbox = createMockSandbox();
|
|
4239
|
+
// The first command exits non-zero; every transfer/guard script stays green.
|
|
4240
|
+
sandbox.process.executeCommand.mockImplementation(async (command) => {
|
|
4241
|
+
if (command === "failing-command") {
|
|
4242
|
+
return { exitCode: 7, result: "boom", artifacts: { stdout: "boom" } };
|
|
4243
|
+
}
|
|
4244
|
+
return { exitCode: 0, result: "", artifacts: { stdout: "" } };
|
|
4245
|
+
});
|
|
4246
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4247
|
+
await expect(plugin.definition.onEnvironmentSyncIn?.({
|
|
4248
|
+
driverKey: "daytona",
|
|
4249
|
+
companyId: "company-1",
|
|
4250
|
+
environmentId: "env-1",
|
|
4251
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4252
|
+
lease: syncLease(),
|
|
4253
|
+
operations: [
|
|
4254
|
+
{
|
|
4255
|
+
operationId: "op-fail",
|
|
4256
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
4257
|
+
postUploadCommands: [{ command: "failing-command" }, { command: "should-not-run" }],
|
|
4258
|
+
},
|
|
4259
|
+
],
|
|
4260
|
+
})).rejects.toThrow(/post-upload command failed \(exit 7\)/);
|
|
4261
|
+
// Fail-fast: the command after the failing one never executed.
|
|
4262
|
+
expect(sandbox.process.executeCommand.mock.calls.some(([c]) => c === "should-not-run")).toBe(false);
|
|
4263
|
+
});
|
|
4264
|
+
// -------------------------------------------------------------------------
|
|
4265
|
+
// Merged git-workspace operation. A git-backed workspace stage-sync rides ONE
|
|
4266
|
+
// operation whose `files` carry the git-history tar and the workspace-overlay
|
|
4267
|
+
// tar, with the two extract commands as ordered `postUploadCommands`. The
|
|
4268
|
+
// operation shares one mkdir, one confine guard, one `uploadFiles`, and one
|
|
4269
|
+
// rename exec.
|
|
4270
|
+
// -------------------------------------------------------------------------
|
|
4271
|
+
it("stages a merged git-workspace operation as one uploadFiles batch and one rename exec, both extracts in order", async () => {
|
|
4272
|
+
const hostDir = await makeHostDir();
|
|
4273
|
+
const gitTar = path.join(hostDir, "git-workspace.tar");
|
|
4274
|
+
const overlayTar = path.join(hostDir, "workspace.tar");
|
|
4275
|
+
await fs.writeFile(gitTar, "git-bytes");
|
|
4276
|
+
await fs.writeFile(overlayTar, "overlay-bytes");
|
|
4277
|
+
const runtimeDir = `${REMOTE_DIR}/.paperclip-runtime/adapter`;
|
|
4278
|
+
const sandbox = createMockSandbox();
|
|
4279
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4280
|
+
const gitExtract = "git-history-extract";
|
|
4281
|
+
const overlayExtract = "workspace-overlay-extract";
|
|
4282
|
+
const result = await plugin.definition.onEnvironmentSyncIn?.({
|
|
4283
|
+
driverKey: "daytona",
|
|
4284
|
+
companyId: "company-1",
|
|
4285
|
+
environmentId: "env-1",
|
|
4286
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4287
|
+
lease: syncLease(),
|
|
4288
|
+
operations: [
|
|
4289
|
+
{
|
|
4290
|
+
operationId: "merged-workspace",
|
|
4291
|
+
files: [
|
|
4292
|
+
{ sourcePath: gitTar, targetPath: `${runtimeDir}/git-workspace-upload.tar`, kind: "file" },
|
|
4293
|
+
{ sourcePath: overlayTar, targetPath: `${runtimeDir}/workspace-upload.tar`, kind: "file" },
|
|
4294
|
+
],
|
|
4295
|
+
postUploadCommands: [{ command: gitExtract }, { command: overlayExtract }],
|
|
4296
|
+
},
|
|
4297
|
+
],
|
|
4298
|
+
});
|
|
4299
|
+
// One bulk upload carries BOTH tars; one rename exec promotes both temps.
|
|
4300
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
|
|
4301
|
+
const [uploads] = sandbox.fs.uploadFiles.mock.calls[0];
|
|
4302
|
+
expect(uploads).toHaveLength(2);
|
|
4303
|
+
const mvCalls = sandbox.process.executeCommand.mock.calls.filter(([cmd]) => String(cmd).includes("mv -f"));
|
|
4304
|
+
expect(mvCalls).toHaveLength(1);
|
|
4305
|
+
expect(String(mvCalls[0][0]).match(/mv -f /g)).toHaveLength(2);
|
|
4306
|
+
// Both extract commands ran, in array order, AFTER the upload (git first).
|
|
4307
|
+
const orderOf = (cmd) => {
|
|
4308
|
+
const idx = sandbox.process.executeCommand.mock.calls.findIndex(([c]) => c === cmd);
|
|
4309
|
+
return sandbox.process.executeCommand.mock.invocationCallOrder[idx];
|
|
4310
|
+
};
|
|
4311
|
+
expect(orderOf(gitExtract)).toBeLessThan(orderOf(overlayExtract));
|
|
4312
|
+
expect(sandbox.fs.uploadFiles.mock.invocationCallOrder[0]).toBeLessThan(orderOf(gitExtract));
|
|
4313
|
+
expect(result).toEqual({
|
|
4314
|
+
operations: [{
|
|
4315
|
+
operationId: "merged-workspace",
|
|
4316
|
+
filesTransferred: 2,
|
|
4317
|
+
bytesTransferred: "git-bytes".length + "overlay-bytes".length,
|
|
4318
|
+
}],
|
|
4319
|
+
});
|
|
4320
|
+
});
|
|
4321
|
+
it("stops the overlay and remove-deleted commands when the git extract fails (merged operation fail-fast)", async () => {
|
|
4322
|
+
const hostDir = await makeHostDir();
|
|
4323
|
+
const gitTar = path.join(hostDir, "git-workspace.tar");
|
|
4324
|
+
const overlayTar = path.join(hostDir, "workspace.tar");
|
|
4325
|
+
await fs.writeFile(gitTar, "git-bytes");
|
|
4326
|
+
await fs.writeFile(overlayTar, "overlay-bytes");
|
|
4327
|
+
const runtimeDir = `${REMOTE_DIR}/.paperclip-runtime/adapter`;
|
|
4328
|
+
const sandbox = createMockSandbox();
|
|
4329
|
+
// The first (git-history) extract exits non-zero; every transfer/guard script
|
|
4330
|
+
// stays green so the fail-fast loop is the only thing that can trip this test.
|
|
4331
|
+
sandbox.process.executeCommand.mockImplementation(async (command) => {
|
|
4332
|
+
if (command === "git-history-extract") {
|
|
4333
|
+
return { exitCode: 5, result: "boom", artifacts: { stdout: "boom" } };
|
|
4334
|
+
}
|
|
4335
|
+
return { exitCode: 0, result: "", artifacts: { stdout: "" } };
|
|
4336
|
+
});
|
|
4337
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4338
|
+
await expect(plugin.definition.onEnvironmentSyncIn?.({
|
|
4339
|
+
driverKey: "daytona",
|
|
4340
|
+
companyId: "company-1",
|
|
4341
|
+
environmentId: "env-1",
|
|
4342
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4343
|
+
lease: syncLease(),
|
|
4344
|
+
operations: [
|
|
4345
|
+
{
|
|
4346
|
+
operationId: "merged-failfast",
|
|
4347
|
+
files: [
|
|
4348
|
+
{ sourcePath: gitTar, targetPath: `${runtimeDir}/git-workspace-upload.tar`, kind: "file" },
|
|
4349
|
+
{ sourcePath: overlayTar, targetPath: `${runtimeDir}/workspace-upload.tar`, kind: "file" },
|
|
4350
|
+
],
|
|
4351
|
+
postUploadCommands: [
|
|
4352
|
+
{ command: "git-history-extract" },
|
|
4353
|
+
{ command: "workspace-overlay-extract" },
|
|
4354
|
+
{ command: "remove-deleted-paths" },
|
|
4355
|
+
],
|
|
4356
|
+
},
|
|
4357
|
+
],
|
|
4358
|
+
})).rejects.toThrow(/post-upload command failed \(exit 5\)/);
|
|
4359
|
+
// Fail-fast: the overlay extract and the remove-deleted command never ran.
|
|
4360
|
+
const ran = (cmd) => sandbox.process.executeCommand.mock.calls.some(([c]) => c === cmd);
|
|
4361
|
+
expect(ran("git-history-extract")).toBe(true);
|
|
4362
|
+
expect(ran("workspace-overlay-extract")).toBe(false);
|
|
4363
|
+
expect(ran("remove-deleted-paths")).toBe(false);
|
|
4364
|
+
});
|
|
4365
|
+
it("issues no extra exec when an operation has no post-upload commands (backward-compat)", async () => {
|
|
4366
|
+
const hostDir = await makeHostDir();
|
|
4367
|
+
const source = path.join(hostDir, "config.txt");
|
|
4368
|
+
await fs.writeFile(source, "plain");
|
|
4369
|
+
const baseline = createMockSandbox();
|
|
4370
|
+
mockGet.mockResolvedValue(baseline);
|
|
4371
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
4372
|
+
driverKey: "daytona",
|
|
4373
|
+
companyId: "company-1",
|
|
4374
|
+
environmentId: "env-1",
|
|
4375
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4376
|
+
lease: syncLease(),
|
|
4377
|
+
operations: [
|
|
4378
|
+
{ operationId: "op-plain", files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }] },
|
|
4379
|
+
],
|
|
4380
|
+
});
|
|
4381
|
+
const baselineExecCount = baseline.process.executeCommand.mock.calls.length;
|
|
4382
|
+
// Same operation, now with an (empty) postUploadCommands array — must be
|
|
4383
|
+
// byte-identical: an absent/empty command list adds zero execs.
|
|
4384
|
+
// Reset the process-scoped handle cache so the second operation fetches its
|
|
4385
|
+
// own `withEmpty` handle. Both operations reuse the same providerLeaseId, so
|
|
4386
|
+
// without this reset the cache serves the first `baseline` handle again and
|
|
4387
|
+
// `withEmpty` records zero execs.
|
|
4388
|
+
__resetDaytonaSandboxHandleCacheForTest();
|
|
4389
|
+
const withEmpty = createMockSandbox();
|
|
4390
|
+
mockGet.mockResolvedValue(withEmpty);
|
|
4391
|
+
await plugin.definition.onEnvironmentSyncIn?.({
|
|
4392
|
+
driverKey: "daytona",
|
|
4393
|
+
companyId: "company-1",
|
|
4394
|
+
environmentId: "env-1",
|
|
4395
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4396
|
+
lease: syncLease(),
|
|
4397
|
+
operations: [
|
|
4398
|
+
{
|
|
4399
|
+
operationId: "op-plain",
|
|
4400
|
+
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
|
|
4401
|
+
postUploadCommands: [],
|
|
4402
|
+
},
|
|
4403
|
+
],
|
|
4404
|
+
});
|
|
4405
|
+
expect(withEmpty.process.executeCommand.mock.calls.length).toBe(baselineExecCount);
|
|
4406
|
+
});
|
|
4407
|
+
it("runs two concurrent inbound syncIn calls with separate reserved scratch names", async () => {
|
|
4408
|
+
const hostDir = await makeHostDir();
|
|
4409
|
+
const sourceA = path.join(hostDir, "a.txt");
|
|
4410
|
+
const sourceB = path.join(hostDir, "b.txt");
|
|
4411
|
+
await fs.writeFile(sourceA, "alpha");
|
|
4412
|
+
await fs.writeFile(sourceB, "beta");
|
|
4413
|
+
const sandbox = createMockSandbox();
|
|
4414
|
+
// Gate the upload so both concurrent calls sit inside uploadFiles together.
|
|
4415
|
+
const gate = createTransferGate(2, async () => undefined);
|
|
4416
|
+
sandbox.fs.uploadFiles.mockImplementation(gate.body);
|
|
4417
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4418
|
+
const callA = plugin.definition.onEnvironmentSyncIn?.(syncInParams({ operationId: "in-a", sourcePath: sourceA, targetPath: `${REMOTE_DIR}/a.txt` }));
|
|
4419
|
+
const callB = plugin.definition.onEnvironmentSyncIn?.(syncInParams({ operationId: "in-b", sourcePath: sourceB, targetPath: `${REMOTE_DIR}/b.txt` }));
|
|
4420
|
+
// Both calls reached the upload before either finished, so they overlap.
|
|
4421
|
+
await gate.bothArrived;
|
|
4422
|
+
expect(gate.peak()).toBe(2);
|
|
4423
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(2);
|
|
4424
|
+
gate.release();
|
|
4425
|
+
await Promise.all([callA, callB]);
|
|
4426
|
+
// Each concurrent call staged its upload under its own reserved scratch name;
|
|
4427
|
+
// the two calls never share a temporary destination.
|
|
4428
|
+
const destinations = sandbox.fs.uploadFiles.mock.calls.flatMap(([uploads]) => uploads.map((upload) => upload.destination));
|
|
4429
|
+
expect(destinations).toHaveLength(2);
|
|
4430
|
+
for (const destination of destinations) {
|
|
4431
|
+
expect(path.posix.basename(destination)).toMatch(/^\.paperclip-upload-/);
|
|
4432
|
+
expect(path.posix.dirname(destination)).toBe(REMOTE_DIR);
|
|
4433
|
+
}
|
|
4434
|
+
expect(new Set(destinations).size).toBe(destinations.length);
|
|
4435
|
+
});
|
|
4436
|
+
it("runs two concurrent outbound syncOut calls that both reach downloadFiles before either opens", async () => {
|
|
4437
|
+
const hostDir = await makeHostDir();
|
|
4438
|
+
const targetA = path.join(hostDir, "a.txt");
|
|
4439
|
+
const targetB = path.join(hostDir, "b.txt");
|
|
4440
|
+
const sandbox = createMockSandbox();
|
|
4441
|
+
// Gate the download so both concurrent calls sit inside downloadFiles
|
|
4442
|
+
// together before either resolves.
|
|
4443
|
+
const gate = createTransferGate(2, fulfilDownload);
|
|
4444
|
+
sandbox.fs.downloadFiles.mockImplementation(gate.body);
|
|
4445
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4446
|
+
const callA = plugin.definition.onEnvironmentSyncOut?.(syncOutParams({ operationId: "out-a", sourcePath: `${REMOTE_DIR}/a.txt`, targetPath: targetA }));
|
|
4447
|
+
const callB = plugin.definition.onEnvironmentSyncOut?.(syncOutParams({ operationId: "out-b", sourcePath: `${REMOTE_DIR}/b.txt`, targetPath: targetB }));
|
|
4448
|
+
// Both calls reached the download before either gate opened, so they overlap.
|
|
4449
|
+
await gate.bothArrived;
|
|
4450
|
+
expect(gate.peak()).toBe(2);
|
|
4451
|
+
expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(2);
|
|
4452
|
+
gate.release();
|
|
4453
|
+
await Promise.all([callA, callB]);
|
|
4454
|
+
// Each concurrent call read its own reserved snapshot; the two calls never
|
|
4455
|
+
// share a download source.
|
|
4456
|
+
const sources = sandbox.fs.downloadFiles.mock.calls.flatMap(([requests]) => requests.map((request) => request.source));
|
|
4457
|
+
expect(sources).toHaveLength(2);
|
|
4458
|
+
for (const source of sources) {
|
|
4459
|
+
expect(source.startsWith(`${REMOTE_DIR}/`)).toBe(true);
|
|
4460
|
+
expect(path.posix.basename(source)).toMatch(/^\.paperclip-upload-/);
|
|
4461
|
+
}
|
|
4462
|
+
expect(new Set(sources).size).toBe(sources.length);
|
|
4463
|
+
expect(await fs.readFile(targetA, "utf8")).toBe("bytes");
|
|
4464
|
+
expect(await fs.readFile(targetB, "utf8")).toBe("bytes");
|
|
4465
|
+
});
|
|
4466
|
+
it("waits for one active inbound and one active outbound call before teardown releases the sandbox", async () => {
|
|
4467
|
+
const hostDir = await makeHostDir();
|
|
4468
|
+
const inboundSource = path.join(hostDir, "in.txt");
|
|
4469
|
+
const outboundTarget = path.join(hostDir, "out.txt");
|
|
4470
|
+
await fs.writeFile(inboundSource, "inbound");
|
|
4471
|
+
const sandbox = createMockSandbox({ id: "sandbox-123" });
|
|
4472
|
+
// Hold the inbound upload and the outbound download open at the same time, so
|
|
4473
|
+
// the shared lease has two active sync calls when teardown starts.
|
|
4474
|
+
let releaseUpload;
|
|
4475
|
+
sandbox.fs.uploadFiles.mockImplementation(async () => {
|
|
4476
|
+
await new Promise((resolve) => {
|
|
4477
|
+
releaseUpload = resolve;
|
|
4478
|
+
});
|
|
4479
|
+
});
|
|
4480
|
+
let releaseDownload;
|
|
4481
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4482
|
+
await new Promise((resolve) => {
|
|
4483
|
+
releaseDownload = resolve;
|
|
4484
|
+
});
|
|
4485
|
+
return Promise.all(requests.map(async (request) => {
|
|
4486
|
+
await fs.writeFile(request.destination, "bytes");
|
|
4487
|
+
return { source: request.source, result: request.destination };
|
|
4488
|
+
}));
|
|
4489
|
+
});
|
|
4490
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4491
|
+
const inboundCall = plugin.definition.onEnvironmentSyncIn?.(syncInParams({ operationId: "in-active", sourcePath: inboundSource, targetPath: `${REMOTE_DIR}/in.txt` }));
|
|
4492
|
+
const outboundCall = plugin.definition.onEnvironmentSyncOut?.(syncOutParams({ operationId: "out-active", sourcePath: `${REMOTE_DIR}/out.txt`, targetPath: outboundTarget }));
|
|
4493
|
+
// Let both sync calls register on the activity gate and reach their hung
|
|
4494
|
+
// transfer, so teardown sees a refCount of two.
|
|
4495
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
4496
|
+
const destroyCall = plugin.definition.onEnvironmentDestroyLease?.({
|
|
4497
|
+
driverKey: "daytona",
|
|
4498
|
+
companyId: "company-1",
|
|
4499
|
+
environmentId: "env-1",
|
|
4500
|
+
providerLeaseId: "sandbox-123",
|
|
4501
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4502
|
+
});
|
|
4503
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
4504
|
+
// Two active sync calls block teardown, so it must not delete the sandbox yet.
|
|
4505
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
4506
|
+
// Release only the inbound call. One outbound call is still active, so
|
|
4507
|
+
// teardown must keep waiting.
|
|
4508
|
+
releaseUpload();
|
|
4509
|
+
await inboundCall;
|
|
4510
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
4511
|
+
expect(sandbox.delete).not.toHaveBeenCalled();
|
|
4512
|
+
// Release the outbound call. No sync call is active now, so teardown deletes.
|
|
4513
|
+
releaseDownload();
|
|
4514
|
+
await Promise.all([outboundCall, destroyCall]);
|
|
4515
|
+
expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1);
|
|
4516
|
+
expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(1);
|
|
4517
|
+
expect(sandbox.delete).toHaveBeenCalledTimes(1);
|
|
4518
|
+
});
|
|
4519
|
+
it("opens a transfer span with the guard round-trip count around the bulk file download", async () => {
|
|
4520
|
+
const hostDir = await makeHostDir();
|
|
4521
|
+
const target = path.join(hostDir, "result.txt");
|
|
4522
|
+
const sandbox = createMockSandbox();
|
|
4523
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4524
|
+
return Promise.all(requests.map(async (request) => {
|
|
4525
|
+
await fs.writeFile(request.destination, "bytes");
|
|
4526
|
+
return { source: request.source, result: request.destination };
|
|
4527
|
+
}));
|
|
4528
|
+
});
|
|
4529
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4530
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
4531
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
4532
|
+
try {
|
|
4533
|
+
await plugin.definition.onEnvironmentSyncOut?.({
|
|
4534
|
+
driverKey: "daytona",
|
|
4535
|
+
companyId: "company-1",
|
|
4536
|
+
environmentId: "env-1",
|
|
4537
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4538
|
+
lease: syncLease(),
|
|
4539
|
+
operations: [
|
|
4540
|
+
{
|
|
4541
|
+
operationId: "sync-op-out",
|
|
4542
|
+
files: [{ sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: target, kind: "file" }],
|
|
4543
|
+
},
|
|
4544
|
+
],
|
|
4545
|
+
});
|
|
4546
|
+
}
|
|
4547
|
+
finally {
|
|
4548
|
+
restore();
|
|
4549
|
+
}
|
|
4550
|
+
const transfer = spans.find((span) => span.name === "transfer");
|
|
4551
|
+
expect(transfer).toBeDefined();
|
|
4552
|
+
expect(transfer.ended).toBe(true);
|
|
4553
|
+
// One serial guard round trip before the transfer: the validate-and-snapshot.
|
|
4554
|
+
expect(transfer.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(1);
|
|
4555
|
+
expect(transfer.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
|
|
4556
|
+
expect(typeof transfer.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number");
|
|
4557
|
+
});
|
|
4558
|
+
it("opens a transfer span around a directory-mapping download with the guard round-trip count", async () => {
|
|
4559
|
+
const hostDir = await makeHostDir();
|
|
4560
|
+
const targetDir = path.join(hostDir, "assets");
|
|
4561
|
+
const sandbox = createMockSandbox();
|
|
4562
|
+
sandbox.fs.downloadFiles.mockImplementation(async (requests) => {
|
|
4563
|
+
// Write a valid empty tar (1024-byte zero EOF marker) so host-side extract
|
|
4564
|
+
// is a clean no-op.
|
|
4565
|
+
await Promise.all(requests.map((request) => fs.writeFile(request.destination, Buffer.alloc(1024))));
|
|
4566
|
+
return requests.map((request) => ({ source: request.source, result: request.destination }));
|
|
4567
|
+
});
|
|
4568
|
+
mockGet.mockResolvedValue(sandbox);
|
|
4569
|
+
const { tracer, spans } = createRecordingPluginTracer();
|
|
4570
|
+
const restore = __setDaytonaPluginContextForTest({ tracer });
|
|
4571
|
+
try {
|
|
4572
|
+
await plugin.definition.onEnvironmentSyncOut?.({
|
|
4573
|
+
driverKey: "daytona",
|
|
4574
|
+
companyId: "company-1",
|
|
4575
|
+
environmentId: "env-1",
|
|
4576
|
+
config: { timeoutMs: 300000, reuseLease: false },
|
|
4577
|
+
lease: syncLease(),
|
|
4578
|
+
operations: [
|
|
4579
|
+
{
|
|
4580
|
+
operationId: "sync-op-out-dir",
|
|
4581
|
+
files: [{ sourcePath: `${REMOTE_DIR}/out/assets`, targetPath: targetDir, kind: "directory" }],
|
|
4582
|
+
},
|
|
4583
|
+
],
|
|
4584
|
+
});
|
|
4585
|
+
}
|
|
4586
|
+
finally {
|
|
4587
|
+
restore();
|
|
4588
|
+
}
|
|
4589
|
+
const transfer = spans.find((span) => span.name === "transfer");
|
|
4590
|
+
expect(transfer).toBeDefined();
|
|
4591
|
+
expect(transfer.ended).toBe(true);
|
|
4592
|
+
// Two serial guard round trips before the transfer: confinement + in-sandbox
|
|
4593
|
+
// tar.
|
|
4594
|
+
expect(transfer.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(2);
|
|
4595
|
+
expect(typeof transfer.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number");
|
|
4596
|
+
});
|
|
4597
|
+
});
|
|
4598
|
+
describe("daytona manifest memory config", () => {
|
|
4599
|
+
const memorySchema = manifest.environmentDrivers?.[0]?.configSchema;
|
|
4600
|
+
it("offers memory as a fixed dropdown of supported sandbox sizes", () => {
|
|
4601
|
+
expect(memorySchema.properties?.memory?.enum).toEqual([1, 2, 4, 8]);
|
|
4602
|
+
});
|
|
4603
|
+
it("excludes 0 — an invalid Daytona memory configuration", () => {
|
|
4604
|
+
expect(memorySchema.properties?.memory?.enum).not.toContain(0);
|
|
4605
|
+
});
|
|
4606
|
+
it("keeps memory optional so the blank/default selection stays valid", () => {
|
|
4607
|
+
expect(memorySchema.required ?? []).not.toContain("memory");
|
|
4608
|
+
});
|
|
4609
|
+
});
|
|
4610
|
+
describe("daytona manifest form defaults", () => {
|
|
4611
|
+
const configSchema = manifest.environmentDrivers?.[0]?.configSchema;
|
|
4612
|
+
const properties = configSchema.properties ?? {};
|
|
4613
|
+
it("pre-fills sizing and image fields for the environment form", () => {
|
|
4614
|
+
expect(properties.cpu?.default).toBe(4);
|
|
4615
|
+
expect(properties.memory?.default).toBe(4);
|
|
4616
|
+
expect(properties.disk?.default).toBe(10);
|
|
4617
|
+
expect(properties.image?.default).toBe("daytonaio/sandbox:0.8.0");
|
|
4618
|
+
});
|
|
4619
|
+
it("keeps each default within its own schema constraints", () => {
|
|
4620
|
+
expect(properties.memory?.enum).toContain(properties.memory?.default);
|
|
4621
|
+
expect(properties.cpu?.default).toBeGreaterThanOrEqual(properties.cpu?.minimum ?? 1);
|
|
4622
|
+
expect(properties.disk?.default).toBeGreaterThanOrEqual(properties.disk?.minimum ?? 1);
|
|
4623
|
+
});
|
|
4624
|
+
it("declares no default on secret-ref fields, which would be persisted as a company secret", () => {
|
|
4625
|
+
for (const prop of Object.values(properties)) {
|
|
4626
|
+
if (prop.format === "secret-ref") {
|
|
4627
|
+
expect(prop.default).toBeUndefined();
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
});
|
|
4631
|
+
});
|
|
4632
|
+
describe("parseTarVerboseListingLine", () => {
|
|
4633
|
+
it("parses GNU tar listing lines (file, dir, symlink, hardlink, numeric owner)", () => {
|
|
4634
|
+
expect(parseTarVerboseListingLine("-rw-r--r-- daytona/daytona 7560 2026-08-11 21:43 AGENTS.md")).toEqual({
|
|
4635
|
+
typeFlag: "-",
|
|
4636
|
+
rest: "AGENTS.md",
|
|
4637
|
+
});
|
|
4638
|
+
expect(parseTarVerboseListingLine("drwxr-xr-x daytona/daytona 0 2026-08-11 21:43 nested/")).toEqual({
|
|
4639
|
+
typeFlag: "d",
|
|
4640
|
+
rest: "nested/",
|
|
4641
|
+
});
|
|
4642
|
+
expect(parseTarVerboseListingLine("lrwxrwxrwx daytona/daytona 0 2026-08-11 21:43 shortcut -> nested/data.txt")).toEqual({ typeFlag: "l", rest: "shortcut -> nested/data.txt" });
|
|
4643
|
+
expect(parseTarVerboseListingLine("hrw-r--r-- daytona/daytona 0 2026-08-11 21:43 copy.txt link to data.txt")).toEqual({ typeFlag: "h", rest: "copy.txt link to data.txt" });
|
|
4644
|
+
expect(parseTarVerboseListingLine("-rw-r--r-- 0/0 12 2026-08-11 21:43 root-owned.txt")).toEqual({
|
|
4645
|
+
typeFlag: "-",
|
|
4646
|
+
rest: "root-owned.txt",
|
|
4647
|
+
});
|
|
4648
|
+
});
|
|
4649
|
+
it("parses bsdtar (macOS) listing lines, including year-form dates", () => {
|
|
4650
|
+
expect(parseTarVerboseListingLine("-rw-r--r-- 0 daytona daytona 7560 Aug 11 21:43 AGENTS.md")).toEqual({
|
|
4651
|
+
typeFlag: "-",
|
|
4652
|
+
rest: "AGENTS.md",
|
|
4653
|
+
});
|
|
4654
|
+
expect(parseTarVerboseListingLine("drwxr-xr-x 0 daytona daytona 0 Aug 11 21:43 nested/")).toEqual({
|
|
4655
|
+
typeFlag: "d",
|
|
4656
|
+
rest: "nested/",
|
|
4657
|
+
});
|
|
4658
|
+
expect(parseTarVerboseListingLine("lrwxr-xr-x 0 daytona daytona 0 Aug 11 21:43 shortcut -> nested/data.txt")).toEqual({ typeFlag: "l", rest: "shortcut -> nested/data.txt" });
|
|
4659
|
+
expect(parseTarVerboseListingLine("hrw-r--r-- 0 daytona daytona 0 Aug 11 21:43 copy.txt link to data.txt")).toEqual({ typeFlag: "h", rest: "copy.txt link to data.txt" });
|
|
4660
|
+
expect(parseTarVerboseListingLine("-rw-r--r-- 0 daytona daytona 7560 Aug 11 2025 old.txt")).toEqual({
|
|
4661
|
+
typeFlag: "-",
|
|
4662
|
+
rest: "old.txt",
|
|
4663
|
+
});
|
|
4664
|
+
});
|
|
4665
|
+
it("keeps the true member name for bsdtar lines with numeric uid/gid, so traversal stays visible", () => {
|
|
4666
|
+
// With unresolvable ids bsdtar prints bare numbers; a looser GNU-first parse
|
|
4667
|
+
// would read this shape shifted by one field and report the member name as
|
|
4668
|
+
// "21:43 ../escape.txt", hiding the leading "../" from the traversal check.
|
|
4669
|
+
expect(parseTarVerboseListingLine("-rw-r--r-- 0 1001 1001 7560 Aug 11 21:43 ../escape.txt")).toEqual({
|
|
4670
|
+
typeFlag: "-",
|
|
4671
|
+
rest: "../escape.txt",
|
|
4672
|
+
});
|
|
4673
|
+
});
|
|
4674
|
+
it("returns null (fail closed) for lines matching neither dialect", () => {
|
|
4675
|
+
expect(parseTarVerboseListingLine("not a tar listing line")).toBeNull();
|
|
4676
|
+
expect(parseTarVerboseListingLine("tar: Error is not recoverable: exiting now")).toBeNull();
|
|
4677
|
+
// Device nodes carry "major,minor" instead of a byte count in both dialects.
|
|
4678
|
+
expect(parseTarVerboseListingLine("crw-rw-rw- root/root 1,3 2026-08-11 21:43 dev/null")).toBeNull();
|
|
4679
|
+
expect(parseTarVerboseListingLine("crw-rw-rw- 0 root wheel 1,3 Aug 11 21:43 dev/null")).toBeNull();
|
|
4680
|
+
});
|
|
4681
|
+
});
|
|
4682
|
+
describe("splitLinkEntryOnce", () => {
|
|
4683
|
+
it("splits a clean single-delimiter link field", () => {
|
|
4684
|
+
expect(splitLinkEntryOnce("shortcut -> nested/data.txt", " -> ")).toEqual({
|
|
4685
|
+
name: "shortcut",
|
|
4686
|
+
target: "nested/data.txt",
|
|
4687
|
+
});
|
|
4688
|
+
expect(splitLinkEntryOnce("copy.txt link to data.txt", " link to ")).toEqual({
|
|
4689
|
+
name: "copy.txt",
|
|
4690
|
+
target: "data.txt",
|
|
4691
|
+
});
|
|
4692
|
+
});
|
|
4693
|
+
it("returns null (fail closed) when the delimiter is absent or appears more than once", () => {
|
|
4694
|
+
expect(splitLinkEntryOnce("no delimiter here", " -> ")).toBeNull();
|
|
4695
|
+
// A link name or target embedding the delimiter makes the split point
|
|
4696
|
+
// unresolvable; either split choice can hide an escaping target.
|
|
4697
|
+
expect(splitLinkEntryOnce("evil -> decoy -> ../../outside.txt", " -> ")).toBeNull();
|
|
4698
|
+
expect(splitLinkEntryOnce("a link to b link to ../../outside.txt", " link to ")).toBeNull();
|
|
4699
|
+
});
|
|
4700
|
+
});
|
|
4701
|
+
//# sourceMappingURL=plugin.test.js.map
|