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.
- package/README.md +12 -11
- package/cordis.patch.yml +1 -29
- package/docs/adr/0015-independent-bundles-agent-remote.md +40 -0
- package/docs/remote-tools.md +52 -0
- package/docs/specs/remote-tool-enhancement/spec.md +203 -0
- package/lib/{LaunchOptions-DXdeJis3.js → LaunchOptions-CLEBmZeM.js} +1 -1
- package/lib/LaunchOptions-CjYFNZFS.js +4 -0
- package/lib/{bidi-CwtLGgcA.js → bidi-Bs8owg1e.js} +1 -1
- package/lib/client/index.js +23 -0
- package/lib/{index-browser-Bp3vxHC3.js → index-browser-pHtsz007.js} +4 -4
- package/lib/{main-CKFNYpqT.js → main-CBC7_uE-.js} +4 -2
- package/lib/{main-BXwPLuYr.js → main-RqgDOjH9.js} +1 -1
- package/lib/mobile/plugin.d.ts +1 -1
- package/lib/{puppeteer-core-Bliv_vNf.js → puppeteer-core-DnI7gBFU.js} +5 -5
- package/lib/remote/plugin.d.ts +427 -5
- package/lib/remote/plugin.js +3106 -209
- package/lib/url-schemes/index.d.ts +11 -11
- package/lib/url-schemes/index.js +3 -3
- package/lib/web-password.d.ts +7 -7
- package/lib/web-password.js +1 -1
- package/lib/{web-trust-C_Y6o8Z2.d.ts → web-trust-RwK0P74B.d.ts} +11 -11
- package/lib/web-trust.d.ts +1 -1
- package/lib/welcome-notice.d.ts +5 -7
- package/lib/welcome-notice.js +9 -48
- package/package.json +3 -11
- package/lib/LaunchOptions-3peHoOAx.js +0 -4
- package/lib/hmr.d.ts +0 -32
- package/lib/hmr.js +0 -49
- package/lib/webserver.d.ts +0 -10
- package/lib/webserver.js +0 -19
package/lib/remote/plugin.js
CHANGED
|
@@ -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 {
|
|
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/
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
25
|
-
let
|
|
26
|
-
let
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
if (
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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 ${
|
|
100
|
-
truncated:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
if (this.
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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,
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
this.
|
|
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
|
-
"
|
|
494
|
-
"
|
|
495
|
-
|
|
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
|
-
|
|
806
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}))
|
|
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
|
|
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
|
-
|
|
893
|
-
renderProbe(await probeTarget(resolved, { runner:
|
|
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
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
const
|
|
954
|
-
const
|
|
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
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
3672
|
+
const tool = defineTool({
|
|
1030
3673
|
name: "remote",
|
|
1031
|
-
description: "
|
|
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 (!
|
|
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
|
-
|
|
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 };
|