@love-moon/conductor-cli 0.11.2 → 0.13.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.
@@ -0,0 +1,703 @@
1
+ import fs from "node:fs";
2
+ import { promises as fsp } from "node:fs";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { Readable, Transform } from "node:stream";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+
9
+ import { resolveUserHome } from "./conductor-paths.js";
10
+ import { isPathInsideGuestRoot } from "./guest-daemon.js";
11
+
12
+ const VALID_ACTIONS = new Set(["pull", "push", "stat"]);
13
+ const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024;
14
+ /**
15
+ * One request carries at most this much.
16
+ *
17
+ * Chunking is what makes a transfer resumable, and it is also what keeps a
18
+ * 1 GiB limit safe: a single request is bounded in duration and in how much any
19
+ * proxy in the path has to hold, so a failure costs one chunk instead of the
20
+ * whole file. Mirrors `cli/src/remote/cp.js` so both ends behave the same.
21
+ */
22
+ const DEFAULT_CHUNK_BYTES = 32 * 1024 * 1024;
23
+ /**
24
+ * Per *request*, not per transfer: a 1 GiB file legitimately takes longer than
25
+ * any single deadline worth setting, but a chunk that has not moved in five
26
+ * minutes is stuck and the retry loop should be told so.
27
+ */
28
+ const REQUEST_TIMEOUT_MS = 300_000;
29
+ /** Four tries, 500 ms apart and doubling: ~3.5 s of blip absorbed, then stop. */
30
+ const RETRY_ATTEMPTS = 4;
31
+ const RETRY_BASE_DELAY_MS = 500;
32
+ /**
33
+ * Byte streams are far heavier than an exec run, so this cap is much tighter
34
+ * than `MAX_CONCURRENT_RUNS`. It bounds this daemon's disk and socket use; the
35
+ * server enforces its own per-user cap independently.
36
+ */
37
+ const MAX_CONCURRENT_TRANSFERS = 4;
38
+ const NUL = String.fromCharCode(0);
39
+
40
+ export const REMOTE_FILE_CAPABILITY = "remote_file";
41
+
42
+ /**
43
+ * Move single files between this daemon's disk and the account that owns it,
44
+ * with the Web backend's staging area as the only hop (RFC 0037).
45
+ *
46
+ * Bytes never touch the agent WebSocket: the control envelope arrives over WS
47
+ * and the daemon then opens an ordinary HTTP stream to the backend, so
48
+ * back-pressure is TCP's problem and a big transfer cannot head-of-line block
49
+ * `stop_task` or `terminal_input`.
50
+ *
51
+ * Transfers are chunked and resumable in both directions: uploads send ranged
52
+ * PUTs and follow the server's `receivedBytes`, downloads resume from the size
53
+ * of the local `.part`. Resume is per call — a blip of a few seconds is
54
+ * invisible, but a failed call still starts over (RFC 0037 deliberately leaves
55
+ * cross-call resume out).
56
+ *
57
+ * Like `remote_exec` this is not a new trust boundary — anyone who can ask for
58
+ * a file here can already ask for `bash -lc 'base64 file'`. It is a separate
59
+ * capability purely so it can be negotiated (an old daemon silently drops an
60
+ * event it does not know, which would strand the caller until timeout) and so
61
+ * a host can decline file transfer while keeping exec.
62
+ *
63
+ * @param {object} opts
64
+ * @param {import("@love-moon/conductor-sdk").ConductorConfig} opts.config source of `backendUrl`/`agentToken`
65
+ * @param {string} opts.agentHost this daemon's name, sent as `X-Conductor-Host`
66
+ * @param {typeof fetch} [opts.fetchImpl]
67
+ * @param {number} [opts.maxBytes]
68
+ * @param {number} [opts.chunkBytes]
69
+ * @param {(ms:number)=>Promise<unknown>} [opts.sleep] backoff hook, injectable for tests
70
+ * @param {string|null} [opts.guestRoot] when set, paths outside it are refused
71
+ */
72
+ export function createRemoteFileHandlers(opts = {}) {
73
+ const config = opts.config || null;
74
+ const agentHost = opts.agentHost || "";
75
+ const fetchImpl = opts.fetchImpl || fetch;
76
+ const maxBytes = resolveRemoteFileMaxBytes(opts.maxBytes);
77
+ const chunkBytes = resolveRemoteChunkBytes(opts.chunkBytes);
78
+ const sleep = typeof opts.sleep === "function" ? opts.sleep : delay;
79
+ const guestRoot = opts.guestRoot || null;
80
+ // One controller for the whole daemon: `abortAll()` on shutdown tears down
81
+ // every in-flight stream instead of letting them hold the process open.
82
+ const shutdownController = new AbortController();
83
+ let activeTransfers = 0;
84
+
85
+ function requestSignal() {
86
+ return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), shutdownController.signal]);
87
+ }
88
+
89
+ /**
90
+ * Retry the transient half of the failure space: a dropped connection, a
91
+ * timed-out chunk, a 5xx. Protocol violations, permanent HTTP statuses and a
92
+ * daemon shutdown are handed straight back, because retrying them only burns
93
+ * the caller's deadline.
94
+ */
95
+ async function withRetry(run) {
96
+ let lastError;
97
+ for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt += 1) {
98
+ try {
99
+ return await run(attempt);
100
+ } catch (error) {
101
+ lastError = error;
102
+ if (error?.permanent || shutdownController.signal.aborted) throw error;
103
+ if (attempt === RETRY_ATTEMPTS - 1) break;
104
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
105
+ }
106
+ }
107
+ throw lastError;
108
+ }
109
+
110
+ /**
111
+ * Resolve a caller-supplied path the same way exec resolves a workspace: the
112
+ * owner is not fenced off from their own machine. The guest root is the one
113
+ * exception, and it is a lexical check (no `realpath`), so it prevents
114
+ * mistakes rather than a determined escape — same caveat as everywhere else
115
+ * in this repo.
116
+ */
117
+ function resolveRemotePath(value) {
118
+ const resolved = normalizeRemotePath(value);
119
+ if (guestRoot && !isPathInsideGuestRoot(resolved, guestRoot)) {
120
+ throw new Error(`path is outside this daemon's shared root: ${resolved}`);
121
+ }
122
+ return resolved;
123
+ }
124
+
125
+ async function acquireSlot() {
126
+ if (activeTransfers >= MAX_CONCURRENT_TRANSFERS) {
127
+ throw new Error(
128
+ `too many concurrent remote file transfers on this daemon (${activeTransfers}/${MAX_CONCURRENT_TRANSFERS}); retry when one finishes`,
129
+ );
130
+ }
131
+ activeTransfers += 1;
132
+ }
133
+
134
+ function releaseSlot() {
135
+ activeTransfers = Math.max(0, activeTransfers - 1);
136
+ }
137
+
138
+ function backendUrl(transferId) {
139
+ if (!config?.backendUrl) {
140
+ throw new Error("this daemon has no backend URL configured");
141
+ }
142
+ return new URL(
143
+ `/api/agent/files/${encodeURIComponent(transferId)}/content`,
144
+ config.backendUrl,
145
+ );
146
+ }
147
+
148
+ function transferHeaders(transferToken, extra = {}) {
149
+ return {
150
+ Authorization: `Bearer ${config?.agentToken || ""}`,
151
+ "X-Conductor-Host": agentHost,
152
+ "X-Conductor-Transfer-Token": transferToken,
153
+ ...extra,
154
+ };
155
+ }
156
+
157
+ /** Download the staged blob and land it atomically at `remotePath`. */
158
+ async function pull(args = {}) {
159
+ const transferId = requireId(args.transferId ?? args.transfer_id, "transferId");
160
+ const transferToken = requireId(args.transferToken ?? args.transfer_token, "transferToken");
161
+ const sha256 = normalizeSha256(args.sha256);
162
+ if (!sha256) {
163
+ throw new Error("pull requires a 64-character hex `sha256`");
164
+ }
165
+ const sizeBytes = normalizeSizeBytes(args.sizeBytes ?? args.size_bytes, maxBytes);
166
+ const mode = normalizeMode(args.mode);
167
+ let dest = resolveRemotePath(args.remotePath ?? args.remote_path);
168
+
169
+ // scp semantics: copying onto a directory means "into that directory".
170
+ const destStat = await statOrNull(dest);
171
+ if (destStat?.isDirectory()) {
172
+ const name = normalizeName(args.name) || path.basename(dest);
173
+ if (!name || name === "." || name === "..") {
174
+ throw new Error(`cannot infer a file name for directory destination: ${dest}`);
175
+ }
176
+ dest = resolveRemotePath(path.join(dest, name));
177
+ }
178
+
179
+ await acquireSlot();
180
+ try {
181
+ // Already have exactly this content? Then the transfer is a no-op; this
182
+ // is what makes a retried `remote cp` cheap.
183
+ const existing = await statOrNull(dest);
184
+ if (existing?.isFile() && existing.size === sizeBytes && (await hashFile(dest)) === sha256) {
185
+ if (mode !== null) await fsp.chmod(dest, mode);
186
+ return { transferId, bytesWritten: sizeBytes, path: dest };
187
+ }
188
+
189
+ // Never truncate the destination in place: a half-written transfer would
190
+ // otherwise corrupt a file that was perfectly good before.
191
+ const temporaryPath = remotePartPath(dest);
192
+ try {
193
+ await withRetry(async () => {
194
+ // The `.part`'s size *is* the offset — no side-car state to keep in
195
+ // sync, and it is still right after a crash mid-write.
196
+ const start = await fileSizeOrZero(temporaryPath);
197
+ const signal = requestSignal();
198
+ const response = await fetchImpl(backendUrl(transferId), {
199
+ headers: transferHeaders(transferToken, {
200
+ Accept: "application/octet-stream",
201
+ ...(start > 0 ? { Range: `bytes=${start}-` } : {}),
202
+ }),
203
+ signal,
204
+ });
205
+
206
+ // 416 is not a failure: it means the `.part` is already at (or past)
207
+ // the full size, so there is nothing left to fetch. Verification
208
+ // below decides whether what we hold is actually good.
209
+ if (response?.status === 416) return;
210
+ if (!response?.ok || !response.body) {
211
+ throw httpError("download", response?.status);
212
+ }
213
+
214
+ // A server that ignored our Range restarts the file, so the partial
215
+ // is stale: appending to it would interleave two copies.
216
+ let writeFrom = start;
217
+ if (response.status === 206) {
218
+ const range = parseContentRange(response.headers?.get?.("content-range"));
219
+ // Unverifiable placement is not "probably fine": appending bytes on
220
+ // faith is exactly how a resumed download corrupts itself silently.
221
+ if (!range) {
222
+ throw protocolError("download returned 206 without a usable Content-Range");
223
+ }
224
+ if (range.start !== start) {
225
+ throw protocolError(
226
+ `download resumed at the wrong offset: server sent from byte ${range.start}, asked for ${start}`,
227
+ );
228
+ }
229
+ if (range.total !== null && range.total !== sizeBytes) {
230
+ throw protocolError(
231
+ `download Content-Range mismatch: server declared ${range.total} bytes total, expected ${sizeBytes}`,
232
+ );
233
+ }
234
+ } else {
235
+ writeFrom = 0;
236
+ }
237
+
238
+ const expected = sizeBytes - writeFrom;
239
+ const declaredLength = parseContentLength(response.headers?.get?.("content-length"));
240
+ if (declaredLength !== null && declaredLength !== expected) {
241
+ throw protocolError(
242
+ `download Content-Length mismatch: server declared ${declaredLength}, expected ${expected}`,
243
+ );
244
+ }
245
+
246
+ let received = 0;
247
+ const verifier = new Transform({
248
+ transform(chunk, _encoding, callback) {
249
+ received += chunk.length;
250
+ // Stop at the first byte past the declared size instead of
251
+ // writing the whole overrun to disk and complaining afterwards.
252
+ if (writeFrom + received > sizeBytes || writeFrom + received > maxBytes) {
253
+ callback(
254
+ protocolError(`download exceeded the declared size of ${sizeBytes} bytes`),
255
+ );
256
+ return;
257
+ }
258
+ callback(null, chunk);
259
+ },
260
+ });
261
+ await pipeline(
262
+ Readable.fromWeb(response.body),
263
+ verifier,
264
+ // `r+` with an explicit start appends exactly where the previous
265
+ // attempt stopped; `w` truncates a stale partial.
266
+ writeFrom > 0
267
+ ? fs.createWriteStream(temporaryPath, { flags: "r+", start: writeFrom })
268
+ : fs.createWriteStream(temporaryPath, { flags: "w", mode: 0o600 }),
269
+ { signal },
270
+ );
271
+
272
+ const landed = await fileSizeOrZero(temporaryPath);
273
+ if (landed < sizeBytes) {
274
+ // Ran short: a retry resumes from the new offset rather than
275
+ // starting the whole file again.
276
+ throw new Error(`download ended early at ${landed} of ${sizeBytes} bytes`);
277
+ }
278
+ });
279
+
280
+ const landed = await fileSizeOrZero(temporaryPath);
281
+ if (landed !== sizeBytes) {
282
+ throw new Error(`download size mismatch: got ${landed} bytes, expected ${sizeBytes}`);
283
+ }
284
+ // Hash the assembled file, not the stream: a resumed download never saw
285
+ // the earlier bytes go past, so a streaming digest would only cover the
286
+ // last range and would happily accept a corrupt prefix.
287
+ const digest = await hashFile(temporaryPath, requestSignal());
288
+ if (digest !== sha256) {
289
+ throw new Error(`download SHA-256 mismatch: got ${digest}, expected ${sha256}`);
290
+ }
291
+ // chmod before the rename so the file is never visible at its final
292
+ // path with the wrong permissions.
293
+ if (mode !== null) await fsp.chmod(temporaryPath, mode);
294
+ await fsp.rename(temporaryPath, dest);
295
+ return { transferId, bytesWritten: landed, path: dest };
296
+ } catch (error) {
297
+ await fsp.rm(temporaryPath, { force: true }).catch(() => {});
298
+ throw error;
299
+ }
300
+ } finally {
301
+ releaseSlot();
302
+ }
303
+ }
304
+
305
+ /** Stream a local file up to the backend's staging area, one chunk at a time. */
306
+ async function push(args = {}) {
307
+ const transferId = requireId(args.transferId ?? args.transfer_id, "transferId");
308
+ const transferToken = requireId(args.transferToken ?? args.transfer_token, "transferToken");
309
+ const source = resolveRemotePath(args.remotePath ?? args.remote_path);
310
+
311
+ const stat = await statOrNull(source);
312
+ if (!stat) {
313
+ throw new Error(`no such file: ${source}`);
314
+ }
315
+ if (stat.isDirectory()) {
316
+ throw new Error(`${source} is a directory (recursive transfer is not supported)`);
317
+ }
318
+ if (!stat.isFile()) {
319
+ throw new Error(`${source} is not a regular file`);
320
+ }
321
+ if (stat.size > maxBytes) {
322
+ throw new Error(`${source} is ${stat.size} bytes, over the ${maxBytes} byte limit`);
323
+ }
324
+
325
+ await acquireSlot();
326
+ try {
327
+ // Hash before sending so the server can reject a corrupted body, and so
328
+ // the caller can compare end to end. This costs one extra read of the
329
+ // file; the alternative (hash while uploading) cannot be sent in the
330
+ // header the server verifies against.
331
+ const sha256 = await hashFile(source, requestSignal());
332
+ const totalBytes = stat.size;
333
+ let offset = 0;
334
+ let resyncs = 0;
335
+ let final = null;
336
+
337
+ // A zero-byte file still needs one request for the server to complete it.
338
+ while (offset < totalBytes || (totalBytes === 0 && !final)) {
339
+ const outcome = await withRetry(async () => {
340
+ const start = offset;
341
+ const end = Math.min(start + chunkBytes, totalBytes) - 1;
342
+ const signal = requestSignal();
343
+ const response = await fetchImpl(backendUrl(transferId), {
344
+ method: "PUT",
345
+ headers: transferHeaders(transferToken, {
346
+ Accept: "application/json",
347
+ "Content-Type": "application/octet-stream",
348
+ "Content-Length": String(totalBytes === 0 ? 0 : end - start + 1),
349
+ // An empty file has no range to describe; no header means "the
350
+ // whole body, from byte 0".
351
+ ...(totalBytes === 0
352
+ ? {}
353
+ : { "Content-Range": `bytes ${start}-${end}/${totalBytes}` }),
354
+ }),
355
+ // Stream the range: a `readFile` of a 1 GiB file would put the
356
+ // whole thing in the daemon's heap.
357
+ body: Readable.toWeb(
358
+ totalBytes === 0
359
+ ? Readable.from([])
360
+ : fs.createReadStream(source, { start, end, signal }),
361
+ ),
362
+ duplex: "half",
363
+ signal,
364
+ });
365
+
366
+ // The server owns the truth about how much it holds: a 409 carries
367
+ // the real offset, and seeking there covers both a retried chunk and
368
+ // one the server only half accepted.
369
+ if (response?.status === 409) {
370
+ const received = toNonNegativeInt((await readJsonBody(response))?.receivedBytes);
371
+ if (received === null || received === start) {
372
+ throw httpError("upload", 409);
373
+ }
374
+ return { resyncTo: received };
375
+ }
376
+ if (!response?.ok) {
377
+ throw httpError("upload", response?.status);
378
+ }
379
+ return { payload: await readJsonBody(response), end };
380
+ });
381
+
382
+ if (outcome?.resyncTo !== undefined) {
383
+ resyncs += 1;
384
+ if (resyncs > RETRY_ATTEMPTS) {
385
+ throw new Error(
386
+ `upload kept being redirected to a different offset (${resyncs} times); giving up`,
387
+ );
388
+ }
389
+ offset = outcome.resyncTo;
390
+ continue;
391
+ }
392
+
393
+ final = outcome.payload ?? {};
394
+ const next = toNonNegativeInt(final.receivedBytes) ?? outcome.end + 1;
395
+ if (totalBytes > 0 && next <= offset) {
396
+ // Without this the loop would re-send the same chunk forever.
397
+ throw new Error(`upload made no progress at byte ${offset} of ${totalBytes}`);
398
+ }
399
+ offset = next;
400
+ if (final.complete || totalBytes === 0) break;
401
+ }
402
+
403
+ return {
404
+ transferId,
405
+ sizeBytes: totalBytes,
406
+ sha256,
407
+ mode: stat.mode & 0o777,
408
+ name: path.basename(source),
409
+ };
410
+ } finally {
411
+ releaseSlot();
412
+ }
413
+ }
414
+
415
+ /** Probe a path. A missing path is an answer, not an error. */
416
+ async function stat(args = {}) {
417
+ const target = resolveRemotePath(args.remotePath ?? args.remote_path);
418
+ const info = await statOrNull(target);
419
+ if (!info) {
420
+ return {
421
+ path: target,
422
+ exists: false,
423
+ isFile: false,
424
+ isDirectory: false,
425
+ sizeBytes: null,
426
+ mode: null,
427
+ mtime: null,
428
+ };
429
+ }
430
+ return {
431
+ path: target,
432
+ exists: true,
433
+ isFile: info.isFile(),
434
+ isDirectory: info.isDirectory(),
435
+ sizeBytes: info.size,
436
+ mode: info.mode & 0o777,
437
+ mtime: new Date(info.mtimeMs).toISOString(),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Run a single action and return a `result` object, never throwing.
443
+ * @param {{action:string,args?:object}} payload
444
+ */
445
+ async function dispatch(payload) {
446
+ const action = payload?.action;
447
+ if (!VALID_ACTIONS.has(action)) {
448
+ return { error: `unknown action: ${action}` };
449
+ }
450
+ try {
451
+ switch (action) {
452
+ case "pull":
453
+ return { result: await pull(payload?.args ?? {}) };
454
+ case "push":
455
+ return { result: await push(payload?.args ?? {}) };
456
+ case "stat":
457
+ return { result: await stat(payload?.args ?? {}) };
458
+ default:
459
+ return { error: `unhandled action: ${action}` };
460
+ }
461
+ } catch (err) {
462
+ return { error: errMsg(err) };
463
+ }
464
+ }
465
+
466
+ function abortAll() {
467
+ shutdownController.abort(new Error("remote file transfers closed"));
468
+ }
469
+
470
+ return { dispatch, abortAll, maxBytes, chunkBytes, guestRoot };
471
+ }
472
+
473
+ /**
474
+ * @param {object} client
475
+ * @param {ReturnType<typeof createRemoteFileHandlers>} handlers
476
+ * @param {object} payload
477
+ */
478
+ export async function handleRemoteFileRequest(client, handlers, payload) {
479
+ const requestId = payload?.request_id ? String(payload.request_id) : "";
480
+ const action = payload?.action ? String(payload.action) : "";
481
+ if (!requestId) {
482
+ return { error: "missing request_id" };
483
+ }
484
+
485
+ const response = await handlers.dispatch({
486
+ action,
487
+ args: payload?.args && typeof payload.args === "object" ? payload.args : {},
488
+ });
489
+
490
+ const outgoing = {
491
+ type: "remote_file_response",
492
+ payload: {
493
+ request_id: requestId,
494
+ action,
495
+ ...(response?.error ? { error: response.error } : { result: response?.result }),
496
+ },
497
+ };
498
+ await client.sendJson(outgoing).catch(() => {});
499
+ return response;
500
+ }
501
+
502
+ /**
503
+ * Resolution order: explicit option, then `CONDUCTOR_REMOTE_FILE_MAX_BYTES`,
504
+ * then the RFC 0037 default of 1 GiB.
505
+ */
506
+ export function resolveRemoteFileMaxBytes(value) {
507
+ const explicit = toPositiveInt(value);
508
+ if (explicit) return explicit;
509
+ const fromEnv = toPositiveInt(process.env.CONDUCTOR_REMOTE_FILE_MAX_BYTES);
510
+ if (fromEnv) return fromEnv;
511
+ return DEFAULT_MAX_BYTES;
512
+ }
513
+
514
+ /**
515
+ * How much one request carries. Overridable so a flaky link can shrink it —
516
+ * and so tests can exercise the multi-chunk path without moving 32 MiB around.
517
+ */
518
+ export function resolveRemoteChunkBytes(value) {
519
+ const explicit = toPositiveInt(value);
520
+ if (explicit) return explicit;
521
+ const fromEnv = toPositiveInt(process.env.CONDUCTOR_REMOTE_CHUNK_BYTES);
522
+ if (fromEnv) return fromEnv;
523
+ return DEFAULT_CHUNK_BYTES;
524
+ }
525
+
526
+ /**
527
+ * Where a download is assembled before it is renamed into place.
528
+ *
529
+ * The name is derived, not random, because the file's own size is the resume
530
+ * offset: a retry inside this call has to find the same partial again. Same
531
+ * shape as the CLI side (`cli/src/remote/cp.js`), and the pid keeps two
532
+ * daemons on one host off each other's partials.
533
+ */
534
+ export function remotePartPath(destination) {
535
+ return `${destination}.${process.pid}.part`;
536
+ }
537
+
538
+ /** Expand `~`, resolve, and refuse anything that is not a usable path string. */
539
+ export function normalizeRemotePath(value) {
540
+ if (typeof value !== "string") {
541
+ throw new Error("this action requires a `remotePath` string");
542
+ }
543
+ const raw = value.trim();
544
+ if (!raw) {
545
+ throw new Error("this action requires a `remotePath` string");
546
+ }
547
+ if (raw.includes(NUL)) {
548
+ throw new Error("`remotePath` must not contain NUL bytes");
549
+ }
550
+ return path.resolve(expandHome(raw));
551
+ }
552
+
553
+ export function normalizeSha256(value) {
554
+ const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
555
+ return /^[a-f0-9]{64}$/.test(normalized) ? normalized : "";
556
+ }
557
+
558
+ function normalizeSizeBytes(value, maxBytes) {
559
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
560
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
561
+ throw new Error("`sizeBytes` must be a non-negative integer");
562
+ }
563
+ if (parsed > maxBytes) {
564
+ throw new Error(`\`sizeBytes\` ${parsed} is over this daemon's ${maxBytes} byte limit`);
565
+ }
566
+ return parsed;
567
+ }
568
+
569
+ function normalizeMode(value) {
570
+ if (value === undefined || value === null || value === "") {
571
+ return null;
572
+ }
573
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 8);
574
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
575
+ throw new Error("`mode` must be a file mode integer");
576
+ }
577
+ return parsed & 0o777;
578
+ }
579
+
580
+ function normalizeName(value) {
581
+ if (typeof value !== "string") return "";
582
+ const trimmed = value.trim();
583
+ if (!trimmed || trimmed.includes(NUL)) return "";
584
+ // Only ever a single segment: the caller names a file, never a subtree.
585
+ return path.basename(trimmed);
586
+ }
587
+
588
+ function requireId(value, field) {
589
+ const normalized = typeof value === "string" ? value.trim() : "";
590
+ if (!normalized || normalized.includes(NUL)) {
591
+ throw new Error(`this action requires a \`${field}\` string`);
592
+ }
593
+ return normalized;
594
+ }
595
+
596
+ function expandHome(raw) {
597
+ if (raw === "~") {
598
+ return resolveUserHome();
599
+ }
600
+ if (raw.startsWith("~/")) {
601
+ return path.join(resolveUserHome(), raw.slice(2));
602
+ }
603
+ return raw;
604
+ }
605
+
606
+ async function statOrNull(target) {
607
+ try {
608
+ return await fsp.stat(target);
609
+ } catch (error) {
610
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
611
+ return null;
612
+ }
613
+ throw error;
614
+ }
615
+ }
616
+
617
+ /** A missing partial is simply an offset of zero. */
618
+ async function fileSizeOrZero(target) {
619
+ const info = await statOrNull(target);
620
+ return info?.isFile() ? info.size : 0;
621
+ }
622
+
623
+ async function hashFile(filePath, signal) {
624
+ const hash = createHash("sha256");
625
+ for await (const chunk of fs.createReadStream(filePath, signal ? { signal } : {})) {
626
+ hash.update(chunk);
627
+ }
628
+ return hash.digest("hex");
629
+ }
630
+
631
+ function toPositiveInt(value) {
632
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
633
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
634
+ }
635
+
636
+ function toNonNegativeInt(value) {
637
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
638
+ }
639
+
640
+ function parseContentLength(raw) {
641
+ if (raw === null || raw === undefined || raw === "") return null;
642
+ const parsed = Number(raw);
643
+ return Number.isFinite(parsed) ? parsed : null;
644
+ }
645
+
646
+ /** `bytes 10-19/20` -> `{ start: 10, end: 19, total: 20 }`. */
647
+ function parseContentRange(raw) {
648
+ const match = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(raw ?? "").trim());
649
+ if (!match) return null;
650
+ return {
651
+ start: Number(match[1]),
652
+ end: Number(match[2]),
653
+ total: match[3] === "*" ? null : Number(match[3]),
654
+ };
655
+ }
656
+
657
+ /** A JSON body, or `null` for anything that is not one. */
658
+ async function readJsonBody(response) {
659
+ try {
660
+ if (typeof response?.json === "function") {
661
+ return await response.json();
662
+ }
663
+ if (typeof response?.text === "function") {
664
+ const text = await response.text();
665
+ return text ? JSON.parse(text) : null;
666
+ }
667
+ } catch {
668
+ return null;
669
+ }
670
+ return null;
671
+ }
672
+
673
+ /**
674
+ * The server broke the protocol (wrong length, wrong range, more bytes than it
675
+ * declared). Retrying cannot fix a server that is lying, and continuing would
676
+ * mean writing bytes we cannot account for.
677
+ */
678
+ function protocolError(message) {
679
+ const error = new Error(message);
680
+ error.permanent = true;
681
+ return error;
682
+ }
683
+
684
+ /**
685
+ * Which failures are worth another try. Network-level failures carry no status
686
+ * at all — those are exactly the blips resume exists for. Among HTTP statuses
687
+ * only 5xx, 408 and 429 can plausibly succeed on a second attempt; retrying a
688
+ * 403, a 404, a 410 or a 413 just burns the caller's deadline.
689
+ */
690
+ function httpError(what, status) {
691
+ const retryable =
692
+ status === undefined || status === null || status >= 500 || status === 408 || status === 429;
693
+ const error = new Error(
694
+ `${what} failed with HTTP ${status ?? "?"}${retryable ? "" : " (permanent)"}`,
695
+ );
696
+ error.permanent = !retryable;
697
+ error.status = status ?? null;
698
+ return error;
699
+ }
700
+
701
+ function errMsg(err) {
702
+ return err?.message ?? String(err);
703
+ }