@tickernelz/paperclip-pro-plugin-kubernetes 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 (100) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +180 -0
  3. package/dist/adapter-defaults.d.ts +29 -0
  4. package/dist/adapter-defaults.d.ts.map +1 -0
  5. package/dist/adapter-defaults.js +98 -0
  6. package/dist/adapter-defaults.js.map +1 -0
  7. package/dist/adapter-registry.d.ts +64 -0
  8. package/dist/adapter-registry.d.ts.map +1 -0
  9. package/dist/adapter-registry.js +25 -0
  10. package/dist/adapter-registry.js.map +1 -0
  11. package/dist/cilium-network-policy.d.ts +12 -0
  12. package/dist/cilium-network-policy.d.ts.map +1 -0
  13. package/dist/cilium-network-policy.js +60 -0
  14. package/dist/cilium-network-policy.js.map +1 -0
  15. package/dist/file-sync.d.ts +73 -0
  16. package/dist/file-sync.d.ts.map +1 -0
  17. package/dist/file-sync.js +752 -0
  18. package/dist/file-sync.js.map +1 -0
  19. package/dist/image-allowlist.d.ts +18 -0
  20. package/dist/image-allowlist.d.ts.map +1 -0
  21. package/dist/image-allowlist.js +39 -0
  22. package/dist/image-allowlist.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/job-orchestrator.d.ts +24 -0
  28. package/dist/job-orchestrator.d.ts.map +1 -0
  29. package/dist/job-orchestrator.js +90 -0
  30. package/dist/job-orchestrator.js.map +1 -0
  31. package/dist/kube-client.d.ts +15 -0
  32. package/dist/kube-client.d.ts.map +1 -0
  33. package/dist/kube-client.js +23 -0
  34. package/dist/kube-client.js.map +1 -0
  35. package/dist/lease-lifecycle.d.ts +62 -0
  36. package/dist/lease-lifecycle.d.ts.map +1 -0
  37. package/dist/lease-lifecycle.js +155 -0
  38. package/dist/lease-lifecycle.js.map +1 -0
  39. package/dist/manifest.d.ts +4 -0
  40. package/dist/manifest.d.ts.map +1 -0
  41. package/dist/manifest.js +108 -0
  42. package/dist/manifest.js.map +1 -0
  43. package/dist/network-policy.d.ts +22 -0
  44. package/dist/network-policy.d.ts.map +1 -0
  45. package/dist/network-policy.js +116 -0
  46. package/dist/network-policy.js.map +1 -0
  47. package/dist/plugin.d.ts +3 -0
  48. package/dist/plugin.d.ts.map +1 -0
  49. package/dist/plugin.js +820 -0
  50. package/dist/plugin.js.map +1 -0
  51. package/dist/pod-exec.d.ts +67 -0
  52. package/dist/pod-exec.d.ts.map +1 -0
  53. package/dist/pod-exec.js +401 -0
  54. package/dist/pod-exec.js.map +1 -0
  55. package/dist/pod-spec-builder.d.ts +25 -0
  56. package/dist/pod-spec-builder.d.ts.map +1 -0
  57. package/dist/pod-spec-builder.js +82 -0
  58. package/dist/pod-spec-builder.js.map +1 -0
  59. package/dist/sandbox-cr-builder.d.ts +40 -0
  60. package/dist/sandbox-cr-builder.d.ts.map +1 -0
  61. package/dist/sandbox-cr-builder.js +123 -0
  62. package/dist/sandbox-cr-builder.js.map +1 -0
  63. package/dist/sandbox-cr-orchestrator.d.ts +62 -0
  64. package/dist/sandbox-cr-orchestrator.d.ts.map +1 -0
  65. package/dist/sandbox-cr-orchestrator.js +248 -0
  66. package/dist/sandbox-cr-orchestrator.js.map +1 -0
  67. package/dist/sandbox-orchestrator.d.ts +39 -0
  68. package/dist/sandbox-orchestrator.d.ts.map +1 -0
  69. package/dist/sandbox-orchestrator.js +2 -0
  70. package/dist/sandbox-orchestrator.js.map +1 -0
  71. package/dist/scoped-network-egress.d.ts +19 -0
  72. package/dist/scoped-network-egress.d.ts.map +1 -0
  73. package/dist/scoped-network-egress.js +86 -0
  74. package/dist/scoped-network-egress.js.map +1 -0
  75. package/dist/secret-manager.d.ts +14 -0
  76. package/dist/secret-manager.d.ts.map +1 -0
  77. package/dist/secret-manager.js +39 -0
  78. package/dist/secret-manager.js.map +1 -0
  79. package/dist/tenant-orchestrator.d.ts +35 -0
  80. package/dist/tenant-orchestrator.d.ts.map +1 -0
  81. package/dist/tenant-orchestrator.js +263 -0
  82. package/dist/tenant-orchestrator.js.map +1 -0
  83. package/dist/types.d.ts +279 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +65 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/upload-interceptor.d.ts +44 -0
  88. package/dist/upload-interceptor.d.ts.map +1 -0
  89. package/dist/upload-interceptor.js +114 -0
  90. package/dist/upload-interceptor.js.map +1 -0
  91. package/dist/utils.d.ts +11 -0
  92. package/dist/utils.d.ts.map +1 -0
  93. package/dist/utils.js +51 -0
  94. package/dist/utils.js.map +1 -0
  95. package/dist/worker.d.ts +3 -0
  96. package/dist/worker.d.ts.map +1 -0
  97. package/dist/worker.js +5 -0
  98. package/dist/worker.js.map +1 -0
  99. package/manifests/operator-prerequisites.yaml +22 -0
  100. package/package.json +47 -0
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Native file-sync transport for the Kubernetes sandbox provider.
3
+ *
4
+ * Implements the opt-in `onEnvironmentSyncIn`/`onEnvironmentSyncOut` hooks over
5
+ * the pod exec data channel, so workspace/asset transfers stream through a
6
+ * SINGLE exec per operation instead of the default base64 chunk loop (which
7
+ * costs one exec per ~4 MB chunk — see `upload-interceptor.ts`).
8
+ *
9
+ * The transport is pure streaming: raw tar bytes move over the exec's stdin /
10
+ * stdout channels straight to and from a host file on disk, so neither the host
11
+ * nor the pod ever holds the whole payload in memory.
12
+ *
13
+ * - syncIn: build one host tarball of the operation's payload on disk, stream
14
+ * its raw bytes over the exec's stdin into `head -c <N> | tar -x`
15
+ * inside the pod (N is the exact archive size, known host-side, so
16
+ * the pod command self-terminates without depending on stdin EOF),
17
+ * then stage-then-`mv -f` each single file onto its target so an
18
+ * interrupted transfer never leaves a truncated file.
19
+ * - syncOut: run `tar -c … -f -` inside the pod over one exec, streaming the
20
+ * archive on stdout straight into a host file, then reassemble it.
21
+ *
22
+ * The exec primitive is injected as `PodStreamExec` so this module stays free of
23
+ * any `@kubernetes/client-node` coupling and is unit-testable against a
24
+ * shell-backed fake. `plugin.ts` binds it to `execInPodStreaming` for a resolved
25
+ * pod.
26
+ *
27
+ * Security model (carried from the seam's design review): every sandbox path is
28
+ * confined to the workspace remote dir both lexically on the host AND via an
29
+ * in-pod `realpath` check that pins the resolved directory inode through
30
+ * `/proc/self/fd` before any byte is written — closing the symlink-swap TOCTOU
31
+ * window. Secret files land at their requested `mode` with no world-readable
32
+ * window (the staging dir is created `0700` and each file is `chmod`ed before
33
+ * the rename). All interpolated paths are single-quoted. Sandbox-authored
34
+ * tarballs (outbound) are member-confined before host extraction.
35
+ *
36
+ * Memory + disk posture: because the archive streams to/from disk, a transfer of
37
+ * any size keeps host and pod memory flat — there is no whole-payload buffer and
38
+ * therefore no 100 MB in-memory cap (the old base64-buffered transport needed
39
+ * one). Inbound bytes are host-authored (the tar this module builds), so they
40
+ * need no untrusted-size bound. Outbound bytes are authored by the untrusted
41
+ * pod, which controls how much it emits, so the sink `Writable` is wrapped in a
42
+ * streamed-bytes guard that fails the transfer closed above `maxOutputBytes`
43
+ * before it can fill the host's disk; the pod's stderr is separately capped in
44
+ * `execInPodStreaming`.
45
+ */
46
+ import path from "node:path";
47
+ import os from "node:os";
48
+ import { promises as fs, createReadStream, createWriteStream } from "node:fs";
49
+ import { Transform } from "node:stream";
50
+ import { finished } from "node:stream/promises";
51
+ import { randomUUID } from "node:crypto";
52
+ import { execFile } from "node:child_process";
53
+ import { promisify } from "node:util";
54
+ const execFileAsync = promisify(execFile);
55
+ // Reserved scratch-name stem for staged transfers and remote tarballs. The
56
+ // runtime's base64 fallback stages to `<path>.paperclip-upload`; the native
57
+ // transport reuses the same reserved prefix so a provider temp never collides
58
+ // with a real target or with the fallback's scratch name.
59
+ const SCRATCH_PREFIX = ".paperclip-upload";
60
+ // Fail-closed guard on the number of bytes an outbound (`syncOut`) transfer will
61
+ // stream to host disk. The archive is authored by the untrusted pod, which
62
+ // controls how many bytes it emits on stdout; streaming keeps memory flat but a
63
+ // hostile pod could still try to exhaust the worker's ephemeral disk. The
64
+ // transfer aborts the instant the pod exceeds this bound. It is deliberately
65
+ // generous (real workspace/asset syncs are far smaller) and overridable per
66
+ // call so operators can tune it to the worker's actual disk budget.
67
+ const MAX_SYNC_OUTPUT_BYTES = 8 * 1024 * 1024 * 1024;
68
+ // Bound on the pod's stderr for a sync exec. stderr carries only the in-pod
69
+ // script's fail-loud diagnostics, so a modest cap is ample; it exists solely so
70
+ // a pod that floods stderr cannot grow the host accumulator without limit.
71
+ const SYNC_STDERR_CAP_BYTES = 1024 * 1024;
72
+ function scratchName(suffix = "") {
73
+ return `${SCRATCH_PREFIX}-${randomUUID()}${suffix}`;
74
+ }
75
+ /**
76
+ * Single-quote a string for safe interpolation into a `sh -c` script (close,
77
+ * escape, reopen). Mirrors `pod-exec.ts`'s `shQuote`; duplicated here so this
78
+ * module carries no `@kubernetes/client-node` import and stays hermetically
79
+ * testable. Every path handed to an exec MUST pass through this.
80
+ */
81
+ function shQuote(value) {
82
+ return `'${value.replace(/'/g, "'\\''")}'`;
83
+ }
84
+ /**
85
+ * Host-side complete-mediation guard applied as defense-in-depth below the
86
+ * orchestrator's own confinement. Every sandbox-side path (the sync target for
87
+ * inbound, the sync source for outbound) MUST be an absolute path that
88
+ * canonicalizes lexically inside the workspace remote dir; absolute escapes and
89
+ * `..` traversal are rejected fail-closed before any bytes move. Sandbox paths
90
+ * are POSIX.
91
+ */
92
+ export function assertConfinedSandboxPath(remoteDir, candidate, label) {
93
+ const normalizedRoot = path.posix.normalize(remoteDir);
94
+ const normalized = path.posix.normalize(candidate);
95
+ if (!path.posix.isAbsolute(normalized) ||
96
+ normalized === ".." ||
97
+ normalized.includes("/../") ||
98
+ normalized.endsWith("/..")) {
99
+ throw new Error(`Kubernetes sync ${label} path is not a confined absolute path: ${candidate}`);
100
+ }
101
+ const prefix = normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`;
102
+ if (normalized !== normalizedRoot && !normalized.startsWith(prefix)) {
103
+ throw new Error(`Kubernetes sync ${label} path escapes the workspace remote dir: ${candidate}`);
104
+ }
105
+ }
106
+ /**
107
+ * True when `relative` (a POSIX path) escapes its anchoring directory once
108
+ * normalized: an absolute path, `..`, or a `..`-leading traversal all break out.
109
+ */
110
+ function posixPathEscapes(relative) {
111
+ const normalized = path.posix.normalize(relative);
112
+ return normalized === ".." || normalized.startsWith("../") || path.posix.isAbsolute(normalized);
113
+ }
114
+ async function withHostTempDir(fn) {
115
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-k8s-sync-"));
116
+ try {
117
+ return await fn(dir);
118
+ }
119
+ finally {
120
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
121
+ }
122
+ }
123
+ /**
124
+ * POSIX-sh preamble defining a `_pc_resolve` canonicalizer (prefer `realpath`,
125
+ * fall back to `readlink -f`; fail closed with exit 40 if neither exists so the
126
+ * host-side lexical check is never the only line of defense) and `_pc_root` =
127
+ * the resolved workspace remote dir. Shared by every in-pod symlink-escape guard.
128
+ *
129
+ * Also defines `_pc_confine_ancestor <path>`: it walks up to the DEEPEST already-
130
+ * existing ancestor of `<path>`, canonicalizes it, and confines it inside
131
+ * `_pc_root` (exit 42 on escape). `mkdir -p` follows an existing symlink ancestor,
132
+ * so creating a target directory before confining could materialize a dir OUTSIDE
133
+ * the root and only reject afterward — the escape has already mutated the host.
134
+ * Confining the closest existing prefix BEFORE `mkdir` closes that window: any
135
+ * component `mkdir -p` newly creates is a real directory, so only a pre-existing
136
+ * symlink ancestor can redirect the write, and that ancestor is exactly what this
137
+ * check canonicalizes and rejects.
138
+ */
139
+ function canonicalizerPreamble(quotedRoot) {
140
+ return [
141
+ 'if command -v realpath >/dev/null 2>&1; then _pc_resolve() { realpath -- "$1"; };',
142
+ 'elif command -v readlink >/dev/null 2>&1; then _pc_resolve() { readlink -f -- "$1"; };',
143
+ 'else echo "no path canonicalizer available" >&2; exit 40; fi;',
144
+ `_pc_root=$(_pc_resolve ${quotedRoot}) || { echo "cannot resolve root" >&2; exit 41; };`,
145
+ '_pc_confine_ancestor() {',
146
+ // Confinement targets are always absolute (asserted host-side), so `dirname`
147
+ // needs no `--` end-of-options guard — and omitting it keeps the walk correct
148
+ // on minimal shells (e.g. BusyBox `dirname`, which does not parse `--`).
149
+ ' _pc_a=$1;',
150
+ ' while [ ! -e "$_pc_a" ] && [ ! -L "$_pc_a" ]; do',
151
+ ' _pc_p=$(dirname "$_pc_a");',
152
+ ' [ "$_pc_p" = "$_pc_a" ] && break;',
153
+ ' _pc_a=$_pc_p;',
154
+ ' done;',
155
+ ' _pc_ar=$(_pc_resolve "$_pc_a") || return 42;',
156
+ ' case "$_pc_ar/" in "$_pc_root"/*) return 0 ;; *) return 42 ;; esac;',
157
+ '};',
158
+ ];
159
+ }
160
+ /**
161
+ * Stream a host file's raw bytes into the pod command's stdin over one exec and
162
+ * assert it exited 0; otherwise throw with the captured stderr so a failed
163
+ * transfer surfaces fail-loud to the orchestrator (never a silent partial
164
+ * success). The pod command bounds its own read (`head -c <N>`), so stdin EOF is
165
+ * never load-bearing.
166
+ */
167
+ async function streamFileToPodStdin(input) {
168
+ const source = createReadStream(input.filePath);
169
+ try {
170
+ const result = await input.exec(["/bin/sh", "-c", input.script], {
171
+ stdin: source,
172
+ timeoutMs: input.timeoutMs,
173
+ maxStderrBytes: SYNC_STDERR_CAP_BYTES,
174
+ });
175
+ if (result.exitCode !== 0) {
176
+ const detail = (result.stderr || "").trim();
177
+ throw new Error(`Kubernetes ${input.label} failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`);
178
+ }
179
+ }
180
+ finally {
181
+ source.destroy();
182
+ }
183
+ }
184
+ /**
185
+ * Run the pod command over one exec and stream its stdout straight into a host
186
+ * file, guarding the byte count against `maxOutputBytes` so an untrusted pod
187
+ * cannot fill the host disk. Resolves only after the file is fully flushed, so a
188
+ * caller that reads it back always sees the complete archive; throws fail-loud on
189
+ * a non-zero exit, a tripped disk guard, or a stream error, writing no file the
190
+ * caller then trusts.
191
+ */
192
+ async function streamPodStdoutToFile(input) {
193
+ const fileStream = createWriteStream(input.filePath);
194
+ let written = 0;
195
+ const guard = new Transform({
196
+ transform(chunk, _enc, cb) {
197
+ written += chunk.length;
198
+ if (input.maxOutputBytes >= 0 && written > input.maxOutputBytes) {
199
+ cb(new Error(`Kubernetes ${input.label} payload exceeded the ${input.maxOutputBytes}-byte streamed-output disk guard; the sandbox emitted more bytes than allowed.`));
200
+ return;
201
+ }
202
+ cb(null, chunk);
203
+ },
204
+ });
205
+ guard.pipe(fileStream);
206
+ const flushed = finished(fileStream);
207
+ try {
208
+ const result = await input.exec(["/bin/sh", "-c", input.script], {
209
+ stdout: guard,
210
+ timeoutMs: input.timeoutMs,
211
+ maxStderrBytes: SYNC_STDERR_CAP_BYTES,
212
+ });
213
+ await flushed;
214
+ if (result.exitCode !== 0) {
215
+ const detail = (result.stderr || "").trim();
216
+ throw new Error(`Kubernetes ${input.label} failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`);
217
+ }
218
+ }
219
+ catch (err) {
220
+ // Tear the sink down so a tripped guard / rejected exec never leaves the
221
+ // file stream open (and its `finished` promise dangling as unhandled).
222
+ guard.destroy();
223
+ fileStream.destroy();
224
+ await flushed.catch(() => undefined);
225
+ throw err;
226
+ }
227
+ }
228
+ /**
229
+ * Build a host-side tarball, mirroring the runtime's own `createTarballFromDirectory`:
230
+ * archive the named top-level entries (no "." self entry), suppress xattr
231
+ * sidecars, honor `exclude`, and reproduce the `followSymlinks` → `-h` mapping so
232
+ * the native path is observationally identical to the base64 fallback's tar. An
233
+ * empty entry set writes a valid empty tar (1024-byte zero EOF marker).
234
+ */
235
+ async function createHostTarball(input) {
236
+ const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
237
+ const entries = input.entries ??
238
+ (await fs.readdir(input.localDir)).sort((left, right) => left.localeCompare(right));
239
+ if (entries.length === 0) {
240
+ await fs.writeFile(input.archivePath, Buffer.alloc(1024));
241
+ return;
242
+ }
243
+ await execFileAsync("tar", [
244
+ "-c",
245
+ "--no-xattrs",
246
+ ...(input.followSymlinks ? ["-h"] : []),
247
+ "-f",
248
+ input.archivePath,
249
+ "-C",
250
+ input.localDir,
251
+ ...excludeArgs,
252
+ "--",
253
+ ...entries,
254
+ ], { env: { ...process.env, COPYFILE_DISABLE: "1" }, maxBuffer: 64 * 1024 * 1024 });
255
+ }
256
+ /**
257
+ * Parse one `tar -tvf` verbose listing line into its leading type flag and the
258
+ * trailing name-and-link-target field. The listing dialect depends on which tar
259
+ * the host ships: GNU/busybox emit
260
+ * `<perms> <owner>/<group> <size> <date> <time> <rest>`, while bsdtar
261
+ * (libarchive — the system tar on macOS) emits the ls-style
262
+ * `<perms> <links> <user> <group> <size> <Mon> <day> <time|year> <rest>`.
263
+ * The second field disambiguates: GNU always slash-joins owner/group, bsdtar
264
+ * puts a pure-digit link count there, so no line satisfies both shapes — the
265
+ * slash requirement is load-bearing, since a bsdtar line with numeric uid/gid
266
+ * would otherwise match the GNU shape shifted, hiding traversal in `<rest>`.
267
+ * Entries whose size column is not a plain byte count (e.g. a device node's
268
+ * `major,minor`) match neither shape. Returns null when nothing matches so
269
+ * callers can fail closed.
270
+ */
271
+ export function parseTarVerboseListingLine(line) {
272
+ const gnu = line.match(/^(\S+)\s+\S+\/\S+\s+\d+\s+\S+\s+\S+\s+(.*)$/);
273
+ if (gnu)
274
+ return { typeFlag: gnu[1][0], rest: gnu[2] };
275
+ 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+(.*)$/);
276
+ if (bsd)
277
+ return { typeFlag: bsd[1][0], rest: bsd[2] };
278
+ return null;
279
+ }
280
+ /**
281
+ * Split a verbose-listing link field (`<name><delimiter><target>`) exactly
282
+ * once. The sandbox controls both halves, so a field with zero or multiple
283
+ * delimiter occurrences is unresolvable: a link name that itself contains the
284
+ * delimiter shifts the split point, and taking the first (or last) occurrence
285
+ * would let a crafted name or target hide an escaping link target from the
286
+ * confinement check. Returns null so callers fail closed.
287
+ */
288
+ export function splitLinkEntryOnce(field, delimiter) {
289
+ const first = field.indexOf(delimiter);
290
+ if (first === -1)
291
+ return null;
292
+ if (field.indexOf(delimiter, first + delimiter.length) !== -1)
293
+ return null;
294
+ return { name: field.slice(0, first), target: field.slice(first + delimiter.length) };
295
+ }
296
+ /**
297
+ * Reject a sandbox-authored tarball before extraction if any member would land
298
+ * outside the extraction dir. The archive is produced by the (untrusted) sandbox,
299
+ * so host-side `tar -xf` must never be handed an archive whose entries carry
300
+ * absolute paths or `../` traversal, nor a symlink/hardlink member whose target
301
+ * escapes the tree. Legitimate in-tree relative links are preserved. Parses the
302
+ * `-tvf` verbose listing; any unparseable line fails closed.
303
+ */
304
+ async function assertTarballEntriesConfined(archivePath) {
305
+ const { stdout } = await execFileAsync("tar", ["-tvf", archivePath], {
306
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
307
+ maxBuffer: 64 * 1024 * 1024,
308
+ });
309
+ const lines = stdout.split("\n").filter((line) => line.trim().length > 0);
310
+ for (const line of lines) {
311
+ const parsed = parseTarVerboseListingLine(line);
312
+ if (!parsed) {
313
+ throw new Error(`Kubernetes syncOut refusing tarball with an unparseable entry listing: ${line}`);
314
+ }
315
+ const typeFlag = parsed.typeFlag;
316
+ let name = parsed.rest;
317
+ let linkTarget = null;
318
+ if (typeFlag === "l") {
319
+ const split = splitLinkEntryOnce(name, " -> ");
320
+ if (!split)
321
+ throw new Error(`Kubernetes syncOut refusing unparseable or ambiguous symlink entry: ${line}`);
322
+ name = split.name;
323
+ linkTarget = split.target;
324
+ }
325
+ else if (typeFlag === "h") {
326
+ const split = splitLinkEntryOnce(name, " link to ");
327
+ if (!split)
328
+ throw new Error(`Kubernetes syncOut refusing unparseable or ambiguous hardlink entry: ${line}`);
329
+ name = split.name;
330
+ linkTarget = split.target;
331
+ }
332
+ const cleanName = name.replace(/\/+$/, "");
333
+ if (cleanName.length > 0 && posixPathEscapes(cleanName)) {
334
+ throw new Error(`Kubernetes syncOut refusing tarball member that escapes the extraction dir: ${name}`);
335
+ }
336
+ if (linkTarget !== null) {
337
+ const resolved = path.posix.join(path.posix.dirname(cleanName), linkTarget);
338
+ if (path.posix.isAbsolute(linkTarget) || posixPathEscapes(resolved)) {
339
+ throw new Error(`Kubernetes syncOut refusing tarball link whose target escapes the extraction dir: ${name} -> ${linkTarget}`);
340
+ }
341
+ }
342
+ }
343
+ }
344
+ async function extractHostTarball(input) {
345
+ // The archive is sandbox-authored and untrusted: validate every member (and
346
+ // link target) is confined before letting host-side tar write a single byte.
347
+ await assertTarballEntriesConfined(input.archivePath);
348
+ await fs.mkdir(input.localDir, { recursive: true });
349
+ await execFileAsync("tar", ["-xf", input.archivePath, "-C", input.localDir], {
350
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
351
+ maxBuffer: 64 * 1024 * 1024,
352
+ });
353
+ }
354
+ async function countHostFiles(root, exclude) {
355
+ const excludeSet = new Set(exclude ?? []);
356
+ let total = 0;
357
+ const walk = async (dir) => {
358
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
359
+ for (const entry of entries) {
360
+ if (excludeSet.has(entry.name))
361
+ continue;
362
+ const full = path.join(dir, entry.name);
363
+ if (entry.isDirectory()) {
364
+ await walk(full);
365
+ }
366
+ else {
367
+ total += 1;
368
+ }
369
+ }
370
+ };
371
+ await walk(root).catch(() => undefined);
372
+ return total;
373
+ }
374
+ // ---------------------------------------------------------------------------
375
+ // Inbound (host → sandbox)
376
+ // ---------------------------------------------------------------------------
377
+ /**
378
+ * Transfer all `kind:"file"` mappings of an operation in ONE exec. The host tars
379
+ * the sources under stable index names on disk, streams the archive's raw bytes
380
+ * over the exec's stdin, and a single in-pod script reads exactly the archive's
381
+ * byte count (`head -c <N>`), extracts into a reserved `0700` staging dir,
382
+ * applies each requested mode BEFORE the rename (no widened window), then
383
+ * atomic-`mv -f`s each file onto its target through a `/proc/self/fd`-pinned
384
+ * parent directory so a symlink swap cannot redirect the write outside the root.
385
+ */
386
+ async function syncInFileMappings(input) {
387
+ const { exec, mappings, remoteDir, timeoutMs } = input;
388
+ if (mappings.length === 0)
389
+ return { filesTransferred: 0, bytesTransferred: 0 };
390
+ for (const mapping of mappings) {
391
+ assertConfinedSandboxPath(remoteDir, mapping.targetPath, "target");
392
+ }
393
+ return withHostTempDir(async (tmp) => {
394
+ // Stage each source under a flat index name so one tar carries the whole
395
+ // operation; the index maps back to the mapping for mode + rename below.
396
+ const stage = path.join(tmp, "stage");
397
+ await fs.mkdir(stage);
398
+ let bytesTransferred = 0;
399
+ const indexNames = [];
400
+ for (let i = 0; i < mappings.length; i += 1) {
401
+ const name = String(i);
402
+ await fs.copyFile(mappings[i].sourcePath, path.join(stage, name));
403
+ bytesTransferred += (await fs.stat(mappings[i].sourcePath)).size;
404
+ indexNames.push(name);
405
+ }
406
+ const archivePath = path.join(tmp, "sync-in.tar");
407
+ await createHostTarball({ localDir: stage, archivePath, entries: indexNames });
408
+ // Exact archive size: the pod reads precisely this many bytes so its extract
409
+ // pipeline self-terminates without depending on stdin EOF delivery.
410
+ const archiveBytes = (await fs.stat(archivePath)).size;
411
+ // Reserved staging dir is a DIRECT child of the workspace root (created
412
+ // `0700`), so a secret is never world-readable and the closing `mv -f` is an
413
+ // atomic same-filesystem rename.
414
+ const remoteStage = path.posix.join(remoteDir, scratchName());
415
+ const parentDirs = [...new Set(mappings.map((m) => path.posix.dirname(m.targetPath)))];
416
+ const script = [...canonicalizerPreamble(shQuote(remoteDir))];
417
+ // Sweep the staging dir on ANY exit so a failed transfer leaves no scratch.
418
+ script.push(`trap 'rm -rf ${shQuote(remoteStage)}' EXIT;`);
419
+ script.push(`mkdir -p -m 700 ${shQuote(remoteStage)} || { echo "stage mkdir failed" >&2; exit 46; };`);
420
+ // Confine the deepest existing ancestor of each target parent BEFORE creating
421
+ // it: `mkdir -p` follows a sandbox-planted symlink ancestor and would
422
+ // otherwise materialize the directory OUTSIDE the root before any post-mkdir
423
+ // check could reject it (P0 escape). Confine-then-create closes that window.
424
+ for (const dir of parentDirs) {
425
+ script.push(`_pc_confine_ancestor ${shQuote(dir)} || { echo "ESCAPE" >&2; exit 42; };`, `mkdir -p ${shQuote(dir)} || { echo "mkdir failed" >&2; exit 46; };`);
426
+ }
427
+ // Re-confine every (now-materialized) target parent dir through realpath before
428
+ // any bytes land: a sandbox-planted symlink parent is caught here too.
429
+ for (const dir of parentDirs) {
430
+ script.push(`_pc_d=$(_pc_resolve ${shQuote(dir)}) || { echo "ESCAPE" >&2; exit 42; };`, `case "$_pc_d/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`);
431
+ }
432
+ // Read exactly the archive's bytes off stdin and extract the whole payload
433
+ // into the staging dir in one pipeline.
434
+ script.push(`head -c ${archiveBytes} | tar -xf - -C ${shQuote(remoteStage)} || { echo "extract failed" >&2; exit 43; };`);
435
+ // Promote each staged file onto its target: chmod the temp first (secret
436
+ // lands at its mode from the instant it exists at targetPath), then bind the
437
+ // parent-dir re-check and the rename into a `/proc/self/fd`-pinned open so an
438
+ // ancestor swap after the check cannot redirect the rename.
439
+ mappings.forEach((mapping, i) => {
440
+ const staged = path.posix.join(remoteStage, indexNames[i]);
441
+ const parentDir = path.posix.dirname(mapping.targetPath);
442
+ const base = path.posix.basename(mapping.targetPath);
443
+ if (typeof mapping.mode === "number") {
444
+ script.push(`chmod ${(mapping.mode & 0o7777).toString(8).padStart(3, "0")} ${shQuote(staged)} || { echo "chmod failed" >&2; exit 48; };`);
445
+ }
446
+ script.push(`_pc_tgt_dir=$(_pc_resolve ${shQuote(parentDir)}) || { echo "ESCAPE" >&2; exit 42; };`, `case "$_pc_tgt_dir/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`, `exec 8<"$_pc_tgt_dir" || { echo "open failed" >&2; exit 47; };`, `_pc_fd_dir=$(_pc_resolve /proc/self/fd/8) || { echo "ESCAPE" >&2; exit 42; };`, `case "$_pc_fd_dir/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`, `mv -f ${shQuote(staged)} /proc/self/fd/8/${shQuote(base)} || { echo "rename failed" >&2; exit 44; };`, `exec 8>&-;`);
447
+ });
448
+ await streamFileToPodStdin({
449
+ exec,
450
+ script: script.join("\n"),
451
+ filePath: archivePath,
452
+ timeoutMs,
453
+ label: "syncIn file transfer",
454
+ });
455
+ return { filesTransferred: mappings.length, bytesTransferred };
456
+ });
457
+ }
458
+ /**
459
+ * Transfer one `kind:"directory"` mapping in ONE exec: the host tars the source
460
+ * on disk (reproducing `followSymlinks` → `-h` and `exclude`), streams its raw
461
+ * bytes over stdin, and the pod reads exactly that byte count and extracts into
462
+ * the target through a `/proc/self/fd`-pinned directory inode. Directory
463
+ * extraction is destroy-into (non-atomic), matching the base64 fallback's tar;
464
+ * only single files are atomic.
465
+ */
466
+ async function syncInDirectoryMapping(input) {
467
+ const { exec, mapping, remoteDir, timeoutMs } = input;
468
+ assertConfinedSandboxPath(remoteDir, mapping.targetPath, "target");
469
+ return withHostTempDir(async (tmp) => {
470
+ const archivePath = path.join(tmp, "sync-in.tar");
471
+ await createHostTarball({
472
+ localDir: mapping.sourcePath,
473
+ archivePath,
474
+ exclude: mapping.exclude,
475
+ followSymlinks: mapping.followSymlinks,
476
+ });
477
+ const archiveBytes = (await fs.stat(archivePath)).size;
478
+ const target = mapping.targetPath;
479
+ const script = [
480
+ ...canonicalizerPreamble(shQuote(remoteDir)),
481
+ // Confine the deepest existing ancestor before `mkdir -p` runs: it follows a
482
+ // sandbox-planted symlink ancestor and would otherwise create the target dir
483
+ // OUTSIDE the root before the post-mkdir realpath check below could reject it.
484
+ `_pc_confine_ancestor ${shQuote(target)} || { echo "ESCAPE" >&2; exit 42; };`,
485
+ `mkdir -p ${shQuote(target)} || { echo "mkdir failed" >&2; exit 46; };`,
486
+ // Open-then-verify the extraction dir: `open()` walks every ancestor, so a
487
+ // post-resolve ancestor swap is caught by re-canonicalizing the pinned fd
488
+ // before extracting; `tar -C /proc/self/fd/9` then chdir's through the
489
+ // pinned inode, immune to a later path swap.
490
+ `_pc_real=$(_pc_resolve ${shQuote(target)}) || { echo "ESCAPE" >&2; exit 42; };`,
491
+ `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
492
+ `exec 9<"$_pc_real" || { echo "open failed" >&2; exit 46; };`,
493
+ `_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE" >&2; exit 42; };`,
494
+ `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
495
+ `head -c ${archiveBytes} | tar -xf - -C /proc/self/fd/9 || { echo "extract failed" >&2; exit 43; };`,
496
+ `exec 9>&-;`,
497
+ ].join("\n");
498
+ await streamFileToPodStdin({
499
+ exec,
500
+ script,
501
+ filePath: archivePath,
502
+ timeoutMs,
503
+ label: "syncIn directory transfer",
504
+ });
505
+ const filesTransferred = await countHostFiles(mapping.sourcePath, mapping.exclude);
506
+ return { filesTransferred, bytesTransferred: archiveBytes };
507
+ });
508
+ }
509
+ /**
510
+ * Execute an operation's ordered `postUploadCommands` in the pod AFTER its files
511
+ * have landed (Phase 3 / Security Conditions C1–C4 + C7). Kubernetes is a native
512
+ * provider, and once the inbound-staging router stops diverting command-bearing
513
+ * (custom-provision) assets away from the native seam, a K8s deployment receives
514
+ * these operations — so it EXECUTES them symmetrically with Daytona rather than
515
+ * silently dropping them (C7 fail-open).
516
+ *
517
+ * Commands run in array order, fail-fast: the first non-zero exit or timeout throws
518
+ * and stops the rest (C4). Each `command` is executed VERBATIM: it rides as a
519
+ * positional argument (`$1`) to a fixed wrapper, run by `sh -c "$1"`, and is never
520
+ * string-concatenated into the wrapper — so the provider adds no shell fragment of
521
+ * its own (C1/C3), exactly as the generic fallback runs `sh -c <command>`. Before
522
+ * the command runs, the wrapper re-confines the command `cwd` under the workspace
523
+ * remote dir with the same realpath + `/proc/self/fd`-pinned open used for file
524
+ * placement (C2): a `..`, absolute-escape, or symlink-escape `cwd` is rejected
525
+ * fail-closed (exit 42) before the command runs, and the pin makes the confinement
526
+ * race-free against a post-resolve ancestor swap. `cwd` is validated lexically on
527
+ * the host first; when absent it defaults to the remote dir — never a process
528
+ * default cwd.
529
+ *
530
+ * Shared by the file- and directory-mapping paths: it runs once per operation,
531
+ * after every mapping of that operation has been placed.
532
+ */
533
+ async function runPostUploadCommands(input) {
534
+ const { exec, commands, remoteDir, timeoutMs } = input;
535
+ for (const command of commands) {
536
+ const cwd = command.cwd ?? remoteDir;
537
+ // C2 lexical guard on the host before the pod ever sees the cwd.
538
+ assertConfinedSandboxPath(remoteDir, cwd, "post-upload command cwd");
539
+ // The wrapper confines `cwd` ($1) through a realpath + /proc/self/fd pin, cd's
540
+ // into the pinned inode, then execs the VERBATIM command ($2). Both `cwd` and
541
+ // the command ride as positional parameters — the wrapper interpolates NEITHER
542
+ // into its own script text, so the command runs byte-for-byte as authored.
543
+ const wrapper = [
544
+ ...canonicalizerPreamble(shQuote(remoteDir)),
545
+ `_pc_real=$(_pc_resolve "$1") || { echo "ESCAPE" >&2; exit 42; };`,
546
+ `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
547
+ `exec 9<"$_pc_real" || { echo "open failed" >&2; exit 46; };`,
548
+ `_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE" >&2; exit 42; };`,
549
+ `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
550
+ `cd /proc/self/fd/9 || { echo "cd failed" >&2; exit 46; };`,
551
+ `exec /bin/sh -c "$2" pc-post-upload;`,
552
+ ].join("\n");
553
+ const commandTimeoutMs = command.timeoutMs ?? timeoutMs;
554
+ // Positional args: $0=pc-post-upload, $1=cwd, $2=the verbatim command string.
555
+ const result = await exec(["/bin/sh", "-c", wrapper, "pc-post-upload", cwd, command.command], {
556
+ timeoutMs: commandTimeoutMs,
557
+ maxStderrBytes: SYNC_STDERR_CAP_BYTES,
558
+ });
559
+ if (result.exitCode !== 0) {
560
+ const detail = (result.stderr || "").trim();
561
+ throw new Error(`Kubernetes post-upload command failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`);
562
+ }
563
+ }
564
+ }
565
+ export async function performSyncIn(input) {
566
+ const operations = [];
567
+ for (const operation of input.operations) {
568
+ let filesTransferred = 0;
569
+ let bytesTransferred = 0;
570
+ const fileMappings = operation.files.filter((mapping) => mapping.kind === "file");
571
+ const directoryMappings = operation.files.filter((mapping) => mapping.kind === "directory");
572
+ const fileResult = await syncInFileMappings({
573
+ exec: input.exec,
574
+ mappings: fileMappings,
575
+ remoteDir: input.remoteDir,
576
+ timeoutMs: input.timeoutMs,
577
+ });
578
+ filesTransferred += fileResult.filesTransferred;
579
+ bytesTransferred += fileResult.bytesTransferred;
580
+ for (const mapping of directoryMappings) {
581
+ const dirResult = await syncInDirectoryMapping({
582
+ exec: input.exec,
583
+ mapping,
584
+ remoteDir: input.remoteDir,
585
+ timeoutMs: input.timeoutMs,
586
+ });
587
+ filesTransferred += dirResult.filesTransferred;
588
+ bytesTransferred += dirResult.bytesTransferred;
589
+ }
590
+ // Run the operation's ordered post-upload commands AFTER every file/directory
591
+ // mapping of this operation has landed (Phase 3 / C1–C4 + C7). Absent/empty →
592
+ // no extra exec, byte-identical to a pre-contract operation.
593
+ await runPostUploadCommands({
594
+ exec: input.exec,
595
+ commands: operation.postUploadCommands ?? [],
596
+ remoteDir: input.remoteDir,
597
+ timeoutMs: input.timeoutMs,
598
+ });
599
+ operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
600
+ }
601
+ return { operations };
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ // Outbound (sandbox → host)
605
+ // ---------------------------------------------------------------------------
606
+ /**
607
+ * Stream all `kind:"file"` mappings of an operation back in ONE exec. The in-pod
608
+ * script validates + snapshots each source (confinement realpath check, then a
609
+ * non-symlink regular-file re-check immediately before `cp` closes the
610
+ * validation→copy window), tars the immutable snapshots under index names, and
611
+ * streams the archive on stdout straight into a host file. The host decodes,
612
+ * extracts to a temp, and atomic-renames each file onto its host target
613
+ * (chmod-before-rename for secret modes) so an interrupted reassembly never
614
+ * truncates a target.
615
+ */
616
+ async function syncOutFileMappings(input) {
617
+ const { exec, mappings, remoteDir, timeoutMs, maxOutputBytes } = input;
618
+ if (mappings.length === 0)
619
+ return { filesTransferred: 0, bytesTransferred: 0 };
620
+ for (const mapping of mappings) {
621
+ assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
622
+ }
623
+ return withHostTempDir(async (tmp) => {
624
+ const remoteStage = path.posix.join(remoteDir, scratchName());
625
+ const indexNames = mappings.map((_, i) => String(i));
626
+ const script = [...canonicalizerPreamble(shQuote(remoteDir))];
627
+ script.push(`trap 'rm -rf ${shQuote(remoteStage)}' EXIT;`);
628
+ script.push(`mkdir -p -m 700 ${shQuote(remoteStage)} || { echo "stage mkdir failed" >&2; exit 46; };`);
629
+ mappings.forEach((mapping, i) => {
630
+ const snapshot = path.posix.join(remoteStage, indexNames[i]);
631
+ script.push(`_pc_real=$(_pc_resolve ${shQuote(mapping.sourcePath)}) || { echo "ESCAPE" >&2; exit 42; };`, `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
632
+ // Close the validation→copy TOCTOU by pinning the file to an FD and
633
+ // snapshotting THROUGH it, never re-opening by name: `open()` captures the
634
+ // inode, so a source replacement (symlink swap) after this point cannot
635
+ // redirect the copy. Re-resolving the pinned fd re-confines the TRUE inode
636
+ // we opened — if the sandbox swapped `$_pc_real` for a symlink between the
637
+ // resolve above and this open, the fd now points at that target and the
638
+ // re-confinement (or the regular-file check) rejects it before any byte is
639
+ // copied. `cp` then reads the fd's inode, immune to any further swap.
640
+ `exec 7<"$_pc_real" || { echo "REPLACED" >&2; exit 45; };`, `_pc_fd_real=$(_pc_resolve /proc/self/fd/7) || { echo "ESCAPE" >&2; exit 42; };`, `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`, `[ -f /proc/self/fd/7 ] || { echo "NOTREG" >&2; exit 45; };`, `cp -- /proc/self/fd/7 ${shQuote(snapshot)} || { echo "snapshot copy failed" >&2; exit 43; };`, `exec 7>&-;`);
641
+ });
642
+ // Tar the immutable snapshots straight to stdout (streamed to a host file).
643
+ script.push(`tar -c --no-xattrs -f - -C ${shQuote(remoteStage)} -- ${indexNames
644
+ .map(shQuote)
645
+ .join(" ")} || { echo "tar failed" >&2; exit 43; };`);
646
+ const localTar = path.join(tmp, "sync-out.tar");
647
+ await streamPodStdoutToFile({
648
+ exec,
649
+ script: script.join("\n"),
650
+ filePath: localTar,
651
+ maxOutputBytes,
652
+ timeoutMs,
653
+ label: "syncOut file transfer",
654
+ });
655
+ // Reassemble on the host: member-confine the sandbox-authored archive,
656
+ // extract to a temp, then atomic-rename each index file onto its host target.
657
+ const extractDir = path.join(tmp, "extract");
658
+ await extractHostTarball({ archivePath: localTar, localDir: extractDir });
659
+ let bytesTransferred = 0;
660
+ for (let i = 0; i < mappings.length; i += 1) {
661
+ const mapping = mappings[i];
662
+ const staged = path.join(extractDir, indexNames[i]);
663
+ await fs.mkdir(path.dirname(mapping.targetPath), { recursive: true });
664
+ if (typeof mapping.mode === "number") {
665
+ await fs.chmod(staged, mapping.mode);
666
+ }
667
+ bytesTransferred += (await fs.stat(staged)).size;
668
+ await fs.rename(staged, mapping.targetPath);
669
+ }
670
+ return { filesTransferred: mappings.length, bytesTransferred };
671
+ });
672
+ }
673
+ /**
674
+ * Stream one `kind:"directory"` mapping back in ONE exec: the in-pod script
675
+ * confines the source (realpath through `/proc/self/fd`), tars it (reproducing
676
+ * `followSymlinks` → `-h` and `exclude`, naming top-level entries so no "." self
677
+ * entry is embedded) straight to stdout, and the host streams that into a file,
678
+ * member-confines the sandbox-authored tar, and extracts into the target dir.
679
+ */
680
+ async function syncOutDirectoryMapping(input) {
681
+ const { exec, mapping, remoteDir, timeoutMs, maxOutputBytes } = input;
682
+ assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source");
683
+ return withHostTempDir(async (tmp) => {
684
+ const excludeFlags = ["._*", ...(mapping.exclude ?? [])]
685
+ .map((entry) => `--exclude ${shQuote(entry)}`)
686
+ .join(" ");
687
+ const script = [
688
+ ...canonicalizerPreamble(shQuote(remoteDir)),
689
+ // Confine the source dir through an open-then-verify pinned fd before taring.
690
+ `_pc_real=$(_pc_resolve ${shQuote(mapping.sourcePath)}) || { echo "ESCAPE" >&2; exit 42; };`,
691
+ `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
692
+ `exec 9<"$_pc_real" || { echo "open failed" >&2; exit 46; };`,
693
+ `_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE" >&2; exit 42; };`,
694
+ `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
695
+ `cd /proc/self/fd/9 || { echo "cd failed" >&2; exit 46; };`,
696
+ // Collect top-level entries (incl. dotfiles) without a "." self-entry, then
697
+ // tar with the `followSymlinks` → `-h` mapping, streaming to stdout.
698
+ "set -- *",
699
+ 'if [ "$#" -eq 1 ] && [ "$1" = "*" ] && [ ! -e "$1" ] && [ ! -L "$1" ]; then set --; fi',
700
+ 'for entry in .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue; set -- "$@" "$entry"; done',
701
+ `if [ "$#" -eq 0 ]; then dd if=/dev/zero bs=1024 count=1 2>/dev/null; ` +
702
+ `else tar -c --no-xattrs ${mapping.followSymlinks ? "-h " : ""}${excludeFlags} -f - -- "$@" || { echo "tar failed" >&2; exit 43; }; fi`,
703
+ `exec 9>&-;`,
704
+ ].join("\n");
705
+ const localTar = path.join(tmp, "sync-out.tar");
706
+ await streamPodStdoutToFile({
707
+ exec,
708
+ script,
709
+ filePath: localTar,
710
+ maxOutputBytes,
711
+ timeoutMs,
712
+ label: "syncOut directory transfer",
713
+ });
714
+ const bytesTransferred = (await fs.stat(localTar)).size;
715
+ await extractHostTarball({ archivePath: localTar, localDir: mapping.targetPath });
716
+ const filesTransferred = await countHostFiles(mapping.targetPath, mapping.exclude);
717
+ return { filesTransferred, bytesTransferred };
718
+ });
719
+ }
720
+ export async function performSyncOut(input) {
721
+ const maxOutputBytes = input.maxOutputBytes ?? MAX_SYNC_OUTPUT_BYTES;
722
+ const operations = [];
723
+ for (const operation of input.operations) {
724
+ let filesTransferred = 0;
725
+ let bytesTransferred = 0;
726
+ const fileMappings = operation.files.filter((mapping) => mapping.kind === "file");
727
+ const directoryMappings = operation.files.filter((mapping) => mapping.kind === "directory");
728
+ const fileResult = await syncOutFileMappings({
729
+ exec: input.exec,
730
+ mappings: fileMappings,
731
+ remoteDir: input.remoteDir,
732
+ timeoutMs: input.timeoutMs,
733
+ maxOutputBytes,
734
+ });
735
+ filesTransferred += fileResult.filesTransferred;
736
+ bytesTransferred += fileResult.bytesTransferred;
737
+ for (const mapping of directoryMappings) {
738
+ const dirResult = await syncOutDirectoryMapping({
739
+ exec: input.exec,
740
+ mapping,
741
+ remoteDir: input.remoteDir,
742
+ timeoutMs: input.timeoutMs,
743
+ maxOutputBytes,
744
+ });
745
+ filesTransferred += dirResult.filesTransferred;
746
+ bytesTransferred += dirResult.bytesTransferred;
747
+ }
748
+ operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
749
+ }
750
+ return { operations };
751
+ }
752
+ //# sourceMappingURL=file-sync.js.map