@love-moon/conductor-cli 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/bin/conductor-config.js +8 -2
- package/bin/conductor-remote.js +93 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +307 -24
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +198 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +126 -154
- package/src/remote-file-handlers.js +703 -0
|
@@ -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
|
+
}
|