better-dsh 0.3.0 → 0.3.1-a
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 +49 -177
- package/cordis.patch.yml +5 -36
- package/docs/20_specs/kernel-provisioning/spec.md +2 -0
- package/docs/adr/0015-independent-bundles-agent-remote.md +40 -0
- package/docs/remote-tools.md +56 -0
- package/docs/specs/kernel-provisioning/spec.md +12 -40
- package/docs/specs/omp-python-repl-port/runtime-guide.md +35 -0
- package/docs/specs/omp-python-repl-port/spec.md +68 -0
- package/docs/specs/remote-tool-enhancement/spec.md +203 -0
- package/dsh-docs/baseline.md +13 -0
- package/dsh-docs/subsystems/invariants.md +3 -0
- package/dsh-docs/subsystems/invariants.zh.md +3 -0
- package/eval-description.md +7 -3
- package/lib/LaunchOptions-0Mr6meJc.js +4 -0
- package/lib/{LaunchOptions-kGXcZOe0.js → LaunchOptions-iE9M-STS.js} +1 -1
- package/lib/{bidi-BalpKaJc.js → bidi-CaQeBZHY.js} +1 -1
- package/lib/client/index.js +30 -6
- package/lib/failover/index.js +1 -1
- package/lib/fs-aware/sandbox-plugin.js +3 -3
- package/lib/{index-browser-CysrjMyM.js → index-browser-CJzxT9Vi.js} +4 -4
- package/lib/index.d.ts +16 -50
- package/lib/index.js +482 -1720
- package/lib/kernel-env-x_WkG6Fr.js +76 -0
- package/lib/kernel-env.d.ts +5 -69
- package/lib/kernel-env.js +2 -2
- package/lib/{lsp-server-registry-BXvUTBY7.js → lsp-server-registry-BcdkHROD.js} +1 -1
- package/lib/{lsp-server-registry-CH9WNFFt.js → lsp-server-registry-D4VLs1Sm.js} +1 -1
- package/lib/{main-BQeFrBM1.js → main-taSMz1KX.js} +1 -1
- package/lib/{main-CupOhMp5.js → main-uqGqYYnA.js} +7 -5
- package/lib/mobile/plugin.d.ts +1 -1
- package/lib/omp-python/DILL-LICENSE +35 -0
- package/lib/omp-python/OMP-LICENSE +23 -0
- package/lib/omp-python/better_snapshot.py +391 -0
- package/lib/omp-python/better_support.py +425 -0
- package/lib/omp-python/dill-provenance.json +55 -0
- package/lib/omp-python/provenance.json +13 -0
- package/lib/omp-python/runner.py +2499 -0
- package/lib/omp-python/vendor/dill/__diff.py +234 -0
- package/lib/omp-python/vendor/dill/__info__.py +291 -0
- package/lib/omp-python/vendor/dill/__init__.py +119 -0
- package/lib/omp-python/vendor/dill/_dill.py +2265 -0
- package/lib/omp-python/vendor/dill/_objects.py +545 -0
- package/lib/omp-python/vendor/dill/_shims.py +193 -0
- package/lib/omp-python/vendor/dill/detect.py +287 -0
- package/lib/omp-python/vendor/dill/logger.py +285 -0
- package/lib/omp-python/vendor/dill/objtypes.py +24 -0
- package/lib/omp-python/vendor/dill/pointers.py +122 -0
- package/lib/omp-python/vendor/dill/session.py +612 -0
- package/lib/omp-python/vendor/dill/settings.py +25 -0
- package/lib/omp-python/vendor/dill/source.py +1025 -0
- package/lib/omp-python/vendor/dill/temp.py +252 -0
- package/lib/{puppeteer-core-CSk2PtSm.js → puppeteer-core-C-4NyOpY.js} +8 -8
- package/lib/remote/plugin.d.ts +459 -5
- package/lib/remote/plugin.js +2389 -210
- package/lib/url-schemes/index.d.ts +11 -11
- package/lib/url-schemes/index.js +11 -11
- package/lib/web-password.d.ts +8 -8
- package/lib/web-password.js +5 -1
- package/lib/{web-trust-Dej_RI2B.d.ts → web-trust-C_Y6o8Z2.d.ts} +11 -11
- package/lib/web-trust.d.ts +1 -2
- package/lib/welcome-notice.d.ts +5 -7
- package/lib/welcome-notice.js +9 -48
- package/lib/{wrap-T4I0nfPM.js → wrap-0VA0uFx8.js} +1 -1
- package/package.json +7 -24
- package/scripts/kernel-check.mjs +5 -0
- package/lib/LaunchOptions-B874eBu2.js +0 -4
- package/lib/bundle-delivery.d.ts +0 -20
- package/lib/bundle-delivery.js +0 -209
- package/lib/hmr.d.ts +0 -32
- package/lib/hmr.js +0 -49
- package/lib/kernel-bridge.py +0 -172
- package/lib/kernel-env-D8UOWeji.js +0 -195
- package/lib/webserver.d.ts +0 -10
- package/lib/webserver.js +0 -19
- package/scripts/kernel-provision.mjs +0 -35
- package/scripts/register-bundle.mjs +0 -80
package/lib/remote/plugin.js
CHANGED
|
@@ -1,19 +1,498 @@
|
|
|
1
1
|
import { t as Schema } from "../lib-C8_jXvwA.js";
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { readFile } from "node:fs/promises";
|
|
5
|
-
import { join } from "node:path";
|
|
6
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
7
|
-
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";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
9
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
import { createConnection, createServer } from "node:net";
|
|
9
13
|
|
|
10
|
-
//#region src/remote/
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
//#region src/remote/archive.ts
|
|
15
|
+
const FILE_FRAME_BYTES = 64 * 1024;
|
|
16
|
+
const FILE_MAX_ENTRIES = 1e4;
|
|
17
|
+
const FILE_MAX_METADATA = 8 * 1024 * 1024;
|
|
18
|
+
const order = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
19
|
+
function checkRelative(p) {
|
|
20
|
+
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");
|
|
21
|
+
}
|
|
22
|
+
function checkAbsolute(p) {
|
|
23
|
+
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");
|
|
24
|
+
}
|
|
25
|
+
function checkSelection(selection) {
|
|
26
|
+
if (selection.length > FILE_MAX_ENTRIES || Buffer.byteLength(JSON.stringify(selection)) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
|
|
27
|
+
const seen = /* @__PURE__ */ new Set();
|
|
28
|
+
for (const p of selection) {
|
|
29
|
+
checkRelative(p);
|
|
30
|
+
if (seen.has(p)) throw new Error("duplicate selection path");
|
|
31
|
+
seen.add(p);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function checkLink(e) {
|
|
35
|
+
const link = e.link;
|
|
36
|
+
if (e.p === "" || typeof link !== "string" || !link || link.includes("\0") || link.startsWith("/") || Buffer.byteLength(link) > 4096) throw new Error("unsafe archive symlink");
|
|
37
|
+
const target = posix.normalize(posix.join(posix.dirname(e.p), link));
|
|
38
|
+
if (target === ".." || target.startsWith("../")) throw new Error("unsafe archive symlink escape");
|
|
39
|
+
}
|
|
40
|
+
/** Conservative effective resolution: links may traverse only members of this incoming tree.
|
|
41
|
+
* Preserved destination extras cannot supply a symlink target or any of its ancestors.
|
|
42
|
+
*/
|
|
43
|
+
function checkTreeLinks(entries) {
|
|
44
|
+
const incoming = new Map(entries.map((e) => [e.p, e]));
|
|
45
|
+
for (const link of entries.filter((e) => e.t === "l")) {
|
|
46
|
+
const stack = posix.dirname(link.p) === "." ? [] : posix.dirname(link.p).split("/");
|
|
47
|
+
let pending = link.link.split("/"), hops = 0;
|
|
48
|
+
while (pending.length) {
|
|
49
|
+
const part = pending.shift();
|
|
50
|
+
if (!part || part === ".") continue;
|
|
51
|
+
if (part === "..") {
|
|
52
|
+
if (!stack.length) throw new Error("unsafe symlink chain escape");
|
|
53
|
+
stack.pop();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const entry = incoming.get([...stack, part].join("/"));
|
|
57
|
+
if (!entry) throw new Error("unsafe symlink target outside incoming tree (destination aliases are not followed)");
|
|
58
|
+
if (entry.t === "l") {
|
|
59
|
+
if (++hops > 40) throw new Error("unsafe symlink cycle or chain limit");
|
|
60
|
+
pending = [...entry.link.split("/"), ...pending];
|
|
61
|
+
} else {
|
|
62
|
+
if (pending.length && entry.t !== "d") throw new Error("unsafe symlink non-directory target ancestor");
|
|
63
|
+
stack.push(part);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function header(e) {
|
|
69
|
+
const b = Buffer.from(JSON.stringify(e)), n = Buffer.alloc(4);
|
|
70
|
+
n.writeUInt32BE(b.length);
|
|
71
|
+
return Buffer.concat([n, b]);
|
|
72
|
+
}
|
|
73
|
+
function manifestDigest(entries) {
|
|
74
|
+
const hash = createHash("sha256");
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
for (const e of [...entries].sort((a, b) => order(a.p, b.p))) {
|
|
77
|
+
hash.update(header([
|
|
78
|
+
e.p,
|
|
79
|
+
e.t,
|
|
80
|
+
e.n,
|
|
81
|
+
e.t === "f" ? e.hash : e.t === "l" ? e.link : ""
|
|
82
|
+
]));
|
|
83
|
+
if (e.t === "f") bytes += e.n;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
sha256: hash.digest("hex"),
|
|
87
|
+
bytes,
|
|
88
|
+
entries: entries.length
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Reject even existing parent symlinks; leaf may be an explicitly copied symlink. */
|
|
92
|
+
async function ancestors(path$1, includeLeaf = false) {
|
|
93
|
+
checkAbsolute(path$1);
|
|
94
|
+
const parts = resolve(path$1).split("/").filter(Boolean);
|
|
95
|
+
for (let i = 1; i <= parts.length - (includeLeaf ? 0 : 1); i++) try {
|
|
96
|
+
const st = await lstat("/" + parts.slice(0, i).join("/"));
|
|
97
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error("unsafe destination/source ancestor: symlink or non-directory");
|
|
98
|
+
} catch (e) {
|
|
99
|
+
if (e.code !== "ENOENT") throw e;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function safeRead(path$1, signal) {
|
|
103
|
+
return (await open(path$1, constants.O_RDONLY | constants.O_NOFOLLOW)).createReadStream({
|
|
104
|
+
autoClose: true,
|
|
105
|
+
highWaterMark: FILE_FRAME_BYTES,
|
|
106
|
+
signal
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function entryAt(root, p, signal) {
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
const path$1 = p ? join(root, p) : root;
|
|
112
|
+
await ancestors(path$1);
|
|
113
|
+
const st = await lstat(path$1);
|
|
114
|
+
if (st.isDirectory()) return {
|
|
115
|
+
p,
|
|
116
|
+
t: "d",
|
|
117
|
+
n: 0
|
|
118
|
+
};
|
|
119
|
+
if (st.isSymbolicLink()) {
|
|
120
|
+
const e = {
|
|
121
|
+
p,
|
|
122
|
+
t: "l",
|
|
123
|
+
n: 0,
|
|
124
|
+
link: await readlink(path$1)
|
|
125
|
+
};
|
|
126
|
+
checkLink(e);
|
|
127
|
+
return e;
|
|
128
|
+
}
|
|
129
|
+
if (!st.isFile()) throw new Error("unsupported file type (only files, directories and safe relative symlinks)");
|
|
130
|
+
const hash = createHash("sha256");
|
|
131
|
+
for await (const chunk of await safeRead(path$1, signal)) hash.update(chunk);
|
|
132
|
+
return {
|
|
133
|
+
p,
|
|
134
|
+
t: "f",
|
|
135
|
+
n: st.size,
|
|
136
|
+
hash: hash.digest("hex")
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function tree(root, signal, selection) {
|
|
140
|
+
checkAbsolute(root);
|
|
141
|
+
const entries = [];
|
|
142
|
+
const visit = async (p) => {
|
|
143
|
+
const e = await entryAt(root, p, signal);
|
|
144
|
+
entries.push(e);
|
|
145
|
+
if (entries.length > FILE_MAX_ENTRIES) throw new Error("archive entry limit exceeded");
|
|
146
|
+
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);
|
|
147
|
+
};
|
|
148
|
+
if (selection) {
|
|
149
|
+
checkSelection(selection);
|
|
150
|
+
for (const p of selection) entries.push(await entryAt(root, p, signal));
|
|
151
|
+
} else await visit("");
|
|
152
|
+
if (Buffer.byteLength(JSON.stringify(entries)) > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
|
|
153
|
+
return entries;
|
|
154
|
+
}
|
|
155
|
+
async function inspectArchive(path$1, signal) {
|
|
156
|
+
const file = await open(path$1, "r");
|
|
157
|
+
let offset = 0, metadata = 0;
|
|
158
|
+
const entries = [];
|
|
159
|
+
const seen = /* @__PURE__ */ new Map();
|
|
160
|
+
const exact = async (n) => {
|
|
161
|
+
const b = Buffer.alloc(n);
|
|
162
|
+
let at = 0;
|
|
163
|
+
while (at < n) {
|
|
164
|
+
signal.throwIfAborted();
|
|
165
|
+
const r = await file.read(b, at, n - at, offset);
|
|
166
|
+
if (!r.bytesRead) throw new Error("truncated binary archive");
|
|
167
|
+
at += r.bytesRead;
|
|
168
|
+
offset += r.bytesRead;
|
|
169
|
+
}
|
|
170
|
+
return b;
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
while (true) {
|
|
174
|
+
const size = (await exact(4)).readUInt32BE();
|
|
175
|
+
if (!size || size > FILE_FRAME_BYTES) throw new Error("archive header limit exceeded");
|
|
176
|
+
metadata += size;
|
|
177
|
+
if (metadata > FILE_MAX_METADATA) throw new Error("archive metadata limit exceeded");
|
|
178
|
+
const e = JSON.parse((await exact(size)).toString("utf8"));
|
|
179
|
+
if (e.end === true) {
|
|
180
|
+
if ((await file.stat()).size !== offset) throw new Error("trailing archive bytes");
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
checkRelative(e.p);
|
|
184
|
+
if (![
|
|
185
|
+
"f",
|
|
186
|
+
"d",
|
|
187
|
+
"l"
|
|
188
|
+
].includes(e.t) || !Number.isSafeInteger(e.n) || e.n < 0 || e.t !== "f" && e.n !== 0) throw new Error("unsafe archive type/size");
|
|
189
|
+
if (entries.length >= FILE_MAX_ENTRIES || seen.has(e.p)) throw new Error("archive entry limit or duplicate path");
|
|
190
|
+
if (e.p !== "" && seen.get(posix.dirname(e.p) === "." ? "" : posix.dirname(e.p))?.t !== "d") throw new Error("unsafe archive parent ordering/type");
|
|
191
|
+
if (!entries.length && e.p !== "") throw new Error("archive root missing");
|
|
192
|
+
if (e.t === "l") checkLink(e);
|
|
193
|
+
e.offset = offset;
|
|
194
|
+
if (e.t === "f") {
|
|
195
|
+
const hash = createHash("sha256");
|
|
196
|
+
let left = e.n;
|
|
197
|
+
while (left) {
|
|
198
|
+
const chunk = await exact(Math.min(FILE_FRAME_BYTES, left));
|
|
199
|
+
hash.update(chunk);
|
|
200
|
+
left -= chunk.length;
|
|
201
|
+
}
|
|
202
|
+
e.hash = hash.digest("hex");
|
|
203
|
+
}
|
|
204
|
+
entries.push(e);
|
|
205
|
+
seen.set(e.p, e);
|
|
206
|
+
}
|
|
207
|
+
if (!entries.length) throw new Error("archive root missing");
|
|
208
|
+
return entries;
|
|
209
|
+
} finally {
|
|
210
|
+
await file.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function importArchive(destination, payload, mkdirParents, signal, resources) {
|
|
214
|
+
checkAbsolute(destination);
|
|
215
|
+
const entries = await inspectArchive(payload, signal);
|
|
216
|
+
checkTreeLinks(entries);
|
|
217
|
+
for (const e of entries) {
|
|
218
|
+
const path$1 = e.p ? join(destination, e.p) : destination;
|
|
219
|
+
await ancestors(path$1);
|
|
220
|
+
try {
|
|
221
|
+
const st = await lstat(path$1);
|
|
222
|
+
const type = st.isSymbolicLink() ? "l" : st.isDirectory() ? "d" : st.isFile() ? "f" : "?";
|
|
223
|
+
if (type !== e.t || type === "l") throw new Error("destination path/type conflict or existing symlink");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error.code !== "ENOENT") throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
await ancestors(destination);
|
|
229
|
+
if (mkdirParents) await mkdir(dirname(destination), { recursive: true });
|
|
230
|
+
for (const e of entries) {
|
|
231
|
+
signal.throwIfAborted();
|
|
232
|
+
const path$1 = e.p ? join(destination, e.p) : destination;
|
|
233
|
+
await ancestors(path$1);
|
|
234
|
+
if (e.t === "d") {
|
|
235
|
+
await mkdir(path$1, { recursive: false }).catch((error) => {
|
|
236
|
+
if (error.code !== "EEXIST") throw error;
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (e.t === "l") {
|
|
241
|
+
await symlink(e.link, path$1);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const temporary = join(dirname(path$1), `.remote-copy-${randomUUID()}`);
|
|
245
|
+
await resources.temporary?.(temporary, true);
|
|
246
|
+
try {
|
|
247
|
+
const output = createWriteStream(temporary, {
|
|
248
|
+
flags: "wx",
|
|
249
|
+
mode: 384
|
|
250
|
+
});
|
|
251
|
+
await pipeline(e.n ? createReadStream(payload, {
|
|
252
|
+
start: e.offset,
|
|
253
|
+
end: e.offset + e.n - 1
|
|
254
|
+
}) : Readable.from([]), output, { signal });
|
|
255
|
+
await ancestors(path$1);
|
|
256
|
+
signal.throwIfAborted();
|
|
257
|
+
await rename(temporary, path$1);
|
|
258
|
+
} finally {
|
|
259
|
+
await rm(temporary, { force: true });
|
|
260
|
+
await resources.temporary?.(temporary, false);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function localFileEndpoint(resources = {}) {
|
|
265
|
+
return {
|
|
266
|
+
async exportTree(path$1, signal) {
|
|
267
|
+
const entries = await tree(path$1, signal);
|
|
268
|
+
return Readable.from((async function* () {
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
signal.throwIfAborted();
|
|
271
|
+
yield header({
|
|
272
|
+
p: e.p,
|
|
273
|
+
t: e.t,
|
|
274
|
+
n: e.n,
|
|
275
|
+
...e.link === void 0 ? {} : { link: e.link }
|
|
276
|
+
});
|
|
277
|
+
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;
|
|
278
|
+
}
|
|
279
|
+
yield header({ end: true });
|
|
280
|
+
})());
|
|
281
|
+
},
|
|
282
|
+
async importTree(path$1, source, { mkdir: parents, signal }) {
|
|
283
|
+
checkAbsolute(path$1);
|
|
284
|
+
const scratch = resources.receiveRoot ?? await mkdtemp(join(tmpdir(), "remote-file-receive-"));
|
|
285
|
+
try {
|
|
286
|
+
const payload = join(scratch, "payload");
|
|
287
|
+
await pipeline(source, createWriteStream(payload, {
|
|
288
|
+
flags: "wx",
|
|
289
|
+
mode: 384
|
|
290
|
+
}), { signal });
|
|
291
|
+
await importArchive(path$1, payload, parents, signal, resources);
|
|
292
|
+
} finally {
|
|
293
|
+
source.destroy?.();
|
|
294
|
+
await rm(scratch, {
|
|
295
|
+
recursive: true,
|
|
296
|
+
force: true
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
async digest(path$1, signal, selection) {
|
|
301
|
+
return manifestDigest(await tree(path$1, signal, selection));
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/remote/copy.ts
|
|
308
|
+
var CopyError = class extends Error {
|
|
309
|
+
constructor(receipt) {
|
|
310
|
+
super(`copy failed: ${JSON.stringify(receipt)}`);
|
|
311
|
+
this.receipt = receipt;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/** Tree lane (Hub/local binary endpoints) with staged archive and digest
|
|
315
|
+
* verification. CLI-option fields are rejected: this lane has no native CLI. */
|
|
316
|
+
async function copyTree(request, source, destination, signal, scratchRoot = join(process.cwd(), ".scratch")) {
|
|
317
|
+
signal.throwIfAborted();
|
|
318
|
+
if (request.argv !== void 0 || request.pullArgv !== void 0 || request.pushArgv !== void 0) throw new Error("copy: argv/pullArgv/pushArgv are native CLI options; the Hub/local staged lane does not accept CLI options");
|
|
319
|
+
await mkdir(scratchRoot, { recursive: true });
|
|
320
|
+
signal.throwIfAborted();
|
|
321
|
+
const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
|
|
322
|
+
const receipt = {
|
|
323
|
+
action: "copy",
|
|
324
|
+
src: request.src,
|
|
325
|
+
dst: request.dst,
|
|
326
|
+
verified: false,
|
|
327
|
+
bytes: 0,
|
|
328
|
+
entries: 0,
|
|
329
|
+
scope: "transferred entries"
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
await pipeline(await source.endpoint.exportTree(source.path, signal), createWriteStream(payload, {
|
|
333
|
+
flags: "wx",
|
|
334
|
+
mode: 384
|
|
335
|
+
}), { signal });
|
|
336
|
+
const manifest = await inspectArchive(payload, signal), staged = manifestDigest(manifest);
|
|
337
|
+
receipt.bytes = staged.bytes;
|
|
338
|
+
receipt.entries = staged.entries;
|
|
339
|
+
const selection = manifest.map((e) => e.p);
|
|
340
|
+
if (request.verify !== false) {
|
|
341
|
+
const original = await source.endpoint.digest(source.path, signal, selection);
|
|
342
|
+
receipt.sourceDigest = original.sha256;
|
|
343
|
+
if (original.sha256 !== staged.sha256) throw new Error("source changed during export; staged digest mismatch");
|
|
344
|
+
}
|
|
345
|
+
signal.throwIfAborted();
|
|
346
|
+
await destination.endpoint.importTree(destination.path, createReadStream(payload), {
|
|
347
|
+
mkdir: request.mkdir ?? true,
|
|
348
|
+
signal
|
|
349
|
+
});
|
|
350
|
+
if (request.verify !== false) {
|
|
351
|
+
const dest = await destination.endpoint.digest(destination.path, signal, selection);
|
|
352
|
+
receipt.destinationDigest = dest.sha256;
|
|
353
|
+
if (dest.sha256 !== receipt.sourceDigest) throw new Error("destination digest mismatch");
|
|
354
|
+
receipt.verified = true;
|
|
355
|
+
}
|
|
356
|
+
signal.throwIfAborted();
|
|
357
|
+
await rm(scratch, {
|
|
358
|
+
recursive: true,
|
|
359
|
+
force: true
|
|
360
|
+
});
|
|
361
|
+
return receipt;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
throw new CopyError({
|
|
364
|
+
...receipt,
|
|
365
|
+
verified: false,
|
|
366
|
+
scratch,
|
|
367
|
+
error: error instanceof Error ? error.message : String(error)
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
//#endregion
|
|
373
|
+
//#region src/remote/output-ring.ts
|
|
374
|
+
const continuation = (byte) => (byte & 192) === 128;
|
|
375
|
+
function completeUtf8End(bytes) {
|
|
376
|
+
if (!bytes.length) return 0;
|
|
377
|
+
let start = bytes.length - 1;
|
|
378
|
+
while (start > 0 && continuation(bytes[start]) && bytes.length - start < 4) start--;
|
|
379
|
+
const lead = bytes[start];
|
|
380
|
+
const width = lead >= 240 && lead <= 244 ? 4 : lead >= 224 && lead <= 239 ? 3 : lead >= 194 && lead <= 223 ? 2 : 1;
|
|
381
|
+
return bytes.length - start < width ? start : bytes.length;
|
|
382
|
+
}
|
|
383
|
+
/** Byte-addressed decoded output: mid-codepoint cursors skip forward and explicitly report loss. */
|
|
384
|
+
function readUtf8Tail(bytes, totalBytes, fromByte) {
|
|
385
|
+
if (!Number.isSafeInteger(fromByte) || fromByte < 0) throw new Error("Output cursor must be a non-negative safe byte offset");
|
|
386
|
+
const start = totalBytes - bytes.length;
|
|
387
|
+
let offset = Math.max(0, Math.min(bytes.length, fromByte - start));
|
|
388
|
+
let lossy = fromByte < start;
|
|
389
|
+
while (offset < bytes.length && continuation(bytes[offset])) {
|
|
390
|
+
offset++;
|
|
391
|
+
lossy = true;
|
|
392
|
+
}
|
|
393
|
+
const end = completeUtf8End(bytes);
|
|
394
|
+
return {
|
|
395
|
+
text: bytes.subarray(Math.min(offset, end), end).toString("utf8"),
|
|
396
|
+
nextOffset: start + end,
|
|
397
|
+
lossy
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/** Native/PTY retention is UTF16 units; cursor offsets remain real UTF8 bytes. Input is decoded text. */
|
|
401
|
+
var Utf16TailRing = class {
|
|
402
|
+
text = "";
|
|
403
|
+
total = 0;
|
|
404
|
+
totalBytes = 0;
|
|
405
|
+
constructor(capacity = 4e6) {
|
|
406
|
+
this.capacity = capacity;
|
|
407
|
+
}
|
|
408
|
+
push(text) {
|
|
409
|
+
this.total += text.length;
|
|
410
|
+
this.totalBytes += Buffer.byteLength(text);
|
|
411
|
+
this.text = (this.text + text).slice(-this.capacity);
|
|
412
|
+
if (this.text.charCodeAt(0) >= 56320 && this.text.charCodeAt(0) <= 57343) this.text = this.text.slice(1);
|
|
413
|
+
}
|
|
414
|
+
stats(returned = this.text.length) {
|
|
415
|
+
return {
|
|
416
|
+
unit: "utf16",
|
|
417
|
+
total: this.total,
|
|
418
|
+
retained: this.text.length,
|
|
419
|
+
dropped: this.total - this.text.length,
|
|
420
|
+
returned,
|
|
421
|
+
retentionLimit: this.capacity
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
read(fromByte) {
|
|
425
|
+
return readUtf8Tail(Buffer.from(this.text), this.totalBytes, fromByte);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
/** Raw byte retention for Hub oneshot. Tail/read starts skip partial UTF8 prefixes, never invent replacement characters. */
|
|
429
|
+
var ByteTailRing = class {
|
|
430
|
+
total = 0;
|
|
431
|
+
chunks = [];
|
|
432
|
+
retained = 0;
|
|
433
|
+
constructor(capacity = 4 * 1024 * 1024) {
|
|
434
|
+
this.capacity = capacity;
|
|
435
|
+
}
|
|
436
|
+
push(chunk) {
|
|
437
|
+
if (chunk.length === 0) return;
|
|
438
|
+
this.total += chunk.length;
|
|
439
|
+
this.chunks.push(chunk);
|
|
440
|
+
this.retained += chunk.length;
|
|
441
|
+
let drop = this.retained - this.capacity;
|
|
442
|
+
while (drop > 0) {
|
|
443
|
+
const head = this.chunks[0];
|
|
444
|
+
const count = Math.min(drop, head.length);
|
|
445
|
+
if (count === head.length) this.chunks.shift();
|
|
446
|
+
else this.chunks[0] = head.subarray(count);
|
|
447
|
+
this.retained -= count;
|
|
448
|
+
drop -= count;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
tail(maxBytes = this.retained) {
|
|
452
|
+
let skip = Math.max(0, this.retained - maxBytes);
|
|
453
|
+
const out = [];
|
|
454
|
+
for (const chunk of this.chunks) {
|
|
455
|
+
if (skip >= chunk.length) {
|
|
456
|
+
skip -= chunk.length;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
out.push(chunk.subarray(skip));
|
|
460
|
+
skip = 0;
|
|
461
|
+
}
|
|
462
|
+
const bytes = Buffer.concat(out);
|
|
463
|
+
let offset = 0;
|
|
464
|
+
while (offset < bytes.length && continuation(bytes[offset])) offset++;
|
|
465
|
+
return bytes.subarray(offset, completeUtf8End(bytes));
|
|
466
|
+
}
|
|
467
|
+
stats(returned = this.tail().length) {
|
|
468
|
+
return {
|
|
469
|
+
unit: "bytes",
|
|
470
|
+
total: this.total,
|
|
471
|
+
retained: this.retained,
|
|
472
|
+
dropped: this.total - this.retained,
|
|
473
|
+
returned,
|
|
474
|
+
retentionLimit: this.capacity
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
read(fromByte) {
|
|
478
|
+
return readUtf8Tail(Buffer.concat(this.chunks), this.total, fromByte);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
//#endregion
|
|
483
|
+
//#region src/remote/process.ts
|
|
484
|
+
const TERM_GRACE_MS = 2e3;
|
|
485
|
+
const KILL_SETTLE_MS = 100;
|
|
486
|
+
const PROCESS_CLEANUP_BOUND_MS = TERM_GRACE_MS + KILL_SETTLE_MS;
|
|
487
|
+
/** Real detached process. Completion includes group cleanup, even after its leader closes. */
|
|
488
|
+
function startBinaryProcess(argv, options = {}) {
|
|
15
489
|
const started = Date.now();
|
|
16
490
|
const child = spawn(argv[0], argv.slice(1), {
|
|
491
|
+
cwd: options.cwd,
|
|
492
|
+
env: options.env === void 0 ? process.env : {
|
|
493
|
+
...process.env,
|
|
494
|
+
...options.env
|
|
495
|
+
},
|
|
17
496
|
stdio: [
|
|
18
497
|
"pipe",
|
|
19
498
|
"pipe",
|
|
@@ -21,57 +500,1075 @@ async function runOneShot(argv, opts = {}) {
|
|
|
21
500
|
],
|
|
22
501
|
detached: true
|
|
23
502
|
});
|
|
24
|
-
let
|
|
25
|
-
let
|
|
26
|
-
let
|
|
27
|
-
|
|
28
|
-
|
|
503
|
+
let diagnostic;
|
|
504
|
+
let timedOut = false, finished = false, cancelled = false;
|
|
505
|
+
let cleanup;
|
|
506
|
+
let timer;
|
|
507
|
+
const signal = (sig) => {
|
|
508
|
+
if (child.pid === void 0) return false;
|
|
509
|
+
try {
|
|
510
|
+
process.kill(-child.pid, sig);
|
|
511
|
+
return true;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error.code !== "ESRCH") diagnostic = `remote: process cleanup failed: ${String(error)}`;
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
let closed;
|
|
518
|
+
const close = new Promise((resolve$1) => {
|
|
519
|
+
closed = resolve$1;
|
|
29
520
|
});
|
|
30
|
-
|
|
31
|
-
if (
|
|
521
|
+
const reap = () => cleanup ??= new Promise((resolve$1) => {
|
|
522
|
+
if (!signal("SIGTERM")) {
|
|
523
|
+
resolve$1();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
let settled = false;
|
|
527
|
+
const finish = () => {
|
|
528
|
+
if (!settled) {
|
|
529
|
+
settled = true;
|
|
530
|
+
clearTimeout(grace);
|
|
531
|
+
resolve$1();
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
const grace = setTimeout(() => {
|
|
535
|
+
signal("SIGKILL");
|
|
536
|
+
setTimeout(finish, KILL_SETTLE_MS);
|
|
537
|
+
}, TERM_GRACE_MS);
|
|
538
|
+
close.then(() => {
|
|
539
|
+
if (!signal(0)) finish();
|
|
540
|
+
});
|
|
32
541
|
});
|
|
542
|
+
const cancel = () => {
|
|
543
|
+
if (!finished) {
|
|
544
|
+
cancelled = true;
|
|
545
|
+
reap();
|
|
546
|
+
}
|
|
547
|
+
};
|
|
33
548
|
child.stdin.on("error", () => {});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
549
|
+
child.once("error", (error) => {
|
|
550
|
+
diagnostic = `exec: spawn failed — ${error.message}${options.cwd === void 0 ? "" : ` (cwd: ${options.cwd})`}`;
|
|
551
|
+
});
|
|
552
|
+
const done = new Promise((resolve$1) => {
|
|
553
|
+
child.once("close", async (exit) => {
|
|
554
|
+
closed();
|
|
555
|
+
if (timer) clearTimeout(timer);
|
|
556
|
+
await reap();
|
|
557
|
+
finished = true;
|
|
558
|
+
resolve$1({
|
|
559
|
+
exit: diagnostic ? null : exit,
|
|
560
|
+
timedOut,
|
|
561
|
+
cancelled,
|
|
562
|
+
durationMs: Date.now() - started
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
if (options.timeoutSec !== void 0) timer = setTimeout(() => {
|
|
567
|
+
timedOut = true;
|
|
568
|
+
cancel();
|
|
569
|
+
}, options.timeoutSec * 1e3);
|
|
570
|
+
return {
|
|
571
|
+
cancel,
|
|
572
|
+
done,
|
|
573
|
+
stdout: child.stdout,
|
|
574
|
+
stderr: child.stderr,
|
|
575
|
+
stdin: child.stdin,
|
|
576
|
+
diagnostic: () => diagnostic
|
|
41
577
|
};
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
578
|
+
}
|
|
579
|
+
/** Text adapter over the same owned process-group lifecycle as binary pipelines. */
|
|
580
|
+
function startOneShot(argv, options = {}) {
|
|
581
|
+
const child = startBinaryProcess(argv, options);
|
|
582
|
+
const byteMode = options.unit === "bytes";
|
|
583
|
+
const stdout = byteMode ? new ByteTailRing() : new Utf16TailRing();
|
|
584
|
+
const stderr = byteMode ? new ByteTailRing() : new Utf16TailRing();
|
|
585
|
+
for (const [stream, ring] of [[child.stdout, stdout], [child.stderr, stderr]]) {
|
|
586
|
+
if (!byteMode) stream.setEncoding("utf8");
|
|
587
|
+
stream.on("data", (chunk) => {
|
|
588
|
+
if (ring instanceof ByteTailRing) ring.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
589
|
+
else ring.push(String(chunk));
|
|
590
|
+
options.onOutput?.();
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
child.stdin.end(options.stdin);
|
|
594
|
+
return {
|
|
595
|
+
cancel: child.cancel,
|
|
596
|
+
done: child.done,
|
|
597
|
+
read(channel, fromByte) {
|
|
598
|
+
const ring = channel === "stdout" ? stdout : stderr;
|
|
599
|
+
const read = ring.read(fromByte), diagnostic = child.diagnostic();
|
|
600
|
+
return {
|
|
601
|
+
...read,
|
|
602
|
+
stats: ring.stats(byteMode ? Buffer.byteLength(read.text) : read.text.length),
|
|
603
|
+
...channel === "stderr" && diagnostic ? { diagnostic } : {}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
//#endregion
|
|
610
|
+
//#region src/remote/native-copy.ts
|
|
611
|
+
const OUTPUT_LIMIT$1 = 512 * 1024;
|
|
612
|
+
var ArgvError = class extends Error {
|
|
613
|
+
constructor(field, reason) {
|
|
614
|
+
super(`remote: ${field} must be an array of non-empty string tokens without NUL (${reason})`);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
/** Ordered native option tokens; one element = one argv token, never shell-parsed. */
|
|
618
|
+
function checkArgv(field, value) {
|
|
619
|
+
if (value === void 0) return [];
|
|
620
|
+
if (!Array.isArray(value)) throw new ArgvError(field, "got a non-array");
|
|
621
|
+
for (const token of value) {
|
|
622
|
+
if (typeof token !== "string" || token.length === 0) throw new ArgvError(field, "empty or non-string element");
|
|
623
|
+
if (token.includes("\0")) throw new ArgvError(field, "NUL byte in element");
|
|
624
|
+
if (token === "--") throw new ArgvError(field, "a bare -- would terminate option parsing and let later tokens act as extra source operands; src/dst cannot be overridden via argv");
|
|
625
|
+
}
|
|
626
|
+
return [...value];
|
|
627
|
+
}
|
|
628
|
+
/** scp -R re-authenticates to the destination FROM the source host, breaking the identities-stay-on-origin boundary. */
|
|
629
|
+
function rejectRemoteToRemoteScp(argv) {
|
|
630
|
+
const boundary = "remote: scp -R would authenticate to the destination from the source host; identities must stay on the origin (scp -3 through-origin is the supported route)";
|
|
631
|
+
for (const token of argv) {
|
|
632
|
+
if (token === "-R" || token === "--remote-to-remote" || token === "--r") throw new Error(boundary);
|
|
633
|
+
if (token.startsWith("-") && !token.startsWith("--") && token.slice(1).includes("R")) throw new Error(boundary);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/** Run one native CLI with group-owned lifecycle; output bounded, exit verbatim.
|
|
637
|
+
* `env` overlays process env (RSYNC_RSH wiring); values are tool-built argv, never model text. */
|
|
638
|
+
async function runNative(argv, signal, env = {}) {
|
|
639
|
+
signal.throwIfAborted();
|
|
640
|
+
const child = startBinaryProcess(argv, { env: {
|
|
641
|
+
...process.env,
|
|
642
|
+
...env
|
|
643
|
+
} });
|
|
644
|
+
const cancel = () => child.cancel();
|
|
645
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
646
|
+
let stdout = "", stderr = "", truncated = false;
|
|
647
|
+
child.stdout.setEncoding("utf8");
|
|
648
|
+
child.stderr.setEncoding("utf8");
|
|
649
|
+
child.stdout.on("data", (text) => {
|
|
650
|
+
const value = stdout + text;
|
|
651
|
+
if (value.length > OUTPUT_LIMIT$1) truncated = true;
|
|
652
|
+
stdout = value.slice(-OUTPUT_LIMIT$1);
|
|
653
|
+
});
|
|
654
|
+
child.stderr.on("data", (text) => {
|
|
655
|
+
const value = stderr + text;
|
|
656
|
+
if (value.length > OUTPUT_LIMIT$1) truncated = true;
|
|
657
|
+
stderr = value.slice(-OUTPUT_LIMIT$1);
|
|
658
|
+
});
|
|
659
|
+
try {
|
|
660
|
+
const result = await child.done;
|
|
661
|
+
const diagnostic = child.diagnostic();
|
|
662
|
+
return {
|
|
663
|
+
exit: result.exit,
|
|
664
|
+
stdout,
|
|
665
|
+
stderr: diagnostic !== void 0 ? (stderr + "\n" + diagnostic).slice(-OUTPUT_LIMIT$1) : stderr,
|
|
666
|
+
outputTruncated: truncated || diagnostic !== void 0
|
|
51
667
|
};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
668
|
+
} finally {
|
|
669
|
+
signal.removeEventListener("abort", cancel);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/** scp spec keeps identity in argv/config; caller -F/-i/-P override by scp's own precedence. */
|
|
673
|
+
function scpSpec(connection, path$1) {
|
|
674
|
+
const host = connection.host.includes(":") ? `[${connection.host}]` : connection.host;
|
|
675
|
+
return `${connection.user !== void 0 ? `${connection.user}@${host}` : host}:${path$1}`;
|
|
676
|
+
}
|
|
677
|
+
/** Connection identity as scp options (port, key), placed BEFORE caller argv so
|
|
678
|
+
* an explicit caller -F/-i/-P/-J overrides the registered identity by scp's own
|
|
679
|
+
* precedence (last option wins). */
|
|
680
|
+
function identityArgv(connection) {
|
|
681
|
+
const options = ["-o", "BatchMode=yes"];
|
|
682
|
+
if (connection.port !== void 0) options.push("-P", String(connection.port));
|
|
683
|
+
if (connection.key !== void 0) options.push("-i", connection.key);
|
|
684
|
+
if (connection.proxyJump !== void 0) options.push("-J", connection.proxyJump);
|
|
685
|
+
return options;
|
|
686
|
+
}
|
|
687
|
+
function sshEndpoint(plan) {
|
|
688
|
+
if (plan.kind !== "ssh") throw new Error("remote: native copy endpoint is not an SSH target");
|
|
689
|
+
return { connection: plan.connection ?? { host: plan.host } };
|
|
690
|
+
}
|
|
691
|
+
/** Container name for docker/incus endpoints (narrows without casts). */
|
|
692
|
+
function containerOf(plan) {
|
|
693
|
+
if (plan.kind !== "docker" && plan.kind !== "incus") throw new Error("remote: native copy endpoint is not a container target");
|
|
694
|
+
return plan.container;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* One native copy.
|
|
698
|
+
* - local↔SSH: scp; SSH↔SSH: scp -3 through origin (both identities here; when
|
|
699
|
+
* they differ a temp origin-only config maps two synthetic aliases)
|
|
700
|
+
* - local↔docker: docker cp; local↔incus: incus file push/pull
|
|
701
|
+
* - every other cross-endpoint pair: two native commands with origin staging,
|
|
702
|
+
* each segment with its own options (pullArgv/pushArgv)
|
|
703
|
+
*/
|
|
704
|
+
async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
705
|
+
signal.throwIfAborted();
|
|
706
|
+
const oneShot = checkArgv("argv", request.argv);
|
|
707
|
+
const pull = checkArgv("pullArgv", request.pullArgv);
|
|
708
|
+
const push = checkArgv("pushArgv", request.pushArgv);
|
|
709
|
+
if (oneShot.length > 0 && (pull.length > 0 || push.length > 0)) throw new Error("remote: argv cannot be combined with pullArgv/pushArgv (argv is for single-command routes; pullArgv/pushArgv for two-segment routes)");
|
|
710
|
+
if (request.pullArgv !== void 0 && request.pushArgv === void 0) throw new Error("remote: pullArgv requires pushArgv (a two-segment copy has both halves)");
|
|
711
|
+
if (request.pushArgv !== void 0 && request.pullArgv === void 0) throw new Error("remote: pushArgv requires pullArgv (a two-segment copy has both halves)");
|
|
712
|
+
const wrap = options.wrapArgv ?? ((argv) => argv);
|
|
713
|
+
const s = endpoints.src.plan.kind, t = endpoints.dst.plan.kind;
|
|
714
|
+
const receipt = {
|
|
715
|
+
action: "copy",
|
|
716
|
+
src: request.src,
|
|
717
|
+
dst: request.dst,
|
|
718
|
+
route: "",
|
|
719
|
+
argv: [],
|
|
720
|
+
exits: [],
|
|
721
|
+
stdout: [],
|
|
722
|
+
stderr: [],
|
|
723
|
+
outputTruncated: false,
|
|
724
|
+
verified: false,
|
|
725
|
+
cleanup: "none-required"
|
|
726
|
+
};
|
|
727
|
+
const finish = (route, argvs, results) => {
|
|
728
|
+
receipt.route = route;
|
|
729
|
+
receipt.argv = argvs;
|
|
730
|
+
receipt.exits = results.map((result) => result.exit);
|
|
731
|
+
receipt.stdout = results.map((result) => result.stdout);
|
|
732
|
+
receipt.stderr = results.map((result) => result.stderr);
|
|
733
|
+
receipt.outputTruncated = results.some((result) => result.outputTruncated);
|
|
734
|
+
return receipt;
|
|
735
|
+
};
|
|
736
|
+
if (s === "local" && t === "ssh") {
|
|
737
|
+
rejectRemoteToRemoteScp(oneShot);
|
|
738
|
+
const connection = sshEndpoint(endpoints.dst.plan).connection;
|
|
739
|
+
const argv = [
|
|
740
|
+
"scp",
|
|
741
|
+
...identityArgv(connection),
|
|
742
|
+
...oneShot,
|
|
743
|
+
endpoints.src.path,
|
|
744
|
+
scpSpec(connection, endpoints.dst.path)
|
|
745
|
+
];
|
|
746
|
+
return finish("scp", [argv], [await runNative(wrap(argv), signal)]);
|
|
747
|
+
}
|
|
748
|
+
if (s === "ssh" && t === "local") {
|
|
749
|
+
rejectRemoteToRemoteScp(oneShot);
|
|
750
|
+
const connection = sshEndpoint(endpoints.src.plan).connection;
|
|
751
|
+
const argv = [
|
|
752
|
+
"scp",
|
|
753
|
+
...identityArgv(connection),
|
|
754
|
+
...oneShot,
|
|
755
|
+
scpSpec(connection, endpoints.src.path),
|
|
756
|
+
endpoints.dst.path
|
|
757
|
+
];
|
|
758
|
+
return finish("scp", [argv], [await runNative(wrap(argv), signal)]);
|
|
759
|
+
}
|
|
760
|
+
if (s === "ssh" && t === "ssh") {
|
|
761
|
+
rejectRemoteToRemoteScp(oneShot);
|
|
762
|
+
const source = sshEndpoint(endpoints.src.plan).connection, destination = sshEndpoint(endpoints.dst.plan).connection;
|
|
763
|
+
const sameIdentity = JSON.stringify(source) === JSON.stringify(destination);
|
|
764
|
+
let config;
|
|
765
|
+
let unknown = false;
|
|
766
|
+
try {
|
|
767
|
+
let srcSpec, dstSpec;
|
|
768
|
+
let extra = [];
|
|
769
|
+
if (sameIdentity) {
|
|
770
|
+
srcSpec = scpSpec(source, endpoints.src.path);
|
|
771
|
+
dstSpec = scpSpec(destination, endpoints.dst.path);
|
|
772
|
+
extra = identityArgv(source);
|
|
773
|
+
} else {
|
|
774
|
+
config = await tempSshConfig(source, destination);
|
|
775
|
+
extra = ["-F", config.path];
|
|
776
|
+
srcSpec = `superd-scp3-source:${endpoints.src.path}`;
|
|
777
|
+
dstSpec = `superd-scp3-destination:${endpoints.dst.path}`;
|
|
778
|
+
}
|
|
779
|
+
const argv = [
|
|
780
|
+
"scp",
|
|
781
|
+
"-3",
|
|
782
|
+
...extra,
|
|
783
|
+
...oneShot,
|
|
784
|
+
srcSpec,
|
|
785
|
+
dstSpec
|
|
786
|
+
];
|
|
787
|
+
return finish("scp -3 (data through origin)", [argv], [await runNative(wrap(argv), signal)]);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (signal.aborted) unknown = true;
|
|
790
|
+
throw error;
|
|
791
|
+
} finally {
|
|
792
|
+
if (config !== void 0) receipt.cleanup = !await rm(config.dir, {
|
|
793
|
+
recursive: true,
|
|
794
|
+
force: true
|
|
795
|
+
}).then(() => true, () => false) || unknown ? "unknown" : "released";
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (s === "local" && t === "docker") {
|
|
799
|
+
const argv = [
|
|
800
|
+
"docker",
|
|
801
|
+
"cp",
|
|
802
|
+
...oneShot,
|
|
803
|
+
endpoints.src.path,
|
|
804
|
+
`${containerOf(endpoints.dst.plan)}:${endpoints.dst.path}`
|
|
805
|
+
];
|
|
806
|
+
return finish("docker cp", [argv], [await runNative(wrap(argv), signal)]);
|
|
807
|
+
}
|
|
808
|
+
if (s === "docker" && t === "local") {
|
|
809
|
+
const argv = [
|
|
810
|
+
"docker",
|
|
811
|
+
"cp",
|
|
812
|
+
...oneShot,
|
|
813
|
+
`${containerOf(endpoints.src.plan)}:${endpoints.src.path}`,
|
|
814
|
+
endpoints.dst.path
|
|
815
|
+
];
|
|
816
|
+
return finish("docker cp", [argv], [await runNative(wrap(argv), signal)]);
|
|
817
|
+
}
|
|
818
|
+
if (s === "local" && t === "incus") {
|
|
819
|
+
const argv = [
|
|
820
|
+
"incus",
|
|
821
|
+
"file",
|
|
822
|
+
"push",
|
|
823
|
+
...oneShot,
|
|
824
|
+
endpoints.src.path,
|
|
825
|
+
`${containerOf(endpoints.dst.plan)}${endpoints.dst.path}`
|
|
826
|
+
];
|
|
827
|
+
return finish("incus file push", [argv], [await runNative(wrap(argv), signal)]);
|
|
828
|
+
}
|
|
829
|
+
if (s === "incus" && t === "local") {
|
|
830
|
+
const argv = [
|
|
831
|
+
"incus",
|
|
832
|
+
"file",
|
|
833
|
+
"pull",
|
|
834
|
+
...oneShot,
|
|
835
|
+
`${containerOf(endpoints.src.plan)}${endpoints.src.path}`,
|
|
836
|
+
endpoints.dst.path
|
|
837
|
+
];
|
|
838
|
+
return finish("incus file pull", [argv], [await runNative(wrap(argv), signal)]);
|
|
839
|
+
}
|
|
840
|
+
if (s === "local" || t === "local") throw new Error("remote: this endpoint pair is a single native command route; pullArgv/pushArgv apply to cross-endpoint pairs only");
|
|
841
|
+
const pullOptions = request.pullArgv !== void 0 ? pull : oneShot;
|
|
842
|
+
const pushOptions = request.pushArgv !== void 0 ? push : oneShot;
|
|
843
|
+
const scratch = await mkdtemp(join(options.scratchRoot ?? join(process.cwd(), ".scratch"), "remote-native-copy-"));
|
|
844
|
+
const staged = join(scratch, "staged");
|
|
845
|
+
try {
|
|
846
|
+
rejectRemoteToRemoteScp(pullOptions);
|
|
847
|
+
rejectRemoteToRemoteScp(pushOptions);
|
|
848
|
+
const pullArgv = segmentArgv(endpoints.src.plan, endpoints.src.path, staged, pullOptions, "pull");
|
|
849
|
+
const pushArgv = segmentArgv(endpoints.dst.plan, endpoints.dst.path, staged, pushOptions, "push");
|
|
850
|
+
const first = await runNative(wrap(pullArgv), signal);
|
|
851
|
+
if (first.exit !== 0) return finish("two native commands with origin staging (push not started: pull failed)", [pullArgv], [first]);
|
|
852
|
+
const second = await runNative(wrap(pushArgv), signal);
|
|
853
|
+
return finish("two native commands with origin staging", [pullArgv, pushArgv], [first, second]);
|
|
854
|
+
} finally {
|
|
855
|
+
receipt.cleanup = await rm(scratch, {
|
|
856
|
+
recursive: true,
|
|
857
|
+
force: true
|
|
858
|
+
}).then(() => true, () => false) && !signal.aborted ? "released" : "unknown";
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/** One segment of a two-segment route: pull remote→staging, or push staging→remote. */
|
|
862
|
+
function segmentArgv(plan, remote, staged, opts, leg) {
|
|
863
|
+
if (plan.kind === "local") throw new Error("remote: two-segment staging connects two CLI peers, not a local path");
|
|
864
|
+
if (plan.kind === "ssh") {
|
|
865
|
+
const connection = sshEndpoint(plan).connection;
|
|
866
|
+
const spec$1 = scpSpec(connection, remote);
|
|
867
|
+
return leg === "pull" ? [
|
|
868
|
+
"scp",
|
|
869
|
+
...opts,
|
|
870
|
+
...identityArgv(connection),
|
|
871
|
+
spec$1,
|
|
872
|
+
staged
|
|
873
|
+
] : [
|
|
874
|
+
"scp",
|
|
875
|
+
...opts,
|
|
876
|
+
...identityArgv(connection),
|
|
877
|
+
staged,
|
|
878
|
+
spec$1
|
|
879
|
+
];
|
|
880
|
+
}
|
|
881
|
+
if (plan.kind === "docker") {
|
|
882
|
+
const spec$1 = `${plan.container}:${remote}`;
|
|
883
|
+
return leg === "pull" ? [
|
|
884
|
+
"docker",
|
|
885
|
+
"cp",
|
|
886
|
+
...opts,
|
|
887
|
+
spec$1,
|
|
888
|
+
staged
|
|
889
|
+
] : [
|
|
890
|
+
"docker",
|
|
891
|
+
"cp",
|
|
892
|
+
...opts,
|
|
893
|
+
staged,
|
|
894
|
+
spec$1
|
|
895
|
+
];
|
|
896
|
+
}
|
|
897
|
+
const spec = `${plan.container}${remote}`;
|
|
898
|
+
return leg === "pull" ? [
|
|
899
|
+
"incus",
|
|
900
|
+
"file",
|
|
901
|
+
"pull",
|
|
902
|
+
...opts,
|
|
903
|
+
spec,
|
|
904
|
+
staged
|
|
905
|
+
] : [
|
|
906
|
+
"incus",
|
|
907
|
+
"file",
|
|
908
|
+
"push",
|
|
909
|
+
...opts,
|
|
910
|
+
staged,
|
|
911
|
+
spec
|
|
912
|
+
];
|
|
913
|
+
}
|
|
914
|
+
/** Distinguishing identities for scp -3: an origin-only temp config maps two
|
|
915
|
+
* synthetic aliases; keys are referenced in place, never copied to any remote. */
|
|
916
|
+
async function tempSshConfig(source, destination) {
|
|
917
|
+
for (const c of [source, destination]) for (const [field, value] of [
|
|
918
|
+
["host", c.host],
|
|
919
|
+
["user", c.user],
|
|
920
|
+
["key", c.key],
|
|
921
|
+
["proxyJump", c.proxyJump]
|
|
922
|
+
]) if (value !== void 0 && /[\r\n]/.test(value)) throw new Error(`remote: scp -3 temp config rejects newline in SSH ${field} (no config injection)`);
|
|
923
|
+
const dir = await mkdtemp(join(tmpdir(), "superd-scp3-"));
|
|
924
|
+
const path$1 = join(dir, "config");
|
|
925
|
+
const block = (name$1, c) => {
|
|
926
|
+
const lines = [`Host ${name$1}`, ` HostName ${c.host.replace(/^.*@/, "")}`];
|
|
927
|
+
if (c.user !== void 0) lines.push(` User ${c.user}`);
|
|
928
|
+
if (c.port !== void 0) lines.push(` Port ${String(c.port)}`);
|
|
929
|
+
if (c.key !== void 0) lines.push(` IdentityFile ${c.key}`);
|
|
930
|
+
if (c.proxyJump !== void 0) lines.push(` ProxyJump ${c.proxyJump}`);
|
|
931
|
+
return lines.join("\n");
|
|
932
|
+
};
|
|
933
|
+
await writeFile(path$1, `${block("superd-scp3-source", source)}\n${block("superd-scp3-destination", destination)}\n`, { mode: 384 });
|
|
934
|
+
await chmod(path$1, 384);
|
|
935
|
+
return {
|
|
936
|
+
path: path$1,
|
|
937
|
+
dir
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
//#endregion
|
|
942
|
+
//#region src/remote/ssh-rsync-native-relay.ts
|
|
943
|
+
/** ssh argv for the relay: batch, no agent, no multiplexing. `command` is the
|
|
944
|
+
* remote command; forwarding options go before `--`, the host after it. */
|
|
945
|
+
function relaySsh(connection, extra, command) {
|
|
946
|
+
const args = [
|
|
947
|
+
"ssh",
|
|
948
|
+
"-o",
|
|
949
|
+
"BatchMode=yes",
|
|
950
|
+
"-T",
|
|
951
|
+
"-a",
|
|
952
|
+
"-o",
|
|
953
|
+
"ForwardAgent=no",
|
|
954
|
+
"-o",
|
|
955
|
+
"ControlMaster=no",
|
|
956
|
+
"-o",
|
|
957
|
+
"ControlPath=none",
|
|
958
|
+
"-o",
|
|
959
|
+
"ConnectTimeout=10",
|
|
960
|
+
"-o",
|
|
961
|
+
"ServerAliveInterval=5",
|
|
962
|
+
"-o",
|
|
963
|
+
"ServerAliveCountMax=2"
|
|
964
|
+
];
|
|
965
|
+
if (connection.user !== void 0) args.push("-l", connection.user);
|
|
966
|
+
if (connection.port !== void 0) args.push("-p", String(connection.port));
|
|
967
|
+
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
968
|
+
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
969
|
+
args.push(...extra, "--", connection.host);
|
|
970
|
+
if (command !== void 0) args.push(command);
|
|
971
|
+
return args;
|
|
972
|
+
}
|
|
973
|
+
async function freePorts(count) {
|
|
974
|
+
const servers = [];
|
|
975
|
+
try {
|
|
976
|
+
for (let i = 0; i < count; i++) {
|
|
977
|
+
const server = createServer();
|
|
978
|
+
const { promise, resolve: resolve$1, reject } = Promise.withResolvers();
|
|
979
|
+
server.once("error", reject);
|
|
980
|
+
server.listen(0, "127.0.0.1", resolve$1);
|
|
981
|
+
await promise;
|
|
982
|
+
servers.push(server);
|
|
983
|
+
}
|
|
984
|
+
const ports = servers.map((server) => server.address().port);
|
|
985
|
+
await Promise.all(servers.map((server) => new Promise((resolve$1) => server.close(() => resolve$1()))));
|
|
986
|
+
return ports;
|
|
987
|
+
} finally {
|
|
988
|
+
await Promise.all(servers.map((server) => new Promise((resolve$1) => server.close(() => resolve$1()))));
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
async function originPortOpen(port, timeoutMs) {
|
|
992
|
+
const until = Date.now() + timeoutMs;
|
|
993
|
+
for (;;) {
|
|
994
|
+
const { promise, resolve: resolve$1 } = Promise.withResolvers();
|
|
995
|
+
const socket = createConnection({
|
|
996
|
+
host: "127.0.0.1",
|
|
997
|
+
port
|
|
998
|
+
});
|
|
999
|
+
socket.once("connect", () => {
|
|
1000
|
+
socket.destroy();
|
|
1001
|
+
resolve$1(true);
|
|
1002
|
+
});
|
|
1003
|
+
socket.once("error", () => {
|
|
1004
|
+
socket.destroy();
|
|
1005
|
+
resolve$1(false);
|
|
1006
|
+
});
|
|
1007
|
+
if (await promise) return true;
|
|
1008
|
+
if (Date.now() >= until) return false;
|
|
1009
|
+
await new Promise((resolve$2) => setTimeout(resolve$2, 50));
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Dual-SSH rsync. Forwarding route first; on forwarding denial the stdio
|
|
1014
|
+
* route; if both are unavailable the combined facts are reported — never a
|
|
1015
|
+
* full-copy fallback.
|
|
1016
|
+
*/
|
|
1017
|
+
async function relayRsync(argv, source, destination, signal, options = {}) {
|
|
1018
|
+
if (options.forceStdioRoute !== true) try {
|
|
1019
|
+
return await forwardRoute(argv, source, destination, signal, options);
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
if (signal.aborted) throw signal.reason;
|
|
1022
|
+
if (!forwardingDenied(error)) throw error;
|
|
1023
|
+
try {
|
|
1024
|
+
return await stdioRoute(argv, source, destination, signal, options);
|
|
1025
|
+
} catch (stdioError) {
|
|
1026
|
+
if (signal.aborted) throw signal.reason;
|
|
1027
|
+
throw new Error(`dual-SSH rsync unavailable: forwarding route denied (${error instanceof Error ? error.message : String(error)}); stdio route failed (${stdioError instanceof Error ? stdioError.message : String(stdioError)}); no full-copy fallback is performed`, { cause: stdioError });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return stdioRoute(argv, source, destination, signal, options);
|
|
1031
|
+
}
|
|
1032
|
+
/** Forwarding was actually refused by an endpoint (admin policy), not any other failure. */
|
|
1033
|
+
function forwardingDenied(error) {
|
|
1034
|
+
const text = error instanceof Error ? `${error.message}\n${String(error.cause ?? "")}` : String(error);
|
|
1035
|
+
return /administratively prohibited|forwarding failed|could not request (local|remote) forwarding|AllowTcpForwarding/i.test(text);
|
|
1036
|
+
}
|
|
1037
|
+
const OUTPUT_LIMIT = 512 * 1024;
|
|
1038
|
+
/** Bounded stdout/stderr capture for a child stream. */
|
|
1039
|
+
function capture(stream, sink) {
|
|
1040
|
+
const onText = (text) => {
|
|
1041
|
+
const value = sink.text + text;
|
|
1042
|
+
if (value.length > OUTPUT_LIMIT) sink.truncated = true;
|
|
1043
|
+
sink.text = value.slice(-OUTPUT_LIMIT);
|
|
1044
|
+
};
|
|
1045
|
+
stream?.setEncoding("utf8");
|
|
1046
|
+
stream?.on("data", onText);
|
|
1047
|
+
return onText;
|
|
1048
|
+
}
|
|
1049
|
+
/** A held SSH child (forward/daemon carrier) with group shutdown and stderr. */
|
|
1050
|
+
var Held = class {
|
|
1051
|
+
child;
|
|
1052
|
+
done;
|
|
1053
|
+
stderrSink = {
|
|
1054
|
+
text: "",
|
|
1055
|
+
truncated: false
|
|
1056
|
+
};
|
|
1057
|
+
stopped = false;
|
|
1058
|
+
constructor(argv, signal) {
|
|
1059
|
+
this.signal = signal;
|
|
1060
|
+
signal.throwIfAborted();
|
|
1061
|
+
this.child = spawn(argv[0], argv.slice(1), {
|
|
1062
|
+
stdio: [
|
|
1063
|
+
"ignore",
|
|
1064
|
+
"pipe",
|
|
1065
|
+
"pipe"
|
|
1066
|
+
],
|
|
1067
|
+
detached: true
|
|
1068
|
+
});
|
|
1069
|
+
capture(this.child.stderr, this.stderrSink);
|
|
1070
|
+
this.done = new Promise((resolve$1) => {
|
|
1071
|
+
this.child.once("close", () => resolve$1());
|
|
63
1072
|
});
|
|
1073
|
+
}
|
|
1074
|
+
async stop() {
|
|
1075
|
+
if (this.stopped) {
|
|
1076
|
+
await this.done;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
this.stopped = true;
|
|
1080
|
+
if (this.child.pid !== void 0) try {
|
|
1081
|
+
process.kill(-this.child.pid, "SIGTERM");
|
|
1082
|
+
} catch {}
|
|
1083
|
+
await Promise.race([this.done, new Promise((resolve$1) => setTimeout(resolve$1, 3e3))]);
|
|
1084
|
+
if (this.child.exitCode === null && this.child.signalCode === null && this.child.pid !== void 0) try {
|
|
1085
|
+
process.kill(-this.child.pid, "SIGKILL");
|
|
1086
|
+
} catch {}
|
|
1087
|
+
await this.done;
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
/**
|
|
1091
|
+
* Route A. Three SSH sessions, all origin-held:
|
|
1092
|
+
* 1. destination: -L + daemon script (daemon binds destination loopback only)
|
|
1093
|
+
* 2. source hold: -N -R maps origin daemon port onto source loopback
|
|
1094
|
+
* 3. source run: executes the native rsync client against its own loopback
|
|
1095
|
+
* rsync protocol bytes traverse source → origin → destination; nothing is
|
|
1096
|
+
* staged on the origin.
|
|
1097
|
+
*/
|
|
1098
|
+
async function forwardRoute(argv, source, destination, signal, options) {
|
|
1099
|
+
signal.throwIfAborted();
|
|
1100
|
+
const wrap = options.wrapArgv ?? ((value) => value);
|
|
1101
|
+
const ports = options.ports?.daemon !== void 0 ? [options.ports.daemon, ...await freePorts(2)] : await freePorts(3);
|
|
1102
|
+
const daemonPort = ports[0], originPort = ports[1], sourcePort = ports[2];
|
|
1103
|
+
const destinationArgv = relaySsh(destination.connection, [
|
|
1104
|
+
"-o",
|
|
1105
|
+
"ExitOnForwardFailure=yes",
|
|
1106
|
+
"-L",
|
|
1107
|
+
`127.0.0.1:${originPort}:127.0.0.1:${daemonPort}`
|
|
1108
|
+
], daemonCommand(destination.path, daemonPort));
|
|
1109
|
+
const sourceHoldArgv = relaySsh(source.connection, [
|
|
1110
|
+
"-o",
|
|
1111
|
+
"ExitOnForwardFailure=yes",
|
|
1112
|
+
"-N",
|
|
1113
|
+
"-R",
|
|
1114
|
+
`127.0.0.1:${sourcePort}:127.0.0.1:${originPort}`
|
|
1115
|
+
]);
|
|
1116
|
+
const destinationChild = new Held(wrap(destinationArgv), signal);
|
|
1117
|
+
const sourceHold = new Held(wrap(sourceHoldArgv), signal);
|
|
1118
|
+
const remoteTempOf = (held) => /^SUPERD-RSYNC-TMP (\S+)$/m.exec(held.stderrSink.text)?.[1];
|
|
1119
|
+
const settle = async () => {
|
|
1120
|
+
await Promise.all([destinationChild.stop(), sourceHold.stop()]);
|
|
1121
|
+
const temp = remoteTempOf(destinationChild);
|
|
1122
|
+
if (temp === void 0) return "unknown";
|
|
1123
|
+
return await removeRemoteTemp(destination.connection, temp, wrap) ? "released" : "unknown";
|
|
1124
|
+
};
|
|
1125
|
+
try {
|
|
1126
|
+
signal.throwIfAborted();
|
|
1127
|
+
if (!await originPortOpen(originPort, 15e3)) throw new Error(`dual-SSH rsync destination daemon never became reachable${relayDetail(destinationChild, sourceHold)}`);
|
|
1128
|
+
signal.throwIfAborted();
|
|
1129
|
+
const runArgv = relaySsh(source.connection, [], rsyncClientCommand(argv, source.path, sourcePort));
|
|
1130
|
+
const result = await runNative(wrap(runArgv), signal);
|
|
1131
|
+
if (result.exit === 255) result.stderr = (result.stderr + relayDetail(destinationChild, sourceHold)).slice(-OUTPUT_LIMIT);
|
|
1132
|
+
if (result.exit !== 0 && /remote port forwarding failed|administratively prohibited|could not request (local|remote) forwarding/.test(relayDetail(destinationChild, sourceHold))) throw new Error(`dual-SSH rsync forwarding route refused by an endpoint: ${relayDetail(destinationChild, sourceHold)}`);
|
|
1133
|
+
return {
|
|
1134
|
+
...result,
|
|
1135
|
+
route: "ssh-ssh-forward",
|
|
1136
|
+
cleanup: await settle(),
|
|
1137
|
+
sshArgv: [
|
|
1138
|
+
destinationArgv,
|
|
1139
|
+
sourceHoldArgv,
|
|
1140
|
+
runArgv
|
|
1141
|
+
]
|
|
1142
|
+
};
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
await settle().catch(() => "unknown");
|
|
1145
|
+
if (signal.aborted) throw signal.reason;
|
|
1146
|
+
const detail = relayDetail(destinationChild, sourceHold);
|
|
1147
|
+
if (detail) {
|
|
1148
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1149
|
+
throw new Error(`${message}${message.includes("[relay stderr]") ? "" : detail}`, { cause: error });
|
|
1150
|
+
}
|
|
1151
|
+
throw error;
|
|
1152
|
+
} finally {
|
|
1153
|
+
await Promise.allSettled([destinationChild.stop(), sourceHold.stop()]);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
function relayDetail(...held) {
|
|
1157
|
+
const detail = held.map((child) => child.stderrSink.text).filter(Boolean).join("\n");
|
|
1158
|
+
return detail ? `\n[relay stderr]\n${detail}` : "";
|
|
1159
|
+
}
|
|
1160
|
+
function daemonCommand(targetPath, port) {
|
|
1161
|
+
if (/[\r\n]/.test(targetPath)) throw new Error("dual-SSH rsync refuses newline-bearing destination paths (no daemon config injection)");
|
|
1162
|
+
return `set -eu
|
|
1163
|
+
[ -d ${shellQuote(targetPath)} ] || { echo 'dual-SSH rsync destination directory does not exist' >&2; exit 64; }
|
|
1164
|
+
umask 077
|
|
1165
|
+
tmp=$(mktemp -d "\${TMPDIR:-/tmp}/superd-rsync.XXXXXXXX")
|
|
1166
|
+
# The origin reads this marker from held stderr and stops the daemon + removes
|
|
1167
|
+
# the directory over a dedicated cleanup session: killing the local ssh cannot
|
|
1168
|
+
# rely on remote traps (a dropped connection terminates the remote session
|
|
1169
|
+
# without HUP), and an unreaped daemon keeps its loopback port open.
|
|
1170
|
+
printf 'SUPERD-RSYNC-TMP %s\\n' "$tmp" >&2
|
|
1171
|
+
printf '%s\\n' 'use chroot = no' 'read only = no' 'munge symlinks = yes' 'hosts allow = 127.0.0.1' 'hosts deny = *' 'pid file = '"$tmp"'/rsyncd.pid' '[task]' ${shellQuote("path = " + targetPath)} > "$tmp/rsyncd.conf"
|
|
1172
|
+
exec rsync --daemon --no-detach --address=127.0.0.1 --port=${port} --config="$tmp/rsyncd.conf"
|
|
1173
|
+
`;
|
|
1174
|
+
}
|
|
1175
|
+
/** Best-effort teardown of the remote daemon (pidfile kill) and its temp dir
|
|
1176
|
+
* over its own SSH session; identities stay origin-held. Returns whether the
|
|
1177
|
+
* whole teardown is confirmed. */
|
|
1178
|
+
async function removeRemoteTemp(connection, path$1, wrap) {
|
|
1179
|
+
const controller = new AbortController();
|
|
1180
|
+
const timer = setTimeout(() => controller.abort(), 1e4);
|
|
1181
|
+
const script = `if [ -f ${shellQuote(path$1 + "/rsyncd.pid")} ]; then
|
|
1182
|
+
p=$(cat ${shellQuote(path$1 + "/rsyncd.pid")})
|
|
1183
|
+
kill "$p" 2>/dev/null || :
|
|
1184
|
+
i=0; while kill -0 "$p" 2>/dev/null && [ "$i" -lt 20 ]; do sleep 0.1; i=$((i+1)); done
|
|
1185
|
+
kill -9 "$p" 2>/dev/null || :
|
|
1186
|
+
fi
|
|
1187
|
+
rm -rf ${shellQuote(path$1)}`;
|
|
1188
|
+
try {
|
|
1189
|
+
return (await runNative(wrap(relaySsh(connection, [], script)), controller.signal)).exit === 0;
|
|
1190
|
+
} catch {
|
|
1191
|
+
return false;
|
|
1192
|
+
} finally {
|
|
1193
|
+
clearTimeout(timer);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function rsyncClientCommand(argv, sourcePath, port) {
|
|
1197
|
+
return `exec rsync ${argv.map(shellQuote).join(" ")} -- ${shellQuote(sourcePath)} rsync://127.0.0.1:${port}/task/`;
|
|
1198
|
+
}
|
|
1199
|
+
function shellQuote(value) {
|
|
1200
|
+
return /[^A-Za-z0-9_@%+=:,./-]/.test(value) ? `'${value.replaceAll("'", `'\\''`)}'` : value;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Route B (empirical verdict): a Node bidirectional bridge of two SSH exec
|
|
1204
|
+
* standard streams carrying one rsync protocol conversation. The design was
|
|
1205
|
+
* validated with local processes (source rsync client + fd-dup adapter +
|
|
1206
|
+
* `rsync --server --daemon .` receiver, full transfer with real delta), but
|
|
1207
|
+
* under real OpenSSH sessions the source-side combination (rsync client in an
|
|
1208
|
+
* exec session + fd-dup adapter) makes ssh tear down the session's stdin
|
|
1209
|
+
* channel ("channel 0: read failed ... Broken pipe" then EOF), starving the
|
|
1210
|
+
* protocol's return path before any byte of rsync protocol flows. Shell-level
|
|
1211
|
+
* adjustments (closing fd 0 for the client, dropping -T/keepalives) did not
|
|
1212
|
+
* change this on OpenSSH 10.2 + rsync 3.4.1. Per the spec this route reports
|
|
1213
|
+
* itself unavailable on such combinations instead of degrading into two
|
|
1214
|
+
* ordinary rsyncs or a full copy: the origin would stage the whole source and
|
|
1215
|
+
* the destination's existing data could not feed delta matching.
|
|
1216
|
+
*/
|
|
1217
|
+
async function stdioRoute(argv, source, destination, signal, options) {
|
|
1218
|
+
signal.throwIfAborted();
|
|
1219
|
+
throw new Error("dual-SSH rsync stdio route unavailable on this SSH/rsync combination: the source-side rsync client with a stdio adapter causes the SSH session stdin channel to be torn down before the rsync protocol starts (validated locally without SSH; fails under OpenSSH 10.2 + rsync 3.4.1). No full-copy fallback is performed");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/remote/rsync.ts
|
|
1224
|
+
/**
|
|
1225
|
+
* Native rsync with ordered caller options. Local↔SSH runs rsync directly with
|
|
1226
|
+
* the endpoint's SSH identity wired as RSYNC_RSH; explicit -e/--rsh in argv
|
|
1227
|
+
* overrides that wiring per rsync's own rules (the receipt shows the argv).
|
|
1228
|
+
* SSH↔SSH delegates to the native relay routes (forwarding first, stdio second).
|
|
1229
|
+
* No option is injected: no archive, no timeout, no dry-run, no remote owner.
|
|
1230
|
+
*/
|
|
1231
|
+
async function runRsync(request, source, destination, signal, options = {}) {
|
|
1232
|
+
signal.throwIfAborted();
|
|
1233
|
+
checkAbsolute(source.path);
|
|
1234
|
+
checkAbsolute(destination.path);
|
|
1235
|
+
const argv = checkArgv("argv", request.argv);
|
|
1236
|
+
if ("pullArgv" in request || "pushArgv" in request) throw new Error("remote: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
1237
|
+
for (const legacy of [
|
|
1238
|
+
"dryRun",
|
|
1239
|
+
"delete",
|
|
1240
|
+
"excludes",
|
|
1241
|
+
"preserve",
|
|
1242
|
+
"verify",
|
|
1243
|
+
"mkdir"
|
|
1244
|
+
]) if (legacy in request) throw new Error(`remote: rsync field "${legacy}" was removed — pass native options through argv instead (e.g. "--dry-run", "--delete", "--exclude=<pattern>", "-a"/"-r"; verify/mkdir belong to the copy Hub/local lane)`);
|
|
1245
|
+
if (!source.connection && !destination.connection) throw new Error("rsync requires at least one SSH endpoint; local-only is unsupported");
|
|
1246
|
+
let result;
|
|
1247
|
+
let route;
|
|
1248
|
+
if (source.connection && destination.connection) {
|
|
1249
|
+
const relayed = await relayRsync(argv, {
|
|
1250
|
+
connection: source.connection,
|
|
1251
|
+
path: source.path
|
|
1252
|
+
}, {
|
|
1253
|
+
connection: destination.connection,
|
|
1254
|
+
path: destination.path
|
|
1255
|
+
}, signal, options);
|
|
1256
|
+
result = relayed;
|
|
1257
|
+
route = relayed.route;
|
|
1258
|
+
} else {
|
|
1259
|
+
route = "local-ssh";
|
|
1260
|
+
const remote = source.connection ?? destination.connection;
|
|
1261
|
+
const wrap = options.wrapArgv ?? ((value) => value);
|
|
1262
|
+
const callerRsh = argv.some((token) => token === "-e" || token === "--rsh" || token.startsWith("--rsh=") || /^-[a-zA-Z]+e$/.test(token) && !token.startsWith("--"));
|
|
1263
|
+
const hostSpec = (connection) => {
|
|
1264
|
+
const host = connection.host.includes(":") && !connection.host.startsWith("[") ? `[${connection.host}]` : connection.host;
|
|
1265
|
+
return connection.user !== void 0 ? `${connection.user}@${host}` : host;
|
|
1266
|
+
};
|
|
1267
|
+
const env = callerRsh ? {} : { RSYNC_RSH: rsyncRsh(remote, wrap) };
|
|
1268
|
+
const src = source.connection ? callerRsh ? `${hostSpec(source.connection)}:${source.path}` : `rsync-native:${source.path}` : source.path;
|
|
1269
|
+
const dst = destination.connection ? callerRsh ? `${hostSpec(destination.connection)}:${destination.path}` : `rsync-native:${destination.path}` : destination.path;
|
|
1270
|
+
result = await runNative(wrap([
|
|
1271
|
+
"rsync",
|
|
1272
|
+
...argv,
|
|
1273
|
+
"--",
|
|
1274
|
+
src,
|
|
1275
|
+
dst
|
|
1276
|
+
]), signal, env);
|
|
1277
|
+
}
|
|
1278
|
+
const number = (label) => {
|
|
1279
|
+
const match = new RegExp(`^${label}: ([\\d,]+)(?: bytes)?$`, "m").exec(result.stdout);
|
|
1280
|
+
return match ? Number(match[1].replaceAll(",", "")) : void 0;
|
|
1281
|
+
};
|
|
1282
|
+
const sentBytes = number("Total bytes sent"), receivedBytes = number("Total bytes received");
|
|
1283
|
+
const literalBytes = number("Literal data"), matchedBytes = number("Matched data");
|
|
1284
|
+
const itemized = result.stdout.split("\n").filter((line) => /^[<>ch.*][fdLDS.]/.test(line) || line.startsWith("*deleting"));
|
|
1285
|
+
return {
|
|
1286
|
+
...result,
|
|
1287
|
+
action: "rsync",
|
|
1288
|
+
src: request.src,
|
|
1289
|
+
dst: request.dst,
|
|
1290
|
+
argv,
|
|
1291
|
+
route,
|
|
1292
|
+
command: `rsync ${argv.join(" ")}`,
|
|
1293
|
+
...itemized.length > 0 || argv.includes("--itemize-changes") || argv.includes("-i") ? { itemizedChanges: itemized } : {},
|
|
1294
|
+
...literalBytes === void 0 ? {} : { literalBytes },
|
|
1295
|
+
...matchedBytes === void 0 ? {} : { matchedBytes },
|
|
1296
|
+
...sentBytes === void 0 ? {} : { sentBytes },
|
|
1297
|
+
...receivedBytes === void 0 ? {} : { receivedBytes },
|
|
1298
|
+
...sentBytes !== void 0 && receivedBytes !== void 0 ? { transferredBytes: sentBytes + receivedBytes } : {}
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/** RSYNC_RSH value: rsync word-splits this string with its own quote
|
|
1302
|
+
* convention (single quotes, internal quotes doubled); the adapter drops the
|
|
1303
|
+
* placeholder host rsync appends and execs the canonical SSH argv. All tokens
|
|
1304
|
+
* are tool-built argv (identity options/aliases), never model text. */
|
|
1305
|
+
function rsyncRsh(connection, wrap) {
|
|
1306
|
+
const args = [
|
|
1307
|
+
"ssh",
|
|
1308
|
+
"-o",
|
|
1309
|
+
"BatchMode=yes",
|
|
1310
|
+
"-T"
|
|
1311
|
+
];
|
|
1312
|
+
if (connection.user !== void 0) args.push("-l", connection.user);
|
|
1313
|
+
if (connection.port !== void 0) args.push("-p", String(connection.port));
|
|
1314
|
+
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
1315
|
+
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
1316
|
+
args.push("--", connection.host);
|
|
1317
|
+
return [
|
|
1318
|
+
"bash",
|
|
1319
|
+
"-c",
|
|
1320
|
+
`shift; exec ${wrap(args).map(rshQuote).join(" ")} "$@"`,
|
|
1321
|
+
"rsync-rsh"
|
|
1322
|
+
].map(rshQuote).join(" ");
|
|
1323
|
+
}
|
|
1324
|
+
/** rsync -e/RSYNC_RSH grammar: doubled quotes, not shell backslashes (rsync(1) --rsh). */
|
|
1325
|
+
const rshQuote = (value) => "'" + value.replaceAll("'", "''") + "'";
|
|
1326
|
+
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/remote/endpoints.ts
|
|
1329
|
+
function parseFileEndpoint(value) {
|
|
1330
|
+
if (typeof value !== "string" || !value) throw new Error("file endpoint required");
|
|
1331
|
+
if (value.startsWith("/")) {
|
|
1332
|
+
checkAbsolute(value);
|
|
1333
|
+
return { path: value };
|
|
1334
|
+
}
|
|
1335
|
+
const split = value.lastIndexOf(":/");
|
|
1336
|
+
if (split < 1) throw new Error("file endpoint must be [target:]absolute/path");
|
|
1337
|
+
const target = value.slice(0, split), path$1 = value.slice(split + 1);
|
|
1338
|
+
checkAbsolute(path$1);
|
|
1339
|
+
if (target.startsWith("byop:")) throw new Error("BYO-PTY has no binary file lane; select SSH/Docker/Incus/Hub");
|
|
1340
|
+
return {
|
|
1341
|
+
target,
|
|
1342
|
+
path: path$1
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
//#endregion
|
|
1347
|
+
//#region src/remote/ssh-targets.ts
|
|
1348
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
1349
|
+
const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
1350
|
+
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:]+)$/;
|
|
1351
|
+
const JUMP = /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9_][A-Za-z0-9_.-]*|\[[A-Fa-f0-9:.]+\])(?::([0-9]+))?$/;
|
|
1352
|
+
function validateSshLabel(label) {
|
|
1353
|
+
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");
|
|
1354
|
+
}
|
|
1355
|
+
function bad(field) {
|
|
1356
|
+
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`);
|
|
1357
|
+
}
|
|
1358
|
+
function normalizeSshConnection(input) {
|
|
1359
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) bad("connection");
|
|
1360
|
+
if (Object.keys(input).some((key$1) => ![
|
|
1361
|
+
"host",
|
|
1362
|
+
"user",
|
|
1363
|
+
"port",
|
|
1364
|
+
"key",
|
|
1365
|
+
"proxyJump"
|
|
1366
|
+
].includes(key$1))) bad("field");
|
|
1367
|
+
if (typeof input.host !== "string" || !HOST.test(input.host)) bad("host");
|
|
1368
|
+
if (input.user !== void 0 && (typeof input.user !== "string" || !USER.test(input.user))) bad("user");
|
|
1369
|
+
if (input.port !== void 0 && (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535)) bad("port");
|
|
1370
|
+
if (input.proxyJump !== void 0) {
|
|
1371
|
+
if (typeof input.proxyJump !== "string" || !input.proxyJump || input.proxyJump.split(",").some((hop) => {
|
|
1372
|
+
const match = JUMP.exec(hop);
|
|
1373
|
+
return !match || match[1] !== void 0 && (Number(match[1]) < 1 || Number(match[1]) > 65535);
|
|
1374
|
+
})) bad("proxyJump");
|
|
1375
|
+
}
|
|
1376
|
+
let key;
|
|
1377
|
+
if (input.key !== void 0) {
|
|
1378
|
+
if (typeof input.key !== "string" || !input.key || /[\0\r\n]/.test(input.key)) bad("key");
|
|
1379
|
+
key = input.key.startsWith("~/") ? join(homedir(), input.key.slice(2)) : resolve(input.key);
|
|
1380
|
+
try {
|
|
1381
|
+
if (!statSync(key).isFile()) bad("key");
|
|
1382
|
+
} catch {
|
|
1383
|
+
bad("key file (must exist on the origin)");
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return Object.freeze({
|
|
1387
|
+
host: input.host,
|
|
1388
|
+
...input.user === void 0 ? {} : { user: input.user },
|
|
1389
|
+
...input.port === void 0 ? {} : { port: input.port },
|
|
1390
|
+
...key === void 0 ? {} : { key },
|
|
1391
|
+
...input.proxyJump === void 0 ? {} : { proxyJump: input.proxyJump }
|
|
64
1392
|
});
|
|
65
|
-
|
|
1393
|
+
}
|
|
1394
|
+
/** One instance per Agent-owned driver; destroyed with its Agent/plugin owners. */
|
|
1395
|
+
var SshTargets = class {
|
|
1396
|
+
targets = /* @__PURE__ */ new Map();
|
|
1397
|
+
register(label, connection) {
|
|
1398
|
+
validateSshLabel(label);
|
|
1399
|
+
const normalized = normalizeSshConnection(connection), existing = this.targets.get(label);
|
|
1400
|
+
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`);
|
|
1401
|
+
if (!existing) this.targets.set(label, normalized);
|
|
1402
|
+
return existing ?? normalized;
|
|
1403
|
+
}
|
|
1404
|
+
get(label) {
|
|
1405
|
+
validateSshLabel(label);
|
|
1406
|
+
return this.targets.get(label);
|
|
1407
|
+
}
|
|
1408
|
+
entries() {
|
|
1409
|
+
return [...this.targets.entries()];
|
|
1410
|
+
}
|
|
1411
|
+
clear() {
|
|
1412
|
+
this.targets.clear();
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
/** Shared by execution, probes and file/relay consumers. extraArgs is trusted
|
|
1416
|
+
* internal SSH argv (e.g. forwarding), never a model-provided shell string. */
|
|
1417
|
+
function sshArgv(connection, options = {}) {
|
|
1418
|
+
const args = options.tty ? [
|
|
1419
|
+
"ssh",
|
|
1420
|
+
"-tt",
|
|
1421
|
+
"-o",
|
|
1422
|
+
"BatchMode=yes"
|
|
1423
|
+
] : [
|
|
1424
|
+
"ssh",
|
|
1425
|
+
"-o",
|
|
1426
|
+
"BatchMode=yes",
|
|
1427
|
+
"-T"
|
|
1428
|
+
];
|
|
1429
|
+
if (connection.user !== void 0) args.push("-l", connection.user);
|
|
1430
|
+
if (connection.port !== void 0) args.push("-p", String(connection.port));
|
|
1431
|
+
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
1432
|
+
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
1433
|
+
args.push(...options.extraArgs ?? [], "--", connection.host);
|
|
1434
|
+
if (options.command !== void 0) args.push(options.command);
|
|
1435
|
+
return args;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
//#endregion
|
|
1439
|
+
//#region src/remote/input.ts
|
|
1440
|
+
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
1441
|
+
async function prepareRemoteInput(input) {
|
|
1442
|
+
if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
|
|
1443
|
+
if (input.script !== void 0) {
|
|
1444
|
+
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");
|
|
1445
|
+
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");
|
|
1446
|
+
}
|
|
1447
|
+
if (!input.target) throw new Error("[E_PARAMS] remote: target is required; spawn requires a byop:<label> target");
|
|
1448
|
+
if (input.script !== void 0) {
|
|
1449
|
+
let stdin;
|
|
1450
|
+
try {
|
|
1451
|
+
stdin = await readFile(input.script, "utf8");
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.script)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
target: input.target,
|
|
1457
|
+
ssh: input.ssh,
|
|
1458
|
+
cmd: "bash -s",
|
|
1459
|
+
stdin,
|
|
1460
|
+
mode: "oneshot",
|
|
1461
|
+
timeout: input.timeout
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
66
1464
|
return {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1465
|
+
target: input.target,
|
|
1466
|
+
spawn: input.spawn,
|
|
1467
|
+
ssh: input.ssh,
|
|
1468
|
+
cmd: input.cmd ?? "",
|
|
1469
|
+
stdin: input.stdin,
|
|
1470
|
+
mode: input.mode,
|
|
1471
|
+
timeout: input.timeout
|
|
72
1472
|
};
|
|
73
1473
|
}
|
|
74
1474
|
|
|
1475
|
+
//#endregion
|
|
1476
|
+
//#region src/remote/oneshot.ts
|
|
1477
|
+
async function runOneShot(argv, opts = {}) {
|
|
1478
|
+
const process$1 = startOneShot(argv, opts);
|
|
1479
|
+
const result = await process$1.done;
|
|
1480
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
1481
|
+
return {
|
|
1482
|
+
...result,
|
|
1483
|
+
stdout: stdout.text,
|
|
1484
|
+
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
1485
|
+
outputStats: {
|
|
1486
|
+
stdout: stdout.stats,
|
|
1487
|
+
stderr: stderr.stats
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
//#endregion
|
|
1493
|
+
//#region src/remote/transports.ts
|
|
1494
|
+
/** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
|
|
1495
|
+
function shQuote(s) {
|
|
1496
|
+
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
1497
|
+
}
|
|
1498
|
+
/** One-shot(§五.1):不分配 tty,收尾 = 子进程退出码,零字符串扫描。
|
|
1499
|
+
* POSIX wrapper(无远端 Python/owner/lease):
|
|
1500
|
+
* - wrapper 跑在 setsid 的私有会话里:sshd 会话进程与命令共享进程组,
|
|
1501
|
+
* 不隔离的话 kill 0 会把连接一起带走。
|
|
1502
|
+
* - EXIT trap(自免疫后杀组):命令正常结束时回收后台化子孙——它们不
|
|
1503
|
+
* 越过调用存活,也不会攥住 stdout 拖住收尾;退出码原样透传。
|
|
1504
|
+
* - cleanupTag:远端把 wrapper 的 pgid 写进 pidfile;本地取消/超时后经
|
|
1505
|
+
* 独立清理会话按组 kill(与 rsync daemon 的远端清理同构——实证 sshd
|
|
1506
|
+
* 对无 PTY 会话断连不发信号,本地 killpg 杀不到远端)。 */
|
|
1507
|
+
function buildOneShotArgv(plan, cmd, options = {}) {
|
|
1508
|
+
const tag = options.cleanupTag;
|
|
1509
|
+
const wrapped = `${tag === void 0 ? "" : `printf '%s' "$(ps -o pgid= -p $$ | tr -d ' ')" > "\${TMPDIR:-/tmp}/superd-oneshot-${tag}.pgid"\n`}trap 'trap "" TERM HUP INT; kill -TERM 0 2>/dev/null; exit 143' TERM HUP INT
|
|
1510
|
+
trap 'trap "" TERM HUP INT; kill -TERM 0 2>/dev/null${tag === void 0 ? "" : `; rm -f "\${TMPDIR:-/tmp}/superd-oneshot-${tag}.pgid"`}' EXIT
|
|
1511
|
+
bash -lc ${shQuote(cmd)}`;
|
|
1512
|
+
const sessioned = [
|
|
1513
|
+
"setsid",
|
|
1514
|
+
"--wait",
|
|
1515
|
+
"bash",
|
|
1516
|
+
"-c",
|
|
1517
|
+
wrapped
|
|
1518
|
+
];
|
|
1519
|
+
switch (plan.kind) {
|
|
1520
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `setsid --wait bash -c ${shQuote(wrapped)}` });
|
|
1521
|
+
case "docker": return [
|
|
1522
|
+
"docker",
|
|
1523
|
+
"exec",
|
|
1524
|
+
"-i",
|
|
1525
|
+
"--",
|
|
1526
|
+
plan.container,
|
|
1527
|
+
...sessioned
|
|
1528
|
+
];
|
|
1529
|
+
case "incus": return [
|
|
1530
|
+
"incus",
|
|
1531
|
+
"exec",
|
|
1532
|
+
"--force-noninteractive",
|
|
1533
|
+
plan.container,
|
|
1534
|
+
"--",
|
|
1535
|
+
...sessioned
|
|
1536
|
+
];
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
/** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。 */
|
|
1540
|
+
function buildPtyArgv(plan) {
|
|
1541
|
+
switch (plan.kind) {
|
|
1542
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
1543
|
+
tty: true,
|
|
1544
|
+
command: "bash"
|
|
1545
|
+
});
|
|
1546
|
+
case "docker": return [
|
|
1547
|
+
"script",
|
|
1548
|
+
"-qfec",
|
|
1549
|
+
`stty -echo 2>/dev/null ; exec docker exec -it -- ${shQuote(plan.container)} bash`,
|
|
1550
|
+
"/dev/null"
|
|
1551
|
+
];
|
|
1552
|
+
case "incus": return [
|
|
1553
|
+
"incus",
|
|
1554
|
+
"exec",
|
|
1555
|
+
"--force-interactive",
|
|
1556
|
+
plan.container,
|
|
1557
|
+
"--",
|
|
1558
|
+
"bash"
|
|
1559
|
+
];
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/** BYO-PTY(§三 spawn):工具只提供 PTY 托管 + Nonce 注入 + 收帧。 */
|
|
1563
|
+
function buildSpawnArgv(spawnCommand) {
|
|
1564
|
+
return [
|
|
1565
|
+
"script",
|
|
1566
|
+
"-qfec",
|
|
1567
|
+
`stty -echo 2>/dev/null ; exec ${spawnCommand}`,
|
|
1568
|
+
"/dev/null"
|
|
1569
|
+
];
|
|
1570
|
+
}
|
|
1571
|
+
|
|
75
1572
|
//#endregion
|
|
76
1573
|
//#region src/remote/nonce-framing.ts
|
|
77
1574
|
/** 8 字节随机串的 hex 形(spec §五.2:每次下发前生成)。 */
|
|
@@ -93,11 +1590,23 @@ function stripAnsi(s) {
|
|
|
93
1590
|
function normalizePtyText(s) {
|
|
94
1591
|
return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
95
1592
|
}
|
|
96
|
-
function tailWindow(text, cap) {
|
|
97
|
-
|
|
1593
|
+
function tailWindow(text, cap, stats) {
|
|
1594
|
+
let tail = text.slice(-cap);
|
|
1595
|
+
if (tail.charCodeAt(0) >= 56320 && tail.charCodeAt(0) <= 57343) tail = tail.slice(1);
|
|
1596
|
+
const returned = stats?.unit === "bytes" ? Buffer.byteLength(tail) : tail.length;
|
|
1597
|
+
const total = stats?.total ?? text.length;
|
|
1598
|
+
const updated = stats === void 0 ? {} : { stats: {
|
|
1599
|
+
...stats,
|
|
1600
|
+
returned
|
|
1601
|
+
} };
|
|
1602
|
+
if (total <= returned) return {
|
|
1603
|
+
text: tail,
|
|
1604
|
+
...updated
|
|
1605
|
+
};
|
|
98
1606
|
return {
|
|
99
|
-
text: `[truncated: showing last ${
|
|
100
|
-
truncated:
|
|
1607
|
+
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}`,
|
|
1608
|
+
truncated: total,
|
|
1609
|
+
...updated
|
|
101
1610
|
};
|
|
102
1611
|
}
|
|
103
1612
|
const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
@@ -182,6 +1691,9 @@ var PtySession = class {
|
|
|
182
1691
|
lastSettleAt = 0;
|
|
183
1692
|
/** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
|
|
184
1693
|
everReady = false;
|
|
1694
|
+
disposal;
|
|
1695
|
+
procClose = Promise.resolve();
|
|
1696
|
+
groupCleanup = Promise.resolve();
|
|
185
1697
|
constructor(opts) {
|
|
186
1698
|
this.opts = opts;
|
|
187
1699
|
}
|
|
@@ -207,16 +1719,17 @@ var PtySession = class {
|
|
|
207
1719
|
const started = Date.now();
|
|
208
1720
|
const reconnected = this.state === "dead";
|
|
209
1721
|
if (this.state !== "ready") await this.start();
|
|
1722
|
+
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' was disposed before dispatch`);
|
|
210
1723
|
this.inFlight++;
|
|
211
1724
|
return await new Promise((resolve$1) => {
|
|
212
1725
|
const nonce = genNonce();
|
|
213
|
-
|
|
1726
|
+
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
214
1727
|
let interrupted = false;
|
|
215
1728
|
let settled = false;
|
|
216
1729
|
let interruptTimer;
|
|
217
1730
|
let killTimer;
|
|
218
1731
|
const deathWaiter = () => finish({
|
|
219
|
-
output,
|
|
1732
|
+
output: ring.text,
|
|
220
1733
|
exit: null,
|
|
221
1734
|
cwd: null,
|
|
222
1735
|
timedOut: interrupted
|
|
@@ -236,20 +1749,17 @@ var PtySession = class {
|
|
|
236
1749
|
this.resetIdleTimer();
|
|
237
1750
|
resolve$1({
|
|
238
1751
|
...r,
|
|
1752
|
+
outputStats: { stdout: ring.stats() },
|
|
239
1753
|
reconnected,
|
|
240
1754
|
durationMs: Date.now() - started
|
|
241
1755
|
});
|
|
242
1756
|
};
|
|
243
1757
|
const parser = createNonceFrameParser(nonce, {
|
|
244
1758
|
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
|
-
}
|
|
1759
|
+
ring.push(t);
|
|
250
1760
|
},
|
|
251
1761
|
onFrame: ({ exit, cwd }) => finish({
|
|
252
|
-
output,
|
|
1762
|
+
output: ring.text,
|
|
253
1763
|
exit,
|
|
254
1764
|
cwd,
|
|
255
1765
|
timedOut: interrupted
|
|
@@ -266,7 +1776,7 @@ var PtySession = class {
|
|
|
266
1776
|
this.killTree();
|
|
267
1777
|
this.markDead();
|
|
268
1778
|
finish({
|
|
269
|
-
output,
|
|
1779
|
+
output: ring.text,
|
|
270
1780
|
exit: null,
|
|
271
1781
|
cwd: null,
|
|
272
1782
|
timedOut: true
|
|
@@ -281,14 +1791,24 @@ var PtySession = class {
|
|
|
281
1791
|
return run;
|
|
282
1792
|
}
|
|
283
1793
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
284
|
-
|
|
285
|
-
if (this.
|
|
1794
|
+
dispose() {
|
|
1795
|
+
if (this.disposal) return this.disposal;
|
|
286
1796
|
this.clearIdleTimer();
|
|
287
1797
|
this.state = "disposed";
|
|
288
1798
|
this.startPromise = null;
|
|
1799
|
+
const proc = this.proc;
|
|
1800
|
+
const close = this.procClose;
|
|
1801
|
+
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
1802
|
+
this.disposal = Promise.all([
|
|
1803
|
+
close,
|
|
1804
|
+
this.groupCleanup,
|
|
1805
|
+
group
|
|
1806
|
+
]).then(() => {
|
|
1807
|
+
if (this.proc === proc) this.proc = null;
|
|
1808
|
+
});
|
|
289
1809
|
this.write("exit\n");
|
|
290
|
-
setTimeout(() => this.killTree(), 1e3).unref();
|
|
291
1810
|
this.opts.onDead?.();
|
|
1811
|
+
return this.disposal;
|
|
292
1812
|
}
|
|
293
1813
|
write(s) {
|
|
294
1814
|
if (this.proc?.stdin?.writable !== true) return;
|
|
@@ -326,7 +1846,12 @@ var PtySession = class {
|
|
|
326
1846
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
327
1847
|
this.diag = (this.diag + d).slice(-2e3);
|
|
328
1848
|
});
|
|
329
|
-
|
|
1849
|
+
this.procClose = new Promise((resolve$1) => {
|
|
1850
|
+
proc.once("close", () => {
|
|
1851
|
+
this.markDead();
|
|
1852
|
+
resolve$1();
|
|
1853
|
+
});
|
|
1854
|
+
});
|
|
330
1855
|
proc.once("error", (err) => {
|
|
331
1856
|
this.diag = (this.diag + `\n[remote: spawn failed: ${err.message}]`).slice(-2e3);
|
|
332
1857
|
});
|
|
@@ -374,6 +1899,7 @@ var PtySession = class {
|
|
|
374
1899
|
}
|
|
375
1900
|
resetIdleTimer() {
|
|
376
1901
|
this.clearIdleTimer();
|
|
1902
|
+
if (this.state === "disposed") return;
|
|
377
1903
|
this.idleTimer = setTimeout(() => void this.dispose(), this.opts.idleTtlSec * 1e3);
|
|
378
1904
|
this.idleTimer.unref();
|
|
379
1905
|
}
|
|
@@ -386,20 +1912,69 @@ var PtySession = class {
|
|
|
386
1912
|
killTree() {
|
|
387
1913
|
const pid = this.proc?.pid;
|
|
388
1914
|
this.proc = null;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
1915
|
+
const cleanup = this.reapGroup(pid, 0, this.procClose);
|
|
1916
|
+
this.groupCleanup = Promise.all([this.groupCleanup, cleanup]).then(() => void 0);
|
|
1917
|
+
}
|
|
1918
|
+
/** A direct child's close is not proof that its detached process group is gone. */
|
|
1919
|
+
reapGroup(pid, graceMs, close) {
|
|
1920
|
+
if (pid === void 0) return Promise.resolve();
|
|
1921
|
+
const signal = (value) => {
|
|
394
1922
|
try {
|
|
395
|
-
process.kill(-pid,
|
|
396
|
-
|
|
397
|
-
|
|
1923
|
+
process.kill(-pid, value);
|
|
1924
|
+
return true;
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
if (error.code === "ESRCH") return false;
|
|
1927
|
+
throw error;
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
return new Promise((resolve$1, reject) => {
|
|
1931
|
+
let termTimer;
|
|
1932
|
+
let killTimer;
|
|
1933
|
+
let settled = false;
|
|
1934
|
+
const finish = (error) => {
|
|
1935
|
+
if (settled) return;
|
|
1936
|
+
settled = true;
|
|
1937
|
+
if (termTimer !== void 0) clearTimeout(termTimer);
|
|
1938
|
+
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
1939
|
+
if (error !== void 0) reject(error);
|
|
1940
|
+
else resolve$1();
|
|
1941
|
+
};
|
|
1942
|
+
const checkGone = () => {
|
|
1943
|
+
try {
|
|
1944
|
+
if (!signal(0)) finish();
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
finish(error);
|
|
1947
|
+
}
|
|
1948
|
+
};
|
|
1949
|
+
checkGone();
|
|
1950
|
+
if (settled) return;
|
|
1951
|
+
close.then(checkGone);
|
|
1952
|
+
termTimer = setTimeout(() => {
|
|
1953
|
+
try {
|
|
1954
|
+
if (!signal("SIGTERM")) {
|
|
1955
|
+
finish();
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
killTimer = setTimeout(() => {
|
|
1959
|
+
try {
|
|
1960
|
+
signal("SIGKILL");
|
|
1961
|
+
killTimer = setTimeout(() => finish(), 100);
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
finish(error);
|
|
1964
|
+
}
|
|
1965
|
+
}, 2e3);
|
|
1966
|
+
} catch (error) {
|
|
1967
|
+
finish(error);
|
|
1968
|
+
}
|
|
1969
|
+
}, graceMs);
|
|
1970
|
+
});
|
|
398
1971
|
}
|
|
399
1972
|
};
|
|
400
1973
|
/** 会话池:键 → 会话。意外死亡的会话留在池内(下次 dispatch 带 reconnect 提示)。 */
|
|
401
1974
|
var PtyPool = class {
|
|
402
1975
|
sessions = /* @__PURE__ */ new Map();
|
|
1976
|
+
disposed = false;
|
|
1977
|
+
disposal;
|
|
403
1978
|
constructor(defaults) {
|
|
404
1979
|
this.defaults = defaults;
|
|
405
1980
|
}
|
|
@@ -424,6 +1999,7 @@ var PtyPool = class {
|
|
|
424
1999
|
return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
|
|
425
2000
|
}
|
|
426
2001
|
getOrCreate(key, argv, origin) {
|
|
2002
|
+
if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: the owning Agent or plugin PTY pool was disposed");
|
|
427
2003
|
let s = this.sessions.get(key);
|
|
428
2004
|
if (s === void 0 || s.sessionState === "disposed") {
|
|
429
2005
|
s = new PtySession({
|
|
@@ -437,9 +2013,12 @@ var PtyPool = class {
|
|
|
437
2013
|
}
|
|
438
2014
|
return s;
|
|
439
2015
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
this.
|
|
2016
|
+
disposeAll() {
|
|
2017
|
+
if (this.disposal) return this.disposal;
|
|
2018
|
+
this.disposed = true;
|
|
2019
|
+
return this.disposal = Promise.all([...this.sessions.values()].map((s) => s.dispose())).then(() => {
|
|
2020
|
+
this.sessions.clear();
|
|
2021
|
+
});
|
|
443
2022
|
}
|
|
444
2023
|
};
|
|
445
2024
|
|
|
@@ -489,17 +2068,10 @@ async function probeTarget(plan, opts = {}) {
|
|
|
489
2068
|
const hard = opts.hardTimeoutSec ?? 10;
|
|
490
2069
|
const runner = opts.runner ?? runOneShot;
|
|
491
2070
|
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" ? [
|
|
2071
|
+
const r = await runner(plan.kind === "ssh" ? sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2072
|
+
extraArgs: ["-o", `ConnectTimeout=${ct}`],
|
|
2073
|
+
command: "true"
|
|
2074
|
+
}) : plan.kind === "docker" ? [
|
|
503
2075
|
"docker",
|
|
504
2076
|
"inspect",
|
|
505
2077
|
"--format",
|
|
@@ -677,6 +2249,7 @@ async function renderRoster(sessions, opts = {}) {
|
|
|
677
2249
|
} catch (error) {
|
|
678
2250
|
lines.push(`ssh hosts: no readable ~/.ssh/config (${error instanceof Error ? error.message : String(error)})`);
|
|
679
2251
|
}
|
|
2252
|
+
lines.push(`byossh labels (this Agent, in memory): ${opts.byosshLabels?.map((label) => `byossh:${label}`).join(", ") || "(none)"}`);
|
|
680
2253
|
const docker = await runner([
|
|
681
2254
|
"docker",
|
|
682
2255
|
"ps",
|
|
@@ -710,91 +2283,21 @@ async function renderRoster(sessions, opts = {}) {
|
|
|
710
2283
|
return lines.join("\n");
|
|
711
2284
|
}
|
|
712
2285
|
|
|
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
2286
|
//#endregion
|
|
792
2287
|
//#region src/remote/driver.ts
|
|
793
2288
|
/** PTY label 字符集:只禁空白 / `:` / 控制字符(roster 一行一 label,带换行的 label 渲染即烂)。 */
|
|
794
2289
|
const PTY_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
795
2290
|
/** 双轨调度器(spec §七 Phase 2):参数校验 → 路由 → oneshot 子进程 / pty 会话池。 */
|
|
796
2291
|
var RemoteDriver = class {
|
|
2292
|
+
sshTargets = new SshTargets();
|
|
797
2293
|
pool;
|
|
2294
|
+
hubOwner = randomUUID();
|
|
2295
|
+
hubProviders = /* @__PURE__ */ new Set();
|
|
2296
|
+
processes = /* @__PURE__ */ new Set();
|
|
2297
|
+
starting = /* @__PURE__ */ new Set();
|
|
2298
|
+
fileAbort = new AbortController();
|
|
2299
|
+
disposed = false;
|
|
2300
|
+
disposal;
|
|
798
2301
|
constructor(opts = {}) {
|
|
799
2302
|
this.opts = opts;
|
|
800
2303
|
this.pool = new PtyPool({ idleTtlSec: opts.idleTtlSec ?? 600 });
|
|
@@ -802,11 +2305,14 @@ var RemoteDriver = class {
|
|
|
802
2305
|
async call(params, callCtx = {}) {
|
|
803
2306
|
const started = Date.now();
|
|
804
2307
|
try {
|
|
805
|
-
|
|
806
|
-
const
|
|
2308
|
+
this.assertOpen();
|
|
2309
|
+
const prepared = await prepareRemoteInput(params);
|
|
2310
|
+
this.assertOpen();
|
|
2311
|
+
const routing = this.route(prepared);
|
|
2312
|
+
const result = await this.execute(prepared, routing, callCtx);
|
|
807
2313
|
this.audit({
|
|
808
2314
|
target: routing.display,
|
|
809
|
-
cmd:
|
|
2315
|
+
cmd: prepared.cmd,
|
|
810
2316
|
cwd: result.cwd ?? void 0,
|
|
811
2317
|
exit: result.exit,
|
|
812
2318
|
durationMs: Date.now() - started
|
|
@@ -815,13 +2321,196 @@ var RemoteDriver = class {
|
|
|
815
2321
|
} catch (error) {
|
|
816
2322
|
this.audit({
|
|
817
2323
|
target: params.target ?? params.spawn ?? "?",
|
|
818
|
-
cmd: params.cmd,
|
|
2324
|
+
cmd: params.cmd ?? "[local script]",
|
|
819
2325
|
error: error instanceof Error ? error.message : String(error),
|
|
820
2326
|
durationMs: Date.now() - started
|
|
821
2327
|
});
|
|
822
2328
|
throw error;
|
|
823
2329
|
}
|
|
824
2330
|
}
|
|
2331
|
+
/** Native-first copy routing (spec: remote-native-shortcut): CLI peers run
|
|
2332
|
+
* their native CLI verbatim; only Hub↔local↔local stays on the staged tree
|
|
2333
|
+
* lane with verify/mkdir. Mixed pairs (Hub↔CLI peer) are refused: Hub has no
|
|
2334
|
+
* native CLI and the tree lane has no CLI options. */
|
|
2335
|
+
copy(request, signal, callCtx = {}) {
|
|
2336
|
+
const combined = AbortSignal.any([signal, this.fileAbort.signal]);
|
|
2337
|
+
const work = (async () => {
|
|
2338
|
+
this.assertOpen();
|
|
2339
|
+
combined.throwIfAborted();
|
|
2340
|
+
const resolve$1 = async (value) => {
|
|
2341
|
+
const parsed = parseFileEndpoint(value);
|
|
2342
|
+
if (!parsed.target) return {
|
|
2343
|
+
kind: "local",
|
|
2344
|
+
path: parsed.path
|
|
2345
|
+
};
|
|
2346
|
+
if (parsed.target.startsWith("hub:")) return {
|
|
2347
|
+
kind: "hub",
|
|
2348
|
+
path: parsed.path,
|
|
2349
|
+
machine: parsed.target.slice(4)
|
|
2350
|
+
};
|
|
2351
|
+
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
2352
|
+
this.assertOpen();
|
|
2353
|
+
combined.throwIfAborted();
|
|
2354
|
+
return {
|
|
2355
|
+
kind: "cli",
|
|
2356
|
+
path: parsed.path,
|
|
2357
|
+
plan
|
|
2358
|
+
};
|
|
2359
|
+
};
|
|
2360
|
+
const src = await resolve$1(request.src), dst = await resolve$1(request.dst);
|
|
2361
|
+
this.assertOpen();
|
|
2362
|
+
combined.throwIfAborted();
|
|
2363
|
+
if (src.kind === "hub" || dst.kind === "hub") {
|
|
2364
|
+
if (src.kind === "hub" && dst.kind === "local") {
|
|
2365
|
+
const hub = this.requireHub();
|
|
2366
|
+
this.hubProviders.add(hub);
|
|
2367
|
+
return copyTree(request, {
|
|
2368
|
+
path: src.path,
|
|
2369
|
+
endpoint: hub.fileEndpoint(src.machine)
|
|
2370
|
+
}, {
|
|
2371
|
+
path: dst.path,
|
|
2372
|
+
endpoint: localFileEndpoint()
|
|
2373
|
+
}, combined);
|
|
2374
|
+
}
|
|
2375
|
+
if (src.kind === "local" && dst.kind === "hub") {
|
|
2376
|
+
const hub = this.requireHub();
|
|
2377
|
+
this.hubProviders.add(hub);
|
|
2378
|
+
return copyTree(request, {
|
|
2379
|
+
path: src.path,
|
|
2380
|
+
endpoint: localFileEndpoint()
|
|
2381
|
+
}, {
|
|
2382
|
+
path: dst.path,
|
|
2383
|
+
endpoint: hub.fileEndpoint(dst.machine)
|
|
2384
|
+
}, combined);
|
|
2385
|
+
}
|
|
2386
|
+
if (src.kind === "hub" && dst.kind === "hub") {
|
|
2387
|
+
const hub = this.requireHub();
|
|
2388
|
+
this.hubProviders.add(hub);
|
|
2389
|
+
return copyTree(request, {
|
|
2390
|
+
path: src.path,
|
|
2391
|
+
endpoint: hub.fileEndpoint(src.machine)
|
|
2392
|
+
}, {
|
|
2393
|
+
path: dst.path,
|
|
2394
|
+
endpoint: hub.fileEndpoint(dst.machine)
|
|
2395
|
+
}, combined);
|
|
2396
|
+
}
|
|
2397
|
+
throw new Error("copy: Hub endpoints pair only with local paths or another Hub machine (native CLI peers have no Hub lane and the Hub has no CLI options)");
|
|
2398
|
+
}
|
|
2399
|
+
if (src.kind === "local" && dst.kind === "local") return copyTree(request, {
|
|
2400
|
+
path: src.path,
|
|
2401
|
+
endpoint: localFileEndpoint()
|
|
2402
|
+
}, {
|
|
2403
|
+
path: dst.path,
|
|
2404
|
+
endpoint: localFileEndpoint()
|
|
2405
|
+
}, combined);
|
|
2406
|
+
if (request.verify !== void 0 || request.mkdir !== void 0) throw new Error("copy: verify/mkdir apply to the Hub/local staged lane only; native CLI routes do not verify or create parents (mirror the CLI's own behavior with argv like -r)");
|
|
2407
|
+
const planOf = (end) => end.kind === "cli" ? end.plan : end.kind === "local" ? { kind: "local" } : void 0;
|
|
2408
|
+
const srcPlan = planOf(src), dstPlan = planOf(dst);
|
|
2409
|
+
if (srcPlan === void 0 || dstPlan === void 0) throw new Error("copy: unreachable route resolution");
|
|
2410
|
+
return nativeCopy({
|
|
2411
|
+
src: request.src,
|
|
2412
|
+
dst: request.dst,
|
|
2413
|
+
...request.argv === void 0 ? {} : { argv: request.argv },
|
|
2414
|
+
...request.pullArgv === void 0 ? {} : { pullArgv: request.pullArgv },
|
|
2415
|
+
...request.pushArgv === void 0 ? {} : { pushArgv: request.pushArgv }
|
|
2416
|
+
}, {
|
|
2417
|
+
src: {
|
|
2418
|
+
plan: srcPlan,
|
|
2419
|
+
path: src.path
|
|
2420
|
+
},
|
|
2421
|
+
dst: {
|
|
2422
|
+
plan: dstPlan,
|
|
2423
|
+
path: dst.path
|
|
2424
|
+
}
|
|
2425
|
+
}, combined, { wrapArgv: this.opts.nativeArgvFor });
|
|
2426
|
+
})();
|
|
2427
|
+
this.starting.add(work);
|
|
2428
|
+
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
2429
|
+
return work;
|
|
2430
|
+
}
|
|
2431
|
+
rsync(request, signal, callCtx = {}) {
|
|
2432
|
+
const combined = AbortSignal.any([signal, this.fileAbort.signal]);
|
|
2433
|
+
const work = (async () => {
|
|
2434
|
+
this.assertOpen();
|
|
2435
|
+
combined.throwIfAborted();
|
|
2436
|
+
const endpoint = async (value) => {
|
|
2437
|
+
const parsed = parseFileEndpoint(value);
|
|
2438
|
+
if (!parsed.target) return { path: parsed.path };
|
|
2439
|
+
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");
|
|
2440
|
+
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
2441
|
+
this.assertOpen();
|
|
2442
|
+
combined.throwIfAborted();
|
|
2443
|
+
if (plan.kind !== "ssh") throw new Error("rsync requires an SSH target; no copy fallback");
|
|
2444
|
+
return {
|
|
2445
|
+
path: parsed.path,
|
|
2446
|
+
connection: plan.connection ?? { host: plan.host }
|
|
2447
|
+
};
|
|
2448
|
+
};
|
|
2449
|
+
const source = await endpoint(request.src), destination = await endpoint(request.dst);
|
|
2450
|
+
this.assertOpen();
|
|
2451
|
+
combined.throwIfAborted();
|
|
2452
|
+
return runRsync(request, source, destination, combined, { wrapArgv: this.opts.nativeArgvFor });
|
|
2453
|
+
})();
|
|
2454
|
+
this.starting.add(work);
|
|
2455
|
+
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
2456
|
+
return work;
|
|
2457
|
+
}
|
|
2458
|
+
/** Only called from JobSpec.run: admission precedes local file reads and discovery. */
|
|
2459
|
+
start(input, signal) {
|
|
2460
|
+
const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]));
|
|
2461
|
+
this.starting.add(work);
|
|
2462
|
+
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
2463
|
+
return work;
|
|
2464
|
+
}
|
|
2465
|
+
async startPrepared(input, signal) {
|
|
2466
|
+
this.assertOpen();
|
|
2467
|
+
signal.throwIfAborted();
|
|
2468
|
+
const params = await prepareRemoteInput(input);
|
|
2469
|
+
this.assertOpen();
|
|
2470
|
+
signal.throwIfAborted();
|
|
2471
|
+
const route = this.route(params);
|
|
2472
|
+
if (route.mode !== "oneshot" || route.pty) throw new Error("remote: background requires oneshot");
|
|
2473
|
+
let process$1;
|
|
2474
|
+
if (route.display.startsWith("hub:")) {
|
|
2475
|
+
const hub = this.requireHub();
|
|
2476
|
+
this.hubProviders.add(hub);
|
|
2477
|
+
process$1 = await hub.start(route.display.slice(4), {
|
|
2478
|
+
...params,
|
|
2479
|
+
mode: "oneshot"
|
|
2480
|
+
}, this.hubOwner);
|
|
2481
|
+
} else {
|
|
2482
|
+
const plan = route.plan ?? await this.resolveBareName(route.bare, "no-session", false);
|
|
2483
|
+
this.assertOpen();
|
|
2484
|
+
signal.throwIfAborted();
|
|
2485
|
+
if (plan.kind === "pty") throw new Error("remote: background requires oneshot");
|
|
2486
|
+
process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
|
|
2487
|
+
}
|
|
2488
|
+
this.own(process$1);
|
|
2489
|
+
if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
|
|
2490
|
+
return process$1;
|
|
2491
|
+
}
|
|
2492
|
+
nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
2493
|
+
const cleanupTag = randomUUID().slice(0, 8);
|
|
2494
|
+
const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, { cleanupTag }), {
|
|
2495
|
+
stdin: params.stdin,
|
|
2496
|
+
timeoutSec
|
|
2497
|
+
});
|
|
2498
|
+
process$1.done.then((result) => {
|
|
2499
|
+
if (!result.cancelled && !result.timedOut) return;
|
|
2500
|
+
const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
|
|
2501
|
+
const cleanup = `p=$(cat ${pidfile} 2>/dev/null) && { kill -TERM -- "-$p" 2>/dev/null; sleep 0.3; kill -KILL -- "-$p" 2>/dev/null; }; rm -f ${pidfile}`;
|
|
2502
|
+
startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, cleanup, {}), { timeoutSec: 15 }).done.catch(() => {});
|
|
2503
|
+
}).catch(() => {});
|
|
2504
|
+
const work = Promise.resolve(process$1);
|
|
2505
|
+
this.starting.add(work);
|
|
2506
|
+
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
2507
|
+
return work;
|
|
2508
|
+
}
|
|
2509
|
+
own(process$1) {
|
|
2510
|
+
this.processes.add(process$1);
|
|
2511
|
+
process$1.done.then(() => this.processes.delete(process$1));
|
|
2512
|
+
return process$1;
|
|
2513
|
+
}
|
|
825
2514
|
/** Ruling 16/17:on-demand 探测 + 会话层,两行如实陈述。 */
|
|
826
2515
|
/**
|
|
827
2516
|
* Ruling P20 + PTY 候选表末位:裸名按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
|
|
@@ -831,13 +2520,15 @@ var RemoteDriver = class {
|
|
|
831
2520
|
async resolveBareName(name$1, sessionKey, usePtyLabels = true) {
|
|
832
2521
|
const runner = this.opts.rosterRunner ?? runOneShot;
|
|
833
2522
|
const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
|
|
2523
|
+
let hosts = [];
|
|
834
2524
|
try {
|
|
835
|
-
|
|
836
|
-
if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
|
|
837
|
-
kind: "ssh",
|
|
838
|
-
host: name$1
|
|
839
|
-
};
|
|
2525
|
+
hosts = literalHosts(await readCfg()).hosts;
|
|
840
2526
|
} catch {}
|
|
2527
|
+
this.assertOpen();
|
|
2528
|
+
if (hosts.some((h) => h.toLowerCase() === name$1.toLowerCase())) return {
|
|
2529
|
+
kind: "ssh",
|
|
2530
|
+
host: name$1
|
|
2531
|
+
};
|
|
841
2532
|
const docker = await runner([
|
|
842
2533
|
"docker",
|
|
843
2534
|
"ps",
|
|
@@ -845,6 +2536,7 @@ var RemoteDriver = class {
|
|
|
845
2536
|
"--format",
|
|
846
2537
|
"{{.Names}}"
|
|
847
2538
|
], { timeoutSec: 10 });
|
|
2539
|
+
this.assertOpen();
|
|
848
2540
|
if (docker.exit === 0 && docker.stdout.trim().split(/\r?\n/).some((n) => n.trim().toLowerCase() === name$1.toLowerCase())) return {
|
|
849
2541
|
kind: "docker",
|
|
850
2542
|
container: name$1
|
|
@@ -857,6 +2549,7 @@ var RemoteDriver = class {
|
|
|
857
2549
|
"--columns",
|
|
858
2550
|
"n"
|
|
859
2551
|
], { timeoutSec: 10 });
|
|
2552
|
+
this.assertOpen();
|
|
860
2553
|
if (incus.exit === 0 && incus.stdout.trim().split(/\r?\n/).some((n) => n.split(",")[0]?.trim().toLowerCase() === name$1.toLowerCase())) return {
|
|
861
2554
|
kind: "incus",
|
|
862
2555
|
container: name$1
|
|
@@ -875,22 +2568,47 @@ var RemoteDriver = class {
|
|
|
875
2568
|
* 而活会话是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,且两个 agent 各开一条线
|
|
876
2569
|
* 的代价可忽略。BYO-PTY 天然是活的,于是自然落进这个 scope,无需另立规则。 */
|
|
877
2570
|
async roster(callCtx = {}) {
|
|
2571
|
+
this.assertOpen();
|
|
878
2572
|
const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
|
|
879
|
-
|
|
2573
|
+
const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
|
|
880
2574
|
target: displayKey(key),
|
|
881
2575
|
state: snapshot.state,
|
|
882
2576
|
idleSec: snapshot.idleMs !== null ? Math.round(snapshot.idleMs / 1e3) : null
|
|
883
|
-
}))
|
|
2577
|
+
}));
|
|
2578
|
+
const runner = this.opts.rosterRunner ?? runOneShot;
|
|
2579
|
+
const hub = this.opts.getHub?.();
|
|
2580
|
+
return [...hub ? ["hub machines: " + (hub.listTargets().map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none")] : [], await renderRoster(sessions, {
|
|
2581
|
+
byosshLabels: this.sshTargets.entries().map(([label]) => label),
|
|
2582
|
+
readSshConfig: this.opts.sshConfigReader,
|
|
2583
|
+
runner: (argv, options) => {
|
|
2584
|
+
this.assertOpen();
|
|
2585
|
+
return runner(argv, options);
|
|
2586
|
+
}
|
|
2587
|
+
})].join("\n");
|
|
884
2588
|
}
|
|
885
2589
|
async status(target, callCtx = {}) {
|
|
2590
|
+
this.assertOpen();
|
|
2591
|
+
if (target.startsWith("hub:")) {
|
|
2592
|
+
const machine = target.slice(4);
|
|
2593
|
+
const row = this.requireHub().listTargets().find((t) => t.id === machine);
|
|
2594
|
+
if (!row) throw new Error(`unknown remote machine ${machine} — select a remote machine from the Hub roster; local execution is not available`);
|
|
2595
|
+
return `${target} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`;
|
|
2596
|
+
}
|
|
886
2597
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
887
2598
|
const label = target.startsWith("byop:") ? target.slice(5) : void 0;
|
|
888
2599
|
if (label !== void 0) return this.ptyStatus(target, label, sessionKey);
|
|
889
|
-
const
|
|
2600
|
+
const direct = /^(docker|incus|ssh):/.test(target);
|
|
2601
|
+
const resolved = target.startsWith("byossh:") ? this.byosshPlan(target) : direct ? resolveTarget(target) : await this.resolveBareName(target, sessionKey);
|
|
2602
|
+
this.assertOpen();
|
|
890
2603
|
if (resolved.kind === "pty") return this.ptyStatus(`byop:${resolved.label}`, resolved.label, sessionKey);
|
|
2604
|
+
const head = `${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`;
|
|
2605
|
+
const runner = this.opts.probeRunner ?? runOneShot;
|
|
891
2606
|
return [
|
|
892
|
-
|
|
893
|
-
renderProbe(await probeTarget(resolved, { runner:
|
|
2607
|
+
head,
|
|
2608
|
+
renderProbe(await probeTarget(resolved, { runner: (argv, options) => {
|
|
2609
|
+
this.assertOpen();
|
|
2610
|
+
return runner(argv, options);
|
|
2611
|
+
} })),
|
|
894
2612
|
renderSession(this.pool.inspect(`${sessionKey}|t:${target}`))
|
|
895
2613
|
].join("\n");
|
|
896
2614
|
}
|
|
@@ -904,12 +2622,50 @@ var RemoteDriver = class {
|
|
|
904
2622
|
this.opts.onAudit?.(r);
|
|
905
2623
|
} catch {}
|
|
906
2624
|
}
|
|
2625
|
+
/** Registration never dials; response reveals labels only, not credentials. */
|
|
2626
|
+
registerSsh(target, connection) {
|
|
2627
|
+
this.assertOpen();
|
|
2628
|
+
if (!target.startsWith("byossh:")) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target");
|
|
2629
|
+
this.sshTargets.register(target.slice(7), connection);
|
|
2630
|
+
return `${target} registered (no dialing). Reuse this Agent's label without ssh; a new Agent/cold resume must register again.`;
|
|
2631
|
+
}
|
|
2632
|
+
byosshPlan(target, connection) {
|
|
2633
|
+
if (connection !== void 0) this.registerSsh(target, connection);
|
|
2634
|
+
const spec = this.sshTargets.get(target.slice(7));
|
|
2635
|
+
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`);
|
|
2636
|
+
return {
|
|
2637
|
+
kind: "ssh",
|
|
2638
|
+
host: spec.host,
|
|
2639
|
+
connection: spec
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
/** Single target resolver for later copy/rsync; BYO identities cannot be
|
|
2643
|
+
* independently re-resolved through the origin's alias table. */
|
|
2644
|
+
async resolveConnectionTarget(target, callCtx = {}) {
|
|
2645
|
+
this.assertOpen();
|
|
2646
|
+
if (target.startsWith("byossh:")) return this.byosshPlan(target);
|
|
2647
|
+
if (/^(docker|incus|ssh):/.test(target)) return resolveTarget(target);
|
|
2648
|
+
if (/^(byop|hub):/.test(target)) throw new Error("remote: select an SSH/Docker/Incus target for this connection resolver");
|
|
2649
|
+
const plan = await this.resolveBareName(target, callCtx.sessionKey ?? "no-session", false);
|
|
2650
|
+
this.assertOpen();
|
|
2651
|
+
if (plan.kind === "pty") throw new Error("remote: BYO-PTY is not a filesystem connection");
|
|
2652
|
+
return plan;
|
|
2653
|
+
}
|
|
907
2654
|
route(params) {
|
|
908
2655
|
const hasTarget = params.target !== void 0 && params.target.length > 0;
|
|
909
2656
|
const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
|
|
910
2657
|
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
2658
|
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
2659
|
if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
|
|
2660
|
+
if (params.ssh !== void 0 && !params.target.startsWith("byossh:")) throw new Error("[E_SSH_PARAMS] remote: ssh requires a byossh:<label> target");
|
|
2661
|
+
if (params.target.startsWith("byossh:")) {
|
|
2662
|
+
if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is only for byop:<label>, not BYO-SSH");
|
|
2663
|
+
return {
|
|
2664
|
+
mode: params.mode ?? "oneshot",
|
|
2665
|
+
display: params.target,
|
|
2666
|
+
plan: this.byosshPlan(params.target, params.ssh)
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
913
2669
|
if (hasTarget && params.target.startsWith("byop:")) {
|
|
914
2670
|
const label = params.target.slice(5);
|
|
915
2671
|
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 +2681,10 @@ var RemoteDriver = class {
|
|
|
925
2681
|
}
|
|
926
2682
|
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
2683
|
const mode = params.mode ?? "oneshot";
|
|
2684
|
+
if (params.target.startsWith("hub:")) return {
|
|
2685
|
+
mode,
|
|
2686
|
+
display: params.target
|
|
2687
|
+
};
|
|
928
2688
|
const direct = /^(docker|incus|ssh):/.test(params.target);
|
|
929
2689
|
return {
|
|
930
2690
|
mode,
|
|
@@ -933,25 +2693,82 @@ var RemoteDriver = class {
|
|
|
933
2693
|
};
|
|
934
2694
|
}
|
|
935
2695
|
async execute(params, routing, callCtx) {
|
|
2696
|
+
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
2697
|
+
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
2698
|
+
if (routing.display.startsWith("hub:")) {
|
|
2699
|
+
const hub = this.requireHub();
|
|
2700
|
+
this.hubProviders.add(hub);
|
|
2701
|
+
const machine = routing.display.slice(4);
|
|
2702
|
+
if (routing.mode === "pty") {
|
|
2703
|
+
const result$1 = await hub.pty(machine, params.cmd, this.hubOwner, {
|
|
2704
|
+
stdin: params.stdin,
|
|
2705
|
+
timeout: timeoutSec
|
|
2706
|
+
});
|
|
2707
|
+
const out$2 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
|
|
2708
|
+
return {
|
|
2709
|
+
...result$1,
|
|
2710
|
+
stdout: out$2.text,
|
|
2711
|
+
...out$2.stats ? { outputStats: {
|
|
2712
|
+
...result$1.outputStats,
|
|
2713
|
+
stdout: out$2.stats
|
|
2714
|
+
} } : {},
|
|
2715
|
+
...out$2.truncated === void 0 ? {} : { truncated: out$2.truncated }
|
|
2716
|
+
};
|
|
2717
|
+
}
|
|
2718
|
+
const process$1 = this.own(await hub.start(machine, {
|
|
2719
|
+
...params,
|
|
2720
|
+
target: routing.display,
|
|
2721
|
+
mode: "oneshot",
|
|
2722
|
+
timeout: timeoutSec
|
|
2723
|
+
}, this.hubOwner));
|
|
2724
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
2725
|
+
const result = await process$1.done;
|
|
2726
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
2727
|
+
const out$1 = tailWindow(stdout.text, cap, stdout.stats);
|
|
2728
|
+
const err = tailWindow(stderr.text, cap, stderr.stats);
|
|
2729
|
+
return {
|
|
2730
|
+
...result,
|
|
2731
|
+
mode: "oneshot",
|
|
2732
|
+
target: routing.display,
|
|
2733
|
+
cwd: null,
|
|
2734
|
+
reconnected: false,
|
|
2735
|
+
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out$1.text,
|
|
2736
|
+
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
2737
|
+
...out$1.stats ? { outputStats: {
|
|
2738
|
+
stdout: out$1.stats,
|
|
2739
|
+
stderr: err.stats
|
|
2740
|
+
} } : { outputStatsUnavailable: true },
|
|
2741
|
+
...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
936
2744
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
937
2745
|
if (routing.bare !== void 0) {
|
|
938
2746
|
const resolved = await this.resolveBareName(routing.bare, sessionKey, params.mode !== "oneshot");
|
|
2747
|
+
this.assertOpen();
|
|
939
2748
|
if (resolved.kind === "pty") {
|
|
940
2749
|
routing.pty = { label: resolved.label };
|
|
941
2750
|
routing.mode = "pty";
|
|
942
2751
|
routing.display = `byop:${resolved.label}`;
|
|
943
2752
|
} else routing.plan = resolved;
|
|
944
2753
|
}
|
|
945
|
-
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
946
|
-
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
947
2754
|
if (routing.pty !== void 0) return await this.ptyCall(routing.pty, params, sessionKey, timeoutSec, cap);
|
|
948
2755
|
if (routing.mode === "oneshot") {
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
const
|
|
954
|
-
const
|
|
2756
|
+
this.assertOpen();
|
|
2757
|
+
const process$1 = this.own(await this.nativeProcess(routing.plan, params, timeoutSec));
|
|
2758
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
2759
|
+
const result = await process$1.done;
|
|
2760
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
2761
|
+
const r$1 = {
|
|
2762
|
+
...result,
|
|
2763
|
+
stdout: stdout.text,
|
|
2764
|
+
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
2765
|
+
outputStats: {
|
|
2766
|
+
stdout: stdout.stats,
|
|
2767
|
+
stderr: stderr.stats
|
|
2768
|
+
}
|
|
2769
|
+
};
|
|
2770
|
+
const out$1 = tailWindow(r$1.stdout, cap, r$1.outputStats?.stdout);
|
|
2771
|
+
const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap, r$1.outputStats?.stderr) : void 0;
|
|
955
2772
|
return {
|
|
956
2773
|
mode: "oneshot",
|
|
957
2774
|
target: routing.display,
|
|
@@ -961,6 +2778,10 @@ var RemoteDriver = class {
|
|
|
961
2778
|
reconnected: false,
|
|
962
2779
|
stdout: out$1.text,
|
|
963
2780
|
...err !== void 0 ? { stderr: err.text } : {},
|
|
2781
|
+
...out$1.stats ? { outputStats: {
|
|
2782
|
+
stdout: out$1.stats,
|
|
2783
|
+
stderr: err?.stats ?? r$1.outputStats?.stderr
|
|
2784
|
+
} } : {},
|
|
964
2785
|
durationMs: r$1.durationMs,
|
|
965
2786
|
...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
|
|
966
2787
|
};
|
|
@@ -971,7 +2792,7 @@ var RemoteDriver = class {
|
|
|
971
2792
|
stdin: params.stdin,
|
|
972
2793
|
timeoutSec
|
|
973
2794
|
});
|
|
974
|
-
const out = tailWindow(r.output, cap);
|
|
2795
|
+
const out = tailWindow(r.output, cap, r.outputStats?.stdout);
|
|
975
2796
|
return {
|
|
976
2797
|
mode: "pty",
|
|
977
2798
|
target: routing.display,
|
|
@@ -980,6 +2801,7 @@ var RemoteDriver = class {
|
|
|
980
2801
|
timedOut: r.timedOut,
|
|
981
2802
|
reconnected: r.reconnected,
|
|
982
2803
|
stdout: out.text,
|
|
2804
|
+
outputStats: out.stats ? { stdout: out.stats } : void 0,
|
|
983
2805
|
durationMs: r.durationMs,
|
|
984
2806
|
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
985
2807
|
};
|
|
@@ -1001,7 +2823,7 @@ var RemoteDriver = class {
|
|
|
1001
2823
|
stdin: params.stdin,
|
|
1002
2824
|
timeoutSec
|
|
1003
2825
|
});
|
|
1004
|
-
const out = tailWindow(r.output, cap);
|
|
2826
|
+
const out = tailWindow(r.output, cap, r.outputStats?.stdout);
|
|
1005
2827
|
return {
|
|
1006
2828
|
mode: "pty",
|
|
1007
2829
|
target: display,
|
|
@@ -1012,27 +2834,200 @@ var RemoteDriver = class {
|
|
|
1012
2834
|
reconnected: r.reconnected,
|
|
1013
2835
|
stdout: out.text,
|
|
1014
2836
|
...notice !== void 0 ? { notice } : {},
|
|
2837
|
+
outputStats: out.stats ? { stdout: out.stats } : void 0,
|
|
1015
2838
|
durationMs: r.durationMs,
|
|
1016
2839
|
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
1017
2840
|
};
|
|
1018
2841
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
2842
|
+
hasHub() {
|
|
2843
|
+
return this.opts.getHub?.() !== void 0;
|
|
2844
|
+
}
|
|
2845
|
+
requireHub() {
|
|
2846
|
+
this.assertOpen();
|
|
2847
|
+
const hub = this.opts.getHub?.();
|
|
2848
|
+
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");
|
|
2849
|
+
return hub;
|
|
2850
|
+
}
|
|
2851
|
+
assertOpen() {
|
|
2852
|
+
if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: this Agent or plugin owner has exited — create a new session or cold resume");
|
|
2853
|
+
}
|
|
2854
|
+
dispose() {
|
|
2855
|
+
if (this.disposal) return this.disposal;
|
|
2856
|
+
this.sshTargets.clear();
|
|
2857
|
+
this.disposed = true;
|
|
2858
|
+
this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
|
|
2859
|
+
for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
|
|
2860
|
+
return this.disposal = (async () => {
|
|
2861
|
+
await Promise.allSettled([...this.starting]);
|
|
2862
|
+
await Promise.all([
|
|
2863
|
+
this.pool.disposeAll(),
|
|
2864
|
+
...[...this.processes].map((p) => p.done),
|
|
2865
|
+
...[...this.hubProviders].map((hub) => hub.releaseOwner(this.hubOwner))
|
|
2866
|
+
]);
|
|
2867
|
+
this.hubProviders.clear();
|
|
2868
|
+
})();
|
|
1021
2869
|
}
|
|
1022
2870
|
};
|
|
1023
2871
|
|
|
2872
|
+
//#endregion
|
|
2873
|
+
//#region src/remote/jobs.ts
|
|
2874
|
+
/** No preparation or discovery before the official registry's admission. */
|
|
2875
|
+
function startRemoteJob(registry, input, exec, driver) {
|
|
2876
|
+
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");
|
|
2877
|
+
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
2878
|
+
let process$1;
|
|
2879
|
+
const output = ["stdout", "stderr"].map((channel) => ({
|
|
2880
|
+
channel,
|
|
2881
|
+
read: (fromByte) => {
|
|
2882
|
+
const read = process$1?.read(channel, fromByte);
|
|
2883
|
+
return read ? {
|
|
2884
|
+
text: read.text,
|
|
2885
|
+
nextOffset: read.nextOffset,
|
|
2886
|
+
lossy: read.lossy
|
|
2887
|
+
} : {
|
|
2888
|
+
text: "",
|
|
2889
|
+
nextOffset: fromByte,
|
|
2890
|
+
lossy: false
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
}));
|
|
2894
|
+
return registry.start({
|
|
2895
|
+
kind: "remote",
|
|
2896
|
+
owner: exec.agent.id,
|
|
2897
|
+
label: `${input.target}: ${input.cmd ?? input.script}`,
|
|
2898
|
+
output,
|
|
2899
|
+
run() {
|
|
2900
|
+
const controller = new AbortController();
|
|
2901
|
+
return {
|
|
2902
|
+
done: (async () => {
|
|
2903
|
+
try {
|
|
2904
|
+
process$1 = await driver.start({
|
|
2905
|
+
...input,
|
|
2906
|
+
mode: "oneshot"
|
|
2907
|
+
}, controller.signal);
|
|
2908
|
+
if (controller.signal.aborted) process$1.cancel(controller.signal.reason);
|
|
2909
|
+
const result = await process$1.done;
|
|
2910
|
+
const diagnostic = process$1.read("stderr", 0).diagnostic;
|
|
2911
|
+
return {
|
|
2912
|
+
status: controller.signal.aborted || result.cancelled ? "killed" : diagnostic ? "failed" : result.timedOut ? "killed" : "completed",
|
|
2913
|
+
detail: diagnostic ?? (result.timedOut ? "timeout" : `exit code: ${result.exit}`)
|
|
2914
|
+
};
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
return {
|
|
2917
|
+
status: controller.signal.aborted ? "killed" : "failed",
|
|
2918
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
})(),
|
|
2922
|
+
cancel(reason) {
|
|
2923
|
+
if (controller.signal.aborted) return;
|
|
2924
|
+
controller.abort(reason);
|
|
2925
|
+
process$1?.cancel(reason);
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
});
|
|
2930
|
+
}
|
|
2931
|
+
|
|
1024
2932
|
//#endregion
|
|
1025
2933
|
//#region src/remote/tool.ts
|
|
1026
2934
|
const NO_SESSION_KEY = "remote:no-session";
|
|
2935
|
+
/**
|
|
2936
|
+
* The model-visible surface of the `remote` tool. Kept as ONE template
|
|
2937
|
+
* literal so it can be edited here directly — no string concatenation and no
|
|
2938
|
+
* apostrophe escaping. Plain text only: no backticks and no "${" inside.
|
|
2939
|
+
*/
|
|
2940
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus persistent named shells. Pick the call shape:
|
|
2941
|
+
{} → roster (local scan, no dialing)
|
|
2942
|
+
{target} → status probe: reachable / unreachable — <native error>, or container state + session layer
|
|
2943
|
+
{target, cmd} → run cmd — oneshot by default; mode "pty" for a persistent shell
|
|
2944
|
+
{target, script:"/abs"} → LOCAL script over stdin to a fixed bash -s (oneshot only; no cmd/stdin/spawn/mode mix)
|
|
2945
|
+
{target:"byossh:lbl", ssh:{host,user?,port?,key?,proxyJump?}} → register this Agent's SSH identity (no cmd = register only); reuse the target later with ssh omitted. Same spec idempotent; different spec refused — use another label. key is an existing origin file, never inline material; nothing written or persisted; a new Agent registers again.
|
|
2946
|
+
{target:"byop:lbl", spawn, cmd} → create the named shell once; reuse as {target:"byop:lbl", cmd} (no spawn)
|
|
2947
|
+
Bare names resolve ssh config → docker → incus → live pty; unmatched goes to ssh and its native error surfaces. Prefixes ssh: / docker: / incus: / byop: force a channel. byop: same spawn repeated is reused; a different spawn is refused ([E_LABEL_CONFLICT], nothing runs); unknown or idle-expired → [E_NO_SESSION] (spawn is not retained — pass it again); a failed spawn frees the label.
|
|
2948
|
+
|
|
2949
|
+
oneshot: stateless bash -lc, stdout/stderr separate, exit code verbatim; the endpoint needs bash only (no Python). A POSIX wrapper reaps backgrounded children on completion and kills the whole group on timeout/cancel — they never outlive the call or hold the channel. pty: one shell per (agent, target); cwd/env survive, streams merge, Ctrl-C interrupts without killing, idle reap after 600s.
|
|
2950
|
+
|
|
2951
|
+
Files — {action:"copy" | "rsync", src:"[target:]abs", dst:"[target:]abs", argv:["-a"]}: argv tokens pass verbatim before src/dst — no whitelist, no forced defaults (dry run = pass "--dry-run" yourself); the CLI's own exit code and stderr come back in the receipt. copy: local↔SSH scp; SSH↔SSH scp -3 through the origin (identities stay local); docker cp; incus file push/pull; any other pair = two commands with origin staging (per-half options via pullArgv/pushArgv, exclusive with argv); scp -R refused; success is NOT independently verified; verify/mkdir only on the Hub↔local lane. rsync: local↔SSH, or SSH↔SSH with one protocol conversation between the peers (real delta; forwarding denied → honest error, never full copy). rsync src trailing slash copies contents, no slash the directory itself.
|
|
2952
|
+
|
|
2953
|
+
Output: pty tails at ~30k chars ("[truncated: …]" header) — redirect bulk output to a file. Hard retention 4,000,000 UTF-16 units per stream (Hub 4MiB bytes); outputStats reports the rest; restart cannot recover discarded output. stdin feeds the command; timeout (seconds) interrupts then kills. run_in_background:true (oneshot) → jobId for job_output/job_kill. Native transport errors pass through verbatim — self-correct from them.`;
|
|
1027
2954
|
function createRemoteTool(driver, opts = {}) {
|
|
1028
2955
|
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
1029
|
-
|
|
2956
|
+
const tool = defineTool({
|
|
1030
2957
|
name: "remote",
|
|
1031
|
-
description:
|
|
2958
|
+
description: REMOTE_TOOL_DESCRIPTION,
|
|
1032
2959
|
parameters: {
|
|
2960
|
+
action: {
|
|
2961
|
+
type: "string",
|
|
2962
|
+
enum: ["copy", "rsync"],
|
|
2963
|
+
description: "Optional file action; omit for execution/status/roster."
|
|
2964
|
+
},
|
|
2965
|
+
src: {
|
|
2966
|
+
type: "string",
|
|
2967
|
+
description: "copy/rsync source [target:]absolute path; rsync retains trailing-slash semantics"
|
|
2968
|
+
},
|
|
2969
|
+
dst: {
|
|
2970
|
+
type: "string",
|
|
2971
|
+
description: "copy/rsync destination [target:]absolute path"
|
|
2972
|
+
},
|
|
2973
|
+
argv: {
|
|
2974
|
+
type: "array",
|
|
2975
|
+
items: { type: "string" },
|
|
2976
|
+
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\"], docker cp [\"-a\"], incus file push/pull). One element = one argv token, appended verbatim before src/dst; no shell parsing, no whitelist, no forced defaults — unknown options fail in the native CLI itself. rsync only (SSH endpoints): explicit -e/--rsh overrides the tool's SSH wiring. Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
|
|
2977
|
+
},
|
|
2978
|
+
pullArgv: {
|
|
2979
|
+
type: "array",
|
|
2980
|
+
items: { type: "string" },
|
|
2981
|
+
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
2982
|
+
},
|
|
2983
|
+
pushArgv: {
|
|
2984
|
+
type: "array",
|
|
2985
|
+
items: { type: "string" },
|
|
2986
|
+
description: "Options for the PUSH half (origin staging → destination endpoint) of a two-command cross-endpoint copy. Mutually exclusive with argv; must be paired with pullArgv. Only options valid for the push half's CLI."
|
|
2987
|
+
},
|
|
2988
|
+
verify: {
|
|
2989
|
+
type: "boolean",
|
|
2990
|
+
description: "copy only, Hub↔local↔local staged lane only: independent SHA-256 verification (default true). Native CLI routes do not verify and report that fact."
|
|
2991
|
+
},
|
|
2992
|
+
mkdir: {
|
|
2993
|
+
type: "boolean",
|
|
2994
|
+
description: "copy only, Hub↔local↔local staged lane only: create missing destination parents (default true). Native CLI routes let the CLI decide."
|
|
2995
|
+
},
|
|
2996
|
+
run_in_background: {
|
|
2997
|
+
type: "boolean",
|
|
2998
|
+
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."
|
|
2999
|
+
},
|
|
1033
3000
|
target: {
|
|
1034
3001
|
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."
|
|
3002
|
+
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."
|
|
3003
|
+
},
|
|
3004
|
+
ssh: {
|
|
3005
|
+
type: "object",
|
|
3006
|
+
description: "Only for byossh:<label>: register origin-side connection; omit on reuse. No inline keys or arbitrary SSH options.",
|
|
3007
|
+
additionalProperties: false,
|
|
3008
|
+
properties: {
|
|
3009
|
+
host: {
|
|
3010
|
+
type: "string",
|
|
3011
|
+
required: true,
|
|
3012
|
+
description: "SSH hostname, IP or native config alias."
|
|
3013
|
+
},
|
|
3014
|
+
user: {
|
|
3015
|
+
type: "string",
|
|
3016
|
+
description: "Login user; omitted preserves native SSH config."
|
|
3017
|
+
},
|
|
3018
|
+
port: {
|
|
3019
|
+
type: "integer",
|
|
3020
|
+
description: "SSH port 1–65535; omitted preserves native SSH config."
|
|
3021
|
+
},
|
|
3022
|
+
key: {
|
|
3023
|
+
type: "string",
|
|
3024
|
+
description: "Existing origin private-key file path, not key contents; never copied or persisted."
|
|
3025
|
+
},
|
|
3026
|
+
proxyJump: {
|
|
3027
|
+
type: "string",
|
|
3028
|
+
description: "Official ProxyJump: comma-separated [user@]host[:port] or native aliases; jump identity remains in origin SSH config."
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
1036
3031
|
},
|
|
1037
3032
|
spawn: {
|
|
1038
3033
|
type: "string",
|
|
@@ -1042,6 +3037,10 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1042
3037
|
type: "string",
|
|
1043
3038
|
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
3039
|
},
|
|
3040
|
+
script: {
|
|
3041
|
+
type: "string",
|
|
3042
|
+
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."
|
|
3043
|
+
},
|
|
1045
3044
|
mode: {
|
|
1046
3045
|
type: "string",
|
|
1047
3046
|
enum: ["oneshot", "pty"],
|
|
@@ -1067,13 +3066,18 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1067
3066
|
enum: [
|
|
1068
3067
|
"exec",
|
|
1069
3068
|
"status",
|
|
1070
|
-
"roster"
|
|
3069
|
+
"roster",
|
|
3070
|
+
"job",
|
|
3071
|
+
"copy",
|
|
3072
|
+
"rsync"
|
|
1071
3073
|
]
|
|
1072
3074
|
},
|
|
1073
3075
|
text: {
|
|
1074
3076
|
type: "string",
|
|
1075
3077
|
required: true
|
|
1076
3078
|
},
|
|
3079
|
+
jobId: { type: "string" },
|
|
3080
|
+
receipt: { type: "json" },
|
|
1077
3081
|
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
1078
3082
|
durationMs: { type: "integer" },
|
|
1079
3083
|
cwd: { type: "string" },
|
|
@@ -1081,6 +3085,8 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1081
3085
|
timedOut: { type: "boolean" },
|
|
1082
3086
|
reconnected: { type: "boolean" },
|
|
1083
3087
|
truncated: { type: "integer" },
|
|
3088
|
+
outputStats: { type: "json" },
|
|
3089
|
+
outputStatsUnavailable: { type: "boolean" },
|
|
1084
3090
|
session: { type: "string" },
|
|
1085
3091
|
notice: { type: "string" }
|
|
1086
3092
|
}
|
|
@@ -1091,13 +3097,105 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1091
3097
|
}]
|
|
1092
3098
|
},
|
|
1093
3099
|
async execute(args, exec) {
|
|
3100
|
+
if (args.action === "copy" || args.action === "rsync") {
|
|
3101
|
+
if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
|
|
3102
|
+
if ([
|
|
3103
|
+
args.cmd,
|
|
3104
|
+
args.script,
|
|
3105
|
+
args.stdin,
|
|
3106
|
+
args.spawn,
|
|
3107
|
+
args.ssh,
|
|
3108
|
+
args.target,
|
|
3109
|
+
args.mode,
|
|
3110
|
+
args.timeout,
|
|
3111
|
+
args.run_in_background
|
|
3112
|
+
].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments; register byossh separately first`);
|
|
3113
|
+
for (const legacy of [
|
|
3114
|
+
"dryRun",
|
|
3115
|
+
"delete",
|
|
3116
|
+
"excludes",
|
|
3117
|
+
"preserve"
|
|
3118
|
+
]) if (args[legacy] !== void 0) throw new Error(`${legacy} was removed: pass native options through argv instead (e.g. ${legacy === "dryRun" ? "\"--dry-run\"" : legacy === "delete" ? "\"--delete\"" : legacy === "excludes" ? "\"--exclude=<pattern>\"" : "\"-a\" / \"-r\""})`);
|
|
3119
|
+
if (args.action === "rsync") {
|
|
3120
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
3121
|
+
if ([args.verify, args.mkdir].some((value) => value !== void 0)) throw new Error("rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
|
|
3122
|
+
const start = Date.now();
|
|
3123
|
+
const receipt$1 = await driver.rsync({
|
|
3124
|
+
src: args.src,
|
|
3125
|
+
dst: args.dst,
|
|
3126
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
3127
|
+
}, exec.signal, { sessionKey: getSessionKey(exec) });
|
|
3128
|
+
if (receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
3129
|
+
return {
|
|
3130
|
+
kind: "rsync",
|
|
3131
|
+
text: JSON.stringify(receipt$1),
|
|
3132
|
+
receipt: receipt$1,
|
|
3133
|
+
exit: receipt$1.exit,
|
|
3134
|
+
durationMs: Date.now() - start
|
|
3135
|
+
};
|
|
3136
|
+
}
|
|
3137
|
+
const receipt = await driver.copy({
|
|
3138
|
+
src: args.src,
|
|
3139
|
+
dst: args.dst,
|
|
3140
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
3141
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
3142
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
3143
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
3144
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
3145
|
+
}, exec.signal, { sessionKey: getSessionKey(exec) });
|
|
3146
|
+
return {
|
|
3147
|
+
kind: "copy",
|
|
3148
|
+
text: JSON.stringify(receipt),
|
|
3149
|
+
receipt,
|
|
3150
|
+
exit: null,
|
|
3151
|
+
durationMs: 0
|
|
3152
|
+
};
|
|
3153
|
+
}
|
|
3154
|
+
if ([
|
|
3155
|
+
args.src,
|
|
3156
|
+
args.dst,
|
|
3157
|
+
args.argv,
|
|
3158
|
+
args.pullArgv,
|
|
3159
|
+
args.pushArgv,
|
|
3160
|
+
args.verify,
|
|
3161
|
+
args.mkdir
|
|
3162
|
+
].some((value) => value !== void 0)) throw new Error("file fields require action copy or rsync");
|
|
1094
3163
|
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
1095
3164
|
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
1096
3165
|
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
1097
3166
|
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 (!
|
|
3167
|
+
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>");
|
|
3168
|
+
if (args.run_in_background) {
|
|
3169
|
+
if (!hasCmd && args.script === void 0) throw new Error("remote: background requires cmd or script");
|
|
3170
|
+
if (args.mode === "pty" || hasSpawn || args.target?.startsWith("byop:")) throw new Error("remote: background requires oneshot; PTY and BYO-PTY are unsupported");
|
|
3171
|
+
const jobs = exec.agent?.ctx.get("jobs");
|
|
3172
|
+
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
3173
|
+
const jobId = startRemoteJob(jobs, {
|
|
3174
|
+
target: args.target,
|
|
3175
|
+
cmd: args.cmd,
|
|
3176
|
+
script: args.script,
|
|
3177
|
+
ssh: args.ssh,
|
|
3178
|
+
stdin: args.stdin,
|
|
3179
|
+
mode: "oneshot",
|
|
3180
|
+
timeout: args.timeout
|
|
3181
|
+
}, exec, driver);
|
|
3182
|
+
return {
|
|
3183
|
+
kind: "job",
|
|
3184
|
+
jobId,
|
|
3185
|
+
text: `Started ${jobId}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
3186
|
+
exit: null,
|
|
3187
|
+
durationMs: 0
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
if (!hasCmd && args.script === void 0) {
|
|
1099
3191
|
if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
|
|
1100
3192
|
if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is an exec channel — pass cmd (there is nothing to probe for BYO-PTY)");
|
|
3193
|
+
if (args.ssh !== void 0) return {
|
|
3194
|
+
kind: "status",
|
|
3195
|
+
text: driver.registerSsh(args.target, args.ssh),
|
|
3196
|
+
exit: null,
|
|
3197
|
+
durationMs: 0
|
|
3198
|
+
};
|
|
1101
3199
|
if (!hasTarget) return {
|
|
1102
3200
|
kind: "roster",
|
|
1103
3201
|
text: await driver.roster({ sessionKey: getSessionKey(exec) }),
|
|
@@ -1114,7 +3212,9 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1114
3212
|
const r = await driver.call({
|
|
1115
3213
|
target: args.target,
|
|
1116
3214
|
spawn: args.spawn,
|
|
3215
|
+
ssh: args.ssh,
|
|
1117
3216
|
cmd: args.cmd,
|
|
3217
|
+
script: args.script,
|
|
1118
3218
|
mode: args.mode === "oneshot" || args.mode === "pty" ? args.mode : void 0,
|
|
1119
3219
|
stdin: args.stdin,
|
|
1120
3220
|
timeout: args.timeout
|
|
@@ -1130,14 +3230,22 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1130
3230
|
if (r.timedOut) v.timedOut = true;
|
|
1131
3231
|
if (r.reconnected) v.reconnected = true;
|
|
1132
3232
|
if (r.truncated !== void 0) v.truncated = r.truncated;
|
|
3233
|
+
if (r.outputStats !== void 0) v.outputStats = r.outputStats;
|
|
3234
|
+
if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
|
|
1133
3235
|
if (r.session !== void 0) v.session = r.session;
|
|
1134
3236
|
if (r.notice !== void 0) v.notice = r.notice;
|
|
1135
3237
|
return v;
|
|
1136
3238
|
}
|
|
1137
3239
|
});
|
|
3240
|
+
const description = tool.description;
|
|
3241
|
+
Object.defineProperty(tool, "description", {
|
|
3242
|
+
enumerable: true,
|
|
3243
|
+
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." : "")
|
|
3244
|
+
});
|
|
3245
|
+
return tool;
|
|
1138
3246
|
}
|
|
1139
3247
|
function renderValue(v) {
|
|
1140
|
-
if (v.kind === "status" || v.kind === "roster") return v.text;
|
|
3248
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync") return v.text;
|
|
1141
3249
|
const parts = [];
|
|
1142
3250
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
1143
3251
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
@@ -1145,7 +3253,8 @@ function renderValue(v) {
|
|
|
1145
3253
|
const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
|
|
1146
3254
|
const markers = [];
|
|
1147
3255
|
if (v.timedOut === true) markers.push("interrupted");
|
|
1148
|
-
if (v.truncated !== void 0) markers.push(`truncated, original ${v.truncated} chars`);
|
|
3256
|
+
if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
|
|
3257
|
+
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable; returned tail only");
|
|
1149
3258
|
const suffix = markers.length > 0 ? ` · ${markers.join(" · ")}` : "";
|
|
1150
3259
|
const cwdTag = typeof v.cwd === "string" && v.cwd.length > 0 ? ` · cwd ${v.cwd}` : "";
|
|
1151
3260
|
const sessionTag = typeof v.session === "string" && v.session.length > 0 ? ` · ${v.session}` : "";
|
|
@@ -1154,6 +3263,84 @@ function renderValue(v) {
|
|
|
1154
3263
|
return parts.join("\n");
|
|
1155
3264
|
}
|
|
1156
3265
|
|
|
3266
|
+
//#endregion
|
|
3267
|
+
//#region src/remote/agent-registration.ts
|
|
3268
|
+
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
3269
|
+
function installAgentRemote(ctx, config) {
|
|
3270
|
+
return ctx.on("agent/created", ({ agent }) => {
|
|
3271
|
+
const tools = agent.ctx.get("tools");
|
|
3272
|
+
if (!tools) return;
|
|
3273
|
+
const driver = new RemoteDriver({
|
|
3274
|
+
...config,
|
|
3275
|
+
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
3276
|
+
});
|
|
3277
|
+
let unregister;
|
|
3278
|
+
try {
|
|
3279
|
+
unregister = tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id }));
|
|
3280
|
+
} catch (error) {
|
|
3281
|
+
driver.dispose();
|
|
3282
|
+
if (error instanceof Error && error.message === "tool \"remote\" is already registered in this scope" && tools.get("remote", agent) !== void 0) return;
|
|
3283
|
+
throw error;
|
|
3284
|
+
}
|
|
3285
|
+
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
3286
|
+
if (name$1 === "superdRemoteTransport") agent.ctx.emit("tools/change");
|
|
3287
|
+
});
|
|
3288
|
+
let disposal;
|
|
3289
|
+
let ownerUndo;
|
|
3290
|
+
let agentUndo;
|
|
3291
|
+
const cleanup = () => {
|
|
3292
|
+
if (disposal) return disposal;
|
|
3293
|
+
disposal = driver.dispose();
|
|
3294
|
+
unregister();
|
|
3295
|
+
stopService();
|
|
3296
|
+
ownerUndo?.();
|
|
3297
|
+
agentUndo?.();
|
|
3298
|
+
return disposal;
|
|
3299
|
+
};
|
|
3300
|
+
agentUndo = agent.ctx.effect(() => cleanup, "remote.agentOwner()");
|
|
3301
|
+
ownerUndo = ctx.effect(() => cleanup, "remote.rowOwner()");
|
|
3302
|
+
});
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
//#endregion
|
|
3306
|
+
//#region src/remote/file-worker.ts
|
|
3307
|
+
async function runFileWorker(request) {
|
|
3308
|
+
const abort = new AbortController(), cancel = () => abort.abort(/* @__PURE__ */ new Error("binary file worker cancelled"));
|
|
3309
|
+
process.on("SIGTERM", cancel);
|
|
3310
|
+
process.on("SIGHUP", cancel);
|
|
3311
|
+
const report = (value) => new Promise((resolve$1, reject) => process.stderr.write(JSON.stringify(value) + "\n", (error) => error ? reject(error) : resolve$1()));
|
|
3312
|
+
try {
|
|
3313
|
+
mkdirSync(request.root, { mode: 448 });
|
|
3314
|
+
const endpoint = localFileEndpoint({
|
|
3315
|
+
receiveRoot: request.root,
|
|
3316
|
+
temporary: (path$1, active) => report({
|
|
3317
|
+
temporary: path$1,
|
|
3318
|
+
active
|
|
3319
|
+
})
|
|
3320
|
+
});
|
|
3321
|
+
if (request.action === "export") await pipeline(await endpoint.exportTree(request.path, abort.signal), process.stdout, { signal: abort.signal });
|
|
3322
|
+
else if (request.action === "import") await endpoint.importTree(request.path, process.stdin, {
|
|
3323
|
+
mkdir: request.mkdir,
|
|
3324
|
+
signal: abort.signal
|
|
3325
|
+
});
|
|
3326
|
+
else {
|
|
3327
|
+
let metadata = "";
|
|
3328
|
+
process.stdin.setEncoding("utf8");
|
|
3329
|
+
for await (const chunk of process.stdin) {
|
|
3330
|
+
metadata += String(chunk);
|
|
3331
|
+
if (Buffer.byteLength(metadata) > FILE_MAX_METADATA) throw new Error("file selection metadata limit exceeded");
|
|
3332
|
+
}
|
|
3333
|
+
process.stdout.write(JSON.stringify(await endpoint.digest(request.path, abort.signal, request.selected ? JSON.parse(metadata) : void 0)));
|
|
3334
|
+
}
|
|
3335
|
+
} catch (error) {
|
|
3336
|
+
await report({ error: error instanceof Error ? error.message : String(error) }).catch(() => {});
|
|
3337
|
+
process.exitCode = 1;
|
|
3338
|
+
} finally {
|
|
3339
|
+
process.removeListener("SIGTERM", cancel);
|
|
3340
|
+
process.removeListener("SIGHUP", cancel);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
|
|
1157
3344
|
//#endregion
|
|
1158
3345
|
//#region src/remote/plugin.ts
|
|
1159
3346
|
const name = "dashr-remote";
|
|
@@ -1169,15 +3356,7 @@ const Config = Schema.object({
|
|
|
1169
3356
|
});
|
|
1170
3357
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
1171
3358
|
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();
|
|
3359
|
+
return installAgentRemote(ctx, config);
|
|
1181
3360
|
};
|
|
1182
3361
|
var plugin_default = {
|
|
1183
3362
|
name,
|
|
@@ -1187,4 +3366,4 @@ var plugin_default = {
|
|
|
1187
3366
|
};
|
|
1188
3367
|
|
|
1189
3368
|
//#endregion
|
|
1190
|
-
export { Config, apply, plugin_default as default, inject, name };
|
|
3369
|
+
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 };
|