super-dsh 0.3.1 → 0.3.2-e
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/client/index.js +135 -14
- package/package.json +1 -1
- package/remote/helper-bin/MANIFEST.json +35 -0
- package/remote/helper-bin/aarch64-apple-darwin/BUILT-VER +1 -0
- package/remote/helper-bin/aarch64-apple-darwin/superd-run +0 -0
- package/remote/helper-bin/aarch64-unknown-linux-musl/BUILT-VER +1 -0
- package/remote/helper-bin/aarch64-unknown-linux-musl/superd-run +0 -0
- package/remote/helper-bin/x86_64-pc-windows-gnu/BUILT-VER +1 -0
- package/remote/helper-bin/x86_64-pc-windows-gnu/superd-run.exe +0 -0
- package/remote/helper-bin/x86_64-unknown-linux-musl/BUILT-VER +1 -0
- package/remote/helper-bin/x86_64-unknown-linux-musl/superd-run +0 -0
- package/remote/hub-pty-pump.mjs +97 -0
- package/remote/plugin.d.ts +523 -199
- package/remote/plugin.js +2530 -768
- package/remote/provenance.json +36 -21
- package/superd-hub/dist/a2a-host.d.ts +11 -3
- package/superd-hub/dist/a2a-host.js +7 -7
- package/superd-hub/dist/a2a-row.js +7 -1
- package/superd-hub/dist/carrier.d.ts +9 -7
- package/superd-hub/dist/carrier.js +3 -3
- package/superd-hub/dist/client/pill-geometry.d.ts +70 -0
- package/superd-hub/dist/client/pill-geometry.js +49 -0
- package/superd-hub/dist/client/switch.d.ts +15 -0
- package/superd-hub/dist/client/switch.js +54 -1
- package/superd-hub/dist/companions.js +8 -6
- package/superd-hub/dist/exec.d.ts +3 -2
- package/superd-hub/dist/exec.js +6 -5
- package/superd-hub/dist/fusion-transport.d.ts +18 -1
- package/superd-hub/dist/fusion-transport.js +44 -5
- package/superd-hub/dist/gateway.js +8 -1
- package/superd-hub/dist/hub-route.d.ts +5 -2
- package/superd-hub/dist/hub-route.js +6 -6
- package/superd-hub/dist/hub-tool.d.ts +1 -1
- package/superd-hub/dist/hub-tool.js +6 -6
- package/superd-hub/dist/remote-provider.js +70 -36
- package/superd-hub/dist/rpc.d.ts +5 -0
- package/superd-hub/dist/rpc.js +20 -1
- package/superd-hub/dist/switch-core.d.ts +41 -0
- package/superd-hub/dist/switch-core.js +40 -0
- package/superd-hub/dist/targets.d.ts +7 -0
- package/superd-hub/dist/world-lifecycle.js +1 -1
- package/superd-hub/lib/client/index.js +135 -14
- package/superd-hub/dist/exec-pty-channel.d.ts +0 -58
- package/superd-hub/dist/exec-pty-channel.js +0 -162
- package/superd-hub/dist/exec-pty-framing.d.ts +0 -34
- package/superd-hub/dist/exec-pty-framing.js +0 -89
- package/superd-hub/dist/exec-pty-session.d.ts +0 -78
- package/superd-hub/dist/exec-pty-session.js +0 -228
package/remote/plugin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import "node:module";
|
|
1
2
|
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
-
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { constants, createReadStream, createWriteStream, mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
4
5
|
import { pipeline } from "node:stream/promises";
|
|
5
6
|
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
6
7
|
import { Readable } from "node:stream";
|
|
@@ -8,6 +9,8 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
8
9
|
import { homedir, tmpdir } from "node:os";
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
10
11
|
import { createConnection, createServer } from "node:net";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
11
14
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
12
15
|
|
|
13
16
|
//#region third_party/schemastery/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
@@ -1209,17 +1212,18 @@ async function copyTree(request, source, destination, signal, scratchRoot = join
|
|
|
1209
1212
|
signal.throwIfAborted();
|
|
1210
1213
|
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");
|
|
1211
1214
|
await mkdir(scratchRoot, { recursive: true });
|
|
1212
|
-
signal.throwIfAborted();
|
|
1213
|
-
const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
|
|
1214
1215
|
const receipt = {
|
|
1215
1216
|
action: "copy",
|
|
1217
|
+
route: "hub-staged",
|
|
1216
1218
|
src: request.src,
|
|
1217
1219
|
dst: request.dst,
|
|
1218
|
-
|
|
1220
|
+
ok: false,
|
|
1221
|
+
verification: "not-applicable",
|
|
1219
1222
|
bytes: 0,
|
|
1220
1223
|
entries: 0,
|
|
1221
1224
|
scope: "transferred entries"
|
|
1222
1225
|
};
|
|
1226
|
+
const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
|
|
1223
1227
|
try {
|
|
1224
1228
|
await pipeline(await source.endpoint.exportTree(source.path, signal), createWriteStream(payload, {
|
|
1225
1229
|
flags: "wx",
|
|
@@ -1243,24 +1247,91 @@ async function copyTree(request, source, destination, signal, scratchRoot = join
|
|
|
1243
1247
|
const dest = await destination.endpoint.digest(destination.path, signal, selection);
|
|
1244
1248
|
receipt.destinationDigest = dest.sha256;
|
|
1245
1249
|
if (dest.sha256 !== receipt.sourceDigest) throw new Error("destination digest mismatch");
|
|
1246
|
-
receipt.
|
|
1250
|
+
receipt.verification = "sha256";
|
|
1247
1251
|
}
|
|
1248
1252
|
signal.throwIfAborted();
|
|
1249
1253
|
await rm(scratch, {
|
|
1250
1254
|
recursive: true,
|
|
1251
1255
|
force: true
|
|
1252
1256
|
});
|
|
1257
|
+
receipt.ok = true;
|
|
1253
1258
|
return receipt;
|
|
1254
1259
|
} catch (error) {
|
|
1260
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1261
|
+
const reason = signal.aborted ? "aborted" : /mismatch/i.test(message) ? "digest-mismatch" : "staging-error";
|
|
1255
1262
|
throw new CopyError({
|
|
1256
1263
|
...receipt,
|
|
1257
|
-
|
|
1264
|
+
ok: false,
|
|
1265
|
+
reason,
|
|
1258
1266
|
scratch,
|
|
1259
|
-
error:
|
|
1267
|
+
error: message
|
|
1260
1268
|
});
|
|
1261
1269
|
}
|
|
1262
1270
|
}
|
|
1263
1271
|
|
|
1272
|
+
//#endregion
|
|
1273
|
+
//#region src/remote/ssh-argv.ts
|
|
1274
|
+
/**
|
|
1275
|
+
* ONE identity-assembly policy for every SSH-speaking lane (user close-out
|
|
1276
|
+
* ruling 2026-10-17: "four builders necessarily mean four identity
|
|
1277
|
+
* semantics" — the structural root of F1).
|
|
1278
|
+
*
|
|
1279
|
+
* Assembly: explicit connection fields (byossh registrations; caller options
|
|
1280
|
+
* may override later by the CLI's own last-option-wins) in ONE order —
|
|
1281
|
+
* user, port, key, proxyJump — spoken in the target CLI's dialect.
|
|
1282
|
+
*
|
|
1283
|
+
* Resolution: config-side identity (aliases, config-only IdentityFile paths)
|
|
1284
|
+
* is read from the ORIGIN's own ssh config natively by every lane. The single
|
|
1285
|
+
* exception is the scp -3 temp config (ssh -F sees no user config), where
|
|
1286
|
+
* tempSshConfig inlines the `ssh -G`-resolved fields — paths only, never key
|
|
1287
|
+
* material (F1, 2026-10-14). That policy lives beside this builder in
|
|
1288
|
+
* native-copy.ts and must stay the only such inline site.
|
|
1289
|
+
*
|
|
1290
|
+
* Former duplicate builders now reduced here: sshArgv (execution/probes),
|
|
1291
|
+
* identityArgv (scp routes), rsyncRsh (RSYNC_RSH wiring), relaySsh
|
|
1292
|
+
* (dual-SSH relay). Behavior is byte-identical to each former site.
|
|
1293
|
+
*/
|
|
1294
|
+
/** Identity options ONLY for one connection, in the target CLI's dialect:
|
|
1295
|
+
* user, port, key, proxyJump — conditional, one order, NO base flags (each
|
|
1296
|
+
* lane's dialect base — BatchMode, -T, hardening — stays at its call site).
|
|
1297
|
+
* scp carries the user inside its user@host operand, so the scp dialect
|
|
1298
|
+
* never emits -l; its port flag is -P. */
|
|
1299
|
+
function sshIdentityArgv(connection, dialect) {
|
|
1300
|
+
const args = [];
|
|
1301
|
+
if (dialect === "ssh" && connection.user !== void 0) args.push("-l", connection.user);
|
|
1302
|
+
if (connection.port !== void 0) args.push(dialect === "scp" ? "-P" : "-p", String(connection.port));
|
|
1303
|
+
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
1304
|
+
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
1305
|
+
return args;
|
|
1306
|
+
}
|
|
1307
|
+
/** Full ssh invocation around the shared identity core. base and extra are
|
|
1308
|
+
* trusted internal argv (dialect flags incl. BatchMode, forwarding options)
|
|
1309
|
+
* — never a model-provided shell string. */
|
|
1310
|
+
/** SSH keepalive for PERSISTENT lanes (pty ruling 2026-10-19): client-owned,
|
|
1311
|
+
* fixed cadence — OpenSSH has NO protocol-default keepalive unit (default is
|
|
1312
|
+
* disabled), so we set our own in our own argv. Control-channel probes never
|
|
1313
|
+
* touch the PTY byte stream; NAT/WiFi idle expiry (~5min) would otherwise
|
|
1314
|
+
* silently kill an idle-but-wanted session inside the 15min TTL. CountMax=2
|
|
1315
|
+
* gives ~6min honest dead-detection instead of a hang on next write. */
|
|
1316
|
+
const SSH_PERSISTENT_KEEPALIVE = [
|
|
1317
|
+
"-o",
|
|
1318
|
+
"ServerAliveInterval=180",
|
|
1319
|
+
"-o",
|
|
1320
|
+
"ServerAliveCountMax=2"
|
|
1321
|
+
];
|
|
1322
|
+
function sshCommandArgv(connection, base, extra, command) {
|
|
1323
|
+
const args = [
|
|
1324
|
+
"ssh",
|
|
1325
|
+
...base,
|
|
1326
|
+
...sshIdentityArgv(connection, "ssh"),
|
|
1327
|
+
...extra,
|
|
1328
|
+
"--",
|
|
1329
|
+
connection.host
|
|
1330
|
+
];
|
|
1331
|
+
if (command !== void 0) args.push(command);
|
|
1332
|
+
return args;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1264
1335
|
//#endregion
|
|
1265
1336
|
//#region src/remote/output-ring.ts
|
|
1266
1337
|
const continuation = (byte) => (byte & 192) === 128;
|
|
@@ -1566,15 +1637,17 @@ function scpSpec(connection, path) {
|
|
|
1566
1637
|
const host = connection.host.includes(":") ? `[${connection.host}]` : connection.host;
|
|
1567
1638
|
return `${connection.user !== void 0 ? `${connection.user}@${host}` : host}:${path}`;
|
|
1568
1639
|
}
|
|
1569
|
-
/** Connection identity as scp options
|
|
1570
|
-
*
|
|
1571
|
-
* precedence (last option wins).
|
|
1640
|
+
/** Connection identity as scp options, placed BEFORE caller argv so an
|
|
1641
|
+
* explicit caller -F/-i/-P/-J overrides the registered identity by scp's own
|
|
1642
|
+
* precedence (last option wins). Assembly itself lives in ssh-argv.ts
|
|
1643
|
+
* (the ONE identity policy — 2026-10-17 ruling); only the scp dialect
|
|
1644
|
+
* placement rule is local. */
|
|
1572
1645
|
function identityArgv(connection) {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1646
|
+
return [
|
|
1647
|
+
"-o",
|
|
1648
|
+
"BatchMode=yes",
|
|
1649
|
+
...sshIdentityArgv(connection, "scp")
|
|
1650
|
+
];
|
|
1578
1651
|
}
|
|
1579
1652
|
function sshEndpoint(plan) {
|
|
1580
1653
|
if (plan.kind !== "ssh") throw new Error("remote: native copy endpoint is not an SSH target");
|
|
@@ -1613,24 +1686,30 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1613
1686
|
stdout: [],
|
|
1614
1687
|
stderr: [],
|
|
1615
1688
|
outputTruncated: false,
|
|
1616
|
-
|
|
1689
|
+
verification: "native-exit-only",
|
|
1690
|
+
ok: true,
|
|
1617
1691
|
cleanup: "none-required"
|
|
1618
1692
|
};
|
|
1619
|
-
const finish = (route, argvs, results) => {
|
|
1693
|
+
const finish = (route, argvs, results, internal = []) => {
|
|
1620
1694
|
receipt.route = route;
|
|
1621
|
-
receipt.argv = argvs;
|
|
1695
|
+
receipt.argv = argvs.map((argv) => argv.map((token) => internal.find(([secret]) => secret === token)?.[1] ?? token));
|
|
1622
1696
|
receipt.exits = results.map((result) => result.exit);
|
|
1623
1697
|
receipt.stdout = results.map((result) => result.stdout);
|
|
1624
1698
|
receipt.stderr = results.map((result) => result.stderr);
|
|
1625
1699
|
receipt.outputTruncated = results.some((result) => result.outputTruncated);
|
|
1700
|
+
receipt.ok = !signal.aborted && receipt.exits.every((exit) => exit === 0);
|
|
1701
|
+
if (!receipt.ok) receipt.reason = signal.aborted ? "aborted" : "non-zero-exit";
|
|
1626
1702
|
return receipt;
|
|
1627
1703
|
};
|
|
1704
|
+
if (request.mkdir !== false) await ensureDestinationDir(endpoints.dst, signal, options.execArgvFor, wrap, receipt);
|
|
1705
|
+
else receipt.created = "n-a";
|
|
1628
1706
|
if (s === "local" && t === "ssh") {
|
|
1629
1707
|
rejectRemoteToRemoteScp(oneShot);
|
|
1630
1708
|
const connection = sshEndpoint(endpoints.dst.plan).connection;
|
|
1631
1709
|
const argv = [
|
|
1632
1710
|
"scp",
|
|
1633
1711
|
...identityArgv(connection),
|
|
1712
|
+
...await recursiveFlag(endpoints.src.path, oneShot, "-r"),
|
|
1634
1713
|
...oneShot,
|
|
1635
1714
|
endpoints.src.path,
|
|
1636
1715
|
scpSpec(connection, endpoints.dst.path)
|
|
@@ -1663,7 +1742,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1663
1742
|
dstSpec = scpSpec(destination, endpoints.dst.path);
|
|
1664
1743
|
extra = identityArgv(source);
|
|
1665
1744
|
} else {
|
|
1666
|
-
config = await tempSshConfig(source, destination);
|
|
1745
|
+
config = await tempSshConfig(source, destination, runNative);
|
|
1667
1746
|
extra = ["-F", config.path];
|
|
1668
1747
|
srcSpec = `superd-scp3-source:${endpoints.src.path}`;
|
|
1669
1748
|
dstSpec = `superd-scp3-destination:${endpoints.dst.path}`;
|
|
@@ -1676,7 +1755,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1676
1755
|
srcSpec,
|
|
1677
1756
|
dstSpec
|
|
1678
1757
|
];
|
|
1679
|
-
return finish("scp
|
|
1758
|
+
return finish("scp-3", [argv], [await runNative(wrap(argv), signal)], config !== void 0 ? [[config.path, "<temp-ssh-config>"]] : []);
|
|
1680
1759
|
} catch (error) {
|
|
1681
1760
|
if (signal.aborted) unknown = true;
|
|
1682
1761
|
throw error;
|
|
@@ -1695,7 +1774,9 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1695
1774
|
endpoints.src.path,
|
|
1696
1775
|
`${containerOf(endpoints.dst.plan)}:${endpoints.dst.path}`
|
|
1697
1776
|
];
|
|
1698
|
-
|
|
1777
|
+
const result = await runNative(wrap(argv), signal);
|
|
1778
|
+
withSymlinkHint(result);
|
|
1779
|
+
return finish("docker-cp", [argv], [result]);
|
|
1699
1780
|
}
|
|
1700
1781
|
if (s === "docker" && t === "local") {
|
|
1701
1782
|
const argv = [
|
|
@@ -1705,18 +1786,21 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1705
1786
|
`${containerOf(endpoints.src.plan)}:${endpoints.src.path}`,
|
|
1706
1787
|
endpoints.dst.path
|
|
1707
1788
|
];
|
|
1708
|
-
|
|
1789
|
+
const result = await runNative(wrap(argv), signal);
|
|
1790
|
+
withSymlinkHint(result);
|
|
1791
|
+
return finish("docker-cp", [argv], [result]);
|
|
1709
1792
|
}
|
|
1710
1793
|
if (s === "local" && t === "incus") {
|
|
1711
1794
|
const argv = [
|
|
1712
1795
|
"incus",
|
|
1713
1796
|
"file",
|
|
1714
1797
|
"push",
|
|
1798
|
+
...await recursiveFlag(endpoints.src.path, oneShot, "-r"),
|
|
1715
1799
|
...oneShot,
|
|
1716
1800
|
endpoints.src.path,
|
|
1717
1801
|
`${containerOf(endpoints.dst.plan)}${endpoints.dst.path}`
|
|
1718
1802
|
];
|
|
1719
|
-
return finish("incus
|
|
1803
|
+
return finish("incus-file-push", [argv], [await runNative(wrap(argv), signal)]);
|
|
1720
1804
|
}
|
|
1721
1805
|
if (s === "incus" && t === "local") {
|
|
1722
1806
|
const argv = [
|
|
@@ -1727,7 +1811,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1727
1811
|
`${containerOf(endpoints.src.plan)}${endpoints.src.path}`,
|
|
1728
1812
|
endpoints.dst.path
|
|
1729
1813
|
];
|
|
1730
|
-
return finish("incus
|
|
1814
|
+
return finish("incus-file-pull", [argv], [await runNative(wrap(argv), signal)]);
|
|
1731
1815
|
}
|
|
1732
1816
|
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");
|
|
1733
1817
|
const pullOptions = request.pullArgv !== void 0 ? pull : oneShot;
|
|
@@ -1740,9 +1824,12 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1740
1824
|
const pullArgv = segmentArgv(endpoints.src.plan, endpoints.src.path, staged, pullOptions, "pull");
|
|
1741
1825
|
const pushArgv = segmentArgv(endpoints.dst.plan, endpoints.dst.path, staged, pushOptions, "push");
|
|
1742
1826
|
const first = await runNative(wrap(pullArgv), signal);
|
|
1743
|
-
if (first.exit !== 0)
|
|
1827
|
+
if (first.exit !== 0) {
|
|
1828
|
+
receipt.note = "push not started: pull failed";
|
|
1829
|
+
return finish("staged-pull-push", [pullArgv], [first], [[staged, "<staging>"]]);
|
|
1830
|
+
}
|
|
1744
1831
|
const second = await runNative(wrap(pushArgv), signal);
|
|
1745
|
-
return finish("
|
|
1832
|
+
return finish("staged-pull-push", [pullArgv, pushArgv], [first, second], [[staged, "<staging>"]]);
|
|
1746
1833
|
} finally {
|
|
1747
1834
|
receipt.cleanup = await rm(scratch, {
|
|
1748
1835
|
recursive: true,
|
|
@@ -1803,40 +1890,162 @@ function segmentArgv(plan, remote, staged, opts, leg) {
|
|
|
1803
1890
|
spec
|
|
1804
1891
|
];
|
|
1805
1892
|
}
|
|
1893
|
+
/** D3 mkdir pre-hop: ensure the destination's PARENT directory exists before
|
|
1894
|
+
* the transfer (never the operand itself — see parent-only note below).
|
|
1895
|
+
* transfer. Local dst is node-side; remote dst runs ONE exec `mkdir -p` probe
|
|
1896
|
+
* whose stdout discloses created-vs-existed. Best-effort by contract: any
|
|
1897
|
+
* failure records created:'n-a' + note and lets the copy proceed so the native
|
|
1898
|
+
* CLI surfaces the real error (and stopped-container docker cp keeps working). */
|
|
1899
|
+
async function ensureDestinationDir(dst, signal, execArgvFor, wrap, receipt) {
|
|
1900
|
+
const dir = dirname(dst.path);
|
|
1901
|
+
if (dir === "/" || dir === ".") {
|
|
1902
|
+
receipt.created = false;
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
if (dst.plan.kind === "local") {
|
|
1906
|
+
try {
|
|
1907
|
+
if (existsSync(dir)) {
|
|
1908
|
+
receipt.created = false;
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
mkdirSync(dir, { recursive: true });
|
|
1912
|
+
receipt.created = true;
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
receipt.created = "n-a";
|
|
1915
|
+
receipt.note = `mkdir pre-hop failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1916
|
+
}
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (execArgvFor === void 0) {
|
|
1920
|
+
receipt.created = "n-a";
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
if (/[\r\n]/.test(dir)) {
|
|
1924
|
+
receipt.created = "n-a";
|
|
1925
|
+
receipt.note = "mkdir pre-hop skipped: newline-bearing destination path";
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
const quoted = `'${dir.replaceAll("'", "'\\\\''")}'`;
|
|
1929
|
+
const script = `if [ -d ${quoted} ]; then echo existed; else mkdir -p ${quoted} && echo created; fi`;
|
|
1930
|
+
try {
|
|
1931
|
+
const result = await runNative(wrap(execArgvFor(dst.plan, script)), signal);
|
|
1932
|
+
const word = result.stdout.trim().split("\n").pop();
|
|
1933
|
+
if (result.exit === 0 && word === "created") receipt.created = true;
|
|
1934
|
+
else if (result.exit === 0 && word === "existed") receipt.created = false;
|
|
1935
|
+
else {
|
|
1936
|
+
receipt.created = "n-a";
|
|
1937
|
+
const tail = result.stderr.trim().split("\n").filter((line) => line.length > 0).pop();
|
|
1938
|
+
receipt.note = `mkdir pre-hop unavailable (exit ${result.exit})${tail !== void 0 ? `: ${tail}` : ""}`;
|
|
1939
|
+
}
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
receipt.created = "n-a";
|
|
1942
|
+
receipt.note = `mkdir pre-hop failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1806
1945
|
/** Distinguishing identities for scp -3: an origin-only temp config maps two
|
|
1807
1946
|
* synthetic aliases; keys are referenced in place, never copied to any remote. */
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1947
|
+
/** D8 (2026-10-14 user adoption, third acceptance round): a LOCAL directory source gets
|
|
1948
|
+
* the route's recursive flag appended automatically — scp / incus file push refuse
|
|
1949
|
+
* directories without it. The appended flag lands in the executed argv, which the
|
|
1950
|
+
* receipt shows verbatim: disclosure by receipt, never silent. Remote sources stay
|
|
1951
|
+
* caller-explicit (we do not dial just to stat); an explicit caller flag wins. */
|
|
1952
|
+
async function recursiveFlag(localPath, callerArgv, flag) {
|
|
1953
|
+
if (callerArgv.includes(flag) || callerArgv.includes("--recursive")) return [];
|
|
1954
|
+
try {
|
|
1955
|
+
if ((await stat(localPath)).isDirectory()) return [flag];
|
|
1956
|
+
} catch {}
|
|
1957
|
+
return [];
|
|
1958
|
+
}
|
|
1959
|
+
/** docker cp follows symlinks only with -L; its native error ("invalid symlink")
|
|
1960
|
+
* names no remedy. Append the one-line hint on that failure path (F5, 2026-10-14). */
|
|
1961
|
+
function withSymlinkHint(result) {
|
|
1962
|
+
if (result.exit !== 0 && /invalid symlink/i.test(result.stderr)) result.stderr = `${result.stderr}\nhint: docker cp follows symlinks only with -L — pass argv:["-L"] (e.g. /etc/os-release is a symlink)`;
|
|
1963
|
+
}
|
|
1964
|
+
async function tempSshConfig(source, destination, runNative$1) {
|
|
1965
|
+
const resolvedSource = await resolveThroughOriginConfig(source, runNative$1);
|
|
1966
|
+
const resolvedDestination = await resolveThroughOriginConfig(destination, runNative$1);
|
|
1967
|
+
for (const { connection: c, identityFiles } of [resolvedSource, resolvedDestination]) {
|
|
1968
|
+
for (const [field, value] of [
|
|
1969
|
+
["host", c.host],
|
|
1970
|
+
["user", c.user],
|
|
1971
|
+
["key", c.key],
|
|
1972
|
+
["proxyJump", c.proxyJump]
|
|
1973
|
+
]) if (value !== void 0 && /[\r\n]/.test(value)) throw new Error(`remote: scp -3 temp config rejects newline in SSH ${field} (no config injection)`);
|
|
1974
|
+
for (const idFile of identityFiles) if (/[\r\n]/.test(idFile)) throw new Error("remote: scp -3 temp config rejects newline in resolved IdentityFile (no config injection)");
|
|
1975
|
+
}
|
|
1815
1976
|
const dir = await mkdtemp(join(tmpdir(), "superd-scp3-"));
|
|
1816
1977
|
const path = join(dir, "config");
|
|
1817
|
-
const block = (name$1, c) => {
|
|
1978
|
+
const block = (name$1, c, identityFiles) => {
|
|
1818
1979
|
const lines = [`Host ${name$1}`, ` HostName ${c.host.replace(/^.*@/, "")}`];
|
|
1819
1980
|
if (c.user !== void 0) lines.push(` User ${c.user}`);
|
|
1820
1981
|
if (c.port !== void 0) lines.push(` Port ${String(c.port)}`);
|
|
1821
|
-
|
|
1982
|
+
for (const idFile of [c.key !== void 0 ? [c.key] : [], identityFiles].flat()) lines.push(` IdentityFile ${idFile}`);
|
|
1822
1983
|
if (c.proxyJump !== void 0) lines.push(` ProxyJump ${c.proxyJump}`);
|
|
1823
1984
|
return lines.join("\n");
|
|
1824
1985
|
};
|
|
1825
|
-
await writeFile(path, `${block("superd-scp3-source",
|
|
1986
|
+
await writeFile(path, `${block("superd-scp3-source", resolvedSource.connection, resolvedSource.identityFiles)}\n${block("superd-scp3-destination", resolvedDestination.connection, resolvedDestination.identityFiles)}\n`, { mode: 384 });
|
|
1826
1987
|
await chmod(path, 384);
|
|
1827
1988
|
return {
|
|
1828
1989
|
path,
|
|
1829
1990
|
dir
|
|
1830
1991
|
};
|
|
1831
1992
|
}
|
|
1993
|
+
/** Effective fields for one connection per the origin's own ssh config (ssh -G,
|
|
1994
|
+
* prints resolved settings without dialing). Explicit fields win; empty/none
|
|
1995
|
+
* proxyjump is dropped. IdentityFile PATHS from the config are returned separately
|
|
1996
|
+
* for inlining into the temp config — a path is not key material and stays on the
|
|
1997
|
+
* origin (F1, 2026-10-14: dev4's only authorized key lived in config, not the agent;
|
|
1998
|
+
* dropping it made scp -3 fail with a bare Permission denied). */
|
|
1999
|
+
async function resolveThroughOriginConfig(c, runNative$1) {
|
|
2000
|
+
let probe;
|
|
2001
|
+
try {
|
|
2002
|
+
probe = await runNative$1([
|
|
2003
|
+
"ssh",
|
|
2004
|
+
"-G",
|
|
2005
|
+
c.host
|
|
2006
|
+
], new AbortController().signal);
|
|
2007
|
+
} catch {
|
|
2008
|
+
probe = void 0;
|
|
2009
|
+
}
|
|
2010
|
+
if (probe === void 0 || probe.exit !== 0) return {
|
|
2011
|
+
connection: c,
|
|
2012
|
+
identityFiles: []
|
|
2013
|
+
};
|
|
2014
|
+
const lines = probe.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
2015
|
+
const pick$1 = (key) => lines.find((line) => line.startsWith(`${key} `))?.slice(key.length + 1).trim() || void 0;
|
|
2016
|
+
const home = process.env.HOME ?? "";
|
|
2017
|
+
const compiledDefault = (path) => path === "none" || [
|
|
2018
|
+
"id_rsa",
|
|
2019
|
+
"id_ecdsa",
|
|
2020
|
+
"id_ecdsa_sk",
|
|
2021
|
+
"id_ed25519",
|
|
2022
|
+
"id_ed25519_sk"
|
|
2023
|
+
].some((name$1) => path === `~/.ssh/${name$1}` || home !== "" && path === `${home}/.ssh/${name$1}`);
|
|
2024
|
+
const identityFiles = [...new Set(lines.filter((line) => line.startsWith("identityfile ")).map((line) => line.slice(13).trim()).filter((path) => path !== "" && !compiledDefault(path)))];
|
|
2025
|
+
const host = (pick$1("hostname") ?? c.host).replace(/^.*@/, "");
|
|
2026
|
+
const user = c.user ?? pick$1("user");
|
|
2027
|
+
const portText = pick$1("port");
|
|
2028
|
+
const port = c.port ?? (portText !== void 0 && /^\d+$/.test(portText) ? Number(portText) : void 0);
|
|
2029
|
+
const proxyJumpRaw = c.proxyJump ?? pick$1("proxyjump");
|
|
2030
|
+
return {
|
|
2031
|
+
connection: {
|
|
2032
|
+
host,
|
|
2033
|
+
...user !== void 0 && user !== "" ? { user } : {},
|
|
2034
|
+
...port !== void 0 && Number.isInteger(port) && port > 0 ? { port } : {},
|
|
2035
|
+
...c.key !== void 0 ? { key: c.key } : {},
|
|
2036
|
+
...proxyJumpRaw !== void 0 && proxyJumpRaw !== "" && proxyJumpRaw.toLowerCase() !== "none" ? { proxyJump: proxyJumpRaw } : {}
|
|
2037
|
+
},
|
|
2038
|
+
identityFiles
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
1832
2041
|
|
|
1833
2042
|
//#endregion
|
|
1834
2043
|
//#region src/remote/ssh-rsync-native-relay.ts
|
|
1835
2044
|
/** ssh argv for the relay: batch, no agent, no multiplexing. `command` is the
|
|
1836
|
-
* remote command; forwarding options go before `--`, the host after it.
|
|
2045
|
+
* remote command; forwarding options go before `--`, the host after it.
|
|
2046
|
+
* Identity core comes from ssh-argv.ts (the ONE policy — 2026-10-17 ruling). */
|
|
1837
2047
|
function relaySsh(connection, extra, command) {
|
|
1838
|
-
|
|
1839
|
-
"ssh",
|
|
2048
|
+
return sshCommandArgv(connection, [
|
|
1840
2049
|
"-o",
|
|
1841
2050
|
"BatchMode=yes",
|
|
1842
2051
|
"-T",
|
|
@@ -1853,14 +2062,7 @@ function relaySsh(connection, extra, command) {
|
|
|
1853
2062
|
"ServerAliveInterval=5",
|
|
1854
2063
|
"-o",
|
|
1855
2064
|
"ServerAliveCountMax=2"
|
|
1856
|
-
];
|
|
1857
|
-
if (connection.user !== void 0) args.push("-l", connection.user);
|
|
1858
|
-
if (connection.port !== void 0) args.push("-p", String(connection.port));
|
|
1859
|
-
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
1860
|
-
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
1861
|
-
args.push(...extra, "--", connection.host);
|
|
1862
|
-
if (command !== void 0) args.push(command);
|
|
1863
|
-
return args;
|
|
2065
|
+
], extra, command);
|
|
1864
2066
|
}
|
|
1865
2067
|
async function freePorts(count) {
|
|
1866
2068
|
const servers = [];
|
|
@@ -2015,12 +2217,16 @@ async function forwardRoute(argv, source, destination, signal, options) {
|
|
|
2015
2217
|
return await removeRemoteTemp(destination.connection, temp, wrap) ? "released" : "unknown";
|
|
2016
2218
|
};
|
|
2017
2219
|
try {
|
|
2220
|
+
signal.throwIfAborted();
|
|
2221
|
+
const daemonState = await waitForDaemonReady(destinationChild, 5e3);
|
|
2222
|
+
if (daemonState === "exited") throw new Error(`dual-SSH rsync destination daemon exited before serving${relayDetail(destinationChild, sourceHold)}`);
|
|
2223
|
+
if (daemonState === "timeout") throw new Error(`dual-SSH rsync destination daemon never announced readiness${relayDetail(destinationChild, sourceHold)}`);
|
|
2018
2224
|
signal.throwIfAborted();
|
|
2019
2225
|
if (!await originPortOpen(originPort, 15e3)) throw new Error(`dual-SSH rsync destination daemon never became reachable${relayDetail(destinationChild, sourceHold)}`);
|
|
2020
2226
|
signal.throwIfAborted();
|
|
2021
2227
|
const runArgv = relaySsh(source.connection, [], rsyncClientCommand(argv, source.path, sourcePort));
|
|
2022
2228
|
const result = await runNative(wrap(runArgv), signal);
|
|
2023
|
-
if (result.exit
|
|
2229
|
+
if (result.exit !== 0) result.stderr = (result.stderr + relayDetail(destinationChild, sourceHold)).slice(-OUTPUT_LIMIT);
|
|
2024
2230
|
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)}`);
|
|
2025
2231
|
return {
|
|
2026
2232
|
...result,
|
|
@@ -2045,6 +2251,19 @@ async function forwardRoute(argv, source, destination, signal, options) {
|
|
|
2045
2251
|
await Promise.allSettled([destinationChild.stop(), sourceHold.stop()]);
|
|
2046
2252
|
}
|
|
2047
2253
|
}
|
|
2254
|
+
/** Wait until the destination daemon script announces itself (stderr marker,
|
|
2255
|
+
* printed after its directory check and right before the daemon exec) or exits.
|
|
2256
|
+
* 'ready' only when the daemon is actually about to serve — closes the 0.3.1-e
|
|
2257
|
+
* race where -L opens at session start but the script dies ~150ms later. */
|
|
2258
|
+
async function waitForDaemonReady(held, budgetMs) {
|
|
2259
|
+
const deadline = Date.now() + budgetMs;
|
|
2260
|
+
for (;;) {
|
|
2261
|
+
if (/^SUPERD-RSYNC-TMP \S+$/m.test(held.stderrSink.text)) return "ready";
|
|
2262
|
+
if (held.child.exitCode !== null) return "exited";
|
|
2263
|
+
if (Date.now() >= deadline) return "timeout";
|
|
2264
|
+
await new Promise((resolve$1) => setTimeout(resolve$1, 50));
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2048
2267
|
function relayDetail(...held) {
|
|
2049
2268
|
const detail = held.map((child) => child.stderrSink.text).filter(Boolean).join("\n");
|
|
2050
2269
|
return detail ? `\n[relay stderr]\n${detail}` : "";
|
|
@@ -2126,14 +2345,7 @@ async function runRsync(request, source, destination, signal, options = {}) {
|
|
|
2126
2345
|
checkAbsolute(destination.path);
|
|
2127
2346
|
const argv = checkArgv("argv", request.argv);
|
|
2128
2347
|
if ("pullArgv" in request || "pushArgv" in request) throw new Error("remote: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
2129
|
-
for (const
|
|
2130
|
-
"dryRun",
|
|
2131
|
-
"delete",
|
|
2132
|
-
"excludes",
|
|
2133
|
-
"preserve",
|
|
2134
|
-
"verify",
|
|
2135
|
-
"mkdir"
|
|
2136
|
-
]) 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)`);
|
|
2348
|
+
for (const key of Object.keys(request)) if (key !== "src" && key !== "dst" && key !== "argv") throw new Error(`remote: rsync rejects unknown field "${key}" — native options go through argv (verify is a copy Hub-lane option; mkdir is copy-only; for destination parents pass --mkpath yourself)`);
|
|
2137
2349
|
if (!source.connection && !destination.connection) throw new Error("rsync requires at least one SSH endpoint; local-only is unsupported");
|
|
2138
2350
|
let result;
|
|
2139
2351
|
let route;
|
|
@@ -2181,6 +2393,9 @@ async function runRsync(request, source, destination, signal, options = {}) {
|
|
|
2181
2393
|
dst: request.dst,
|
|
2182
2394
|
argv,
|
|
2183
2395
|
route,
|
|
2396
|
+
verification: "native-exit-only",
|
|
2397
|
+
ok: result.exit === 0,
|
|
2398
|
+
...result.exit === 0 ? {} : { reason: signal.aborted ? "aborted" : "non-zero-exit" },
|
|
2184
2399
|
command: `rsync ${argv.join(" ")}`,
|
|
2185
2400
|
...itemized.length > 0 || argv.includes("--itemize-changes") || argv.includes("-i") ? { itemizedChanges: itemized } : {},
|
|
2186
2401
|
...literalBytes === void 0 ? {} : { literalBytes },
|
|
@@ -2195,21 +2410,14 @@ async function runRsync(request, source, destination, signal, options = {}) {
|
|
|
2195
2410
|
* placeholder host rsync appends and execs the canonical SSH argv. All tokens
|
|
2196
2411
|
* are tool-built argv (identity options/aliases), never model text. */
|
|
2197
2412
|
function rsyncRsh(connection, wrap) {
|
|
2198
|
-
const args = [
|
|
2199
|
-
"ssh",
|
|
2200
|
-
"-o",
|
|
2201
|
-
"BatchMode=yes",
|
|
2202
|
-
"-T"
|
|
2203
|
-
];
|
|
2204
|
-
if (connection.user !== void 0) args.push("-l", connection.user);
|
|
2205
|
-
if (connection.port !== void 0) args.push("-p", String(connection.port));
|
|
2206
|
-
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
2207
|
-
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
2208
|
-
args.push("--", connection.host);
|
|
2209
2413
|
return [
|
|
2210
2414
|
"bash",
|
|
2211
2415
|
"-c",
|
|
2212
|
-
`shift; exec ${wrap(
|
|
2416
|
+
`shift; exec ${wrap(sshCommandArgv(connection, [
|
|
2417
|
+
"-o",
|
|
2418
|
+
"BatchMode=yes",
|
|
2419
|
+
"-T"
|
|
2420
|
+
], [])).map(rshQuote).join(" ")} "$@"`,
|
|
2213
2421
|
"rsync-rsh"
|
|
2214
2422
|
].map(rshQuote).join(" ");
|
|
2215
2423
|
}
|
|
@@ -2228,7 +2436,7 @@ function parseFileEndpoint(value) {
|
|
|
2228
2436
|
if (split < 1) throw new Error("file endpoint must be [target:]absolute/path");
|
|
2229
2437
|
const target = value.slice(0, split), path = value.slice(split + 1);
|
|
2230
2438
|
checkAbsolute(path);
|
|
2231
|
-
if (target.startsWith("
|
|
2439
|
+
if (target.startsWith("pty:")) throw new Error("a PTY session is not a filesystem endpoint; select SSH/Docker/Incus/Hub");
|
|
2232
2440
|
return {
|
|
2233
2441
|
target,
|
|
2234
2442
|
path
|
|
@@ -2237,13 +2445,9 @@ function parseFileEndpoint(value) {
|
|
|
2237
2445
|
|
|
2238
2446
|
//#endregion
|
|
2239
2447
|
//#region src/remote/ssh-targets.ts
|
|
2240
|
-
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2241
2448
|
const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
2242
2449
|
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:]+)$/;
|
|
2243
2450
|
const JUMP = /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9_][A-Za-z0-9_.-]*|\[[A-Fa-f0-9:.]+\])(?::([0-9]+))?$/;
|
|
2244
|
-
function validateSshLabel(label) {
|
|
2245
|
-
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");
|
|
2246
|
-
}
|
|
2247
2451
|
function bad(field) {
|
|
2248
2452
|
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`);
|
|
2249
2453
|
}
|
|
@@ -2283,48 +2487,20 @@ function normalizeSshConnection(input) {
|
|
|
2283
2487
|
...input.proxyJump === void 0 ? {} : { proxyJump: input.proxyJump }
|
|
2284
2488
|
});
|
|
2285
2489
|
}
|
|
2286
|
-
/** One instance per Agent-owned driver; destroyed with its Agent/plugin owners. */
|
|
2287
|
-
var SshTargets = class {
|
|
2288
|
-
targets = /* @__PURE__ */ new Map();
|
|
2289
|
-
register(label, connection) {
|
|
2290
|
-
validateSshLabel(label);
|
|
2291
|
-
const normalized = normalizeSshConnection(connection), existing = this.targets.get(label);
|
|
2292
|
-
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`);
|
|
2293
|
-
if (!existing) this.targets.set(label, normalized);
|
|
2294
|
-
return existing ?? normalized;
|
|
2295
|
-
}
|
|
2296
|
-
get(label) {
|
|
2297
|
-
validateSshLabel(label);
|
|
2298
|
-
return this.targets.get(label);
|
|
2299
|
-
}
|
|
2300
|
-
entries() {
|
|
2301
|
-
return [...this.targets.entries()];
|
|
2302
|
-
}
|
|
2303
|
-
clear() {
|
|
2304
|
-
this.targets.clear();
|
|
2305
|
-
}
|
|
2306
|
-
};
|
|
2307
2490
|
/** Shared by execution, probes and file/relay consumers. extraArgs is trusted
|
|
2308
|
-
* internal SSH argv (e.g. forwarding), never a model-provided shell string.
|
|
2491
|
+
* internal SSH argv (e.g. forwarding), never a model-provided shell string.
|
|
2492
|
+
* Identity assembly lives in ssh-argv.ts (the ONE policy — 2026-10-17 ruling);
|
|
2493
|
+
* this wrapper only picks the tty/batch dialect base. */
|
|
2309
2494
|
function sshArgv(connection, options = {}) {
|
|
2310
|
-
|
|
2311
|
-
"ssh",
|
|
2495
|
+
return sshCommandArgv(connection, options.tty ? [
|
|
2312
2496
|
"-tt",
|
|
2313
2497
|
"-o",
|
|
2314
2498
|
"BatchMode=yes"
|
|
2315
2499
|
] : [
|
|
2316
|
-
"ssh",
|
|
2317
2500
|
"-o",
|
|
2318
2501
|
"BatchMode=yes",
|
|
2319
2502
|
"-T"
|
|
2320
|
-
];
|
|
2321
|
-
if (connection.user !== void 0) args.push("-l", connection.user);
|
|
2322
|
-
if (connection.port !== void 0) args.push("-p", String(connection.port));
|
|
2323
|
-
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
2324
|
-
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
2325
|
-
args.push(...options.extraArgs ?? [], "--", connection.host);
|
|
2326
|
-
if (options.command !== void 0) args.push(options.command);
|
|
2327
|
-
return args;
|
|
2503
|
+
], options.extraArgs ?? [], options.command);
|
|
2328
2504
|
}
|
|
2329
2505
|
|
|
2330
2506
|
//#endregion
|
|
@@ -2332,17 +2508,17 @@ function sshArgv(connection, options = {}) {
|
|
|
2332
2508
|
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
2333
2509
|
async function prepareRemoteInput(input) {
|
|
2334
2510
|
if (input.mode !== void 0 && input.mode !== "oneshot" && input.mode !== "pty") throw new Error("[E_BAD_MODE] remote: mode must be oneshot or pty");
|
|
2335
|
-
if (input.
|
|
2336
|
-
if (input.cmd !== void 0 || input.stdin !== void 0 || input.mode === "pty" || input.spawn !== void 0
|
|
2337
|
-
if (typeof input.
|
|
2511
|
+
if (input.scriptPath !== void 0) {
|
|
2512
|
+
if (input.cmd !== void 0 || input.stdin !== void 0 || input.mode === "pty" || input.spawn !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: script is oneshot-only and cannot be combined with cmd, stdin, spawn or PTY lanes");
|
|
2513
|
+
if (typeof input.scriptPath !== "string" || !isAbsolute(input.scriptPath) || input.scriptPath.includes("\0")) throw new Error("[E_SCRIPT_PATH] remote: script must be a local absolute path without NUL");
|
|
2338
2514
|
}
|
|
2339
|
-
if (!input.target) throw new Error("[E_PARAMS] remote: target is required
|
|
2340
|
-
if (input.
|
|
2515
|
+
if (!input.target && input.spawn === void 0 && input.ssh === void 0) throw new Error("[E_PARAMS] remote: target is required (spawn / ssh are BYO entry points and may stand alone)");
|
|
2516
|
+
if (input.scriptPath !== void 0) {
|
|
2341
2517
|
let stdin;
|
|
2342
2518
|
try {
|
|
2343
|
-
stdin = await readFile(input.
|
|
2519
|
+
stdin = await readFile(input.scriptPath, "utf8");
|
|
2344
2520
|
} catch (error) {
|
|
2345
|
-
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.
|
|
2521
|
+
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
2346
2522
|
}
|
|
2347
2523
|
return {
|
|
2348
2524
|
target: input.target,
|
|
@@ -2381,6 +2557,96 @@ async function runOneShot(argv, opts = {}) {
|
|
|
2381
2557
|
};
|
|
2382
2558
|
}
|
|
2383
2559
|
|
|
2560
|
+
//#endregion
|
|
2561
|
+
//#region src/remote/helper.ts
|
|
2562
|
+
/** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in the npm
|
|
2563
|
+
* package, super-dsh/remote in the Super closure — same relative layout). Missing or
|
|
2564
|
+
* malformed assets ⇒ null ⇒ shim-only mode; never throws. */
|
|
2565
|
+
function helperAssets(baseDir) {
|
|
2566
|
+
const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
|
|
2567
|
+
try {
|
|
2568
|
+
const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
|
|
2569
|
+
if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
|
|
2570
|
+
return {
|
|
2571
|
+
dir,
|
|
2572
|
+
manifest
|
|
2573
|
+
};
|
|
2574
|
+
} catch {
|
|
2575
|
+
return null;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
/** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
|
|
2579
|
+
function archTriple(sys, machine, shipped) {
|
|
2580
|
+
const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
|
|
2581
|
+
const candidates = s === "linux" ? m === "x86_64" || m === "amd64" ? "x86_64-unknown-linux-musl" : m === "aarch64" || m === "arm64" ? "aarch64-unknown-linux-musl" : null : s === "darwin" ? m === "arm64" || m === "aarch64" ? "aarch64-apple-darwin" : null : null;
|
|
2582
|
+
if (candidates === null) return null;
|
|
2583
|
+
return shipped.has(candidates) ? candidates : null;
|
|
2584
|
+
}
|
|
2585
|
+
function planKey(plan) {
|
|
2586
|
+
switch (plan.kind) {
|
|
2587
|
+
case "ssh": {
|
|
2588
|
+
const c = plan.connection;
|
|
2589
|
+
const host = c?.host ?? plan.host;
|
|
2590
|
+
return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
|
|
2591
|
+
}
|
|
2592
|
+
case "docker": return `docker|${plan.container}`;
|
|
2593
|
+
case "incus": return `incus|${plan.container}`;
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
|
|
2597
|
+
const READY_RE = /^SUPERD-READY (\S+)$/m;
|
|
2598
|
+
var HelperRegistry = class {
|
|
2599
|
+
states = /* @__PURE__ */ new Map();
|
|
2600
|
+
constructor(assets, exec, probeArgv, uploadArgv) {
|
|
2601
|
+
this.assets = assets;
|
|
2602
|
+
this.exec = exec;
|
|
2603
|
+
this.probeArgv = probeArgv;
|
|
2604
|
+
this.uploadArgv = uploadArgv;
|
|
2605
|
+
}
|
|
2606
|
+
/** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
|
|
2607
|
+
ensure(plan) {
|
|
2608
|
+
if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
|
|
2609
|
+
const key = planKey(plan);
|
|
2610
|
+
let entry = this.states.get(key);
|
|
2611
|
+
if (entry === void 0) {
|
|
2612
|
+
entry = this.bootstrap(plan).catch(() => null);
|
|
2613
|
+
entry.catch(() => {});
|
|
2614
|
+
this.states.set(key, entry);
|
|
2615
|
+
}
|
|
2616
|
+
return entry;
|
|
2617
|
+
}
|
|
2618
|
+
async bootstrap(plan) {
|
|
2619
|
+
const { manifest } = this.assets;
|
|
2620
|
+
const first = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
2621
|
+
const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
|
|
2622
|
+
if (ready !== null) return ready;
|
|
2623
|
+
const probe = PROBE_RE.exec(first.stdout);
|
|
2624
|
+
if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
|
|
2625
|
+
const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
|
|
2626
|
+
if (triple === null) return null;
|
|
2627
|
+
const target = manifest.targets[triple];
|
|
2628
|
+
const binary = await readFile(join(this.assets.dir, triple, target.file));
|
|
2629
|
+
if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets), {
|
|
2630
|
+
stdin: binary,
|
|
2631
|
+
timeoutSec: 60
|
|
2632
|
+
})).exit !== 0) return null;
|
|
2633
|
+
const second = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
2634
|
+
return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
|
|
2635
|
+
}
|
|
2636
|
+
parseReady(stdout, ver, protocol) {
|
|
2637
|
+
const ready = READY_RE.exec(stdout);
|
|
2638
|
+
const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
|
|
2639
|
+
if (ready === null || banner === null) return null;
|
|
2640
|
+
if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
|
|
2641
|
+
return {
|
|
2642
|
+
binPath: ready[1],
|
|
2643
|
+
triple: banner[3],
|
|
2644
|
+
ver,
|
|
2645
|
+
protocol
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
};
|
|
2649
|
+
|
|
2384
2650
|
//#endregion
|
|
2385
2651
|
//#region src/remote/transports.ts
|
|
2386
2652
|
/** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
|
|
@@ -2428,12 +2694,15 @@ bash -lc ${shQuote(cmd)}`;
|
|
|
2428
2694
|
];
|
|
2429
2695
|
}
|
|
2430
2696
|
}
|
|
2431
|
-
/** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。
|
|
2697
|
+
/** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。
|
|
2698
|
+
* ssh 基座常驻固定 keepalive(2026-10-19 裁决):客户端自有、零对端依赖,控制消息
|
|
2699
|
+
* 不触碰 PTY 字节流;oneshot 短命连接不带。 */
|
|
2432
2700
|
function buildPtyArgv(plan) {
|
|
2433
2701
|
switch (plan.kind) {
|
|
2434
2702
|
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2435
2703
|
tty: true,
|
|
2436
|
-
command: "bash"
|
|
2704
|
+
command: "bash",
|
|
2705
|
+
extraArgs: SSH_PERSISTENT_KEEPALIVE
|
|
2437
2706
|
});
|
|
2438
2707
|
case "docker": return [
|
|
2439
2708
|
"script",
|
|
@@ -2460,6 +2729,121 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
2460
2729
|
"/dev/null"
|
|
2461
2730
|
];
|
|
2462
2731
|
}
|
|
2732
|
+
/** Hub PTY(0.3.2-c;0.3.2-d F4 修正):会话子进程 = 本地 `script` PTY 托管的 WS 字节泵
|
|
2733
|
+
* (hub-pty-pump.mjs)。与 docker exec 的嵌套 PTY 同形,但泵是哑 node 子进程、不像
|
|
2734
|
+
* `ssh -tt` / `docker exec -it` 那样作为前台子进程自己把本地 tty 置 raw——所以本地包装必须
|
|
2735
|
+
* `stty raw`:canonical+ISIG 下 0x03 在本地变 SIGINT 杀泵、0x04 变 EOF 结束泵 stdin
|
|
2736
|
+
* (F4:input ^C/^D 与 job_kill 会打死整条会话)。raw 只作用于本地半段;对端 script PTY
|
|
2737
|
+
* 保持 canonical,^C/^D 在对端产生 SIGINT/EOF 正是我们要的语义(字节原样过线,解释权在
|
|
2738
|
+
* 对端)。kill 链 session 组杀 → 泵死 → WS 关 → 对端组杀,双向自然闭合。连接事实走 wrapper
|
|
2739
|
+
* 内 env 赋值(cookie 不进 argv、不落 ps)。 */
|
|
2740
|
+
function buildHubPtyArgv(endpoint, assets) {
|
|
2741
|
+
const node = assets.nodePath ?? process.execPath;
|
|
2742
|
+
return [
|
|
2743
|
+
"script",
|
|
2744
|
+
"-qfec",
|
|
2745
|
+
`stty raw -echo 2>/dev/null ; ${`export SUPERD_HUB_PTY_URL=${shQuote(endpoint.url)} SUPERD_HUB_PTY_COOKIE=${shQuote(endpoint.cookie)} SUPERD_HUB_PTY_WS=${shQuote(endpoint.wsPath)}`} ; exec ${shQuote(node)} ${shQuote(assets.pumpPath)}`,
|
|
2746
|
+
"/dev/null"
|
|
2747
|
+
];
|
|
2748
|
+
}
|
|
2749
|
+
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
2750
|
+
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
2751
|
+
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
2752
|
+
function buildHelperProbeArgv(plan, assets) {
|
|
2753
|
+
const { ver, targets } = assets.manifest;
|
|
2754
|
+
return helperShellArgv(plan, `${Object.entries(targets).map(([triple, t]) => `d="$HOME/.cache/superd/run/${triple}-${ver}"; [ -x "$d/${t.file}" ] && { echo "SUPERD-READY $d/${t.file}"; "$d/${t.file}" banner; exit 0; };`).join(" ")} echo "SUPERD-PROBE $(uname -s 2>/dev/null || echo unknown) $(uname -m 2>/dev/null || echo unknown)"; exit 90`);
|
|
2755
|
+
}
|
|
2756
|
+
function buildHelperUploadArgv(plan, triple, file, assets) {
|
|
2757
|
+
const dir = `$HOME/.cache/superd/run/${triple}-${assets.manifest.ver}`;
|
|
2758
|
+
return helperShellArgv(plan, `mkdir -p "${dir}" && cat > "${dir}/${file}.tmp" && chmod 0755 "${dir}/${file}.tmp" && mv -f "${dir}/${file}.tmp" "${dir}/${file}"`);
|
|
2759
|
+
}
|
|
2760
|
+
function buildHelperOneShotArgv(plan, binPath, options) {
|
|
2761
|
+
const { tag, cmd } = options;
|
|
2762
|
+
switch (plan.kind) {
|
|
2763
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `exec ${shQuote(binPath)} run --tag ${shQuote(tag)} -- bash -lc ${shQuote(cmd)}` });
|
|
2764
|
+
case "docker": return [
|
|
2765
|
+
"docker",
|
|
2766
|
+
"exec",
|
|
2767
|
+
"-i",
|
|
2768
|
+
"--",
|
|
2769
|
+
plan.container,
|
|
2770
|
+
binPath,
|
|
2771
|
+
"run",
|
|
2772
|
+
"--tag",
|
|
2773
|
+
tag,
|
|
2774
|
+
"--",
|
|
2775
|
+
"bash",
|
|
2776
|
+
"-lc",
|
|
2777
|
+
cmd
|
|
2778
|
+
];
|
|
2779
|
+
case "incus": return [
|
|
2780
|
+
"incus",
|
|
2781
|
+
"exec",
|
|
2782
|
+
"--force-noninteractive",
|
|
2783
|
+
plan.container,
|
|
2784
|
+
"--",
|
|
2785
|
+
binPath,
|
|
2786
|
+
"run",
|
|
2787
|
+
"--tag",
|
|
2788
|
+
tag,
|
|
2789
|
+
"--",
|
|
2790
|
+
"bash",
|
|
2791
|
+
"-lc",
|
|
2792
|
+
cmd
|
|
2793
|
+
];
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
2797
|
+
switch (plan.kind) {
|
|
2798
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `${shQuote(binPath)} killgroup --tag ${shQuote(tag)}` });
|
|
2799
|
+
case "docker": return [
|
|
2800
|
+
"docker",
|
|
2801
|
+
"exec",
|
|
2802
|
+
"--",
|
|
2803
|
+
plan.container,
|
|
2804
|
+
binPath,
|
|
2805
|
+
"killgroup",
|
|
2806
|
+
"--tag",
|
|
2807
|
+
tag
|
|
2808
|
+
];
|
|
2809
|
+
case "incus": return [
|
|
2810
|
+
"incus",
|
|
2811
|
+
"exec",
|
|
2812
|
+
"--force-noninteractive",
|
|
2813
|
+
plan.container,
|
|
2814
|
+
"--",
|
|
2815
|
+
binPath,
|
|
2816
|
+
"killgroup",
|
|
2817
|
+
"--tag",
|
|
2818
|
+
tag
|
|
2819
|
+
];
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
function helperShellArgv(plan, script) {
|
|
2823
|
+
switch (plan.kind) {
|
|
2824
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: script });
|
|
2825
|
+
case "docker": return [
|
|
2826
|
+
"docker",
|
|
2827
|
+
"exec",
|
|
2828
|
+
"-i",
|
|
2829
|
+
"--",
|
|
2830
|
+
plan.container,
|
|
2831
|
+
"sh",
|
|
2832
|
+
"-c",
|
|
2833
|
+
script
|
|
2834
|
+
];
|
|
2835
|
+
case "incus": return [
|
|
2836
|
+
"incus",
|
|
2837
|
+
"exec",
|
|
2838
|
+
"--force-noninteractive",
|
|
2839
|
+
plan.container,
|
|
2840
|
+
"--",
|
|
2841
|
+
"sh",
|
|
2842
|
+
"-c",
|
|
2843
|
+
script
|
|
2844
|
+
];
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2463
2847
|
|
|
2464
2848
|
//#endregion
|
|
2465
2849
|
//#region src/remote/nonce-framing.ts
|
|
@@ -2469,11 +2853,11 @@ function genNonce(bytes = 8) {
|
|
|
2469
2853
|
}
|
|
2470
2854
|
/** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
|
|
2471
2855
|
function wrapPtyCommand(cmd, nonce) {
|
|
2472
|
-
return `{\n${cmd}\n} ; printf '\\033]133;D;${nonce};%s;%s\\007' "
|
|
2856
|
+
return `{\n${cmd}\n} ; __superd_e=$? ; printf '\\033]133;E;${nonce};%s\\007' "$(env -0 | base64 | tr -d '\\n\\r')" ; printf '\\033]133;D;${nonce};%s;%s\\007' "$__superd_e" "$PWD" ; unset __superd_e\n`;
|
|
2473
2857
|
}
|
|
2474
2858
|
/** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
|
|
2475
2859
|
function buildInitCommand(nonce) {
|
|
2476
|
-
return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
|
|
2860
|
+
return `stty -echo 2>/dev/null ; stty rows 40 cols 120 2>/dev/null ; PS1='' ; PS2='' ; export TERM=dumb ; printf '\\033]133;E;${nonce};%s\\007' "$(env -0 | base64 | tr -d '\\n\\r')" ; printf '\\033]133;D;${nonce};0;%s\\007' "$PWD"\n`;
|
|
2477
2861
|
}
|
|
2478
2862
|
const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
|
|
2479
2863
|
function stripAnsi(s) {
|
|
@@ -2482,26 +2866,30 @@ function stripAnsi(s) {
|
|
|
2482
2866
|
function normalizePtyText(s) {
|
|
2483
2867
|
return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
2484
2868
|
}
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2869
|
+
/** 0.3.2-e:自有 30K 返回窗口已删(原生 agent runtime 的 spill/输出保护接管模型上下文
|
|
2870
|
+
* 尺寸)。本 helper 只做 retention 披露:ring 留存(4M UTF-16 / 4MiB bytes)未驱逐时
|
|
2871
|
+
* 全量透传(returned=retained,无 banner);驱逐(dropped>0,仅 >4M 输出发生)时打一行
|
|
2872
|
+
* banner 且 truncated=原始总量。text 已是 ring 尾部,不再二次裁剪。 */
|
|
2873
|
+
function disclose(text, stats) {
|
|
2874
|
+
if (stats === void 0) return { text };
|
|
2875
|
+
const returned = stats.retained;
|
|
2876
|
+
const updated = { stats: {
|
|
2491
2877
|
...stats,
|
|
2492
2878
|
returned
|
|
2493
2879
|
} };
|
|
2494
|
-
if (
|
|
2495
|
-
text
|
|
2880
|
+
if (stats.dropped <= 0) return {
|
|
2881
|
+
text,
|
|
2496
2882
|
...updated
|
|
2497
2883
|
};
|
|
2884
|
+
const unit = stats.unit === "bytes" ? "bytes" : "chars";
|
|
2498
2885
|
return {
|
|
2499
|
-
text: `[
|
|
2500
|
-
truncated: total,
|
|
2886
|
+
text: `[retention tail: showing last ${stats.retained} of ${stats.total} ${unit}; dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}]\n${text}`,
|
|
2887
|
+
truncated: stats.total,
|
|
2501
2888
|
...updated
|
|
2502
2889
|
};
|
|
2503
2890
|
}
|
|
2504
2891
|
const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
2892
|
+
const ENV_BODY_RE = /^([A-Za-z0-9+/=]*)\x07/;
|
|
2505
2893
|
/**
|
|
2506
2894
|
* 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
|
|
2507
2895
|
* 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
|
|
@@ -2509,31 +2897,60 @@ const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
|
2509
2897
|
*/
|
|
2510
2898
|
function createNonceFrameParser(nonce, events) {
|
|
2511
2899
|
const prefix = `\x1b]133;D;${nonce};`;
|
|
2900
|
+
const prefixE = `\x1b]133;E;${nonce};`;
|
|
2901
|
+
const prefixes = [prefix, prefixE];
|
|
2512
2902
|
let buf = "";
|
|
2513
2903
|
let done = false;
|
|
2514
2904
|
const emit = (raw) => {
|
|
2515
2905
|
const text = normalizePtyText(raw);
|
|
2516
2906
|
if (text.length > 0) events.onOutput?.(text);
|
|
2517
2907
|
};
|
|
2908
|
+
/** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
|
|
2518
2909
|
const holdback = () => {
|
|
2519
|
-
|
|
2520
|
-
for (
|
|
2521
|
-
|
|
2910
|
+
let best = 0;
|
|
2911
|
+
for (const p of prefixes) {
|
|
2912
|
+
const max = Math.min(buf.length, p.length - 1);
|
|
2913
|
+
for (let k = max; k > best; k--) if (buf.endsWith(p.slice(0, k))) {
|
|
2914
|
+
best = k;
|
|
2915
|
+
break;
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
return best;
|
|
2522
2919
|
};
|
|
2920
|
+
/** 扫描最早出现的帧:E(env 侧信道)先于 D 下发;D = settle,帧后字节丢弃。 */
|
|
2523
2921
|
const consume = () => {
|
|
2524
2922
|
if (done) return;
|
|
2525
|
-
|
|
2526
|
-
|
|
2923
|
+
let idx = -1;
|
|
2924
|
+
let isEnv = false;
|
|
2925
|
+
for (const p of prefixes) {
|
|
2926
|
+
const i = buf.indexOf(p);
|
|
2927
|
+
if (i >= 0 && (idx === -1 || i < idx)) {
|
|
2928
|
+
idx = i;
|
|
2929
|
+
isEnv = p === prefixE;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
if (idx < 0) {
|
|
2527
2933
|
const keep = holdback();
|
|
2528
2934
|
emit(buf.slice(0, buf.length - keep));
|
|
2529
2935
|
buf = buf.slice(buf.length - keep);
|
|
2530
2936
|
return;
|
|
2531
2937
|
}
|
|
2532
|
-
emit(buf.slice(0,
|
|
2533
|
-
const rest = buf.slice(
|
|
2938
|
+
emit(buf.slice(0, idx));
|
|
2939
|
+
const rest = buf.slice(idx + (isEnv ? prefixE.length : prefix.length));
|
|
2940
|
+
if (isEnv) {
|
|
2941
|
+
const m$1 = ENV_BODY_RE.exec(rest);
|
|
2942
|
+
if (m$1 === null) {
|
|
2943
|
+
buf = buf.slice(idx);
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
if (m$1[1].length > 0) events.onEnv?.(m$1[1]);
|
|
2947
|
+
buf = rest.slice(m$1[0].length);
|
|
2948
|
+
consume();
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2534
2951
|
const m = FRAME_BODY_RE.exec(rest);
|
|
2535
2952
|
if (m === null) {
|
|
2536
|
-
buf = buf.slice(
|
|
2953
|
+
buf = buf.slice(idx);
|
|
2537
2954
|
return;
|
|
2538
2955
|
}
|
|
2539
2956
|
events.onFrame({
|
|
@@ -2554,33 +2971,90 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2554
2971
|
}
|
|
2555
2972
|
};
|
|
2556
2973
|
}
|
|
2974
|
+
/** E 帧 payload → name→value-hash(值不离开端点;hash 只为 changed 判定)。空/坏 payload → undefined。 */
|
|
2975
|
+
function decodeEnvSnapshot(b64) {
|
|
2976
|
+
if (b64.length === 0) return void 0;
|
|
2977
|
+
let text;
|
|
2978
|
+
try {
|
|
2979
|
+
text = Buffer.from(b64, "base64").toString("utf8");
|
|
2980
|
+
} catch {
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
const map = /* @__PURE__ */ new Map();
|
|
2984
|
+
for (const entry of text.split("\0")) {
|
|
2985
|
+
if (entry.length === 0) continue;
|
|
2986
|
+
const eq = entry.indexOf("=");
|
|
2987
|
+
const name$1 = eq === -1 ? entry : entry.slice(0, eq);
|
|
2988
|
+
if (name$1.startsWith("__superd")) continue;
|
|
2989
|
+
let h = 5381;
|
|
2990
|
+
const value = eq === -1 ? "" : entry.slice(eq + 1);
|
|
2991
|
+
for (let i = 0; i < value.length; i++) h = (h * 33 ^ value.charCodeAt(i)) >>> 0;
|
|
2992
|
+
map.set(name$1, h);
|
|
2993
|
+
}
|
|
2994
|
+
return map.size > 0 ? map : void 0;
|
|
2995
|
+
}
|
|
2996
|
+
/** prev 未知(init 侧信道失败)时返回 undefined——不报告假 delta。 */
|
|
2997
|
+
function diffEnv(prev, next) {
|
|
2998
|
+
if (prev === void 0) return void 0;
|
|
2999
|
+
const added = [], changed = [], removed = [];
|
|
3000
|
+
for (const [name$1, h] of next) if (!prev.has(name$1)) added.push(name$1);
|
|
3001
|
+
else if (prev.get(name$1) !== h) changed.push(name$1);
|
|
3002
|
+
for (const name$1 of prev.keys()) if (!next.has(name$1)) removed.push(name$1);
|
|
3003
|
+
added.sort();
|
|
3004
|
+
changed.sort();
|
|
3005
|
+
removed.sort();
|
|
3006
|
+
return {
|
|
3007
|
+
added,
|
|
3008
|
+
changed,
|
|
3009
|
+
removed
|
|
3010
|
+
};
|
|
3011
|
+
}
|
|
2557
3012
|
|
|
2558
3013
|
//#endregion
|
|
2559
3014
|
//#region src/remote/pty-session.ts
|
|
2560
3015
|
/** spec §五.2 逐字。 */
|
|
2561
3016
|
const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
|
|
2562
|
-
/** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`
|
|
2563
|
-
*
|
|
2564
|
-
*
|
|
3017
|
+
/** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`p:` → `pty:<id>`)。
|
|
3018
|
+
* 这是**唯一**的 key→名字映射规则,roster 与错误面共用——错误面不得回吐内部键:
|
|
3019
|
+
* 2026-10-03 实测报告 F1,`E_SESSION_*` 曾把 `session-<uuid>|p:<label>` 整条吐给模型。 */
|
|
2565
3020
|
function displayKey(key) {
|
|
2566
3021
|
const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
|
|
2567
|
-
return raw.startsWith("
|
|
3022
|
+
return raw.startsWith("p:") ? `pty:${raw.slice(2)}` : raw;
|
|
2568
3023
|
}
|
|
3024
|
+
const LEDGER_MAX = 20;
|
|
3025
|
+
const summaryOf = (cmd) => {
|
|
3026
|
+
const first = cmd.trim().split(/\r?\n/, 1)[0] ?? "";
|
|
3027
|
+
return first.length > 72 ? `${first.slice(0, 71)}…` : first;
|
|
3028
|
+
};
|
|
2569
3029
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
2570
3030
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
3031
|
+
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
3032
|
+
* 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
|
|
3033
|
+
const MARKER_SETTLE_MS = 80;
|
|
2571
3034
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
2572
3035
|
const OUTPUT_HARD_CAP = 4e6;
|
|
2573
3036
|
var PtySession = class {
|
|
2574
3037
|
proc = null;
|
|
2575
3038
|
state = "cold";
|
|
2576
3039
|
startPromise = null;
|
|
2577
|
-
queue = Promise.resolve();
|
|
2578
3040
|
idleTimer = null;
|
|
2579
3041
|
onChunk = null;
|
|
2580
3042
|
deathWaiters = [];
|
|
2581
3043
|
diag = "";
|
|
2582
3044
|
inFlight = 0;
|
|
2583
|
-
|
|
3045
|
+
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
3046
|
+
detachedJob = null;
|
|
3047
|
+
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
3048
|
+
activeCommand = null;
|
|
3049
|
+
/** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
|
|
3050
|
+
ledger = [];
|
|
3051
|
+
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
3052
|
+
envBaseline;
|
|
3053
|
+
/** idle 起点(ready / settle 时重置);null = 尚无 idle 时钟(cold 或已 dispose)。 */
|
|
3054
|
+
idleSince = null;
|
|
3055
|
+
/** 意外死亡后的回收定时器(R2):ready 过的会话先留池供 reconnect,超时仍无人寻址则出池。 */
|
|
3056
|
+
deathTimer = null;
|
|
3057
|
+
lastCwdSeen = null;
|
|
2584
3058
|
/** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
|
|
2585
3059
|
everReady = false;
|
|
2586
3060
|
disposal;
|
|
@@ -2592,7 +3066,39 @@ var PtySession = class {
|
|
|
2592
3066
|
get sessionState() {
|
|
2593
3067
|
return this.state;
|
|
2594
3068
|
}
|
|
2595
|
-
/**
|
|
3069
|
+
/** 池键(driver 的 ttl/inspect 用)。 */
|
|
3070
|
+
get key() {
|
|
3071
|
+
return this.opts.key;
|
|
3072
|
+
}
|
|
3073
|
+
/** 最近帧 cwd(card 面);从未执行过 = null。 */
|
|
3074
|
+
get lastCwd() {
|
|
3075
|
+
return this.lastCwdSeen;
|
|
3076
|
+
}
|
|
3077
|
+
/** 占用前台的 detached job 的 commandId(无 = null;close 拒绝与 busy 文案共用)。 */
|
|
3078
|
+
get detachedCommandId() {
|
|
3079
|
+
return this.detachedJob?.commandId ?? null;
|
|
3080
|
+
}
|
|
3081
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者);运行中的命令同步携带(input 回执用)。 */
|
|
3082
|
+
attachJob(commandId, jobId) {
|
|
3083
|
+
const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
|
|
3084
|
+
if (entry !== void 0) entry.jobId = jobId;
|
|
3085
|
+
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
3086
|
+
}
|
|
3087
|
+
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
3088
|
+
get identity() {
|
|
3089
|
+
return {
|
|
3090
|
+
id: this.opts.id,
|
|
3091
|
+
...this.opts.label !== void 0 ? { label: this.opts.label } : {},
|
|
3092
|
+
connection: this.opts.connection,
|
|
3093
|
+
byo: this.opts.byo === true,
|
|
3094
|
+
builtOn: this.opts.builtOn === true
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
/** 账本快照(session card 面,只读)。 */
|
|
3098
|
+
ledgerSnapshot() {
|
|
3099
|
+
return this.ledger;
|
|
3100
|
+
}
|
|
3101
|
+
/** spawn 创建原文(仅 BYO 会话;v2 起仅存档,不参与复用判定)。 */
|
|
2596
3102
|
get origin() {
|
|
2597
3103
|
return this.opts.origin;
|
|
2598
3104
|
}
|
|
@@ -2601,93 +3107,226 @@ var PtySession = class {
|
|
|
2601
3107
|
return {
|
|
2602
3108
|
state: this.state,
|
|
2603
3109
|
busy: this.inFlight > 0,
|
|
2604
|
-
idleMs: this.
|
|
3110
|
+
idleMs: this.idleSince === null ? null : Date.now() - this.idleSince,
|
|
2605
3111
|
pid: this.proc?.pid ?? null
|
|
2606
3112
|
};
|
|
2607
3113
|
}
|
|
2608
|
-
/**
|
|
2609
|
-
|
|
3114
|
+
/** 预热创建(创建形无 cmd):真正拨号到 init 帧——「会话已就绪」必须是真的,
|
|
3115
|
+
* 而且只有 ready 过才挂 idle 回收(cold 会话没有 timer,会永远占配额)。 */
|
|
3116
|
+
async warm() {
|
|
3117
|
+
await this.start();
|
|
3118
|
+
}
|
|
3119
|
+
/** 同一会话命令占位制(2026-10-25 interactive-stdin 轮):dispatch 入口**同步**建立
|
|
3120
|
+
* 命令占位(activeCommand + inFlight + 账本条目)——上一条命令仍在运行(同步等待 /
|
|
3121
|
+
* 等输入 / detached job)时新 cmd 立即 E_SESSION_BUSY;原「纯同步并发排 FIFO」语义
|
|
3122
|
+
* 废除(排队会让等输入的命令与补输入动作互相卡死,也给 busy 留下绕行窗口)。
|
|
3123
|
+
* P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
|
|
3124
|
+
* interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
|
|
3125
|
+
dispatch(cmd, dOpts) {
|
|
3126
|
+
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
3127
|
+
if (this.activeCommand !== null) {
|
|
3128
|
+
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
3129
|
+
return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a command (${this.activeCommand.commandId}) — send it more stdin with the input action, follow its output via job_output ${handle} (a synchronous exec hands its job off there on timeout), cancel with job_kill ${handle}, or address other work to another session / oneshot`));
|
|
3130
|
+
}
|
|
3131
|
+
const commandId = dOpts.commandId;
|
|
3132
|
+
const entry = {
|
|
3133
|
+
commandId,
|
|
3134
|
+
summary: summaryOf(cmd),
|
|
3135
|
+
exit: null,
|
|
3136
|
+
durationMs: 0,
|
|
3137
|
+
running: true,
|
|
3138
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3139
|
+
};
|
|
3140
|
+
this.ledger.push(entry);
|
|
3141
|
+
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
3142
|
+
this.activeCommand = {
|
|
3143
|
+
commandId,
|
|
3144
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
3145
|
+
};
|
|
3146
|
+
this.inFlight++;
|
|
2610
3147
|
const attempt = async () => {
|
|
2611
3148
|
const started = Date.now();
|
|
2612
3149
|
const reconnected = this.state === "dead";
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
3150
|
+
/** 占位撤销:命令从未真正启动(建连失败 / 建连中被 dispose)——释放 busy 与计数,
|
|
3151
|
+
* 撤掉账本条目(与旧时序等价:条目原本在 start 成功之后才建立)。 */
|
|
3152
|
+
const abandon = () => {
|
|
3153
|
+
this.activeCommand = null;
|
|
3154
|
+
this.inFlight--;
|
|
3155
|
+
const index = this.ledger.indexOf(entry);
|
|
3156
|
+
if (index >= 0) this.ledger.splice(index, 1);
|
|
3157
|
+
};
|
|
3158
|
+
if (this.state !== "ready") try {
|
|
3159
|
+
await this.start();
|
|
3160
|
+
} catch (error) {
|
|
3161
|
+
abandon();
|
|
3162
|
+
throw error;
|
|
3163
|
+
}
|
|
3164
|
+
if (this.state === "disposed") {
|
|
3165
|
+
abandon();
|
|
3166
|
+
throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
3167
|
+
}
|
|
2616
3168
|
return await new Promise((resolve$1) => {
|
|
2617
3169
|
const nonce = genNonce();
|
|
2618
3170
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
2619
3171
|
let interrupted = false;
|
|
2620
3172
|
let settled = false;
|
|
3173
|
+
let jobSettled = false;
|
|
2621
3174
|
let interruptTimer;
|
|
2622
3175
|
let killTimer;
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
3176
|
+
let markerTimer;
|
|
3177
|
+
let jobDone = null;
|
|
3178
|
+
let envNext;
|
|
3179
|
+
const detach = () => {
|
|
3180
|
+
const job = {
|
|
3181
|
+
commandId,
|
|
3182
|
+
read: (fromByte) => ring.read(fromByte),
|
|
3183
|
+
stats: () => ring.stats(),
|
|
3184
|
+
interrupt: () => {
|
|
3185
|
+
if (!jobSettled) twoStage();
|
|
3186
|
+
},
|
|
3187
|
+
done: new Promise((res) => {
|
|
3188
|
+
jobDone = res;
|
|
3189
|
+
})
|
|
3190
|
+
};
|
|
3191
|
+
this.detachedJob = job;
|
|
3192
|
+
settled = true;
|
|
3193
|
+
resolve$1({
|
|
3194
|
+
output: ring.text,
|
|
3195
|
+
outputStats: { stdout: ring.stats() },
|
|
3196
|
+
exit: null,
|
|
3197
|
+
cwd: null,
|
|
3198
|
+
timedOut: false,
|
|
3199
|
+
running: true,
|
|
3200
|
+
job,
|
|
3201
|
+
commandId,
|
|
3202
|
+
reconnected,
|
|
3203
|
+
durationMs: Date.now() - started
|
|
3204
|
+
});
|
|
3205
|
+
return job;
|
|
3206
|
+
};
|
|
2629
3207
|
const cleanup = () => {
|
|
2630
3208
|
this.onChunk = null;
|
|
3209
|
+
this.detachedJob = null;
|
|
3210
|
+
this.activeCommand = null;
|
|
2631
3211
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
2632
3212
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
2633
3213
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
3214
|
+
if (markerTimer !== void 0) clearTimeout(markerTimer);
|
|
2634
3215
|
};
|
|
2635
|
-
const
|
|
2636
|
-
if (
|
|
2637
|
-
|
|
3216
|
+
const settle = (r) => {
|
|
3217
|
+
if (jobSettled) return;
|
|
3218
|
+
jobSettled = true;
|
|
2638
3219
|
cleanup();
|
|
2639
3220
|
this.inFlight--;
|
|
2640
|
-
this.
|
|
3221
|
+
this.idleSince = Date.now();
|
|
2641
3222
|
this.resetIdleTimer();
|
|
2642
|
-
|
|
3223
|
+
if (r.cwd !== null) this.lastCwdSeen = r.cwd;
|
|
3224
|
+
const envDelta = envNext !== void 0 ? diffEnv(this.envBaseline, envNext) : void 0;
|
|
3225
|
+
if (envNext !== void 0) this.envBaseline = envNext;
|
|
3226
|
+
entry.exit = r.exit;
|
|
3227
|
+
entry.durationMs = Date.now() - started;
|
|
3228
|
+
entry.running = false;
|
|
3229
|
+
const full = {
|
|
2643
3230
|
...r,
|
|
2644
3231
|
outputStats: { stdout: ring.stats() },
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
}
|
|
3232
|
+
commandId,
|
|
3233
|
+
...envDelta !== void 0 ? { envDelta } : {}
|
|
3234
|
+
};
|
|
3235
|
+
jobDone?.(full);
|
|
3236
|
+
if (!settled) {
|
|
3237
|
+
settled = true;
|
|
3238
|
+
resolve$1({
|
|
3239
|
+
...full,
|
|
3240
|
+
reconnected,
|
|
3241
|
+
durationMs: Date.now() - started
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
2648
3244
|
};
|
|
3245
|
+
const deathWaiter = () => settle({
|
|
3246
|
+
output: ring.text,
|
|
3247
|
+
exit: null,
|
|
3248
|
+
cwd: null,
|
|
3249
|
+
timedOut: interrupted
|
|
3250
|
+
});
|
|
2649
3251
|
const parser = createNonceFrameParser(nonce, {
|
|
2650
3252
|
onOutput: (t) => {
|
|
2651
3253
|
ring.push(t);
|
|
2652
3254
|
},
|
|
2653
|
-
|
|
3255
|
+
onEnv: (b64) => {
|
|
3256
|
+
const m = decodeEnvSnapshot(b64);
|
|
3257
|
+
if (m !== void 0) envNext = m;
|
|
3258
|
+
},
|
|
3259
|
+
onFrame: ({ exit, cwd }) => settle({
|
|
2654
3260
|
output: ring.text,
|
|
2655
3261
|
exit,
|
|
2656
3262
|
cwd,
|
|
2657
3263
|
timedOut: interrupted
|
|
2658
3264
|
})
|
|
2659
3265
|
});
|
|
2660
|
-
|
|
2661
|
-
this.write(wrapPtyCommand(cmd, nonce));
|
|
2662
|
-
if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
|
|
2663
|
-
interruptTimer = setTimeout(() => {
|
|
3266
|
+
const twoStage = () => {
|
|
2664
3267
|
interrupted = true;
|
|
2665
3268
|
this.write("");
|
|
2666
|
-
|
|
3269
|
+
markerTimer = setTimeout(() => {
|
|
3270
|
+
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
3271
|
+
}, MARKER_SETTLE_MS);
|
|
2667
3272
|
killTimer = setTimeout(() => {
|
|
2668
3273
|
this.killTree();
|
|
2669
3274
|
this.markDead();
|
|
2670
|
-
|
|
3275
|
+
settle({
|
|
2671
3276
|
output: ring.text,
|
|
2672
3277
|
exit: null,
|
|
2673
3278
|
cwd: null,
|
|
2674
3279
|
timedOut: true
|
|
2675
3280
|
});
|
|
2676
3281
|
}, INTERRUPT_GRACE_MS);
|
|
2677
|
-
}
|
|
3282
|
+
};
|
|
3283
|
+
this.onChunk = (c) => parser.feed(c);
|
|
3284
|
+
this.write(wrapPtyCommand(cmd, nonce));
|
|
3285
|
+
if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
|
|
2678
3286
|
this.deathWaiters.push(deathWaiter);
|
|
3287
|
+
if (dOpts.background === true) {
|
|
3288
|
+
detach();
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
2679
3292
|
});
|
|
2680
3293
|
};
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
3294
|
+
return attempt();
|
|
3295
|
+
}
|
|
3296
|
+
/** §3.1(2026-10-25)interactive-stdin:向当前运行中的命令追加 stdin——只写既有通道
|
|
3297
|
+
* 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
|
|
3298
|
+
* 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
|
|
3299
|
+
* write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
|
|
3300
|
+
input(options) {
|
|
3301
|
+
const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
|
|
3302
|
+
if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
|
|
3303
|
+
if (this.state === "dead") return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' died — input never reconnects or replays; start a new command (exec reconnects a fresh shell) and re-send only what is still needed`);
|
|
3304
|
+
if (this.state === "starting") return fail("[E_SESSION_NOT_READY]", `pty session '${displayKey(this.opts.key)}' is still initializing — wait for the exec receipt, then input`);
|
|
3305
|
+
const active = this.activeCommand;
|
|
3306
|
+
if (active === null) return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' has no running command (state ${this.state}) — input writes into a command started by exec; it is never an implicit shell command`);
|
|
3307
|
+
if (options.commandId !== void 0 && options.commandId !== active.commandId) return fail("[E_COMMAND_MISMATCH]", `commandId ${JSON.stringify(options.commandId)} does not match the running command ${active.commandId} — input aimed at an earlier prompt is never written to the next command`);
|
|
3308
|
+
const stdin = this.proc?.stdin;
|
|
3309
|
+
if (stdin?.writable !== true) return fail("[E_SESSION_WRITE_FAILED]", `pty session '${displayKey(this.opts.key)}' has no writable stdin channel (state ${this.state}) — follow the running command via job_output for its settled result`);
|
|
3310
|
+
try {
|
|
3311
|
+
stdin.write(options.stdin);
|
|
3312
|
+
} catch (error) {
|
|
3313
|
+
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3314
|
+
}
|
|
3315
|
+
return Promise.resolve({
|
|
3316
|
+
session: displayKey(this.opts.key),
|
|
3317
|
+
commandId: active.commandId,
|
|
3318
|
+
...active.jobId !== void 0 ? { jobId: active.jobId } : {},
|
|
3319
|
+
writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
|
|
3320
|
+
});
|
|
2684
3321
|
}
|
|
2685
3322
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
2686
3323
|
dispose() {
|
|
2687
3324
|
if (this.disposal) return this.disposal;
|
|
2688
3325
|
this.clearIdleTimer();
|
|
3326
|
+
this.clearDeathTimer();
|
|
2689
3327
|
this.state = "disposed";
|
|
2690
3328
|
this.startPromise = null;
|
|
3329
|
+
this.detachedJob?.interrupt();
|
|
2691
3330
|
const proc = this.proc;
|
|
2692
3331
|
const close = this.procClose;
|
|
2693
3332
|
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
@@ -2698,7 +3337,7 @@ var PtySession = class {
|
|
|
2698
3337
|
]).then(() => {
|
|
2699
3338
|
if (this.proc === proc) this.proc = null;
|
|
2700
3339
|
});
|
|
2701
|
-
this.write("exit\n");
|
|
3340
|
+
if (this.detachedJob === null) this.write("exit\n");
|
|
2702
3341
|
this.opts.onDead?.();
|
|
2703
3342
|
return this.disposal;
|
|
2704
3343
|
}
|
|
@@ -2710,6 +3349,7 @@ var PtySession = class {
|
|
|
2710
3349
|
}
|
|
2711
3350
|
async start() {
|
|
2712
3351
|
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' was disposed — the pool replaces disposed sessions; this reference is stale`);
|
|
3352
|
+
this.clearDeathTimer();
|
|
2713
3353
|
if (this.startPromise === null) {
|
|
2714
3354
|
this.state = "starting";
|
|
2715
3355
|
this.startPromise = this.doStart().catch((err) => {
|
|
@@ -2738,6 +3378,9 @@ var PtySession = class {
|
|
|
2738
3378
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
2739
3379
|
this.diag = (this.diag + d).slice(-2e3);
|
|
2740
3380
|
});
|
|
3381
|
+
proc.stdin.on("error", (err) => {
|
|
3382
|
+
this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
|
|
3383
|
+
});
|
|
2741
3384
|
this.procClose = new Promise((resolve$1) => {
|
|
2742
3385
|
proc.once("close", () => {
|
|
2743
3386
|
this.markDead();
|
|
@@ -2759,18 +3402,24 @@ var PtySession = class {
|
|
|
2759
3402
|
reject(/* @__PURE__ */ new Error(`[E_SESSION_DIED] remote pty session '${displayKey(this.opts.key)}' died before initializing` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
|
|
2760
3403
|
};
|
|
2761
3404
|
this.deathWaiters.push(waiter);
|
|
2762
|
-
const parser = createNonceFrameParser(initNonce, {
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3405
|
+
const parser = createNonceFrameParser(initNonce, {
|
|
3406
|
+
onEnv: (b64) => {
|
|
3407
|
+
const m = decodeEnvSnapshot(b64);
|
|
3408
|
+
if (m !== void 0) this.envBaseline = m;
|
|
3409
|
+
},
|
|
3410
|
+
onFrame: () => {
|
|
3411
|
+
clearTimeout(timer);
|
|
3412
|
+
this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
|
|
3413
|
+
if (this.state === "disposed") {
|
|
3414
|
+
reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' disposed during init`));
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
this.everReady = true;
|
|
3418
|
+
this.state = "ready";
|
|
3419
|
+
this.resetIdleTimer();
|
|
3420
|
+
resolve$1();
|
|
2768
3421
|
}
|
|
2769
|
-
|
|
2770
|
-
this.state = "ready";
|
|
2771
|
-
this.resetIdleTimer();
|
|
2772
|
-
resolve$1();
|
|
2773
|
-
} });
|
|
3422
|
+
});
|
|
2774
3423
|
this.onChunk = (c) => parser.feed(c);
|
|
2775
3424
|
this.write(buildInitCommand(initNonce));
|
|
2776
3425
|
});
|
|
@@ -2785,6 +3434,7 @@ var PtySession = class {
|
|
|
2785
3434
|
this.state = "dead";
|
|
2786
3435
|
this.startPromise = null;
|
|
2787
3436
|
this.clearIdleTimer();
|
|
3437
|
+
this.armDeathReaper();
|
|
2788
3438
|
const waiters = this.deathWaiters;
|
|
2789
3439
|
this.deathWaiters = [];
|
|
2790
3440
|
for (const w of waiters) w();
|
|
@@ -2792,7 +3442,14 @@ var PtySession = class {
|
|
|
2792
3442
|
resetIdleTimer() {
|
|
2793
3443
|
this.clearIdleTimer();
|
|
2794
3444
|
if (this.state === "disposed") return;
|
|
2795
|
-
this.
|
|
3445
|
+
this.idleSince = Date.now();
|
|
3446
|
+
this.idleTimer = setTimeout(() => {
|
|
3447
|
+
if (this.inFlight > 0) {
|
|
3448
|
+
this.resetIdleTimer();
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
this.dispose();
|
|
3452
|
+
}, this.opts.idleTtlSec * 1e3);
|
|
2796
3453
|
this.idleTimer.unref();
|
|
2797
3454
|
}
|
|
2798
3455
|
clearIdleTimer() {
|
|
@@ -2801,6 +3458,22 @@ var PtySession = class {
|
|
|
2801
3458
|
this.idleTimer = null;
|
|
2802
3459
|
}
|
|
2803
3460
|
}
|
|
3461
|
+
/** 意外死亡后的回收:留池只换取一次 reconnect 机会,不得超过一段 idle TTL(R2 复核 P0)。
|
|
3462
|
+
* 否则死会话永久占 label + 永久计入 10 配额,Agent 再也开不出新会话。 */
|
|
3463
|
+
armDeathReaper() {
|
|
3464
|
+
this.clearDeathTimer();
|
|
3465
|
+
if (!this.everReady || this.state !== "dead") return;
|
|
3466
|
+
this.deathTimer = setTimeout(() => {
|
|
3467
|
+
this.dispose();
|
|
3468
|
+
}, this.opts.idleTtlSec * 1e3);
|
|
3469
|
+
this.deathTimer.unref();
|
|
3470
|
+
}
|
|
3471
|
+
clearDeathTimer() {
|
|
3472
|
+
if (this.deathTimer !== null) {
|
|
3473
|
+
clearTimeout(this.deathTimer);
|
|
3474
|
+
this.deathTimer = null;
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
2804
3477
|
killTree() {
|
|
2805
3478
|
const pid = this.proc?.pid;
|
|
2806
3479
|
this.proc = null;
|
|
@@ -2890,19 +3563,20 @@ var PtyPool = class {
|
|
|
2890
3563
|
const s = this.sessions.get(key);
|
|
2891
3564
|
return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
|
|
2892
3565
|
}
|
|
2893
|
-
|
|
3566
|
+
/** v2:创建一律新建(「同 label 同来源复用」废除——复用只走 get/get by id);disposed 残留由这里替换。 */
|
|
3567
|
+
create(opts) {
|
|
2894
3568
|
if (this.disposed) throw new Error("[E_REMOTE_DISPOSED] remote: the owning Agent or plugin PTY pool was disposed");
|
|
2895
|
-
|
|
2896
|
-
if (
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
onDead
|
|
2903
|
-
}
|
|
2904
|
-
|
|
2905
|
-
|
|
3569
|
+
const existing = this.sessions.get(opts.key);
|
|
3570
|
+
if (existing !== void 0 && existing.sessionState !== "disposed") throw new Error(`[E_INTERNAL] remote: pty pool key collision for ${opts.id}`);
|
|
3571
|
+
const s = new PtySession({
|
|
3572
|
+
...opts,
|
|
3573
|
+
...this.defaults,
|
|
3574
|
+
onDead: () => {
|
|
3575
|
+
this.sessions.delete(opts.key);
|
|
3576
|
+
opts.onDead?.();
|
|
3577
|
+
}
|
|
3578
|
+
});
|
|
3579
|
+
this.sessions.set(opts.key, s);
|
|
2906
3580
|
return s;
|
|
2907
3581
|
}
|
|
2908
3582
|
disposeAll() {
|
|
@@ -2945,9 +3619,205 @@ function resolveTarget(target) {
|
|
|
2945
3619
|
host: raw
|
|
2946
3620
|
};
|
|
2947
3621
|
}
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
3622
|
+
const TRANSPORTS = [
|
|
3623
|
+
"ssh",
|
|
3624
|
+
"docker",
|
|
3625
|
+
"incus"
|
|
3626
|
+
];
|
|
3627
|
+
function parseTargetForm(target) {
|
|
3628
|
+
if (target === void 0 || target.length === 0) return { form: "empty" };
|
|
3629
|
+
if (target === "pty") return { form: "pty-selector" };
|
|
3630
|
+
if (target.startsWith("pty:")) {
|
|
3631
|
+
const rest = target.slice(4);
|
|
3632
|
+
if (rest.length === 0 || rest.includes(":")) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — the pty selector takes exactly one id or label ("pty:p-1a2b", "pty:build")`);
|
|
3633
|
+
return {
|
|
3634
|
+
form: "pty-selector",
|
|
3635
|
+
idOrLabel: rest
|
|
3636
|
+
};
|
|
3637
|
+
}
|
|
3638
|
+
if (target === "ssh") return { form: "ssh-prefix" };
|
|
3639
|
+
if (target === "docker" || target === "incus" || target === "hub") throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} is a transport prefix — pass ${target === "hub" ? "hub:<machine>" : `${target}:<name>`}`);
|
|
3640
|
+
if (target.startsWith("hub:")) {
|
|
3641
|
+
const segs = target.slice(4).split(":");
|
|
3642
|
+
if (segs[0] === void 0 || segs[0].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub takes a machine id`);
|
|
3643
|
+
if (segs.length === 1) return {
|
|
3644
|
+
form: "hub",
|
|
3645
|
+
machine: segs[0]
|
|
3646
|
+
};
|
|
3647
|
+
if (segs.length === 2 && segs[1] === "pty") return {
|
|
3648
|
+
form: "hub-pty",
|
|
3649
|
+
machine: segs[0]
|
|
3650
|
+
};
|
|
3651
|
+
if (segs.length === 3 && segs[1] === "pty") {
|
|
3652
|
+
if (segs[2] === void 0 || segs[2].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
3653
|
+
return {
|
|
3654
|
+
form: "hub-pty",
|
|
3655
|
+
machine: segs[0],
|
|
3656
|
+
label: segs[2]
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
3659
|
+
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY is hub:<m4>:pty[:label]`);
|
|
3660
|
+
}
|
|
3661
|
+
for (const transport of TRANSPORTS) {
|
|
3662
|
+
if (!target.startsWith(`${transport}:`)) continue;
|
|
3663
|
+
const rest = target.slice(transport.length + 1);
|
|
3664
|
+
if (rest.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ${transport} takes a name`);
|
|
3665
|
+
const ptyAt = rest.lastIndexOf(":pty");
|
|
3666
|
+
if (ptyAt === -1) return {
|
|
3667
|
+
form: "direct",
|
|
3668
|
+
transport,
|
|
3669
|
+
name: rest
|
|
3670
|
+
};
|
|
3671
|
+
const base = rest.slice(0, ptyAt);
|
|
3672
|
+
if (base.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ":pty" needs a transport target before it ("ssh:pty" is the host named pty)`);
|
|
3673
|
+
const tail = rest.slice(ptyAt + 4);
|
|
3674
|
+
if (tail.startsWith(":")) {
|
|
3675
|
+
if (tail.length === 1) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
3676
|
+
return {
|
|
3677
|
+
form: "create-pty",
|
|
3678
|
+
transport,
|
|
3679
|
+
name: base,
|
|
3680
|
+
label: tail.slice(1)
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
if (tail.length > 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — unexpected ${JSON.stringify(tail)} after ":pty"`);
|
|
3684
|
+
return {
|
|
3685
|
+
form: "create-pty",
|
|
3686
|
+
transport,
|
|
3687
|
+
name: base
|
|
3688
|
+
};
|
|
3689
|
+
}
|
|
3690
|
+
if (target.includes(":")) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: unknown target ${JSON.stringify(target)} — transports are ssh: / docker: / incus: / hub:, the PTY selector is pty:<id|label>, creation appends :pty (roster lists names)`);
|
|
3691
|
+
return {
|
|
3692
|
+
form: "bare",
|
|
3693
|
+
name: target
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
//#endregion
|
|
3698
|
+
//#region src/remote/labels.ts
|
|
3699
|
+
/** label 字符集(与 BYO ssh label 同源);禁 `pty` 与会话 id 形态(防 selector 歧义)。 */
|
|
3700
|
+
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
3701
|
+
const PTY_ID_RE = /^pty-[a-z0-9]{4}$/;
|
|
3702
|
+
const RESERVED_LABELS = new Set(["pty"]);
|
|
3703
|
+
function validateLabel(label) {
|
|
3704
|
+
if (!LABEL_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: label must match ${LABEL_RE.source} (got ${JSON.stringify(label)})`);
|
|
3705
|
+
if (RESERVED_LABELS.has(label) || PTY_ID_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: ${JSON.stringify(label)} is reserved (session ids are auto-generated; pick a different label)`);
|
|
3706
|
+
}
|
|
3707
|
+
function describeEntry(e) {
|
|
3708
|
+
switch (e.kind) {
|
|
3709
|
+
case "ssh": return `ssh host ${e.name}`;
|
|
3710
|
+
case "ssh-byo": return `a BYO ssh identity (${e.name})`;
|
|
3711
|
+
case "docker": return `docker container ${e.container}`;
|
|
3712
|
+
case "incus": return `incus container ${e.container}`;
|
|
3713
|
+
case "hub": return `hub machine ${e.machine}`;
|
|
3714
|
+
case "pty": return `pty session ${e.id}${e.label !== void 0 ? ` (label ${e.label})` : ""}`;
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
/** 机器身份键:同一实体重扫后名字保持稳定(按身份匹配,不按名字)。 */
|
|
3718
|
+
const machineId = (e) => e.kind === "ssh" ? `ssh:${e.host}` : e.kind === "docker" ? `docker:${e.container}` : e.kind === "incus" ? `incus:${e.container}` : e.kind === "hub" ? `hub:${e.machine}` : void 0;
|
|
3719
|
+
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
3720
|
+
var LabelPool = class {
|
|
3721
|
+
/** 每 agent session 一个注册表:name → entry(机器 + 认领,到达序)。 */
|
|
3722
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3723
|
+
registry(agent) {
|
|
3724
|
+
let reg = this.sessions.get(agent);
|
|
3725
|
+
if (reg === void 0) {
|
|
3726
|
+
reg = /* @__PURE__ */ new Map();
|
|
3727
|
+
this.sessions.set(agent, reg);
|
|
3728
|
+
}
|
|
3729
|
+
return reg;
|
|
3730
|
+
}
|
|
3731
|
+
resolve(name$1, agent) {
|
|
3732
|
+
return this.sessions.get(agent)?.get(name$1);
|
|
3733
|
+
}
|
|
3734
|
+
/** 显式认领(BYO ssh / PTY label):任何已立名字占用即拒——名字是模型选的,不自动改名。 */
|
|
3735
|
+
claim(name$1, agent, claim) {
|
|
3736
|
+
const reg = this.registry(agent);
|
|
3737
|
+
const existing = reg.get(name$1);
|
|
3738
|
+
if (existing !== void 0) throw new Error(`[E_LABEL_IN_USE] remote: label ${JSON.stringify(name$1)} is already taken by ${describeEntry(existing)} — nothing was run.\n To address it: use the name directly (creation always claims a fresh name — spawn/ssh never adopt)\n` + (existing.kind === "pty" ? ` To free it: close ${existing.id} ({action:"close", target:"pty:${existing.id}"}) — a session that died still holds its label until it is closed or reaped\n` : "") + ` To create new: pick another label`);
|
|
3739
|
+
reg.set(name$1, claim);
|
|
3740
|
+
}
|
|
3741
|
+
/** 生成不撞本 session 的 id(`pty-` PTY 会话 / `ssh-` BYO ssh 身份)。 */
|
|
3742
|
+
freshId(prefix, agent) {
|
|
3743
|
+
const reg = this.registry(agent);
|
|
3744
|
+
for (;;) {
|
|
3745
|
+
let id = prefix;
|
|
3746
|
+
for (let i = 0; i < 4; i++) id += ID_ALPHABET[Math.floor(Math.random() * 36)];
|
|
3747
|
+
if (!reg.has(id)) return id;
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
/**
|
|
3751
|
+
* 现场采集 merge(roster / 裸名 miss 触发):按 ssh → docker → incus → hub 顺序并入。
|
|
3752
|
+
* 已立名字永不挪动:同一实体(身份键相同)保持原槽;新实体撞已立名字 = latecomer,
|
|
3753
|
+
* 自动加 `<transport>-<name>`(再冲突 -2/-3)——无论对方是认领还是更早的机器。
|
|
3754
|
+
* 本轮扫描里消失的机器出表(名字释放);认领永远保留。
|
|
3755
|
+
*/
|
|
3756
|
+
mergeScan(agent, scan) {
|
|
3757
|
+
const reg = this.registry(agent);
|
|
3758
|
+
const renamed = [];
|
|
3759
|
+
const present = /* @__PURE__ */ new Map();
|
|
3760
|
+
for (const [name$1, e] of [...reg]) {
|
|
3761
|
+
const id = machineId(e);
|
|
3762
|
+
if (id === void 0) continue;
|
|
3763
|
+
reg.delete(name$1);
|
|
3764
|
+
present.set(id, e);
|
|
3765
|
+
}
|
|
3766
|
+
const place = (transport, raw, make) => {
|
|
3767
|
+
const id = `${transport}:${raw}`;
|
|
3768
|
+
const known = present.get(id);
|
|
3769
|
+
if (known !== void 0) {
|
|
3770
|
+
reg.set(known.name, known);
|
|
3771
|
+
return;
|
|
3772
|
+
}
|
|
3773
|
+
const fresh = make(raw);
|
|
3774
|
+
if (!reg.has(raw)) {
|
|
3775
|
+
reg.set(raw, fresh);
|
|
3776
|
+
return;
|
|
3777
|
+
}
|
|
3778
|
+
let candidate = `${transport}-${raw}`, n = 2;
|
|
3779
|
+
while (reg.has(candidate)) candidate = `${transport}-${raw}-${n++}`;
|
|
3780
|
+
reg.set(candidate, make(candidate));
|
|
3781
|
+
renamed.push([fresh, candidate]);
|
|
3782
|
+
};
|
|
3783
|
+
for (const host of scan.sshHosts) place("ssh", host, (name$1) => ({
|
|
3784
|
+
kind: "ssh",
|
|
3785
|
+
name: name$1,
|
|
3786
|
+
host
|
|
3787
|
+
}));
|
|
3788
|
+
for (const c of scan.docker) place("docker", c, (name$1) => ({
|
|
3789
|
+
kind: "docker",
|
|
3790
|
+
name: name$1,
|
|
3791
|
+
container: c
|
|
3792
|
+
}));
|
|
3793
|
+
for (const c of scan.incus) place("incus", c, (name$1) => ({
|
|
3794
|
+
kind: "incus",
|
|
3795
|
+
name: name$1,
|
|
3796
|
+
container: c
|
|
3797
|
+
}));
|
|
3798
|
+
for (const m of scan.hub) place("hub", m, (name$1) => ({
|
|
3799
|
+
kind: "hub",
|
|
3800
|
+
name: name$1,
|
|
3801
|
+
machine: m
|
|
3802
|
+
}));
|
|
3803
|
+
return { renamed };
|
|
3804
|
+
}
|
|
3805
|
+
/** 认领释放(会话 dispose/TTL;BYO 身份随 session 存亡);幂等。 */
|
|
3806
|
+
release(name$1, agent) {
|
|
3807
|
+
this.sessions.get(agent)?.delete(name$1);
|
|
3808
|
+
}
|
|
3809
|
+
/** roster 面:本 session 的全部条目(机器 + 认领,插入序)。 */
|
|
3810
|
+
list(agent) {
|
|
3811
|
+
return [...this.sessions.get(agent)?.values() ?? []];
|
|
3812
|
+
}
|
|
3813
|
+
/** driver dispose:全部 session 注册表清空。 */
|
|
3814
|
+
clear() {
|
|
3815
|
+
this.sessions.clear();
|
|
3816
|
+
}
|
|
3817
|
+
};
|
|
3818
|
+
|
|
3819
|
+
//#endregion
|
|
3820
|
+
//#region src/remote/status.ts
|
|
2951
3821
|
function oneLine$1(s) {
|
|
2952
3822
|
return s.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
|
|
2953
3823
|
}
|
|
@@ -3020,21 +3890,11 @@ function renderProbe(p) {
|
|
|
3020
3890
|
case "container": return `probe: container ${p.status} (just now, on demand)`;
|
|
3021
3891
|
}
|
|
3022
3892
|
}
|
|
3023
|
-
/**
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
function renderSession(s) {
|
|
3029
|
-
if (s === void 0 || s.state === "cold" || s.state === "starting" || s.state === "disposed") return "session: none — dials on first exec";
|
|
3030
|
-
if (s.state === "dead") return "session: died — next exec cold-starts a fresh shell";
|
|
3031
|
-
if (s.busy) return "session: busy (a command is running)";
|
|
3032
|
-
return `session: idle ${Math.round((s.idleMs ?? 0) / 1e3)}s (connected)`;
|
|
3033
|
-
}
|
|
3034
|
-
/** PTY 会话状态行(BYO-PTY 面):无会话 = 这个 label 下没有活会话,且**不会自建**——
|
|
3035
|
-
* 与 transport target 的 "dials on first exec" 是两回事,故不复用那句。 */
|
|
3036
|
-
function renderPtySession(s) {
|
|
3037
|
-
return s === void 0 ? "session: none — no live PTY under this label" : renderSession(s);
|
|
3893
|
+
/** session card(§5.5,`pty:<id|label>` 无 cmd):connection/state/cwd/ttl 由调用方拼头行,这里渲染账本。 */
|
|
3894
|
+
function renderLedger(ledger) {
|
|
3895
|
+
if (ledger.length === 0) return "ledger: empty — no command has run in this session yet";
|
|
3896
|
+
const rows = [...ledger].reverse().map((e) => ` ${e.commandId}${e.jobId !== void 0 && e.jobId !== e.commandId ? ` (job ${e.jobId})` : ""} · ${e.running ? "running" : `exit ${e.exit}`}${e.running ? "" : ` · ${(e.durationMs / 1e3).toFixed(1)}s`} · ${e.summary}`);
|
|
3897
|
+
return `ledger (last ${ledger.length}):\n${rows.join("\n")}`;
|
|
3038
3898
|
}
|
|
3039
3899
|
|
|
3040
3900
|
//#endregion
|
|
@@ -3126,22 +3986,24 @@ function literalHosts(text) {
|
|
|
3126
3986
|
wildcardBlocks
|
|
3127
3987
|
};
|
|
3128
3988
|
}
|
|
3129
|
-
/**
|
|
3130
|
-
|
|
3131
|
-
* renders its native error (honest vocabulary: never invent, never hide).
|
|
3132
|
-
*/
|
|
3133
|
-
async function renderRoster(sessions, opts = {}) {
|
|
3989
|
+
/** 三段本地扫描(各自独立降级,错误如实带回给 roster 呈现)。 */
|
|
3990
|
+
async function scanLocal(opts = {}) {
|
|
3134
3991
|
const runner = opts.runner ?? runOneShot;
|
|
3135
3992
|
const readSshConfig = opts.readSshConfig ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
|
|
3136
3993
|
const hard = opts.hardTimeoutSec ?? 10;
|
|
3137
|
-
|
|
3994
|
+
let ssh = {
|
|
3995
|
+
hosts: [],
|
|
3996
|
+
wildcardBlocks: 0
|
|
3997
|
+
};
|
|
3138
3998
|
try {
|
|
3139
|
-
|
|
3140
|
-
lines.push(`ssh hosts (~/.ssh/config): ${hosts.length > 0 ? hosts.join(", ") : "(none)"}${wildcardBlocks > 0 ? ` (+${wildcardBlocks} wildcard default blocks, not listed)` : ""}`);
|
|
3999
|
+
ssh = literalHosts(await readSshConfig());
|
|
3141
4000
|
} catch (error) {
|
|
3142
|
-
|
|
4001
|
+
ssh = {
|
|
4002
|
+
hosts: [],
|
|
4003
|
+
wildcardBlocks: 0,
|
|
4004
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4005
|
+
};
|
|
3143
4006
|
}
|
|
3144
|
-
lines.push(`byossh labels (this Agent, in memory): ${opts.byosshLabels?.map((label) => `byossh:${label}`).join(", ") || "(none)"}`);
|
|
3145
4007
|
const docker = await runner([
|
|
3146
4008
|
"docker",
|
|
3147
4009
|
"ps",
|
|
@@ -3149,13 +4011,6 @@ async function renderRoster(sessions, opts = {}) {
|
|
|
3149
4011
|
"--format",
|
|
3150
4012
|
"{{.Names}} {{.State}}"
|
|
3151
4013
|
], { timeoutSec: hard });
|
|
3152
|
-
if (docker.exit === 0) {
|
|
3153
|
-
const rows = docker.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
|
|
3154
|
-
const [name$1, state] = l.split(" ");
|
|
3155
|
-
return `${name$1}${state !== void 0 && state !== "" ? ` (${state})` : ""}`;
|
|
3156
|
-
});
|
|
3157
|
-
lines.push(`docker containers: ${rows.length > 0 ? rows.join(", ") : "(none)"}`);
|
|
3158
|
-
} else lines.push(`docker containers: unavailable — ${docker.timedOut ? `scan timed out after ${hard}s` : oneLine(docker.stderr) || `docker exited ${docker.exit}`}`);
|
|
3159
4014
|
const incus = await runner([
|
|
3160
4015
|
"incus",
|
|
3161
4016
|
"list",
|
|
@@ -3164,35 +4019,97 @@ async function renderRoster(sessions, opts = {}) {
|
|
|
3164
4019
|
"--columns",
|
|
3165
4020
|
"ns"
|
|
3166
4021
|
], { timeoutSec: hard });
|
|
3167
|
-
|
|
3168
|
-
|
|
4022
|
+
return {
|
|
4023
|
+
ssh,
|
|
4024
|
+
docker: docker.exit === 0 ? { rows: docker.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
|
|
4025
|
+
const [name$1, state] = l.split(" ");
|
|
4026
|
+
return {
|
|
4027
|
+
name: (name$1 ?? "").trim(),
|
|
4028
|
+
state: (state ?? "").trim()
|
|
4029
|
+
};
|
|
4030
|
+
}).filter((r) => r.name.length > 0) } : {
|
|
4031
|
+
rows: [],
|
|
4032
|
+
error: oneLine(docker.stderr) || `docker exited ${docker.exit}`,
|
|
4033
|
+
...docker.timedOut ? { timedOut: true } : {}
|
|
4034
|
+
},
|
|
4035
|
+
incus: incus.exit === 0 ? { rows: incus.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
|
|
3169
4036
|
const [name$1, state] = l.split(",");
|
|
3170
|
-
return
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
4037
|
+
return {
|
|
4038
|
+
name: (name$1 ?? "").trim(),
|
|
4039
|
+
state: (state ?? "").trim()
|
|
4040
|
+
};
|
|
4041
|
+
}).filter((r) => r.name.length > 0) } : {
|
|
4042
|
+
rows: [],
|
|
4043
|
+
error: oneLine(incus.stderr) || `incus exited ${incus.exit}`,
|
|
4044
|
+
...incus.timedOut ? { timedOut: true } : {}
|
|
4045
|
+
}
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
/** 统一池视图的 roster 呈现:机器三段 + BYO ssh 同位(built on)+ pty 会话段(caller-scoped)。 */
|
|
4049
|
+
function renderRoster(scan, entries, sessions, hubTargets) {
|
|
4050
|
+
const lines = [];
|
|
4051
|
+
if (hubTargets !== null) lines.push(`hub machines: ${hubTargets.map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none"}`);
|
|
4052
|
+
if (scan.ssh.error !== void 0) lines.push(`ssh hosts: no readable ~/.ssh/config (${scan.ssh.error})`);
|
|
4053
|
+
else {
|
|
4054
|
+
const sshNames = entries.filter((e) => e.kind === "ssh").map((e) => e.name);
|
|
4055
|
+
const byoNames = entries.filter((e) => e.kind === "ssh-byo").map((e) => `${e.name} (built on)`);
|
|
4056
|
+
const all = [...sshNames, ...byoNames];
|
|
4057
|
+
lines.push(`ssh hosts (~/.ssh/config + registered): ${all.length > 0 ? all.join(", ") : "(none)"}${scan.ssh.wildcardBlocks > 0 ? ` (+${scan.ssh.wildcardBlocks} wildcard default blocks, not listed)` : ""}`);
|
|
4058
|
+
}
|
|
4059
|
+
const dockerStates = new Map(scan.docker.rows.map((r) => [r.name, r.state]));
|
|
4060
|
+
const dockerEntries = entries.filter((e) => e.kind === "docker");
|
|
4061
|
+
if (scan.docker.error !== void 0) lines.push(`docker containers: unavailable — ${scan.docker.timedOut ? `scan timed out` : scan.docker.error}`);
|
|
4062
|
+
else lines.push(`docker containers: ${dockerEntries.length > 0 ? dockerEntries.map((e) => e.kind === "docker" && `${e.name}${e.name !== e.container ? ` (= docker:${e.container}${dockerStates.get(e.container) ? `, ${dockerStates.get(e.container)}` : ""})` : dockerStates.get(e.container) !== void 0 && dockerStates.get(e.container) !== "" ? ` (${dockerStates.get(e.container)})` : ""}`).join(", ") : "(none)"}`);
|
|
4063
|
+
const incusStates = new Map(scan.incus.rows.map((r) => [r.name, r.state]));
|
|
4064
|
+
const incusEntries = entries.filter((e) => e.kind === "incus");
|
|
4065
|
+
if (scan.incus.error !== void 0) lines.push(`incus containers: unavailable — ${scan.incus.timedOut ? `scan timed out` : scan.incus.error}`);
|
|
4066
|
+
else lines.push(`incus containers: ${incusEntries.length > 0 ? incusEntries.map((e) => e.kind === "incus" && `${e.name}${e.name !== e.container ? ` (= incus:${e.container}${incusStates.get(e.container) ? `, ${incusStates.get(e.container)}` : ""})` : incusStates.get(e.container) !== void 0 && incusStates.get(e.container) !== "" ? ` (${incusStates.get(e.container)})` : ""}`).join(", ") : "(none)"}`);
|
|
4067
|
+
lines.push(sessions.length > 0 ? `pty sessions: ${sessions.map((s) => {
|
|
4068
|
+
const id = s.identity;
|
|
4069
|
+
const snap = s.snapshot;
|
|
4070
|
+
const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
|
|
4071
|
+
return `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} [${state}]`;
|
|
4072
|
+
}).join(" · ")}` : "pty sessions: none");
|
|
3175
4073
|
return lines.join("\n");
|
|
3176
4074
|
}
|
|
3177
4075
|
|
|
3178
4076
|
//#endregion
|
|
3179
4077
|
//#region src/remote/driver.ts
|
|
3180
|
-
/**
|
|
3181
|
-
const
|
|
3182
|
-
/**
|
|
4078
|
+
/** R1:idle TTL 15 min——idle 随活动重置,在用会话永不超时(PtySession 双条件)。 */
|
|
4079
|
+
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
4080
|
+
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
4081
|
+
const REMOTE_PTY_SESSION_LIMIT = 10;
|
|
4082
|
+
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
4083
|
+
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
4084
|
+
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
4085
|
+
const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
|
|
4086
|
+
/** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
|
|
4087
|
+
* ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
|
|
4088
|
+
* ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
|
|
4089
|
+
function hubPtyAssets() {
|
|
4090
|
+
return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
|
|
4091
|
+
}
|
|
4092
|
+
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
4093
|
+
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
4094
|
+
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
3183
4095
|
var RemoteDriver = class {
|
|
3184
|
-
sshTargets = new SshTargets();
|
|
3185
4096
|
pool;
|
|
4097
|
+
labels = new LabelPool();
|
|
3186
4098
|
hubOwner = randomUUID();
|
|
3187
4099
|
hubProviders = /* @__PURE__ */ new Set();
|
|
3188
4100
|
processes = /* @__PURE__ */ new Set();
|
|
3189
4101
|
starting = /* @__PURE__ */ new Set();
|
|
3190
4102
|
fileAbort = new AbortController();
|
|
3191
4103
|
disposed = false;
|
|
4104
|
+
/** undefined = 未尝试;null = 资产缺失(shim-only);registry = 每 target sticky 状态机。 */
|
|
4105
|
+
helpers;
|
|
4106
|
+
runnerOf = /* @__PURE__ */ new WeakMap();
|
|
3192
4107
|
disposal;
|
|
4108
|
+
ptyIdleTtlSec;
|
|
3193
4109
|
constructor(opts = {}) {
|
|
3194
4110
|
this.opts = opts;
|
|
3195
|
-
this.
|
|
4111
|
+
this.ptyIdleTtlSec = opts.idleTtlSec ?? REMOTE_PTY_IDLE_TTL_SEC;
|
|
4112
|
+
this.pool = new PtyPool({ idleTtlSec: this.ptyIdleTtlSec });
|
|
3196
4113
|
}
|
|
3197
4114
|
async call(params, callCtx = {}) {
|
|
3198
4115
|
const started = Date.now();
|
|
@@ -3200,10 +4117,11 @@ var RemoteDriver = class {
|
|
|
3200
4117
|
this.assertOpen();
|
|
3201
4118
|
const prepared = await prepareRemoteInput(params);
|
|
3202
4119
|
this.assertOpen();
|
|
3203
|
-
const
|
|
3204
|
-
|
|
4120
|
+
const form = parseTargetForm(prepared.target);
|
|
4121
|
+
this.validate(form, prepared);
|
|
4122
|
+
const result = await this.execute(prepared, form, callCtx);
|
|
3205
4123
|
this.audit({
|
|
3206
|
-
target:
|
|
4124
|
+
target: result.target,
|
|
3207
4125
|
cmd: prepared.cmd,
|
|
3208
4126
|
cwd: result.cwd ?? void 0,
|
|
3209
4127
|
exit: result.exit,
|
|
@@ -3220,10 +4138,106 @@ var RemoteDriver = class {
|
|
|
3220
4138
|
throw error;
|
|
3221
4139
|
}
|
|
3222
4140
|
}
|
|
3223
|
-
/**
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
4141
|
+
/** 参数互斥矩阵(§2):spawn/ssh 是 BYO 入口(参数在场即 BYO),只与对应形态的 target 配对。 */
|
|
4142
|
+
validate(form, params) {
|
|
4143
|
+
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)})`);
|
|
4144
|
+
if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
|
|
4145
|
+
const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
|
|
4146
|
+
const hasSsh = params.ssh !== void 0;
|
|
4147
|
+
if (hasSpawn && hasSsh) throw new Error("[E_PARAMS] remote: spawn and ssh are two different BYO entry points — pass one, not both");
|
|
4148
|
+
const ptyForm = form.form === "pty-selector" || form.form === "empty" || form.form === "bare";
|
|
4149
|
+
const sshForm = form.form === "ssh-prefix" || form.form === "empty" || form.form === "bare" || form.form === "direct" && form.transport === "ssh" || form.form === "create-pty" && form.transport === "ssh";
|
|
4150
|
+
if (hasSpawn && !ptyForm) throw new Error("[E_PARAMS] remote: spawn pairs only with a pty target (pty, pty:<label>, or a bare label) — machines are dialed, not spawned");
|
|
4151
|
+
if (hasSsh && !sshForm) throw new Error("[E_PARAMS] remote: ssh registration pairs with ssh-form targets only (ssh, ssh:<label>, ssh:<label>:pty, a bare label, or no target)");
|
|
4152
|
+
}
|
|
4153
|
+
async execute(params, form, callCtx) {
|
|
4154
|
+
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
4155
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4156
|
+
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
4157
|
+
params.spawn !== void 0 && params.spawn.length;
|
|
4158
|
+
if (form.form === "hub") {
|
|
4159
|
+
const hub = this.requireHub();
|
|
4160
|
+
this.hubProviders.add(hub);
|
|
4161
|
+
const process$1 = this.own(await hub.start(form.machine, {
|
|
4162
|
+
...params,
|
|
4163
|
+
target: `hub:${form.machine}`,
|
|
4164
|
+
mode: "oneshot",
|
|
4165
|
+
timeout: timeoutSec
|
|
4166
|
+
}, this.hubOwner));
|
|
4167
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
4168
|
+
const result = await process$1.done;
|
|
4169
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4170
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4171
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
4172
|
+
return {
|
|
4173
|
+
...result,
|
|
4174
|
+
mode: "oneshot",
|
|
4175
|
+
target: `hub:${form.machine}`,
|
|
4176
|
+
connection: `hub:${form.machine}`,
|
|
4177
|
+
cwd: null,
|
|
4178
|
+
reconnected: false,
|
|
4179
|
+
runner: HUB_RUNNER,
|
|
4180
|
+
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4181
|
+
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4182
|
+
...out.stats ? { outputStats: {
|
|
4183
|
+
stdout: out.stats,
|
|
4184
|
+
stderr: err.stats
|
|
4185
|
+
} } : { outputStatsUnavailable: true },
|
|
4186
|
+
...out.truncated === void 0 ? {} : { truncated: out.truncated }
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
const lane = this.createPtyLane(params, form, sessionKey);
|
|
4190
|
+
if (lane !== void 0) {
|
|
4191
|
+
const session = lane.session;
|
|
4192
|
+
if (!hasCmd) {
|
|
4193
|
+
await session.warm();
|
|
4194
|
+
return this.warmReceipt(session);
|
|
4195
|
+
}
|
|
4196
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4197
|
+
}
|
|
4198
|
+
if (params.ssh !== void 0) {
|
|
4199
|
+
const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
|
|
4200
|
+
const name$1 = this.registerByoSsh(label, params.ssh, sessionKey);
|
|
4201
|
+
if (!hasCmd) return {
|
|
4202
|
+
mode: "oneshot",
|
|
4203
|
+
target: `ssh:${name$1}`,
|
|
4204
|
+
connection: `ssh:${name$1}`,
|
|
4205
|
+
byoTag: "byo-ssh",
|
|
4206
|
+
exit: null,
|
|
4207
|
+
cwd: null,
|
|
4208
|
+
timedOut: false,
|
|
4209
|
+
reconnected: false,
|
|
4210
|
+
stdout: `ssh:${name$1} registered (byo-ssh, no dialing). Reuse the label anywhere ssh: aliases work; a new Agent registers again.`,
|
|
4211
|
+
durationMs: 0
|
|
4212
|
+
};
|
|
4213
|
+
return await this.oneshot({
|
|
4214
|
+
kind: "ssh",
|
|
4215
|
+
host: normalizeSshConnection(params.ssh).host,
|
|
4216
|
+
connection: normalizeSshConnection(params.ssh)
|
|
4217
|
+
}, `ssh:${name$1}`, params, timeoutSec);
|
|
4218
|
+
}
|
|
4219
|
+
if (form.form === "pty-selector" && form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build) or pass spawn to create one");
|
|
4220
|
+
if (form.form === "pty-selector") {
|
|
4221
|
+
const session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
4222
|
+
if (!hasCmd) return this.cardReceipt(session);
|
|
4223
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4224
|
+
}
|
|
4225
|
+
if (form.form === "direct") {
|
|
4226
|
+
const plan$1 = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
|
|
4227
|
+
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec);
|
|
4228
|
+
}
|
|
4229
|
+
if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
|
|
4230
|
+
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
4231
|
+
if (entry.kind === "pty") {
|
|
4232
|
+
const session = this.resolvePty(form.name, sessionKey);
|
|
4233
|
+
if (!hasCmd) return this.cardReceipt(session);
|
|
4234
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
4235
|
+
}
|
|
4236
|
+
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec);
|
|
4237
|
+
const plan = this.planOf(entry);
|
|
4238
|
+
return await this.oneshot(plan, form.name, params, timeoutSec);
|
|
4239
|
+
}
|
|
4240
|
+
/** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
|
|
3227
4241
|
copy(request, signal, callCtx = {}) {
|
|
3228
4242
|
const combined = AbortSignal.any([signal, this.fileAbort.signal]);
|
|
3229
4243
|
const work = (async () => {
|
|
@@ -3295,13 +4309,18 @@ var RemoteDriver = class {
|
|
|
3295
4309
|
path: dst.path,
|
|
3296
4310
|
endpoint: localFileEndpoint()
|
|
3297
4311
|
}, combined);
|
|
3298
|
-
if (request.verify !== void 0
|
|
4312
|
+
if (request.verify !== void 0) throw new Error("copy: verify is a Hub/local staged lane option (independent SHA-256); native routes report exit-code-only verification — self-verify via oneshot digest commands when needed");
|
|
3299
4313
|
const planOf = (end) => end.kind === "cli" ? end.plan : end.kind === "local" ? { kind: "local" } : void 0;
|
|
3300
4314
|
const srcPlan = planOf(src), dstPlan = planOf(dst);
|
|
3301
4315
|
if (srcPlan === void 0 || dstPlan === void 0) throw new Error("copy: unreachable route resolution");
|
|
4316
|
+
const execArgvFor = (plan, script) => {
|
|
4317
|
+
const argv = helperShellArgv(plan, script);
|
|
4318
|
+
return this.opts.nativeArgvFor ? this.opts.nativeArgvFor(argv) : argv;
|
|
4319
|
+
};
|
|
3302
4320
|
return nativeCopy({
|
|
3303
4321
|
src: request.src,
|
|
3304
4322
|
dst: request.dst,
|
|
4323
|
+
...request.mkdir === void 0 ? {} : { mkdir: request.mkdir },
|
|
3305
4324
|
...request.argv === void 0 ? {} : { argv: request.argv },
|
|
3306
4325
|
...request.pullArgv === void 0 ? {} : { pullArgv: request.pullArgv },
|
|
3307
4326
|
...request.pushArgv === void 0 ? {} : { pushArgv: request.pushArgv }
|
|
@@ -3314,7 +4333,10 @@ var RemoteDriver = class {
|
|
|
3314
4333
|
plan: dstPlan,
|
|
3315
4334
|
path: dst.path
|
|
3316
4335
|
}
|
|
3317
|
-
}, combined, {
|
|
4336
|
+
}, combined, {
|
|
4337
|
+
wrapArgv: this.opts.nativeArgvFor,
|
|
4338
|
+
execArgvFor
|
|
4339
|
+
});
|
|
3318
4340
|
})();
|
|
3319
4341
|
this.starting.add(work);
|
|
3320
4342
|
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
@@ -3328,7 +4350,8 @@ var RemoteDriver = class {
|
|
|
3328
4350
|
const endpoint = async (value) => {
|
|
3329
4351
|
const parsed = parseFileEndpoint(value);
|
|
3330
4352
|
if (!parsed.target) return { path: parsed.path };
|
|
3331
|
-
|
|
4353
|
+
const form = parseTargetForm(parsed.target);
|
|
4354
|
+
if (form.form === "hub" || form.form === "hub-pty" || form.form === "pty-selector") throw new Error("rsync supports SSH endpoints only (plus one local endpoint); no mixed-transport or copy fallback");
|
|
3332
4355
|
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
3333
4356
|
this.assertOpen();
|
|
3334
4357
|
combined.throwIfAborted();
|
|
@@ -3348,45 +4371,66 @@ var RemoteDriver = class {
|
|
|
3348
4371
|
return work;
|
|
3349
4372
|
}
|
|
3350
4373
|
/** Only called from JobSpec.run: admission precedes local file reads and discovery. */
|
|
3351
|
-
start(input, signal) {
|
|
3352
|
-
const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]));
|
|
4374
|
+
start(input, signal, callCtx = {}) {
|
|
4375
|
+
const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]), callCtx);
|
|
3353
4376
|
this.starting.add(work);
|
|
3354
4377
|
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
3355
4378
|
return work;
|
|
3356
4379
|
}
|
|
3357
|
-
async startPrepared(input, signal) {
|
|
4380
|
+
async startPrepared(input, signal, callCtx = {}) {
|
|
3358
4381
|
this.assertOpen();
|
|
3359
4382
|
signal.throwIfAborted();
|
|
3360
4383
|
const params = await prepareRemoteInput(input);
|
|
3361
4384
|
this.assertOpen();
|
|
3362
4385
|
signal.throwIfAborted();
|
|
3363
|
-
const
|
|
3364
|
-
|
|
4386
|
+
const form = parseTargetForm(params.target);
|
|
4387
|
+
this.validate(form, params);
|
|
4388
|
+
if (form.form !== "direct" && form.form !== "bare" && form.form !== "hub") throw new Error("remote: background oneshot takes a machine target; PTY lanes go through the pty job channel");
|
|
4389
|
+
if (params.ssh !== void 0 && (form.form === "direct" || form.form === "bare") && form.transport !== "docker" && form.transport !== "incus") this.registerByoSsh(form.name, params.ssh, callCtx.sessionKey ?? "no-session");
|
|
3365
4390
|
let process$1;
|
|
3366
|
-
if (
|
|
3367
|
-
|
|
3368
|
-
this.
|
|
3369
|
-
process$1 = await hub.start(route.display.slice(4), {
|
|
3370
|
-
...params,
|
|
3371
|
-
mode: "oneshot"
|
|
3372
|
-
}, this.hubOwner);
|
|
3373
|
-
} else {
|
|
3374
|
-
const plan = route.plan ?? await this.resolveBareName(route.bare, "no-session", false);
|
|
4391
|
+
if (form.form === "hub") process$1 = await this.hubOneshotProcess(form.machine, params);
|
|
4392
|
+
else if (form.form === "direct") {
|
|
4393
|
+
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
3375
4394
|
this.assertOpen();
|
|
3376
4395
|
signal.throwIfAborted();
|
|
3377
|
-
if (plan.kind === "pty") throw new Error("remote: background requires oneshot");
|
|
3378
4396
|
process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
|
|
3379
|
-
}
|
|
4397
|
+
} else if (form.form === "bare") {
|
|
4398
|
+
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
4399
|
+
this.assertOpen();
|
|
4400
|
+
signal.throwIfAborted();
|
|
4401
|
+
if (entry.kind === "pty" || entry.kind === "hub") throw new Error("remote: background requires an ssh/docker/incus target");
|
|
4402
|
+
process$1 = await this.nativeProcess(this.planOf(entry), params, params.timeout, signal);
|
|
4403
|
+
} else throw new Error("remote: background oneshot takes a machine target");
|
|
3380
4404
|
this.own(process$1);
|
|
3381
4405
|
if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
|
|
3382
4406
|
return process$1;
|
|
3383
4407
|
}
|
|
3384
|
-
nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
4408
|
+
async nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
3385
4409
|
const cleanupTag = randomUUID().slice(0, 8);
|
|
4410
|
+
const helper = await this.ensureHelper(plan);
|
|
4411
|
+
if (helper !== null && !this.disposed) {
|
|
4412
|
+
const process$2 = startOneShot(buildHelperOneShotArgv(plan, helper.binPath, {
|
|
4413
|
+
tag: cleanupTag,
|
|
4414
|
+
cmd: params.cmd
|
|
4415
|
+
}), {
|
|
4416
|
+
stdin: params.stdin,
|
|
4417
|
+
timeoutSec
|
|
4418
|
+
});
|
|
4419
|
+
this.runnerOf.set(process$2, `superd-run ${helper.ver} (${helper.triple})`);
|
|
4420
|
+
process$2.done.then((result) => {
|
|
4421
|
+
if (!result.cancelled && !result.timedOut) return;
|
|
4422
|
+
startOneShot(buildHelperKillgroupArgv(plan, helper.binPath, cleanupTag), { timeoutSec: 15 }).done.catch(() => {});
|
|
4423
|
+
}).catch(() => {});
|
|
4424
|
+
const tracked = Promise.resolve(process$2);
|
|
4425
|
+
this.starting.add(tracked);
|
|
4426
|
+
tracked.then(() => this.starting.delete(tracked), () => this.starting.delete(tracked));
|
|
4427
|
+
return tracked;
|
|
4428
|
+
}
|
|
3386
4429
|
const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, { cleanupTag }), {
|
|
3387
4430
|
stdin: params.stdin,
|
|
3388
4431
|
timeoutSec
|
|
3389
4432
|
});
|
|
4433
|
+
this.runnerOf.set(process$1, "posix-shim");
|
|
3390
4434
|
process$1.done.then((result) => {
|
|
3391
4435
|
if (!result.cancelled && !result.timedOut) return;
|
|
3392
4436
|
const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
|
|
@@ -3398,296 +4442,395 @@ var RemoteDriver = class {
|
|
|
3398
4442
|
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
3399
4443
|
return work;
|
|
3400
4444
|
}
|
|
4445
|
+
/** Helper bootstrap is best-effort and sticky: any failure parks null for this driver
|
|
4446
|
+
* lifetime and the shim stays the path. First call to a target pays probe (+ upload). */
|
|
4447
|
+
async ensureHelper(plan) {
|
|
4448
|
+
if (this.helpers === void 0) {
|
|
4449
|
+
const assets = this.opts.helperAssetsFor?.() ?? helperAssets();
|
|
4450
|
+
this.helpers = assets === null ? null : new HelperRegistry(assets, (argv, options) => this.helperExec(argv, options), buildHelperProbeArgv, buildHelperUploadArgv);
|
|
4451
|
+
}
|
|
4452
|
+
if (this.helpers === null || this.disposed) return null;
|
|
4453
|
+
try {
|
|
4454
|
+
return await this.helpers.ensure(plan);
|
|
4455
|
+
} catch {
|
|
4456
|
+
return null;
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
async helperExec(argv, options) {
|
|
4460
|
+
this.assertOpen();
|
|
4461
|
+
const stdin = options?.stdin;
|
|
4462
|
+
if (stdin !== void 0) {
|
|
4463
|
+
const proc$1 = startBinaryProcess(argv, { timeoutSec: options?.timeoutSec });
|
|
4464
|
+
proc$1.stdin.on("error", () => {});
|
|
4465
|
+
proc$1.stdin.end(stdin);
|
|
4466
|
+
return {
|
|
4467
|
+
exit: (await proc$1.done).exit,
|
|
4468
|
+
stdout: ""
|
|
4469
|
+
};
|
|
4470
|
+
}
|
|
4471
|
+
const proc = startOneShot(argv, { timeoutSec: options?.timeoutSec });
|
|
4472
|
+
return {
|
|
4473
|
+
exit: (await proc.done).exit,
|
|
4474
|
+
stdout: proc.read("stdout", 0).text
|
|
4475
|
+
};
|
|
4476
|
+
}
|
|
3401
4477
|
own(process$1) {
|
|
3402
4478
|
this.processes.add(process$1);
|
|
3403
4479
|
process$1.done.then(() => this.processes.delete(process$1));
|
|
3404
4480
|
return process$1;
|
|
3405
4481
|
}
|
|
3406
|
-
/**
|
|
3407
|
-
|
|
3408
|
-
* Ruling P20 + PTY 候选表末位:裸名按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
|
|
3409
|
-
* 撞名 ssh 优先),三段全不中再看池内活 PTY label,最后才是默认 ssh(交原生错误)。
|
|
3410
|
-
* 位置即优先级:现有三段一位不挪。选择器前缀的 target 不经此路径。
|
|
3411
|
-
*/
|
|
3412
|
-
async resolveBareName(name$1, sessionKey, usePtyLabels = true) {
|
|
4482
|
+
/** 现场采集机器面并 merge 进本 session 注册表(ssh → docker → incus → hub,迟到者加前缀)。错误逐段吞掉(roster 另行如实呈现)。 */
|
|
4483
|
+
async refreshMachines(sessionKey) {
|
|
3413
4484
|
const runner = this.opts.rosterRunner ?? runOneShot;
|
|
3414
4485
|
const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
|
|
3415
|
-
let
|
|
4486
|
+
let sshHosts = [];
|
|
3416
4487
|
try {
|
|
3417
|
-
|
|
4488
|
+
sshHosts = literalHosts(await readCfg()).hosts;
|
|
3418
4489
|
} catch {}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
};
|
|
3424
|
-
const docker = await
|
|
4490
|
+
const names = (argv) => (async () => {
|
|
4491
|
+
const r = await runner(argv, { timeoutSec: 10 });
|
|
4492
|
+
this.assertOpen();
|
|
4493
|
+
return r.exit === 0 ? r.stdout.trim().split(/\r?\n/).map((l) => l.split(/[\t,]/)[0].trim()).filter((n) => n.length > 0) : [];
|
|
4494
|
+
})().catch(() => []);
|
|
4495
|
+
const [docker, incus] = await Promise.all([names([
|
|
3425
4496
|
"docker",
|
|
3426
4497
|
"ps",
|
|
3427
4498
|
"-a",
|
|
3428
4499
|
"--format",
|
|
3429
4500
|
"{{.Names}}"
|
|
3430
|
-
],
|
|
3431
|
-
this.assertOpen();
|
|
3432
|
-
if (docker.exit === 0 && docker.stdout.trim().split(/\r?\n/).some((n) => n.trim().toLowerCase() === name$1.toLowerCase())) return {
|
|
3433
|
-
kind: "docker",
|
|
3434
|
-
container: name$1
|
|
3435
|
-
};
|
|
3436
|
-
const incus = await runner([
|
|
4501
|
+
]), names([
|
|
3437
4502
|
"incus",
|
|
3438
4503
|
"list",
|
|
3439
4504
|
"--format",
|
|
3440
4505
|
"csv",
|
|
3441
4506
|
"--columns",
|
|
3442
4507
|
"n"
|
|
3443
|
-
]
|
|
4508
|
+
])]);
|
|
4509
|
+
const hub = this.opts.getHub?.()?.listTargets().map((t) => t.id) ?? [];
|
|
4510
|
+
this.labels.mergeScan(sessionKey, {
|
|
4511
|
+
sshHosts,
|
|
4512
|
+
docker,
|
|
4513
|
+
incus,
|
|
4514
|
+
hub
|
|
4515
|
+
});
|
|
4516
|
+
}
|
|
4517
|
+
/** 池寻址:命中即回;miss 现场采集一次再寻址(docker 容器名不经 roster 也可达)。 */
|
|
4518
|
+
async resolvePoolEntry(name$1, sessionKey) {
|
|
4519
|
+
const hit = this.labels.resolve(name$1, sessionKey);
|
|
4520
|
+
if (hit !== void 0) return hit;
|
|
4521
|
+
await this.refreshMachines(sessionKey);
|
|
3444
4522
|
this.assertOpen();
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
4523
|
+
const after = this.labels.resolve(name$1, sessionKey);
|
|
4524
|
+
if (after !== void 0) return after;
|
|
4525
|
+
return {
|
|
4526
|
+
kind: "ssh",
|
|
4527
|
+
name: name$1,
|
|
4528
|
+
host: name$1
|
|
3448
4529
|
};
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
4530
|
+
}
|
|
4531
|
+
planOf(entry) {
|
|
4532
|
+
switch (entry.kind) {
|
|
4533
|
+
case "ssh": return {
|
|
4534
|
+
kind: "ssh",
|
|
4535
|
+
host: entry.host
|
|
4536
|
+
};
|
|
4537
|
+
case "ssh-byo": return {
|
|
4538
|
+
kind: "ssh",
|
|
4539
|
+
host: entry.connection.host,
|
|
4540
|
+
connection: entry.connection
|
|
4541
|
+
};
|
|
4542
|
+
case "docker": return {
|
|
4543
|
+
kind: "docker",
|
|
4544
|
+
container: entry.container
|
|
4545
|
+
};
|
|
4546
|
+
case "incus": return {
|
|
4547
|
+
kind: "incus",
|
|
4548
|
+
container: entry.container
|
|
4549
|
+
};
|
|
4550
|
+
case "pty": throw new Error("remote: a PTY session is not a connection target");
|
|
4551
|
+
case "hub": throw new Error("remote: hub machines are not connection targets");
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
/** ssh:<name> 的池感知解析:BYO 身份 → 其 connection;否则按 config 别名/主机名。 */
|
|
4555
|
+
sshPlanFor(name$1, sessionKey) {
|
|
4556
|
+
const entry = this.labels.resolve(name$1, sessionKey);
|
|
4557
|
+
if (entry?.kind === "ssh-byo") return {
|
|
4558
|
+
kind: "ssh",
|
|
4559
|
+
host: entry.connection.host,
|
|
4560
|
+
connection: entry.connection
|
|
3452
4561
|
};
|
|
3453
4562
|
return {
|
|
3454
4563
|
kind: "ssh",
|
|
3455
4564
|
host: name$1
|
|
3456
4565
|
};
|
|
3457
4566
|
}
|
|
3458
|
-
/**
|
|
3459
|
-
|
|
3460
|
-
* 而活会话是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,且两个 agent 各开一条线
|
|
3461
|
-
* 的代价可忽略。BYO-PTY 天然是活的,于是自然落进这个 scope,无需另立规则。 */
|
|
3462
|
-
async roster(callCtx = {}) {
|
|
3463
|
-
this.assertOpen();
|
|
3464
|
-
const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
|
|
3465
|
-
const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot: snapshot$1 }) => ({
|
|
3466
|
-
target: displayKey(key),
|
|
3467
|
-
state: snapshot$1.state,
|
|
3468
|
-
idleSec: snapshot$1.idleMs !== null ? Math.round(snapshot$1.idleMs / 1e3) : null
|
|
3469
|
-
}));
|
|
3470
|
-
const runner = this.opts.rosterRunner ?? runOneShot;
|
|
3471
|
-
const hub = this.opts.getHub?.();
|
|
3472
|
-
return [...hub ? ["hub machines: " + (hub.listTargets().map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none")] : [], await renderRoster(sessions, {
|
|
3473
|
-
byosshLabels: this.sshTargets.entries().map(([label]) => label),
|
|
3474
|
-
readSshConfig: this.opts.sshConfigReader,
|
|
3475
|
-
runner: (argv, options) => {
|
|
3476
|
-
this.assertOpen();
|
|
3477
|
-
return runner(argv, options);
|
|
3478
|
-
}
|
|
3479
|
-
})].join("\n");
|
|
3480
|
-
}
|
|
3481
|
-
async status(target, callCtx = {}) {
|
|
4567
|
+
/** BYO ssh 注册(v2:无 spec 对比——label 占用即拒;注册成功才认领)。返回实际名(label 或 s-id)。 */
|
|
4568
|
+
registerByoSsh(label, connection, sessionKey) {
|
|
3482
4569
|
this.assertOpen();
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
4570
|
+
const normalized = normalizeSshConnection(connection);
|
|
4571
|
+
if (label !== void 0) validateLabel(label);
|
|
4572
|
+
const name$1 = label ?? this.labels.freshId("ssh-", sessionKey);
|
|
4573
|
+
if (label !== void 0) {
|
|
4574
|
+
const existing = this.labels.resolve(label, sessionKey);
|
|
4575
|
+
if (existing !== void 0) throw new Error(`[E_LABEL_IN_USE] remote: label ${JSON.stringify(label)} is already taken by ${describeEntry(existing)} — nothing was run.\n To reuse it: address ssh:${label} without the ssh field\n To register new: pick another label`);
|
|
4576
|
+
this.labels.claim(label, sessionKey, {
|
|
4577
|
+
kind: "ssh-byo",
|
|
4578
|
+
name: label,
|
|
4579
|
+
connection: normalized
|
|
4580
|
+
});
|
|
4581
|
+
return label;
|
|
3488
4582
|
}
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
if (resolved.kind === "pty") return this.ptyStatus(`byop:${resolved.label}`, resolved.label, sessionKey);
|
|
3496
|
-
const head = `${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`;
|
|
3497
|
-
const runner = this.opts.probeRunner ?? runOneShot;
|
|
3498
|
-
return [
|
|
3499
|
-
head,
|
|
3500
|
-
renderProbe(await probeTarget(resolved, { runner: (argv, options) => {
|
|
3501
|
-
this.assertOpen();
|
|
3502
|
-
return runner(argv, options);
|
|
3503
|
-
} })),
|
|
3504
|
-
renderSession(this.pool.inspect(`${sessionKey}|t:${target}`))
|
|
3505
|
-
].join("\n");
|
|
3506
|
-
}
|
|
3507
|
-
/** PTY 状态行:纯池内读取,零拨号(label 从来不是可拨目标)。 */
|
|
3508
|
-
ptyStatus(display, label, sessionKey) {
|
|
3509
|
-
return [`${display} — BYO pty session`, renderPtySession(this.pool.inspect(`${sessionKey}|p:${label}`))].join("\n");
|
|
3510
|
-
}
|
|
3511
|
-
/** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径(成功被毒化成失败+双重审计)。 */
|
|
3512
|
-
audit(r) {
|
|
3513
|
-
try {
|
|
3514
|
-
this.opts.onAudit?.(r);
|
|
3515
|
-
} catch {}
|
|
4583
|
+
this.labels.claim(name$1, sessionKey, {
|
|
4584
|
+
kind: "ssh-byo",
|
|
4585
|
+
name: name$1,
|
|
4586
|
+
connection: normalized
|
|
4587
|
+
});
|
|
4588
|
+
return name$1;
|
|
3516
4589
|
}
|
|
3517
|
-
/**
|
|
3518
|
-
|
|
4590
|
+
/** PTY 会话创建(native/spawn 同一形状):上限 → label 认领 → 惰性会话(失败由 onDead 释放 label)。 */
|
|
4591
|
+
createSession(opts, sessionKey) {
|
|
3519
4592
|
this.assertOpen();
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
kind: "
|
|
3530
|
-
|
|
3531
|
-
|
|
4593
|
+
const live = this.pool.list().filter(({ key: key$1 }) => key$1.startsWith(`${sessionKey}|`)).map(({ key: key$1 }) => this.pool.get(key$1)).filter((s) => s !== void 0 && s.sessionState !== "dead");
|
|
4594
|
+
if (live.length >= REMOTE_PTY_SESSION_LIMIT) {
|
|
4595
|
+
const listing = live.map(sessionLine).join(" · ");
|
|
4596
|
+
throw new Error(`[E_SESSION_LIMIT] remote: this Agent already holds ${live.length} live PTY sessions (limit ${REMOTE_PTY_SESSION_LIMIT}) — close one ({action:"close", target:"pty:<id>"}); idle sessions are reaped after ${this.ttlMinutes()} min.\n live: ${listing}`);
|
|
4597
|
+
}
|
|
4598
|
+
const { label } = opts;
|
|
4599
|
+
if (label !== void 0) validateLabel(label);
|
|
4600
|
+
const id = this.labels.freshId("pty-", sessionKey);
|
|
4601
|
+
const entry = {
|
|
4602
|
+
kind: "pty",
|
|
4603
|
+
name: id,
|
|
4604
|
+
id,
|
|
4605
|
+
...label !== void 0 ? { label } : {},
|
|
4606
|
+
connection: opts.connection,
|
|
4607
|
+
byo: opts.byo
|
|
3532
4608
|
};
|
|
4609
|
+
if (label !== void 0) this.labels.claim(label, sessionKey, {
|
|
4610
|
+
...entry,
|
|
4611
|
+
name: label
|
|
4612
|
+
});
|
|
4613
|
+
const key = `${sessionKey}|p:${id}`;
|
|
4614
|
+
let session;
|
|
4615
|
+
try {
|
|
4616
|
+
session = this.pool.create({
|
|
4617
|
+
key,
|
|
4618
|
+
argv: opts.argv,
|
|
4619
|
+
id,
|
|
4620
|
+
...label !== void 0 ? { label } : {},
|
|
4621
|
+
connection: opts.connection,
|
|
4622
|
+
byo: opts.byo,
|
|
4623
|
+
builtOn: this.isBuiltOn(opts.connection, sessionKey),
|
|
4624
|
+
...opts.origin !== void 0 ? { origin: opts.origin } : {},
|
|
4625
|
+
onDead: () => {
|
|
4626
|
+
this.labels.release(id, sessionKey);
|
|
4627
|
+
if (label !== void 0) this.labels.release(label, sessionKey);
|
|
4628
|
+
}
|
|
4629
|
+
});
|
|
4630
|
+
} catch (error) {
|
|
4631
|
+
this.labels.release(id, sessionKey);
|
|
4632
|
+
if (label !== void 0) this.labels.release(label, sessionKey);
|
|
4633
|
+
throw error;
|
|
4634
|
+
}
|
|
4635
|
+
this.labels.claim(id, sessionKey, entry);
|
|
4636
|
+
return session;
|
|
4637
|
+
}
|
|
4638
|
+
ttlMinutes() {
|
|
4639
|
+
return Math.max(1, Math.round(this.ptyIdleTtlSec / 60));
|
|
4640
|
+
}
|
|
4641
|
+
/** connection 由 BYO ssh 身份建立 → card 面标 `(built on)`(与 roster 同词,R2 复核 P2-10)。 */
|
|
4642
|
+
isBuiltOn(connection, sessionKey) {
|
|
4643
|
+
return connection.startsWith("ssh:") && this.labels.resolve(connection.slice(4), sessionKey)?.kind === "ssh-byo";
|
|
4644
|
+
}
|
|
4645
|
+
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO / ssh BYO / native `<transport>:pty`。 */
|
|
4646
|
+
createPtyLane(params, form, sessionKey) {
|
|
4647
|
+
if (params.spawn !== void 0 && params.spawn.length > 0) {
|
|
4648
|
+
const label = form.form === "pty-selector" && form.idOrLabel !== void 0 ? form.idOrLabel : form.form === "bare" ? form.name : void 0;
|
|
4649
|
+
if (label !== void 0 && PTY_ID_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: ${JSON.stringify(label)} looks like an auto id — ids are generated, labels are yours to choose`);
|
|
4650
|
+
return { session: this.createSession({
|
|
4651
|
+
argv: (this.opts.spawnArgvFor ?? buildSpawnArgv)(params.spawn),
|
|
4652
|
+
connection: "BYOP",
|
|
4653
|
+
byo: true,
|
|
4654
|
+
...label !== void 0 ? { label } : {},
|
|
4655
|
+
origin: params.spawn
|
|
4656
|
+
}, sessionKey) };
|
|
4657
|
+
}
|
|
4658
|
+
if (params.ssh !== void 0 && form.form === "create-pty") {
|
|
4659
|
+
const name$1 = this.registerByoSsh(form.name, params.ssh, sessionKey);
|
|
4660
|
+
const connection = normalizeSshConnection(params.ssh);
|
|
4661
|
+
const plan = {
|
|
4662
|
+
kind: "ssh",
|
|
4663
|
+
host: connection.host,
|
|
4664
|
+
connection
|
|
4665
|
+
};
|
|
4666
|
+
return { session: this.createSession({
|
|
4667
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
4668
|
+
connection: `ssh:${name$1}`,
|
|
4669
|
+
byo: false,
|
|
4670
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
4671
|
+
}, sessionKey) };
|
|
4672
|
+
}
|
|
4673
|
+
if (form.form === "create-pty") {
|
|
4674
|
+
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
|
|
4675
|
+
return { session: this.createSession({
|
|
4676
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
4677
|
+
connection: `${form.transport}:${form.name}`,
|
|
4678
|
+
byo: false,
|
|
4679
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
4680
|
+
}, sessionKey) };
|
|
4681
|
+
}
|
|
4682
|
+
if (form.form === "hub-pty") {
|
|
4683
|
+
const hub = this.requireHub();
|
|
4684
|
+
this.hubProviders.add(hub);
|
|
4685
|
+
const endpoint = hub.ptyEndpoint(form.machine);
|
|
4686
|
+
const argv = this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets());
|
|
4687
|
+
return { session: this.createSession({
|
|
4688
|
+
argv,
|
|
4689
|
+
connection: `hub:${form.machine}`,
|
|
4690
|
+
byo: false,
|
|
4691
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
4692
|
+
}, sessionKey) };
|
|
4693
|
+
}
|
|
3533
4694
|
}
|
|
3534
|
-
/**
|
|
3535
|
-
*
|
|
3536
|
-
async
|
|
4695
|
+
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
4696
|
+
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
4697
|
+
async preparePtyLane(params, callCtx = {}) {
|
|
3537
4698
|
this.assertOpen();
|
|
3538
|
-
|
|
3539
|
-
if (/^(docker|incus|ssh):/.test(target)) return resolveTarget(target);
|
|
3540
|
-
if (/^(byop|hub):/.test(target)) throw new Error("remote: select an SSH/Docker/Incus target for this connection resolver");
|
|
3541
|
-
const plan = await this.resolveBareName(target, callCtx.sessionKey ?? "no-session", false);
|
|
4699
|
+
const prepared = await prepareRemoteInput(params);
|
|
3542
4700
|
this.assertOpen();
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
if (
|
|
3554
|
-
if (
|
|
4701
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4702
|
+
const form = parseTargetForm(prepared.target);
|
|
4703
|
+
const target = (id$1) => ({
|
|
4704
|
+
target: `pty:${id$1}`,
|
|
4705
|
+
cmd: params.cmd,
|
|
4706
|
+
stdin: params.stdin,
|
|
4707
|
+
timeout: params.timeout,
|
|
4708
|
+
mode: "pty"
|
|
4709
|
+
});
|
|
4710
|
+
const lane = this.createPtyLane(prepared, form, sessionKey);
|
|
4711
|
+
if (lane === void 0 && form.form === "pty-selector") {
|
|
4712
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build) or pass spawn to create one");
|
|
4713
|
+
const id$1 = this.resolvePty(form.idOrLabel, sessionKey).identity;
|
|
3555
4714
|
return {
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
4715
|
+
input: target(id$1.id),
|
|
4716
|
+
session: `pty:${id$1.id}`,
|
|
4717
|
+
connection: id$1.connection,
|
|
4718
|
+
...id$1.label !== void 0 ? { label: id$1.label } : {},
|
|
4719
|
+
cleanup: () => void 0
|
|
3559
4720
|
};
|
|
3560
4721
|
}
|
|
3561
|
-
if (
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
4722
|
+
if (lane === void 0) return void 0;
|
|
4723
|
+
const id = lane.session.identity;
|
|
4724
|
+
return {
|
|
4725
|
+
input: target(id.id),
|
|
4726
|
+
session: `pty:${id.id}`,
|
|
4727
|
+
connection: id.connection,
|
|
4728
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4729
|
+
cleanup: () => {
|
|
4730
|
+
lane.session.dispose();
|
|
4731
|
+
}
|
|
4732
|
+
};
|
|
4733
|
+
}
|
|
4734
|
+
/** `pty:<id|label>` 寻址:先 id 后 label;miss 给重建形式。 */
|
|
4735
|
+
resolvePty(selector, sessionKey) {
|
|
4736
|
+
const entry = this.labels.resolve(selector, sessionKey);
|
|
4737
|
+
if (entry?.kind === "pty") {
|
|
4738
|
+
const s = this.pool.get(`${sessionKey}|p:${entry.id}`);
|
|
4739
|
+
if (s !== void 0) return s;
|
|
3573
4740
|
}
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
4741
|
+
throw new Error(`[E_NO_SESSION] remote: no live PTY under ${PTY_ID_RE.test(selector) ? `id ${selector}` : `label ${JSON.stringify(selector)}`} — sessions are created by appending :pty to a transport target ({target:"ssh:dev4:pty", cmd:"…"}) or by spawn; idle sessions are reaped after ${this.ttlMinutes()} min`);
|
|
4742
|
+
}
|
|
4743
|
+
/** §6 close:默认撞 detached job 报错(列杀法);force = 杀组+关闭(披露 killed)。幂等 not-found。 */
|
|
4744
|
+
closePty(selector, sessionKey, force = false) {
|
|
4745
|
+
this.assertOpen();
|
|
4746
|
+
const entry = this.labels.resolve(selector, sessionKey);
|
|
4747
|
+
if (entry?.kind !== "pty") return {
|
|
4748
|
+
session: selector,
|
|
4749
|
+
connection: "",
|
|
4750
|
+
closed: false,
|
|
4751
|
+
reason: "not-found",
|
|
4752
|
+
killed: [],
|
|
4753
|
+
message: `no pty session is registered under ${JSON.stringify(selector)}`
|
|
3579
4754
|
};
|
|
3580
|
-
const
|
|
4755
|
+
const session = this.pool.get(`${sessionKey}|p:${entry.id}`);
|
|
4756
|
+
if (session === void 0) return {
|
|
4757
|
+
session: `pty:${entry.id}`,
|
|
4758
|
+
connection: entry.connection,
|
|
4759
|
+
closed: false,
|
|
4760
|
+
reason: "already-closed-or-reaped",
|
|
4761
|
+
killed: [],
|
|
4762
|
+
message: `pty:${entry.id} was already closed or reaped after ${this.ttlMinutes()} min idle`
|
|
4763
|
+
};
|
|
4764
|
+
const pending = session.detachedCommandId;
|
|
4765
|
+
if (pending !== null && !force) throw new Error(`[E_SESSION_BUSY] remote: pty session ${entry.id} still runs a detached command (${pending}) — job_kill ${pending} first (or wait for it), then close again; force:true closes the tab and kills the group in one step`);
|
|
4766
|
+
session.dispose();
|
|
3581
4767
|
return {
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
4768
|
+
session: `pty:${entry.id}`,
|
|
4769
|
+
connection: entry.connection,
|
|
4770
|
+
closed: true,
|
|
4771
|
+
...pending !== null ? { killed: [pending] } : { killed: [] }
|
|
3585
4772
|
};
|
|
3586
4773
|
}
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
const
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
this.hubProviders.add(hub);
|
|
3593
|
-
const machine = routing.display.slice(4);
|
|
3594
|
-
if (routing.mode === "pty") {
|
|
3595
|
-
const result$1 = await hub.pty(machine, params.cmd, this.hubOwner, {
|
|
3596
|
-
stdin: params.stdin,
|
|
3597
|
-
timeout: timeoutSec
|
|
3598
|
-
});
|
|
3599
|
-
const out$2 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
|
|
3600
|
-
return {
|
|
3601
|
-
...result$1,
|
|
3602
|
-
stdout: out$2.text,
|
|
3603
|
-
...out$2.stats ? { outputStats: {
|
|
3604
|
-
...result$1.outputStats,
|
|
3605
|
-
stdout: out$2.stats
|
|
3606
|
-
} } : {},
|
|
3607
|
-
...out$2.truncated === void 0 ? {} : { truncated: out$2.truncated }
|
|
3608
|
-
};
|
|
3609
|
-
}
|
|
3610
|
-
const process$1 = this.own(await hub.start(machine, {
|
|
3611
|
-
...params,
|
|
3612
|
-
target: routing.display,
|
|
3613
|
-
mode: "oneshot",
|
|
3614
|
-
timeout: timeoutSec
|
|
3615
|
-
}, this.hubOwner));
|
|
3616
|
-
if (this.disposed) process$1.cancel("owner disposed");
|
|
3617
|
-
const result = await process$1.done;
|
|
3618
|
-
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
3619
|
-
const out$1 = tailWindow(stdout.text, cap, stdout.stats);
|
|
3620
|
-
const err = tailWindow(stderr.text, cap, stderr.stats);
|
|
3621
|
-
return {
|
|
3622
|
-
...result,
|
|
3623
|
-
mode: "oneshot",
|
|
3624
|
-
target: routing.display,
|
|
3625
|
-
cwd: null,
|
|
3626
|
-
reconnected: false,
|
|
3627
|
-
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out$1.text,
|
|
3628
|
-
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
3629
|
-
...out$1.stats ? { outputStats: {
|
|
3630
|
-
stdout: out$1.stats,
|
|
3631
|
-
stderr: err.stats
|
|
3632
|
-
} } : { outputStatsUnavailable: true },
|
|
3633
|
-
...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
|
|
3634
|
-
};
|
|
4774
|
+
/** job 通道收养回填(timeout handoff 时 registry id ≠ commandId,账本记录两者)。 */
|
|
4775
|
+
attachPtyJob(commandId, jobId, sessionKey) {
|
|
4776
|
+
for (const { key } of this.pool.list()) {
|
|
4777
|
+
if (!key.startsWith(`${sessionKey ?? "no-session"}|`)) continue;
|
|
4778
|
+
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
3635
4779
|
}
|
|
4780
|
+
}
|
|
4781
|
+
/** §3.1(2026-10-25)interactive-stdin:input 动作——向已有 live PTY 的当前运行命令
|
|
4782
|
+
* 追加 stdin。只解析池内已存在的会话(pty:<id|label> / 裸 label 命中 pty 项),
|
|
4783
|
+
* 不触发发现、拨号、创建或重连;machine target 与创建形(:pty 后缀,含 hub:<m4>:pty)
|
|
4784
|
+
* 一律明确拒绝并给出可恢复指引。回执附 connection/label 供工具层渲染。 */
|
|
4785
|
+
async input(target, options, callCtx = {}) {
|
|
4786
|
+
this.assertOpen();
|
|
3636
4787
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
if (
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
4788
|
+
const form = parseTargetForm(target);
|
|
4789
|
+
let selector;
|
|
4790
|
+
if (form.form === "pty-selector") {
|
|
4791
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4792
|
+
selector = form.idOrLabel;
|
|
4793
|
+
} else if (form.form === "bare") {
|
|
4794
|
+
if (this.labels.resolve(form.name, sessionKey)?.kind !== "pty") throw new Error(`[E_NO_SESSION] remote: no live PTY session under ${JSON.stringify(form.name)} in this Agent's pool — input never dials, discovers or creates; run {action:"status"} to list sessions, then address one as pty:<id|label>`);
|
|
4795
|
+
selector = form.name;
|
|
4796
|
+
} else if (form.form === "create-pty" || form.form === "hub-pty") throw new Error(`[E_TARGET_FORMAT] remote: input writes into a live PTY session — "${target}" is a creation form; input never creates a session. Start the interactive program with {action:"exec", target:"${target}", cmd:…} first, then input into it`);
|
|
4797
|
+
else throw new Error(`[E_TARGET_FORMAT] remote: input writes into a live PTY session — "${target}" is a machine target and machines are dialed per call. Run the interactive program with exec on a :pty session (e.g. {action:"exec", target:"ssh:dev4:pty" or "hub:<m4>:pty", cmd:…}), then input into that session`);
|
|
4798
|
+
const session = this.resolvePty(selector, sessionKey);
|
|
4799
|
+
const receipt = await session.input(options);
|
|
4800
|
+
const id = session.identity;
|
|
4801
|
+
return {
|
|
4802
|
+
...receipt,
|
|
4803
|
+
connection: id.connection,
|
|
4804
|
+
...id.label !== void 0 ? { label: id.label } : {}
|
|
4805
|
+
};
|
|
4806
|
+
}
|
|
4807
|
+
async dispatchPty(session, params, callCtx, timeoutSec) {
|
|
4808
|
+
const hubConn = session.identity.connection;
|
|
4809
|
+
if (hubConn.startsWith("hub:")) {
|
|
4810
|
+
const hub = this.requireHub();
|
|
4811
|
+
this.hubProviders.add(hub);
|
|
4812
|
+
hub.ptyEndpoint(hubConn.slice(4));
|
|
3645
4813
|
}
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
this.assertOpen();
|
|
3649
|
-
const process$1 = this.own(await this.nativeProcess(routing.plan, params, timeoutSec));
|
|
3650
|
-
if (this.disposed) process$1.cancel("owner disposed");
|
|
3651
|
-
const result = await process$1.done;
|
|
3652
|
-
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
3653
|
-
const r$1 = {
|
|
3654
|
-
...result,
|
|
3655
|
-
stdout: stdout.text,
|
|
3656
|
-
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
3657
|
-
outputStats: {
|
|
3658
|
-
stdout: stdout.stats,
|
|
3659
|
-
stderr: stderr.stats
|
|
3660
|
-
}
|
|
3661
|
-
};
|
|
3662
|
-
const out$1 = tailWindow(r$1.stdout, cap, r$1.outputStats?.stdout);
|
|
3663
|
-
const err = r$1.stderr.length > 0 ? tailWindow(r$1.stderr, cap, r$1.outputStats?.stderr) : void 0;
|
|
3664
|
-
return {
|
|
3665
|
-
mode: "oneshot",
|
|
3666
|
-
target: routing.display,
|
|
3667
|
-
exit: r$1.exit,
|
|
3668
|
-
cwd: null,
|
|
3669
|
-
timedOut: r$1.timedOut,
|
|
3670
|
-
reconnected: false,
|
|
3671
|
-
stdout: out$1.text,
|
|
3672
|
-
...err !== void 0 ? { stderr: err.text } : {},
|
|
3673
|
-
...out$1.stats ? { outputStats: {
|
|
3674
|
-
stdout: out$1.stats,
|
|
3675
|
-
stderr: err?.stats ?? r$1.outputStats?.stderr
|
|
3676
|
-
} } : {},
|
|
3677
|
-
durationMs: r$1.durationMs,
|
|
3678
|
-
...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
|
|
3679
|
-
};
|
|
3680
|
-
}
|
|
3681
|
-
const argv = (this.opts.ptyArgvFor ?? buildPtyArgv)(routing.plan);
|
|
3682
|
-
const key = `${sessionKey}|t:${routing.display}`;
|
|
3683
|
-
const r = await this.pool.getOrCreate(key, argv).dispatch(params.cmd, {
|
|
4814
|
+
const commandId = callCtx.commandId ?? genCommandId();
|
|
4815
|
+
const r = await session.dispatch(params.cmd, {
|
|
3684
4816
|
stdin: params.stdin,
|
|
3685
|
-
timeoutSec
|
|
4817
|
+
timeoutSec,
|
|
4818
|
+
commandId,
|
|
4819
|
+
...callCtx.ptyBackground === true ? {
|
|
4820
|
+
background: true,
|
|
4821
|
+
jobId: commandId
|
|
4822
|
+
} : {}
|
|
3686
4823
|
});
|
|
3687
|
-
const out =
|
|
4824
|
+
const out = disclose(r.output, r.outputStats?.stdout);
|
|
4825
|
+
const id = session.identity;
|
|
3688
4826
|
return {
|
|
3689
4827
|
mode: "pty",
|
|
3690
|
-
target:
|
|
4828
|
+
target: `pty:${id.id}`,
|
|
4829
|
+
session: `pty:${id.id}`,
|
|
4830
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4831
|
+
connection: id.connection,
|
|
4832
|
+
...id.byo ? { byoTag: "byo-pty" } : {},
|
|
4833
|
+
commandId: r.commandId,
|
|
3691
4834
|
exit: r.exit,
|
|
3692
4835
|
cwd: r.cwd,
|
|
3693
4836
|
timedOut: r.timedOut,
|
|
@@ -3695,42 +4838,211 @@ var RemoteDriver = class {
|
|
|
3695
4838
|
stdout: out.text,
|
|
3696
4839
|
outputStats: out.stats ? { stdout: out.stats } : void 0,
|
|
3697
4840
|
durationMs: r.durationMs,
|
|
3698
|
-
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
4841
|
+
...out.truncated !== void 0 ? { truncated: out.truncated } : {},
|
|
4842
|
+
...r.envDelta !== void 0 ? { envDelta: r.envDelta } : {},
|
|
4843
|
+
...r.running === true && r.job !== void 0 ? {
|
|
4844
|
+
running: true,
|
|
4845
|
+
ptyJob: r.job
|
|
4846
|
+
} : {},
|
|
4847
|
+
...this.ptyTtl(session.key)
|
|
3699
4848
|
};
|
|
3700
4849
|
}
|
|
3701
|
-
/**
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
const display = `byop:${pty.label}`;
|
|
3705
|
-
const key = `${sessionKey}|p:${pty.label}`;
|
|
3706
|
-
const existing = this.pool.get(key);
|
|
3707
|
-
let session = existing;
|
|
3708
|
-
let notice;
|
|
3709
|
-
if (existing !== void 0 && pty.spawn !== void 0 && existing.origin !== pty.spawn) throw new Error(`[E_LABEL_CONFLICT] remote: ${display} already exists and was created by a different command — nothing was run.\n existing spawn: ${JSON.stringify(existing.origin)}\n given spawn: ${JSON.stringify(pty.spawn)}\n To reuse it: { target: "${display}", cmd: "…" } (drop spawn — spawn only ever creates)\n To start new: pick another label, e.g. { target: "byop:${pty.label}2", spawn: ${JSON.stringify(pty.spawn)}, cmd: "…" }`);
|
|
3710
|
-
if (session === void 0) {
|
|
3711
|
-
if (pty.spawn === void 0) throw new Error(`[E_NO_SESSION] remote: no live PTY under ${display} — one exists only after a call that supplies spawn, and it is reaped after the idle TTL (600s).\n Create it: { target: "${display}", spawn: "…", cmd: "…" } (the spawn command is not retained; the label is yours to choose)`);
|
|
3712
|
-
session = this.pool.getOrCreate(key, (this.opts.spawnArgvFor ?? buildSpawnArgv)(pty.spawn), pty.spawn);
|
|
3713
|
-
} else if (pty.spawn !== void 0) notice = `[remote: ${display} already runs that exact command and is persistent — reuse it with { target: "${display}", cmd: "…" }; spawn is only needed to create.]`;
|
|
3714
|
-
const r = await session.dispatch(params.cmd, {
|
|
3715
|
-
stdin: params.stdin,
|
|
3716
|
-
timeoutSec
|
|
3717
|
-
});
|
|
3718
|
-
const out = tailWindow(r.output, cap, r.outputStats?.stdout);
|
|
4850
|
+
/** 预热(创建形无 cmd)回执:§3——无 commandId/envDelta;timeout 忽略。 */
|
|
4851
|
+
warmReceipt(session) {
|
|
4852
|
+
const id = session.identity;
|
|
3719
4853
|
return {
|
|
3720
4854
|
mode: "pty",
|
|
4855
|
+
target: `pty:${id.id}`,
|
|
4856
|
+
session: `pty:${id.id}`,
|
|
4857
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4858
|
+
connection: id.connection,
|
|
4859
|
+
...id.byo ? { byoTag: "byo-pty" } : {},
|
|
4860
|
+
created: true,
|
|
4861
|
+
exit: null,
|
|
4862
|
+
cwd: null,
|
|
4863
|
+
timedOut: false,
|
|
4864
|
+
reconnected: false,
|
|
4865
|
+
stdout: "",
|
|
4866
|
+
durationMs: 0,
|
|
4867
|
+
...this.ptyTtl(session.key)
|
|
4868
|
+
};
|
|
4869
|
+
}
|
|
4870
|
+
/** session card 回执(`pty:<id|label>` 无 cmd):connection/state/cwd + 最近 20 条账本(不带输出尾,§9 Q7)。 */
|
|
4871
|
+
cardReceipt(session) {
|
|
4872
|
+
const id = session.identity;
|
|
4873
|
+
const snap = session.snapshot;
|
|
4874
|
+
const state = snap.busy ? "busy (a command is running)" : snap.state === "dead" ? "died — next dispatch reconnects a fresh shell" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s (connected)`;
|
|
4875
|
+
const head = `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} — ${id.connection}${id.byo ? " (byo-pty)" : id.builtOn ? " (built on)" : ""} · ${state} · cwd ${session.lastCwd ?? "(none yet)"}`;
|
|
4876
|
+
return {
|
|
4877
|
+
mode: "pty",
|
|
4878
|
+
target: `pty:${id.id}`,
|
|
4879
|
+
session: `pty:${id.id}`,
|
|
4880
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4881
|
+
connection: id.connection,
|
|
4882
|
+
...id.byo ? { byoTag: "byo-pty" } : {},
|
|
4883
|
+
exit: null,
|
|
4884
|
+
cwd: session.lastCwd,
|
|
4885
|
+
timedOut: false,
|
|
4886
|
+
reconnected: false,
|
|
4887
|
+
stdout: [head, renderLedger(session.ledgerSnapshot())].join("\n"),
|
|
4888
|
+
durationMs: 0,
|
|
4889
|
+
...this.ptyTtl(session.key)
|
|
4890
|
+
};
|
|
4891
|
+
}
|
|
4892
|
+
async oneshot(plan, display, params, timeoutSec) {
|
|
4893
|
+
this.assertOpen();
|
|
4894
|
+
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
4895
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
4896
|
+
const result = await process$1.done;
|
|
4897
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4898
|
+
const r = {
|
|
4899
|
+
...result,
|
|
4900
|
+
stdout: stdout.text,
|
|
4901
|
+
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4902
|
+
outputStats: {
|
|
4903
|
+
stdout: stdout.stats,
|
|
4904
|
+
stderr: stderr.stats
|
|
4905
|
+
}
|
|
4906
|
+
};
|
|
4907
|
+
const out = disclose(r.stdout, r.outputStats?.stdout);
|
|
4908
|
+
const err = r.stderr.length > 0 ? disclose(r.stderr, r.outputStats?.stderr) : void 0;
|
|
4909
|
+
return {
|
|
4910
|
+
mode: "oneshot",
|
|
3721
4911
|
target: display,
|
|
3722
|
-
session: display,
|
|
3723
4912
|
exit: r.exit,
|
|
3724
|
-
cwd:
|
|
4913
|
+
cwd: null,
|
|
3725
4914
|
timedOut: r.timedOut,
|
|
3726
|
-
reconnected:
|
|
4915
|
+
reconnected: false,
|
|
3727
4916
|
stdout: out.text,
|
|
3728
|
-
...
|
|
3729
|
-
|
|
4917
|
+
...err !== void 0 ? { stderr: err.text } : {},
|
|
4918
|
+
...out.stats ? { outputStats: {
|
|
4919
|
+
stdout: out.stats,
|
|
4920
|
+
stderr: err?.stats ?? r.outputStats?.stderr
|
|
4921
|
+
} } : {},
|
|
3730
4922
|
durationMs: r.durationMs,
|
|
3731
|
-
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
4923
|
+
...out.truncated !== void 0 ? { truncated: out.truncated } : {},
|
|
4924
|
+
...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
|
|
3732
4925
|
};
|
|
3733
4926
|
}
|
|
4927
|
+
async hubOneshot(machine, display, params, timeoutSec) {
|
|
4928
|
+
const process$1 = this.own(await this.hubOneshotProcess(machine, params));
|
|
4929
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
4930
|
+
const result = await process$1.done;
|
|
4931
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4932
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4933
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
4934
|
+
return {
|
|
4935
|
+
...result,
|
|
4936
|
+
mode: "oneshot",
|
|
4937
|
+
target: display,
|
|
4938
|
+
connection: display,
|
|
4939
|
+
cwd: null,
|
|
4940
|
+
reconnected: false,
|
|
4941
|
+
runner: HUB_RUNNER,
|
|
4942
|
+
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4943
|
+
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4944
|
+
...out.stats ? { outputStats: {
|
|
4945
|
+
stdout: out.stats,
|
|
4946
|
+
stderr: err.stats
|
|
4947
|
+
} } : { outputStatsUnavailable: true },
|
|
4948
|
+
...out.truncated === void 0 ? {} : { truncated: out.truncated }
|
|
4949
|
+
};
|
|
4950
|
+
}
|
|
4951
|
+
async hubOneshotProcess(machine, params) {
|
|
4952
|
+
const hub = this.requireHub();
|
|
4953
|
+
this.hubProviders.add(hub);
|
|
4954
|
+
return await hub.start(machine, {
|
|
4955
|
+
...params,
|
|
4956
|
+
target: `hub:${machine}`,
|
|
4957
|
+
mode: "oneshot"
|
|
4958
|
+
}, this.hubOwner);
|
|
4959
|
+
}
|
|
4960
|
+
/** Ruling P19 + §2.5:roster = 注意力入口——本地扫描刷新统一池,零拨号;机器面公共,会话面 caller-scoped。 */
|
|
4961
|
+
async roster(callCtx = {}) {
|
|
4962
|
+
this.assertOpen();
|
|
4963
|
+
const scan = await scanLocal({
|
|
4964
|
+
runner: this.opts.rosterRunner,
|
|
4965
|
+
readSshConfig: this.opts.sshConfigReader
|
|
4966
|
+
});
|
|
4967
|
+
this.assertOpen();
|
|
4968
|
+
this.labels.mergeScan(callCtx.sessionKey ?? "no-session", {
|
|
4969
|
+
sshHosts: scan.ssh.hosts,
|
|
4970
|
+
docker: scan.docker.rows.map((r) => r.name),
|
|
4971
|
+
incus: scan.incus.rows.map((r) => r.name),
|
|
4972
|
+
hub: this.opts.getHub?.()?.listTargets().map((t) => t.id) ?? []
|
|
4973
|
+
});
|
|
4974
|
+
const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
|
|
4975
|
+
const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key }) => {
|
|
4976
|
+
const s = this.pool.get(key);
|
|
4977
|
+
return s === void 0 ? null : s;
|
|
4978
|
+
}).filter((s) => s !== null);
|
|
4979
|
+
const hub = this.opts.getHub?.();
|
|
4980
|
+
return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets());
|
|
4981
|
+
}
|
|
4982
|
+
/** status probe(target 无 cmd):机器面 = probe + 该 connection 上的会话;pty selector = session card。 */
|
|
4983
|
+
async status(target, callCtx = {}) {
|
|
4984
|
+
this.assertOpen();
|
|
4985
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4986
|
+
const form = parseTargetForm(target);
|
|
4987
|
+
if (form.form === "hub" || form.form === "hub-pty") {
|
|
4988
|
+
const row = this.requireHub().listTargets().find((t) => t.id === form.machine);
|
|
4989
|
+
if (!row) throw new Error(`unknown remote machine ${form.machine} — select a remote machine from the Hub roster; local execution is not available`);
|
|
4990
|
+
return `hub:${form.machine} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`;
|
|
4991
|
+
}
|
|
4992
|
+
if (form.form === "pty-selector") {
|
|
4993
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4994
|
+
return this.cardReceipt(this.resolvePty(form.idOrLabel, sessionKey)).stdout;
|
|
4995
|
+
}
|
|
4996
|
+
if (form.form !== "direct" && form.form !== "bare") throw new Error("[E_PARAMS] remote: status takes a machine target (bare name or ssh:/docker:/incus:/hub:) or a pty:<id|label> selector");
|
|
4997
|
+
const name$1 = directName(form) || (form.form === "bare" ? form.name : "");
|
|
4998
|
+
const entry = await this.resolvePoolEntry(name$1, sessionKey);
|
|
4999
|
+
this.assertOpen();
|
|
5000
|
+
if (entry.kind === "pty") {
|
|
5001
|
+
if (form.form !== "bare") throw new Error("[E_TARGET_FORMAT] remote: ssh:/docker:/incus: force a machine channel — the pty selector is pty:<id|label>");
|
|
5002
|
+
return this.cardReceipt(this.resolvePty(form.name, sessionKey)).stdout;
|
|
5003
|
+
}
|
|
5004
|
+
if (entry.kind === "hub") return this.status(`hub:${entry.machine}`, callCtx);
|
|
5005
|
+
const plan = this.planOf(entry);
|
|
5006
|
+
const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}${entry.kind === "ssh-byo" ? " (built on)" : ""}`;
|
|
5007
|
+
const runner = this.opts.probeRunner ?? runOneShot;
|
|
5008
|
+
const probe = await probeTarget(plan, { runner: (argv, options) => {
|
|
5009
|
+
this.assertOpen();
|
|
5010
|
+
return runner(argv, options);
|
|
5011
|
+
} });
|
|
5012
|
+
const prefix = `${sessionKey}|`;
|
|
5013
|
+
const onConnection = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key }) => this.pool.get(key)).filter((s) => s !== void 0 && s.identity.connection === `${plan.kind === "ssh" ? "ssh" : plan.kind}:${name$1}`);
|
|
5014
|
+
return [
|
|
5015
|
+
head,
|
|
5016
|
+
renderProbe(probe),
|
|
5017
|
+
onConnection.length > 0 ? `pty sessions on this connection: ${onConnection.map(sessionLine).join(" · ")}` : "pty sessions on this connection: none"
|
|
5018
|
+
].join("\n");
|
|
5019
|
+
}
|
|
5020
|
+
/** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径。 */
|
|
5021
|
+
audit(r) {
|
|
5022
|
+
try {
|
|
5023
|
+
this.opts.onAudit?.(r);
|
|
5024
|
+
} catch {}
|
|
5025
|
+
}
|
|
5026
|
+
/** Single target resolver for later copy/rsync; PTY sessions are not filesystem connections. */
|
|
5027
|
+
async resolveConnectionTarget(target, callCtx = {}) {
|
|
5028
|
+
this.assertOpen();
|
|
5029
|
+
const form = parseTargetForm(target);
|
|
5030
|
+
if (form.form === "pty-selector") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
5031
|
+
if (form.form === "hub" || form.form === "hub-pty") throw new Error("remote: hub endpoints resolve through the Hub file lane, not a CLI connection");
|
|
5032
|
+
if (form.form === "create-pty") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
5033
|
+
if (form.form === "direct") return form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
5034
|
+
if (form.form === "ssh-prefix" || form.form === "empty") throw new Error("[E_TARGET_FORMAT] remote: connection targets need a name");
|
|
5035
|
+
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
5036
|
+
this.assertOpen();
|
|
5037
|
+
return this.planOf(entry);
|
|
5038
|
+
}
|
|
5039
|
+
/** P2/P3:回执即状态快照——idle TTL 剩余随每次 pty 回执带回。 */
|
|
5040
|
+
ptyTtl(key) {
|
|
5041
|
+
const snap = this.pool.inspect(key);
|
|
5042
|
+
if (snap === void 0 || snap.state === "dead" || snap.state === "disposed") return {};
|
|
5043
|
+
if (snap.idleMs === null) return { ttlSec: this.ptyIdleTtlSec };
|
|
5044
|
+
return { ttlSec: Math.max(0, Math.round(this.ptyIdleTtlSec - snap.idleMs / 1e3)) };
|
|
5045
|
+
}
|
|
3734
5046
|
hasHub() {
|
|
3735
5047
|
return this.opts.getHub?.() !== void 0;
|
|
3736
5048
|
}
|
|
@@ -3745,8 +5057,8 @@ var RemoteDriver = class {
|
|
|
3745
5057
|
}
|
|
3746
5058
|
dispose() {
|
|
3747
5059
|
if (this.disposal) return this.disposal;
|
|
3748
|
-
this.sshTargets.clear();
|
|
3749
5060
|
this.disposed = true;
|
|
5061
|
+
this.labels.clear();
|
|
3750
5062
|
this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
|
|
3751
5063
|
for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
|
|
3752
5064
|
return this.disposal = (async () => {
|
|
@@ -3760,13 +5072,87 @@ var RemoteDriver = class {
|
|
|
3760
5072
|
})();
|
|
3761
5073
|
}
|
|
3762
5074
|
};
|
|
5075
|
+
/** direct 形态回退到 resolveTarget 可解析的名字(ssh:dev4 → dev4)。 */
|
|
5076
|
+
function directName(form) {
|
|
5077
|
+
return form.form === "direct" ? form.name : form.form === "create-pty" ? form.name : "";
|
|
5078
|
+
}
|
|
5079
|
+
/** roster/status 一行的会话描述:`p-ab12 (label build) ssh:dev4 idle 12s`。 */
|
|
5080
|
+
function sessionLine(s) {
|
|
5081
|
+
const id = s.identity;
|
|
5082
|
+
const snap = s.snapshot;
|
|
5083
|
+
const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
|
|
5084
|
+
return `${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} ${state}`;
|
|
5085
|
+
}
|
|
3763
5086
|
|
|
3764
5087
|
//#endregion
|
|
3765
5088
|
//#region src/remote/jobs.ts
|
|
5089
|
+
/** PTY job 的单通道输出:流在 PTY 里天然合并,无独立 stderr 通道(P5 如实披露)。
|
|
5090
|
+
* read 是 byte-cursor 增量(Utf16TailRing.read 语义:逐字节游标 + lossy 报尾环逐出)。 */
|
|
5091
|
+
const ptyOutputSources = (holder) => [{
|
|
5092
|
+
channel: "stdout",
|
|
5093
|
+
read: (fromByte) => holder.job?.read(fromByte) ?? {
|
|
5094
|
+
text: "",
|
|
5095
|
+
nextOffset: fromByte,
|
|
5096
|
+
lossy: false
|
|
5097
|
+
}
|
|
5098
|
+
}];
|
|
5099
|
+
/** 0.3.2-d CF:timedOut=true 且 aborted=false = 会话侧主动收束(close force / 行回收走的
|
|
5100
|
+
* dispose→interrupt 两段路)——同为 killed;exit 到手用 exit,只剩会话死亡时写 session closed。 */
|
|
5101
|
+
const ptyOutcome = (aborted, result) => {
|
|
5102
|
+
const interrupted = aborted || result.timedOut === true;
|
|
5103
|
+
return {
|
|
5104
|
+
status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
5105
|
+
detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
5106
|
+
};
|
|
5107
|
+
};
|
|
3766
5108
|
/** No preparation or discovery before the official registry's admission. */
|
|
3767
5109
|
function startRemoteJob(registry, input, exec, driver) {
|
|
3768
|
-
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");
|
|
3769
5110
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
5111
|
+
const form = parseTargetForm(input.target);
|
|
5112
|
+
if (form.form === "pty-selector" || form.form === "create-pty" || form.form === "hub-pty" || input.spawn !== void 0 || input.mode === "pty") {
|
|
5113
|
+
const holder = {};
|
|
5114
|
+
return registry.start({
|
|
5115
|
+
kind: "remote",
|
|
5116
|
+
owner: exec.agent.id,
|
|
5117
|
+
label: `${input.target}: ${input.cmd}`,
|
|
5118
|
+
output: ptyOutputSources(holder),
|
|
5119
|
+
run(job) {
|
|
5120
|
+
const controller = new AbortController();
|
|
5121
|
+
return {
|
|
5122
|
+
done: (async () => {
|
|
5123
|
+
try {
|
|
5124
|
+
const r = await driver.call({
|
|
5125
|
+
...input,
|
|
5126
|
+
mode: "pty"
|
|
5127
|
+
}, {
|
|
5128
|
+
ptyBackground: true,
|
|
5129
|
+
commandId: job.id,
|
|
5130
|
+
sessionKey: exec.agent?.id ?? "no-session"
|
|
5131
|
+
});
|
|
5132
|
+
if (r.running !== true || r.ptyJob === void 0) return {
|
|
5133
|
+
status: "failed",
|
|
5134
|
+
detail: "pty dispatch did not hand off a running job"
|
|
5135
|
+
};
|
|
5136
|
+
holder.job = r.ptyJob;
|
|
5137
|
+
if (controller.signal.aborted) r.ptyJob.interrupt();
|
|
5138
|
+
const outcome = await r.ptyJob.done;
|
|
5139
|
+
return ptyOutcome(controller.signal.aborted, outcome);
|
|
5140
|
+
} catch (error) {
|
|
5141
|
+
return {
|
|
5142
|
+
status: controller.signal.aborted ? "killed" : "failed",
|
|
5143
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
5144
|
+
};
|
|
5145
|
+
}
|
|
5146
|
+
})(),
|
|
5147
|
+
cancel(reason) {
|
|
5148
|
+
if (controller.signal.aborted) return;
|
|
5149
|
+
controller.abort(reason);
|
|
5150
|
+
holder.job?.interrupt();
|
|
5151
|
+
}
|
|
5152
|
+
};
|
|
5153
|
+
}
|
|
5154
|
+
});
|
|
5155
|
+
}
|
|
3770
5156
|
let process$1;
|
|
3771
5157
|
const output = ["stdout", "stderr"].map((channel) => ({
|
|
3772
5158
|
channel,
|
|
@@ -3786,7 +5172,7 @@ function startRemoteJob(registry, input, exec, driver) {
|
|
|
3786
5172
|
return registry.start({
|
|
3787
5173
|
kind: "remote",
|
|
3788
5174
|
owner: exec.agent.id,
|
|
3789
|
-
label: `${input.target}: ${input.cmd
|
|
5175
|
+
label: `${input.target}: ${input.cmd}`,
|
|
3790
5176
|
output,
|
|
3791
5177
|
run() {
|
|
3792
5178
|
const controller = new AbortController();
|
|
@@ -3796,7 +5182,7 @@ function startRemoteJob(registry, input, exec, driver) {
|
|
|
3796
5182
|
process$1 = await driver.start({
|
|
3797
5183
|
...input,
|
|
3798
5184
|
mode: "oneshot"
|
|
3799
|
-
}, controller.signal);
|
|
5185
|
+
}, controller.signal, { sessionKey: exec.agent?.id ?? "no-session" });
|
|
3800
5186
|
if (controller.signal.aborted) process$1.cancel(controller.signal.reason);
|
|
3801
5187
|
const result = await process$1.done;
|
|
3802
5188
|
const diagnostic = process$1.read("stderr", 0).diagnostic;
|
|
@@ -3820,82 +5206,351 @@ function startRemoteJob(registry, input, exec, driver) {
|
|
|
3820
5206
|
}
|
|
3821
5207
|
});
|
|
3822
5208
|
}
|
|
5209
|
+
/** P4(2026-10-19)handoff 收养:同步 pty dispatch 超时后命令仍在跑——注册成 job,
|
|
5210
|
+
* job_output 即增量轮询、job_kill 即显式中断(P8 ^C+130-marker,会话存活)。 */
|
|
5211
|
+
function adoptRunningPtyJob(registry, exec, label, job) {
|
|
5212
|
+
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
5213
|
+
return registry.start({
|
|
5214
|
+
kind: "remote",
|
|
5215
|
+
owner: exec.agent.id,
|
|
5216
|
+
label,
|
|
5217
|
+
output: ptyOutputSources({ job }),
|
|
5218
|
+
run() {
|
|
5219
|
+
const controller = new AbortController();
|
|
5220
|
+
return {
|
|
5221
|
+
done: (async () => {
|
|
5222
|
+
if (controller.signal.aborted) job.interrupt();
|
|
5223
|
+
const outcome = await job.done;
|
|
5224
|
+
return ptyOutcome(controller.signal.aborted, outcome);
|
|
5225
|
+
})(),
|
|
5226
|
+
cancel(reason) {
|
|
5227
|
+
if (controller.signal.aborted) return;
|
|
5228
|
+
controller.abort(reason);
|
|
5229
|
+
job.interrupt();
|
|
5230
|
+
}
|
|
5231
|
+
};
|
|
5232
|
+
}
|
|
5233
|
+
});
|
|
5234
|
+
}
|
|
3823
5235
|
|
|
3824
5236
|
//#endregion
|
|
3825
5237
|
//#region src/remote/tool.ts
|
|
3826
|
-
const NO_SESSION_KEY = "remote:no-session";
|
|
5238
|
+
const NO_SESSION_KEY$1 = "remote:no-session";
|
|
3827
5239
|
/**
|
|
3828
|
-
* The model-visible surface of the `remote` tool.
|
|
3829
|
-
*
|
|
3830
|
-
*
|
|
5240
|
+
* The model-visible surface of the `remote` tool (0.3.2-d user-ruled restructure:
|
|
5241
|
+
* Actions / Targets / PTY / Oneshot / Examples — the PTY block is the user's verbatim
|
|
5242
|
+
* final draft; Examples receipts match renderValue byte-for-byte).
|
|
5243
|
+
* ONE template literal — no backticks, no "${".
|
|
5244
|
+
*
|
|
5245
|
+
* 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
|
|
5246
|
+
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
5247
|
+
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
3831
5248
|
*/
|
|
3832
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
5249
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote shell over ssh / docker / incus (Hub when configured): stateless oneshots + persistent interactive PTY sessions. Default to PTY for multi-step or interactive work; oneshot for isolated commands.
|
|
5250
|
+
|
|
5251
|
+
Actions (action is the only dispatch dimension; unknown fields are rejected):
|
|
5252
|
+
- exec {target, cmd | scriptPath, stdin?, timeout?, run_in_background?}: run a command. Machine target → oneshot; pty:<id|label> → run inside that live session; <machine>:pty[:label] → create a NEW session and run there.
|
|
5253
|
+
- add {ssh:{...} | spawn:"<cmd>", target?}: register a BYO endpoint (identity or interactive-shell command); no command runs. Returns the addressable label; a taken label answers [E_LABEL_IN_USE] — address existing names directly, never add again.
|
|
5254
|
+
- status {}: roster — the one unified pool (machines, identities, live PTY sessions).
|
|
5255
|
+
- status {target}: machine probe, or a session card (connection, cwd, ledger of the last 20 commands).
|
|
5256
|
+
- input {target:"pty:<id|label>", stdin, commandId?}: write stdin into the command currently running in that session — passwords, OTP, tokens, menu choices, REPL lines. Never creates a session, never dials.
|
|
5257
|
+
- close {target, force?}: close a PTY session; a running command refuses it (job_kill first) unless force:true kills it in the same step.
|
|
5258
|
+
|
|
5259
|
+
Targets:
|
|
5260
|
+
- ssh:<name> / docker:<name> / incus:<name> / bare pool name → stateless oneshot.
|
|
5261
|
+
- <machine>:pty[:label] → NEW PTY every call — creation is a target suffix, not a different tool.
|
|
5262
|
+
- pty:<id|label> (or its bare label) → REUSE the live session.
|
|
5263
|
+
After creation, reuse the returned session; repeating :pty opens another. Prefixes force the channel; bare names resolve through the roster.
|
|
5264
|
+
|
|
5265
|
+
PTY:
|
|
5266
|
+
- stateful oneshot, interact-able stdin, managed output, persistent env
|
|
5267
|
+
- timeout (default 120s) → command keeps running; returns job handle.
|
|
5268
|
+
- run_in_background → same job lane. jobId = commandId.
|
|
5269
|
+
- Read: job_output(jobId). Cancel: job_kill; try Ctrl-C first.
|
|
5270
|
+
- Busy session: input / poll / cancel / use another session.
|
|
5271
|
+
- exec.stdin = startup input; input = subsequent input.
|
|
5272
|
+
- input is verbatim: "\\n" submits; "\\u0003" Ctrl-C; "\\u0004" Ctrl-D; "" writes nothing. Ctrl-D follows terminal EOF rules.
|
|
5273
|
+
- commandId, if supplied, must match running command.
|
|
5274
|
+
- Input receipt confirms write only; read result via SAME job.
|
|
5275
|
+
- Output returns in full up to retention (4M chars per stream; Hub oneshot 4MiB bytes) — beyond it the retained tail plus outputStats. Redirect bulk output to files.
|
|
5276
|
+
- Idle expiry: 15 min; running commands exempt.
|
|
5277
|
+
|
|
5278
|
+
Oneshot:
|
|
5279
|
+
- stdout/stderr separate, exit code verbatim; no retained shell state. stdin is startup-only: there is no input after start — a prompt on a oneshot means cancel (job_kill) and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives.
|
|
5280
|
+
- Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run), falls back to a POSIX shim — the receipt field "runner" says which ran. Either way children are reaped on completion and the whole group is killed on timeout/cancel. The endpoint needs bash only — no Python.
|
|
5281
|
+
- scriptPath (oneshot only): a LOCAL absolute script read before any dialing, sent literally over stdin to a fixed bash -s — the file carrier for multi-line scripts; pairs with neither cmd nor stdin.
|
|
5282
|
+
|
|
5283
|
+
Examples
|
|
5284
|
+
|
|
5285
|
+
Manage the persistent PTY session. One-shot style output, but stable: every command returns synchronously with exit, cwd, and an env delta.
|
|
5286
|
+
|
|
5287
|
+
No direct route to the endpoint? Compose the transport and land the session on the innermost machine — bring your own PTY. Here: borrow pc as a jump host to reach dev4.
|
|
5288
|
+
|
|
5289
|
+
remote { "action": "add", "spawn": "ssh -t pc ssh -t dev4", "target": "pty:dev4via" }
|
|
5290
|
+
|
|
5291
|
+
[session created — address it as pty:pty-irsq]
|
|
5292
|
+
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · idle-left ~15m · exit null · 0.0s]
|
|
5293
|
+
|
|
5294
|
+
remote { "action": "exec", "target": "pty:dev4via", "cmd": "hostname && sudo -n ss -tlnp | awk 'NR==1 || /LISTEN/' | head -5" }
|
|
5295
|
+
|
|
5296
|
+
dev4
|
|
5297
|
+
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
|
5298
|
+
LISTEN 0 4096 127.0.0.1:45207 0.0.0.0:* users:(("containerd",pid=1070,fd=14))
|
|
5299
|
+
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=5600,fd=4))
|
|
5300
|
+
LISTEN 0 4096 127.0.0.1:19514 0.0.0.0:* users:(("multica",pid=27030,fd=6))
|
|
5301
|
+
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=26758,fd=17))
|
|
5302
|
+
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · cmd remote-0d89215d1cc64cdc · cwd /home/u1 · idle-left ~15m · exit 0 · 0.3s]
|
|
5303
|
+
|
|
5304
|
+
Reuse the same session for a long task — returns a jobId immediately; collect with job_output, cancel with job_kill:
|
|
5305
|
+
|
|
5306
|
+
remote { "action": "exec", "target": "pty:dev4via", "cmd": "for i in 1 2 3; do sleep 1; echo \\"tick $i\\"; done; echo done", "run_in_background": true }
|
|
5307
|
+
|
|
5308
|
+
Started remote-47676dff2c6443e8 on pty:pty-irsq (dev4via) (BYOP). Use job_output to read and job_kill to cancel; completion is reported by the host.
|
|
3840
5309
|
|
|
3841
|
-
|
|
5310
|
+
job_output { "job_id": "remote-47676dff2c6443e8", "wait": true }
|
|
3842
5311
|
|
|
3843
|
-
|
|
5312
|
+
tick 1
|
|
5313
|
+
tick 2
|
|
5314
|
+
tick 3
|
|
5315
|
+
done
|
|
5316
|
+
[status: completed, exit code: 0]
|
|
3844
5317
|
|
|
3845
|
-
|
|
5318
|
+
- Bring Your Own PTY fits when the endpoint has no direct route, or the connection/transport layer must be composed yourself (jump chains, docker exec, console servers).
|
|
5319
|
+
- It also strips one or two shell-escaping layers — commands go straight to the innermost bash, quotes behave naturally.
|
|
5320
|
+
|
|
5321
|
+
Register once — the label enters the pool and works everywhere: exec, pty, copy, rsync. Worth it whenever more than one operation type is involved. Keys stay local-file references; nothing persisted.
|
|
5322
|
+
|
|
5323
|
+
remote { "action": "add", "ssh": { "host": "10.0.0.8", "user": "u1", "port": 22, "key": "/home/u1/.ssh/id_key4" }, "target": "ssh:lab" }
|
|
5324
|
+
remote { "action": "exec", "target": "ssh:lab", "cmd": "uname -a" }
|
|
5325
|
+
remote_transfer { "mode": "rsync", "src": "ssh:lab:/var/log/app/", "dst": "/home/u1/app-logs/", "argv": ["-a"] }`;
|
|
5326
|
+
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
5327
|
+
const ACTION_PARAMS = {
|
|
5328
|
+
exec: new Set([
|
|
5329
|
+
"action",
|
|
5330
|
+
"target",
|
|
5331
|
+
"cmd",
|
|
5332
|
+
"stdin",
|
|
5333
|
+
"scriptPath",
|
|
5334
|
+
"timeout",
|
|
5335
|
+
"run_in_background"
|
|
5336
|
+
]),
|
|
5337
|
+
add: new Set([
|
|
5338
|
+
"action",
|
|
5339
|
+
"target",
|
|
5340
|
+
"ssh",
|
|
5341
|
+
"spawn"
|
|
5342
|
+
]),
|
|
5343
|
+
status: new Set(["action", "target"]),
|
|
5344
|
+
close: new Set([
|
|
5345
|
+
"action",
|
|
5346
|
+
"target",
|
|
5347
|
+
"force"
|
|
5348
|
+
]),
|
|
5349
|
+
input: new Set([
|
|
5350
|
+
"action",
|
|
5351
|
+
"target",
|
|
5352
|
+
"stdin",
|
|
5353
|
+
"commandId"
|
|
5354
|
+
])
|
|
5355
|
+
};
|
|
5356
|
+
const ACTION_HINT = {
|
|
5357
|
+
exec: "exec takes target plus cmd (or scriptPath) and the execution options; ssh/spawn belong to add",
|
|
5358
|
+
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
5359
|
+
status: "status takes only an optional target — omit it for the roster",
|
|
5360
|
+
close: "close takes target pty:<id|label> and optional force",
|
|
5361
|
+
input: "input takes target (a live PTY session), stdin, and an optional commandId guard — no cmd, no timeout, no creation"
|
|
5362
|
+
};
|
|
5363
|
+
async function remoteExecute(ENV, args, exec) {
|
|
5364
|
+
const action = args.action;
|
|
5365
|
+
if (action === void 0) throw new Error("[E_PARAMS] remote: action is required — \"exec\" (run a command), \"add\" (register a BYO endpoint), \"status\" (roster / probe / session card), \"close\" (close a PTY session), \"input\" (append stdin to a running PTY command)");
|
|
5366
|
+
const allowed = ACTION_PARAMS[action];
|
|
5367
|
+
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / add / close / status / input`);
|
|
5368
|
+
for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`[E_PARAMS] remote: "${key}" does not belong to action "${action}" — ${ACTION_HINT[action]}`);
|
|
5369
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
5370
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
5371
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
5372
|
+
if (action === "status") {
|
|
5373
|
+
if (!hasTarget) return {
|
|
5374
|
+
kind: "roster",
|
|
5375
|
+
text: await ENV.driver.roster({ sessionKey }),
|
|
5376
|
+
exit: null,
|
|
5377
|
+
durationMs: 0
|
|
5378
|
+
};
|
|
5379
|
+
return {
|
|
5380
|
+
kind: "status",
|
|
5381
|
+
text: await ENV.driver.status(args.target, { sessionKey }),
|
|
5382
|
+
exit: null,
|
|
5383
|
+
durationMs: 0
|
|
5384
|
+
};
|
|
5385
|
+
}
|
|
5386
|
+
if (action === "close") {
|
|
5387
|
+
if (!hasTarget) throw new Error("[E_PARAMS] close requires target pty:<id|label>");
|
|
5388
|
+
const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, sessionKey, args.force === true);
|
|
5389
|
+
return {
|
|
5390
|
+
kind: "close",
|
|
5391
|
+
text: r$1.closed ? `closed ${r$1.session} (${r$1.connection})${r$1.killed.length > 0 ? ` — killed running job ${r$1.killed.join(", ")}` : ""}` : `${r$1.session}: not closed (${r$1.reason}) — ${r$1.message ?? "no live session"}`,
|
|
5392
|
+
exit: null,
|
|
5393
|
+
durationMs: 0,
|
|
5394
|
+
session: r$1.session,
|
|
5395
|
+
closed: r$1.closed,
|
|
5396
|
+
...r$1.reason !== void 0 ? { reason: r$1.reason } : {},
|
|
5397
|
+
killed: r$1.killed
|
|
5398
|
+
};
|
|
5399
|
+
}
|
|
5400
|
+
if (action === "input") {
|
|
5401
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"input\" requires target — the live PTY session whose command is waiting (pty:<id|label> or its bare label)");
|
|
5402
|
+
if (args.stdin === void 0) throw new Error("[E_PARAMS] remote: action \"input\" requires stdin — the exact text to append; include a trailing \\n to submit a line, \"\\u0003\" for Ctrl-C, \"\\u0004\" for Ctrl-D (\"\" = zero bytes, not EOF)");
|
|
5403
|
+
if (typeof args.commandId === "string" && args.commandId.length === 0) throw new Error("[E_PARAMS] remote: input: commandId, when given, must be the non-empty id from the exec receipt / job_output");
|
|
5404
|
+
const r$1 = await ENV.driver.input(args.target, {
|
|
5405
|
+
stdin: args.stdin,
|
|
5406
|
+
...args.commandId !== void 0 ? { commandId: args.commandId } : {}
|
|
5407
|
+
}, { sessionKey });
|
|
5408
|
+
const handle = r$1.jobId ?? r$1.commandId;
|
|
5409
|
+
return {
|
|
5410
|
+
kind: "input",
|
|
5411
|
+
text: `wrote ${r$1.writtenBytes} byte${r$1.writtenBytes === 1 ? "" : "s"} into ${r$1.session} for command ${r$1.commandId} — this confirms the write, not the program's response; read the response with job_output ${handle}`,
|
|
5412
|
+
exit: null,
|
|
5413
|
+
durationMs: 0,
|
|
5414
|
+
session: r$1.session,
|
|
5415
|
+
connection: r$1.connection,
|
|
5416
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
5417
|
+
commandId: r$1.commandId,
|
|
5418
|
+
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
5419
|
+
writtenBytes: r$1.writtenBytes
|
|
5420
|
+
};
|
|
5421
|
+
}
|
|
5422
|
+
if (action === "add") {
|
|
5423
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
5424
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
|
|
5425
|
+
if (args.ssh === void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: action \"add\" registers a BYO endpoint — pass ssh:{host,...} (identity) or spawn:\"<command>\" (PTY), exactly one");
|
|
5426
|
+
if (args.ssh !== void 0 && hasSpawn) throw new Error("[E_PARAMS] remote: add: ssh and spawn are two different BYO entry points — pass one, not both");
|
|
5427
|
+
const r$1 = await ENV.driver.call({
|
|
5428
|
+
...hasTarget ? { target: args.target } : {},
|
|
5429
|
+
...args.ssh === void 0 ? {} : { ssh: args.ssh },
|
|
5430
|
+
...hasSpawn ? { spawn: args.spawn } : {},
|
|
5431
|
+
cmd: ""
|
|
5432
|
+
}, { sessionKey });
|
|
5433
|
+
const v$1 = workbenchValue(r$1);
|
|
5434
|
+
v$1.created = true;
|
|
5435
|
+
if (!hasSpawn) v$1.notice = `registered — address it as ${r$1.session ?? r$1.target} from now on (the label, id and ssh alias all work)`;
|
|
5436
|
+
return v$1;
|
|
5437
|
+
}
|
|
5438
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"exec\" requires target — a machine (ssh:dev4, docker:corti, hub:<m4>, a pool name) or a PTY session (pty:<id|label>, a bare label, or <machine>:pty[:label] to create one). Status probes and the roster live under action \"status\".");
|
|
5439
|
+
const target = args.target;
|
|
5440
|
+
const ptyTarget = /^pty:/.test(target) || /:pty(:|$)/.test(target);
|
|
5441
|
+
if (args.scriptPath !== void 0) {
|
|
5442
|
+
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
5443
|
+
if (ptyTarget) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
5444
|
+
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
5445
|
+
if (args.stdin !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath already occupies stdin — do not pass stdin with it");
|
|
5446
|
+
}
|
|
5447
|
+
if (args.stdin !== void 0 && !hasCmd) throw new Error("[E_STDIN_WITHOUT_CMD] remote: exec stdin is startup input and requires cmd — to feed a command that is already running in a PTY, use {action:\"input\", target:\"pty:<id|label>\", stdin}");
|
|
5448
|
+
if (!hasCmd && args.scriptPath === void 0) throw new Error("[E_PARAMS] remote: action \"exec\" must execute something — pass cmd, or scriptPath for a local script file");
|
|
5449
|
+
if (args.run_in_background) {
|
|
5450
|
+
const jobs = exec.agent?.ctx.get("jobs");
|
|
5451
|
+
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
5452
|
+
const lane = await ENV.driver.preparePtyLane({
|
|
5453
|
+
target: args.target,
|
|
5454
|
+
cmd: args.cmd,
|
|
5455
|
+
scriptPath: args.scriptPath,
|
|
5456
|
+
stdin: args.stdin,
|
|
5457
|
+
timeout: args.timeout
|
|
5458
|
+
}, { sessionKey });
|
|
5459
|
+
let jobId;
|
|
5460
|
+
try {
|
|
5461
|
+
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
5462
|
+
target: args.target,
|
|
5463
|
+
cmd: args.cmd,
|
|
5464
|
+
scriptPath: args.scriptPath,
|
|
5465
|
+
stdin: args.stdin,
|
|
5466
|
+
timeout: args.timeout
|
|
5467
|
+
}, exec, ENV.driver);
|
|
5468
|
+
} catch (error) {
|
|
5469
|
+
lane?.cleanup();
|
|
5470
|
+
throw error;
|
|
5471
|
+
}
|
|
5472
|
+
const where = lane === void 0 ? "" : ` on ${lane.session}${lane.label !== void 0 ? ` (${lane.label})` : ""} (${lane.connection})`;
|
|
5473
|
+
return {
|
|
5474
|
+
kind: "job",
|
|
5475
|
+
jobId,
|
|
5476
|
+
exit: null,
|
|
5477
|
+
durationMs: 0,
|
|
5478
|
+
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
5479
|
+
...lane !== void 0 ? {
|
|
5480
|
+
commandId: jobId,
|
|
5481
|
+
session: lane.session,
|
|
5482
|
+
connection: lane.connection,
|
|
5483
|
+
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
5484
|
+
} : {}
|
|
5485
|
+
};
|
|
5486
|
+
}
|
|
5487
|
+
const r = await ENV.driver.call({
|
|
5488
|
+
target: args.target,
|
|
5489
|
+
cmd: args.cmd,
|
|
5490
|
+
scriptPath: args.scriptPath,
|
|
5491
|
+
stdin: args.stdin,
|
|
5492
|
+
timeout: args.timeout
|
|
5493
|
+
}, { sessionKey });
|
|
5494
|
+
const v = workbenchValue(r);
|
|
5495
|
+
if (r.running === true && r.ptyJob !== void 0) {
|
|
5496
|
+
const jobs = exec.agent?.ctx.get("jobs");
|
|
5497
|
+
if (jobs) {
|
|
5498
|
+
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
5499
|
+
v.jobId = jobId;
|
|
5500
|
+
v.running = true;
|
|
5501
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, sessionKey);
|
|
5502
|
+
} else {
|
|
5503
|
+
r.ptyJob.interrupt();
|
|
5504
|
+
v.notice = "The command outlived its synchronous wait and no jobs service is available to poll it, so it was interrupted. Use run_in_background:true for long commands.";
|
|
5505
|
+
}
|
|
5506
|
+
}
|
|
5507
|
+
return v;
|
|
5508
|
+
}
|
|
3846
5509
|
function createRemoteTool(driver, opts = {}) {
|
|
3847
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5510
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY$1);
|
|
3848
5511
|
const tool = defineTool({
|
|
3849
5512
|
name: "remote",
|
|
3850
5513
|
description: REMOTE_TOOL_DESCRIPTION,
|
|
3851
5514
|
parameters: {
|
|
3852
5515
|
action: {
|
|
3853
5516
|
type: "string",
|
|
3854
|
-
enum: [
|
|
3855
|
-
|
|
5517
|
+
enum: [
|
|
5518
|
+
"exec",
|
|
5519
|
+
"add",
|
|
5520
|
+
"status",
|
|
5521
|
+
"close",
|
|
5522
|
+
"input"
|
|
5523
|
+
],
|
|
5524
|
+
required: true,
|
|
5525
|
+
description: "Required. exec = run a command (oneshot on a machine, or inside a PTY session); add = register a BYO ssh identity or BYO PTY into the pool; status = roster (no target) or probe/session card (target); close = close a PTY session; input = append stdin to the command currently running in a live PTY session."
|
|
3856
5526
|
},
|
|
3857
|
-
|
|
5527
|
+
target: {
|
|
3858
5528
|
type: "string",
|
|
3859
|
-
description: "
|
|
5529
|
+
description: "exec: required. A machine (bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>), a live PTY session (pty:<id|label> or its bare label), or a machine with :pty[:label] to create a new session (ssh:dev4:pty, hub:<m4>:pty, ssh:dev4:pty:build). add: optional label for the BYO entry. status: optional — omit for the roster. close / input: pty:<id|label> (input never creates or dials — the session must already run the command)."
|
|
3860
5530
|
},
|
|
3861
|
-
|
|
5531
|
+
cmd: {
|
|
3862
5532
|
type: "string",
|
|
3863
|
-
description: "
|
|
5533
|
+
description: "exec: one raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Required unless scriptPath is given."
|
|
3864
5534
|
},
|
|
3865
|
-
|
|
3866
|
-
type: "
|
|
3867
|
-
|
|
3868
|
-
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."
|
|
3869
|
-
},
|
|
3870
|
-
pullArgv: {
|
|
3871
|
-
type: "array",
|
|
3872
|
-
items: { type: "string" },
|
|
3873
|
-
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."
|
|
3874
|
-
},
|
|
3875
|
-
pushArgv: {
|
|
3876
|
-
type: "array",
|
|
3877
|
-
items: { type: "string" },
|
|
3878
|
-
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."
|
|
5535
|
+
stdin: {
|
|
5536
|
+
type: "string",
|
|
5537
|
+
description: "exec: startup input fed to cmd — oneshot: piped to stdin; pty: written right after the command line (REPL-style); requires cmd. input: the exact text to append to the RUNNING command — \"\" writes zero bytes (not EOF); include \"\\n\" to submit a line; \"\\u0003\" = Ctrl-C, \"\\u0004\" = Ctrl-D as raw characters."
|
|
3879
5538
|
},
|
|
3880
|
-
|
|
3881
|
-
type: "
|
|
3882
|
-
description: "
|
|
5539
|
+
commandId: {
|
|
5540
|
+
type: "string",
|
|
5541
|
+
description: "input only: optional guard — when given it must equal the running command's id (from the exec receipt or job_output); a stale id refuses the write instead of feeding the next command. Ignored by other actions."
|
|
3883
5542
|
},
|
|
3884
|
-
|
|
3885
|
-
type: "
|
|
3886
|
-
description: "
|
|
5543
|
+
timeout: {
|
|
5544
|
+
type: "number",
|
|
5545
|
+
description: "exec: per-command timeout in seconds (default 120). oneshot: interrupts then kills the whole group. pty: the command is NOT killed on timeout — it hands off to a running job you can poll (job_output) and cancel (job_kill = Ctrl-C first; the session survives an interrupt)."
|
|
3887
5546
|
},
|
|
3888
5547
|
run_in_background: {
|
|
3889
5548
|
type: "boolean",
|
|
3890
|
-
description: "
|
|
3891
|
-
},
|
|
3892
|
-
target: {
|
|
3893
|
-
type: "string",
|
|
3894
|
-
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."
|
|
5549
|
+
description: "exec: oneshot AND pty — return a native jobId immediately, read with job_output, cancel with job_kill; output is available while running. On pty lanes the jobId IS the commandId — it tracks the running command inside a persistent session; job_kill sends Ctrl-C and the session survives. Requires the official jobs service/controller."
|
|
3895
5550
|
},
|
|
3896
5551
|
ssh: {
|
|
3897
5552
|
type: "object",
|
|
3898
|
-
description: "
|
|
5553
|
+
description: "add: BYO ssh identity to register into the pool (no dialing). Later exec calls address the returned label. No inline keys or arbitrary SSH options; nothing is persisted — a new Agent registers again.",
|
|
3899
5554
|
additionalProperties: false,
|
|
3900
5555
|
properties: {
|
|
3901
5556
|
host: {
|
|
@@ -3923,28 +5578,11 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3923
5578
|
},
|
|
3924
5579
|
spawn: {
|
|
3925
5580
|
type: "string",
|
|
3926
|
-
description: "
|
|
3927
|
-
},
|
|
3928
|
-
cmd: {
|
|
3929
|
-
type: "string",
|
|
3930
|
-
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."
|
|
3931
|
-
},
|
|
3932
|
-
script: {
|
|
3933
|
-
type: "string",
|
|
3934
|
-
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."
|
|
5581
|
+
description: "add: BYO PTY entry point — the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). Pairs with target pty[:<label>] / a bare <label> / nothing (auto id). Nested hops are dumb pipes (only the innermost bash frames)."
|
|
3935
5582
|
},
|
|
3936
|
-
|
|
3937
|
-
type: "
|
|
3938
|
-
|
|
3939
|
-
description: "'oneshot' (default): stateless `bash -lc`, stdout/stderr separate, exit code is the process's own. 'pty': one persistent shell per (agent, target), cwd/env survive, streams merge, idle TTL 600s. A \"byop:<label>\" target locks mode to 'pty'."
|
|
3940
|
-
},
|
|
3941
|
-
stdin: {
|
|
3942
|
-
type: "string",
|
|
3943
|
-
description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style) — only helps commands that read stdin there. Requires cmd."
|
|
3944
|
-
},
|
|
3945
|
-
timeout: {
|
|
3946
|
-
type: "number",
|
|
3947
|
-
description: "Per-command timeout in seconds (default 120, row-configurable). pty: Ctrl-C first, then kill; an interrupt does not kill the session."
|
|
5583
|
+
force: {
|
|
5584
|
+
type: "boolean",
|
|
5585
|
+
description: "close only: default false — a session with a running job is refused (job_kill it first). true = kill the group and close in one step; the receipt lists what was killed."
|
|
3948
5586
|
}
|
|
3949
5587
|
},
|
|
3950
5588
|
output: {
|
|
@@ -3960,8 +5598,8 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3960
5598
|
"status",
|
|
3961
5599
|
"roster",
|
|
3962
5600
|
"job",
|
|
3963
|
-
"
|
|
3964
|
-
"
|
|
5601
|
+
"close",
|
|
5602
|
+
"input"
|
|
3965
5603
|
]
|
|
3966
5604
|
},
|
|
3967
5605
|
text: {
|
|
@@ -3979,8 +5617,49 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3979
5617
|
truncated: { type: "integer" },
|
|
3980
5618
|
outputStats: { type: "json" },
|
|
3981
5619
|
outputStatsUnavailable: { type: "boolean" },
|
|
3982
|
-
session: {
|
|
3983
|
-
|
|
5620
|
+
session: {
|
|
5621
|
+
type: "string",
|
|
5622
|
+
description: "pty session id, e.g. pty:pty-7k2m"
|
|
5623
|
+
},
|
|
5624
|
+
label: { type: "string" },
|
|
5625
|
+
connection: {
|
|
5626
|
+
type: "string",
|
|
5627
|
+
description: "underlying connection: ssh:dev4 / docker:c / incus:x / hub:m4 / BYOP"
|
|
5628
|
+
},
|
|
5629
|
+
byoTag: { type: "string" },
|
|
5630
|
+
commandId: {
|
|
5631
|
+
type: "string",
|
|
5632
|
+
description: "ledger key; equals the jobId whenever a job exists"
|
|
5633
|
+
},
|
|
5634
|
+
writtenBytes: {
|
|
5635
|
+
type: "integer",
|
|
5636
|
+
description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
|
|
5637
|
+
},
|
|
5638
|
+
envDelta: {
|
|
5639
|
+
type: "json",
|
|
5640
|
+
description: "names only: {added,changed,removed}"
|
|
5641
|
+
},
|
|
5642
|
+
created: { type: "boolean" },
|
|
5643
|
+
notice: { type: "string" },
|
|
5644
|
+
runner: {
|
|
5645
|
+
type: "string",
|
|
5646
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>), posix-shim, or hub-lease (peer hub server supervises natively)"
|
|
5647
|
+
},
|
|
5648
|
+
running: { type: "boolean" },
|
|
5649
|
+
ttlSec: { type: "integer" },
|
|
5650
|
+
closed: {
|
|
5651
|
+
type: "boolean",
|
|
5652
|
+
description: "close: true = the session is gone now"
|
|
5653
|
+
},
|
|
5654
|
+
reason: {
|
|
5655
|
+
type: "string",
|
|
5656
|
+
description: "close: not-found / already-closed-or-reaped"
|
|
5657
|
+
},
|
|
5658
|
+
killed: {
|
|
5659
|
+
type: "array",
|
|
5660
|
+
items: { type: "string" },
|
|
5661
|
+
description: "close force: the running commandIds that were killed"
|
|
5662
|
+
}
|
|
3984
5663
|
}
|
|
3985
5664
|
},
|
|
3986
5665
|
render: (_args, value) => [{
|
|
@@ -3989,174 +5668,257 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3989
5668
|
}]
|
|
3990
5669
|
},
|
|
3991
5670
|
async execute(args, exec) {
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
args.script,
|
|
3997
|
-
args.stdin,
|
|
3998
|
-
args.spawn,
|
|
3999
|
-
args.ssh,
|
|
4000
|
-
args.target,
|
|
4001
|
-
args.mode,
|
|
4002
|
-
args.timeout,
|
|
4003
|
-
args.run_in_background
|
|
4004
|
-
].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments; register byossh separately first`);
|
|
4005
|
-
for (const legacy of [
|
|
4006
|
-
"dryRun",
|
|
4007
|
-
"delete",
|
|
4008
|
-
"excludes",
|
|
4009
|
-
"preserve"
|
|
4010
|
-
]) 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\""})`);
|
|
4011
|
-
if (args.action === "rsync") {
|
|
4012
|
-
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
4013
|
-
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");
|
|
4014
|
-
const start = Date.now();
|
|
4015
|
-
const receipt$1 = await driver.rsync({
|
|
4016
|
-
src: args.src,
|
|
4017
|
-
dst: args.dst,
|
|
4018
|
-
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
4019
|
-
}, exec.signal, { sessionKey: getSessionKey(exec) });
|
|
4020
|
-
if (receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
4021
|
-
return {
|
|
4022
|
-
kind: "rsync",
|
|
4023
|
-
text: JSON.stringify(receipt$1),
|
|
4024
|
-
receipt: receipt$1,
|
|
4025
|
-
exit: receipt$1.exit,
|
|
4026
|
-
durationMs: Date.now() - start
|
|
4027
|
-
};
|
|
4028
|
-
}
|
|
4029
|
-
const receipt = await driver.copy({
|
|
4030
|
-
src: args.src,
|
|
4031
|
-
dst: args.dst,
|
|
4032
|
-
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
4033
|
-
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
4034
|
-
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
4035
|
-
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
4036
|
-
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
4037
|
-
}, exec.signal, { sessionKey: getSessionKey(exec) });
|
|
4038
|
-
return {
|
|
4039
|
-
kind: "copy",
|
|
4040
|
-
text: JSON.stringify(receipt),
|
|
4041
|
-
receipt,
|
|
4042
|
-
exit: null,
|
|
4043
|
-
durationMs: 0
|
|
4044
|
-
};
|
|
4045
|
-
}
|
|
4046
|
-
if ([
|
|
4047
|
-
args.src,
|
|
4048
|
-
args.dst,
|
|
4049
|
-
args.argv,
|
|
4050
|
-
args.pullArgv,
|
|
4051
|
-
args.pushArgv,
|
|
4052
|
-
args.verify,
|
|
4053
|
-
args.mkdir
|
|
4054
|
-
].some((value) => value !== void 0)) throw new Error("file fields require action copy or rsync");
|
|
4055
|
-
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
4056
|
-
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
4057
|
-
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
4058
|
-
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)})`);
|
|
4059
|
-
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>");
|
|
4060
|
-
if (args.run_in_background) {
|
|
4061
|
-
if (!hasCmd && args.script === void 0) throw new Error("remote: background requires cmd or script");
|
|
4062
|
-
if (args.mode === "pty" || hasSpawn || args.target?.startsWith("byop:")) throw new Error("remote: background requires oneshot; PTY and BYO-PTY are unsupported");
|
|
4063
|
-
const jobs = exec.agent?.ctx.get("jobs");
|
|
4064
|
-
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
4065
|
-
const jobId = startRemoteJob(jobs, {
|
|
4066
|
-
target: args.target,
|
|
4067
|
-
cmd: args.cmd,
|
|
4068
|
-
script: args.script,
|
|
4069
|
-
ssh: args.ssh,
|
|
4070
|
-
stdin: args.stdin,
|
|
4071
|
-
mode: "oneshot",
|
|
4072
|
-
timeout: args.timeout
|
|
4073
|
-
}, exec, driver);
|
|
4074
|
-
return {
|
|
4075
|
-
kind: "job",
|
|
4076
|
-
jobId,
|
|
4077
|
-
text: `Started ${jobId}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
4078
|
-
exit: null,
|
|
4079
|
-
durationMs: 0
|
|
4080
|
-
};
|
|
4081
|
-
}
|
|
4082
|
-
if (!hasCmd && args.script === void 0) {
|
|
4083
|
-
if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
|
|
4084
|
-
if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is an exec channel — pass cmd (there is nothing to probe for BYO-PTY)");
|
|
4085
|
-
if (args.ssh !== void 0) return {
|
|
4086
|
-
kind: "status",
|
|
4087
|
-
text: driver.registerSsh(args.target, args.ssh),
|
|
4088
|
-
exit: null,
|
|
4089
|
-
durationMs: 0
|
|
4090
|
-
};
|
|
4091
|
-
if (!hasTarget) return {
|
|
4092
|
-
kind: "roster",
|
|
4093
|
-
text: await driver.roster({ sessionKey: getSessionKey(exec) }),
|
|
4094
|
-
exit: null,
|
|
4095
|
-
durationMs: 0
|
|
4096
|
-
};
|
|
4097
|
-
return {
|
|
4098
|
-
kind: "status",
|
|
4099
|
-
text: await driver.status(args.target, { sessionKey: getSessionKey(exec) }),
|
|
4100
|
-
exit: null,
|
|
4101
|
-
durationMs: 0
|
|
4102
|
-
};
|
|
4103
|
-
}
|
|
4104
|
-
const r = await driver.call({
|
|
4105
|
-
target: args.target,
|
|
4106
|
-
spawn: args.spawn,
|
|
4107
|
-
ssh: args.ssh,
|
|
4108
|
-
cmd: args.cmd,
|
|
4109
|
-
script: args.script,
|
|
4110
|
-
mode: args.mode === "oneshot" || args.mode === "pty" ? args.mode : void 0,
|
|
4111
|
-
stdin: args.stdin,
|
|
4112
|
-
timeout: args.timeout
|
|
4113
|
-
}, { sessionKey: getSessionKey(exec) });
|
|
4114
|
-
const v = {
|
|
4115
|
-
kind: "exec",
|
|
4116
|
-
text: r.stdout,
|
|
4117
|
-
exit: r.exit,
|
|
4118
|
-
durationMs: r.durationMs
|
|
4119
|
-
};
|
|
4120
|
-
if (r.cwd !== null) v.cwd = r.cwd;
|
|
4121
|
-
if (r.stderr !== void 0) v.stderr = r.stderr;
|
|
4122
|
-
if (r.timedOut) v.timedOut = true;
|
|
4123
|
-
if (r.reconnected) v.reconnected = true;
|
|
4124
|
-
if (r.truncated !== void 0) v.truncated = r.truncated;
|
|
4125
|
-
if (r.outputStats !== void 0) v.outputStats = r.outputStats;
|
|
4126
|
-
if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
|
|
4127
|
-
if (r.session !== void 0) v.session = r.session;
|
|
4128
|
-
if (r.notice !== void 0) v.notice = r.notice;
|
|
4129
|
-
return v;
|
|
5671
|
+
return remoteExecute({
|
|
5672
|
+
driver,
|
|
5673
|
+
getSessionKey
|
|
5674
|
+
}, args, exec);
|
|
4130
5675
|
}
|
|
4131
5676
|
});
|
|
4132
5677
|
const description = tool.description;
|
|
4133
5678
|
Object.defineProperty(tool, "description", {
|
|
4134
5679
|
enumerable: true,
|
|
4135
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self).
|
|
5680
|
+
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self; the roster lists them). Hub failures never fall back to SSH.\n- hub:<m4> oneshot: the peer hub server is the runner itself — bash -lc in a detached group, TERM→2s→KILL cleanup; the receipt runner field says \"hub-lease\" (the superd-run helper is a dumb-pipe artifact, never uploaded over the Hub). Inline cmd is limited to 4096 chars; scriptPath is the file carrier and is not argv.\n- hub:<m4>:pty[:label] opens the SAME persistent interactive session as every other transport — id, label, input, busy, timeout handoff, background, close, session card, ledger, idle TTL, quota — carried by a local pump child over one authenticated WebSocket to the peer terminal." : "")
|
|
4136
5681
|
});
|
|
4137
5682
|
return tool;
|
|
4138
5683
|
}
|
|
5684
|
+
/** RemoteCallResult → RemoteToolValue(pty 工作台字段 + 既有字段)。 */
|
|
5685
|
+
function workbenchValue(r) {
|
|
5686
|
+
const v = {
|
|
5687
|
+
kind: "exec",
|
|
5688
|
+
text: r.stdout,
|
|
5689
|
+
exit: r.exit,
|
|
5690
|
+
durationMs: r.durationMs
|
|
5691
|
+
};
|
|
5692
|
+
if (r.cwd !== null && r.cwd !== void 0) v.cwd = r.cwd;
|
|
5693
|
+
if (r.stderr !== void 0) v.stderr = r.stderr;
|
|
5694
|
+
if (r.timedOut) v.timedOut = true;
|
|
5695
|
+
if (r.reconnected) v.reconnected = true;
|
|
5696
|
+
if (r.truncated !== void 0) v.truncated = r.truncated;
|
|
5697
|
+
if (r.outputStats !== void 0) v.outputStats = r.outputStats;
|
|
5698
|
+
if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
|
|
5699
|
+
if (r.session !== void 0) v.session = r.session;
|
|
5700
|
+
if (r.label !== void 0) v.label = r.label;
|
|
5701
|
+
if (r.connection !== void 0) v.connection = r.connection;
|
|
5702
|
+
if (r.byoTag !== void 0) v.byoTag = r.byoTag;
|
|
5703
|
+
if (r.commandId !== void 0) v.commandId = r.commandId;
|
|
5704
|
+
if (r.envDelta !== void 0) v.envDelta = r.envDelta;
|
|
5705
|
+
if (r.created === true) v.created = true;
|
|
5706
|
+
if (r.notice !== void 0) v.notice = r.notice;
|
|
5707
|
+
if (r.runner !== void 0) v.runner = r.runner;
|
|
5708
|
+
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
5709
|
+
return v;
|
|
5710
|
+
}
|
|
5711
|
+
function renderEnvDelta(d) {
|
|
5712
|
+
const bits = [];
|
|
5713
|
+
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
5714
|
+
if (d.changed.length > 0) bits.push(`~${d.changed.join(",~")}`);
|
|
5715
|
+
if (d.removed.length > 0) bits.push(`-${d.removed.join(",-")}`);
|
|
5716
|
+
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
5717
|
+
}
|
|
4139
5718
|
function renderValue(v) {
|
|
4140
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "
|
|
5719
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input") return v.text;
|
|
4141
5720
|
const parts = [];
|
|
4142
5721
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
4143
5722
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
5723
|
+
if (v.created === true && v.session !== void 0) parts.push(`[session created — address it as ${v.session}]`);
|
|
4144
5724
|
parts.push(v.text);
|
|
4145
5725
|
const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
|
|
4146
5726
|
const markers = [];
|
|
5727
|
+
if (typeof v.session === "string" && v.session.length > 0) markers.push(`${v.session}${typeof v.label === "string" && v.label.length > 0 ? ` (${v.label})` : ""}${v.byoTag !== void 0 ? ` (${v.byoTag})` : ""}`);
|
|
5728
|
+
if (typeof v.connection === "string" && v.connection.length > 0 && v.session !== void 0) markers.push(`conn ${v.connection}`);
|
|
5729
|
+
if (typeof v.commandId === "string" && v.commandId.length > 0) markers.push(`cmd ${v.commandId}`);
|
|
5730
|
+
if (typeof v.cwd === "string" && v.cwd.length > 0) markers.push(`cwd ${v.cwd}`);
|
|
5731
|
+
const envTag = v.envDelta !== void 0 ? renderEnvDelta(v.envDelta) : void 0;
|
|
5732
|
+
if (envTag !== void 0) markers.push(`env ${envTag}`);
|
|
5733
|
+
if (typeof v.ttlSec === "number") markers.push(`idle-left ~${Math.max(1, Math.round(v.ttlSec / 60))}m`);
|
|
5734
|
+
if (typeof v.runner === "string" && v.runner.length > 0) markers.push(`runner ${v.runner}`);
|
|
4147
5735
|
if (v.timedOut === true) markers.push("interrupted");
|
|
5736
|
+
markers.push(`exit ${v.exit}`);
|
|
5737
|
+
markers.push(`${secs}s`);
|
|
5738
|
+
if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
|
|
4148
5739
|
if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
|
|
4149
|
-
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
const sessionTag = typeof v.session === "string" && v.session.length > 0 ? ` · ${v.session}` : "";
|
|
4153
|
-
parts.push(`[exit ${v.exit} · ${secs}s${suffix}${cwdTag}${sessionTag}]`);
|
|
5740
|
+
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable");
|
|
5741
|
+
parts.push(`[${markers.join(" · ")}]`);
|
|
5742
|
+
if (v.runner === "posix-shim" && (v.exit !== null && v.exit !== 0 || v.timedOut === true)) parts.push("note: this oneshot ran through the POSIX fallback on this target; PTY lanes use an independent lane and may still work here");
|
|
4154
5743
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
4155
5744
|
return parts.join("\n");
|
|
4156
5745
|
}
|
|
4157
5746
|
|
|
5747
|
+
//#endregion
|
|
5748
|
+
//#region src/remote/transfer-tool.ts
|
|
5749
|
+
const NO_SESSION_KEY = "remote:no-session";
|
|
5750
|
+
/**
|
|
5751
|
+
* The model-visible surface of the `remote_transfer` tool (split out of
|
|
5752
|
+
* `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
|
|
5753
|
+
*/
|
|
5754
|
+
const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
|
|
5755
|
+
mode:"copy" → tree copy. Native CLI per endpoint (scp for ssh, docker cp, incus file push/pull), a plain tree copy when both ends are local, and the Hub binary lane when a Hub machine is involved. Directories copy recursively; exit codes are authoritative.
|
|
5756
|
+
mode:"rsync" → rsync over SSH only: at least one endpoint remote SSH, the other local or SSH (both remote legs tunnel through this machine; identities never leave it). Hub and container endpoints are rejected — no silent copy fallback. Trailing slashes keep rsync semantics.
|
|
5757
|
+
|
|
5758
|
+
Endpoints are "[target:]absolute path". No target = the machine running this Agent. Targets are the same unified pool remote uses: a bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. Examples: "/srv/app", "ssh:dev4:/srv/app", "docker:corti:/var/lib/x", "hub:m4:/srv". A path is never guessed: both operands must be absolute.
|
|
5759
|
+
|
|
5760
|
+
Native CLI options travel through argv verbatim — one element = one token, appended before src/dst, no shell parsing and no whitelist: rsync ["-a","--partial"], scp ["-p","-r"], docker cp ["-a","-L"], incus file push ["--recursive"]. Cross-endpoint copies that need two commands (SSH↔Docker, SSH↔Incus, Docker↔Incus) take pullArgv plus pushArgv instead of argv. rsync is a single SSH command, so it takes argv only; an explicit -e/--rsh overrides the wiring.
|
|
5761
|
+
|
|
5762
|
+
Options: verify (copy only) — the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them; native CLI routes have no independent hash tier, so verify is rejected there (self-verify through a oneshot digest command). mkdir (copy only, default true) — one best-effort pre-hop creates the destination PARENT directory; the operand itself is never pre-created, so each CLI's own layout semantics stay byte-identical.
|
|
5763
|
+
|
|
5764
|
+
Failures are loud: a non-zero native exit throws with the receipt instead of returning a normal-looking result. The receipt stays in the receipt field (route, exits, verification, bytes/entries, cleanup); text is one formatted line. For checksum-grade verification of a native route, run a digest command through remote oneshot or PTY instead of trusting the copy receipt.`;
|
|
5765
|
+
const KNOWN_PARAMS = new Set([
|
|
5766
|
+
"mode",
|
|
5767
|
+
"src",
|
|
5768
|
+
"dst",
|
|
5769
|
+
"argv",
|
|
5770
|
+
"pullArgv",
|
|
5771
|
+
"pushArgv",
|
|
5772
|
+
"verify",
|
|
5773
|
+
"mkdir"
|
|
5774
|
+
]);
|
|
5775
|
+
async function transferExecute(ENV, args, exec) {
|
|
5776
|
+
for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote_transfer: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
|
|
5777
|
+
if (args.mode !== "copy" && args.mode !== "rsync") throw new Error(`[E_PARAMS] remote_transfer: mode is required — "copy" (tree copy through each endpoint's native CLI or the Hub lane) or "rsync" (SSH only)`);
|
|
5778
|
+
if (typeof args.src !== "string" || args.src.length === 0 || typeof args.dst !== "string" || args.dst.length === 0) throw new Error(`[E_PARAMS] remote_transfer: ${args.mode} requires src and dst absolute endpoints`);
|
|
5779
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
5780
|
+
if (args.mode === "rsync") {
|
|
5781
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
5782
|
+
if (args.verify !== void 0 || args.mkdir !== void 0) throw new Error("remote_transfer: rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
|
|
5783
|
+
const start$1 = Date.now();
|
|
5784
|
+
const receipt$1 = await ENV.driver.rsync({
|
|
5785
|
+
src: args.src,
|
|
5786
|
+
dst: args.dst,
|
|
5787
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
5788
|
+
}, exec.signal, { sessionKey });
|
|
5789
|
+
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
5790
|
+
const durationMs$1 = Date.now() - start$1;
|
|
5791
|
+
return {
|
|
5792
|
+
kind: "rsync",
|
|
5793
|
+
text: formatTransferLine("rsync", receipt$1, durationMs$1),
|
|
5794
|
+
receipt: receipt$1,
|
|
5795
|
+
exit: receipt$1.exit,
|
|
5796
|
+
durationMs: durationMs$1
|
|
5797
|
+
};
|
|
5798
|
+
}
|
|
5799
|
+
const start = Date.now();
|
|
5800
|
+
const receipt = await ENV.driver.copy({
|
|
5801
|
+
src: args.src,
|
|
5802
|
+
dst: args.dst,
|
|
5803
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
5804
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
5805
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
5806
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
5807
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
5808
|
+
}, exec.signal, { sessionKey });
|
|
5809
|
+
const asReceipt = receipt;
|
|
5810
|
+
const exits = asReceipt.exits ?? [];
|
|
5811
|
+
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
5812
|
+
const durationMs = Date.now() - start;
|
|
5813
|
+
return {
|
|
5814
|
+
kind: "copy",
|
|
5815
|
+
text: formatTransferLine("copy", receipt, durationMs),
|
|
5816
|
+
receipt,
|
|
5817
|
+
exit: exits.length > 0 ? 0 : null,
|
|
5818
|
+
durationMs
|
|
5819
|
+
};
|
|
5820
|
+
}
|
|
5821
|
+
function createRemoteTransferTool(driver, opts = {}) {
|
|
5822
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
5823
|
+
return defineTool({
|
|
5824
|
+
name: "remote_transfer",
|
|
5825
|
+
description: TRANSFER_TOOL_DESCRIPTION,
|
|
5826
|
+
parameters: {
|
|
5827
|
+
mode: {
|
|
5828
|
+
type: "string",
|
|
5829
|
+
enum: ["copy", "rsync"],
|
|
5830
|
+
required: true,
|
|
5831
|
+
description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
|
|
5832
|
+
},
|
|
5833
|
+
src: {
|
|
5834
|
+
type: "string",
|
|
5835
|
+
description: "Source [target:]absolute path. No target = the machine running this Agent."
|
|
5836
|
+
},
|
|
5837
|
+
dst: {
|
|
5838
|
+
type: "string",
|
|
5839
|
+
description: "Destination [target:]absolute path. No target = the machine running this Agent."
|
|
5840
|
+
},
|
|
5841
|
+
argv: {
|
|
5842
|
+
type: "array",
|
|
5843
|
+
items: { type: "string" },
|
|
5844
|
+
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], docker cp [\"-a\",\"-L\"], 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. docker cp: pass -L to follow symlinks (a symlink source like /etc/os-release fails without it). Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
|
|
5845
|
+
},
|
|
5846
|
+
pullArgv: {
|
|
5847
|
+
type: "array",
|
|
5848
|
+
items: { type: "string" },
|
|
5849
|
+
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."
|
|
5850
|
+
},
|
|
5851
|
+
pushArgv: {
|
|
5852
|
+
type: "array",
|
|
5853
|
+
items: { type: "string" },
|
|
5854
|
+
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."
|
|
5855
|
+
},
|
|
5856
|
+
verify: {
|
|
5857
|
+
type: "boolean",
|
|
5858
|
+
description: "copy only. Receipts name the verification tier actually performed (D1=A): native CLI routes are \"native-exit-only\" (no independent check — self-verify via oneshot digest commands when needed); the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them (\"not-applicable\"). No hash tier exists on native routes."
|
|
5859
|
+
},
|
|
5860
|
+
mkdir: {
|
|
5861
|
+
type: "boolean",
|
|
5862
|
+
description: "copy only, all routes. Default true: before transfer, one best-effort exec pre-hop ensures the destination's PARENT directory exists (the operand itself is never pre-created — each CLI's own layout semantics stay byte-identical). Receipt created field: true (parent created this call) / false (already existed) / \"n-a\" (skipped or pre-hop unavailable, e.g. docker exec on a stopped container — the native CLI then reports the real error itself). false skips the pre-hop."
|
|
5863
|
+
}
|
|
5864
|
+
},
|
|
5865
|
+
output: {
|
|
5866
|
+
schema: {
|
|
5867
|
+
type: "object",
|
|
5868
|
+
additionalProperties: false,
|
|
5869
|
+
properties: {
|
|
5870
|
+
kind: {
|
|
5871
|
+
type: "string",
|
|
5872
|
+
required: true,
|
|
5873
|
+
enum: ["copy", "rsync"]
|
|
5874
|
+
},
|
|
5875
|
+
text: {
|
|
5876
|
+
type: "string",
|
|
5877
|
+
required: true
|
|
5878
|
+
},
|
|
5879
|
+
receipt: { type: "json" },
|
|
5880
|
+
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
5881
|
+
durationMs: { type: "integer" }
|
|
5882
|
+
}
|
|
5883
|
+
},
|
|
5884
|
+
render: (_args, value) => [{
|
|
5885
|
+
type: "text",
|
|
5886
|
+
text: value.text
|
|
5887
|
+
}]
|
|
5888
|
+
},
|
|
5889
|
+
async execute(args, exec) {
|
|
5890
|
+
return transferExecute({
|
|
5891
|
+
driver,
|
|
5892
|
+
getSessionKey
|
|
5893
|
+
}, args, exec);
|
|
5894
|
+
}
|
|
5895
|
+
});
|
|
5896
|
+
}
|
|
5897
|
+
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5898
|
+
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5899
|
+
function formatTransferLine(mode, receipt, durationMs) {
|
|
5900
|
+
const r = receipt;
|
|
5901
|
+
const parts = [];
|
|
5902
|
+
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5903
|
+
parts.push(`${mode} ${r.src} → ${r.dst}`);
|
|
5904
|
+
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5905
|
+
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5906
|
+
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5907
|
+
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5908
|
+
if (r.created === true) parts.push("created yes");
|
|
5909
|
+
else if (r.created === false) parts.push("created pre-existing");
|
|
5910
|
+
else if (r.created === "n-a") parts.push("created n-a");
|
|
5911
|
+
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5912
|
+
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5913
|
+
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5914
|
+
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5915
|
+
return parts.join(" · ");
|
|
5916
|
+
}
|
|
5917
|
+
|
|
4158
5918
|
//#endregion
|
|
4159
5919
|
//#region src/remote/agent-registration.ts
|
|
5920
|
+
/** The pair a row contributes: execution/PTY and file transfer are separate tools. */
|
|
5921
|
+
const TOOL_NAMES = ["remote", "remote_transfer"];
|
|
4160
5922
|
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
4161
5923
|
function installAgentRemote(ctx, config) {
|
|
4162
5924
|
return ctx.on("agent/created", ({ agent }) => {
|
|
@@ -4166,12 +5928,14 @@ function installAgentRemote(ctx, config) {
|
|
|
4166
5928
|
...config,
|
|
4167
5929
|
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
4168
5930
|
});
|
|
4169
|
-
|
|
5931
|
+
const registered = [];
|
|
4170
5932
|
try {
|
|
4171
|
-
|
|
5933
|
+
registered.push(tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id })));
|
|
5934
|
+
registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
|
|
4172
5935
|
} catch (error) {
|
|
5936
|
+
for (const undo of registered.splice(0)) undo();
|
|
4173
5937
|
driver.dispose();
|
|
4174
|
-
if (error instanceof Error && error.message ===
|
|
5938
|
+
if (TOOL_NAMES.find((name$1) => error instanceof Error && error.message === `tool "${name$1}" is already registered in this scope` && tools.get(name$1, agent) !== void 0) !== void 0) return;
|
|
4175
5939
|
throw error;
|
|
4176
5940
|
}
|
|
4177
5941
|
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
@@ -4183,7 +5947,7 @@ function installAgentRemote(ctx, config) {
|
|
|
4183
5947
|
const cleanup = () => {
|
|
4184
5948
|
if (disposal) return disposal;
|
|
4185
5949
|
disposal = driver.dispose();
|
|
4186
|
-
|
|
5950
|
+
for (const undo of registered.splice(0)) undo();
|
|
4187
5951
|
stopService();
|
|
4188
5952
|
ownerUndo?.();
|
|
4189
5953
|
agentUndo?.();
|
|
@@ -4239,12 +6003,10 @@ const name = "dashr-remote";
|
|
|
4239
6003
|
const inject = [];
|
|
4240
6004
|
const Config = Schema.object({
|
|
4241
6005
|
execTimeoutSec: Schema.natural().min(1).default(120),
|
|
4242
|
-
idleTtlSec: Schema.natural().min(1).default(
|
|
4243
|
-
maxOutputChars: Schema.natural().min(1).default(3e4)
|
|
6006
|
+
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
4244
6007
|
}).default({
|
|
4245
6008
|
execTimeoutSec: 120,
|
|
4246
|
-
idleTtlSec:
|
|
4247
|
-
maxOutputChars: 3e4
|
|
6009
|
+
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
4248
6010
|
});
|
|
4249
6011
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
4250
6012
|
const apply = (ctx, config) => {
|
|
@@ -4258,4 +6020,4 @@ var plugin_default = {
|
|
|
4258
6020
|
};
|
|
4259
6021
|
|
|
4260
6022
|
//#endregion
|
|
4261
|
-
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS,
|
|
6023
|
+
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, PtySession, Utf16TailRing, apply, buildHubPtyArgv, checkAbsolute, checkSelection, plugin_default as default, disclose, hubPtyAssets, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };
|