@tickernelz/wardmote-plugin-daytona 0.0.0 → 2026.1108.1

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