@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.
Files changed (58) hide show
  1. package/README.md +49 -0
  2. package/dist/duplex-command-stream.d.ts +97 -0
  3. package/dist/duplex-command-stream.d.ts.map +1 -0
  4. package/dist/duplex-command-stream.js +205 -0
  5. package/dist/duplex-command-stream.js.map +1 -0
  6. package/dist/duplex-command-stream.live.test.d.ts +2 -0
  7. package/dist/duplex-command-stream.live.test.d.ts.map +1 -0
  8. package/dist/duplex-command-stream.live.test.js +324 -0
  9. package/dist/duplex-command-stream.live.test.js.map +1 -0
  10. package/dist/duplex-command-stream.test.d.ts +2 -0
  11. package/dist/duplex-command-stream.test.d.ts.map +1 -0
  12. package/dist/duplex-command-stream.test.js +519 -0
  13. package/dist/duplex-command-stream.test.js.map +1 -0
  14. package/dist/file-sync.d.ts +77 -0
  15. package/dist/file-sync.d.ts.map +1 -0
  16. package/dist/file-sync.js +1055 -0
  17. package/dist/file-sync.js.map +1 -0
  18. package/dist/file-sync.test.d.ts +2 -0
  19. package/dist/file-sync.test.d.ts.map +1 -0
  20. package/dist/file-sync.test.js +974 -0
  21. package/dist/file-sync.test.js.map +1 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/login-pty.d.ts +162 -0
  27. package/dist/login-pty.d.ts.map +1 -0
  28. package/dist/login-pty.js +258 -0
  29. package/dist/login-pty.js.map +1 -0
  30. package/dist/login-pty.test.d.ts +2 -0
  31. package/dist/login-pty.test.d.ts.map +1 -0
  32. package/dist/login-pty.test.js +319 -0
  33. package/dist/login-pty.test.js.map +1 -0
  34. package/dist/manifest.d.ts +4 -0
  35. package/dist/manifest.d.ts.map +1 -0
  36. package/dist/manifest.js +179 -0
  37. package/dist/manifest.js.map +1 -0
  38. package/dist/plugin.d.ts +49 -0
  39. package/dist/plugin.d.ts.map +1 -0
  40. package/dist/plugin.js +2563 -0
  41. package/dist/plugin.js.map +1 -0
  42. package/dist/plugin.test.d.ts +2 -0
  43. package/dist/plugin.test.d.ts.map +1 -0
  44. package/dist/plugin.test.js +4701 -0
  45. package/dist/plugin.test.js.map +1 -0
  46. package/dist/pty-chunked-input.d.ts +48 -0
  47. package/dist/pty-chunked-input.d.ts.map +1 -0
  48. package/dist/pty-chunked-input.js +74 -0
  49. package/dist/pty-chunked-input.js.map +1 -0
  50. package/dist/pty-chunked-input.test.d.ts +2 -0
  51. package/dist/pty-chunked-input.test.d.ts.map +1 -0
  52. package/dist/pty-chunked-input.test.js +115 -0
  53. package/dist/pty-chunked-input.test.js.map +1 -0
  54. package/dist/worker.d.ts +3 -0
  55. package/dist/worker.d.ts.map +1 -0
  56. package/dist/worker.js +5 -0
  57. package/dist/worker.js.map +1 -0
  58. package/package.json +44 -0
@@ -0,0 +1,1055 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ import { promises as fs, createReadStream, createWriteStream } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import zlib from "node:zlib";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { getPluginTracer } from "./plugin.js";
10
+ const execFileAsync = promisify(execFile);
11
+ // The span-attribute names. They mirror the host span-attribute contract by
12
+ // value. The plugin ships bundled, so it stays free of the host packages and
13
+ // repeats these strings; the host re-clamps a provider span by these exact keys.
14
+ const SPAN_ATTR_PREFIX = "paperclip.sandbox.startup.";
15
+ const SPAN_ATTR = {
16
+ provider: `${SPAN_ATTR_PREFIX}provider`,
17
+ packWallMs: `${SPAN_ATTR_PREFIX}pack.wall_ms`,
18
+ transferWallMs: `${SPAN_ATTR_PREFIX}transfer.wall_ms`,
19
+ transferGuardCount: `${SPAN_ATTR_PREFIX}transfer.guard.count`,
20
+ // The transfer direction: `inbound` for an upload to the sandbox, `outbound`
21
+ // for a download from the sandbox. Operation identity comes from the parent
22
+ // span, so the transfer span never carries an operation label.
23
+ transferDirection: `${SPAN_ATTR_PREFIX}transfer.direction`,
24
+ // The five zstd-transport-compression attributes. Values are a closed codec
25
+ // set or a finite number — never a path, a command line, file content, a
26
+ // raw identifier, or error text.
27
+ transferCompressionCodec: `${SPAN_ATTR_PREFIX}transfer.compression.codec`,
28
+ transferCompressionWallMs: `${SPAN_ATTR_PREFIX}transfer.compression.wall_ms`,
29
+ transferCompressionBytesIn: `${SPAN_ATTR_PREFIX}transfer.compression.bytes_in`,
30
+ transferCompressionBytesOut: `${SPAN_ATTR_PREFIX}transfer.compression.bytes_out`,
31
+ transferDecompressWallMs: `${SPAN_ATTR_PREFIX}transfer.decompress.wall_ms`,
32
+ };
33
+ /** The value of `SpanStatusCode.ERROR` in `@opentelemetry/api`. The plugin stays
34
+ * OpenTelemetry-free, so it uses the numeric value directly. */
35
+ const SPAN_STATUS_CODE_ERROR = 2;
36
+ /**
37
+ * Run one span-wrapped step through the plugin tracer. The pack step, the
38
+ * transfer step, and each command round trip share this helper. It seeds the
39
+ * provider family, runs the step, marks a thrown step failed, and always ends
40
+ * the span. The host records the span with its true wall-clock width from the
41
+ * worker timestamps, so the span shows real time in the trace. The tracer is a
42
+ * no-op until the host injects a live tracer, so the span never changes the sync
43
+ * control flow.
44
+ *
45
+ * `wallMsAttr` is optional. The `pack` and `transfer` spans pass it to keep
46
+ * their existing `*.wall_ms` attribute. A per-round-trip span omits it, so it
47
+ * carries no `*.wall_ms` attribute and relies on the native span width.
48
+ *
49
+ * `run` receives the live span, so a caller that needs to record an attribute
50
+ * only known after the step completes (for example the compression byte
51
+ * counts) can call `span.setAttribute` directly, without a second span.
52
+ */
53
+ export async function withProviderSpan(input) {
54
+ const span = getPluginTracer().startSpan(input.name, {
55
+ attributes: { [SPAN_ATTR.provider]: "daytona", ...(input.attributes ?? {}) },
56
+ });
57
+ const startedAtMs = Date.now();
58
+ try {
59
+ return await input.run(span);
60
+ }
61
+ catch (error) {
62
+ span.setStatus({ code: SPAN_STATUS_CODE_ERROR });
63
+ throw error;
64
+ }
65
+ finally {
66
+ if (input.wallMsAttr)
67
+ span.setAttribute(input.wallMsAttr, Date.now() - startedAtMs);
68
+ span.end();
69
+ }
70
+ }
71
+ /** Convert a millisecond timeout to the whole-seconds value the Daytona SDK expects. */
72
+ function toTimeoutSeconds(timeoutMs) {
73
+ return Math.max(1, Math.ceil(timeoutMs / 1000));
74
+ }
75
+ // Reserved scratch-name stem for staged uploads/downloads and remote tarballs.
76
+ // The runtime's base64 fallback stages to `<path>.paperclip-upload`; the native
77
+ // transport reuses the same reserved prefix so a provider temp never collides
78
+ // with a real target or with the fallback's scratch name.
79
+ const SCRATCH_PREFIX = ".paperclip-upload";
80
+ function scratchName(suffix = "") {
81
+ return `${SCRATCH_PREFIX}-${randomUUID()}${suffix}`;
82
+ }
83
+ /**
84
+ * Single-quote a path for safe interpolation into a sandbox shell command. Every
85
+ * path handed to `sandbox.process.executeCommand` (tar extract / `mv -f` rename)
86
+ * MUST pass through this so a path containing shell metacharacters is transferred
87
+ * literally, never interpreted.
88
+ */
89
+ function shellQuote(value) {
90
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
91
+ }
92
+ /**
93
+ * Convert a POSIX numeric mode (e.g. `0o600`) to the octal string the Daytona
94
+ * SDK's `setFilePermissions` expects (e.g. `"600"`), masked to the permission
95
+ * bits so an accidental type flag never widens the mode.
96
+ */
97
+ function toOctalModeString(mode) {
98
+ return (mode & 0o7777).toString(8).padStart(3, "0");
99
+ }
100
+ /**
101
+ * Host-side complete-mediation guard applied as defense-in-depth below the
102
+ * orchestrator's own confinement. Every sandbox-side path (the sync target for
103
+ * inbound, the sync source for outbound) MUST canonicalize inside the workspace
104
+ * remote dir; absolute escapes and `..` traversal are rejected fail-closed before
105
+ * any bytes move. Sandbox paths on the server are POSIX.
106
+ */
107
+ export function assertConfinedSandboxPath(remoteDir, candidate, label) {
108
+ const normalizedRoot = path.posix.normalize(remoteDir);
109
+ const normalized = path.posix.normalize(candidate);
110
+ if (!path.posix.isAbsolute(normalized) ||
111
+ normalized === ".." ||
112
+ normalized.includes("/../") ||
113
+ normalized.endsWith("/..")) {
114
+ throw new Error(`Daytona sync ${label} path is not a confined absolute path: ${candidate}`);
115
+ }
116
+ const prefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`;
117
+ if (normalized !== normalizedRoot && !normalized.startsWith(prefix)) {
118
+ throw new Error(`Daytona sync ${label} path escapes the workspace remote dir: ${candidate}`);
119
+ }
120
+ }
121
+ async function withHostTempDir(fn) {
122
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-sync-"));
123
+ try {
124
+ return await fn(dir);
125
+ }
126
+ finally {
127
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
128
+ }
129
+ }
130
+ /**
131
+ * Build a host-side gzip-compressed tarball of a directory, mirroring the runtime's own
132
+ * `createTarballFromDirectory`: archive top-level entries by name (no "." self
133
+ * entry), suppress AppleDouble/xattr sidecars, honor `exclude`, and reproduce the
134
+ * `followSymlinks` → `-h` mapping so the native path is observationally identical
135
+ * to the base64 fallback's tar.
136
+ */
137
+ async function createHostTarball(input) {
138
+ const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
139
+ const entries = (await fs.readdir(input.localDir)).sort((left, right) => left.localeCompare(right));
140
+ if (entries.length === 0) {
141
+ // An empty source is valid (blank workspace / empty asset dir). Write a valid
142
+ // gzip-compressed empty tar (1024-byte zero EOF marker) so extraction is a
143
+ // clean no-op and uses the same transport as non-empty directories.
144
+ await fs.writeFile(input.archivePath, await new Promise((resolve, reject) => {
145
+ zlib.gzip(Buffer.alloc(1024), (error, compressed) => error ? reject(error) : resolve(compressed));
146
+ }));
147
+ return;
148
+ }
149
+ await execFileAsync("tar", [
150
+ "-cz",
151
+ "--no-xattrs",
152
+ ...(input.followSymlinks ? ["-h"] : []),
153
+ "-f",
154
+ input.archivePath,
155
+ "-C",
156
+ input.localDir,
157
+ ...excludeArgs,
158
+ "--",
159
+ ...entries,
160
+ ], { env: { ...process.env, COPYFILE_DISABLE: "1" }, maxBuffer: 32 * 1024 * 1024 });
161
+ }
162
+ /**
163
+ * True when `relative` (a POSIX path) escapes its anchoring directory once
164
+ * normalized: an absolute path, `..`, or a `..`-leading traversal all break out.
165
+ */
166
+ function posixPathEscapes(relative) {
167
+ const normalized = path.posix.normalize(relative);
168
+ return normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized);
169
+ }
170
+ /**
171
+ * Parse one `tar -tvf` verbose listing line into its leading type flag and the
172
+ * trailing name-and-link-target field. The listing dialect depends on which tar
173
+ * the host ships: GNU/busybox emit
174
+ * `<perms> <owner>/<group> <size> <date> <time> <rest>`, while bsdtar
175
+ * (libarchive — the system tar on macOS) emits the ls-style
176
+ * `<perms> <links> <user> <group> <size> <Mon> <day> <time|year> <rest>`.
177
+ * The second field disambiguates: GNU always slash-joins owner/group, bsdtar
178
+ * puts a pure-digit link count there, so no line satisfies both shapes — the
179
+ * slash requirement is load-bearing, since a bsdtar line with numeric uid/gid
180
+ * would otherwise match the GNU shape shifted, hiding traversal in `<rest>`.
181
+ * Entries whose size column is not a plain byte count (e.g. a device node's
182
+ * `major,minor`) match neither shape. Returns null when nothing matches so
183
+ * callers can fail closed.
184
+ */
185
+ export function parseTarVerboseListingLine(line) {
186
+ const gnu = line.match(/^(\S+)\s+\S+\/\S+\s+\d+\s+\S+\s+\S+\s+(.*)$/);
187
+ if (gnu)
188
+ return { typeFlag: gnu[1][0], rest: gnu[2] };
189
+ const bsd = line.match(/^(\S+)\s+\d+\s+\S+\s+\S+\s+\d+\s+\S+\s+\d{1,2}\s+(?:\d{4}|\d{1,2}:\d{2}(?::\d{2})?)\s+(.*)$/);
190
+ if (bsd)
191
+ return { typeFlag: bsd[1][0], rest: bsd[2] };
192
+ return null;
193
+ }
194
+ /**
195
+ * Split a verbose-listing link field (`<name><delimiter><target>`) exactly
196
+ * once. The sandbox controls both halves, so a field with zero or multiple
197
+ * delimiter occurrences is unresolvable: a link name that itself contains the
198
+ * delimiter shifts the split point, and taking the first (or last) occurrence
199
+ * would let a crafted name or target hide an escaping link target from the
200
+ * confinement check. Returns null so callers fail closed.
201
+ */
202
+ export function splitLinkEntryOnce(field, delimiter) {
203
+ const first = field.indexOf(delimiter);
204
+ if (first === -1)
205
+ return null;
206
+ if (field.indexOf(delimiter, first + delimiter.length) !== -1)
207
+ return null;
208
+ return { name: field.slice(0, first), target: field.slice(first + delimiter.length) };
209
+ }
210
+ /**
211
+ * Reject a sandbox-authored tarball before extraction if any member would land
212
+ * outside the extraction dir. The archive is produced by the (untrusted) sandbox,
213
+ * so `tar -xf` on the host must never be handed an archive whose entries carry
214
+ * absolute paths or `../` traversal, nor a symlink/hardlink member whose target
215
+ * escapes the tree — the latter would let a follow-up member be written through
216
+ * the link to an arbitrary host path. Legitimate in-tree relative links (targets
217
+ * that resolve back inside the archive, e.g. `shortcut -> nested/data.txt`) are
218
+ * preserved. Parses the `-tvf` verbose listing so both member names and link
219
+ * targets are inspected; any unparseable line fails closed.
220
+ */
221
+ async function assertTarballEntriesConfined(archivePath) {
222
+ const { stdout } = await execFileAsync("tar", ["-tvf", archivePath], {
223
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
224
+ maxBuffer: 32 * 1024 * 1024,
225
+ });
226
+ const lines = stdout.split("\n").filter((line) => line.trim().length > 0);
227
+ for (const line of lines) {
228
+ const parsed = parseTarVerboseListingLine(line);
229
+ if (!parsed) {
230
+ throw new Error(`Daytona syncOut refusing tarball with an unparseable entry listing: ${line}`);
231
+ }
232
+ const typeFlag = parsed.typeFlag;
233
+ let name = parsed.rest;
234
+ let linkTarget = null;
235
+ if (typeFlag === "l") {
236
+ const split = splitLinkEntryOnce(name, " -> ");
237
+ if (!split)
238
+ throw new Error(`Daytona syncOut refusing unparseable or ambiguous symlink entry: ${line}`);
239
+ name = split.name;
240
+ linkTarget = split.target;
241
+ }
242
+ else if (typeFlag === "h") {
243
+ const split = splitLinkEntryOnce(name, " link to ");
244
+ if (!split)
245
+ throw new Error(`Daytona syncOut refusing unparseable or ambiguous hardlink entry: ${line}`);
246
+ name = split.name;
247
+ linkTarget = split.target;
248
+ }
249
+ const cleanName = name.replace(/\/+$/, "");
250
+ if (cleanName.length > 0 && posixPathEscapes(cleanName)) {
251
+ throw new Error(`Daytona syncOut refusing tarball member that escapes the extraction dir: ${name}`);
252
+ }
253
+ if (linkTarget !== null) {
254
+ const resolved = path.posix.join(path.posix.dirname(cleanName), linkTarget);
255
+ if (path.posix.isAbsolute(linkTarget) || posixPathEscapes(resolved)) {
256
+ throw new Error(`Daytona syncOut refusing tarball link whose target escapes the extraction dir: ${name} -> ${linkTarget}`);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ async function extractHostTarball(input) {
262
+ // The archive is sandbox-authored and untrusted: validate every member (and
263
+ // link target) is confined before letting host-side tar write a single byte.
264
+ await assertTarballEntriesConfined(input.archivePath);
265
+ await fs.mkdir(input.localDir, { recursive: true });
266
+ await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], {
267
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
268
+ maxBuffer: 32 * 1024 * 1024,
269
+ });
270
+ }
271
+ async function countHostFiles(root, exclude) {
272
+ const excludeSet = new Set(exclude ?? []);
273
+ let total = 0;
274
+ const walk = async (dir) => {
275
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
276
+ for (const entry of entries) {
277
+ if (excludeSet.has(entry.name))
278
+ continue;
279
+ const full = path.join(dir, entry.name);
280
+ if (entry.isDirectory()) {
281
+ await walk(full);
282
+ }
283
+ else {
284
+ total += 1;
285
+ }
286
+ }
287
+ };
288
+ await walk(root).catch(() => undefined);
289
+ return total;
290
+ }
291
+ /**
292
+ * Run one sandbox command and return its stdout on success. Throws the same
293
+ * shaped error as {@link assertSandboxCommandOk} on a non-zero exit. Used by
294
+ * the mkdir+zstd-probe round trip, which must read the probe's answer from
295
+ * the command's own output rather than only checking the exit code.
296
+ */
297
+ async function assertSandboxCommandOkWithOutput(sandbox, command, timeoutSeconds, label) {
298
+ const result = await sandbox.process.executeCommand(command, undefined, undefined, timeoutSeconds);
299
+ if ((result.exitCode ?? 1) !== 0) {
300
+ const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim();
301
+ throw new Error(`Daytona ${label} command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`);
302
+ }
303
+ return (result.result ?? result.artifacts?.stdout ?? "").toString();
304
+ }
305
+ async function assertSandboxCommandOk(sandbox, command, timeoutSeconds, label) {
306
+ await assertSandboxCommandOkWithOutput(sandbox, command, timeoutSeconds, label);
307
+ }
308
+ // -------------------------------------------------------------
309
+ // zstd transport compression (inbound file-mapping path only)
310
+ // -------------------------------------------------------------
311
+ /** A source file below this size never compresses: the round-trip and CPU
312
+ * cost of compression is not worth it for a small file. */
313
+ const ZSTD_MIN_SOURCE_BYTES = 8 * 1024 * 1024;
314
+ /** Reject a compressed candidate whose saving is below this fraction of the
315
+ * source size (a saving under 10 percent falls back to the raw path). */
316
+ const ZSTD_MIN_SAVING_RATIO = 0.1;
317
+ /** The zstd compression level for the host-side compressor. */
318
+ const ZSTD_COMPRESSION_LEVEL = 3;
319
+ /** Marker the mkdir+probe command echoes to sandbox stdout when the sandbox
320
+ * has a `zstd` binary on `PATH`. An absent or unexpected answer fails closed
321
+ * (no compression), per the design's fallback rules. */
322
+ const ZSTD_PROBE_MARKER = "PAPERCLIP_ZSTD_AVAILABLE";
323
+ /**
324
+ * Feature-detect zstd support on the running Node runtime. `node:zlib` shipped
325
+ * zstd as of Node v22.15.0 / v23.8.0, ahead of this package's declared
326
+ * `engines.node` floor, but the design directs a runtime check rather than an
327
+ * assumption from the `engines` field alone: a floor can be wrong, and this
328
+ * check costs nothing to keep in place after the floor moves.
329
+ */
330
+ function isZstdCompressionSupported() {
331
+ return typeof zlib.createZstdCompress === "function";
332
+ }
333
+ /**
334
+ * Stream-compress `sourcePath` to a new file with zstd at
335
+ * {@link ZSTD_COMPRESSION_LEVEL}, never buffering the whole file in memory.
336
+ * The compressed file lives in a private directory this function creates with
337
+ * `fs.mkdtemp` (mode `0700`), and the file itself opens with `wx` and mode
338
+ * `0600` — so the workspace content this holds is never readable by another
339
+ * local principal, unlike a bare `os.tmpdir()` file at the default `0644`.
340
+ * The caller removes the returned directory (on the accept path, after the
341
+ * upload; on every reject/error path, immediately) — this function only
342
+ * removes it on its OWN failure, so a caller never has to distinguish a
343
+ * partial directory from a finished one. The cleanup scope covers every
344
+ * step after the directory create, including the post-compression size
345
+ * stat, so a throw there does not leave the directory behind.
346
+ */
347
+ async function compressFileToHostTemp(sourcePath) {
348
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-zstd-"));
349
+ const tempPath = path.join(dir, "artifact.zst");
350
+ try {
351
+ await pipeline(createReadStream(sourcePath), zlib.createZstdCompress({ params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL } }), createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));
352
+ const bytesOut = (await fs.stat(tempPath)).size;
353
+ return { dir, path: tempPath, bytesOut };
354
+ }
355
+ catch (error) {
356
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
357
+ throw error;
358
+ }
359
+ }
360
+ /**
361
+ * POSIX-sh preamble defining a `_pc_resolve` canonicalizer (prefer `realpath`,
362
+ * fall back to `readlink -f`; fail closed with exit 40 if neither exists so the
363
+ * host-side lexical check is never the only line of defense) and `_pc_root` =
364
+ * the resolved workspace remote dir. Shared by every sandbox-side symlink-escape
365
+ * guard. The caller wraps the assembled script in `sh -c` so it runs under a
366
+ * POSIX shell regardless of the sandbox's default login shell.
367
+ */
368
+ function canonicalizerPreamble(quotedRoot) {
369
+ return [
370
+ 'if command -v realpath >/dev/null 2>&1; then _pc_resolve() { realpath -- "$1"; };',
371
+ 'elif command -v readlink >/dev/null 2>&1; then _pc_resolve() { readlink -f -- "$1"; };',
372
+ 'else echo "no path canonicalizer available"; exit 40; fi;',
373
+ `_pc_root=$(_pc_resolve ${quotedRoot}) || { echo "cannot resolve root"; exit 41; };`,
374
+ ];
375
+ }
376
+ /**
377
+ * Fail-closed guard: assert that every supplied sandbox path canonicalizes
378
+ * (through symlinks) inside the workspace remote dir. The sandbox is untrusted
379
+ * relative to the host, so a sandbox-planted symlink on an inbound target parent
380
+ * or an outbound source must never widen a transfer past the confinement root.
381
+ * Runs as a single batched `sh -c` precheck: any path whose realpath escapes
382
+ * fails the whole sync (exit 42) before any bytes move. `label` distinguishes
383
+ * the inbound vs outbound call site in the surfaced error.
384
+ */
385
+ async function assertSandboxPathsConfined(input) {
386
+ const { sandbox, remoteDir, paths, timeoutSeconds, label } = input;
387
+ if (paths.length === 0)
388
+ return;
389
+ const quotedPaths = paths.map(shellQuote).join(" ");
390
+ const script = [
391
+ ...canonicalizerPreamble(shellQuote(remoteDir)),
392
+ `for _pc_p in ${quotedPaths}; do`,
393
+ ' _pc_real=$(_pc_resolve "$_pc_p") || { echo "ESCAPE:$_pc_p"; exit 42; };',
394
+ ' case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE:$_pc_p"; exit 42 ;; esac;',
395
+ "done",
396
+ ].join("\n");
397
+ await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(script)}`, timeoutSeconds, label);
398
+ }
399
+ /**
400
+ * Validate every outbound source AND capture a protected snapshot of it in one
401
+ * atomic sandbox-side step, then hand the snapshot paths to `downloadFiles`. This
402
+ * shrinks the TOCTOU window between validation and download to near zero: the
403
+ * guard resolves each source's realpath, confirms it is inside the remote dir,
404
+ * re-checks the resolved path is still a (non-symlink) regular file, then `cp`s
405
+ * those exact bytes to a reserved snapshot — all in a single `sh -c` invocation.
406
+ *
407
+ * Two windows are closed here:
408
+ * - validation→copy: `_pc_real` is a canonical path, so a `[ -L ]`/`[ -f ]`
409
+ * re-check immediately before `cp` refuses a source the sandbox swapped for a
410
+ * symlink (or non-regular file) after `realpath` resolved, rather than letting
411
+ * `cp` follow the swap.
412
+ * - copy→download: the privileged `downloadFiles` reads the reserved snapshot,
413
+ * which is an unguessable random name that is a DIRECT child of the resolved
414
+ * workspace root — no sandbox-swappable intermediate directory sits on the
415
+ * read path, and the sandbox cannot pre-plant a symlink at the leaf name.
416
+ *
417
+ * The sandbox-side `cp` runs at sandbox-user privilege, so its residual race
418
+ * cannot read anything that user could not already read; the confinement is
419
+ * defense-in-depth for the privileged host-mediated download. Returns the
420
+ * reserved snapshot paths, index-aligned with `sources`; the caller downloads
421
+ * and then removes them.
422
+ *
423
+ * Accepted residual risk (copy→download leaf swap): the sandbox user runs this
424
+ * `cp`, so it knows the reserved snapshot path and could overwrite that leaf with
425
+ * different bytes after `cp` returns but before the privileged `downloadFiles`
426
+ * opens it. This is informational, not a privilege-boundary crossing: the sandbox
427
+ * user can only substitute bytes it can already produce, and the host download
428
+ * would then receive bytes that same user could equally have written into the real
429
+ * source before the snapshot ran. The swap cannot redirect the read outside the
430
+ * confinement root — the leaf is a direct child of the resolved root with no
431
+ * swappable intermediate dir, and the sandbox user cannot use it to exfiltrate any
432
+ * file it lacks read access to — so no privilege escalation is possible and the
433
+ * window is accepted rather than closed.
434
+ */
435
+ async function snapshotOutboundFileSources(input) {
436
+ const { sandbox, remoteDir, sources, timeoutSeconds } = input;
437
+ // Reserved snapshot names are a DIRECT child of remoteDir (the confinement
438
+ // root), so the privileged download leg carries no swappable intermediate dir.
439
+ const snapshots = sources.map(() => path.posix.join(remoteDir, scratchName()));
440
+ if (sources.length === 0)
441
+ return snapshots;
442
+ const lines = [...canonicalizerPreamble(shellQuote(remoteDir))];
443
+ sources.forEach((source, index) => {
444
+ const quotedSource = shellQuote(source);
445
+ const quotedSnapshot = shellQuote(snapshots[index]);
446
+ lines.push(`_pc_real=$(_pc_resolve ${quotedSource}) || { echo "ESCAPE"; exit 42; };`, `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`,
447
+ // Close the validation→copy window: refuse a canonical path the sandbox has
448
+ // repointed to a symlink or a non-regular file since `realpath` resolved,
449
+ // so `cp` never follows a post-validation swap.
450
+ `[ -L "$_pc_real" ] && { echo "REPLACED"; exit 44; };`, `[ -f "$_pc_real" ] || { echo "NOTREG"; exit 45; };`,
451
+ // Copy the confined canonical bytes into the reserved snapshot so the
452
+ // subsequent download reads this immutable copy, not the live source.
453
+ `cp -- "$_pc_real" ${quotedSnapshot} || { echo "snapshot copy failed"; exit 43; };`);
454
+ });
455
+ await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(lines.join("\n"))}`, timeoutSeconds, "outbound symlink-escape guard");
456
+ return snapshots;
457
+ }
458
+ /**
459
+ * Best-effort removal of reserved sandbox-side scratch files (upload/download
460
+ * snapshots or partially promoted temps) on both the happy path and error paths,
461
+ * so a failed transfer never accumulates `.paperclip-upload-*` scratch in the
462
+ * sandbox. Swallows its own failure — cleanup must never mask the original error.
463
+ */
464
+ async function removeSandboxScratch(sandbox, paths, timeoutSeconds) {
465
+ if (paths.length === 0)
466
+ return;
467
+ const script = paths.map((entry) => `rm -f ${shellQuote(entry)}`).join(" ; ");
468
+ await sandbox.process
469
+ .executeCommand(`sh -c ${shellQuote(script)}`, undefined, undefined, timeoutSeconds)
470
+ .catch(() => undefined);
471
+ }
472
+ /**
473
+ * Try to remove each `.zst` scratch name a SECOND time, after every target in
474
+ * the batch already promoted successfully.
475
+ *
476
+ * The promote script's own cleanup (`rm -f ... || true`) already tried once.
477
+ * It never fails the sync when that cleanup fails, because a completed and
478
+ * safely promoted target must never read back as a failure.
479
+ *
480
+ * This function does not touch the promote script or its fail-closed guards.
481
+ * It runs one separate, later sandbox command that retries the removal, then
482
+ * reports how many names are still present. This makes a leftover that
483
+ * survives both tries observable instead of silent.
484
+ *
485
+ * This function runs exactly once. It is not a retry loop. It swallows its
486
+ * own command failure and reports the full count as still present — the
487
+ * same "assume the worst, never throw" contract as
488
+ * {@link removeSandboxScratch}.
489
+ */
490
+ async function sweepZstdScratchAfterSuccess(sandbox, zstdScratchNames, timeoutSeconds) {
491
+ if (zstdScratchNames.length === 0)
492
+ return 0;
493
+ const removeScript = zstdScratchNames.map((name) => `rm -f ${shellQuote(name)}`).join(" ; ");
494
+ const checkScript = zstdScratchNames.map((name) => `[ -e ${shellQuote(name)} ] && echo 1 || echo 0`).join(" ; ");
495
+ const result = await sandbox.process
496
+ .executeCommand(`sh -c ${shellQuote(`${removeScript} ; ${checkScript}`)}`, undefined, undefined, timeoutSeconds)
497
+ .catch(() => null);
498
+ if (!result)
499
+ return zstdScratchNames.length;
500
+ const output = (result.result ?? result.artifacts?.stdout ?? "").toString();
501
+ return output.split("\n").filter((line) => line.trim() === "1").length;
502
+ }
503
+ async function syncInFileMappings(input) {
504
+ const { sandbox, mappings, remoteDir, timeoutSeconds } = input;
505
+ if (mappings.length === 0)
506
+ return { filesTransferred: 0, bytesTransferred: 0 };
507
+ const parentDirs = new Set();
508
+ let bytesTransferred = 0;
509
+ const plans = [];
510
+ for (const mapping of mappings) {
511
+ parentDirs.add(path.posix.dirname(mapping.targetPath));
512
+ const sourceSize = (await fs.stat(mapping.sourcePath)).size;
513
+ bytesTransferred += sourceSize;
514
+ // Stage each upload to a reserved temp that is a DIRECT child of the workspace
515
+ // root (`remoteDir`). `remoteDir` and the target dir share the workspace
516
+ // filesystem, so the closing `mv -f` is still an atomic same-fs rename and an
517
+ // interrupted upload never leaves a truncated file at targetPath.
518
+ plans.push({ mapping, sourceSize, rawScratch: path.posix.join(remoteDir, scratchName()), compressed: null });
519
+ }
520
+ // Count the serial guard round trips before the transfer, so the transfer span
521
+ // records how much of the wall time is guard cost.
522
+ let guardRoundTrips = 0;
523
+ // Ensure every target directory exists before the bulk upload writes its temp.
524
+ // The zstd availability probe rides this SAME round trip: no new sandbox round
525
+ // trip and no availability cache — a cache at any scope would hold one
526
+ // principal's observation and reuse it for another, so probing fresh on every
527
+ // call has no poisoning surface. `command -v zstd` runs only after a successful
528
+ // `mkdir -p`, and always reports success itself (`|| true`), so a sandbox with no
529
+ // `zstd` binary never fails the mkdir step — it only fails closed on compression
530
+ // eligibility below.
531
+ const mkdirCommand = [...parentDirs].map((dir) => `mkdir -p ${shellQuote(dir)}`).join(" && ");
532
+ const mkdirAndProbeCommand = [
533
+ mkdirCommand,
534
+ `&& { command -v zstd >/dev/null 2>&1 && echo ${ZSTD_PROBE_MARKER} || true; }`,
535
+ ].join(" ");
536
+ // `ensureDirectory` span: `mkdir -p` (plus the zstd availability probe) —
537
+ // ensure a directory exists before a write.
538
+ const mkdirOutput = await withProviderSpan({
539
+ name: "ensureDirectory",
540
+ run: () => assertSandboxCommandOkWithOutput(sandbox, mkdirAndProbeCommand, timeoutSeconds, "syncIn mkdir"),
541
+ });
542
+ guardRoundTrips += 1;
543
+ // An absent or unexpected probe answer fails closed: no compression, byte-
544
+ // identical to a sandbox that has no `zstd` binary.
545
+ const sandboxHasZstd = mkdirOutput.includes(ZSTD_PROBE_MARKER);
546
+ // Host-side compression, gated on the probe AND a runtime feature check (a
547
+ // declared `engines.node` floor is an assumption, not a guarantee — always
548
+ // feature-detect). Every candidate at or above `ZSTD_MIN_SOURCE_BYTES` is
549
+ // compressed on the host with `node:zlib` at level 3, streamed so the whole
550
+ // file never buffers in memory. A candidate that throws, or whose saving
551
+ // misses `ZSTD_MIN_SAVING_RATIO`, falls back to the raw path — a fallback is
552
+ // never an error, it reproduces the present behavior exactly.
553
+ const compressCandidates = plans.filter((plan) => plan.sourceSize >= ZSTD_MIN_SOURCE_BYTES);
554
+ if (sandboxHasZstd && isZstdCompressionSupported() && compressCandidates.length > 0) {
555
+ let compressBytesIn = 0;
556
+ let compressBytesOut = 0;
557
+ await withProviderSpan({
558
+ name: "compress",
559
+ wallMsAttr: SPAN_ATTR.transferCompressionWallMs,
560
+ attributes: { [SPAN_ATTR.transferCompressionCodec]: "zstd" },
561
+ run: async (span) => {
562
+ for (const plan of compressCandidates) {
563
+ let hostTempDir = null;
564
+ try {
565
+ const compressed = await compressFileToHostTemp(plan.mapping.sourcePath);
566
+ hostTempDir = compressed.dir;
567
+ compressBytesIn += plan.sourceSize;
568
+ compressBytesOut += compressed.bytesOut;
569
+ const savingRatio = 1 - compressed.bytesOut / plan.sourceSize;
570
+ if (savingRatio < ZSTD_MIN_SAVING_RATIO) {
571
+ await fs.rm(compressed.dir, { recursive: true, force: true }).catch(() => undefined);
572
+ continue;
573
+ }
574
+ plan.compressed = {
575
+ zstdScratch: path.posix.join(remoteDir, scratchName(".zst")),
576
+ hostTempDir: compressed.dir,
577
+ hostTempPath: compressed.path,
578
+ };
579
+ }
580
+ catch {
581
+ // Host compression failed for this candidate — fall back to the raw
582
+ // path for it. Never fail the whole sync over a compression error.
583
+ if (hostTempDir)
584
+ await fs.rm(hostTempDir, { recursive: true, force: true }).catch(() => undefined);
585
+ }
586
+ }
587
+ span.setAttribute(SPAN_ATTR.transferCompressionBytesIn, compressBytesIn);
588
+ span.setAttribute(SPAN_ATTR.transferCompressionBytesOut, compressBytesOut);
589
+ },
590
+ });
591
+ }
592
+ const uploads = [];
593
+ const modeApplies = [];
594
+ for (const plan of plans) {
595
+ if (plan.compressed) {
596
+ // Upload ONLY the `.zst` file. The host never creates the raw scratch
597
+ // name — the in-sandbox decompression step below does.
598
+ uploads.push({ source: plan.compressed.hostTempPath, destination: plan.compressed.zstdScratch });
599
+ }
600
+ else {
601
+ uploads.push({ source: plan.mapping.sourcePath, destination: plan.rawScratch });
602
+ if (typeof plan.mapping.mode === "number") {
603
+ modeApplies.push({ temp: plan.rawScratch, mode: plan.mapping.mode });
604
+ }
605
+ }
606
+ }
607
+ const hasCompressedMapping = plans.some((plan) => plan.compressed !== null);
608
+ // Every reserved scratch name in this batch (raw + `.zst`), for the failure
609
+ // sweep below. A compressed mapping reserves two names; a raw mapping one.
610
+ const allScratchNames = plans.flatMap((plan) => plan.compressed ? [plan.rawScratch, plan.compressed.zstdScratch] : [plan.rawScratch]);
611
+ const compressedPlans = plans.filter((plan) => plan.compressed !== null);
612
+ const hostTempDirs = compressedPlans.map((plan) => plan.compressed.hostTempDir);
613
+ // The `.zst` scratch names for compressed mappings. The bounded post-success
614
+ // sweep below uses this list. It excludes the raw scratch names, because the
615
+ // promote script's own rename already consumes them.
616
+ const compressedZstdScratchNames = compressedPlans.map((plan) => plan.compressed.zstdScratch);
617
+ // A failed upload or a mid-batch `mv -f`/decompress failure leaves reserved
618
+ // scratch (some targets promoted, others not) — sweep every reserved name on
619
+ // any error so a retry never accumulates stale `.paperclip-upload-*` scratch.
620
+ // The private host temp directory is removed in `finally` regardless of
621
+ // outcome — no temp remains after success or failure.
622
+ try {
623
+ // One batched bulk upload (single /files/bulk-upload) for all file mappings.
624
+ // `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`.
625
+ await withProviderSpan({
626
+ name: "transfer",
627
+ wallMsAttr: SPAN_ATTR.transferWallMs,
628
+ attributes: {
629
+ [SPAN_ATTR.transferGuardCount]: guardRoundTrips,
630
+ [SPAN_ATTR.transferDirection]: "inbound",
631
+ },
632
+ run: () => sandbox.fs.uploadFiles(uploads, timeoutSeconds),
633
+ });
634
+ // Apply the requested mode on the RAW mapping's temp file BEFORE the rename
635
+ // so the target never appears at a widened window. A compressed mapping's
636
+ // raw scratch does not exist yet at this point — its mode (if any) is
637
+ // applied inside the promotion script below, on the raw scratch.
638
+ for (const apply of modeApplies) {
639
+ await sandbox.fs.setFilePermissions(apply.temp, { mode: toOctalModeString(apply.mode) });
640
+ }
641
+ // Promote every mapping onto its final target with one `mv -f` per mapping,
642
+ // atomic on the shared workspace filesystem. A compressed mapping first
643
+ // decompresses its `.zst` scratch to the raw scratch name with `zstd -d -o`,
644
+ // applies the mapping's mode (if set) with `chmod`, then removes the `.zst`
645
+ // scratch after the `mv -f` promotes the raw scratch.
646
+ const renameScript = [];
647
+ for (const plan of plans) {
648
+ if (plan.compressed) {
649
+ // `zstd -d -o` copies the mode of its INPUT (the uploaded `.zst`
650
+ // scratch) onto its output with its own `chmod` call. That call runs
651
+ // AFTER creation, so it overrides any `umask` in effect — a mapping
652
+ // with no explicit `mode` must not rely on the scratch file's mode
653
+ // being owner-only already. Always `chmod` the decompressed file
654
+ // right after decompression: to the mapping's `mode` when set, or to
655
+ // owner-only (0600) otherwise. The pre-refactor decompression step
656
+ // applied the same 0600 default. The raw (uncompressed) path above
657
+ // applies no `chmod` when the mapping sets no `mode`, so the two
658
+ // inbound branches do not use the same no-mode default today.
659
+ const targetMode = typeof plan.mapping.mode === "number" ? plan.mapping.mode : 0o600;
660
+ renameScript.push(`zstd -d -o ${shellQuote(plan.rawScratch)} ${shellQuote(plan.compressed.zstdScratch)} || { echo "decompress failed"; exit 49; };`, `chmod ${toOctalModeString(targetMode)} ${shellQuote(plan.rawScratch)} || { echo "chmod failed"; exit 50; };`);
661
+ }
662
+ renameScript.push(`mv -f ${shellQuote(plan.rawScratch)} ${shellQuote(plan.mapping.targetPath)} || { echo "rename failed"; exit 43; };`);
663
+ if (plan.compressed) {
664
+ // Clean up the `.zst` scratch after a successful promotion. `|| true`
665
+ // keeps a cleanup failure from becoming the promote script's own exit
666
+ // status — every target file is already in place by this point, so a
667
+ // stray `.zst` scratch must never read back as a sync failure.
668
+ renameScript.push(`rm -f ${shellQuote(plan.compressed.zstdScratch)} || true;`);
669
+ }
670
+ }
671
+ // `promote` span: move the staged temp onto its target. When this batch
672
+ // decompressed at least one mapping, this span also carries
673
+ // `transfer.decompress.wall_ms`. That value measures the WHOLE promote
674
+ // command — every decompression and every `mv` — not decompression alone.
675
+ // Treat it as an upper bound on the decompress wall time, not an exact
676
+ // measurement.
677
+ await withProviderSpan({
678
+ name: "promote",
679
+ wallMsAttr: hasCompressedMapping ? SPAN_ATTR.transferDecompressWallMs : undefined,
680
+ run: () => assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(renameScript.join("\n"))}`, timeoutSeconds, "syncIn rename"),
681
+ });
682
+ }
683
+ catch (error) {
684
+ await removeSandboxScratch(sandbox, allScratchNames, timeoutSeconds);
685
+ throw error;
686
+ }
687
+ finally {
688
+ await Promise.all(hostTempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }).catch(() => undefined)));
689
+ }
690
+ // Every target is already promoted at this point. Give the promote
691
+ // script's own `.zst` cleanup (`|| true`) one more, separate try.
692
+ // Log a count, never a path, when a leftover survives both tries.
693
+ if (compressedZstdScratchNames.length > 0) {
694
+ const leftoverCount = await sweepZstdScratchAfterSuccess(sandbox, compressedZstdScratchNames, timeoutSeconds);
695
+ if (leftoverCount > 0) {
696
+ console.warn(`Daytona zstd transport compression: ${leftoverCount} post-promotion scratch file(s) could not be removed after two attempts. The already-promoted target file(s) are unaffected.`);
697
+ }
698
+ }
699
+ return { filesTransferred: mappings.length, bytesTransferred };
700
+ }
701
+ async function syncInDirectoryMapping(input) {
702
+ const { sandbox, mapping, remoteDir, timeoutSeconds } = input;
703
+ return withHostTempDir(async (tmp) => {
704
+ const archivePath = path.join(tmp, "sync-in.tar.gz");
705
+ // The pack step is host-local: it builds the tarball and makes no sandbox
706
+ // round trip. The `pack` span records its wall time.
707
+ // `pack` span: build a tarball on the host — no sandbox round trip.
708
+ await withProviderSpan({
709
+ name: "pack",
710
+ wallMsAttr: SPAN_ATTR.packWallMs,
711
+ run: () => createHostTarball({
712
+ localDir: mapping.sourcePath,
713
+ archivePath,
714
+ exclude: mapping.exclude,
715
+ followSymlinks: mapping.followSymlinks,
716
+ }),
717
+ });
718
+ const bytesTransferred = (await fs.stat(archivePath)).size;
719
+ // The tar bytes ride the native bulk channel (string source ⇒ streamed);
720
+ // only the extract/cleanup control commands use exec.
721
+ const remoteTar = path.posix.join(remoteDir, scratchName(".tar.gz"));
722
+ // Count the serial guard round trips before the transfer, so the transfer
723
+ // span records how much of the wall time is guard cost.
724
+ let guardRoundTrips = 0;
725
+ // Materialize the target dir before the upload so the extract step below has
726
+ // somewhere to write.
727
+ // `ensureDirectory` span: `mkdir -p` — ensure a directory exists before a write.
728
+ await withProviderSpan({
729
+ name: "ensureDirectory",
730
+ run: () => assertSandboxCommandOk(sandbox, `mkdir -p ${shellQuote(mapping.targetPath)}`, timeoutSeconds, "syncIn mkdir"),
731
+ });
732
+ guardRoundTrips += 1;
733
+ // The uploaded scratch tar lands at the workspace root as a reserved
734
+ // `.paperclip-upload-*` entry. The extract script below removes it only on
735
+ // success. On an upload or extract failure the scratch tar can remain, and the
736
+ // runtime workspace wipe preserves every `.paperclip-upload-*` entry, so a
737
+ // stale tar would surface in the agent workspace. Sweep the scratch on any
738
+ // failure — symmetric with the file-mapping path — so a failed sync (for
739
+ // example a referenced-project extraction) leaves no residue.
740
+ try {
741
+ // `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`.
742
+ await withProviderSpan({
743
+ name: "transfer",
744
+ wallMsAttr: SPAN_ATTR.transferWallMs,
745
+ attributes: {
746
+ [SPAN_ATTR.transferGuardCount]: guardRoundTrips,
747
+ [SPAN_ATTR.transferDirection]: "inbound",
748
+ },
749
+ run: () => sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds),
750
+ });
751
+ // Extract the uploaded tarball onto the already-created target directory,
752
+ // then remove the scratch tarball.
753
+ const immutable = mapping.mode !== undefined && (mapping.mode & 0o222) === 0;
754
+ const extractScript = [
755
+ // A resumed sandbox can already contain these immutable 0444/0555
756
+ // assets. Repack a private extraction as the sandbox user: tar --diff
757
+ // otherwise rejects identical bytes because the host UID/GID differ.
758
+ // Compare content and modes, never chmod the live bundle or skip
759
+ // unverified old files. Extra user files in the target stay untouched.
760
+ ...(immutable ? [
761
+ `compare_dir=${shellQuote(`${remoteTar}.compare`)};`,
762
+ `compare_tar=${shellQuote(`${remoteTar}.normalized`)};`,
763
+ `compare_list=${shellQuote(`${remoteTar}.members`)};`,
764
+ 'cleanup_compare() { if [ -d "$compare_dir" ]; then find "$compare_dir" -type d -exec chmod u+w {} +; rm -rf "$compare_dir"; fi; rm -f "$compare_tar" "$compare_list"; };',
765
+ "trap cleanup_compare EXIT;",
766
+ 'mkdir -m 700 "$compare_dir" || exit 43;',
767
+ `tar -xf ${shellQuote(remoteTar)} --no-same-owner --delay-directory-restore -C "$compare_dir" || exit 43;`,
768
+ '(cd "$compare_dir" && find . -mindepth 1 -maxdepth 1 -print0) > "$compare_list" || exit 43;',
769
+ 'tar -cf "$compare_tar" --format=pax -C "$compare_dir" --null -T "$compare_list" || exit 43;',
770
+ `if tar -df "$compare_tar" -C ${shellQuote(mapping.targetPath)} >/dev/null 2>&1; then rm -f ${shellQuote(remoteTar)}; exit 0; fi;`,
771
+ "cleanup_compare;",
772
+ "trap - EXIT;",
773
+ ] : []),
774
+ // BSD archives may revisit a directory after its parent's files. Keep
775
+ // GNU tar from restoring a read-only skill directory's mode before all
776
+ // of its children are extracted; final permissions remain unchanged.
777
+ `tar -xf ${shellQuote(remoteTar)} --delay-directory-restore -C ${shellQuote(mapping.targetPath)} || { echo "extract failed"; exit 43; };`,
778
+ `rm -f ${shellQuote(remoteTar)};`,
779
+ ].join("\n");
780
+ // `extractTarball` span: one round trip — re-check the path, `tar -xf`, and
781
+ // remove the scratch tarball.
782
+ await withProviderSpan({
783
+ name: "extractTarball",
784
+ run: () => assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(extractScript)}`, timeoutSeconds, "syncIn extract"),
785
+ });
786
+ }
787
+ catch (error) {
788
+ await removeSandboxScratch(sandbox, [remoteTar], timeoutSeconds);
789
+ throw error;
790
+ }
791
+ const filesTransferred = await countHostFiles(mapping.sourcePath, mapping.exclude);
792
+ return { filesTransferred, bytesTransferred };
793
+ });
794
+ }
795
+ /**
796
+ * Execute an operation's ordered `postUploadCommands` in-sandbox AFTER its files
797
+ * have landed. Commands run in array order, fail-fast: the first non-zero exit
798
+ * or timeout throws and stops the rest. Each `command` string is executed
799
+ * VERBATIM via the exec seam; the provider never rewrites, concatenates, or
800
+ * appends a shell fragment to it — the working directory rides
801
+ * `executeCommand`'s structured `cwd` argument, never a `cd &&` prefix on the
802
+ * command. An absent `cwd` defaults to the provider-resolved remote dir — never
803
+ * a process default cwd.
804
+ *
805
+ * Shared by the file- and directory-mapping paths: it runs once per operation,
806
+ * after every mapping of that operation has been placed.
807
+ */
808
+ async function runPostUploadCommands(input) {
809
+ const { sandbox, commands, remoteDir, timeoutSeconds } = input;
810
+ for (const command of commands) {
811
+ // Absent cwd defaults to the remote dir, never a process default cwd.
812
+ const cwd = command.cwd ?? remoteDir;
813
+ // Run the command VERBATIM with a structured cwd (no string rewrite). The
814
+ // first non-zero exit or timeout throws and aborts the remaining commands.
815
+ const commandTimeoutSeconds = command.timeoutMs != null ? toTimeoutSeconds(command.timeoutMs) : timeoutSeconds;
816
+ // `postUploadCommand` span: run one caller-supplied post-upload command.
817
+ const result = await withProviderSpan({
818
+ name: "postUploadCommand",
819
+ run: () => sandbox.process.executeCommand(command.command, cwd, undefined, commandTimeoutSeconds),
820
+ });
821
+ if ((result.exitCode ?? 1) !== 0) {
822
+ const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim();
823
+ throw new Error(`Daytona post-upload command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`);
824
+ }
825
+ }
826
+ }
827
+ export async function performSyncIn(input) {
828
+ const operations = [];
829
+ for (const operation of input.operations) {
830
+ let filesTransferred = 0;
831
+ let bytesTransferred = 0;
832
+ const fileMappings = operation.files.filter((mapping) => mapping.kind === "file");
833
+ const directoryMappings = operation.files.filter((mapping) => mapping.kind === "directory");
834
+ const fileResult = await syncInFileMappings({
835
+ sandbox: input.sandbox,
836
+ mappings: fileMappings,
837
+ remoteDir: input.remoteDir,
838
+ timeoutSeconds: input.timeoutSeconds,
839
+ });
840
+ filesTransferred += fileResult.filesTransferred;
841
+ bytesTransferred += fileResult.bytesTransferred;
842
+ for (const mapping of directoryMappings) {
843
+ const dirResult = await syncInDirectoryMapping({
844
+ sandbox: input.sandbox,
845
+ mapping,
846
+ remoteDir: input.remoteDir,
847
+ timeoutSeconds: input.timeoutSeconds,
848
+ });
849
+ filesTransferred += dirResult.filesTransferred;
850
+ bytesTransferred += dirResult.bytesTransferred;
851
+ }
852
+ // Run the operation's ordered post-upload commands AFTER every file/directory
853
+ // mapping of this operation has landed. Absent/empty → no extra exec.
854
+ await runPostUploadCommands({
855
+ sandbox: input.sandbox,
856
+ commands: operation.postUploadCommands ?? [],
857
+ remoteDir: input.remoteDir,
858
+ timeoutSeconds: input.timeoutSeconds,
859
+ });
860
+ operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
861
+ }
862
+ return { operations };
863
+ }
864
+ // ---------------------------------------------------------------------------
865
+ // Outbound (sandbox → host)
866
+ // ---------------------------------------------------------------------------
867
+ async function syncOutFileMappings(input) {
868
+ const { sandbox, mappings, remoteDir, timeoutSeconds } = input;
869
+ if (mappings.length === 0)
870
+ return { filesTransferred: 0, bytesTransferred: 0 };
871
+ for (const mapping of mappings) {
872
+ assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
873
+ }
874
+ // Close the validation→download TOCTOU: instead of re-opening each mutable
875
+ // source, validate-and-snapshot it in one atomic sandbox-side step and download
876
+ // the immutable snapshot. `snapshots` is index-aligned with `mappings`.
877
+ const snapshots = await snapshotOutboundFileSources({
878
+ sandbox,
879
+ remoteDir,
880
+ sources: mappings.map((mapping) => mapping.sourcePath),
881
+ timeoutSeconds,
882
+ });
883
+ const requests = [];
884
+ const finalize = [];
885
+ mappings.forEach((mapping, index) => {
886
+ const dir = path.dirname(mapping.targetPath);
887
+ // Stream each snapshot into a reserved host temp sibling, then atomic-rename
888
+ // onto the host targetPath so an interrupted download never truncates it.
889
+ const temp = path.join(dir, scratchName());
890
+ requests.push({ source: snapshots[index], destination: temp });
891
+ finalize.push({ temp, target: mapping.targetPath, source: mapping.sourcePath, snapshot: snapshots[index], mode: mapping.mode });
892
+ });
893
+ const cleanup = async () => {
894
+ await Promise.all(finalize.map((entry) => fs.rm(entry.temp, { force: true }).catch(() => undefined)));
895
+ await removeSandboxScratch(sandbox, snapshots, timeoutSeconds);
896
+ };
897
+ // mkdir host target dirs up front (outside the download try) so a mkdir failure
898
+ // still runs snapshot cleanup below.
899
+ try {
900
+ for (const entry of finalize) {
901
+ await fs.mkdir(path.dirname(entry.target), { recursive: true });
902
+ }
903
+ }
904
+ catch (error) {
905
+ await cleanup();
906
+ throw error;
907
+ }
908
+ // Count the serial sandbox round trips before the transfer, so the transfer
909
+ // span records how much of the wall time is guard cost. The validate-and-
910
+ // snapshot step is one sandbox round trip. This is symmetric with the inbound
911
+ // transfer span.
912
+ const guardRoundTrips = 1;
913
+ let responses;
914
+ try {
915
+ // One batched bulk download for all file mappings, reading the snapshots.
916
+ // `transfer` span: the real byte download — `sandbox.fs.downloadFiles`.
917
+ responses = await withProviderSpan({
918
+ name: "transfer",
919
+ wallMsAttr: SPAN_ATTR.transferWallMs,
920
+ attributes: {
921
+ [SPAN_ATTR.transferGuardCount]: guardRoundTrips,
922
+ [SPAN_ATTR.transferDirection]: "outbound",
923
+ },
924
+ run: () => sandbox.fs.downloadFiles(requests, timeoutSeconds),
925
+ });
926
+ }
927
+ catch (error) {
928
+ await cleanup();
929
+ throw error;
930
+ }
931
+ // Per-file failures surface in `.error`, not a thrown batch — fail loud on any.
932
+ // Responses are keyed by the (snapshot) request source; report the original
933
+ // sourcePath in the surfaced error for a caller-meaningful message.
934
+ const bySource = new Map(responses.map((response) => [response.source, response]));
935
+ for (const entry of finalize) {
936
+ const response = bySource.get(entry.snapshot);
937
+ if (!response || response.error) {
938
+ await cleanup();
939
+ throw new Error(`Daytona syncOut download failed for ${entry.source}: ${response?.error ?? "no response returned"}`);
940
+ }
941
+ }
942
+ let bytesTransferred = 0;
943
+ try {
944
+ for (const entry of finalize) {
945
+ // chmod the temp before the rename so the target never appears at a widened
946
+ // window; rename preserves the inode's mode.
947
+ if (typeof entry.mode === "number") {
948
+ await fs.chmod(entry.temp, entry.mode);
949
+ }
950
+ bytesTransferred += (await fs.stat(entry.temp)).size;
951
+ await fs.rename(entry.temp, entry.target);
952
+ }
953
+ }
954
+ catch (error) {
955
+ await cleanup();
956
+ throw error;
957
+ }
958
+ // Success: the host temps have been renamed onto their targets; remove the
959
+ // sandbox-side snapshots so no reserved scratch lingers.
960
+ await removeSandboxScratch(sandbox, snapshots, timeoutSeconds);
961
+ return { filesTransferred: mappings.length, bytesTransferred };
962
+ }
963
+ async function syncOutDirectoryMapping(input) {
964
+ const { sandbox, mapping, remoteDir, timeoutSeconds } = input;
965
+ assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
966
+ // Count the serial sandbox round trips before the transfer, so the transfer
967
+ // span records how much of the wall time is guard cost.
968
+ let guardRoundTrips = 0;
969
+ await assertSandboxPathsConfined({
970
+ sandbox,
971
+ remoteDir,
972
+ paths: [mapping.sourcePath],
973
+ timeoutSeconds,
974
+ label: "outbound symlink-escape guard",
975
+ });
976
+ guardRoundTrips += 1;
977
+ return withHostTempDir(async (tmp) => {
978
+ const remoteTar = path.posix.join(remoteDir, scratchName(".tar"));
979
+ const excludeFlags = ["._*", ...(mapping.exclude ?? [])]
980
+ .map((entry) => `--exclude ${shellQuote(entry)}`)
981
+ .join(" ");
982
+ // Tar the source in-sandbox (naming top-level entries so no "." self-entry is
983
+ // embedded), reproducing the `followSymlinks` → `-h` mapping, then stream the
984
+ // single archive back over the native bulk channel.
985
+ const tarScript = [
986
+ `cd ${shellQuote(mapping.sourcePath)}`,
987
+ "set -- *",
988
+ 'if [ "$#" -eq 1 ] && [ "$1" = "*" ] && [ ! -e "$1" ] && [ ! -L "$1" ]; then set --; fi',
989
+ 'for entry in .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue; set -- "$@" "$entry"; done',
990
+ `if [ "$#" -eq 0 ]; then dd if=/dev/zero of=${shellQuote(remoteTar)} bs=1024 count=1; ` +
991
+ `else tar -c --no-xattrs ${mapping.followSymlinks ? "-h " : ""}${excludeFlags} -f ${shellQuote(remoteTar)} -- "$@"; fi`,
992
+ ].join(" && ");
993
+ await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(tarScript)}`, timeoutSeconds, "syncOut tar");
994
+ guardRoundTrips += 1;
995
+ const localTar = path.join(tmp, "sync-out.tar");
996
+ let bytesTransferred = 0;
997
+ try {
998
+ // `transfer` span: the real byte download — `sandbox.fs.downloadFiles`.
999
+ const responses = await withProviderSpan({
1000
+ name: "transfer",
1001
+ wallMsAttr: SPAN_ATTR.transferWallMs,
1002
+ attributes: {
1003
+ [SPAN_ATTR.transferGuardCount]: guardRoundTrips,
1004
+ [SPAN_ATTR.transferDirection]: "outbound",
1005
+ },
1006
+ run: () => sandbox.fs.downloadFiles([{ source: remoteTar, destination: localTar }], timeoutSeconds),
1007
+ });
1008
+ const response = responses.find((entry) => entry.source === remoteTar) ?? responses[0];
1009
+ if (!response || response.error) {
1010
+ throw new Error(`Daytona syncOut directory download failed for ${mapping.sourcePath}: ${response?.error ?? "no response returned"}`);
1011
+ }
1012
+ bytesTransferred = (await fs.stat(localTar)).size;
1013
+ await extractHostTarball({ archivePath: localTar, localDir: mapping.targetPath });
1014
+ }
1015
+ finally {
1016
+ // Best-effort remove the sandbox-side scratch tar; the host temp dir is
1017
+ // cleaned by withHostTempDir.
1018
+ await sandbox.fs
1019
+ .deleteFile(remoteTar)
1020
+ .catch(() => undefined);
1021
+ }
1022
+ const filesTransferred = await countHostFiles(mapping.targetPath, mapping.exclude);
1023
+ return { filesTransferred, bytesTransferred };
1024
+ });
1025
+ }
1026
+ export async function performSyncOut(input) {
1027
+ const operations = [];
1028
+ for (const operation of input.operations) {
1029
+ let filesTransferred = 0;
1030
+ let bytesTransferred = 0;
1031
+ const fileMappings = operation.files.filter((mapping) => mapping.kind === "file");
1032
+ const directoryMappings = operation.files.filter((mapping) => mapping.kind === "directory");
1033
+ const fileResult = await syncOutFileMappings({
1034
+ sandbox: input.sandbox,
1035
+ mappings: fileMappings,
1036
+ remoteDir: input.remoteDir,
1037
+ timeoutSeconds: input.timeoutSeconds,
1038
+ });
1039
+ filesTransferred += fileResult.filesTransferred;
1040
+ bytesTransferred += fileResult.bytesTransferred;
1041
+ for (const mapping of directoryMappings) {
1042
+ const dirResult = await syncOutDirectoryMapping({
1043
+ sandbox: input.sandbox,
1044
+ mapping,
1045
+ remoteDir: input.remoteDir,
1046
+ timeoutSeconds: input.timeoutSeconds,
1047
+ });
1048
+ filesTransferred += dirResult.filesTransferred;
1049
+ bytesTransferred += dirResult.bytesTransferred;
1050
+ }
1051
+ operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
1052
+ }
1053
+ return { operations };
1054
+ }
1055
+ //# sourceMappingURL=file-sync.js.map