better-dsh 0.3.0-e → 0.3.0-g

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.
@@ -1,19 +1,971 @@
1
1
  import { t as Schema } from "../lib-C8_jXvwA.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
- import { randomBytes } from "node:crypto";
4
- import { join } from "node:path";
3
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { constants, createReadStream, createWriteStream, mkdirSync, statSync } from "node:fs";
5
+ import { dirname, isAbsolute, join, posix, resolve } from "node:path";
5
6
  import { spawn } from "node:child_process";
6
- import { homedir } from "node:os";
7
+ import { homedir, tmpdir } from "node:os";
7
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
- import { readFile } from "node:fs/promises";
9
+ import { lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink } from "node:fs/promises";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { Readable } from "node:stream";
12
+ import { createConnection, createServer } from "node:net";
13
+ import { EventEmitter } from "node:events";
9
14
 
10
- //#region src/remote/oneshot.ts
11
- /** 单流硬收集帽(4M UTF-16 code units,ASCII≈4MB):防失控输出吃内存;模型面截断由上层 tailWindow 负责。 */
12
- const HARD_CAP = 4e6;
13
- /** One-shot:子进程退出码即收尾(spec §五.1),stdout/stderr 分离保留。 */
14
- async function runOneShot(argv, opts = {}) {
15
+ //#region src/remote/archive.ts
16
+ const FILE_FRAME_BYTES = 64 * 1024;
17
+ const FILE_MAX_ENTRIES = 1e4;
18
+ const FILE_MAX_METADATA = 8 * 1024 * 1024;
19
+ const order = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
20
+ function checkRelative(p) {
21
+ if (typeof p !== "string" || Buffer.byteLength(p) > 4096 || p.includes("\0") || p !== "" && (p.startsWith("/") || p.split("/").some((s) => !s || s === "." || s === ".."))) throw new Error("unsafe archive relative path");
22
+ }
23
+ function checkAbsolute(p) {
24
+ if (typeof p !== "string" || !p.startsWith("/") || p.includes("\0") || p.split("/").includes("..")) throw new Error("file endpoint requires an absolute path without traversal or NUL");
25
+ }
26
+ function checkSelection(selection) {
27
+ if (selection.length > FILE_MAX_ENTRIES || Buffer.byteLength(JSON.stringify(selection)) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
28
+ const seen = /* @__PURE__ */ new Set();
29
+ for (const p of selection) {
30
+ checkRelative(p);
31
+ if (seen.has(p)) throw new Error("duplicate selection path");
32
+ seen.add(p);
33
+ }
34
+ }
35
+ function checkLink(e) {
36
+ const link = e.link;
37
+ if (e.p === "" || typeof link !== "string" || !link || link.includes("\0") || link.startsWith("/") || Buffer.byteLength(link) > 4096) throw new Error("unsafe archive symlink");
38
+ const target = posix.normalize(posix.join(posix.dirname(e.p), link));
39
+ if (target === ".." || target.startsWith("../")) throw new Error("unsafe archive symlink escape");
40
+ }
41
+ /** Conservative effective resolution: links may traverse only members of this incoming tree.
42
+ * Preserved destination extras cannot supply a symlink target or any of its ancestors.
43
+ */
44
+ function checkTreeLinks(entries) {
45
+ const incoming = new Map(entries.map((e) => [e.p, e]));
46
+ for (const link of entries.filter((e) => e.t === "l")) {
47
+ const stack = posix.dirname(link.p) === "." ? [] : posix.dirname(link.p).split("/");
48
+ let pending = link.link.split("/"), hops = 0;
49
+ while (pending.length) {
50
+ const part = pending.shift();
51
+ if (!part || part === ".") continue;
52
+ if (part === "..") {
53
+ if (!stack.length) throw new Error("unsafe symlink chain escape");
54
+ stack.pop();
55
+ continue;
56
+ }
57
+ const entry = incoming.get([...stack, part].join("/"));
58
+ if (!entry) throw new Error("unsafe symlink target outside incoming tree (destination aliases are not followed)");
59
+ if (entry.t === "l") {
60
+ if (++hops > 40) throw new Error("unsafe symlink cycle or chain limit");
61
+ pending = [...entry.link.split("/"), ...pending];
62
+ } else {
63
+ if (pending.length && entry.t !== "d") throw new Error("unsafe symlink non-directory target ancestor");
64
+ stack.push(part);
65
+ }
66
+ }
67
+ }
68
+ }
69
+ function header(e) {
70
+ const b = Buffer.from(JSON.stringify(e)), n = Buffer.alloc(4);
71
+ n.writeUInt32BE(b.length);
72
+ return Buffer.concat([n, b]);
73
+ }
74
+ function manifestDigest(entries) {
75
+ const hash = createHash("sha256");
76
+ let bytes = 0;
77
+ for (const e of [...entries].sort((a, b) => order(a.p, b.p))) {
78
+ hash.update(header([
79
+ e.p,
80
+ e.t,
81
+ e.n,
82
+ e.t === "f" ? e.hash : e.t === "l" ? e.link : ""
83
+ ]));
84
+ if (e.t === "f") bytes += e.n;
85
+ }
86
+ return {
87
+ sha256: hash.digest("hex"),
88
+ bytes,
89
+ entries: entries.length
90
+ };
91
+ }
92
+ /** Reject even existing parent symlinks; leaf may be an explicitly copied symlink. */
93
+ async function ancestors(path$1, includeLeaf = false) {
94
+ checkAbsolute(path$1);
95
+ const parts = resolve(path$1).split("/").filter(Boolean);
96
+ for (let i = 1; i <= parts.length - (includeLeaf ? 0 : 1); i++) try {
97
+ const st = await lstat("/" + parts.slice(0, i).join("/"));
98
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error("unsafe destination/source ancestor: symlink or non-directory");
99
+ } catch (e) {
100
+ if (e.code !== "ENOENT") throw e;
101
+ }
102
+ }
103
+ async function safeRead(path$1, signal) {
104
+ return (await open(path$1, constants.O_RDONLY | constants.O_NOFOLLOW)).createReadStream({
105
+ autoClose: true,
106
+ highWaterMark: FILE_FRAME_BYTES,
107
+ signal
108
+ });
109
+ }
110
+ async function entryAt(root, p, signal) {
111
+ signal.throwIfAborted();
112
+ const path$1 = p ? join(root, p) : root;
113
+ await ancestors(path$1);
114
+ const st = await lstat(path$1);
115
+ if (st.isDirectory()) return {
116
+ p,
117
+ t: "d",
118
+ n: 0
119
+ };
120
+ if (st.isSymbolicLink()) {
121
+ const e = {
122
+ p,
123
+ t: "l",
124
+ n: 0,
125
+ link: await readlink(path$1)
126
+ };
127
+ checkLink(e);
128
+ return e;
129
+ }
130
+ if (!st.isFile()) throw new Error("unsupported file type (only files, directories and safe relative symlinks)");
131
+ const hash = createHash("sha256");
132
+ for await (const chunk of await safeRead(path$1, signal)) hash.update(chunk);
133
+ return {
134
+ p,
135
+ t: "f",
136
+ n: st.size,
137
+ hash: hash.digest("hex")
138
+ };
139
+ }
140
+ async function tree(root, signal, selection) {
141
+ checkAbsolute(root);
142
+ const entries = [];
143
+ const visit = async (p) => {
144
+ const e = await entryAt(root, p, signal);
145
+ entries.push(e);
146
+ if (entries.length > FILE_MAX_ENTRIES) throw new Error("archive entry limit exceeded");
147
+ if (e.t === "d") for (const name$1 of (await readdir(p ? join(root, p) : root)).sort(order)) await visit(p ? `${p}/${name$1}` : name$1);
148
+ };
149
+ if (selection) {
150
+ checkSelection(selection);
151
+ for (const p of selection) entries.push(await entryAt(root, p, signal));
152
+ } else await visit("");
153
+ if (Buffer.byteLength(JSON.stringify(entries)) > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
154
+ return entries;
155
+ }
156
+ async function inspectArchive(path$1, signal) {
157
+ const file = await open(path$1, "r");
158
+ let offset = 0, metadata = 0;
159
+ const entries = [];
160
+ const seen = /* @__PURE__ */ new Map();
161
+ const exact = async (n) => {
162
+ const b = Buffer.alloc(n);
163
+ let at = 0;
164
+ while (at < n) {
165
+ signal.throwIfAborted();
166
+ const r = await file.read(b, at, n - at, offset);
167
+ if (!r.bytesRead) throw new Error("truncated binary archive");
168
+ at += r.bytesRead;
169
+ offset += r.bytesRead;
170
+ }
171
+ return b;
172
+ };
173
+ try {
174
+ while (true) {
175
+ const size = (await exact(4)).readUInt32BE();
176
+ if (!size || size > FILE_FRAME_BYTES) throw new Error("archive header limit exceeded");
177
+ metadata += size;
178
+ if (metadata > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
179
+ const e = JSON.parse((await exact(size)).toString("utf8"));
180
+ if (e.end === true) {
181
+ if ((await file.stat()).size !== offset) throw new Error("trailing archive bytes");
182
+ break;
183
+ }
184
+ checkRelative(e.p);
185
+ if (![
186
+ "f",
187
+ "d",
188
+ "l"
189
+ ].includes(e.t) || !Number.isSafeInteger(e.n) || e.n < 0 || e.t !== "f" && e.n !== 0) throw new Error("unsafe archive type/size");
190
+ if (entries.length >= FILE_MAX_ENTRIES || seen.has(e.p)) throw new Error("archive entry limit or duplicate path");
191
+ if (e.p !== "" && seen.get(posix.dirname(e.p) === "." ? "" : posix.dirname(e.p))?.t !== "d") throw new Error("unsafe archive parent ordering/type");
192
+ if (!entries.length && e.p !== "") throw new Error("archive root missing");
193
+ if (e.t === "l") checkLink(e);
194
+ e.offset = offset;
195
+ if (e.t === "f") {
196
+ const hash = createHash("sha256");
197
+ let left = e.n;
198
+ while (left) {
199
+ const chunk = await exact(Math.min(FILE_FRAME_BYTES, left));
200
+ hash.update(chunk);
201
+ left -= chunk.length;
202
+ }
203
+ e.hash = hash.digest("hex");
204
+ }
205
+ entries.push(e);
206
+ seen.set(e.p, e);
207
+ }
208
+ if (!entries.length) throw new Error("archive root missing");
209
+ return entries;
210
+ } finally {
211
+ await file.close();
212
+ }
213
+ }
214
+ async function importArchive(destination, payload, mkdirParents, signal, resources) {
215
+ checkAbsolute(destination);
216
+ const entries = await inspectArchive(payload, signal);
217
+ checkTreeLinks(entries);
218
+ for (const e of entries) {
219
+ const path$1 = e.p ? join(destination, e.p) : destination;
220
+ await ancestors(path$1);
221
+ try {
222
+ const st = await lstat(path$1);
223
+ const type = st.isSymbolicLink() ? "l" : st.isDirectory() ? "d" : st.isFile() ? "f" : "?";
224
+ if (type !== e.t || type === "l") throw new Error("destination path/type conflict or existing symlink");
225
+ } catch (error) {
226
+ if (error.code !== "ENOENT") throw error;
227
+ }
228
+ }
229
+ await ancestors(destination);
230
+ if (mkdirParents) await mkdir(dirname(destination), { recursive: true });
231
+ for (const e of entries) {
232
+ signal.throwIfAborted();
233
+ const path$1 = e.p ? join(destination, e.p) : destination;
234
+ await ancestors(path$1);
235
+ if (e.t === "d") {
236
+ await mkdir(path$1, { recursive: false }).catch((error) => {
237
+ if (error.code !== "EEXIST") throw error;
238
+ });
239
+ continue;
240
+ }
241
+ if (e.t === "l") {
242
+ await symlink(e.link, path$1);
243
+ continue;
244
+ }
245
+ const temporary = join(dirname(path$1), `.remote-copy-${randomUUID()}`);
246
+ await resources.temporary?.(temporary, true);
247
+ try {
248
+ const output = createWriteStream(temporary, {
249
+ flags: "wx",
250
+ mode: 384
251
+ });
252
+ await pipeline(e.n ? createReadStream(payload, {
253
+ start: e.offset,
254
+ end: e.offset + e.n - 1
255
+ }) : Readable.from([]), output, { signal });
256
+ await ancestors(path$1);
257
+ signal.throwIfAborted();
258
+ await rename(temporary, path$1);
259
+ } finally {
260
+ await rm(temporary, { force: true });
261
+ await resources.temporary?.(temporary, false);
262
+ }
263
+ }
264
+ }
265
+ function localFileEndpoint(resources = {}) {
266
+ return {
267
+ async exportTree(path$1, signal) {
268
+ const entries = await tree(path$1, signal);
269
+ return Readable.from((async function* () {
270
+ for (const e of entries) {
271
+ signal.throwIfAborted();
272
+ yield header({
273
+ p: e.p,
274
+ t: e.t,
275
+ n: e.n,
276
+ ...e.link === void 0 ? {} : { link: e.link }
277
+ });
278
+ if (e.t === "f" && e.n) for await (const chunk of await safeRead(e.p ? join(path$1, e.p) : path$1, signal)) yield chunk;
279
+ }
280
+ yield header({ end: true });
281
+ })());
282
+ },
283
+ async importTree(path$1, source, { mkdir: parents, signal }) {
284
+ checkAbsolute(path$1);
285
+ const scratch = resources.receiveRoot ?? await mkdtemp(join(tmpdir(), "remote-file-receive-"));
286
+ try {
287
+ const payload = join(scratch, "payload");
288
+ await pipeline(source, createWriteStream(payload, {
289
+ flags: "wx",
290
+ mode: 384
291
+ }), { signal });
292
+ await importArchive(path$1, payload, parents, signal, resources);
293
+ } finally {
294
+ source.destroy?.();
295
+ await rm(scratch, {
296
+ recursive: true,
297
+ force: true
298
+ });
299
+ }
300
+ },
301
+ async digest(path$1, signal, selection) {
302
+ return manifestDigest(await tree(path$1, signal, selection));
303
+ }
304
+ };
305
+ }
306
+
307
+ //#endregion
308
+ //#region src/remote/copy.ts
309
+ var CopyError = class extends Error {
310
+ constructor(receipt) {
311
+ super(`copy failed: ${JSON.stringify(receipt)}`);
312
+ this.receipt = receipt;
313
+ }
314
+ };
315
+ async function copyTree(request, source, destination, signal, scratchRoot = join(process.cwd(), ".scratch")) {
316
+ signal.throwIfAborted();
317
+ await mkdir(scratchRoot, { recursive: true });
318
+ signal.throwIfAborted();
319
+ const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
320
+ const receipt = {
321
+ action: "copy",
322
+ src: request.src,
323
+ dst: request.dst,
324
+ verified: false,
325
+ bytes: 0,
326
+ entries: 0,
327
+ scope: "transferred entries"
328
+ };
329
+ try {
330
+ await pipeline(await source.endpoint.exportTree(source.path, signal), createWriteStream(payload, {
331
+ flags: "wx",
332
+ mode: 384
333
+ }), { signal });
334
+ const manifest = await inspectArchive(payload, signal), staged = manifestDigest(manifest);
335
+ receipt.bytes = staged.bytes;
336
+ receipt.entries = staged.entries;
337
+ const selection = manifest.map((e) => e.p);
338
+ if (request.verify !== false) {
339
+ const original = await source.endpoint.digest(source.path, signal, selection);
340
+ receipt.sourceDigest = original.sha256;
341
+ if (original.sha256 !== staged.sha256) throw new Error("source changed during export; staged digest mismatch");
342
+ }
343
+ signal.throwIfAborted();
344
+ await destination.endpoint.importTree(destination.path, createReadStream(payload), {
345
+ mkdir: request.mkdir ?? true,
346
+ signal
347
+ });
348
+ if (request.verify !== false) {
349
+ const dest = await destination.endpoint.digest(destination.path, signal, selection);
350
+ receipt.destinationDigest = dest.sha256;
351
+ if (dest.sha256 !== receipt.sourceDigest) throw new Error("destination digest mismatch");
352
+ receipt.verified = true;
353
+ }
354
+ signal.throwIfAborted();
355
+ await rm(scratch, {
356
+ recursive: true,
357
+ force: true
358
+ });
359
+ return receipt;
360
+ } catch (error) {
361
+ throw new CopyError({
362
+ ...receipt,
363
+ verified: false,
364
+ scratch,
365
+ error: error instanceof Error ? error.message : String(error)
366
+ });
367
+ }
368
+ }
369
+
370
+ //#endregion
371
+ //#region src/remote/ssh-targets.ts
372
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
373
+ const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
374
+ const HOST = /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9_][A-Za-z0-9_.-]*|\[[A-Fa-f0-9:.]+\]|[A-Fa-f0-9:]+)$/;
375
+ const JUMP = /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9_][A-Za-z0-9_.-]*|\[[A-Fa-f0-9:.]+\])(?::([0-9]+))?$/;
376
+ function validateSshLabel(label) {
377
+ if (!LABEL.test(label)) throw new Error("[E_BAD_LABEL] remote: BYO-SSH label must use letters, digits, dot, underscore or hyphen and start with a letter/digit");
378
+ }
379
+ function bad(field) {
380
+ throw new Error(`[E_SSH_PARAMS] remote: invalid SSH ${field} — use host/user/port, an existing origin key file path, and optional ProxyJump aliases or [user@]host[:port] hops; no inline keys or shell options`);
381
+ }
382
+ function normalizeSshConnection(input) {
383
+ if (!input || typeof input !== "object" || Array.isArray(input)) bad("connection");
384
+ if (Object.keys(input).some((key$1) => ![
385
+ "host",
386
+ "user",
387
+ "port",
388
+ "key",
389
+ "proxyJump"
390
+ ].includes(key$1))) bad("field");
391
+ if (typeof input.host !== "string" || !HOST.test(input.host)) bad("host");
392
+ if (input.user !== void 0 && (typeof input.user !== "string" || !USER.test(input.user))) bad("user");
393
+ if (input.port !== void 0 && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) bad("port");
394
+ if (input.proxyJump !== void 0) {
395
+ if (typeof input.proxyJump !== "string" || !input.proxyJump || input.proxyJump.split(",").some((hop) => {
396
+ const match = JUMP.exec(hop);
397
+ return !match || match[1] !== void 0 && (Number(match[1]) < 1 || Number(match[1]) > 65535);
398
+ })) bad("proxyJump");
399
+ }
400
+ let key;
401
+ if (input.key !== void 0) {
402
+ if (typeof input.key !== "string" || !input.key || /[\0\r\n]/.test(input.key)) bad("key");
403
+ key = input.key.startsWith("~/") ? join(homedir(), input.key.slice(2)) : resolve(input.key);
404
+ try {
405
+ if (!statSync(key).isFile()) bad("key");
406
+ } catch {
407
+ bad("key file (must exist on the origin)");
408
+ }
409
+ }
410
+ return Object.freeze({
411
+ host: input.host,
412
+ ...input.user === void 0 ? {} : { user: input.user },
413
+ ...input.port === void 0 ? {} : { port: input.port },
414
+ ...key === void 0 ? {} : { key },
415
+ ...input.proxyJump === void 0 ? {} : { proxyJump: input.proxyJump }
416
+ });
417
+ }
418
+ /** One instance per Agent-owned driver; destroyed with its Agent/plugin owners. */
419
+ var SshTargets = class {
420
+ targets = /* @__PURE__ */ new Map();
421
+ register(label, connection) {
422
+ validateSshLabel(label);
423
+ const normalized = normalizeSshConnection(connection), existing = this.targets.get(label);
424
+ if (existing && JSON.stringify(existing) !== JSON.stringify(normalized)) throw new Error(`[E_LABEL_CONFLICT] remote: byossh:${label} SSH identity conflict — nothing was run. Reuse the label without ssh, or choose another label; existing credentials were not replaced`);
425
+ if (!existing) this.targets.set(label, normalized);
426
+ return existing ?? normalized;
427
+ }
428
+ get(label) {
429
+ validateSshLabel(label);
430
+ return this.targets.get(label);
431
+ }
432
+ entries() {
433
+ return [...this.targets.entries()];
434
+ }
435
+ clear() {
436
+ this.targets.clear();
437
+ }
438
+ };
439
+ /** Shared by execution, probes and file/relay consumers. extraArgs is trusted
440
+ * internal SSH argv (e.g. forwarding), never a model-provided shell string. */
441
+ function sshArgv(connection, options = {}) {
442
+ const args = options.tty ? [
443
+ "ssh",
444
+ "-tt",
445
+ "-o",
446
+ "BatchMode=yes"
447
+ ] : [
448
+ "ssh",
449
+ "-o",
450
+ "BatchMode=yes",
451
+ "-T"
452
+ ];
453
+ if (connection.user !== void 0) args.push("-l", connection.user);
454
+ if (connection.port !== void 0) args.push("-p", String(connection.port));
455
+ if (connection.key !== void 0) args.push("-i", connection.key);
456
+ if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
457
+ args.push(...options.extraArgs ?? [], "--", connection.host);
458
+ if (options.command !== void 0) args.push(options.command);
459
+ return args;
460
+ }
461
+
462
+ //#endregion
463
+ //#region src/remote/transports.ts
464
+ /** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
465
+ function shQuote(s) {
466
+ return `'${s.replaceAll("'", `'\\''`)}'`;
467
+ }
468
+ /** One-shot(§五.1):不分配 tty,收尾 = 子进程退出码,零字符串扫描。 */
469
+ function buildOneShotArgv(plan, cmd) {
470
+ switch (plan.kind) {
471
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `bash -lc ${shQuote(cmd)}` });
472
+ case "docker": return [
473
+ "docker",
474
+ "exec",
475
+ "-i",
476
+ "--",
477
+ plan.container,
478
+ "bash",
479
+ "-lc",
480
+ cmd
481
+ ];
482
+ case "incus": return [
483
+ "incus",
484
+ "exec",
485
+ "--force-noninteractive",
486
+ plan.container,
487
+ "--",
488
+ "bash",
489
+ "-lc",
490
+ cmd
491
+ ];
492
+ }
493
+ }
494
+ /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
495
+ function buildPtyArgv(plan) {
496
+ switch (plan.kind) {
497
+ case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
498
+ tty: true,
499
+ command: "bash"
500
+ });
501
+ case "docker": return [
502
+ "script",
503
+ "-qfec",
504
+ `stty -echo 2>/dev/null ; exec docker exec -it -- ${shQuote(plan.container)} bash`,
505
+ "/dev/null"
506
+ ];
507
+ case "incus": return [
508
+ "incus",
509
+ "exec",
510
+ "--force-interactive",
511
+ plan.container,
512
+ "--",
513
+ "bash"
514
+ ];
515
+ }
516
+ }
517
+ /** BYO-PTY(§三 spawn):工具只提供 PTY 托管 + Nonce 注入 + 收帧。 */
518
+ function buildSpawnArgv(spawnCommand) {
519
+ return [
520
+ "script",
521
+ "-qfec",
522
+ `stty -echo 2>/dev/null ; exec ${spawnCommand}`,
523
+ "/dev/null"
524
+ ];
525
+ }
526
+
527
+ //#endregion
528
+ //#region src/remote/ssh-lease.ts
529
+ /** Portable, transient recovery gate. The flock is inherited by the supervised cohort,
530
+ * so a wrapper exit alone cannot certify descendant release. No persistent tombstone. */
531
+ const SSH_RECOVERY_HELPER = String.raw`import fcntl, json, os, signal, stat, subprocess, sys, tempfile, time
532
+ role=sys.argv[1];args=json.loads(sys.argv[2])
533
+ if sys.version_info<(3,7) or (sys.platform=='darwin' and sys.version_info<(3,10)):
534
+ raise RuntimeError('SSH recovery requires Python3.7+ (macOS3.10+) shared cross-process monotonic clock and Unix fcntl.flock')
535
+ info=time.get_clock_info('monotonic')
536
+ if not info.monotonic or info.adjustable or not hasattr(fcntl,'flock'):
537
+ raise RuntimeError('SSH recovery requires shared non-adjustable monotonic clock and Unix fcntl.flock')
538
+ def now():return time.monotonic_ns()//1000000
539
+ if role=='recovery-clock':
540
+ print(json.dumps({'tick':now(),'base':tempfile.gettempdir()}),flush=True);sys.exit(0)
541
+ root=args['root'];cutoff=args['cutoff'];since=args['since'];token=args['token']
542
+ if type(cutoff) is not int or type(since) is not int or cutoff!=since+8000 or now()<since:
543
+ raise RuntimeError('invalid recovery clock epoch/entry fence')
544
+ stopped=False
545
+ def stop(sig,frame):
546
+ global stopped
547
+ stopped=True
548
+ for sig in (signal.SIGHUP,signal.SIGTERM,signal.SIGINT):signal.signal(sig,stop)
549
+ def gate():
550
+ try:os.mkdir(root,0o700)
551
+ except FileExistsError:pass
552
+ directory=os.open(root,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
553
+ st=os.fstat(directory)
554
+ if st.st_uid!=os.getuid() or stat.S_IMODE(st.st_mode)!=0o700:
555
+ os.close(directory);raise RuntimeError('unsafe rsync recovery owner directory')
556
+ fd=os.open('lock',os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600,dir_fd=directory)
557
+ st=os.fstat(fd)
558
+ if st.st_uid!=os.getuid() or not stat.S_ISREG(st.st_mode) or stat.S_IMODE(st.st_mode)!=0o600:
559
+ os.close(fd);os.close(directory);raise RuntimeError('unsafe rsync recovery lock')
560
+ return directory,fd
561
+ def current(directory,fd):
562
+ try:
563
+ a=os.stat(root,follow_symlinks=False);b=os.fstat(directory)
564
+ c=os.stat('lock',dir_fd=directory,follow_symlinks=False);d=os.fstat(fd)
565
+ return (a.st_dev,a.st_ino)==(b.st_dev,b.st_ino) and (c.st_dev,c.st_ino)==(d.st_dev,d.st_ino)
566
+ except FileNotFoundError:return False
567
+ def remove(directory,fd):
568
+ if not current(directory,fd):return
569
+ for name in ('cancel','lock'):
570
+ try:os.unlink(name,dir_fd=directory)
571
+ except FileNotFoundError:pass
572
+ os.rmdir(root)
573
+ def cancelled(directory):
574
+ try:os.stat('cancel',dir_fd=directory,follow_symlinks=False);return True
575
+ except FileNotFoundError:return False
576
+ if role=='recovery-run':
577
+ while not stopped and now()<cutoff:
578
+ directory=fd=None;child=None
579
+ try:
580
+ directory,fd=gate()
581
+ try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
582
+ except BlockingIOError:sys.stderr.write('recovery-run: gate entry fenced, token already owned\n');sys.exit(125) # observer/owner already fenced this unique token
583
+ if not current(directory,fd):continue
584
+ if stopped or now()>=cutoff or cancelled(directory):
585
+ sys.stderr.write('recovery-run: gate entry fenced stopped=%s cutoff-reached=%s cancelled=%s\n'%(stopped,now()>=cutoff,cancelled(directory)));sys.exit(125)
586
+ command=args['command'];command=command if command.startswith('exec ') else 'exec '+command
587
+ # The dead original SSH channel must never block cancellation/cleanup.
588
+ # Preserve binary output normally, bounded to 512KiB/channel; once
589
+ # cancelled, drain/discard it so even terminal cleanup writes finish.
590
+ for output in (1,2):os.set_blocking(output,False)
591
+ child=subprocess.Popen(['bash','-c',command],pass_fds=(fd,),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
592
+ streams={1:child.stdout,2:child.stderr};pending={1:bytearray(),2:bytearray()};sent=False
593
+ phase=None;deadline=None;incoming=bytearray();forward=bytearray();tail=bytearray()
594
+ prepared=('__SUPERD_RSYNC_'+token+'_PREPARED__\n').encode()
595
+ cleaned=('__SUPERD_RSYNC_'+token+'_CLEANED__\n').encode()
596
+ os.set_blocking(0,False);os.set_blocking(child.stdin.fileno(),False)
597
+ for stream in streams.values():os.set_blocking(stream.fileno(),False)
598
+ while True:
599
+ # This gate is itself an owner, including after the inner child
600
+ # exits. Anchor BEFORE forwarding PREPARED; KEEP adds only the
601
+ # same fixed step already counted before origin dispatch.
602
+ if deadline is not None and now()>=deadline:stopped=True
603
+ if not stopped:
604
+ try:control=os.read(0,4096)
605
+ except BlockingIOError:control=None
606
+ if control==b'':stopped=True
607
+ elif control:
608
+ incoming.extend(control)
609
+ if len(incoming)+len(forward)>65536:stopped=True
610
+ while not stopped and b'\n' in incoming:
611
+ line,rest=incoming.split(b'\n',1);incoming=bytearray(rest);tick=now()
612
+ if deadline is None or tick>=deadline:stopped=True
613
+ elif line==b'START' and phase=='prepared':phase='active'
614
+ elif line==b'KEEP' and phase=='active':deadline=min(deadline+2000,tick+8000)
615
+ else:stopped=True
616
+ if not stopped:forward.extend(line+b'\n')
617
+ if forward and not child.stdin.closed:
618
+ try:count=os.write(child.stdin.fileno(),forward);del forward[:count]
619
+ except BlockingIOError:pass
620
+ except BrokenPipeError:child.stdin.close();forward.clear()
621
+ if child.stdin.closed:forward.clear()
622
+ discard=stopped or cancelled(directory)
623
+ if discard:
624
+ stopped=True;tail.clear();forward.clear();incoming.clear()
625
+ for data in pending.values():data.clear()
626
+ if not sent and child.poll() is None:
627
+ try:child.terminate()
628
+ except ProcessLookupError:pass
629
+ sent=True
630
+ progress=False
631
+ for output,stream in list(streams.items()):
632
+ for _ in range(8):
633
+ room=65536 if discard else min(65536,524288-len(pending[output])-(len(tail) if output==2 else 0))
634
+ if not room:break
635
+ try:data=os.read(stream.fileno(),room)
636
+ except BlockingIOError:break
637
+ if not data:
638
+ stream.close();del streams[output]
639
+ if output==2 and not discard:pending[2].extend(tail);tail.clear()
640
+ break
641
+ progress=True
642
+ if discard:continue
643
+ if output==1:pending[1].extend(data);continue
644
+ # Streaming marker recognition retains only a possible
645
+ # marker prefix. The inner CLEANED is not gate cleanup.
646
+ tail.extend(data)
647
+ while tail:
648
+ hits=[(tail.find(mark),mark) for mark in (prepared,cleaned) if tail.find(mark)>=0]
649
+ if hits:
650
+ index,mark=min(hits,key=lambda item:item[0]);pending[2].extend(tail[:index]);del tail[:index+len(mark)]
651
+ if mark==prepared:
652
+ if phase is None:phase='prepared';deadline=now()+8000
653
+ pending[2].extend(mark)
654
+ else:
655
+ keep=max([n for mark in (prepared,cleaned) for n in range(1,min(len(tail),len(mark)-1)+1) if tail[-n:]==mark[:n]] or [0])
656
+ count=len(tail)-keep;pending[2].extend(tail[:count]);del tail[:count];break
657
+ if not discard:
658
+ for output,data in pending.items():
659
+ if not data:continue
660
+ try:
661
+ count=os.write(output,data);del data[:count];progress=True
662
+ except BlockingIOError:pass
663
+ except OSError:stopped=True;break
664
+ if child.poll() is not None and not streams and not any(pending.values()):break
665
+ if not progress:time.sleep(.01)
666
+ status=child.wait();child.stdin.close()
667
+ remove(directory,fd);os.close(fd);os.close(directory);fd=directory=None
668
+ # Only the post-cohort, post-pipe, post-lock release is an ACK.
669
+ # A lost/full old channel may lose this receipt, never hold cleanup.
670
+ try:os.write(2,cleaned)
671
+ except OSError:pass
672
+ sys.exit(125 if stopped else status if status>=0 else 128-status)
673
+ finally:
674
+ # Never remove ownership while a child remains. Its inherited lock also
675
+ # survives abrupt wrapper death, until the existing supervisor reaps it.
676
+ if child is None and directory is not None and fd is not None and current(directory,fd):
677
+ # A different owner may hold this file: only a held exclusive lock permits removal.
678
+ try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB);remove(directory,fd)
679
+ except BlockingIOError:pass
680
+ if fd is not None:os.close(fd)
681
+ if directory is not None:os.close(directory)
682
+ if not stopped:sys.stderr.write('recovery-run: session ended before cutoff without a signal\n')
683
+ sys.exit(125)
684
+ elif role=='recovery-lock':
685
+ directory=fd=None
686
+ try:
687
+ while not stopped and now()<cutoff:
688
+ directory,fd=gate()
689
+ try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
690
+ except BlockingIOError:
691
+ raise RuntimeError('rsync recovery entry already owned or cancelled')
692
+ if current(directory,fd) and now()<cutoff and not cancelled(directory):break
693
+ remove(directory,fd);os.close(fd);os.close(directory);directory=fd=None
694
+ if directory is None:raise RuntimeError('rsync recovery entry closed; no controller admitted')
695
+ except BaseException:
696
+ if fd is not None:os.close(fd)
697
+ if directory is not None:os.close(directory)
698
+ raise
699
+ elif role=='recovery-observe':
700
+ while True:
701
+ directory=fd=None
702
+ try:
703
+ directory,fd=gate()
704
+ cancel=os.open('cancel',os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600,dir_fd=directory);os.close(cancel)
705
+ while True:
706
+ try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB);break
707
+ except BlockingIOError:time.sleep(.05)
708
+ if not current(directory,fd):continue
709
+ # No active cohort holds the lock. Preserve cancellation against an
710
+ # original exec request that has not entered its gate yet.
711
+ while now()<cutoff:time.sleep(.05)
712
+ remove(directory,fd)
713
+ break
714
+ except FileNotFoundError:continue
715
+ finally:
716
+ if fd is not None:os.close(fd)
717
+ if directory is not None:os.close(directory)
718
+ print('__SUPERD_RSYNC_'+token+'_CLEANED__',flush=True)
719
+ else:raise RuntimeError('unknown rsync recovery role')
720
+ `;
721
+ const recoveryHelperCommand = (role, args = {}) => "exec python3 -u -c " + shQuote(SSH_RECOVERY_HELPER) + " " + shQuote(role) + " " + shQuote(JSON.stringify(args));
722
+ /** One receiving helper: portable monotonic clock; Linux only for owned -R transport. */
723
+ const SSH_LEASE_HELPER = String.raw`import json, os, selectors, signal, subprocess, sys, time
724
+ role=sys.argv[1]
725
+ args=json.loads(sys.argv[2]) if len(sys.argv)>2 else {}
726
+ if sys.version_info<(3,7):raise RuntimeError('SSH ownership requires Python3.7+ monotonic_ns')
727
+ info=time.get_clock_info('monotonic')
728
+ if not info.monotonic or info.adjustable:
729
+ raise RuntimeError('SSH ownership requires a non-adjustable monotonic Python clock')
730
+ last=0
731
+ def now():
732
+ global last
733
+ value=time.monotonic_ns()//1000000
734
+ if not isinstance(value,int) or value<last or value<0:
735
+ raise RuntimeError('invalid monotonic SSH clock; admission closed')
736
+ last=value
737
+ return value
738
+
739
+ def clock_pipe(leased):
740
+ selector=selectors.DefaultSelector()
741
+ for fd in ([0,3] if leased else [0]):
742
+ os.set_blocking(fd,False);selector.register(fd,selectors.EVENT_READ)
743
+ buffers={0:b'',3:b''};phase='prepared';deadline=now()+8000;count=0
744
+ while True:
745
+ if leased and now()>=deadline:phase='closed'
746
+ for key,events in selector.select(.05):
747
+ fd=key.fd;data=os.read(fd,4096)
748
+ if not data:
749
+ if fd==0:return
750
+ phase='closed';selector.unregister(fd);continue
751
+ buffers[fd]+=data
752
+ if len(buffers[fd])>65536:raise RuntimeError('rsync lease input too large')
753
+ while b'\n' in buffers[fd]:
754
+ line,buffers[fd]=buffers[fd].split(b'\n',1);tick=now()
755
+ if fd==0:
756
+ if line!=b'QUERY':raise RuntimeError('invalid clock query')
757
+ if leased:
758
+ if tick>=deadline:phase='closed'
759
+ print(tick,deadline,count,phase,flush=True)
760
+ else:print(tick,flush=True)
761
+ elif tick>=deadline:phase='closed'
762
+ elif line==b'START' and phase=='prepared':phase='active'
763
+ elif line==b'KEEP' and phase=='active':deadline=min(deadline+2000,tick+8000);count+=1
764
+ else:phase='closed'
765
+
766
+ def native_command():
767
+ # fd0 is exclusively the user's literal stdin. Control lives in the separate owner SSH.
768
+ owner=args['directory'];active=owner+'/active';record=owner+'/server.'+str(os.getpid())
769
+ child=None;stopped=False;status=125
770
+ subreaper=args.get('subreaper',False)
771
+ if subreaper:
772
+ # Containers can have a non-reaping PID1: own adopted descendants ourselves.
773
+ import ctypes
774
+ if sys.platform!='linux':raise RuntimeError('container command ownership requires Linux PR_SET_CHILD_SUBREAPER')
775
+ libc=ctypes.CDLL(None,use_errno=True)
776
+ if libc.prctl(36,1,0,0,0)!=0:raise OSError(ctypes.get_errno(),'container PR_SET_CHILD_SUBREAPER denied before work admission')
777
+ def reap():
778
+ if not subreaper:return
779
+ # Popen retains its direct-child status; waitpid only after it is collected.
780
+ child.poll()
781
+ if child.returncode is None:return
782
+ while True:
783
+ try:
784
+ pid,status=os.waitpid(-1,os.WNOHANG)
785
+ if pid==0:return
786
+ except ChildProcessError:return
787
+ def stop(signum,frame):
788
+ nonlocal stopped
789
+ stopped=True
790
+ for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,stop)
791
+ def registered(group):
792
+ with open(record,'w') as f:f.write('%s %s 0\n'%(os.getpid(),group))
793
+ def lease():
794
+ with open(owner+'/lease') as f:values=f.read(128).split()
795
+ if len(values)!=2:raise RuntimeError('invalid SSH command lease')
796
+ remaining,count=map(int,values)
797
+ if remaining<=0 or remaining>8000 or count<0:raise RuntimeError('invalid SSH command lease')
798
+ return remaining,count
799
+ def group_live():
800
+ reap()
801
+ try:os.killpg(child.pid,0);return True
802
+ except ProcessLookupError:return False
803
+ try:
804
+ # No child/clock/IPC is created before the sideband admission fences.
805
+ if stopped or not os.path.isfile(active):
806
+ sys.stderr.write('native-command admission closed before spawn: signal=%s owner=%s owner-dir=%s owner-active=%s\n'%(stopped,owner,os.path.isdir(owner),os.path.isfile(active)));return 125
807
+ registered(0)
808
+ if stopped or not os.path.isfile(active):
809
+ sys.stderr.write('native-command admission closed after registration: signal=%s owner-active=%s\n'%(stopped,os.path.isfile(active)));return 125
810
+ remaining,seen=lease();deadline=now()+remaining
811
+ if stopped or not os.path.isfile(active) or now()>=deadline:
812
+ sys.stderr.write('native-command admission closed before child: signal=%s owner-active=%s lease-expired=%s\n'%(stopped,os.path.isfile(active),now()>=deadline));return 125
813
+ child=subprocess.Popen(args['argv'],stdin=0,stdout=1,stderr=2,start_new_session=True)
814
+ registered(child.pid)
815
+ while True:
816
+ status=child.poll()
817
+ if status is not None:break
818
+ if stopped or not os.path.isfile(active) or now()>=deadline:
819
+ status=125
820
+ if not stopped:sys.stderr.write('native-command admission closed while running without signal: owner-active=%s lease-expired=%s child=%s\n'%(os.path.isfile(active),now()>=deadline,child.pid))
821
+ break
822
+ remaining,count=lease()
823
+ if count<seen:raise RuntimeError('SSH command lease count regressed')
824
+ deadline=min(deadline+(count-seen)*2000,now()+8000);seen=count
825
+ time.sleep(.02)
826
+ return status if status>=0 else 128-status
827
+ finally:
828
+ if child is not None:
829
+ try:os.killpg(child.pid,signal.SIGTERM)
830
+ except ProcessLookupError:pass
831
+ end=now()+200
832
+ while now()<end:
833
+ child.poll()
834
+ if not group_live():break
835
+ time.sleep(.01)
836
+ try:os.killpg(child.pid,signal.SIGKILL)
837
+ except ProcessLookupError:pass
838
+ child.wait()
839
+ while group_live():time.sleep(.01)
840
+
841
+ if role=='clock':clock_pipe(False)
842
+ elif role=='lease-clock':clock_pipe(True)
843
+ elif role=='native-command':sys.exit(native_command())
844
+ elif role=='preflight':now()
845
+ else:raise RuntimeError('unknown rsync ownership helper role')
846
+ `;
847
+ const leaseHelperCommand = (role, args = {}) => "exec python3 -u -c " + shQuote(SSH_LEASE_HELPER) + " " + shQuote(role) + " " + shQuote(JSON.stringify(args));
848
+
849
+ //#endregion
850
+ //#region src/remote/output-ring.ts
851
+ const continuation = (byte) => (byte & 192) === 128;
852
+ function completeUtf8End(bytes) {
853
+ if (!bytes.length) return 0;
854
+ let start = bytes.length - 1;
855
+ while (start > 0 && continuation(bytes[start]) && bytes.length - start < 4) start--;
856
+ const lead = bytes[start];
857
+ const width = lead >= 240 && lead <= 244 ? 4 : lead >= 224 && lead <= 239 ? 3 : lead >= 194 && lead <= 223 ? 2 : 1;
858
+ return bytes.length - start < width ? start : bytes.length;
859
+ }
860
+ /** Byte-addressed decoded output: mid-codepoint cursors skip forward and explicitly report loss. */
861
+ function readUtf8Tail(bytes, totalBytes, fromByte) {
862
+ if (!Number.isSafeInteger(fromByte) || fromByte < 0) throw new Error("Output cursor must be a non-negative safe byte offset");
863
+ const start = totalBytes - bytes.length;
864
+ let offset = Math.max(0, Math.min(bytes.length, fromByte - start));
865
+ let lossy = fromByte < start;
866
+ while (offset < bytes.length && continuation(bytes[offset])) {
867
+ offset++;
868
+ lossy = true;
869
+ }
870
+ const end = completeUtf8End(bytes);
871
+ return {
872
+ text: bytes.subarray(Math.min(offset, end), end).toString("utf8"),
873
+ nextOffset: start + end,
874
+ lossy
875
+ };
876
+ }
877
+ /** Native/PTY retention is UTF16 units; cursor offsets remain real UTF8 bytes. Input is decoded text. */
878
+ var Utf16TailRing = class {
879
+ text = "";
880
+ total = 0;
881
+ totalBytes = 0;
882
+ constructor(capacity = 4e6) {
883
+ this.capacity = capacity;
884
+ }
885
+ push(text) {
886
+ this.total += text.length;
887
+ this.totalBytes += Buffer.byteLength(text);
888
+ this.text = (this.text + text).slice(-this.capacity);
889
+ if (this.text.charCodeAt(0) >= 56320 && this.text.charCodeAt(0) <= 57343) this.text = this.text.slice(1);
890
+ }
891
+ stats(returned = this.text.length) {
892
+ return {
893
+ unit: "utf16",
894
+ total: this.total,
895
+ retained: this.text.length,
896
+ dropped: this.total - this.text.length,
897
+ returned,
898
+ retentionLimit: this.capacity
899
+ };
900
+ }
901
+ read(fromByte) {
902
+ return readUtf8Tail(Buffer.from(this.text), this.totalBytes, fromByte);
903
+ }
904
+ };
905
+ /** Raw byte retention for Hub oneshot. Tail/read starts skip partial UTF8 prefixes, never invent replacement characters. */
906
+ var ByteTailRing = class {
907
+ total = 0;
908
+ chunks = [];
909
+ retained = 0;
910
+ constructor(capacity = 4 * 1024 * 1024) {
911
+ this.capacity = capacity;
912
+ }
913
+ push(chunk) {
914
+ if (chunk.length === 0) return;
915
+ this.total += chunk.length;
916
+ this.chunks.push(chunk);
917
+ this.retained += chunk.length;
918
+ let drop = this.retained - this.capacity;
919
+ while (drop > 0) {
920
+ const head = this.chunks[0];
921
+ const count = Math.min(drop, head.length);
922
+ if (count === head.length) this.chunks.shift();
923
+ else this.chunks[0] = head.subarray(count);
924
+ this.retained -= count;
925
+ drop -= count;
926
+ }
927
+ }
928
+ tail(maxBytes = this.retained) {
929
+ let skip = Math.max(0, this.retained - maxBytes);
930
+ const out = [];
931
+ for (const chunk of this.chunks) {
932
+ if (skip >= chunk.length) {
933
+ skip -= chunk.length;
934
+ continue;
935
+ }
936
+ out.push(chunk.subarray(skip));
937
+ skip = 0;
938
+ }
939
+ const bytes = Buffer.concat(out);
940
+ let offset = 0;
941
+ while (offset < bytes.length && continuation(bytes[offset])) offset++;
942
+ return bytes.subarray(offset, completeUtf8End(bytes));
943
+ }
944
+ stats(returned = this.tail().length) {
945
+ return {
946
+ unit: "bytes",
947
+ total: this.total,
948
+ retained: this.retained,
949
+ dropped: this.total - this.retained,
950
+ returned,
951
+ retentionLimit: this.capacity
952
+ };
953
+ }
954
+ read(fromByte) {
955
+ return readUtf8Tail(Buffer.concat(this.chunks), this.total, fromByte);
956
+ }
957
+ };
958
+
959
+ //#endregion
960
+ //#region src/remote/process.ts
961
+ const TERM_GRACE_MS = 2e3;
962
+ const KILL_SETTLE_MS = 100;
963
+ const PROCESS_CLEANUP_BOUND_MS = TERM_GRACE_MS + KILL_SETTLE_MS;
964
+ /** Real detached process. Completion includes group cleanup, even after its leader closes. */
965
+ function startBinaryProcess(argv, options = {}) {
15
966
  const started = Date.now();
16
967
  const child = spawn(argv[0], argv.slice(1), {
968
+ cwd: options.cwd,
17
969
  stdio: [
18
970
  "pipe",
19
971
  "pipe",
@@ -21,54 +973,1401 @@ async function runOneShot(argv, opts = {}) {
21
973
  ],
22
974
  detached: true
23
975
  });
24
- let stdout = "";
25
- let stderr = "";
26
- let timedOut = false;
27
- child.stdout.setEncoding("utf8").on("data", (d) => {
28
- if (stdout.length < HARD_CAP) stdout = (stdout + d).slice(0, HARD_CAP);
976
+ let diagnostic;
977
+ let timedOut = false, finished = false, cancelled = false;
978
+ let cleanup;
979
+ let timer;
980
+ const signal = (sig) => {
981
+ if (child.pid === void 0) return false;
982
+ try {
983
+ process.kill(-child.pid, sig);
984
+ return true;
985
+ } catch (error) {
986
+ if (error.code !== "ESRCH") diagnostic = `remote: process cleanup failed: ${String(error)}`;
987
+ return false;
988
+ }
989
+ };
990
+ let closed;
991
+ const close = new Promise((resolve$1) => {
992
+ closed = resolve$1;
29
993
  });
30
- child.stderr.setEncoding("utf8").on("data", (d) => {
31
- if (stderr.length < HARD_CAP) stderr = (stderr + d).slice(0, HARD_CAP);
994
+ const reap = () => cleanup ??= new Promise((resolve$1) => {
995
+ if (!signal("SIGTERM")) {
996
+ resolve$1();
997
+ return;
998
+ }
999
+ let settled = false;
1000
+ const finish = () => {
1001
+ if (!settled) {
1002
+ settled = true;
1003
+ clearTimeout(grace);
1004
+ resolve$1();
1005
+ }
1006
+ };
1007
+ const grace = setTimeout(() => {
1008
+ signal("SIGKILL");
1009
+ setTimeout(finish, KILL_SETTLE_MS);
1010
+ }, TERM_GRACE_MS);
1011
+ close.then(() => {
1012
+ if (!signal(0)) finish();
1013
+ });
32
1014
  });
1015
+ const cancel = () => {
1016
+ if (!finished) {
1017
+ cancelled = true;
1018
+ reap();
1019
+ }
1020
+ };
33
1021
  child.stdin.on("error", () => {});
34
- if (opts.stdin !== void 0) child.stdin.write(opts.stdin);
35
- child.stdin.end();
36
- let termTimer;
37
- let killTimer;
38
- const clearTimers = () => {
39
- if (termTimer !== void 0) clearTimeout(termTimer);
40
- if (killTimer !== void 0) clearTimeout(killTimer);
1022
+ child.once("error", (error) => {
1023
+ diagnostic = `exec: spawn failed — ${error.message}${options.cwd === void 0 ? "" : ` (cwd: ${options.cwd})`}`;
1024
+ });
1025
+ const done = new Promise((resolve$1) => {
1026
+ child.once("close", async (exit) => {
1027
+ closed();
1028
+ if (timer) clearTimeout(timer);
1029
+ await reap();
1030
+ finished = true;
1031
+ resolve$1({
1032
+ exit: diagnostic ? null : exit,
1033
+ timedOut,
1034
+ cancelled,
1035
+ durationMs: Date.now() - started
1036
+ });
1037
+ });
1038
+ });
1039
+ if (options.timeoutSec !== void 0) timer = setTimeout(() => {
1040
+ timedOut = true;
1041
+ cancel();
1042
+ }, options.timeoutSec * 1e3);
1043
+ return {
1044
+ cancel,
1045
+ done,
1046
+ stdout: child.stdout,
1047
+ stderr: child.stderr,
1048
+ stdin: child.stdin,
1049
+ diagnostic: () => diagnostic
1050
+ };
1051
+ }
1052
+ /** Text adapter over the same owned process-group lifecycle as binary pipelines. */
1053
+ function startOneShot(argv, options = {}) {
1054
+ const child = startBinaryProcess(argv, options);
1055
+ const byteMode = options.unit === "bytes";
1056
+ const stdout = byteMode ? new ByteTailRing() : new Utf16TailRing();
1057
+ const stderr = byteMode ? new ByteTailRing() : new Utf16TailRing();
1058
+ for (const [stream, ring] of [[child.stdout, stdout], [child.stderr, stderr]]) {
1059
+ if (!byteMode) stream.setEncoding("utf8");
1060
+ stream.on("data", (chunk) => {
1061
+ if (ring instanceof ByteTailRing) ring.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1062
+ else ring.push(String(chunk));
1063
+ options.onOutput?.();
1064
+ });
1065
+ }
1066
+ child.stdin.end(options.stdin);
1067
+ return {
1068
+ cancel: child.cancel,
1069
+ done: child.done,
1070
+ read(channel, fromByte) {
1071
+ const ring = channel === "stdout" ? stdout : stderr;
1072
+ const read = ring.read(fromByte), diagnostic = child.diagnostic();
1073
+ return {
1074
+ ...read,
1075
+ stats: ring.stats(byteMode ? Buffer.byteLength(read.text) : read.text.length),
1076
+ ...channel === "stderr" && diagnostic ? { diagnostic } : {}
1077
+ };
1078
+ }
1079
+ };
1080
+ }
1081
+
1082
+ //#endregion
1083
+ //#region src/remote/ssh-source-proxy.ts
1084
+ /** One sequential connection at a time; all queues bounded independently of file size.
1085
+ * This helper is a child of native-command, never an independently owned daemon. */
1086
+ const SOURCE_PROXY_HELPER = String.raw`import json,os,select,signal,socket,struct,sys,time
1087
+ port=int(sys.argv[1]);MAX=65536;CAP=262144
1088
+ stopped=False
1089
+ def stop(sig,frame):
1090
+ global stopped
1091
+ stopped=True
1092
+ for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,stop)
1093
+ for fd in (0,1,2):os.set_blocking(fd,False)
1094
+ listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
1095
+ client=None;probe=None;incoming=bytearray();out=bytearray();pending=bytearray()
1096
+ read_end=False;write_end=False;shutdown=False;verified=False;ready=False;greeting=bytearray()
1097
+ def frame(kind,data=b''):
1098
+ if len(data)>MAX or len(out)+len(data)+5>CAP:raise RuntimeError('source proxy output bound')
1099
+ out.extend(struct.pack('!BI',kind,len(data)));out.extend(data)
1100
+ try:
1101
+ listener.bind(('127.0.0.1',port));listener.listen(1);listener.setblocking(False)
1102
+ # An internal client traverses this exact listener, SSH stream and destination-L.
1103
+ probe=socket.create_connection(('127.0.0.1',port),timeout=2);probe.setblocking(False)
1104
+ deadline=time.monotonic()+10
1105
+ while not stopped:
1106
+ if not ready and time.monotonic()>deadline:raise RuntimeError('source proxy @RSYNCD greeting timeout')
1107
+ # Parse at most one bounded data frame ahead of the destination writer.
1108
+ while len(incoming)>=5:
1109
+ kind,size=struct.unpack('!BI',incoming[:5])
1110
+ if size>MAX or kind not in (2,3) or (kind==3 and size):raise RuntimeError('invalid source proxy frame')
1111
+ if len(incoming)<5+size or len(pending)+size>CAP:break
1112
+ if client is None:raise RuntimeError('source proxy frame without connection')
1113
+ data=bytes(incoming[5:5+size]);del incoming[:5+size]
1114
+ if kind==2:
1115
+ if write_end:raise RuntimeError('source proxy data after EOF')
1116
+ pending.extend(data)
1117
+ else:write_end=True
1118
+ if client is not None and write_end and not pending and not shutdown:
1119
+ client.shutdown(socket.SHUT_WR);shutdown=True
1120
+ if client is not None and read_end and shutdown and not pending and len(out)+5<=CAP:
1121
+ client.close();client=None;frame(4)
1122
+ if verified and not ready:
1123
+ os.write(2,b'SOURCE_PROXY_READY\n');ready=True
1124
+ readers=[];writers=[]
1125
+ if len(incoming)<MAX+5 and len(pending)<=CAP-MAX:readers.append(0)
1126
+ if out:writers.append(1)
1127
+ if client is None and len(out)<=CAP-5:readers.append(listener)
1128
+ if client is not None:
1129
+ if not read_end and len(out)<=CAP-MAX-5:readers.append(client)
1130
+ if pending:writers.append(client)
1131
+ if probe is not None:readers.append(probe)
1132
+ r,w,_=select.select(readers,writers,[],.05)
1133
+ if 0 in r:
1134
+ data=os.read(0,MAX+5-len(incoming))
1135
+ if not data:break
1136
+ incoming.extend(data)
1137
+ if 1 in w:
1138
+ n=os.write(1,out);del out[:n]
1139
+ if listener in r:
1140
+ client,_=listener.accept();client.setblocking(False)
1141
+ read_end=False;write_end=False;shutdown=False;frame(1)
1142
+ if client is not None and client in r:
1143
+ data=client.recv(MAX)
1144
+ if data:frame(2,data)
1145
+ else:read_end=True;frame(3)
1146
+ if client is not None and client in w:
1147
+ n=client.send(pending);del pending[:n]
1148
+ if probe is not None and probe in r:
1149
+ data=probe.recv(1024-len(greeting))
1150
+ if not data:raise RuntimeError('source proxy missing @RSYNCD greeting')
1151
+ greeting.extend(data)
1152
+ if b'\n' in greeting:
1153
+ if not greeting.startswith(b'@RSYNCD: '):raise RuntimeError('source proxy invalid @RSYNCD greeting')
1154
+ verified=True;probe.close();probe=None
1155
+ elif len(greeting)>=1024:raise RuntimeError('source proxy greeting exceeds bound')
1156
+ finally:
1157
+ for item in (probe,client,listener):
1158
+ if item is not None:item.close()
1159
+ `;
1160
+ const MAX = 65536;
1161
+ function write(stream, data) {
1162
+ return new Promise((resolve$1, reject) => {
1163
+ stream.write(data, (error) => error ? reject(error) : resolve$1());
1164
+ });
1165
+ }
1166
+ /** Origin-only bytes bridge. Local teardown is a resource barrier, not receiving proof. */
1167
+ function startSourceProxy(argv, destinationPort, signal) {
1168
+ signal.throwIfAborted();
1169
+ const child = startBinaryProcess(argv);
1170
+ let socket, socketReader;
1171
+ let closing = false, stderr = "", readyResolve, readyReject, fail;
1172
+ const ready = new Promise((resolve$1, reject) => {
1173
+ readyResolve = resolve$1;
1174
+ readyReject = reject;
1175
+ });
1176
+ const failed = new Promise((_, reject) => {
1177
+ fail = reject;
1178
+ });
1179
+ failed.catch(() => {});
1180
+ const failure = (error, stage = "bridge") => {
1181
+ if (!closing) {
1182
+ const detail = new Error(`source proxy ${stage}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1183
+ readyReject(detail);
1184
+ fail(detail);
1185
+ }
1186
+ };
1187
+ child.stderr.setEncoding("utf8");
1188
+ child.stderr.on("data", (data) => {
1189
+ stderr = (stderr + data).slice(-2 * 1024 * 1024);
1190
+ if (stderr.includes("SOURCE_PROXY_READY\n")) readyResolve();
1191
+ });
1192
+ const send = async (kind, data = Buffer.alloc(0)) => {
1193
+ const header$1 = Buffer.alloc(5);
1194
+ header$1[0] = kind;
1195
+ header$1.writeUInt32BE(data.length, 1);
1196
+ await write(child.stdin, Buffer.concat([header$1, data]));
1197
+ };
1198
+ const pump = (async () => {
1199
+ let input = Buffer.alloc(0);
1200
+ for await (const chunk of child.stdout) {
1201
+ input = Buffer.concat([input, chunk]);
1202
+ while (input.length >= 5) {
1203
+ const kind = input[0], size = input.readUInt32BE(1);
1204
+ if (size > MAX || ![
1205
+ 1,
1206
+ 2,
1207
+ 3,
1208
+ 4
1209
+ ].includes(kind) || kind !== 2 && size) throw new Error("invalid source proxy frame");
1210
+ if (input.length < size + 5) break;
1211
+ const data = input.subarray(5, size + 5);
1212
+ input = input.subarray(size + 5);
1213
+ if (kind === 1) {
1214
+ if (socket) throw new Error("source proxy overlapping connection");
1215
+ const active = createConnection({
1216
+ host: "127.0.0.1",
1217
+ port: destinationPort,
1218
+ allowHalfOpen: true
1219
+ });
1220
+ socket = active;
1221
+ active.on("error", (error) => failure(error, "destination socket"));
1222
+ await new Promise((resolve$1, reject) => {
1223
+ const timer = setTimeout(() => {
1224
+ active.destroy(/* @__PURE__ */ new Error("source proxy destination connect timeout"));
1225
+ }, 5e3);
1226
+ active.once("connect", () => {
1227
+ clearTimeout(timer);
1228
+ resolve$1();
1229
+ });
1230
+ active.once("error", (error) => {
1231
+ clearTimeout(timer);
1232
+ reject(error);
1233
+ });
1234
+ });
1235
+ socketReader = (async () => {
1236
+ for await (const bytes of active.iterator({ destroyOnReturn: false })) {
1237
+ const data$1 = bytes;
1238
+ for (let i = 0; i < data$1.length; i += MAX) await send(2, data$1.subarray(i, i + MAX));
1239
+ }
1240
+ await send(3);
1241
+ })();
1242
+ socketReader.catch((error) => failure(error, "destination reader"));
1243
+ } else {
1244
+ if (!socket) throw new Error("source proxy frame without socket");
1245
+ if (kind === 2) await write(socket, data);
1246
+ else if (kind === 3) socket.end();
1247
+ else {
1248
+ const previous = socket;
1249
+ const closed = previous.closed ? Promise.resolve() : new Promise((resolve$1) => previous.once("close", () => resolve$1()));
1250
+ await socketReader;
1251
+ previous.destroy();
1252
+ await closed;
1253
+ socket = void 0;
1254
+ socketReader = void 0;
1255
+ }
1256
+ }
1257
+ }
1258
+ if (input.length > MAX + 5) throw new Error("source proxy input bound");
1259
+ }
1260
+ if (input.length) throw new Error("truncated source proxy frame");
1261
+ })();
1262
+ pump.catch((error) => failure(error, "source frame reader"));
1263
+ child.done.then((result) => failure(/* @__PURE__ */ new Error(`source proxy closed: exit ${result.exit}; ${stderr || child.diagnostic() || "no greeting"}`)));
1264
+ const abort = () => {
1265
+ socket?.destroy();
1266
+ child.stdin.destroy();
1267
+ failure(signal.reason);
1268
+ };
1269
+ signal.addEventListener("abort", abort, { once: true });
1270
+ if (signal.aborted) abort();
1271
+ let closePromise;
1272
+ const close = () => closePromise ??= (async () => {
1273
+ closing = true;
1274
+ signal.removeEventListener("abort", abort);
1275
+ const active = socket;
1276
+ const socketClosed = active && !active.closed ? new Promise((resolve$1) => active.once("close", () => resolve$1())) : Promise.resolve();
1277
+ active?.destroy();
1278
+ child.stdin.destroy();
1279
+ child.stdout.destroy();
1280
+ child.cancel();
1281
+ await Promise.allSettled([
1282
+ pump,
1283
+ socketReader,
1284
+ socketClosed,
1285
+ child.done
1286
+ ]);
1287
+ })();
1288
+ return {
1289
+ ready,
1290
+ failed,
1291
+ close,
1292
+ stderr: () => stderr
1293
+ };
1294
+ }
1295
+
1296
+ //#endregion
1297
+ //#region src/remote/ssh-relay.ts
1298
+ const RELAY_SSH_OPTIONS = [
1299
+ "-a",
1300
+ "-o",
1301
+ "ForwardAgent=no",
1302
+ "-o",
1303
+ "ControlMaster=no",
1304
+ "-o",
1305
+ "ControlPath=none",
1306
+ "-o",
1307
+ "ExitOnForwardFailure=yes",
1308
+ "-o",
1309
+ "ConnectTimeout=10",
1310
+ "-o",
1311
+ "ServerAliveInterval=5",
1312
+ "-o",
1313
+ "ServerAliveCountMax=2"
1314
+ ];
1315
+ const OUTPUT_LIMIT = 2 * 1024 * 1024;
1316
+ const LEASE_MS = 8e3;
1317
+ const RENEW_STEP_MS = 2e3;
1318
+ const REMOTE_CLEANUP_MS = 3500;
1319
+ const marker = (token, kind) => `__SUPERD_RSYNC_${token}_${kind}__`;
1320
+ /** Existing origin group ownership plus the independently accounted remote barrier. */
1321
+ var Command = class {
1322
+ child;
1323
+ events = new EventEmitter();
1324
+ stdout = "";
1325
+ stderr = "";
1326
+ truncated = false;
1327
+ renew;
1328
+ stopping = false;
1329
+ prepared = false;
1330
+ exit;
1331
+ argv;
1332
+ acknowledged = false;
1333
+ stopPromise;
1334
+ cleanupDeadline = 0;
1335
+ constructor(argv, signal, token, recover) {
1336
+ this.signal = signal;
1337
+ this.token = token;
1338
+ this.recover = recover;
1339
+ signal.throwIfAborted();
1340
+ this.argv = argv;
1341
+ this.child = startBinaryProcess(argv);
1342
+ for (const channel of ["stdout", "stderr"]) {
1343
+ this.child[channel].setEncoding("utf8");
1344
+ this.child[channel].on("data", (text) => {
1345
+ const value = this[channel] + text;
1346
+ if (value.length > OUTPUT_LIMIT) this.truncated = true;
1347
+ this[channel] = value.slice(-OUTPUT_LIMIT);
1348
+ if (token && channel === "stderr") {
1349
+ if (!this.prepared && this.stderr.includes(marker(token, "PREPARED"))) {
1350
+ this.prepared = true;
1351
+ this.cleanupDeadline = performance.now() + LEASE_MS + REMOTE_CLEANUP_MS;
1352
+ if (!this.stopping && !signal.aborted) {
1353
+ this.child.stdin.write("START\n");
1354
+ this.renew = setInterval(() => {
1355
+ if (this.stopping || this.child.stdin.destroyed) return;
1356
+ this.cleanupDeadline += RENEW_STEP_MS;
1357
+ this.child.stdin.write("KEEP\n");
1358
+ }, RENEW_STEP_MS);
1359
+ }
1360
+ }
1361
+ if (this.stderr.includes(marker(token, "CLEANED"))) this.acknowledged = true;
1362
+ }
1363
+ this.events.emit("change");
1364
+ });
1365
+ }
1366
+ if (!token) this.child.stdin.end();
1367
+ signal.addEventListener("abort", this.abort, { once: true });
1368
+ if (signal.aborted) this.abort();
1369
+ this.child.done.then((result) => {
1370
+ this.exit = result.exit;
1371
+ this.quiesce();
1372
+ signal.removeEventListener("abort", this.abort);
1373
+ this.events.emit("change");
1374
+ });
1375
+ }
1376
+ quiesce() {
1377
+ this.stopping = true;
1378
+ if (this.renew) clearInterval(this.renew);
1379
+ }
1380
+ send(line) {
1381
+ this.signal.throwIfAborted();
1382
+ if (this.stopping || this.child.stdin.destroyed) throw new Error("SSH relay admission closed");
1383
+ this.child.stdin.write(line + "\n");
1384
+ }
1385
+ stderrOutput() {
1386
+ return this.token ? this.stderr.replace(new RegExp(`^__SUPERD_RSYNC_${this.token}_(?:PREPARED|CLEANED)__[^\n]*\n?`, "gm"), "") : this.stderr;
1387
+ }
1388
+ abort = () => {
1389
+ this.quiesce();
1390
+ if (!this.token) {
1391
+ this.child.stdin.end();
1392
+ this.child.cancel();
1393
+ }
1394
+ this.events.emit("change");
1395
+ };
1396
+ async change(ms) {
1397
+ await new Promise((resolve$1) => {
1398
+ const timer = setTimeout(finish, ms), self = this;
1399
+ function finish() {
1400
+ clearTimeout(timer);
1401
+ self.events.off("change", finish);
1402
+ resolve$1();
1403
+ }
1404
+ this.events.once("change", finish);
1405
+ });
1406
+ }
1407
+ async result() {
1408
+ this.signal.throwIfAborted();
1409
+ let rejectAbort;
1410
+ const aborted = new Promise((_, reject) => {
1411
+ rejectAbort = () => reject(this.signal.reason);
1412
+ });
1413
+ this.signal.addEventListener("abort", rejectAbort, { once: true });
1414
+ let result;
1415
+ try {
1416
+ result = await Promise.race([this.child.done, aborted]);
1417
+ } finally {
1418
+ this.signal.removeEventListener("abort", rejectAbort);
1419
+ }
1420
+ this.signal.throwIfAborted();
1421
+ const stderr = this.stderrOutput() + (this.child.diagnostic() ? "\n" + this.child.diagnostic() : "");
1422
+ return {
1423
+ exit: result.exit,
1424
+ stdout: this.stdout,
1425
+ stderr: stderr.slice(-OUTPUT_LIMIT),
1426
+ outputTruncated: this.truncated || stderr.length > OUTPUT_LIMIT
1427
+ };
1428
+ }
1429
+ async ready(pattern, timeoutMs = 1e4) {
1430
+ let ended = false;
1431
+ this.child.done.then(() => {
1432
+ ended = true;
1433
+ });
1434
+ const until = Date.now() + timeoutMs;
1435
+ for (;;) {
1436
+ this.signal.throwIfAborted();
1437
+ const match = pattern.exec(this.stdout);
1438
+ if (match) return match;
1439
+ if (ended || Date.now() >= until) {
1440
+ const status = ended ? `exit ${(await this.child.done).exit}` : "timeout";
1441
+ throw new Error(`SSH relay readiness failed (${status}): ${this.stderrOutput() || this.child.diagnostic() || "no SSH ownership readiness greeting"}`);
1442
+ }
1443
+ await this.change(40);
1444
+ }
1445
+ }
1446
+ stop() {
1447
+ return this.stopPromise ??= this.stopOwned();
1448
+ }
1449
+ rejectedBeforeRemoteAdmission() {
1450
+ if (this.prepared || this.acknowledged) return false;
1451
+ if (this.exit !== 255) return false;
1452
+ const lines = this.stderr.split("\n").map((line) => line.replace(/\r$/, ""));
1453
+ if (lines.some((line) => /^ssh: connect to host [^\r\n]+ port [0-9]+: (?:Connection refused|No route to host|Network is unreachable|Connection timed out)$/.test(line))) return true;
1454
+ if (lines.some((line) => /^[^\r\n ]+@[^\r\n ]+: Permission denied \((?:publickey|keyboard-interactive|password)(?:,[a-z-]+)*\)\.$/.test(line))) return true;
1455
+ const forward = this.argv[this.argv.indexOf("-L") + 1];
1456
+ const local = forward && /^127\.0\.0\.1:([0-9]+):/.exec(forward)?.[1];
1457
+ return !!local && this.argv.includes("ExitOnForwardFailure=yes") && lines.includes(`bind [127.0.0.1]:${local}: Address already in use`) && lines.includes("Could not request local forwarding.");
1458
+ }
1459
+ async stopOwned() {
1460
+ this.quiesce();
1461
+ if (!this.child.stdin.destroyed) this.child.stdin.end("STOP\n");
1462
+ while (this.token && !this.prepared && !this.acknowledged && !this.rejectedBeforeRemoteAdmission()) if (this.exit !== void 0 && this.recover) {
1463
+ await this.recover();
1464
+ this.acknowledged = true;
1465
+ } else await this.change(100);
1466
+ let timer;
1467
+ try {
1468
+ await Promise.race([this.child.done, new Promise((resolve$1) => {
1469
+ timer = setTimeout(() => {
1470
+ this.child.cancel();
1471
+ resolve$1();
1472
+ }, 2500);
1473
+ })]);
1474
+ await this.child.done;
1475
+ while (this.prepared && !this.acknowledged && performance.now() < this.cleanupDeadline) await this.change(Math.min(100, this.cleanupDeadline - performance.now()));
1476
+ } finally {
1477
+ if (timer) clearTimeout(timer);
1478
+ }
1479
+ }
1480
+ };
1481
+ /** Native groups plus one owned monotonic clock/control coprocess. */
1482
+ const supervisor = (leased = true, token) => `set -eu
1483
+ set -m
1484
+ pid=
1485
+ clockPid=
1486
+ tmp=
1487
+ ack=${token ? shQuote(marker(token, "CLEANED")) : "''"}
1488
+ extraCleanup() { :; }
1489
+ cleanup() {
1490
+ trap - EXIT
1491
+ trap '' HUP INT TERM # A second gate/lease signal cannot interrupt cleanup or its final ACK.
1492
+ extraCleanup
1493
+ for child in $pid $clockPid $(jobs -pr); do
1494
+ kill -TERM -- "-$child" 2>/dev/null || :
1495
+ sleep .1
1496
+ kill -KILL -- "-$child" 2>/dev/null || :
1497
+ wait "$child" 2>/dev/null || :
1498
+ while kill -0 -- "-$child" 2>/dev/null; do sleep .02; done
1499
+ done
1500
+ if [ -n "$tmp" ]; then rm -rf -- "$tmp"; fi
1501
+ if [ -n "$ack" ]; then printf '%s\n' "$ack" >&2; fi
1502
+ }
1503
+ trap cleanup EXIT
1504
+ trap 'exit 125' HUP INT TERM
1505
+ ${leased ? "exec {leaseInput}<&0" : ""}
1506
+ coproc CLOCK { ${leaseHelperCommand(leased ? "lease-clock" : "clock")}${leased ? " 3<&$leaseInput" : ""}; }
1507
+ clockPid=$CLOCK_PID
1508
+ exec {clockRead}<&\${CLOCK[0]}
1509
+ exec {clockWrite}>&\${CLOCK[1]}
1510
+ ${leased ? "exec {leaseInput}<&-" : ""}
1511
+ now=0
1512
+ phase=prepared
1513
+ leaseEnd=0
1514
+ renewCount=0
1515
+ clock() {
1516
+ previous=$now
1517
+ printf 'QUERY\n' >&$clockWrite || { echo 'rsync monotonic clock pipe failed; admission closed' >&2; exit 125; }
1518
+ IFS=' ' read -r now incomingEnd incomingCount incomingPhase <&$clockRead || { echo 'rsync monotonic clock EOF; admission closed' >&2; exit 125; }
1519
+ [[ "$now" =~ ^[0-9]+$ ]] && [ "$now" -ge "$previous" ] || { echo 'invalid monotonic rsync clock; admission closed' >&2; exit 125; }
1520
+ ${leased ? `[[ "$incomingEnd" =~ ^[0-9]+$ ]] && [[ "$incomingCount" =~ ^[0-9]+$ ]] || exit 125
1521
+ leaseEnd=$incomingEnd
1522
+ renewCount=$incomingCount
1523
+ phase=$incomingPhase
1524
+ [ "$phase" = prepared ] || [ "$phase" = active ] || exit 125` : ""}
1525
+ }
1526
+ publish() { :; }
1527
+ control() {
1528
+ clock
1529
+ [ "$phase" = active ] || return 1
1530
+ publish
1531
+ sleep .1
1532
+ }
1533
+ `;
1534
+ const leasedCommand = (script, token) => "bash -c " + shQuote(supervisor(true, token) + `clock
1535
+ [ "$phase" = prepared ] && [ "$now" -lt "$leaseEnd" ] && [ "$leaseEnd" -le $((now + 8000)) ] || { echo "native supervisor: prepared fence failed now=$now leaseEnd=$leaseEnd phase=$phase" >&2; exit 125; }
1536
+ printf '%s\n' ${shQuote(marker(token, "PREPARED"))} >&2
1537
+ while :; do clock; [ "$phase" = prepared ] || break; sleep .02; done
1538
+ [ "$phase" = active ] || { echo "native supervisor: admission not activated phase=$phase now=$now leaseEnd=$leaseEnd" >&2; exit 125; }
1539
+ ` + script);
1540
+ async function startRecoverable(connection, command, token, signal, ssh, extra = []) {
1541
+ const clock = await runCommand(commandArgv(connection, "bash -c " + shQuote("(( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1) ))") + " && " + recoveryHelperCommand("recovery-clock"), ssh), signal);
1542
+ if (clock.exit !== 0) throw new Error(`native receiving recovery prerequisite failed: Bash4.1+, Python3.7+ (macOS3.10+), Unix fcntl.flock and shared monotonic clock required: ${clock.stderr}`);
1543
+ const value = JSON.parse(clock.stdout);
1544
+ if (!Number.isSafeInteger(value.tick) || value.tick < 0 || typeof value.base !== "string" || !value.base.startsWith("/") || /[\0\r\n]/.test(value.base)) throw new Error("invalid rsync receiving recovery clock");
1545
+ const gate = {
1546
+ root: value.base.replace(/\/$/, "") + "/superd-rsync-recovery." + token,
1547
+ since: value.tick,
1548
+ cutoff: value.tick + LEASE_MS,
1549
+ token
1550
+ };
1551
+ const recover = async () => {
1552
+ for (;;) {
1553
+ const result = await runCommand(commandArgv(connection, recoveryHelperCommand("recovery-observe", gate), ssh), new AbortController().signal);
1554
+ if (result.exit === 0 && result.stdout.trim() === marker(token, "CLEANED")) return;
1555
+ await new Promise((resolve$1) => setTimeout(resolve$1, 500));
1556
+ }
1557
+ };
1558
+ signal.throwIfAborted();
1559
+ return new Command(commandArgv(connection, "bash -c " + shQuote(recoveryHelperCommand("recovery-run", {
1560
+ ...gate,
1561
+ command
1562
+ })), ssh, extra), signal, token, recover);
1563
+ }
1564
+ async function startLeased(connection, script, signal, ssh, extra = []) {
1565
+ const token = randomUUID();
1566
+ return startRecoverable(connection, leasedCommand(script, token), token, signal, ssh, extra);
1567
+ }
1568
+ const commandArgv = (connection, command, ssh, extra = []) => connection ? "commandArgv" in connection ? connection.commandArgv(command) : ssh(connection, {
1569
+ extraArgs: [...RELAY_SSH_OPTIONS, ...extra],
1570
+ command
1571
+ }) : [
1572
+ "bash",
1573
+ "-c",
1574
+ command
1575
+ ];
1576
+ async function runCommand(argv, signal) {
1577
+ const command = new Command(argv, signal);
1578
+ try {
1579
+ return await command.result();
1580
+ } finally {
1581
+ await command.stop();
1582
+ }
1583
+ }
1584
+ async function preflightRsync(connection, signal, ssh = sshArgv) {
1585
+ const test = `set -eu
1586
+ (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1) )) || { echo 'rsync requires Bash4.1+ coproc and dynamic file descriptors' >&2; exit 69; }
1587
+ coproc CLOCK { ${leaseHelperCommand("clock")}; }
1588
+ pid=$CLOCK_PID
1589
+ trap 'kill "$pid" 2>/dev/null || :; wait "$pid" 2>/dev/null || :' EXIT
1590
+ exec {reader}<&\${CLOCK[0]}
1591
+ exec {writer}>&\${CLOCK[1]}
1592
+ printf 'QUERY\\n' >&$writer
1593
+ IFS= read -r value <&$reader
1594
+ [[ "$value" =~ ^[0-9]+$ ]]
1595
+ `;
1596
+ const result = await runCommand(commandArgv(connection, "command -v bash >/dev/null && command -v rsync >/dev/null" + (connection ? " && bash -c " + shQuote(test) : ""), ssh), signal);
1597
+ if (result.exit !== 0) throw new Error(`rsync requires Bash/native rsync on the origin and Bash4.1+, native rsync, Python3.7+ monotonic clock/coprocess channel on SSH endpoints (no copy fallback): exit ${result.exit}; ${result.stderr}`);
1598
+ }
1599
+ /** Inspect link presence, not filenames: newline-bearing names cannot hide a link.
1600
+ * find owns a separate leased group, including while a large tree is inspected. */
1601
+ async function preflightRelaySource(connection, path$1, signal, ssh = sshArgv) {
1602
+ await preflightRsync(connection, signal, ssh);
1603
+ const source = path$1.replace(/\/+$/, "") || "/";
1604
+ const command = await startLeased(connection, `command -v find >/dev/null || { echo 'SSH relay source inspection requires native find (no copy fallback)' >&2; exit 69; }
1605
+ if [ -e ${shQuote(source)} ] || [ -L ${shQuote(source)} ]; then
1606
+ umask 077
1607
+ tmp=$(mktemp -d "\${TMPDIR:-/tmp}/superd-rsync-inspect.XXXXXXXX")
1608
+ clock; [ "$now" -lt "$leaseEnd" ] || exit 125
1609
+ find ${shQuote(source)} -type l -print > "$tmp/links" &
1610
+ pid=$!
1611
+ while kill -0 "$pid" 2>/dev/null; do control || exit 125; done
1612
+ set +e
1613
+ wait "$pid"
1614
+ status=$?
1615
+ set -e
1616
+ [ "$status" = 0 ] || exit "$status"
1617
+ [ ! -s "$tmp/links" ] || { echo 'rsync SSH relay source symlinks are unsupported: confined non-chroot daemon cannot preserve their values. Use an ordinary local-SSH rsync leg or materialize files first.' >&2; exit 64; }
1618
+ fi`, signal, ssh);
1619
+ try {
1620
+ const result = await command.result();
1621
+ if (result.exit !== 0) throw new Error(`rsync relay source inspection: exit ${result.exit}; ${result.stderr}`);
1622
+ } finally {
1623
+ await command.stop();
1624
+ }
1625
+ }
1626
+ async function availablePort() {
1627
+ const server = createServer();
1628
+ await new Promise((resolve$1, reject) => {
1629
+ server.once("error", reject);
1630
+ server.listen(0, "127.0.0.1", resolve$1);
1631
+ });
1632
+ const address = server.address();
1633
+ if (!address || typeof address === "string") throw new Error("cannot allocate SSH relay loopback port");
1634
+ await new Promise((resolve$1, reject) => server.close((error) => error ? reject(error) : resolve$1()));
1635
+ return address.port;
1636
+ }
1637
+ async function withSshRelay(source, destination, targetPath, signal, run, options = {}) {
1638
+ if (!targetPath.startsWith("/") || /[\0\r\n]/.test(targetPath) || targetPath.trim() !== targetPath) throw new Error("rsync daemon destination must be an absolute existing directory without NUL/newline or leading/trailing whitespace");
1639
+ if (targetPath.includes("%")) throw new Error("rsync SSH relay destination percent signs are unsupported by daemon config path expansion; use an ordinary local-SSH rsync leg or rename the directory (no relay writes performed)");
1640
+ const target = targetPath.replace(/\/+$/, "") || "/";
1641
+ if (target.trim() !== target) throw new Error("rsync daemon normalized destination must not end in whitespace (no relay writes performed)");
1642
+ const ssh = options.ssh ?? sshArgv;
1643
+ const chosen = /* @__PURE__ */ new Set();
1644
+ if (!options.ports) while (chosen.size < 3) chosen.add(await availablePort());
1645
+ const [d, o, s] = [...chosen];
1646
+ const ports = options.ports ?? {
1647
+ destination: d,
1648
+ origin: o,
1649
+ source: s
1650
+ };
1651
+ for (const value of Object.values(ports)) if (!Number.isInteger(value) || value < 1024 || value > 65535) throw new Error("SSH relay requires unprivileged high ports");
1652
+ let dest;
1653
+ let proxy;
1654
+ let closed = false;
1655
+ const senders = /* @__PURE__ */ new Set();
1656
+ let outcome;
1657
+ try {
1658
+ await preflightRsync(source, signal, ssh);
1659
+ await preflightRsync(destination, signal, ssh);
1660
+ dest = await startLeased(destination, `[ -d ${shQuote(target)} ] && [ ! -L ${shQuote(target)} ] || { echo 'rsync remote relay requires an existing non-symlink destination directory; files/new paths are unsupported (module not widened)' >&2; exit 64; }
1661
+ umask 077
1662
+ tmp=$(mktemp -d "\${TMPDIR:-/tmp}/superd-rsync.XXXXXXXX")
1663
+ printf '%s\\n' 'use chroot = no' 'read only = no' 'munge symlinks = yes' 'hosts allow = 127.0.0.1' 'hosts deny = *' '[task]' ${shQuote("path = " + target)} > "$tmp/rsyncd.conf"
1664
+ clock; [ "$now" -lt "$leaseEnd" ] || exit 125
1665
+ rsync --daemon --no-detach --address=127.0.0.1 --port=${ports.destination} --config="$tmp/rsyncd.conf" &
1666
+ pid=$!
1667
+ for i in $(seq 1 100); do
1668
+ clock; [ "$now" -lt "$leaseEnd" ] || exit 125
1669
+ kill -0 "$pid" || exit 70
1670
+ if (exec 3<>/dev/tcp/127.0.0.1/${ports.destination}) 2>/dev/null; then break; fi
1671
+ sleep .05
1672
+ done
1673
+ kill -0 "$pid"
1674
+ printf 'READY %s %s\\n' "$pid" "$tmp"
1675
+ while kill -0 "$pid" 2>/dev/null; do control || exit 125; done
1676
+ wait "$pid"
1677
+ `, signal, ssh, ["-L", `127.0.0.1:${ports.origin}:127.0.0.1:${ports.destination}`]);
1678
+ await dest.ready(/^READY \d+ \S+$/m);
1679
+ outcome = await withNativeCommandOwner(source, signal, async (owner) => {
1680
+ proxy = startSourceProxy(commandArgv(source, leaseHelperCommand("native-command", {
1681
+ directory: owner,
1682
+ argv: [
1683
+ "python3",
1684
+ "-u",
1685
+ "-c",
1686
+ SOURCE_PROXY_HELPER,
1687
+ String(ports.source)
1688
+ ]
1689
+ }), ssh), ports.origin, signal);
1690
+ await Promise.race([proxy.ready, proxy.failed]);
1691
+ return await Promise.race([proxy.failed, run({
1692
+ url: `rsync://127.0.0.1:${ports.source}/task/`,
1693
+ async run(argv) {
1694
+ signal.throwIfAborted();
1695
+ if (closed) throw new Error("SSH relay admission closed");
1696
+ const sender = await startLeased(source, `LC_ALL=C ${argv.map(shQuote).join(" ")} <&0 &\npid=$!\nwhile kill -0 "$pid" 2>/dev/null; do control || exit 125; done\nset +e\nwait "$pid"\nexit $?\n`, signal, ssh);
1697
+ senders.add(sender);
1698
+ try {
1699
+ const result = await sender.result();
1700
+ const tunnel = [dest?.stderrOutput(), proxy?.stderr().replace("SOURCE_PROXY_READY\n", "")].filter(Boolean).join("\n");
1701
+ const stderr = result.stderr + (tunnel ? "\n[tunnel stderr]\n" + tunnel : "");
1702
+ return {
1703
+ ...result,
1704
+ stderr: stderr.slice(-OUTPUT_LIMIT),
1705
+ outputTruncated: result.outputTruncated || !!dest?.truncated || stderr.length > OUTPUT_LIMIT
1706
+ };
1707
+ } finally {
1708
+ await sender.stop();
1709
+ senders.delete(sender);
1710
+ }
1711
+ }
1712
+ })]);
1713
+ }, ssh);
1714
+ signal.throwIfAborted();
1715
+ return outcome;
1716
+ } catch (error) {
1717
+ const native = [dest?.stderrOutput(), proxy?.stderr()].filter(Boolean).join("\n");
1718
+ if (signal.aborted) throw signal.reason;
1719
+ throw new Error(`${error instanceof Error ? error.message : String(error)}${native ? "\n[tunnel stderr]\n" + native : ""}`, { cause: error });
1720
+ } finally {
1721
+ closed = true;
1722
+ dest?.quiesce();
1723
+ for (const sender of senders) sender.quiesce();
1724
+ try {
1725
+ for (const sender of senders) await sender.stop();
1726
+ } finally {
1727
+ try {
1728
+ await proxy?.close();
1729
+ } finally {
1730
+ await dest?.stop();
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+ /** Portable sideband ownership: work stdin stays exclusively with the native command. */
1736
+ async function withNativeCommandOwner(connection, signal, run, ssh = sshArgv) {
1737
+ signal.throwIfAborted();
1738
+ const controller = await startLeased(connection, `for executable in mktemp mv rm sleep; do
1739
+ command -v "$executable" >/dev/null || { printf 'native receiving control requires %s (no transfer admitted)\\n' "$executable" >&2; exit 69; }
1740
+ done
1741
+ umask 077
1742
+ tmp=$(mktemp -d "\${TMPDIR:-/tmp}/superd-rsync-native.XXXXXXXX")
1743
+ extraCleanup() {
1744
+ rm -f -- "$tmp/active"
1745
+ helpers=()
1746
+ groups=()
1747
+ clocks=()
1748
+ for record in "$tmp"/server.*; do
1749
+ [ -f "$record" ] || continue
1750
+ helper=\${record##*/server.}
1751
+ [[ "$helper" =~ ^[0-9]+$ ]] || continue
1752
+ group=0
1753
+ clockGroup=0
1754
+ IFS=' ' read -r ignored group clockGroup < "$record" || :
1755
+ [[ "$group" =~ ^[1-9][0-9]*$ ]] || group=0
1756
+ [[ "$clockGroup" =~ ^[1-9][0-9]*$ ]] || clockGroup=0
1757
+ helpers+=("$helper")
1758
+ groups+=("$group")
1759
+ clocks+=("$clockGroup")
1760
+ [ "$group" = 0 ] || kill -TERM -- "-$group" 2>/dev/null || :
1761
+ [ "$clockGroup" = 0 ] || kill -TERM -- "-$clockGroup" 2>/dev/null || :
1762
+ kill -TERM -- "$helper" 2>/dev/null || :
1763
+ done
1764
+ sleep .25
1765
+ for group in "\${groups[@]}"; do [ "$group" = 0 ] || kill -KILL -- "-$group" 2>/dev/null || :; done
1766
+ for group in "\${clocks[@]}"; do [ "$group" = 0 ] || kill -KILL -- "-$group" 2>/dev/null || :; done
1767
+ for helper in "\${helpers[@]}"; do kill -KILL -- "$helper" 2>/dev/null || :; done
1768
+ for group in "\${groups[@]}" "\${clocks[@]}"; do
1769
+ [ "$group" = 0 ] && continue
1770
+ while kill -0 -- "-$group" 2>/dev/null; do sleep .02; done
1771
+ done
1772
+ for helper in "\${helpers[@]}"; do while kill -0 "$helper" 2>/dev/null; do sleep .02; done; done
1773
+ }
1774
+ publish() {
1775
+ printf '%s %s\\n' "$((leaseEnd - now))" "$renewCount" > "$tmp/lease.new"
1776
+ mv -- "$tmp/lease.new" "$tmp/lease"
1777
+ }
1778
+ publish
1779
+ clock; [ "$now" -lt "$leaseEnd" ] || { echo "native owner: lease expired before activation now=$now leaseEnd=$leaseEnd" >&2; exit 125; }
1780
+ printf '%s\n' active > "$tmp/active"
1781
+ printf 'OWNER %s\n' "$tmp"
1782
+ while :; do control || { [ "$phase" = closed ] || echo "native owner: control lost while phase=$phase now=$now leaseEnd=$leaseEnd" >&2; exit 125; }; done
1783
+ `, signal, ssh);
1784
+ try {
1785
+ const directory = (await controller.ready(/^OWNER (.+)$/m))[1];
1786
+ if (!directory.startsWith("/") || /[\0\r\n]/.test(directory)) throw new Error("native receiving ownership path is not representable");
1787
+ const result = await run(directory);
1788
+ signal.throwIfAborted();
1789
+ return result;
1790
+ } finally {
1791
+ await controller.stop();
1792
+ }
1793
+ }
1794
+ /** Registration precedes the second activation fence and protocol child admission.
1795
+ * Controller and server use counted increments, never refresh leases from late arrival. */
1796
+ function nativeRsyncPath(directory) {
1797
+ return "bash -c " + shQuote(supervisor(false) + `owner=${shQuote(directory)}
1798
+ record="$owner/server.$$"
1799
+ extraCleanup() { :; }
1800
+ [ -f "$owner/active" ] || exit 125
1801
+ printf '%s 0 %s\\n' "$$" "$clockPid" > "$record"
1802
+ IFS=' ' read -r remaining seen < "$owner/lease"
1803
+ [[ "$remaining" =~ ^[1-9][0-9]*$ ]] && [[ "$seen" =~ ^[0-9]+$ ]] || exit 125
1804
+ clock
1805
+ leaseEnd=$((now + remaining))
1806
+ [ -f "$owner/active" ] || exit 125
1807
+ rsync "$@" <&0 &
1808
+ pid=$!
1809
+ printf '%s %s %s\\n' "$$" "$pid" "$clockPid" > "$record"
1810
+ while kill -0 "$pid" 2>/dev/null; do
1811
+ clock; [ "$now" -lt "$leaseEnd" ] && [ -f "$owner/active" ] || exit 125
1812
+ IFS=' ' read -r remaining count < "$owner/lease" || exit 125
1813
+ [[ "$remaining" =~ ^[1-9][0-9]*$ ]] && [[ "$count" =~ ^[0-9]+$ ]] || exit 125
1814
+ [ "$count" -ge "$seen" ] || exit 125
1815
+ leaseEnd=$((leaseEnd + (count - seen) * 2000))
1816
+ seen=$count
1817
+ [ "$leaseEnd" -le $((now + 8000)) ] || leaseEnd=$((now + 8000))
1818
+ sleep .1
1819
+ done
1820
+ set +e
1821
+ wait "$pid"
1822
+ status=$?
1823
+ exit "$status"
1824
+ `) + " rsync-server";
1825
+ }
1826
+ const withNativeRsyncOwner = withNativeCommandOwner;
1827
+
1828
+ //#endregion
1829
+ //#region src/remote/rsync.ts
1830
+ const rshQuote = (value) => "'" + value.replaceAll("'", "''") + "'";
1831
+ async function runRsync(request, source, destination, signal, options = {}) {
1832
+ signal.throwIfAborted();
1833
+ checkAbsolute(source.path);
1834
+ checkAbsolute(destination.path);
1835
+ if (!source.connection && !destination.connection) throw new Error("rsync requires at least one SSH endpoint; local-only is unsupported");
1836
+ if (request.excludes !== void 0 && (!Array.isArray(request.excludes) || request.excludes.some((value) => typeof value !== "string" || value.includes("\0")))) throw new Error("rsync excludes must be strings without NUL");
1837
+ const dryRun = request.dryRun ?? true, remove = request.delete ?? false, preserve = request.preserve ?? true;
1838
+ const args = [
1839
+ "rsync",
1840
+ preserve ? "-a" : "-r",
1841
+ "--no-compress",
1842
+ "--stats",
1843
+ "--itemize-changes",
1844
+ "--timeout=10",
1845
+ ...dryRun ? ["--dry-run"] : [],
1846
+ ...remove ? ["--delete"] : [],
1847
+ ...(request.excludes ?? []).map((value) => "--exclude=" + value)
1848
+ ];
1849
+ const ssh = options.ssh ?? sshArgv;
1850
+ let result;
1851
+ if (source.connection && destination.connection) {
1852
+ await preflightRelaySource(source.connection, source.path, signal, ssh);
1853
+ result = await withSshRelay(source.connection, destination.connection, destination.path, signal, (relay) => relay.run([
1854
+ ...args,
1855
+ "--",
1856
+ source.path,
1857
+ relay.url
1858
+ ]), options);
1859
+ } else {
1860
+ const remote = source.connection ?? destination.connection;
1861
+ await preflightRsync(void 0, signal, ssh);
1862
+ await preflightRsync(remote, signal, ssh);
1863
+ const transport = [
1864
+ "bash",
1865
+ "-c",
1866
+ "shift; exec " + ssh(remote, { extraArgs: RELAY_SSH_OPTIONS }).map(shQuote).join(" ") + " \"$@\"",
1867
+ "rsync-ssh"
1868
+ ].map(rshQuote).join(" ");
1869
+ const src = source.connection ? `rsync-peer:${source.path}` : source.path;
1870
+ const dst = destination.connection ? `rsync-peer:${destination.path}` : destination.path;
1871
+ result = await withNativeRsyncOwner(remote, signal, (directory) => {
1872
+ return runCommand([
1873
+ "bash",
1874
+ "-c",
1875
+ "LC_ALL=C exec " + [
1876
+ ...args,
1877
+ "--secluded-args",
1878
+ "-e",
1879
+ transport,
1880
+ "--rsync-path=" + nativeRsyncPath(directory),
1881
+ "--",
1882
+ src,
1883
+ dst
1884
+ ].map(shQuote).join(" ")
1885
+ ], signal);
1886
+ }, ssh);
1887
+ }
1888
+ const number = (label) => {
1889
+ const match = new RegExp(`^${label}: ([\\d,]+)(?: bytes)?$`, "m").exec(result.stdout);
1890
+ return match ? Number(match[1].replaceAll(",", "")) : void 0;
1891
+ };
1892
+ const sentBytes = number("Total bytes sent"), receivedBytes = number("Total bytes received");
1893
+ return {
1894
+ ...result,
1895
+ action: "rsync",
1896
+ src: request.src,
1897
+ dst: request.dst,
1898
+ dryRun,
1899
+ delete: remove,
1900
+ preserve,
1901
+ itemizedChanges: result.stdout.split("\n").filter((line) => /^[<>ch.*][fdLDS.]/.test(line) || line.startsWith("*deleting")),
1902
+ literalBytes: number("Literal data"),
1903
+ matchedBytes: number("Matched data"),
1904
+ sentBytes,
1905
+ receivedBytes,
1906
+ transferredBytes: sentBytes !== void 0 && receivedBytes !== void 0 ? sentBytes + receivedBytes : void 0
1907
+ };
1908
+ }
1909
+
1910
+ //#endregion
1911
+ //#region src/remote/ssh-process.ts
1912
+ /** The sideband owns receiving cleanup; the separate native work transport keeps literal stdio. */
1913
+ function startOwnedNativeProcess(plan, cmd, options) {
1914
+ const started = Date.now(), abort = new AbortController(), ssh = options.ssh ?? sshArgv;
1915
+ const connection = plan.kind === "ssh" ? plan.connection ?? { host: plan.host } : { commandArgv: (command) => buildOneShotArgv(plan, command) };
1916
+ const workArgv = (command) => "commandArgv" in connection ? connection.commandArgv(command) : ssh(connection, {
1917
+ extraArgs: [...RELAY_SSH_OPTIONS],
1918
+ command
1919
+ });
1920
+ let timedOut = false, raw, diagnostic;
1921
+ let complete;
1922
+ const done = new Promise((resolve$1) => {
1923
+ complete = resolve$1;
1924
+ });
1925
+ let admit, reject;
1926
+ const ready = new Promise((resolve$1, fail) => {
1927
+ admit = resolve$1;
1928
+ reject = fail;
1929
+ });
1930
+ const cancel = () => abort.abort(/* @__PURE__ */ new Error("remote command cancelled"));
1931
+ options.signal.addEventListener("abort", cancel, { once: true });
1932
+ if (options.signal.aborted) cancel();
1933
+ const timer = options.timeoutSec === void 0 ? void 0 : setTimeout(() => {
1934
+ timedOut = true;
1935
+ cancel();
1936
+ }, options.timeoutSec * 1e3);
1937
+ const operation = withNativeCommandOwner(connection, abort.signal, async (directory) => {
1938
+ abort.signal.throwIfAborted();
1939
+ raw = startOneShot(workArgv(leaseHelperCommand("native-command", {
1940
+ directory,
1941
+ argv: [
1942
+ "bash",
1943
+ "-lc",
1944
+ cmd
1945
+ ],
1946
+ subreaper: plan.kind !== "ssh"
1947
+ })), { stdin: options.stdin });
1948
+ admit({
1949
+ done,
1950
+ cancel,
1951
+ read(channel, cursor) {
1952
+ const value = raw.read(channel, cursor);
1953
+ return channel === "stderr" && diagnostic ? {
1954
+ ...value,
1955
+ diagnostic
1956
+ } : value;
1957
+ }
1958
+ });
1959
+ let aborted;
1960
+ const interrupted = new Promise((_, fail) => {
1961
+ aborted = () => fail(abort.signal.reason);
1962
+ });
1963
+ abort.signal.addEventListener("abort", aborted, { once: true });
1964
+ if (abort.signal.aborted) aborted();
1965
+ try {
1966
+ return await Promise.race([raw.done, interrupted]);
1967
+ } finally {
1968
+ abort.signal.removeEventListener("abort", aborted);
1969
+ }
1970
+ }, ssh);
1971
+ (async () => {
1972
+ let result;
1973
+ try {
1974
+ result = await operation;
1975
+ } catch (error) {
1976
+ if (!abort.signal.aborted) diagnostic = String(error);
1977
+ if (!raw) reject(error);
1978
+ } finally {
1979
+ if (raw) {
1980
+ let drainTimer;
1981
+ const drained = await Promise.race([raw.done, new Promise((resolve$1) => {
1982
+ drainTimer = setTimeout(() => resolve$1(void 0), 2500);
1983
+ })]);
1984
+ if (drainTimer) clearTimeout(drainTimer);
1985
+ if (!drained) {
1986
+ diagnostic = [diagnostic, "remote: receiver cleanup confirmed; work output drain unavailable after 2500ms, output may be incomplete (stats count received data only)"].filter(Boolean).join("\n");
1987
+ raw.cancel();
1988
+ }
1989
+ result = drained ?? await raw.done;
1990
+ }
1991
+ if (timer) clearTimeout(timer);
1992
+ options.signal.removeEventListener("abort", cancel);
1993
+ complete({
1994
+ exit: diagnostic ? null : result?.exit ?? null,
1995
+ cancelled: abort.signal.aborted,
1996
+ timedOut,
1997
+ durationMs: Date.now() - started
1998
+ });
1999
+ }
2000
+ })();
2001
+ return ready;
2002
+ }
2003
+ /** Raw file/protocol lane: no text rings or successful incomplete-output deadline.
2004
+ * Cancellation stops the receiver through its independent owner before the local
2005
+ * work transport is torn down. Normal completion requires the work stream close.
2006
+ */
2007
+ function startOwnedBinaryProcess(plan, argv, signal) {
2008
+ const started = Date.now(), abort = new AbortController();
2009
+ const connection = plan.kind === "ssh" ? plan.connection ?? { host: plan.host } : { commandArgv: (command) => buildOneShotArgv(plan, command) };
2010
+ const workArgv = (command) => "commandArgv" in connection ? connection.commandArgv(command) : sshArgv(connection, {
2011
+ extraArgs: [...RELAY_SSH_OPTIONS],
2012
+ command
2013
+ });
2014
+ let raw, diagnostic;
2015
+ let complete;
2016
+ const done = new Promise((resolve$1) => {
2017
+ complete = resolve$1;
2018
+ });
2019
+ let admit, reject;
2020
+ const ready = new Promise((resolve$1, fail) => {
2021
+ admit = resolve$1;
2022
+ reject = fail;
2023
+ });
2024
+ const cancel = () => abort.abort(signal.reason ?? /* @__PURE__ */ new Error("remote binary operation cancelled"));
2025
+ signal.addEventListener("abort", cancel, { once: true });
2026
+ if (signal.aborted) cancel();
2027
+ const operation = withNativeCommandOwner(connection, abort.signal, async (directory) => {
2028
+ abort.signal.throwIfAborted();
2029
+ raw = startBinaryProcess(workArgv(leaseHelperCommand("native-command", {
2030
+ directory,
2031
+ argv,
2032
+ subreaper: plan.kind !== "ssh"
2033
+ })));
2034
+ admit({
2035
+ ...raw,
2036
+ cancel,
2037
+ done,
2038
+ diagnostic: () => diagnostic ?? raw.diagnostic()
2039
+ });
2040
+ let interrupted;
2041
+ const interruption = new Promise((_, fail) => {
2042
+ interrupted = () => fail(abort.signal.reason);
2043
+ });
2044
+ abort.signal.addEventListener("abort", interrupted, { once: true });
2045
+ if (abort.signal.aborted) interrupted();
2046
+ try {
2047
+ return await Promise.race([raw.done, interruption]);
2048
+ } finally {
2049
+ abort.signal.removeEventListener("abort", interrupted);
2050
+ }
2051
+ });
2052
+ (async () => {
2053
+ let result;
2054
+ try {
2055
+ result = await operation;
2056
+ } catch (error) {
2057
+ diagnostic = String(error);
2058
+ if (!raw) reject(error);
2059
+ } finally {
2060
+ if (raw) {
2061
+ if (!result) raw.cancel();
2062
+ result = await raw.done;
2063
+ }
2064
+ signal.removeEventListener("abort", cancel);
2065
+ complete({
2066
+ exit: diagnostic ? null : result?.exit ?? null,
2067
+ cancelled: abort.signal.aborted,
2068
+ timedOut: false,
2069
+ durationMs: Date.now() - started
2070
+ });
2071
+ }
2072
+ })();
2073
+ return ready;
2074
+ }
2075
+
2076
+ //#endregion
2077
+ //#region src/remote/archive-python.ts
2078
+ /** CLI peer implementation of archive.ts carrier v1. Never installed or persisted. */
2079
+ const FILE_PYTHON = String.raw`
2080
+ import os,sys,json,hashlib,struct,stat,tempfile,shutil,posixpath,signal
2081
+ def interrupted(signum,frame): raise InterruptedError('binary transfer cancelled')
2082
+ for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,interrupted)
2083
+ LIMIT=65536
2084
+ MAX_ENTRIES=10000
2085
+ MAX_META=8388608
2086
+ op,root=sys.argv[1:3]
2087
+ def relative(p):
2088
+ if not isinstance(p,str) or len(p.encode())>4096 or '\0' in p or (p and (p.startswith('/') or any(x in ('','.','..') for x in p.split('/')))): raise ValueError('unsafe archive relative path')
2089
+ def absolute(p):
2090
+ if not p.startswith('/') or '\0' in p or '..' in p.split('/'): raise ValueError('absolute file path without traversal required')
2091
+ def ancestors(p):
2092
+ absolute(p)
2093
+ parts=os.path.abspath(p).split('/')[1:-1]
2094
+ for i in range(1,len(parts)+1):
2095
+ try:
2096
+ st=os.lstat('/'+'/'.join(parts[:i]))
2097
+ if not stat.S_ISDIR(st.st_mode): raise ValueError('unsafe symlink or non-directory ancestor')
2098
+ except FileNotFoundError: pass
2099
+ def linkcheck(e):
2100
+ link=e.get('link')
2101
+ if not e['p'] or not isinstance(link,str) or not link or link.startswith('/') or '\0' in link or len(link.encode())>4096: raise ValueError('unsafe archive symlink')
2102
+ target=posixpath.normpath(posixpath.join(posixpath.dirname(e['p']),link))
2103
+ if target=='..' or target.startswith('../'): raise ValueError('unsafe archive symlink escape')
2104
+ def tree_links(entries):
2105
+ incoming={e['p']:e for e in entries}
2106
+ for link in entries:
2107
+ if link['t']!='l':continue
2108
+ parent=posixpath.dirname(link['p']);stack=parent.split('/') if parent else [];pending=link['link'].split('/');hops=0
2109
+ while pending:
2110
+ part=pending.pop(0)
2111
+ if part in ('','.'):continue
2112
+ if part=='..':
2113
+ if not stack:raise ValueError('unsafe symlink chain escape')
2114
+ stack.pop();continue
2115
+ e=incoming.get('/'.join(stack+[part]))
2116
+ if not e:raise ValueError('unsafe symlink target outside incoming tree (destination aliases are not followed)')
2117
+ if e['t']=='l':
2118
+ hops+=1
2119
+ if hops>40:raise ValueError('unsafe symlink cycle or chain limit')
2120
+ pending=e['link'].split('/')+pending
2121
+ else:
2122
+ if pending and e['t']!='d':raise ValueError('unsafe symlink non-directory target ancestor')
2123
+ stack.append(part)
2124
+ def encode(o): return json.dumps(o,ensure_ascii=False,separators=(',',':')).encode()
2125
+ def header(o):
2126
+ b=encode(o)
2127
+ return struct.pack('>I',len(b))+b
2128
+ def entry(p):
2129
+ relative(p);path=os.path.join(root,p) if p else root;ancestors(path);st=os.lstat(path)
2130
+ if stat.S_ISDIR(st.st_mode): return dict(p=p,t='d',n=0)
2131
+ if stat.S_ISLNK(st.st_mode):
2132
+ e=dict(p=p,t='l',n=0,link=os.readlink(path));linkcheck(e);return e
2133
+ if not stat.S_ISREG(st.st_mode): raise ValueError('unsupported file type')
2134
+ h=hashlib.sha256()
2135
+ with os.fdopen(os.open(path,os.O_RDONLY|os.O_NOFOLLOW),'rb') as f:
2136
+ for data in iter(lambda:f.read(LIMIT),b''):h.update(data)
2137
+ return dict(p=p,t='f',n=st.st_size,hash=h.hexdigest())
2138
+ def tree(selection=None):
2139
+ entries=[]
2140
+ def visit(p):
2141
+ e=entry(p);entries.append(e)
2142
+ if len(entries)>MAX_ENTRIES:raise ValueError('archive entry limit')
2143
+ if e['t']=='d':
2144
+ for name in sorted(os.listdir(os.path.join(root,p)),key=lambda x:x.encode()):visit(posixpath.join(p,name))
2145
+ if selection is None:visit('')
2146
+ else:
2147
+ if not isinstance(selection,list) or len(selection)>MAX_ENTRIES or len(encode(selection))>MAX_META or len(set(selection))!=len(selection):raise ValueError('invalid selection limit/duplicates')
2148
+ entries=[entry(p) for p in selection]
2149
+ if len(encode(entries))>MAX_META:raise ValueError('archive metadata limit')
2150
+ return entries
2151
+ def digest(entries):
2152
+ h=hashlib.sha256();size=0
2153
+ for e in sorted(entries,key=lambda e:e['p'].encode()):
2154
+ h.update(header([e['p'],e['t'],e['n'],e.get('hash','') if e['t']=='f' else e.get('link','') if e['t']=='l' else '']))
2155
+ if e['t']=='f':size+=e['n']
2156
+ return dict(sha256=h.hexdigest(),bytes=size,entries=len(entries))
2157
+ def readexact(f,n):
2158
+ b=f.read(n)
2159
+ if len(b)!=n:raise ValueError('truncated binary archive')
2160
+ return b
2161
+ def inspect(f):
2162
+ result=[];seen={};meta=0
2163
+ while True:
2164
+ n=struct.unpack('>I',readexact(f,4))[0]
2165
+ if not n or n>LIMIT:raise ValueError('archive header limit')
2166
+ meta+=n
2167
+ if meta>MAX_META:raise ValueError('archive metadata limit')
2168
+ e=json.loads(readexact(f,n))
2169
+ if e.get('end') is True:
2170
+ if f.read(1):raise ValueError('trailing archive bytes')
2171
+ break
2172
+ relative(e.get('p'));p=e['p']
2173
+ if e.get('t') not in ('f','d','l') or type(e.get('n')) is not int or not 0<=e['n']<=9007199254740991 or (e['t']!='f' and e['n']!=0):raise ValueError('unsafe archive type/size')
2174
+ if len(result)>=MAX_ENTRIES or p in seen:raise ValueError('archive duplicate or limit')
2175
+ if not result and p!='':raise ValueError('archive root missing')
2176
+ if p and seen.get(posixpath.dirname(p),{}).get('t')!='d':raise ValueError('unsafe archive parent')
2177
+ if e['t']=='l':linkcheck(e)
2178
+ e['offset']=f.tell();h=hashlib.sha256();left=e['n']
2179
+ while left:
2180
+ data=readexact(f,min(LIMIT,left));h.update(data);left-=len(data)
2181
+ if e['t']=='f':e['hash']=h.hexdigest()
2182
+ seen[p]=e;result.append(e)
2183
+ if not result:raise ValueError('archive root missing')
2184
+ return result
2185
+ absolute(root)
2186
+ if op=='export':
2187
+ for e in tree():
2188
+ sys.stdout.buffer.write(header({k:v for k,v in e.items() if k!='hash'}))
2189
+ if e['t']=='f':
2190
+ with os.fdopen(os.open(os.path.join(root,e['p']) if e['p'] else root,os.O_RDONLY|os.O_NOFOLLOW),'rb') as f:shutil.copyfileobj(f,sys.stdout.buffer,LIMIT)
2191
+ sys.stdout.buffer.write(header(dict(end=True)));sys.stdout.buffer.flush()
2192
+ elif op=='digest':
2193
+ raw=sys.stdin.buffer.read(MAX_META+1)
2194
+ if len(raw)>MAX_META:raise ValueError('selection metadata limit')
2195
+ print(json.dumps(digest(tree(json.loads(raw) if raw else None)),separators=(',',':')))
2196
+ elif op=='import':
2197
+ with tempfile.TemporaryFile() as f:
2198
+ shutil.copyfileobj(sys.stdin.buffer,f,LIMIT);f.seek(0);entries=inspect(f);tree_links(entries)
2199
+ for e in entries:
2200
+ path=os.path.join(root,e['p']) if e['p'] else root;ancestors(path)
2201
+ try:
2202
+ st=os.lstat(path);typ='l' if stat.S_ISLNK(st.st_mode) else 'd' if stat.S_ISDIR(st.st_mode) else 'f' if stat.S_ISREG(st.st_mode) else '?'
2203
+ if typ!=e['t'] or typ=='l':raise ValueError('destination path/type conflict or existing symlink')
2204
+ except FileNotFoundError:pass
2205
+ if sys.argv[3]=='true':os.makedirs(os.path.dirname(root),exist_ok=True)
2206
+ for e in entries:
2207
+ path=os.path.join(root,e['p']) if e['p'] else root;ancestors(path)
2208
+ if e['t']=='d':os.makedirs(path,exist_ok=True)
2209
+ elif e['t']=='l':os.symlink(e['link'],path)
2210
+ else:
2211
+ fd,temp=tempfile.mkstemp(prefix='.remote-copy-',dir=os.path.dirname(path))
2212
+ try:
2213
+ with os.fdopen(fd,'wb') as out:
2214
+ f.seek(e['offset']);left=e['n']
2215
+ while left:
2216
+ b=readexact(f,min(LIMIT,left));out.write(b);left-=len(b)
2217
+ ancestors(path);os.replace(temp,path)
2218
+ finally:
2219
+ if os.path.exists(temp):os.unlink(temp)
2220
+ else:raise ValueError('unsupported binary operation')
2221
+ `;
2222
+
2223
+ //#endregion
2224
+ //#region src/remote/endpoints.ts
2225
+ function parseFileEndpoint(value) {
2226
+ if (typeof value !== "string" || !value) throw new Error("file endpoint required");
2227
+ if (value.startsWith("/")) {
2228
+ checkAbsolute(value);
2229
+ return { path: value };
2230
+ }
2231
+ const split = value.lastIndexOf(":/");
2232
+ if (split < 1) throw new Error("file endpoint must be [target:]absolute/path");
2233
+ const target = value.slice(0, split), path$1 = value.slice(split + 1);
2234
+ checkAbsolute(path$1);
2235
+ if (target.startsWith("byop:")) throw new Error("BYO-PTY has no binary file lane; select SSH/Docker/Incus/Hub");
2236
+ return {
2237
+ target,
2238
+ path: path$1
41
2239
  };
42
- if (opts.timeoutSec !== void 0) {
43
- const killGroup = (sig) => {
44
- if (child.pid === void 0) {
45
- child.kill(sig);
46
- return;
2240
+ }
2241
+ /** Shared process-group lifecycle; binary stdout never enters the text output ring. */
2242
+ function cliFileEndpoint(plan) {
2243
+ const run = async (op, path$1, signal, mkdir$1 = false) => {
2244
+ checkAbsolute(path$1);
2245
+ signal.throwIfAborted();
2246
+ const child = await startOwnedBinaryProcess(plan, [
2247
+ "python3",
2248
+ "-c",
2249
+ FILE_PYTHON,
2250
+ op,
2251
+ path$1,
2252
+ String(mkdir$1)
2253
+ ], signal);
2254
+ let stderr = "";
2255
+ child.stderr.on("data", (chunk) => {
2256
+ stderr = (stderr + String(chunk)).slice(-8e3);
2257
+ });
2258
+ const cancel = () => child.cancel();
2259
+ signal.addEventListener("abort", cancel, { once: true });
2260
+ const done = child.done.then((result) => {
2261
+ signal.removeEventListener("abort", cancel);
2262
+ if (signal.aborted) throw signal.reason;
2263
+ if (result.exit !== 0) throw new Error(`binary CLI ${op} failed (receiving owner requires Bash 4.1+, Python 3.7+, fcntl and shared monotonic clock; containers also ctypes/subreaper): ${child.diagnostic() ?? stderr ?? result.exit}`);
2264
+ });
2265
+ done.catch(() => {});
2266
+ return {
2267
+ child,
2268
+ done
2269
+ };
2270
+ };
2271
+ return {
2272
+ async exportTree(path$1, signal) {
2273
+ const { child, done } = await run("export", path$1, signal);
2274
+ child.stdin.end();
2275
+ return Readable.from((async function* () {
2276
+ try {
2277
+ for await (const chunk of child.stdout) yield chunk;
2278
+ await done;
2279
+ } finally {
2280
+ child.cancel();
2281
+ await done.catch(() => {});
2282
+ }
2283
+ })());
2284
+ },
2285
+ async importTree(path$1, source, { mkdir: mkdir$1, signal }) {
2286
+ const { child, done } = await run("import", path$1, signal, mkdir$1);
2287
+ child.stdout.resume();
2288
+ try {
2289
+ await pipeline(source, child.stdin, { signal });
2290
+ await done;
2291
+ } catch (error) {
2292
+ child.cancel();
2293
+ await done;
2294
+ throw error;
2295
+ } finally {
2296
+ child.cancel();
2297
+ await done.catch(() => {});
47
2298
  }
2299
+ },
2300
+ async digest(path$1, signal, selection) {
2301
+ if (selection) checkSelection(selection);
2302
+ const { child, done } = await run("digest", path$1, signal);
2303
+ child.stdin.end(selection ? JSON.stringify(selection) : "");
2304
+ let data = "";
48
2305
  try {
49
- process.kill(-child.pid, sig);
50
- } catch {}
2306
+ for await (const chunk of child.stdout) {
2307
+ data += String(chunk);
2308
+ if (data.length > 8192) throw new Error("oversized digest response");
2309
+ }
2310
+ await done;
2311
+ return JSON.parse(data);
2312
+ } finally {
2313
+ child.cancel();
2314
+ await done.catch(() => {});
2315
+ }
2316
+ }
2317
+ };
2318
+ }
2319
+
2320
+ //#endregion
2321
+ //#region src/remote/input.ts
2322
+ /** Local carrier validation precedes any target discovery, connection or spawn. */
2323
+ async function prepareRemoteInput(input) {
2324
+ if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
2325
+ if (input.script !== void 0) {
2326
+ if (input.cmd !== void 0 || input.stdin !== void 0 || input.mode === "pty" || input.spawn !== void 0 || input.target?.startsWith("byop:")) throw new Error("[E_SCRIPT_PARAMS] remote: script is oneshot-only and cannot be combined with cmd, stdin, spawn or BYO-PTY");
2327
+ if (typeof input.script !== "string" || !isAbsolute(input.script) || input.script.includes("\0")) throw new Error("[E_SCRIPT_PATH] remote: script must be a local absolute path without NUL");
2328
+ }
2329
+ if (!input.target) throw new Error("[E_PARAMS] remote: target is required; spawn requires a byop:<label> target");
2330
+ if (input.script !== void 0) {
2331
+ let stdin;
2332
+ try {
2333
+ stdin = await readFile(input.script, "utf8");
2334
+ } catch (error) {
2335
+ throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.script)} — ${error instanceof Error ? error.message : String(error)}`);
2336
+ }
2337
+ return {
2338
+ target: input.target,
2339
+ ssh: input.ssh,
2340
+ cmd: "bash -s",
2341
+ stdin,
2342
+ mode: "oneshot",
2343
+ timeout: input.timeout
51
2344
  };
52
- termTimer = setTimeout(() => {
53
- timedOut = true;
54
- killGroup("SIGTERM");
55
- killTimer = setTimeout(() => killGroup("SIGKILL"), 2e3);
56
- }, opts.timeoutSec * 1e3);
57
- }
58
- const exit = await new Promise((resolve$1) => {
59
- child.once("close", (code) => resolve$1(code));
60
- child.once("error", (err) => {
61
- stderr += `\n[remote: spawn failed: ${err.message}]`;
62
- resolve$1(null);
63
- });
64
- });
65
- clearTimers();
2345
+ }
2346
+ return {
2347
+ target: input.target,
2348
+ spawn: input.spawn,
2349
+ ssh: input.ssh,
2350
+ cmd: input.cmd ?? "",
2351
+ stdin: input.stdin,
2352
+ mode: input.mode,
2353
+ timeout: input.timeout
2354
+ };
2355
+ }
2356
+
2357
+ //#endregion
2358
+ //#region src/remote/oneshot.ts
2359
+ async function runOneShot(argv, opts = {}) {
2360
+ const process$1 = startOneShot(argv, opts);
2361
+ const result = await process$1.done;
2362
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
66
2363
  return {
67
- exit,
68
- timedOut,
69
- stdout,
70
- stderr,
71
- durationMs: Date.now() - started
2364
+ ...result,
2365
+ stdout: stdout.text,
2366
+ stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
2367
+ outputStats: {
2368
+ stdout: stdout.stats,
2369
+ stderr: stderr.stats
2370
+ }
72
2371
  };
73
2372
  }
74
2373
 
@@ -93,11 +2392,23 @@ function stripAnsi(s) {
93
2392
  function normalizePtyText(s) {
94
2393
  return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
95
2394
  }
96
- function tailWindow(text, cap) {
97
- if (text.length <= cap) return { text };
2395
+ function tailWindow(text, cap, stats) {
2396
+ let tail = text.slice(-cap);
2397
+ if (tail.charCodeAt(0) >= 56320 && tail.charCodeAt(0) <= 57343) tail = tail.slice(1);
2398
+ const returned = stats?.unit === "bytes" ? Buffer.byteLength(tail) : tail.length;
2399
+ const total = stats?.total ?? text.length;
2400
+ const updated = stats === void 0 ? {} : { stats: {
2401
+ ...stats,
2402
+ returned
2403
+ } };
2404
+ if (total <= returned) return {
2405
+ text: tail,
2406
+ ...updated
2407
+ };
98
2408
  return {
99
- text: `[truncated: showing last ${cap} of ${text.length} chars]\n${text.slice(text.length - cap)}`,
100
- truncated: text.length
2409
+ text: `[truncated: showing last ${returned} of ${total} ${stats?.unit === "bytes" ? "bytes" : "chars"}${stats !== void 0 && stats.dropped > 0 ? `; retained ${stats.retained}, dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}` : ""}]\n${tail}`,
2410
+ truncated: total,
2411
+ ...updated
101
2412
  };
102
2413
  }
103
2414
  const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
@@ -182,6 +2493,9 @@ var PtySession = class {
182
2493
  lastSettleAt = 0;
183
2494
  /** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
184
2495
  everReady = false;
2496
+ disposal;
2497
+ procClose = Promise.resolve();
2498
+ groupCleanup = Promise.resolve();
185
2499
  constructor(opts) {
186
2500
  this.opts = opts;
187
2501
  }
@@ -207,16 +2521,17 @@ var PtySession = class {
207
2521
  const started = Date.now();
208
2522
  const reconnected = this.state === "dead";
209
2523
  if (this.state !== "ready") await this.start();
2524
+ if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' was disposed before dispatch`);
210
2525
  this.inFlight++;
211
2526
  return await new Promise((resolve$1) => {
212
2527
  const nonce = genNonce();
213
- let output = "";
2528
+ const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
214
2529
  let interrupted = false;
215
2530
  let settled = false;
216
2531
  let interruptTimer;
217
2532
  let killTimer;
218
2533
  const deathWaiter = () => finish({
219
- output,
2534
+ output: ring.text,
220
2535
  exit: null,
221
2536
  cwd: null,
222
2537
  timedOut: interrupted
@@ -236,20 +2551,17 @@ var PtySession = class {
236
2551
  this.resetIdleTimer();
237
2552
  resolve$1({
238
2553
  ...r,
2554
+ outputStats: { stdout: ring.stats() },
239
2555
  reconnected,
240
2556
  durationMs: Date.now() - started
241
2557
  });
242
2558
  };
243
2559
  const parser = createNonceFrameParser(nonce, {
244
2560
  onOutput: (t) => {
245
- output += t;
246
- if (output.length > OUTPUT_HARD_CAP) {
247
- output = output.slice(output.length - OUTPUT_HARD_CAP);
248
- if (output.charCodeAt(0) >= 56320 && output.charCodeAt(0) <= 57343) output = output.slice(1);
249
- }
2561
+ ring.push(t);
250
2562
  },
251
2563
  onFrame: ({ exit, cwd }) => finish({
252
- output,
2564
+ output: ring.text,
253
2565
  exit,
254
2566
  cwd,
255
2567
  timedOut: interrupted
@@ -266,7 +2578,7 @@ var PtySession = class {
266
2578
  this.killTree();
267
2579
  this.markDead();
268
2580
  finish({
269
- output,
2581
+ output: ring.text,
270
2582
  exit: null,
271
2583
  cwd: null,
272
2584
  timedOut: true
@@ -281,14 +2593,24 @@ var PtySession = class {
281
2593
  return run;
282
2594
  }
283
2595
  /** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
284
- async dispose() {
285
- if (this.state === "disposed") return;
2596
+ dispose() {
2597
+ if (this.disposal) return this.disposal;
286
2598
  this.clearIdleTimer();
287
2599
  this.state = "disposed";
288
2600
  this.startPromise = null;
2601
+ const proc = this.proc;
2602
+ const close = this.procClose;
2603
+ const group = this.reapGroup(proc?.pid, 1e3, close);
2604
+ this.disposal = Promise.all([
2605
+ close,
2606
+ this.groupCleanup,
2607
+ group
2608
+ ]).then(() => {
2609
+ if (this.proc === proc) this.proc = null;
2610
+ });
289
2611
  this.write("exit\n");
290
- setTimeout(() => this.killTree(), 1e3).unref();
291
2612
  this.opts.onDead?.();
2613
+ return this.disposal;
292
2614
  }
293
2615
  write(s) {
294
2616
  if (this.proc?.stdin?.writable !== true) return;
@@ -326,7 +2648,12 @@ var PtySession = class {
326
2648
  proc.stderr.setEncoding("utf8").on("data", (d) => {
327
2649
  this.diag = (this.diag + d).slice(-2e3);
328
2650
  });
329
- proc.once("close", () => this.markDead());
2651
+ this.procClose = new Promise((resolve$1) => {
2652
+ proc.once("close", () => {
2653
+ this.markDead();
2654
+ resolve$1();
2655
+ });
2656
+ });
330
2657
  proc.once("error", (err) => {
331
2658
  this.diag = (this.diag + `\n[remote: spawn failed: ${err.message}]`).slice(-2e3);
332
2659
  });
@@ -374,6 +2701,7 @@ var PtySession = class {
374
2701
  }
375
2702
  resetIdleTimer() {
376
2703
  this.clearIdleTimer();
2704
+ if (this.state === "disposed") return;
377
2705
  this.idleTimer = setTimeout(() => void this.dispose(), this.opts.idleTtlSec * 1e3);
378
2706
  this.idleTimer.unref();
379
2707
  }
@@ -386,20 +2714,69 @@ var PtySession = class {
386
2714
  killTree() {
387
2715
  const pid = this.proc?.pid;
388
2716
  this.proc = null;
389
- if (pid === void 0) return;
390
- try {
391
- process.kill(-pid, "SIGTERM");
392
- } catch {}
393
- setTimeout(() => {
2717
+ const cleanup = this.reapGroup(pid, 0, this.procClose);
2718
+ this.groupCleanup = Promise.all([this.groupCleanup, cleanup]).then(() => void 0);
2719
+ }
2720
+ /** A direct child's close is not proof that its detached process group is gone. */
2721
+ reapGroup(pid, graceMs, close) {
2722
+ if (pid === void 0) return Promise.resolve();
2723
+ const signal = (value) => {
394
2724
  try {
395
- process.kill(-pid, "SIGKILL");
396
- } catch {}
397
- }, 2e3).unref();
2725
+ process.kill(-pid, value);
2726
+ return true;
2727
+ } catch (error) {
2728
+ if (error.code === "ESRCH") return false;
2729
+ throw error;
2730
+ }
2731
+ };
2732
+ return new Promise((resolve$1, reject) => {
2733
+ let termTimer;
2734
+ let killTimer;
2735
+ let settled = false;
2736
+ const finish = (error) => {
2737
+ if (settled) return;
2738
+ settled = true;
2739
+ if (termTimer !== void 0) clearTimeout(termTimer);
2740
+ if (killTimer !== void 0) clearTimeout(killTimer);
2741
+ if (error !== void 0) reject(error);
2742
+ else resolve$1();
2743
+ };
2744
+ const checkGone = () => {
2745
+ try {
2746
+ if (!signal(0)) finish();
2747
+ } catch (error) {
2748
+ finish(error);
2749
+ }
2750
+ };
2751
+ checkGone();
2752
+ if (settled) return;
2753
+ close.then(checkGone);
2754
+ termTimer = setTimeout(() => {
2755
+ try {
2756
+ if (!signal("SIGTERM")) {
2757
+ finish();
2758
+ return;
2759
+ }
2760
+ killTimer = setTimeout(() => {
2761
+ try {
2762
+ signal("SIGKILL");
2763
+ killTimer = setTimeout(() => finish(), 100);
2764
+ } catch (error) {
2765
+ finish(error);
2766
+ }
2767
+ }, 2e3);
2768
+ } catch (error) {
2769
+ finish(error);
2770
+ }
2771
+ }, graceMs);
2772
+ });
398
2773
  }
399
2774
  };
400
2775
  /** 会话池:键 → 会话。意外死亡的会话留在池内(下次 dispatch 带 reconnect 提示)。 */
401
2776
  var PtyPool = class {
402
2777
  sessions = /* @__PURE__ */ new Map();
2778
+ disposed = false;
2779
+ disposal;
403
2780
  constructor(defaults) {
404
2781
  this.defaults = defaults;
405
2782
  }
@@ -424,6 +2801,7 @@ var PtyPool = class {
424
2801
  return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
425
2802
  }
426
2803
  getOrCreate(key, argv, origin) {
2804
+ if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: the owning Agent or plugin PTY pool was disposed");
427
2805
  let s = this.sessions.get(key);
428
2806
  if (s === void 0 || s.sessionState === "disposed") {
429
2807
  s = new PtySession({
@@ -437,9 +2815,12 @@ var PtyPool = class {
437
2815
  }
438
2816
  return s;
439
2817
  }
440
- async disposeAll() {
441
- await Promise.all([...this.sessions.values()].map((s) => s.dispose()));
442
- this.sessions.clear();
2818
+ disposeAll() {
2819
+ if (this.disposal) return this.disposal;
2820
+ this.disposed = true;
2821
+ return this.disposal = Promise.all([...this.sessions.values()].map((s) => s.dispose())).then(() => {
2822
+ this.sessions.clear();
2823
+ });
443
2824
  }
444
2825
  };
445
2826
 
@@ -489,17 +2870,10 @@ async function probeTarget(plan, opts = {}) {
489
2870
  const hard = opts.hardTimeoutSec ?? 10;
490
2871
  const runner = opts.runner ?? runOneShot;
491
2872
  const started = Date.now();
492
- const r = await runner(plan.kind === "ssh" ? [
493
- "ssh",
494
- "-o",
495
- "BatchMode=yes",
496
- "-o",
497
- `ConnectTimeout=${ct}`,
498
- "-T",
499
- "--",
500
- plan.host,
501
- "true"
502
- ] : plan.kind === "docker" ? [
2873
+ const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
2874
+ extraArgs: ["-o", `ConnectTimeout=${ct}`],
2875
+ command: "true"
2876
+ }) : plan.kind === "docker" ? [
503
2877
  "docker",
504
2878
  "inspect",
505
2879
  "--format",
@@ -677,6 +3051,7 @@ async function renderRoster(sessions, opts = {}) {
677
3051
  } catch (error) {
678
3052
  lines.push(`ssh hosts: no readable ~/.ssh/config (${error instanceof Error ? error.message : String(error)})`);
679
3053
  }
3054
+ lines.push(`byossh labels (this Agent, in memory): ${opts.byosshLabels?.map((label) => `byossh:${label}`).join(", ") || "(none)"}`);
680
3055
  const docker = await runner([
681
3056
  "docker",
682
3057
  "ps",
@@ -710,91 +3085,21 @@ async function renderRoster(sessions, opts = {}) {
710
3085
  return lines.join("\n");
711
3086
  }
712
3087
 
713
- //#endregion
714
- //#region src/remote/transports.ts
715
- /** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
716
- function shQuote(s) {
717
- return `'${s.replaceAll("'", `'\\''`)}'`;
718
- }
719
- /** One-shot(§五.1):不分配 tty,收尾 = 子进程退出码,零字符串扫描。 */
720
- function buildOneShotArgv(plan, cmd) {
721
- switch (plan.kind) {
722
- case "ssh": return [
723
- "ssh",
724
- "-o",
725
- "BatchMode=yes",
726
- "-T",
727
- "--",
728
- plan.host,
729
- `bash -lc ${shQuote(cmd)}`
730
- ];
731
- case "docker": return [
732
- "docker",
733
- "exec",
734
- "-i",
735
- "--",
736
- plan.container,
737
- "bash",
738
- "-lc",
739
- cmd
740
- ];
741
- case "incus": return [
742
- "incus",
743
- "exec",
744
- "--force-noninteractive",
745
- plan.container,
746
- "--",
747
- "bash",
748
- "-lc",
749
- cmd
750
- ];
751
- }
752
- }
753
- /** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
754
- function buildPtyArgv(plan) {
755
- switch (plan.kind) {
756
- case "ssh": return [
757
- "ssh",
758
- "-tt",
759
- "-o",
760
- "BatchMode=yes",
761
- "--",
762
- plan.host,
763
- "bash"
764
- ];
765
- case "docker": return [
766
- "script",
767
- "-qfec",
768
- `stty -echo 2>/dev/null ; exec docker exec -it -- ${shQuote(plan.container)} bash`,
769
- "/dev/null"
770
- ];
771
- case "incus": return [
772
- "incus",
773
- "exec",
774
- "--force-interactive",
775
- plan.container,
776
- "--",
777
- "bash"
778
- ];
779
- }
780
- }
781
- /** BYO-PTY(§三 spawn):工具只提供 PTY 托管 + Nonce 注入 + 收帧。 */
782
- function buildSpawnArgv(spawnCommand) {
783
- return [
784
- "script",
785
- "-qfec",
786
- `stty -echo 2>/dev/null ; exec ${spawnCommand}`,
787
- "/dev/null"
788
- ];
789
- }
790
-
791
3088
  //#endregion
792
3089
  //#region src/remote/driver.ts
793
3090
  /** PTY label 字符集:只禁空白 / `:` / 控制字符(roster 一行一 label,带换行的 label 渲染即烂)。 */
794
3091
  const PTY_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
795
3092
  /** 双轨调度器(spec §七 Phase 2):参数校验 → 路由 → oneshot 子进程 / pty 会话池。 */
796
3093
  var RemoteDriver = class {
3094
+ sshTargets = new SshTargets();
797
3095
  pool;
3096
+ hubOwner = randomUUID();
3097
+ hubProviders = /* @__PURE__ */ new Set();
3098
+ processes = /* @__PURE__ */ new Set();
3099
+ starting = /* @__PURE__ */ new Set();
3100
+ fileAbort = new AbortController();
3101
+ disposed = false;
3102
+ disposal;
798
3103
  constructor(opts = {}) {
799
3104
  this.opts = opts;
800
3105
  this.pool = new PtyPool({ idleTtlSec: opts.idleTtlSec ?? 600 });
@@ -802,11 +3107,14 @@ var RemoteDriver = class {
802
3107
  async call(params, callCtx = {}) {
803
3108
  const started = Date.now();
804
3109
  try {
805
- const routing = this.route(params);
806
- const result = await this.execute(params, routing, callCtx);
3110
+ this.assertOpen();
3111
+ const prepared = await prepareRemoteInput(params);
3112
+ this.assertOpen();
3113
+ const routing = this.route(prepared);
3114
+ const result = await this.execute(prepared, routing, callCtx);
807
3115
  this.audit({
808
3116
  target: routing.display,
809
- cmd: params.cmd,
3117
+ cmd: prepared.cmd,
810
3118
  cwd: result.cwd ?? void 0,
811
3119
  exit: result.exit,
812
3120
  durationMs: Date.now() - started
@@ -815,13 +3123,129 @@ var RemoteDriver = class {
815
3123
  } catch (error) {
816
3124
  this.audit({
817
3125
  target: params.target ?? params.spawn ?? "?",
818
- cmd: params.cmd,
3126
+ cmd: params.cmd ?? "[local script]",
819
3127
  error: error instanceof Error ? error.message : String(error),
820
3128
  durationMs: Date.now() - started
821
3129
  });
822
3130
  throw error;
823
3131
  }
824
3132
  }
3133
+ copy(request, signal, callCtx = {}) {
3134
+ const combined = AbortSignal.any([signal, this.fileAbort.signal]);
3135
+ const work = (async () => {
3136
+ this.assertOpen();
3137
+ combined.throwIfAborted();
3138
+ const endpoint = async (value) => {
3139
+ const parsed = parseFileEndpoint(value);
3140
+ if (!parsed.target) return {
3141
+ path: parsed.path,
3142
+ endpoint: localFileEndpoint()
3143
+ };
3144
+ if (parsed.target.startsWith("hub:")) {
3145
+ const hub = this.requireHub();
3146
+ this.hubProviders.add(hub);
3147
+ return {
3148
+ path: parsed.path,
3149
+ endpoint: hub.fileEndpoint(parsed.target.slice(4))
3150
+ };
3151
+ }
3152
+ const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
3153
+ this.assertOpen();
3154
+ combined.throwIfAborted();
3155
+ return {
3156
+ path: parsed.path,
3157
+ endpoint: cliFileEndpoint(plan)
3158
+ };
3159
+ };
3160
+ const source = await endpoint(request.src), destination = await endpoint(request.dst);
3161
+ this.assertOpen();
3162
+ combined.throwIfAborted();
3163
+ return copyTree(request, source, destination, combined);
3164
+ })();
3165
+ this.starting.add(work);
3166
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
3167
+ return work;
3168
+ }
3169
+ rsync(request, signal, callCtx = {}) {
3170
+ const combined = AbortSignal.any([signal, this.fileAbort.signal]);
3171
+ const work = (async () => {
3172
+ this.assertOpen();
3173
+ combined.throwIfAborted();
3174
+ const endpoint = async (value) => {
3175
+ const parsed = parseFileEndpoint(value);
3176
+ if (!parsed.target) return { path: parsed.path };
3177
+ if (/^(hub|docker|incus|byop):/.test(parsed.target)) throw new Error("rsync supports SSH/byossh only (plus one local endpoint); no mixed-transport or copy fallback");
3178
+ const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
3179
+ this.assertOpen();
3180
+ combined.throwIfAborted();
3181
+ if (plan.kind !== "ssh") throw new Error("rsync requires an SSH target; no copy fallback");
3182
+ return {
3183
+ path: parsed.path,
3184
+ connection: plan.connection ?? { host: plan.host }
3185
+ };
3186
+ };
3187
+ const source = await endpoint(request.src), destination = await endpoint(request.dst);
3188
+ this.assertOpen();
3189
+ combined.throwIfAborted();
3190
+ return runRsync(request, source, destination, combined, { ssh: this.opts.rsyncSshArgv });
3191
+ })();
3192
+ this.starting.add(work);
3193
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
3194
+ return work;
3195
+ }
3196
+ /** Only called from JobSpec.run: admission precedes local file reads and discovery. */
3197
+ start(input, signal) {
3198
+ const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]));
3199
+ this.starting.add(work);
3200
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
3201
+ return work;
3202
+ }
3203
+ async startPrepared(input, signal) {
3204
+ this.assertOpen();
3205
+ signal.throwIfAborted();
3206
+ const params = await prepareRemoteInput(input);
3207
+ this.assertOpen();
3208
+ signal.throwIfAborted();
3209
+ const route = this.route(params);
3210
+ if (route.mode !== "oneshot" || route.pty) throw new Error("remote: background requires oneshot");
3211
+ let process$1;
3212
+ if (route.display.startsWith("hub:")) {
3213
+ const hub = this.requireHub();
3214
+ this.hubProviders.add(hub);
3215
+ process$1 = await hub.start(route.display.slice(4), {
3216
+ ...params,
3217
+ mode: "oneshot"
3218
+ }, this.hubOwner);
3219
+ } else {
3220
+ const plan = route.plan ?? await this.resolveBareName(route.bare, "no-session", false);
3221
+ this.assertOpen();
3222
+ signal.throwIfAborted();
3223
+ if (plan.kind === "pty") throw new Error("remote: background requires oneshot");
3224
+ process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
3225
+ }
3226
+ this.own(process$1);
3227
+ if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
3228
+ return process$1;
3229
+ }
3230
+ nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
3231
+ const work = !this.opts.oneshotArgvFor ? startOwnedNativeProcess(plan, params.cmd, {
3232
+ stdin: params.stdin,
3233
+ timeoutSec,
3234
+ signal,
3235
+ ssh: this.opts.rsyncSshArgv
3236
+ }) : Promise.resolve(startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd), {
3237
+ stdin: params.stdin,
3238
+ timeoutSec
3239
+ }));
3240
+ this.starting.add(work);
3241
+ work.then(() => this.starting.delete(work), () => this.starting.delete(work));
3242
+ return work;
3243
+ }
3244
+ own(process$1) {
3245
+ this.processes.add(process$1);
3246
+ process$1.done.then(() => this.processes.delete(process$1));
3247
+ return process$1;
3248
+ }
825
3249
  /** Ruling 16/17:on-demand 探测 + 会话层,两行如实陈述。 */
826
3250
  /**
827
3251
  * Ruling P20 + PTY 候选表末位:裸名按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
@@ -831,13 +3255,15 @@ var RemoteDriver = class {
831
3255
  async resolveBareName(name$1, sessionKey, usePtyLabels = true) {
832
3256
  const runner = this.opts.rosterRunner ?? runOneShot;
833
3257
  const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
3258
+ let hosts = [];
834
3259
  try {
835
- const { hosts } = literalHosts(await readCfg());
836
- if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
837
- kind: "ssh",
838
- host: name$1
839
- };
3260
+ hosts = literalHosts(await readCfg()).hosts;
840
3261
  } catch {}
3262
+ this.assertOpen();
3263
+ if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
3264
+ kind: "ssh",
3265
+ host: name$1
3266
+ };
841
3267
  const docker = await runner([
842
3268
  "docker",
843
3269
  "ps",
@@ -845,6 +3271,7 @@ var RemoteDriver = class {
845
3271
  "--format",
846
3272
  "{{.Names}}"
847
3273
  ], { timeoutSec: 10 });
3274
+ this.assertOpen();
848
3275
  if (docker.exit === 0 && docker.stdout.trim().split(/\r?\n/).some((n) => n.trim().toLowerCase() === name$1.toLowerCase())) return {
849
3276
  kind: "docker",
850
3277
  container: name$1
@@ -857,6 +3284,7 @@ var RemoteDriver = class {
857
3284
  "--columns",
858
3285
  "n"
859
3286
  ], { timeoutSec: 10 });
3287
+ this.assertOpen();
860
3288
  if (incus.exit === 0 && incus.stdout.trim().split(/\r?\n/).some((n) => n.split(",")[0]?.trim().toLowerCase() === name$1.toLowerCase())) return {
861
3289
  kind: "incus",
862
3290
  container: name$1
@@ -875,22 +3303,47 @@ var RemoteDriver = class {
875
3303
  * 而活会话是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,且两个 agent 各开一条线
876
3304
  * 的代价可忽略。BYO-PTY 天然是活的,于是自然落进这个 scope,无需另立规则。 */
877
3305
  async roster(callCtx = {}) {
3306
+ this.assertOpen();
878
3307
  const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
879
- return await renderRoster(this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
3308
+ const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
880
3309
  target: displayKey(key),
881
3310
  state: snapshot.state,
882
3311
  idleSec: snapshot.idleMs !== null ? Math.round(snapshot.idleMs / 1e3) : null
883
- })), this.opts.rosterRunner !== void 0 ? { runner: this.opts.rosterRunner } : {});
3312
+ }));
3313
+ const runner = this.opts.rosterRunner ?? runOneShot;
3314
+ const hub = this.opts.getHub?.();
3315
+ return [...hub ? ["hub machines: " + (hub.listTargets().map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none")] : [], await renderRoster(sessions, {
3316
+ byosshLabels: this.sshTargets.entries().map(([label]) => label),
3317
+ readSshConfig: this.opts.sshConfigReader,
3318
+ runner: (argv, options) => {
3319
+ this.assertOpen();
3320
+ return runner(argv, options);
3321
+ }
3322
+ })].join("\n");
884
3323
  }
885
3324
  async status(target, callCtx = {}) {
3325
+ this.assertOpen();
3326
+ if (target.startsWith("hub:")) {
3327
+ const machine = target.slice(4);
3328
+ const row = this.requireHub().listTargets().find((t) => t.id === machine);
3329
+ if (!row) throw new Error(`unknown remote machine ${machine} — select a remote machine from the Hub roster; local execution is not available`);
3330
+ return `${target} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`;
3331
+ }
886
3332
  const sessionKey = callCtx.sessionKey ?? "no-session";
887
3333
  const label = target.startsWith("byop:") ? target.slice(5) : void 0;
888
3334
  if (label !== void 0) return this.ptyStatus(target, label, sessionKey);
889
- const resolved = /^(docker|incus|ssh):/.test(target) ? resolveTarget(target) : await this.resolveBareName(target, sessionKey);
3335
+ const direct = /^(docker|incus|ssh):/.test(target);
3336
+ const resolved = target.startsWith("byossh:") ? this.byosshPlan(target) : direct ? resolveTarget(target) : await this.resolveBareName(target, sessionKey);
3337
+ this.assertOpen();
890
3338
  if (resolved.kind === "pty") return this.ptyStatus(`byop:${resolved.label}`, resolved.label, sessionKey);
3339
+ const head = `${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`;
3340
+ const runner = this.opts.probeRunner ?? runOneShot;
891
3341
  return [
892
- `${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`,
893
- renderProbe(await probeTarget(resolved, { runner: this.opts.probeRunner })),
3342
+ head,
3343
+ renderProbe(await probeTarget(resolved, { runner: (argv, options) => {
3344
+ this.assertOpen();
3345
+ return runner(argv, options);
3346
+ } })),
894
3347
  renderSession(this.pool.inspect(`${sessionKey}|t:${target}`))
895
3348
  ].join("\n");
896
3349
  }
@@ -904,12 +3357,50 @@ var RemoteDriver = class {
904
3357
  this.opts.onAudit?.(r);
905
3358
  } catch {}
906
3359
  }
3360
+ /** Registration never dials; response reveals labels only, not credentials. */
3361
+ registerSsh(target, connection) {
3362
+ this.assertOpen();
3363
+ if (!target.startsWith("byossh:")) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target");
3364
+ this.sshTargets.register(target.slice(7), connection);
3365
+ return `${target} registered (no dialing). Reuse this Agent's label without ssh; a new Agent/cold resume must register again.`;
3366
+ }
3367
+ byosshPlan(target, connection) {
3368
+ if (connection !== void 0) this.registerSsh(target, connection);
3369
+ const spec = this.sshTargets.get(target.slice(7));
3370
+ if (!spec) throw new Error(`[E_NO_SSH_TARGET] remote: ${target} is not registered in this Agent — supply ssh:{host,user?,port?,key?,proxyJump?} first; key must be an existing origin file path`);
3371
+ return {
3372
+ kind: "ssh",
3373
+ host: spec.host,
3374
+ connection: spec
3375
+ };
3376
+ }
3377
+ /** Single target resolver for later copy/rsync; BYO identities cannot be
3378
+ * independently re-resolved through the origin's alias table. */
3379
+ async resolveConnectionTarget(target, callCtx = {}) {
3380
+ this.assertOpen();
3381
+ if (target.startsWith("byossh:")) return this.byosshPlan(target);
3382
+ if (/^(docker|incus|ssh):/.test(target)) return resolveTarget(target);
3383
+ if (/^(byop|hub):/.test(target)) throw new Error("remote: select an SSH/Docker/Incus target for this connection resolver");
3384
+ const plan = await this.resolveBareName(target, callCtx.sessionKey ?? "no-session", false);
3385
+ this.assertOpen();
3386
+ if (plan.kind === "pty") throw new Error("remote: BYO-PTY is not a filesystem connection");
3387
+ return plan;
3388
+ }
907
3389
  route(params) {
908
3390
  const hasTarget = params.target !== void 0 && params.target.length > 0;
909
3391
  const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
910
3392
  if (!hasTarget && !hasSpawn) throw new Error("[E_PARAMS] remote: target is required (e.g. { target: \"dev4\", cmd: \"git status\" }); spawn is a modifier of a \"byop:<label>\" target and cannot stand alone");
911
3393
  if (params.timeout !== void 0 && (!Number.isFinite(params.timeout) || params.timeout <= 0)) throw new Error(`[E_BAD_TIMEOUT] remote: timeout must be a positive number of seconds (got ${JSON.stringify(params.timeout)})`);
912
3394
  if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
3395
+ if (params.ssh !== void 0 && !params.target.startsWith("byossh:")) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target");
3396
+ if (params.target.startsWith("byossh:")) {
3397
+ if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is only for byop:<label>, not BYO-SSH");
3398
+ return {
3399
+ mode: params.mode ?? "oneshot",
3400
+ display: params.target,
3401
+ plan: this.byosshPlan(params.target, params.ssh)
3402
+ };
3403
+ }
913
3404
  if (hasTarget && params.target.startsWith("byop:")) {
914
3405
  const label = params.target.slice(5);
915
3406
  if (!PTY_LABEL_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: PTY label must match ${PTY_LABEL_RE.source} (got ${JSON.stringify(label)}) — e.g. target: "byop:dev3"`);
@@ -925,6 +3416,10 @@ var RemoteDriver = class {
925
3416
  }
926
3417
  if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn requires a \"byop:<label>\" target — e.g. { target: \"byop:dev3\", spawn: \"ssh dev3\", cmd: \"…\" }; it is not a standalone entry point");
927
3418
  const mode = params.mode ?? "oneshot";
3419
+ if (params.target.startsWith("hub:")) return {
3420
+ mode,
3421
+ display: params.target
3422
+ };
928
3423
  const direct = /^(docker|incus|ssh):/.test(params.target);
929
3424
  return {
930
3425
  mode,
@@ -933,25 +3428,82 @@ var RemoteDriver = class {
933
3428
  };
934
3429
  }
935
3430
  async execute(params, routing, callCtx) {
3431
+ const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
3432
+ const cap = this.opts.maxOutputChars ?? 3e4;
3433
+ if (routing.display.startsWith("hub:")) {
3434
+ const hub = this.requireHub();
3435
+ this.hubProviders.add(hub);
3436
+ const machine = routing.display.slice(4);
3437
+ if (routing.mode === "pty") {
3438
+ const result$1 = await hub.pty(machine, params.cmd, this.hubOwner, {
3439
+ stdin: params.stdin,
3440
+ timeout: timeoutSec
3441
+ });
3442
+ const out$2 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
3443
+ return {
3444
+ ...result$1,
3445
+ stdout: out$2.text,
3446
+ ...out$2.stats ? { outputStats: {
3447
+ ...result$1.outputStats,
3448
+ stdout: out$2.stats
3449
+ } } : {},
3450
+ ...out$2.truncated === void 0 ? {} : { truncated: out$2.truncated }
3451
+ };
3452
+ }
3453
+ const process$1 = this.own(await hub.start(machine, {
3454
+ ...params,
3455
+ target: routing.display,
3456
+ mode: "oneshot",
3457
+ timeout: timeoutSec
3458
+ }, this.hubOwner));
3459
+ if (this.disposed) process$1.cancel("owner disposed");
3460
+ const result = await process$1.done;
3461
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3462
+ const out$1 = tailWindow(stdout.text, cap, stdout.stats);
3463
+ const err = tailWindow(stderr.text, cap, stderr.stats);
3464
+ return {
3465
+ ...result,
3466
+ mode: "oneshot",
3467
+ target: routing.display,
3468
+ cwd: null,
3469
+ reconnected: false,
3470
+ stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out$1.text,
3471
+ stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
3472
+ ...out$1.stats ? { outputStats: {
3473
+ stdout: out$1.stats,
3474
+ stderr: err.stats
3475
+ } } : { outputStatsUnavailable: true },
3476
+ ...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
3477
+ };
3478
+ }
936
3479
  const sessionKey = callCtx.sessionKey ?? "no-session";
937
3480
  if (routing.bare !== void 0) {
938
3481
  const resolved = await this.resolveBareName(routing.bare, sessionKey, params.mode !== "oneshot");
3482
+ this.assertOpen();
939
3483
  if (resolved.kind === "pty") {
940
3484
  routing.pty = { label: resolved.label };
941
3485
  routing.mode = "pty";
942
3486
  routing.display = `byop:${resolved.label}`;
943
3487
  } else routing.plan = resolved;
944
3488
  }
945
- const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
946
- const cap = this.opts.maxOutputChars ?? 3e4;
947
3489
  if (routing.pty !== void 0) return await this.ptyCall(routing.pty, params, sessionKey, timeoutSec, cap);
948
3490
  if (routing.mode === "oneshot") {
949
- const r$1 = await runOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(routing.plan, params.cmd), {
950
- stdin: params.stdin,
951
- timeoutSec
952
- });
953
- const out$1 = tailWindow(r$1.stdout, cap);
954
- const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap) : void 0;
3491
+ this.assertOpen();
3492
+ const process$1 = this.own(await this.nativeProcess(routing.plan, params, timeoutSec));
3493
+ if (this.disposed) process$1.cancel("owner disposed");
3494
+ const result = await process$1.done;
3495
+ const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
3496
+ const r$1 = {
3497
+ ...result,
3498
+ stdout: stdout.text,
3499
+ stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
3500
+ outputStats: {
3501
+ stdout: stdout.stats,
3502
+ stderr: stderr.stats
3503
+ }
3504
+ };
3505
+ const out$1 = tailWindow(r$1.stdout, cap, r$1.outputStats?.stdout);
3506
+ const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap, r$1.outputStats?.stderr) : void 0;
955
3507
  return {
956
3508
  mode: "oneshot",
957
3509
  target: routing.display,
@@ -961,6 +3513,10 @@ var RemoteDriver = class {
961
3513
  reconnected: false,
962
3514
  stdout: out$1.text,
963
3515
  ...err !== void 0 ? { stderr: err.text } : {},
3516
+ ...out$1.stats ? { outputStats: {
3517
+ stdout: out$1.stats,
3518
+ stderr: err?.stats ?? r$1.outputStats?.stderr
3519
+ } } : {},
964
3520
  durationMs: r$1.durationMs,
965
3521
  ...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
966
3522
  };
@@ -971,7 +3527,7 @@ var RemoteDriver = class {
971
3527
  stdin: params.stdin,
972
3528
  timeoutSec
973
3529
  });
974
- const out = tailWindow(r.output, cap);
3530
+ const out = tailWindow(r.output, cap, r.outputStats?.stdout);
975
3531
  return {
976
3532
  mode: "pty",
977
3533
  target: routing.display,
@@ -980,6 +3536,7 @@ var RemoteDriver = class {
980
3536
  timedOut: r.timedOut,
981
3537
  reconnected: r.reconnected,
982
3538
  stdout: out.text,
3539
+ outputStats: out.stats ? { stdout: out.stats } : void 0,
983
3540
  durationMs: r.durationMs,
984
3541
  ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
985
3542
  };
@@ -1001,7 +3558,7 @@ var RemoteDriver = class {
1001
3558
  stdin: params.stdin,
1002
3559
  timeoutSec
1003
3560
  });
1004
- const out = tailWindow(r.output, cap);
3561
+ const out = tailWindow(r.output, cap, r.outputStats?.stdout);
1005
3562
  return {
1006
3563
  mode: "pty",
1007
3564
  target: display,
@@ -1012,27 +3569,183 @@ var RemoteDriver = class {
1012
3569
  reconnected: r.reconnected,
1013
3570
  stdout: out.text,
1014
3571
  ...notice !== void 0 ? { notice } : {},
3572
+ outputStats: out.stats ? { stdout: out.stats } : void 0,
1015
3573
  durationMs: r.durationMs,
1016
3574
  ...out.truncated !== void 0 ? { truncated: out.truncated } : {}
1017
3575
  };
1018
3576
  }
1019
- async dispose() {
1020
- await this.pool.disposeAll();
3577
+ hasHub() {
3578
+ return this.opts.getHub?.() !== void 0;
3579
+ }
3580
+ requireHub() {
3581
+ this.assertOpen();
3582
+ const hub = this.opts.getHub?.();
3583
+ if (!hub || hub.protocol !== 1) throw new Error("[E_HUB_UNAVAILABLE] remote: Hub transport unavailable — enable the Super D Hub body, or select an explicit SSH/Docker/Incus/BYO target");
3584
+ return hub;
3585
+ }
3586
+ assertOpen() {
3587
+ if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: this Agent or plugin owner has exited — create a new session or cold resume");
3588
+ }
3589
+ dispose() {
3590
+ if (this.disposal) return this.disposal;
3591
+ this.sshTargets.clear();
3592
+ this.disposed = true;
3593
+ this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
3594
+ for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
3595
+ return this.disposal = (async () => {
3596
+ await Promise.allSettled([...this.starting]);
3597
+ await Promise.all([
3598
+ this.pool.disposeAll(),
3599
+ ...[...this.processes].map((p) => p.done),
3600
+ ...[...this.hubProviders].map((hub) => hub.releaseOwner(this.hubOwner))
3601
+ ]);
3602
+ this.hubProviders.clear();
3603
+ })();
1021
3604
  }
1022
3605
  };
1023
3606
 
3607
+ //#endregion
3608
+ //#region src/remote/jobs.ts
3609
+ /** No preparation or discovery before the official registry's admission. */
3610
+ function startRemoteJob(registry, input, exec, driver) {
3611
+ if (input.mode === "pty" || input.spawn !== void 0 || input.target?.startsWith("byop:")) throw new Error("remote: background execution requires oneshot; PTY and BYO-PTY are unsupported");
3612
+ if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
3613
+ let process$1;
3614
+ const output = ["stdout", "stderr"].map((channel) => ({
3615
+ channel,
3616
+ read: (fromByte) => {
3617
+ const read = process$1?.read(channel, fromByte);
3618
+ return read ? {
3619
+ text: read.text,
3620
+ nextOffset: read.nextOffset,
3621
+ lossy: read.lossy
3622
+ } : {
3623
+ text: "",
3624
+ nextOffset: fromByte,
3625
+ lossy: false
3626
+ };
3627
+ }
3628
+ }));
3629
+ return registry.start({
3630
+ kind: "remote",
3631
+ owner: exec.agent.id,
3632
+ label: `${input.target}: ${input.cmd ?? input.script}`,
3633
+ output,
3634
+ run() {
3635
+ const controller = new AbortController();
3636
+ return {
3637
+ done: (async () => {
3638
+ try {
3639
+ process$1 = await driver.start({
3640
+ ...input,
3641
+ mode: "oneshot"
3642
+ }, controller.signal);
3643
+ if (controller.signal.aborted) process$1.cancel(controller.signal.reason);
3644
+ const result = await process$1.done;
3645
+ const diagnostic = process$1.read("stderr", 0).diagnostic;
3646
+ return {
3647
+ status: controller.signal.aborted || result.cancelled ? "killed" : diagnostic ? "failed" : result.timedOut ? "killed" : "completed",
3648
+ detail: diagnostic ?? (result.timedOut ? "timeout" : `exit code: ${result.exit}`)
3649
+ };
3650
+ } catch (error) {
3651
+ return {
3652
+ status: controller.signal.aborted ? "killed" : "failed",
3653
+ detail: error instanceof Error ? error.message : String(error)
3654
+ };
3655
+ }
3656
+ })(),
3657
+ cancel(reason) {
3658
+ if (controller.signal.aborted) return;
3659
+ controller.abort(reason);
3660
+ process$1?.cancel(reason);
3661
+ }
3662
+ };
3663
+ }
3664
+ });
3665
+ }
3666
+
1024
3667
  //#endregion
1025
3668
  //#region src/remote/tool.ts
1026
3669
  const NO_SESSION_KEY = "remote:no-session";
1027
3670
  function createRemoteTool(driver, opts = {}) {
1028
3671
  const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
1029
- return defineTool({
3672
+ const tool = defineTool({
1030
3673
  name: "remote",
1031
- description: "Remote execution over three transports (ssh / docker / incus) plus named persistent BYO shells. Pick the call shape first:\n {} → roster: known target names from a local scan (no dialing)\n {target} → on-demand status probe of that one target (for \"byop:<label>\": its session state)\n {target, cmd} → run cmd — oneshot by default; mode \"pty\" for a persistent shell\n {target:\"byop:<label>\", spawn, cmd} → BYO-PTY: create the persistent shell you named <label>\n {target:\"byop:<label>\", cmd} → reuse that shell (spawn is only needed to create)\nBYO-PTY labels are yours to choose, and the label alone is the whole reuse mechanism: \"byop:dev3\" is created once with `spawn` (the command that starts an interactive shell, e.g. \"docker exec -it img bash\"), and from then on you address it as {target:\"byop:dev3\", cmd} with no spawn. Repeating the same spawn for a live label is reused and the result tells you to drop it; repeating a *different* spawn is refused with [E_LABEL_CONFLICT] and nothing runs — reuse the label, or pick a new one. A label that was never created, or was reaped after the idle TTL, answers [E_NO_SESSION]: spawn is not retained, so pass it again to re-create. A spawn that fails to come up (e.g. a bad host) leaves nothing behind either — the label stays free, so just retry with a corrected spawn. Bare names match in order — ssh config, then docker, then incus, then a live pty label (on a collision the earlier source wins; a name matching none of them goes to ssh and its native error surfaces). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel when you must be explicit. oneshot: stateless `bash -lc`, stdout/stderr separate, the exit code is the remote process's own. pty: one persistent shell per (agent, target) — cwd/env survive across calls, streams merge, Ctrl-C interrupts without killing the session, idle sessions are reaped after 600s (a reconnect notice precedes the fresh shell). Output is tail-truncated at ~30k chars with a \"[truncated: …]\" header — redirect bulk output to a file yourself. `stdin` feeds the command; `timeout` (seconds) interrupts then kills. Native transport errors pass through verbatim — self-correct from them. Background processes must redirect their own output (`cmd > log 2>&1 &`). Status probes report \"reachable\"/\"unreachable — <native error>\" or the factual container state plus the session layer (\"idle Ns\" / \"busy\" / \"none — dials on first exec\"), always probed at call time.",
3674
+ description: "Native rsync: {action:\"rsync\",src:\"[target:]/absolute/path\",dst:\"[target:]/absolute/path\",dryRun:true,delete:false,excludes:[\"pattern\"],preserve:true}. SSH/byossh only, optionally one local endpoint; no Docker/Incus/Hub/BYO-PTY and no copy fallback. Requires Bash/native rsync on the origin; SSH endpoints need Bash 4.1+ coprocess/dynamic-FD support and Python 3.7+ (macOS 3.10+) shared cross-process non-adjustable monotonic clock and Unix fcntl.flock (preflighted, no auto-install). Default dry-run makes no destination data changes; deletion is opt-in; preserve uses native archive (-a), false uses recursive (-r). Source trailing slash copies contents; no slash copies the named directory. Local↔SSH supports native files/new destinations. SSH↔SSH requires an existing non-symlink destination directory (files/new paths, percent-containing paths and trim-sensitive/newline destinations refused, module never widened); source symlinks are refused before transfer because its confined non-chroot daemon cannot preserve their values. Use an ordinary local↔SSH rsync leg or materialize files first. Relay source inspection also requires native find. Two-remote relay SOURCE uses a task-owned Python loopback byte proxy over its authenticated SSH work stream, bridged on the origin into the destination SSH -L tunnel; no source Linux /proc, pidfd, privileged sshd descriptor access or remote forwarding is required. Native find remains required for source inspection. The exact route must return a real rsync greeting before transfer. Identities stay on the origin, agent forwarding and reusable multiplexing are disabled; native SSH config/ProxyJump remain honored. Normal two-remote startup uses 12 SSH launches: source prerequisite/inspection clock/inspection (3), source+destination prerequisites (2), destination clock/daemon+tunnel (2), source proxy clock/owner/work (3), source rsync clock/work (2). Lost admission may require additional authenticated cleanup reconnects; no transfer retry. Native delta is uncompressed; receipt includes real exit/stdout/stderr, itemized changes and native byte stats when available. Receipt stdout/stderr retain at most 2,097,152 UTF-16 code units each; outputTruncated marks trimmed tails (changes/stats may then be incomplete). Operation/disposal settlement waits for owned tunnels/daemon/config cleanup, not pre-existing SSH sessions. Each owned command uses an extra clock-only SSH round trip and a private transient receiving lock/record. If startup loses its closed control channel before a receipt, cancellation reconnects using the same SSH identity, fences late entry through the original 8-second entry cutoff, and waits for actual owned cleanup. Restore control connectivity if observation is pending; recovery never restarts the transfer. A still-live but hidden startup or unavailable cleanup helper can keep settlement pending; receiver progress and usable authenticated control connectivity are required.\nCopy files/trees: {action:\"copy\",src:\"[target:]/absolute/path\",dst:\"[target:]/absolute/path\",verify:true,mkdir:true}. Local/SSH/byossh/Docker/Incus/Hub binary transport; BYO-PTY unsupported. CLI peers require Bash 4.1+, Python 3.7+ (macOS 3.10+), Unix fcntl.flock/shared non-adjustable monotonic clock and mktemp/mv/rm/sleep; Linux containers additionally require stdlib ctypes/permitted PR_SET_CHILD_SUBREAPER. No auto-install or origin Python requirement. Each remote export/import/digest uses three native transport starts (clock, receiving owner, raw binary work), with extra authenticated cleanup recovery only on faults. A verified local-to-CLI copy uses six starts (import plus destination digest); CLI-to-local uses six (export plus source digest); CLI-to-CLI uses twelve. verify:false skips digests, reducing these counts to three/three/six. Cancellation/failure settlement waits for actual receiving helper/group/temp cleanup. Normal binary success requires complete stream drain; there is no successful truncated-output timeout. Local/Hub file lanes are unchanged. Full origin staging, no delta/resume. Directories merge without deletion; SHA-256 verifies transferred entries only. Receipt sourceDigest/destinationDigest are canonical transferred-entry manifest digests, not a raw file sha256sum, even for one file. Copy has no dryRun; omit all rsync-only options including dryRun:false. Failures report retained scratch for diagnosis; success removes owned staging. Symlinks must resolve through incoming tree entries only; dangling links and destination aliases are rejected.\nOwned SSH/byossh oneshot commands (foreground, background, script and stdin) require remote Bash 4.1+, Python 3.7+ (macOS 3.10+), Unix fcntl.flock with a shared non-adjustable monotonic clock, and mktemp/mv/rm/sleep. No rsync is needed for ordinary commands; no new origin Python prerequisite or Linux/pidfd requirement. Each command uses three SSH startups: clock-only prerequisite, separate receiving owner/control, and literal-stdio work. Cancellation/timeout/Agent or plugin disposal waits for receiving helper and child/group cleanup; a lost startup receipt can require same-identity cleanup reconnect and restored connectivity. Receiver progress is required, not a hard-real-time guarantee. Work output gets up to 2500ms to drain after cleanup proof; unavailable drain reports incomplete output separately, and counters describe only received data. PTY/BYO-PTY behavior is unchanged.\nDocker/Incus oneshot commands use the same receiving owner: remote Bash 4.1+, Python 3.7+, fcntl/shared monotonic clock and mktemp/mv/rm/sleep; three native exec startups per command (clock, owner, work). Linux containers additionally require Python stdlib ctypes and permitted unprivileged PR_SET_CHILD_SUBREAPER, checked before business work; the helper reaps its own/adopted descendants, including containers without an init. No image/config installation, origin Python, rsync or pidfd is added. PTY paths are unchanged.\nRemote execution over three transports (ssh / docker / incus) plus named persistent BYO shells. Pick the call shape first:\n {} → roster: known target names from a local scan (no dialing)\n {target} → on-demand status probe of that one target (for \"byop:<label>\": its session state)\n {target:\"byossh:<label>\", ssh:{host,user?,port?,key?,proxyJump?}, cmd?} → register SSH identity in this Agent; optional cmd executes. No cmd means register only, no dialing. Later omit ssh and reuse target. Same spec is idempotent; different spec conflicts: use another label. key is an existing file on the origin, never inline private key. Native SSH config and ProxyJump are honored; no config writes, copied keys or credential persistence. Cold/new Agent must register again.\n {target, cmd} → run cmd — oneshot by default; mode \"pty\" for a persistent shell\n {target, script:\"/absolute/path.sh\"} → read a LOCAL script before dialing; oneshot only, sent raw over stdin to fixed `bash -s`. No cmd/stdin/spawn/PTY combination. Large scripts do not expand into argv.\n {target:\"byop:<label>\", spawn, cmd} → BYO-PTY: create the persistent shell you named <label>\n {target:\"byop:<label>\", cmd} → reuse that shell (spawn is only needed to create)\nBYO-PTY labels are yours to choose, and the label alone is the whole reuse mechanism: \"byop:dev3\" is created once with `spawn` (the command that starts an interactive shell, e.g. \"docker exec -it img bash\"), and from then on you address it as {target:\"byop:dev3\", cmd} with no spawn. Repeating the same spawn for a live label is reused and the result tells you to drop it; repeating a *different* spawn is refused with [E_LABEL_CONFLICT] and nothing runs — reuse the label, or pick a new one. A label that was never created, or was reaped after the idle TTL, answers [E_NO_SESSION]: spawn is not retained, so pass it again to re-create. A spawn that fails to come up (e.g. a bad host) leaves nothing behind either — the label stays free, so just retry with a corrected spawn. Bare names match in order — ssh config, then docker, then incus, then a live pty label (on a collision the earlier source wins; a name matching none of them goes to ssh and its native error surfaces). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel when you must be explicit. oneshot: stateless `bash -lc`, stdout/stderr separate, the exit code is the remote process's own. pty: one persistent shell per (agent, target) — cwd/env survive across calls, streams merge, Ctrl-C interrupts without killing the session, idle sessions are reaped after 600s (a reconnect notice precedes the fresh shell). Output is tail-truncated at ~30k chars with a \"[truncated: …]\" header — redirect bulk output to a file yourself. Native/PTY hard retention is 4,000,000 UTF-16 code units per stream, not bytes; Hub oneshot is 4MiB bytes per stream. outputStats reports total/retained/dropped/returned and units; restart cannot recover discarded output. `stdin` feeds the command; `timeout` (seconds) interrupts then kills. Native transport errors pass through verbatim — self-correct from them. run_in_background:true starts an official DSH job (oneshot only); use job_output/job_kill and host completion notices. Requires the jobs service and controller. Background has no default foreground timeout; an explicit timeout still applies. Status probes report \"reachable\"/\"unreachable — <native error>\" or the factual container state plus the session layer (\"idle Ns\" / \"busy\" / \"none — dials on first exec\"), always probed at call time.",
1032
3675
  parameters: {
3676
+ action: {
3677
+ type: "string",
3678
+ enum: ["copy", "rsync"],
3679
+ description: "Optional file action; omit for execution/status/roster."
3680
+ },
3681
+ src: {
3682
+ type: "string",
3683
+ description: "copy/rsync source [target:]absolute path; rsync retains trailing-slash semantics"
3684
+ },
3685
+ dst: {
3686
+ type: "string",
3687
+ description: "copy/rsync destination [target:]absolute path"
3688
+ },
3689
+ verify: {
3690
+ type: "boolean",
3691
+ description: "copy independent SHA-256 verification (default true)"
3692
+ },
3693
+ mkdir: {
3694
+ type: "boolean",
3695
+ description: "copy create missing destination parents (default true)"
3696
+ },
3697
+ dryRun: {
3698
+ type: "boolean",
3699
+ description: "rsync native dry-run (default true); false permits writes"
3700
+ },
3701
+ delete: {
3702
+ type: "boolean",
3703
+ description: "rsync delete extraneous destination entries (default false)"
3704
+ },
3705
+ excludes: {
3706
+ type: "array",
3707
+ items: { type: "string" },
3708
+ description: "rsync native exclude patterns, passed as separate argv options"
3709
+ },
3710
+ preserve: {
3711
+ type: "boolean",
3712
+ description: "rsync archive -a (default true) versus recursive -r; remote relay refuses source symlinks"
3713
+ },
3714
+ run_in_background: {
3715
+ type: "boolean",
3716
+ description: "Oneshot only: return a native jobId immediately, read with job_output, cancel with job_kill; output is available while running. Requires official jobs service/controller."
3717
+ },
1033
3718
  target: {
1034
3719
  type: "string",
1035
- description: "Single entry point. Bare name / IP / domain matches in order: ssh config, then docker, then incus, then a live pty label (a collision goes to the earlier source). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel. For \"byop:<label>\", pass spawn the first time to create it and omit spawn afterwards to reuse it. Omit cmd → status probe."
3720
+ description: "Single entry point. byossh:<label> reuses this Agent-scoped registered SSH identity (pass ssh on first call; no cmd registers only). Bare name / IP / domain matches in order: ssh config, then docker, then incus, then a live pty label (a collision goes to the earlier source). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel. For \"byop:<label>\", pass spawn the first time to create it and omit spawn afterwards to reuse it. Omit cmd → status probe."
3721
+ },
3722
+ ssh: {
3723
+ type: "object",
3724
+ description: "Only for byossh:<label>: register origin-side connection; omit on reuse. No inline keys or arbitrary SSH options.",
3725
+ additionalProperties: false,
3726
+ properties: {
3727
+ host: {
3728
+ type: "string",
3729
+ required: true,
3730
+ description: "SSH hostname, IP or native config alias."
3731
+ },
3732
+ user: {
3733
+ type: "string",
3734
+ description: "Login user; omitted preserves native SSH config."
3735
+ },
3736
+ port: {
3737
+ type: "integer",
3738
+ description: "SSH port 1–65535; omitted preserves native SSH config."
3739
+ },
3740
+ key: {
3741
+ type: "string",
3742
+ description: "Existing origin private-key file path, not key contents; never copied or persisted."
3743
+ },
3744
+ proxyJump: {
3745
+ type: "string",
3746
+ description: "Official ProxyJump: comma-separated [user@]host[:port] or native aliases; jump identity remains in origin SSH config."
3747
+ }
3748
+ }
1036
3749
  },
1037
3750
  spawn: {
1038
3751
  type: "string",
@@ -1042,6 +3755,10 @@ function createRemoteTool(driver, opts = {}) {
1042
3755
  type: "string",
1043
3756
  description: "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status probe; omit everything → roster."
1044
3757
  },
3758
+ script: {
3759
+ type: "string",
3760
+ description: "LOCAL absolute script path. Read before any dialing, sent literally over stdin to fixed bash -s. Oneshot only; cannot combine with cmd, stdin, spawn, pty mode or byop target. Hub inline cmd remains limited to 4096; script body is not argv."
3761
+ },
1045
3762
  mode: {
1046
3763
  type: "string",
1047
3764
  enum: ["oneshot", "pty"],
@@ -1067,13 +3784,18 @@ function createRemoteTool(driver, opts = {}) {
1067
3784
  enum: [
1068
3785
  "exec",
1069
3786
  "status",
1070
- "roster"
3787
+ "roster",
3788
+ "job",
3789
+ "copy",
3790
+ "rsync"
1071
3791
  ]
1072
3792
  },
1073
3793
  text: {
1074
3794
  type: "string",
1075
3795
  required: true
1076
3796
  },
3797
+ jobId: { type: "string" },
3798
+ receipt: { type: "json" },
1077
3799
  exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
1078
3800
  durationMs: { type: "integer" },
1079
3801
  cwd: { type: "string" },
@@ -1081,6 +3803,8 @@ function createRemoteTool(driver, opts = {}) {
1081
3803
  timedOut: { type: "boolean" },
1082
3804
  reconnected: { type: "boolean" },
1083
3805
  truncated: { type: "integer" },
3806
+ outputStats: { type: "json" },
3807
+ outputStatsUnavailable: { type: "boolean" },
1084
3808
  session: { type: "string" },
1085
3809
  notice: { type: "string" }
1086
3810
  }
@@ -1091,13 +3815,105 @@ function createRemoteTool(driver, opts = {}) {
1091
3815
  }]
1092
3816
  },
1093
3817
  async execute(args, exec) {
3818
+ if (args.action === "copy" || args.action === "rsync") {
3819
+ if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
3820
+ if ([
3821
+ args.cmd,
3822
+ args.script,
3823
+ args.stdin,
3824
+ args.spawn,
3825
+ args.ssh,
3826
+ args.target,
3827
+ args.mode,
3828
+ args.timeout,
3829
+ args.run_in_background
3830
+ ].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments; register byossh separately first`);
3831
+ if (args.action === "rsync") {
3832
+ if ([args.verify, args.mkdir].some((value) => value !== void 0)) throw new Error("rsync cannot be combined with copy verify/mkdir options");
3833
+ const start = Date.now();
3834
+ const receipt$1 = await driver.rsync({
3835
+ src: args.src,
3836
+ dst: args.dst,
3837
+ dryRun: args.dryRun,
3838
+ delete: args.delete,
3839
+ excludes: args.excludes,
3840
+ preserve: args.preserve
3841
+ }, exec.signal, { sessionKey: getSessionKey(exec) });
3842
+ if (receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
3843
+ return {
3844
+ kind: "rsync",
3845
+ text: JSON.stringify(receipt$1),
3846
+ receipt: receipt$1,
3847
+ exit: receipt$1.exit,
3848
+ durationMs: Date.now() - start
3849
+ };
3850
+ }
3851
+ if ([
3852
+ args.dryRun,
3853
+ args.delete,
3854
+ args.excludes,
3855
+ args.preserve
3856
+ ].some((value) => value !== void 0)) throw new Error("copy cannot be combined with rsync options");
3857
+ const receipt = await driver.copy({
3858
+ src: args.src,
3859
+ dst: args.dst,
3860
+ verify: args.verify,
3861
+ mkdir: args.mkdir
3862
+ }, exec.signal, { sessionKey: getSessionKey(exec) });
3863
+ return {
3864
+ kind: "copy",
3865
+ text: JSON.stringify(receipt),
3866
+ receipt,
3867
+ exit: null,
3868
+ durationMs: 0
3869
+ };
3870
+ }
3871
+ if ([
3872
+ args.src,
3873
+ args.dst,
3874
+ args.verify,
3875
+ args.mkdir,
3876
+ args.dryRun,
3877
+ args.delete,
3878
+ args.excludes,
3879
+ args.preserve
3880
+ ].some((value) => value !== void 0)) throw new Error("file fields require action copy or rsync");
1094
3881
  const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
1095
3882
  const hasTarget = typeof args.target === "string" && args.target.length > 0;
1096
3883
  const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
1097
3884
  if (args.mode !== void 0 && args.mode !== "oneshot" && args.mode !== "pty") throw new Error(`[E_BAD_MODE] remote: mode must be 'oneshot' or 'pty' (got ${JSON.stringify(args.mode)})`);
1098
- if (!hasCmd) {
3885
+ if (args.ssh !== void 0 && (!args.target?.startsWith("byossh:") || hasSpawn)) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target; spawn is only for byop:<label>");
3886
+ if (args.run_in_background) {
3887
+ if (!hasCmd && args.script === void 0) throw new Error("remote: background requires cmd or script");
3888
+ if (args.mode === "pty" || hasSpawn || args.target?.startsWith("byop:")) throw new Error("remote: background requires oneshot; PTY and BYO-PTY are unsupported");
3889
+ const jobs = exec.agent?.ctx.get("jobs");
3890
+ if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
3891
+ const jobId = startRemoteJob(jobs, {
3892
+ target: args.target,
3893
+ cmd: args.cmd,
3894
+ script: args.script,
3895
+ ssh: args.ssh,
3896
+ stdin: args.stdin,
3897
+ mode: "oneshot",
3898
+ timeout: args.timeout
3899
+ }, exec, driver);
3900
+ return {
3901
+ kind: "job",
3902
+ jobId,
3903
+ text: `Started ${jobId}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
3904
+ exit: null,
3905
+ durationMs: 0
3906
+ };
3907
+ }
3908
+ if (!hasCmd && args.script === void 0) {
1099
3909
  if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
1100
3910
  if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is an exec channel — pass cmd (there is nothing to probe for BYO-PTY)");
3911
+ if (args.ssh !== void 0) return {
3912
+ kind: "status",
3913
+ text: driver.registerSsh(args.target, args.ssh),
3914
+ exit: null,
3915
+ durationMs: 0
3916
+ };
1101
3917
  if (!hasTarget) return {
1102
3918
  kind: "roster",
1103
3919
  text: await driver.roster({ sessionKey: getSessionKey(exec) }),
@@ -1114,7 +3930,9 @@ function createRemoteTool(driver, opts = {}) {
1114
3930
  const r = await driver.call({
1115
3931
  target: args.target,
1116
3932
  spawn: args.spawn,
3933
+ ssh: args.ssh,
1117
3934
  cmd: args.cmd,
3935
+ script: args.script,
1118
3936
  mode: args.mode === "oneshot" || args.mode === "pty" ? args.mode : void 0,
1119
3937
  stdin: args.stdin,
1120
3938
  timeout: args.timeout
@@ -1130,14 +3948,22 @@ function createRemoteTool(driver, opts = {}) {
1130
3948
  if (r.timedOut) v.timedOut = true;
1131
3949
  if (r.reconnected) v.reconnected = true;
1132
3950
  if (r.truncated !== void 0) v.truncated = r.truncated;
3951
+ if (r.outputStats !== void 0) v.outputStats = r.outputStats;
3952
+ if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
1133
3953
  if (r.session !== void 0) v.session = r.session;
1134
3954
  if (r.notice !== void 0) v.notice = r.notice;
1135
3955
  return v;
1136
3956
  }
1137
3957
  });
3958
+ const description = tool.description;
3959
+ Object.defineProperty(tool, "description", {
3960
+ enumerable: true,
3961
+ get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self). {} includes the Hub directory even when empty. Uses authenticated Hub HTTP oneshot / persistent WS PTY; no SSH alias needed. Hub failures never fall back to SSH." : "")
3962
+ });
3963
+ return tool;
1138
3964
  }
1139
3965
  function renderValue(v) {
1140
- if (v.kind === "status" || v.kind === "roster") return v.text;
3966
+ if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync") return v.text;
1141
3967
  const parts = [];
1142
3968
  if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
1143
3969
  if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
@@ -1145,7 +3971,8 @@ function renderValue(v) {
1145
3971
  const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
1146
3972
  const markers = [];
1147
3973
  if (v.timedOut === true) markers.push("interrupted");
1148
- if (v.truncated !== void 0) markers.push(`truncated, original ${v.truncated} chars`);
3974
+ if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
3975
+ if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable; returned tail only");
1149
3976
  const suffix = markers.length > 0 ? ` · ${markers.join(" · ")}` : "";
1150
3977
  const cwdTag = typeof v.cwd === "string" && v.cwd.length > 0 ? ` · cwd ${v.cwd}` : "";
1151
3978
  const sessionTag = typeof v.session === "string" && v.session.length > 0 ? ` · ${v.session}` : "";
@@ -1154,6 +3981,84 @@ function renderValue(v) {
1154
3981
  return parts.join("\n");
1155
3982
  }
1156
3983
 
3984
+ //#endregion
3985
+ //#region src/remote/agent-registration.ts
3986
+ /** Host listeners attempt once per official creation edge, never on vacancy. */
3987
+ function installAgentRemote(ctx, config) {
3988
+ return ctx.on("agent/created", ({ agent }) => {
3989
+ const tools = agent.ctx.get("tools");
3990
+ if (!tools) return;
3991
+ const driver = new RemoteDriver({
3992
+ ...config,
3993
+ getHub: () => agent.ctx.get("superdRemoteTransport")
3994
+ });
3995
+ let unregister;
3996
+ try {
3997
+ unregister = tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id }));
3998
+ } catch (error) {
3999
+ driver.dispose();
4000
+ if (error instanceof Error && error.message === "tool \"remote\" is already registered in this scope" && tools.get("remote", agent) !== void 0) return;
4001
+ throw error;
4002
+ }
4003
+ const stopService = agent.ctx.on("internal/service", (name$1) => {
4004
+ if (name$1 === "superdRemoteTransport") agent.ctx.emit("tools/change");
4005
+ });
4006
+ let disposal;
4007
+ let ownerUndo;
4008
+ let agentUndo;
4009
+ const cleanup = () => {
4010
+ if (disposal) return disposal;
4011
+ disposal = driver.dispose();
4012
+ unregister();
4013
+ stopService();
4014
+ ownerUndo?.();
4015
+ agentUndo?.();
4016
+ return disposal;
4017
+ };
4018
+ agentUndo = agent.ctx.effect(() => cleanup, "remote.agentOwner()");
4019
+ ownerUndo = ctx.effect(() => cleanup, "remote.rowOwner()");
4020
+ });
4021
+ }
4022
+
4023
+ //#endregion
4024
+ //#region src/remote/file-worker.ts
4025
+ async function runFileWorker(request) {
4026
+ const abort = new AbortController(), cancel = () => abort.abort(/* @__PURE__ */ new Error("binary file worker cancelled"));
4027
+ process.on("SIGTERM", cancel);
4028
+ process.on("SIGHUP", cancel);
4029
+ const report = (value) => new Promise((resolve$1, reject) => process.stderr.write(JSON.stringify(value) + "\n", (error) => error ? reject(error) : resolve$1()));
4030
+ try {
4031
+ mkdirSync(request.root, { mode: 448 });
4032
+ const endpoint = localFileEndpoint({
4033
+ receiveRoot: request.root,
4034
+ temporary: (path$1, active) => report({
4035
+ temporary: path$1,
4036
+ active
4037
+ })
4038
+ });
4039
+ if (request.action === "export") await pipeline(await endpoint.exportTree(request.path, abort.signal), process.stdout, { signal: abort.signal });
4040
+ else if (request.action === "import") await endpoint.importTree(request.path, process.stdin, {
4041
+ mkdir: request.mkdir,
4042
+ signal: abort.signal
4043
+ });
4044
+ else {
4045
+ let metadata = "";
4046
+ process.stdin.setEncoding("utf8");
4047
+ for await (const chunk of process.stdin) {
4048
+ metadata += String(chunk);
4049
+ if (Buffer.byteLength(metadata) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
4050
+ }
4051
+ process.stdout.write(JSON.stringify(await endpoint.digest(request.path, abort.signal, request.selected ? JSON.parse(metadata) : void 0)));
4052
+ }
4053
+ } catch (error) {
4054
+ await report({ error: error instanceof Error ? error.message : String(error) }).catch(() => {});
4055
+ process.exitCode = 1;
4056
+ } finally {
4057
+ process.removeListener("SIGTERM", cancel);
4058
+ process.removeListener("SIGHUP", cancel);
4059
+ }
4060
+ }
4061
+
1157
4062
  //#endregion
1158
4063
  //#region src/remote/plugin.ts
1159
4064
  const name = "dashr-remote";
@@ -1169,15 +4074,7 @@ const Config = Schema.object({
1169
4074
  });
1170
4075
  /** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
1171
4076
  const apply = (ctx, config) => {
1172
- const driver = new RemoteDriver({
1173
- execTimeoutSec: config.execTimeoutSec,
1174
- idleTtlSec: config.idleTtlSec,
1175
- maxOutputChars: config.maxOutputChars
1176
- });
1177
- ctx.inject(["tools"], (toolsCtx) => {
1178
- toolsCtx.tools.register(createRemoteTool(driver, { getSessionKey: (exec) => exec.agent?.id ?? "remote:no-session" }));
1179
- });
1180
- return () => void driver.dispose();
4077
+ return installAgentRemote(ctx, config);
1181
4078
  };
1182
4079
  var plugin_default = {
1183
4080
  name,
@@ -1187,4 +4084,4 @@ var plugin_default = {
1187
4084
  };
1188
4085
 
1189
4086
  //#endregion
1190
- export { Config, apply, plugin_default as default, inject, name };
4087
+ export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, SshTargets, Utf16TailRing, apply, checkAbsolute, checkSelection, plugin_default as default, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob, withSshRelay };