super-dsh 0.3.1 → 0.3.2
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/plugin.d.ts +172 -51
- package/remote/plugin.js +2161 -711
- package/remote/provenance.json +32 -20
- 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/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/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/remote/plugin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
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";
|
|
2
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
5
|
import { dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
@@ -8,6 +8,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { createConnection, createServer } from "node:net";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
12
13
|
|
|
13
14
|
//#region third_party/schemastery/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
@@ -1209,17 +1210,18 @@ async function copyTree(request, source, destination, signal, scratchRoot = join
|
|
|
1209
1210
|
signal.throwIfAborted();
|
|
1210
1211
|
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
1212
|
await mkdir(scratchRoot, { recursive: true });
|
|
1212
|
-
signal.throwIfAborted();
|
|
1213
|
-
const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
|
|
1214
1213
|
const receipt = {
|
|
1215
1214
|
action: "copy",
|
|
1215
|
+
route: "hub-staged",
|
|
1216
1216
|
src: request.src,
|
|
1217
1217
|
dst: request.dst,
|
|
1218
|
-
|
|
1218
|
+
ok: false,
|
|
1219
|
+
verification: "not-applicable",
|
|
1219
1220
|
bytes: 0,
|
|
1220
1221
|
entries: 0,
|
|
1221
1222
|
scope: "transferred entries"
|
|
1222
1223
|
};
|
|
1224
|
+
const scratch = await mkdtemp(join(scratchRoot, "remote-copy-")), payload = join(scratch, "payload.bin");
|
|
1223
1225
|
try {
|
|
1224
1226
|
await pipeline(await source.endpoint.exportTree(source.path, signal), createWriteStream(payload, {
|
|
1225
1227
|
flags: "wx",
|
|
@@ -1243,24 +1245,91 @@ async function copyTree(request, source, destination, signal, scratchRoot = join
|
|
|
1243
1245
|
const dest = await destination.endpoint.digest(destination.path, signal, selection);
|
|
1244
1246
|
receipt.destinationDigest = dest.sha256;
|
|
1245
1247
|
if (dest.sha256 !== receipt.sourceDigest) throw new Error("destination digest mismatch");
|
|
1246
|
-
receipt.
|
|
1248
|
+
receipt.verification = "sha256";
|
|
1247
1249
|
}
|
|
1248
1250
|
signal.throwIfAborted();
|
|
1249
1251
|
await rm(scratch, {
|
|
1250
1252
|
recursive: true,
|
|
1251
1253
|
force: true
|
|
1252
1254
|
});
|
|
1255
|
+
receipt.ok = true;
|
|
1253
1256
|
return receipt;
|
|
1254
1257
|
} catch (error) {
|
|
1258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1259
|
+
const reason = signal.aborted ? "aborted" : /mismatch/i.test(message) ? "digest-mismatch" : "staging-error";
|
|
1255
1260
|
throw new CopyError({
|
|
1256
1261
|
...receipt,
|
|
1257
|
-
|
|
1262
|
+
ok: false,
|
|
1263
|
+
reason,
|
|
1258
1264
|
scratch,
|
|
1259
|
-
error:
|
|
1265
|
+
error: message
|
|
1260
1266
|
});
|
|
1261
1267
|
}
|
|
1262
1268
|
}
|
|
1263
1269
|
|
|
1270
|
+
//#endregion
|
|
1271
|
+
//#region src/remote/ssh-argv.ts
|
|
1272
|
+
/**
|
|
1273
|
+
* ONE identity-assembly policy for every SSH-speaking lane (user close-out
|
|
1274
|
+
* ruling 2026-10-17: "four builders necessarily mean four identity
|
|
1275
|
+
* semantics" — the structural root of F1).
|
|
1276
|
+
*
|
|
1277
|
+
* Assembly: explicit connection fields (byossh registrations; caller options
|
|
1278
|
+
* may override later by the CLI's own last-option-wins) in ONE order —
|
|
1279
|
+
* user, port, key, proxyJump — spoken in the target CLI's dialect.
|
|
1280
|
+
*
|
|
1281
|
+
* Resolution: config-side identity (aliases, config-only IdentityFile paths)
|
|
1282
|
+
* is read from the ORIGIN's own ssh config natively by every lane. The single
|
|
1283
|
+
* exception is the scp -3 temp config (ssh -F sees no user config), where
|
|
1284
|
+
* tempSshConfig inlines the `ssh -G`-resolved fields — paths only, never key
|
|
1285
|
+
* material (F1, 2026-10-14). That policy lives beside this builder in
|
|
1286
|
+
* native-copy.ts and must stay the only such inline site.
|
|
1287
|
+
*
|
|
1288
|
+
* Former duplicate builders now reduced here: sshArgv (execution/probes),
|
|
1289
|
+
* identityArgv (scp routes), rsyncRsh (RSYNC_RSH wiring), relaySsh
|
|
1290
|
+
* (dual-SSH relay). Behavior is byte-identical to each former site.
|
|
1291
|
+
*/
|
|
1292
|
+
/** Identity options ONLY for one connection, in the target CLI's dialect:
|
|
1293
|
+
* user, port, key, proxyJump — conditional, one order, NO base flags (each
|
|
1294
|
+
* lane's dialect base — BatchMode, -T, hardening — stays at its call site).
|
|
1295
|
+
* scp carries the user inside its user@host operand, so the scp dialect
|
|
1296
|
+
* never emits -l; its port flag is -P. */
|
|
1297
|
+
function sshIdentityArgv(connection, dialect) {
|
|
1298
|
+
const args = [];
|
|
1299
|
+
if (dialect === "ssh" && connection.user !== void 0) args.push("-l", connection.user);
|
|
1300
|
+
if (connection.port !== void 0) args.push(dialect === "scp" ? "-P" : "-p", String(connection.port));
|
|
1301
|
+
if (connection.key !== void 0) args.push("-i", connection.key);
|
|
1302
|
+
if (connection.proxyJump !== void 0) args.push("-J", connection.proxyJump);
|
|
1303
|
+
return args;
|
|
1304
|
+
}
|
|
1305
|
+
/** Full ssh invocation around the shared identity core. base and extra are
|
|
1306
|
+
* trusted internal argv (dialect flags incl. BatchMode, forwarding options)
|
|
1307
|
+
* — never a model-provided shell string. */
|
|
1308
|
+
/** SSH keepalive for PERSISTENT lanes (pty ruling 2026-10-19): client-owned,
|
|
1309
|
+
* fixed cadence — OpenSSH has NO protocol-default keepalive unit (default is
|
|
1310
|
+
* disabled), so we set our own in our own argv. Control-channel probes never
|
|
1311
|
+
* touch the PTY byte stream; NAT/WiFi idle expiry (~5min) would otherwise
|
|
1312
|
+
* silently kill an idle-but-wanted session inside the 15min TTL. CountMax=2
|
|
1313
|
+
* gives ~6min honest dead-detection instead of a hang on next write. */
|
|
1314
|
+
const SSH_PERSISTENT_KEEPALIVE = [
|
|
1315
|
+
"-o",
|
|
1316
|
+
"ServerAliveInterval=180",
|
|
1317
|
+
"-o",
|
|
1318
|
+
"ServerAliveCountMax=2"
|
|
1319
|
+
];
|
|
1320
|
+
function sshCommandArgv(connection, base, extra, command) {
|
|
1321
|
+
const args = [
|
|
1322
|
+
"ssh",
|
|
1323
|
+
...base,
|
|
1324
|
+
...sshIdentityArgv(connection, "ssh"),
|
|
1325
|
+
...extra,
|
|
1326
|
+
"--",
|
|
1327
|
+
connection.host
|
|
1328
|
+
];
|
|
1329
|
+
if (command !== void 0) args.push(command);
|
|
1330
|
+
return args;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1264
1333
|
//#endregion
|
|
1265
1334
|
//#region src/remote/output-ring.ts
|
|
1266
1335
|
const continuation = (byte) => (byte & 192) === 128;
|
|
@@ -1566,15 +1635,17 @@ function scpSpec(connection, path) {
|
|
|
1566
1635
|
const host = connection.host.includes(":") ? `[${connection.host}]` : connection.host;
|
|
1567
1636
|
return `${connection.user !== void 0 ? `${connection.user}@${host}` : host}:${path}`;
|
|
1568
1637
|
}
|
|
1569
|
-
/** Connection identity as scp options
|
|
1570
|
-
*
|
|
1571
|
-
* precedence (last option wins).
|
|
1638
|
+
/** Connection identity as scp options, placed BEFORE caller argv so an
|
|
1639
|
+
* explicit caller -F/-i/-P/-J overrides the registered identity by scp's own
|
|
1640
|
+
* precedence (last option wins). Assembly itself lives in ssh-argv.ts
|
|
1641
|
+
* (the ONE identity policy — 2026-10-17 ruling); only the scp dialect
|
|
1642
|
+
* placement rule is local. */
|
|
1572
1643
|
function identityArgv(connection) {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1644
|
+
return [
|
|
1645
|
+
"-o",
|
|
1646
|
+
"BatchMode=yes",
|
|
1647
|
+
...sshIdentityArgv(connection, "scp")
|
|
1648
|
+
];
|
|
1578
1649
|
}
|
|
1579
1650
|
function sshEndpoint(plan) {
|
|
1580
1651
|
if (plan.kind !== "ssh") throw new Error("remote: native copy endpoint is not an SSH target");
|
|
@@ -1613,24 +1684,30 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1613
1684
|
stdout: [],
|
|
1614
1685
|
stderr: [],
|
|
1615
1686
|
outputTruncated: false,
|
|
1616
|
-
|
|
1687
|
+
verification: "native-exit-only",
|
|
1688
|
+
ok: true,
|
|
1617
1689
|
cleanup: "none-required"
|
|
1618
1690
|
};
|
|
1619
|
-
const finish = (route, argvs, results) => {
|
|
1691
|
+
const finish = (route, argvs, results, internal = []) => {
|
|
1620
1692
|
receipt.route = route;
|
|
1621
|
-
receipt.argv = argvs;
|
|
1693
|
+
receipt.argv = argvs.map((argv) => argv.map((token) => internal.find(([secret]) => secret === token)?.[1] ?? token));
|
|
1622
1694
|
receipt.exits = results.map((result) => result.exit);
|
|
1623
1695
|
receipt.stdout = results.map((result) => result.stdout);
|
|
1624
1696
|
receipt.stderr = results.map((result) => result.stderr);
|
|
1625
1697
|
receipt.outputTruncated = results.some((result) => result.outputTruncated);
|
|
1698
|
+
receipt.ok = !signal.aborted && receipt.exits.every((exit) => exit === 0);
|
|
1699
|
+
if (!receipt.ok) receipt.reason = signal.aborted ? "aborted" : "non-zero-exit";
|
|
1626
1700
|
return receipt;
|
|
1627
1701
|
};
|
|
1702
|
+
if (request.mkdir !== false) await ensureDestinationDir(endpoints.dst, signal, options.execArgvFor, wrap, receipt);
|
|
1703
|
+
else receipt.created = "n-a";
|
|
1628
1704
|
if (s === "local" && t === "ssh") {
|
|
1629
1705
|
rejectRemoteToRemoteScp(oneShot);
|
|
1630
1706
|
const connection = sshEndpoint(endpoints.dst.plan).connection;
|
|
1631
1707
|
const argv = [
|
|
1632
1708
|
"scp",
|
|
1633
1709
|
...identityArgv(connection),
|
|
1710
|
+
...await recursiveFlag(endpoints.src.path, oneShot, "-r"),
|
|
1634
1711
|
...oneShot,
|
|
1635
1712
|
endpoints.src.path,
|
|
1636
1713
|
scpSpec(connection, endpoints.dst.path)
|
|
@@ -1663,7 +1740,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1663
1740
|
dstSpec = scpSpec(destination, endpoints.dst.path);
|
|
1664
1741
|
extra = identityArgv(source);
|
|
1665
1742
|
} else {
|
|
1666
|
-
config = await tempSshConfig(source, destination);
|
|
1743
|
+
config = await tempSshConfig(source, destination, runNative);
|
|
1667
1744
|
extra = ["-F", config.path];
|
|
1668
1745
|
srcSpec = `superd-scp3-source:${endpoints.src.path}`;
|
|
1669
1746
|
dstSpec = `superd-scp3-destination:${endpoints.dst.path}`;
|
|
@@ -1676,7 +1753,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1676
1753
|
srcSpec,
|
|
1677
1754
|
dstSpec
|
|
1678
1755
|
];
|
|
1679
|
-
return finish("scp
|
|
1756
|
+
return finish("scp-3", [argv], [await runNative(wrap(argv), signal)], config !== void 0 ? [[config.path, "<temp-ssh-config>"]] : []);
|
|
1680
1757
|
} catch (error) {
|
|
1681
1758
|
if (signal.aborted) unknown = true;
|
|
1682
1759
|
throw error;
|
|
@@ -1695,7 +1772,9 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1695
1772
|
endpoints.src.path,
|
|
1696
1773
|
`${containerOf(endpoints.dst.plan)}:${endpoints.dst.path}`
|
|
1697
1774
|
];
|
|
1698
|
-
|
|
1775
|
+
const result = await runNative(wrap(argv), signal);
|
|
1776
|
+
withSymlinkHint(result);
|
|
1777
|
+
return finish("docker-cp", [argv], [result]);
|
|
1699
1778
|
}
|
|
1700
1779
|
if (s === "docker" && t === "local") {
|
|
1701
1780
|
const argv = [
|
|
@@ -1705,18 +1784,21 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1705
1784
|
`${containerOf(endpoints.src.plan)}:${endpoints.src.path}`,
|
|
1706
1785
|
endpoints.dst.path
|
|
1707
1786
|
];
|
|
1708
|
-
|
|
1787
|
+
const result = await runNative(wrap(argv), signal);
|
|
1788
|
+
withSymlinkHint(result);
|
|
1789
|
+
return finish("docker-cp", [argv], [result]);
|
|
1709
1790
|
}
|
|
1710
1791
|
if (s === "local" && t === "incus") {
|
|
1711
1792
|
const argv = [
|
|
1712
1793
|
"incus",
|
|
1713
1794
|
"file",
|
|
1714
1795
|
"push",
|
|
1796
|
+
...await recursiveFlag(endpoints.src.path, oneShot, "-r"),
|
|
1715
1797
|
...oneShot,
|
|
1716
1798
|
endpoints.src.path,
|
|
1717
1799
|
`${containerOf(endpoints.dst.plan)}${endpoints.dst.path}`
|
|
1718
1800
|
];
|
|
1719
|
-
return finish("incus
|
|
1801
|
+
return finish("incus-file-push", [argv], [await runNative(wrap(argv), signal)]);
|
|
1720
1802
|
}
|
|
1721
1803
|
if (s === "incus" && t === "local") {
|
|
1722
1804
|
const argv = [
|
|
@@ -1727,7 +1809,7 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1727
1809
|
`${containerOf(endpoints.src.plan)}${endpoints.src.path}`,
|
|
1728
1810
|
endpoints.dst.path
|
|
1729
1811
|
];
|
|
1730
|
-
return finish("incus
|
|
1812
|
+
return finish("incus-file-pull", [argv], [await runNative(wrap(argv), signal)]);
|
|
1731
1813
|
}
|
|
1732
1814
|
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
1815
|
const pullOptions = request.pullArgv !== void 0 ? pull : oneShot;
|
|
@@ -1740,9 +1822,12 @@ async function nativeCopy(request, endpoints, signal, options = {}) {
|
|
|
1740
1822
|
const pullArgv = segmentArgv(endpoints.src.plan, endpoints.src.path, staged, pullOptions, "pull");
|
|
1741
1823
|
const pushArgv = segmentArgv(endpoints.dst.plan, endpoints.dst.path, staged, pushOptions, "push");
|
|
1742
1824
|
const first = await runNative(wrap(pullArgv), signal);
|
|
1743
|
-
if (first.exit !== 0)
|
|
1825
|
+
if (first.exit !== 0) {
|
|
1826
|
+
receipt.note = "push not started: pull failed";
|
|
1827
|
+
return finish("staged-pull-push", [pullArgv], [first], [[staged, "<staging>"]]);
|
|
1828
|
+
}
|
|
1744
1829
|
const second = await runNative(wrap(pushArgv), signal);
|
|
1745
|
-
return finish("
|
|
1830
|
+
return finish("staged-pull-push", [pullArgv, pushArgv], [first, second], [[staged, "<staging>"]]);
|
|
1746
1831
|
} finally {
|
|
1747
1832
|
receipt.cleanup = await rm(scratch, {
|
|
1748
1833
|
recursive: true,
|
|
@@ -1803,40 +1888,162 @@ function segmentArgv(plan, remote, staged, opts, leg) {
|
|
|
1803
1888
|
spec
|
|
1804
1889
|
];
|
|
1805
1890
|
}
|
|
1891
|
+
/** D3 mkdir pre-hop: ensure the destination's PARENT directory exists before
|
|
1892
|
+
* the transfer (never the operand itself — see parent-only note below).
|
|
1893
|
+
* transfer. Local dst is node-side; remote dst runs ONE exec `mkdir -p` probe
|
|
1894
|
+
* whose stdout discloses created-vs-existed. Best-effort by contract: any
|
|
1895
|
+
* failure records created:'n-a' + note and lets the copy proceed so the native
|
|
1896
|
+
* CLI surfaces the real error (and stopped-container docker cp keeps working). */
|
|
1897
|
+
async function ensureDestinationDir(dst, signal, execArgvFor, wrap, receipt) {
|
|
1898
|
+
const dir = dirname(dst.path);
|
|
1899
|
+
if (dir === "/" || dir === ".") {
|
|
1900
|
+
receipt.created = false;
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (dst.plan.kind === "local") {
|
|
1904
|
+
try {
|
|
1905
|
+
if (existsSync(dir)) {
|
|
1906
|
+
receipt.created = false;
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
mkdirSync(dir, { recursive: true });
|
|
1910
|
+
receipt.created = true;
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
receipt.created = "n-a";
|
|
1913
|
+
receipt.note = `mkdir pre-hop failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1914
|
+
}
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (execArgvFor === void 0) {
|
|
1918
|
+
receipt.created = "n-a";
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
if (/[\r\n]/.test(dir)) {
|
|
1922
|
+
receipt.created = "n-a";
|
|
1923
|
+
receipt.note = "mkdir pre-hop skipped: newline-bearing destination path";
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const quoted = `'${dir.replaceAll("'", "'\\\\''")}'`;
|
|
1927
|
+
const script = `if [ -d ${quoted} ]; then echo existed; else mkdir -p ${quoted} && echo created; fi`;
|
|
1928
|
+
try {
|
|
1929
|
+
const result = await runNative(wrap(execArgvFor(dst.plan, script)), signal);
|
|
1930
|
+
const word = result.stdout.trim().split("\n").pop();
|
|
1931
|
+
if (result.exit === 0 && word === "created") receipt.created = true;
|
|
1932
|
+
else if (result.exit === 0 && word === "existed") receipt.created = false;
|
|
1933
|
+
else {
|
|
1934
|
+
receipt.created = "n-a";
|
|
1935
|
+
const tail = result.stderr.trim().split("\n").filter((line) => line.length > 0).pop();
|
|
1936
|
+
receipt.note = `mkdir pre-hop unavailable (exit ${result.exit})${tail !== void 0 ? `: ${tail}` : ""}`;
|
|
1937
|
+
}
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
receipt.created = "n-a";
|
|
1940
|
+
receipt.note = `mkdir pre-hop failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1806
1943
|
/** Distinguishing identities for scp -3: an origin-only temp config maps two
|
|
1807
1944
|
* synthetic aliases; keys are referenced in place, never copied to any remote. */
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1945
|
+
/** D8 (2026-10-14 user adoption, third acceptance round): a LOCAL directory source gets
|
|
1946
|
+
* the route's recursive flag appended automatically — scp / incus file push refuse
|
|
1947
|
+
* directories without it. The appended flag lands in the executed argv, which the
|
|
1948
|
+
* receipt shows verbatim: disclosure by receipt, never silent. Remote sources stay
|
|
1949
|
+
* caller-explicit (we do not dial just to stat); an explicit caller flag wins. */
|
|
1950
|
+
async function recursiveFlag(localPath, callerArgv, flag) {
|
|
1951
|
+
if (callerArgv.includes(flag) || callerArgv.includes("--recursive")) return [];
|
|
1952
|
+
try {
|
|
1953
|
+
if ((await stat(localPath)).isDirectory()) return [flag];
|
|
1954
|
+
} catch {}
|
|
1955
|
+
return [];
|
|
1956
|
+
}
|
|
1957
|
+
/** docker cp follows symlinks only with -L; its native error ("invalid symlink")
|
|
1958
|
+
* names no remedy. Append the one-line hint on that failure path (F5, 2026-10-14). */
|
|
1959
|
+
function withSymlinkHint(result) {
|
|
1960
|
+
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)`;
|
|
1961
|
+
}
|
|
1962
|
+
async function tempSshConfig(source, destination, runNative$1) {
|
|
1963
|
+
const resolvedSource = await resolveThroughOriginConfig(source, runNative$1);
|
|
1964
|
+
const resolvedDestination = await resolveThroughOriginConfig(destination, runNative$1);
|
|
1965
|
+
for (const { connection: c, identityFiles } of [resolvedSource, resolvedDestination]) {
|
|
1966
|
+
for (const [field, value] of [
|
|
1967
|
+
["host", c.host],
|
|
1968
|
+
["user", c.user],
|
|
1969
|
+
["key", c.key],
|
|
1970
|
+
["proxyJump", c.proxyJump]
|
|
1971
|
+
]) if (value !== void 0 && /[\r\n]/.test(value)) throw new Error(`remote: scp -3 temp config rejects newline in SSH ${field} (no config injection)`);
|
|
1972
|
+
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)");
|
|
1973
|
+
}
|
|
1815
1974
|
const dir = await mkdtemp(join(tmpdir(), "superd-scp3-"));
|
|
1816
1975
|
const path = join(dir, "config");
|
|
1817
|
-
const block = (name$1, c) => {
|
|
1976
|
+
const block = (name$1, c, identityFiles) => {
|
|
1818
1977
|
const lines = [`Host ${name$1}`, ` HostName ${c.host.replace(/^.*@/, "")}`];
|
|
1819
1978
|
if (c.user !== void 0) lines.push(` User ${c.user}`);
|
|
1820
1979
|
if (c.port !== void 0) lines.push(` Port ${String(c.port)}`);
|
|
1821
|
-
|
|
1980
|
+
for (const idFile of [c.key !== void 0 ? [c.key] : [], identityFiles].flat()) lines.push(` IdentityFile ${idFile}`);
|
|
1822
1981
|
if (c.proxyJump !== void 0) lines.push(` ProxyJump ${c.proxyJump}`);
|
|
1823
1982
|
return lines.join("\n");
|
|
1824
1983
|
};
|
|
1825
|
-
await writeFile(path, `${block("superd-scp3-source",
|
|
1984
|
+
await writeFile(path, `${block("superd-scp3-source", resolvedSource.connection, resolvedSource.identityFiles)}\n${block("superd-scp3-destination", resolvedDestination.connection, resolvedDestination.identityFiles)}\n`, { mode: 384 });
|
|
1826
1985
|
await chmod(path, 384);
|
|
1827
1986
|
return {
|
|
1828
1987
|
path,
|
|
1829
1988
|
dir
|
|
1830
1989
|
};
|
|
1831
1990
|
}
|
|
1991
|
+
/** Effective fields for one connection per the origin's own ssh config (ssh -G,
|
|
1992
|
+
* prints resolved settings without dialing). Explicit fields win; empty/none
|
|
1993
|
+
* proxyjump is dropped. IdentityFile PATHS from the config are returned separately
|
|
1994
|
+
* for inlining into the temp config — a path is not key material and stays on the
|
|
1995
|
+
* origin (F1, 2026-10-14: dev4's only authorized key lived in config, not the agent;
|
|
1996
|
+
* dropping it made scp -3 fail with a bare Permission denied). */
|
|
1997
|
+
async function resolveThroughOriginConfig(c, runNative$1) {
|
|
1998
|
+
let probe;
|
|
1999
|
+
try {
|
|
2000
|
+
probe = await runNative$1([
|
|
2001
|
+
"ssh",
|
|
2002
|
+
"-G",
|
|
2003
|
+
c.host
|
|
2004
|
+
], new AbortController().signal);
|
|
2005
|
+
} catch {
|
|
2006
|
+
probe = void 0;
|
|
2007
|
+
}
|
|
2008
|
+
if (probe === void 0 || probe.exit !== 0) return {
|
|
2009
|
+
connection: c,
|
|
2010
|
+
identityFiles: []
|
|
2011
|
+
};
|
|
2012
|
+
const lines = probe.stdout.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
2013
|
+
const pick$1 = (key) => lines.find((line) => line.startsWith(`${key} `))?.slice(key.length + 1).trim() || void 0;
|
|
2014
|
+
const home = process.env.HOME ?? "";
|
|
2015
|
+
const compiledDefault = (path) => path === "none" || [
|
|
2016
|
+
"id_rsa",
|
|
2017
|
+
"id_ecdsa",
|
|
2018
|
+
"id_ecdsa_sk",
|
|
2019
|
+
"id_ed25519",
|
|
2020
|
+
"id_ed25519_sk"
|
|
2021
|
+
].some((name$1) => path === `~/.ssh/${name$1}` || home !== "" && path === `${home}/.ssh/${name$1}`);
|
|
2022
|
+
const identityFiles = [...new Set(lines.filter((line) => line.startsWith("identityfile ")).map((line) => line.slice(13).trim()).filter((path) => path !== "" && !compiledDefault(path)))];
|
|
2023
|
+
const host = (pick$1("hostname") ?? c.host).replace(/^.*@/, "");
|
|
2024
|
+
const user = c.user ?? pick$1("user");
|
|
2025
|
+
const portText = pick$1("port");
|
|
2026
|
+
const port = c.port ?? (portText !== void 0 && /^\d+$/.test(portText) ? Number(portText) : void 0);
|
|
2027
|
+
const proxyJumpRaw = c.proxyJump ?? pick$1("proxyjump");
|
|
2028
|
+
return {
|
|
2029
|
+
connection: {
|
|
2030
|
+
host,
|
|
2031
|
+
...user !== void 0 && user !== "" ? { user } : {},
|
|
2032
|
+
...port !== void 0 && Number.isInteger(port) && port > 0 ? { port } : {},
|
|
2033
|
+
...c.key !== void 0 ? { key: c.key } : {},
|
|
2034
|
+
...proxyJumpRaw !== void 0 && proxyJumpRaw !== "" && proxyJumpRaw.toLowerCase() !== "none" ? { proxyJump: proxyJumpRaw } : {}
|
|
2035
|
+
},
|
|
2036
|
+
identityFiles
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
1832
2039
|
|
|
1833
2040
|
//#endregion
|
|
1834
2041
|
//#region src/remote/ssh-rsync-native-relay.ts
|
|
1835
2042
|
/** ssh argv for the relay: batch, no agent, no multiplexing. `command` is the
|
|
1836
|
-
* remote command; forwarding options go before `--`, the host after it.
|
|
2043
|
+
* remote command; forwarding options go before `--`, the host after it.
|
|
2044
|
+
* Identity core comes from ssh-argv.ts (the ONE policy — 2026-10-17 ruling). */
|
|
1837
2045
|
function relaySsh(connection, extra, command) {
|
|
1838
|
-
|
|
1839
|
-
"ssh",
|
|
2046
|
+
return sshCommandArgv(connection, [
|
|
1840
2047
|
"-o",
|
|
1841
2048
|
"BatchMode=yes",
|
|
1842
2049
|
"-T",
|
|
@@ -1853,14 +2060,7 @@ function relaySsh(connection, extra, command) {
|
|
|
1853
2060
|
"ServerAliveInterval=5",
|
|
1854
2061
|
"-o",
|
|
1855
2062
|
"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;
|
|
2063
|
+
], extra, command);
|
|
1864
2064
|
}
|
|
1865
2065
|
async function freePorts(count) {
|
|
1866
2066
|
const servers = [];
|
|
@@ -2015,12 +2215,16 @@ async function forwardRoute(argv, source, destination, signal, options) {
|
|
|
2015
2215
|
return await removeRemoteTemp(destination.connection, temp, wrap) ? "released" : "unknown";
|
|
2016
2216
|
};
|
|
2017
2217
|
try {
|
|
2218
|
+
signal.throwIfAborted();
|
|
2219
|
+
const daemonState = await waitForDaemonReady(destinationChild, 5e3);
|
|
2220
|
+
if (daemonState === "exited") throw new Error(`dual-SSH rsync destination daemon exited before serving${relayDetail(destinationChild, sourceHold)}`);
|
|
2221
|
+
if (daemonState === "timeout") throw new Error(`dual-SSH rsync destination daemon never announced readiness${relayDetail(destinationChild, sourceHold)}`);
|
|
2018
2222
|
signal.throwIfAborted();
|
|
2019
2223
|
if (!await originPortOpen(originPort, 15e3)) throw new Error(`dual-SSH rsync destination daemon never became reachable${relayDetail(destinationChild, sourceHold)}`);
|
|
2020
2224
|
signal.throwIfAborted();
|
|
2021
2225
|
const runArgv = relaySsh(source.connection, [], rsyncClientCommand(argv, source.path, sourcePort));
|
|
2022
2226
|
const result = await runNative(wrap(runArgv), signal);
|
|
2023
|
-
if (result.exit
|
|
2227
|
+
if (result.exit !== 0) result.stderr = (result.stderr + relayDetail(destinationChild, sourceHold)).slice(-OUTPUT_LIMIT);
|
|
2024
2228
|
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
2229
|
return {
|
|
2026
2230
|
...result,
|
|
@@ -2045,6 +2249,19 @@ async function forwardRoute(argv, source, destination, signal, options) {
|
|
|
2045
2249
|
await Promise.allSettled([destinationChild.stop(), sourceHold.stop()]);
|
|
2046
2250
|
}
|
|
2047
2251
|
}
|
|
2252
|
+
/** Wait until the destination daemon script announces itself (stderr marker,
|
|
2253
|
+
* printed after its directory check and right before the daemon exec) or exits.
|
|
2254
|
+
* 'ready' only when the daemon is actually about to serve — closes the 0.3.1-e
|
|
2255
|
+
* race where -L opens at session start but the script dies ~150ms later. */
|
|
2256
|
+
async function waitForDaemonReady(held, budgetMs) {
|
|
2257
|
+
const deadline = Date.now() + budgetMs;
|
|
2258
|
+
for (;;) {
|
|
2259
|
+
if (/^SUPERD-RSYNC-TMP \S+$/m.test(held.stderrSink.text)) return "ready";
|
|
2260
|
+
if (held.child.exitCode !== null) return "exited";
|
|
2261
|
+
if (Date.now() >= deadline) return "timeout";
|
|
2262
|
+
await new Promise((resolve$1) => setTimeout(resolve$1, 50));
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2048
2265
|
function relayDetail(...held) {
|
|
2049
2266
|
const detail = held.map((child) => child.stderrSink.text).filter(Boolean).join("\n");
|
|
2050
2267
|
return detail ? `\n[relay stderr]\n${detail}` : "";
|
|
@@ -2126,14 +2343,7 @@ async function runRsync(request, source, destination, signal, options = {}) {
|
|
|
2126
2343
|
checkAbsolute(destination.path);
|
|
2127
2344
|
const argv = checkArgv("argv", request.argv);
|
|
2128
2345
|
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)`);
|
|
2346
|
+
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
2347
|
if (!source.connection && !destination.connection) throw new Error("rsync requires at least one SSH endpoint; local-only is unsupported");
|
|
2138
2348
|
let result;
|
|
2139
2349
|
let route;
|
|
@@ -2181,6 +2391,9 @@ async function runRsync(request, source, destination, signal, options = {}) {
|
|
|
2181
2391
|
dst: request.dst,
|
|
2182
2392
|
argv,
|
|
2183
2393
|
route,
|
|
2394
|
+
verification: "native-exit-only",
|
|
2395
|
+
ok: result.exit === 0,
|
|
2396
|
+
...result.exit === 0 ? {} : { reason: signal.aborted ? "aborted" : "non-zero-exit" },
|
|
2184
2397
|
command: `rsync ${argv.join(" ")}`,
|
|
2185
2398
|
...itemized.length > 0 || argv.includes("--itemize-changes") || argv.includes("-i") ? { itemizedChanges: itemized } : {},
|
|
2186
2399
|
...literalBytes === void 0 ? {} : { literalBytes },
|
|
@@ -2195,21 +2408,14 @@ async function runRsync(request, source, destination, signal, options = {}) {
|
|
|
2195
2408
|
* placeholder host rsync appends and execs the canonical SSH argv. All tokens
|
|
2196
2409
|
* are tool-built argv (identity options/aliases), never model text. */
|
|
2197
2410
|
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
2411
|
return [
|
|
2210
2412
|
"bash",
|
|
2211
2413
|
"-c",
|
|
2212
|
-
`shift; exec ${wrap(
|
|
2414
|
+
`shift; exec ${wrap(sshCommandArgv(connection, [
|
|
2415
|
+
"-o",
|
|
2416
|
+
"BatchMode=yes",
|
|
2417
|
+
"-T"
|
|
2418
|
+
], [])).map(rshQuote).join(" ")} "$@"`,
|
|
2213
2419
|
"rsync-rsh"
|
|
2214
2420
|
].map(rshQuote).join(" ");
|
|
2215
2421
|
}
|
|
@@ -2228,7 +2434,7 @@ function parseFileEndpoint(value) {
|
|
|
2228
2434
|
if (split < 1) throw new Error("file endpoint must be [target:]absolute/path");
|
|
2229
2435
|
const target = value.slice(0, split), path = value.slice(split + 1);
|
|
2230
2436
|
checkAbsolute(path);
|
|
2231
|
-
if (target.startsWith("
|
|
2437
|
+
if (target.startsWith("pty:")) throw new Error("a PTY session is not a filesystem endpoint; select SSH/Docker/Incus/Hub");
|
|
2232
2438
|
return {
|
|
2233
2439
|
target,
|
|
2234
2440
|
path
|
|
@@ -2237,13 +2443,9 @@ function parseFileEndpoint(value) {
|
|
|
2237
2443
|
|
|
2238
2444
|
//#endregion
|
|
2239
2445
|
//#region src/remote/ssh-targets.ts
|
|
2240
|
-
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2241
2446
|
const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
2242
2447
|
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
2448
|
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
2449
|
function bad(field) {
|
|
2248
2450
|
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
2451
|
}
|
|
@@ -2283,48 +2485,20 @@ function normalizeSshConnection(input) {
|
|
|
2283
2485
|
...input.proxyJump === void 0 ? {} : { proxyJump: input.proxyJump }
|
|
2284
2486
|
});
|
|
2285
2487
|
}
|
|
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
2488
|
/** Shared by execution, probes and file/relay consumers. extraArgs is trusted
|
|
2308
|
-
* internal SSH argv (e.g. forwarding), never a model-provided shell string.
|
|
2489
|
+
* internal SSH argv (e.g. forwarding), never a model-provided shell string.
|
|
2490
|
+
* Identity assembly lives in ssh-argv.ts (the ONE policy — 2026-10-17 ruling);
|
|
2491
|
+
* this wrapper only picks the tty/batch dialect base. */
|
|
2309
2492
|
function sshArgv(connection, options = {}) {
|
|
2310
|
-
|
|
2311
|
-
"ssh",
|
|
2493
|
+
return sshCommandArgv(connection, options.tty ? [
|
|
2312
2494
|
"-tt",
|
|
2313
2495
|
"-o",
|
|
2314
2496
|
"BatchMode=yes"
|
|
2315
2497
|
] : [
|
|
2316
|
-
"ssh",
|
|
2317
2498
|
"-o",
|
|
2318
2499
|
"BatchMode=yes",
|
|
2319
2500
|
"-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;
|
|
2501
|
+
], options.extraArgs ?? [], options.command);
|
|
2328
2502
|
}
|
|
2329
2503
|
|
|
2330
2504
|
//#endregion
|
|
@@ -2332,17 +2506,17 @@ function sshArgv(connection, options = {}) {
|
|
|
2332
2506
|
/** Local carrier validation precedes any target discovery, connection or spawn. */
|
|
2333
2507
|
async function prepareRemoteInput(input) {
|
|
2334
2508
|
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.
|
|
2509
|
+
if (input.scriptPath !== void 0) {
|
|
2510
|
+
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");
|
|
2511
|
+
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
2512
|
}
|
|
2339
|
-
if (!input.target) throw new Error("[E_PARAMS] remote: target is required
|
|
2340
|
-
if (input.
|
|
2513
|
+
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)");
|
|
2514
|
+
if (input.scriptPath !== void 0) {
|
|
2341
2515
|
let stdin;
|
|
2342
2516
|
try {
|
|
2343
|
-
stdin = await readFile(input.
|
|
2517
|
+
stdin = await readFile(input.scriptPath, "utf8");
|
|
2344
2518
|
} catch (error) {
|
|
2345
|
-
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.
|
|
2519
|
+
throw new Error(`[E_SCRIPT_READ] remote: cannot read local script ${JSON.stringify(input.scriptPath)} — ${error instanceof Error ? error.message : String(error)}`);
|
|
2346
2520
|
}
|
|
2347
2521
|
return {
|
|
2348
2522
|
target: input.target,
|
|
@@ -2381,6 +2555,96 @@ async function runOneShot(argv, opts = {}) {
|
|
|
2381
2555
|
};
|
|
2382
2556
|
}
|
|
2383
2557
|
|
|
2558
|
+
//#endregion
|
|
2559
|
+
//#region src/remote/helper.ts
|
|
2560
|
+
/** Reads helper-bin/MANIFEST.json next to the compiled module (lib/remote in the npm
|
|
2561
|
+
* package, super-dsh/remote in the Super closure — same relative layout). Missing or
|
|
2562
|
+
* malformed assets ⇒ null ⇒ shim-only mode; never throws. */
|
|
2563
|
+
function helperAssets(baseDir) {
|
|
2564
|
+
const dir = baseDir ?? join(dirname(fileURLToPath(import.meta.url)), "helper-bin");
|
|
2565
|
+
try {
|
|
2566
|
+
const manifest = JSON.parse(readFileSync(join(dir, "MANIFEST.json"), "utf8"));
|
|
2567
|
+
if (manifest?.format !== 1 || typeof manifest.ver !== "string" || typeof manifest.protocol !== "number" || !manifest.targets || typeof manifest.targets !== "object") return null;
|
|
2568
|
+
return {
|
|
2569
|
+
dir,
|
|
2570
|
+
manifest
|
|
2571
|
+
};
|
|
2572
|
+
} catch {
|
|
2573
|
+
return null;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
/** `uname -s`/`uname -m` → shipped target triple. Unknown combos ⇒ null (shim). */
|
|
2577
|
+
function archTriple(sys, machine, shipped) {
|
|
2578
|
+
const s = sys.trim().toLowerCase(), m = machine.trim().toLowerCase();
|
|
2579
|
+
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;
|
|
2580
|
+
if (candidates === null) return null;
|
|
2581
|
+
return shipped.has(candidates) ? candidates : null;
|
|
2582
|
+
}
|
|
2583
|
+
function planKey(plan) {
|
|
2584
|
+
switch (plan.kind) {
|
|
2585
|
+
case "ssh": {
|
|
2586
|
+
const c = plan.connection;
|
|
2587
|
+
const host = c?.host ?? plan.host;
|
|
2588
|
+
return `ssh|${c?.user ? c.user + "@" : ""}${host}${c?.port ? ":" + c.port : ""}${c?.proxyJump ? "|" + c.proxyJump : ""}`;
|
|
2589
|
+
}
|
|
2590
|
+
case "docker": return `docker|${plan.container}`;
|
|
2591
|
+
case "incus": return `incus|${plan.container}`;
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
const PROBE_RE = /^SUPERD-PROBE (\S+) (\S+)$/m;
|
|
2595
|
+
const READY_RE = /^SUPERD-READY (\S+)$/m;
|
|
2596
|
+
var HelperRegistry = class {
|
|
2597
|
+
states = /* @__PURE__ */ new Map();
|
|
2598
|
+
constructor(assets, exec, probeArgv, uploadArgv) {
|
|
2599
|
+
this.assets = assets;
|
|
2600
|
+
this.exec = exec;
|
|
2601
|
+
this.probeArgv = probeArgv;
|
|
2602
|
+
this.uploadArgv = uploadArgv;
|
|
2603
|
+
}
|
|
2604
|
+
/** Never throws: any bootstrap failure resolves null (sticky) and the caller stays on the shim. */
|
|
2605
|
+
ensure(plan) {
|
|
2606
|
+
if (plan.kind !== "ssh" && plan.kind !== "docker" && plan.kind !== "incus") return Promise.resolve(null);
|
|
2607
|
+
const key = planKey(plan);
|
|
2608
|
+
let entry = this.states.get(key);
|
|
2609
|
+
if (entry === void 0) {
|
|
2610
|
+
entry = this.bootstrap(plan).catch(() => null);
|
|
2611
|
+
entry.catch(() => {});
|
|
2612
|
+
this.states.set(key, entry);
|
|
2613
|
+
}
|
|
2614
|
+
return entry;
|
|
2615
|
+
}
|
|
2616
|
+
async bootstrap(plan) {
|
|
2617
|
+
const { manifest } = this.assets;
|
|
2618
|
+
const first = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
2619
|
+
const ready = this.parseReady(first.stdout, manifest.ver, manifest.protocol);
|
|
2620
|
+
if (ready !== null) return ready;
|
|
2621
|
+
const probe = PROBE_RE.exec(first.stdout);
|
|
2622
|
+
if (probe === null || first.exit !== 90 && first.exit !== 0) return null;
|
|
2623
|
+
const triple = archTriple(probe[1], probe[2], new Set(Object.keys(manifest.targets)));
|
|
2624
|
+
if (triple === null) return null;
|
|
2625
|
+
const target = manifest.targets[triple];
|
|
2626
|
+
const binary = await readFile(join(this.assets.dir, triple, target.file));
|
|
2627
|
+
if ((await this.exec(this.uploadArgv(plan, triple, target.file, this.assets), {
|
|
2628
|
+
stdin: binary,
|
|
2629
|
+
timeoutSec: 60
|
|
2630
|
+
})).exit !== 0) return null;
|
|
2631
|
+
const second = await this.exec(this.probeArgv(plan, this.assets), { timeoutSec: 15 });
|
|
2632
|
+
return this.parseReady(second.stdout, manifest.ver, manifest.protocol);
|
|
2633
|
+
}
|
|
2634
|
+
parseReady(stdout, ver, protocol) {
|
|
2635
|
+
const ready = READY_RE.exec(stdout);
|
|
2636
|
+
const banner = /^SUPERD-RUN proto=(\d+) name=superd-run ver=(\S+) target=(\S+)/m.exec(stdout);
|
|
2637
|
+
if (ready === null || banner === null) return null;
|
|
2638
|
+
if (banner[1] !== String(protocol) || banner[2] !== ver) return null;
|
|
2639
|
+
return {
|
|
2640
|
+
binPath: ready[1],
|
|
2641
|
+
triple: banner[3],
|
|
2642
|
+
ver,
|
|
2643
|
+
protocol
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
};
|
|
2647
|
+
|
|
2384
2648
|
//#endregion
|
|
2385
2649
|
//#region src/remote/transports.ts
|
|
2386
2650
|
/** POSIX 单引号机械转义(唯一一层转义,spec §三"一层机械转义")。 */
|
|
@@ -2428,12 +2692,15 @@ bash -lc ${shQuote(cmd)}`;
|
|
|
2428
2692
|
];
|
|
2429
2693
|
}
|
|
2430
2694
|
}
|
|
2431
|
-
/** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。
|
|
2695
|
+
/** Persistent pty(Ruling 2):ssh/incus 走宿主管道,docker 经 script(1) 托管宿主 PTY。
|
|
2696
|
+
* ssh 基座常驻固定 keepalive(2026-10-19 裁决):客户端自有、零对端依赖,控制消息
|
|
2697
|
+
* 不触碰 PTY 字节流;oneshot 短命连接不带。 */
|
|
2432
2698
|
function buildPtyArgv(plan) {
|
|
2433
2699
|
switch (plan.kind) {
|
|
2434
2700
|
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, {
|
|
2435
2701
|
tty: true,
|
|
2436
|
-
command: "bash"
|
|
2702
|
+
command: "bash",
|
|
2703
|
+
extraArgs: SSH_PERSISTENT_KEEPALIVE
|
|
2437
2704
|
});
|
|
2438
2705
|
case "docker": return [
|
|
2439
2706
|
"script",
|
|
@@ -2460,6 +2727,104 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
2460
2727
|
"/dev/null"
|
|
2461
2728
|
];
|
|
2462
2729
|
}
|
|
2730
|
+
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
2731
|
+
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
2732
|
+
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
2733
|
+
function buildHelperProbeArgv(plan, assets) {
|
|
2734
|
+
const { ver, targets } = assets.manifest;
|
|
2735
|
+
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`);
|
|
2736
|
+
}
|
|
2737
|
+
function buildHelperUploadArgv(plan, triple, file, assets) {
|
|
2738
|
+
const dir = `$HOME/.cache/superd/run/${triple}-${assets.manifest.ver}`;
|
|
2739
|
+
return helperShellArgv(plan, `mkdir -p "${dir}" && cat > "${dir}/${file}.tmp" && chmod 0755 "${dir}/${file}.tmp" && mv -f "${dir}/${file}.tmp" "${dir}/${file}"`);
|
|
2740
|
+
}
|
|
2741
|
+
function buildHelperOneShotArgv(plan, binPath, options) {
|
|
2742
|
+
const { tag, cmd } = options;
|
|
2743
|
+
switch (plan.kind) {
|
|
2744
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `exec ${shQuote(binPath)} run --tag ${shQuote(tag)} -- bash -lc ${shQuote(cmd)}` });
|
|
2745
|
+
case "docker": return [
|
|
2746
|
+
"docker",
|
|
2747
|
+
"exec",
|
|
2748
|
+
"-i",
|
|
2749
|
+
"--",
|
|
2750
|
+
plan.container,
|
|
2751
|
+
binPath,
|
|
2752
|
+
"run",
|
|
2753
|
+
"--tag",
|
|
2754
|
+
tag,
|
|
2755
|
+
"--",
|
|
2756
|
+
"bash",
|
|
2757
|
+
"-lc",
|
|
2758
|
+
cmd
|
|
2759
|
+
];
|
|
2760
|
+
case "incus": return [
|
|
2761
|
+
"incus",
|
|
2762
|
+
"exec",
|
|
2763
|
+
"--force-noninteractive",
|
|
2764
|
+
plan.container,
|
|
2765
|
+
"--",
|
|
2766
|
+
binPath,
|
|
2767
|
+
"run",
|
|
2768
|
+
"--tag",
|
|
2769
|
+
tag,
|
|
2770
|
+
"--",
|
|
2771
|
+
"bash",
|
|
2772
|
+
"-lc",
|
|
2773
|
+
cmd
|
|
2774
|
+
];
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
function buildHelperKillgroupArgv(plan, binPath, tag) {
|
|
2778
|
+
switch (plan.kind) {
|
|
2779
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: `${shQuote(binPath)} killgroup --tag ${shQuote(tag)}` });
|
|
2780
|
+
case "docker": return [
|
|
2781
|
+
"docker",
|
|
2782
|
+
"exec",
|
|
2783
|
+
"--",
|
|
2784
|
+
plan.container,
|
|
2785
|
+
binPath,
|
|
2786
|
+
"killgroup",
|
|
2787
|
+
"--tag",
|
|
2788
|
+
tag
|
|
2789
|
+
];
|
|
2790
|
+
case "incus": return [
|
|
2791
|
+
"incus",
|
|
2792
|
+
"exec",
|
|
2793
|
+
"--force-noninteractive",
|
|
2794
|
+
plan.container,
|
|
2795
|
+
"--",
|
|
2796
|
+
binPath,
|
|
2797
|
+
"killgroup",
|
|
2798
|
+
"--tag",
|
|
2799
|
+
tag
|
|
2800
|
+
];
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
function helperShellArgv(plan, script) {
|
|
2804
|
+
switch (plan.kind) {
|
|
2805
|
+
case "ssh": return sshArgv(plan.connection ?? { host: plan.host }, { command: script });
|
|
2806
|
+
case "docker": return [
|
|
2807
|
+
"docker",
|
|
2808
|
+
"exec",
|
|
2809
|
+
"-i",
|
|
2810
|
+
"--",
|
|
2811
|
+
plan.container,
|
|
2812
|
+
"sh",
|
|
2813
|
+
"-c",
|
|
2814
|
+
script
|
|
2815
|
+
];
|
|
2816
|
+
case "incus": return [
|
|
2817
|
+
"incus",
|
|
2818
|
+
"exec",
|
|
2819
|
+
"--force-noninteractive",
|
|
2820
|
+
plan.container,
|
|
2821
|
+
"--",
|
|
2822
|
+
"sh",
|
|
2823
|
+
"-c",
|
|
2824
|
+
script
|
|
2825
|
+
];
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2463
2828
|
|
|
2464
2829
|
//#endregion
|
|
2465
2830
|
//#region src/remote/nonce-framing.ts
|
|
@@ -2469,11 +2834,11 @@ function genNonce(bytes = 8) {
|
|
|
2469
2834
|
}
|
|
2470
2835
|
/** 命令整行:花括号复合体 + Nonce 收帧尾(spec §五.2 的 printf 逐字落地 + cwd 第三字段)。 */
|
|
2471
2836
|
function wrapPtyCommand(cmd, nonce) {
|
|
2472
|
-
return `{\n${cmd}\n} ; printf '\\033]133;D;${nonce};%s;%s\\007' "
|
|
2837
|
+
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
2838
|
}
|
|
2474
2839
|
/** init 整行:哑终端(echo/PS1/PS2/TERM)+ 固定 40×120(Non-Goal ③)+ INIT 帧。 */
|
|
2475
2840
|
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`;
|
|
2841
|
+
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
2842
|
}
|
|
2478
2843
|
const ANSI_RE = /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)?|\[[0-9;?]*[ -/]*[@-~]|[@-Z\\-_])/g;
|
|
2479
2844
|
function stripAnsi(s) {
|
|
@@ -2502,6 +2867,7 @@ function tailWindow(text, cap, stats) {
|
|
|
2502
2867
|
};
|
|
2503
2868
|
}
|
|
2504
2869
|
const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
2870
|
+
const ENV_BODY_RE = /^([A-Za-z0-9+/=]*)\x07/;
|
|
2505
2871
|
/**
|
|
2506
2872
|
* 单 nonce、单帧解析器:一轮 dispatch 一个实例。只认 `\x1b]133;D;<nonce>;`,
|
|
2507
2873
|
* 错误 nonce 的静态/伪造 marker 永不匹配,按普通输出流过(再经 ANSI 剥洗)。
|
|
@@ -2509,31 +2875,60 @@ const FRAME_BODY_RE = /^(\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/;
|
|
|
2509
2875
|
*/
|
|
2510
2876
|
function createNonceFrameParser(nonce, events) {
|
|
2511
2877
|
const prefix = `\x1b]133;D;${nonce};`;
|
|
2878
|
+
const prefixE = `\x1b]133;E;${nonce};`;
|
|
2879
|
+
const prefixes = [prefix, prefixE];
|
|
2512
2880
|
let buf = "";
|
|
2513
2881
|
let done = false;
|
|
2514
2882
|
const emit = (raw) => {
|
|
2515
2883
|
const text = normalizePtyText(raw);
|
|
2516
2884
|
if (text.length > 0) events.onOutput?.(text);
|
|
2517
2885
|
};
|
|
2886
|
+
/** 缓冲尾若是任一候选前缀的真前缀则扣留(D/E 共用);flush() 放出残余。 */
|
|
2518
2887
|
const holdback = () => {
|
|
2519
|
-
|
|
2520
|
-
for (
|
|
2521
|
-
|
|
2888
|
+
let best = 0;
|
|
2889
|
+
for (const p of prefixes) {
|
|
2890
|
+
const max = Math.min(buf.length, p.length - 1);
|
|
2891
|
+
for (let k = max; k > best; k--) if (buf.endsWith(p.slice(0, k))) {
|
|
2892
|
+
best = k;
|
|
2893
|
+
break;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
return best;
|
|
2522
2897
|
};
|
|
2898
|
+
/** 扫描最早出现的帧:E(env 侧信道)先于 D 下发;D = settle,帧后字节丢弃。 */
|
|
2523
2899
|
const consume = () => {
|
|
2524
2900
|
if (done) return;
|
|
2525
|
-
|
|
2526
|
-
|
|
2901
|
+
let idx = -1;
|
|
2902
|
+
let isEnv = false;
|
|
2903
|
+
for (const p of prefixes) {
|
|
2904
|
+
const i = buf.indexOf(p);
|
|
2905
|
+
if (i >= 0 && (idx === -1 || i < idx)) {
|
|
2906
|
+
idx = i;
|
|
2907
|
+
isEnv = p === prefixE;
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
if (idx < 0) {
|
|
2527
2911
|
const keep = holdback();
|
|
2528
2912
|
emit(buf.slice(0, buf.length - keep));
|
|
2529
2913
|
buf = buf.slice(buf.length - keep);
|
|
2530
2914
|
return;
|
|
2531
2915
|
}
|
|
2532
|
-
emit(buf.slice(0,
|
|
2533
|
-
const rest = buf.slice(
|
|
2916
|
+
emit(buf.slice(0, idx));
|
|
2917
|
+
const rest = buf.slice(idx + (isEnv ? prefixE.length : prefix.length));
|
|
2918
|
+
if (isEnv) {
|
|
2919
|
+
const m$1 = ENV_BODY_RE.exec(rest);
|
|
2920
|
+
if (m$1 === null) {
|
|
2921
|
+
buf = buf.slice(idx);
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
if (m$1[1].length > 0) events.onEnv?.(m$1[1]);
|
|
2925
|
+
buf = rest.slice(m$1[0].length);
|
|
2926
|
+
consume();
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2534
2929
|
const m = FRAME_BODY_RE.exec(rest);
|
|
2535
2930
|
if (m === null) {
|
|
2536
|
-
buf = buf.slice(
|
|
2931
|
+
buf = buf.slice(idx);
|
|
2537
2932
|
return;
|
|
2538
2933
|
}
|
|
2539
2934
|
events.onFrame({
|
|
@@ -2554,18 +2949,61 @@ function createNonceFrameParser(nonce, events) {
|
|
|
2554
2949
|
}
|
|
2555
2950
|
};
|
|
2556
2951
|
}
|
|
2952
|
+
/** E 帧 payload → name→value-hash(值不离开端点;hash 只为 changed 判定)。空/坏 payload → undefined。 */
|
|
2953
|
+
function decodeEnvSnapshot(b64) {
|
|
2954
|
+
if (b64.length === 0) return void 0;
|
|
2955
|
+
let text;
|
|
2956
|
+
try {
|
|
2957
|
+
text = Buffer.from(b64, "base64").toString("utf8");
|
|
2958
|
+
} catch {
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
const map = /* @__PURE__ */ new Map();
|
|
2962
|
+
for (const entry of text.split("\0")) {
|
|
2963
|
+
if (entry.length === 0) continue;
|
|
2964
|
+
const eq = entry.indexOf("=");
|
|
2965
|
+
const name$1 = eq === -1 ? entry : entry.slice(0, eq);
|
|
2966
|
+
if (name$1.startsWith("__superd")) continue;
|
|
2967
|
+
let h = 5381;
|
|
2968
|
+
const value = eq === -1 ? "" : entry.slice(eq + 1);
|
|
2969
|
+
for (let i = 0; i < value.length; i++) h = (h * 33 ^ value.charCodeAt(i)) >>> 0;
|
|
2970
|
+
map.set(name$1, h);
|
|
2971
|
+
}
|
|
2972
|
+
return map.size > 0 ? map : void 0;
|
|
2973
|
+
}
|
|
2974
|
+
/** prev 未知(init 侧信道失败)时返回 undefined——不报告假 delta。 */
|
|
2975
|
+
function diffEnv(prev, next) {
|
|
2976
|
+
if (prev === void 0) return void 0;
|
|
2977
|
+
const added = [], changed = [], removed = [];
|
|
2978
|
+
for (const [name$1, h] of next) if (!prev.has(name$1)) added.push(name$1);
|
|
2979
|
+
else if (prev.get(name$1) !== h) changed.push(name$1);
|
|
2980
|
+
for (const name$1 of prev.keys()) if (!next.has(name$1)) removed.push(name$1);
|
|
2981
|
+
added.sort();
|
|
2982
|
+
changed.sort();
|
|
2983
|
+
removed.sort();
|
|
2984
|
+
return {
|
|
2985
|
+
added,
|
|
2986
|
+
changed,
|
|
2987
|
+
removed
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2557
2990
|
|
|
2558
2991
|
//#endregion
|
|
2559
2992
|
//#region src/remote/pty-session.ts
|
|
2560
2993
|
/** spec §五.2 逐字。 */
|
|
2561
2994
|
const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
|
|
2562
|
-
/** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`
|
|
2563
|
-
*
|
|
2564
|
-
*
|
|
2995
|
+
/** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`p:` → `pty:<id>`)。
|
|
2996
|
+
* 这是**唯一**的 key→名字映射规则,roster 与错误面共用——错误面不得回吐内部键:
|
|
2997
|
+
* 2026-10-03 实测报告 F1,`E_SESSION_*` 曾把 `session-<uuid>|p:<label>` 整条吐给模型。 */
|
|
2565
2998
|
function displayKey(key) {
|
|
2566
2999
|
const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
|
|
2567
|
-
return raw.startsWith("
|
|
3000
|
+
return raw.startsWith("p:") ? `pty:${raw.slice(2)}` : raw;
|
|
2568
3001
|
}
|
|
3002
|
+
const LEDGER_MAX = 20;
|
|
3003
|
+
const summaryOf = (cmd) => {
|
|
3004
|
+
const first = cmd.trim().split(/\r?\n/, 1)[0] ?? "";
|
|
3005
|
+
return first.length > 72 ? `${first.slice(0, 71)}…` : first;
|
|
3006
|
+
};
|
|
2569
3007
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
2570
3008
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
2571
3009
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
@@ -2580,7 +3018,17 @@ var PtySession = class {
|
|
|
2580
3018
|
deathWaiters = [];
|
|
2581
3019
|
diag = "";
|
|
2582
3020
|
inFlight = 0;
|
|
2583
|
-
|
|
3021
|
+
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
3022
|
+
detachedJob = null;
|
|
3023
|
+
/** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
|
|
3024
|
+
ledger = [];
|
|
3025
|
+
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
3026
|
+
envBaseline;
|
|
3027
|
+
/** idle 起点(ready / settle 时重置);null = 尚无 idle 时钟(cold 或已 dispose)。 */
|
|
3028
|
+
idleSince = null;
|
|
3029
|
+
/** 意外死亡后的回收定时器(R2):ready 过的会话先留池供 reconnect,超时仍无人寻址则出池。 */
|
|
3030
|
+
deathTimer = null;
|
|
3031
|
+
lastCwdSeen = null;
|
|
2584
3032
|
/** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
|
|
2585
3033
|
everReady = false;
|
|
2586
3034
|
disposal;
|
|
@@ -2592,7 +3040,38 @@ var PtySession = class {
|
|
|
2592
3040
|
get sessionState() {
|
|
2593
3041
|
return this.state;
|
|
2594
3042
|
}
|
|
2595
|
-
/**
|
|
3043
|
+
/** 池键(driver 的 ttl/inspect 用)。 */
|
|
3044
|
+
get key() {
|
|
3045
|
+
return this.opts.key;
|
|
3046
|
+
}
|
|
3047
|
+
/** 最近帧 cwd(card 面);从未执行过 = null。 */
|
|
3048
|
+
get lastCwd() {
|
|
3049
|
+
return this.lastCwdSeen;
|
|
3050
|
+
}
|
|
3051
|
+
/** 占用前台的 detached job 的 commandId(无 = null;close 拒绝与 busy 文案共用)。 */
|
|
3052
|
+
get detachedCommandId() {
|
|
3053
|
+
return this.detachedJob?.commandId ?? null;
|
|
3054
|
+
}
|
|
3055
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者)。 */
|
|
3056
|
+
attachJob(commandId, jobId) {
|
|
3057
|
+
const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
|
|
3058
|
+
if (entry !== void 0) entry.jobId = jobId;
|
|
3059
|
+
}
|
|
3060
|
+
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
3061
|
+
get identity() {
|
|
3062
|
+
return {
|
|
3063
|
+
id: this.opts.id,
|
|
3064
|
+
...this.opts.label !== void 0 ? { label: this.opts.label } : {},
|
|
3065
|
+
connection: this.opts.connection,
|
|
3066
|
+
byo: this.opts.byo === true,
|
|
3067
|
+
builtOn: this.opts.builtOn === true
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
/** 账本快照(session card 面,只读)。 */
|
|
3071
|
+
ledgerSnapshot() {
|
|
3072
|
+
return this.ledger;
|
|
3073
|
+
}
|
|
3074
|
+
/** spawn 创建原文(仅 BYO 会话;v2 起仅存档,不参与复用判定)。 */
|
|
2596
3075
|
get origin() {
|
|
2597
3076
|
return this.opts.origin;
|
|
2598
3077
|
}
|
|
@@ -2601,81 +3080,156 @@ var PtySession = class {
|
|
|
2601
3080
|
return {
|
|
2602
3081
|
state: this.state,
|
|
2603
3082
|
busy: this.inFlight > 0,
|
|
2604
|
-
idleMs: this.
|
|
3083
|
+
idleMs: this.idleSince === null ? null : Date.now() - this.idleSince,
|
|
2605
3084
|
pid: this.proc?.pid ?? null
|
|
2606
3085
|
};
|
|
2607
3086
|
}
|
|
2608
|
-
/**
|
|
2609
|
-
|
|
3087
|
+
/** 预热创建(创建形无 cmd):真正拨号到 init 帧——「会话已就绪」必须是真的,
|
|
3088
|
+
* 而且只有 ready 过才挂 idle 回收(cold 会话没有 timer,会永远占配额)。 */
|
|
3089
|
+
async warm() {
|
|
3090
|
+
await this.start();
|
|
3091
|
+
}
|
|
3092
|
+
/** 同一会话命令严格串行(FIFO);死亡/冷态先重启。P4/P5(2026-10-19):超时不再自动
|
|
3093
|
+
* 打断——handoff 成 running job(byte-cursor 轮询 + 显式 interrupt,P8 机制原样保留在
|
|
3094
|
+
* job.interrupt);background 选项让 dispatch 立即回执。占用者是 detached job 时新命令
|
|
3095
|
+
* 拒绝(Q6:诚实回状态优先于排队),纯同步并发 FIFO 语义不变。 */
|
|
3096
|
+
dispatch(cmd, dOpts) {
|
|
2610
3097
|
const attempt = async () => {
|
|
2611
3098
|
const started = Date.now();
|
|
2612
3099
|
const reconnected = this.state === "dead";
|
|
2613
3100
|
if (this.state !== "ready") await this.start();
|
|
2614
|
-
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${
|
|
3101
|
+
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
3102
|
+
if (this.detachedJob !== null) throw new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a detached command (${this.detachedJob.commandId}) — poll job_output ${this.detachedJob.commandId}, cancel with job_kill ${this.detachedJob.commandId}, or address other work to another session / oneshot`);
|
|
3103
|
+
const commandId = dOpts.commandId;
|
|
3104
|
+
const entry = {
|
|
3105
|
+
commandId,
|
|
3106
|
+
summary: summaryOf(cmd),
|
|
3107
|
+
exit: null,
|
|
3108
|
+
durationMs: 0,
|
|
3109
|
+
running: true
|
|
3110
|
+
};
|
|
3111
|
+
this.ledger.push(entry);
|
|
3112
|
+
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
2615
3113
|
this.inFlight++;
|
|
2616
3114
|
return await new Promise((resolve$1) => {
|
|
2617
3115
|
const nonce = genNonce();
|
|
2618
3116
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
2619
3117
|
let interrupted = false;
|
|
2620
3118
|
let settled = false;
|
|
3119
|
+
let jobSettled = false;
|
|
2621
3120
|
let interruptTimer;
|
|
2622
3121
|
let killTimer;
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
3122
|
+
let jobDone = null;
|
|
3123
|
+
let envNext;
|
|
3124
|
+
const detach = () => {
|
|
3125
|
+
const job = {
|
|
3126
|
+
commandId,
|
|
3127
|
+
read: (fromByte) => ring.read(fromByte),
|
|
3128
|
+
stats: () => ring.stats(),
|
|
3129
|
+
interrupt: () => {
|
|
3130
|
+
if (!jobSettled) twoStage();
|
|
3131
|
+
},
|
|
3132
|
+
done: new Promise((res) => {
|
|
3133
|
+
jobDone = res;
|
|
3134
|
+
})
|
|
3135
|
+
};
|
|
3136
|
+
this.detachedJob = job;
|
|
3137
|
+
settled = true;
|
|
3138
|
+
resolve$1({
|
|
3139
|
+
output: ring.text,
|
|
3140
|
+
outputStats: { stdout: ring.stats() },
|
|
3141
|
+
exit: null,
|
|
3142
|
+
cwd: null,
|
|
3143
|
+
timedOut: false,
|
|
3144
|
+
running: true,
|
|
3145
|
+
job,
|
|
3146
|
+
commandId,
|
|
3147
|
+
reconnected,
|
|
3148
|
+
durationMs: Date.now() - started
|
|
3149
|
+
});
|
|
3150
|
+
return job;
|
|
3151
|
+
};
|
|
2629
3152
|
const cleanup = () => {
|
|
2630
3153
|
this.onChunk = null;
|
|
3154
|
+
this.detachedJob = null;
|
|
2631
3155
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
2632
3156
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
2633
3157
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
2634
3158
|
};
|
|
2635
|
-
const
|
|
2636
|
-
if (
|
|
2637
|
-
|
|
3159
|
+
const settle = (r) => {
|
|
3160
|
+
if (jobSettled) return;
|
|
3161
|
+
jobSettled = true;
|
|
2638
3162
|
cleanup();
|
|
2639
3163
|
this.inFlight--;
|
|
2640
|
-
this.
|
|
3164
|
+
this.idleSince = Date.now();
|
|
2641
3165
|
this.resetIdleTimer();
|
|
2642
|
-
|
|
3166
|
+
if (r.cwd !== null) this.lastCwdSeen = r.cwd;
|
|
3167
|
+
const envDelta = envNext !== void 0 ? diffEnv(this.envBaseline, envNext) : void 0;
|
|
3168
|
+
if (envNext !== void 0) this.envBaseline = envNext;
|
|
3169
|
+
entry.exit = r.exit;
|
|
3170
|
+
entry.durationMs = Date.now() - started;
|
|
3171
|
+
entry.running = false;
|
|
3172
|
+
const full = {
|
|
2643
3173
|
...r,
|
|
2644
3174
|
outputStats: { stdout: ring.stats() },
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
}
|
|
3175
|
+
commandId,
|
|
3176
|
+
...envDelta !== void 0 ? { envDelta } : {}
|
|
3177
|
+
};
|
|
3178
|
+
jobDone?.(full);
|
|
3179
|
+
if (!settled) {
|
|
3180
|
+
settled = true;
|
|
3181
|
+
resolve$1({
|
|
3182
|
+
...full,
|
|
3183
|
+
reconnected,
|
|
3184
|
+
durationMs: Date.now() - started
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
2648
3187
|
};
|
|
3188
|
+
const deathWaiter = () => settle({
|
|
3189
|
+
output: ring.text,
|
|
3190
|
+
exit: null,
|
|
3191
|
+
cwd: null,
|
|
3192
|
+
timedOut: interrupted
|
|
3193
|
+
});
|
|
2649
3194
|
const parser = createNonceFrameParser(nonce, {
|
|
2650
3195
|
onOutput: (t) => {
|
|
2651
3196
|
ring.push(t);
|
|
2652
3197
|
},
|
|
2653
|
-
|
|
3198
|
+
onEnv: (b64) => {
|
|
3199
|
+
const m = decodeEnvSnapshot(b64);
|
|
3200
|
+
if (m !== void 0) envNext = m;
|
|
3201
|
+
},
|
|
3202
|
+
onFrame: ({ exit, cwd }) => settle({
|
|
2654
3203
|
output: ring.text,
|
|
2655
3204
|
exit,
|
|
2656
3205
|
cwd,
|
|
2657
3206
|
timedOut: interrupted
|
|
2658
3207
|
})
|
|
2659
3208
|
});
|
|
2660
|
-
|
|
2661
|
-
this.write(wrapPtyCommand(cmd, nonce));
|
|
2662
|
-
if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
|
|
2663
|
-
interruptTimer = setTimeout(() => {
|
|
3209
|
+
const twoStage = () => {
|
|
2664
3210
|
interrupted = true;
|
|
2665
3211
|
this.write("");
|
|
2666
3212
|
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
2667
3213
|
killTimer = setTimeout(() => {
|
|
2668
3214
|
this.killTree();
|
|
2669
3215
|
this.markDead();
|
|
2670
|
-
|
|
3216
|
+
settle({
|
|
2671
3217
|
output: ring.text,
|
|
2672
3218
|
exit: null,
|
|
2673
3219
|
cwd: null,
|
|
2674
3220
|
timedOut: true
|
|
2675
3221
|
});
|
|
2676
3222
|
}, INTERRUPT_GRACE_MS);
|
|
2677
|
-
}
|
|
3223
|
+
};
|
|
3224
|
+
this.onChunk = (c) => parser.feed(c);
|
|
3225
|
+
this.write(wrapPtyCommand(cmd, nonce));
|
|
3226
|
+
if (dOpts.stdin !== void 0) this.write(dOpts.stdin);
|
|
2678
3227
|
this.deathWaiters.push(deathWaiter);
|
|
3228
|
+
if (dOpts.background === true) {
|
|
3229
|
+
detach();
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
2679
3233
|
});
|
|
2680
3234
|
};
|
|
2681
3235
|
const run = this.queue.then(attempt, attempt);
|
|
@@ -2686,6 +3240,7 @@ var PtySession = class {
|
|
|
2686
3240
|
dispose() {
|
|
2687
3241
|
if (this.disposal) return this.disposal;
|
|
2688
3242
|
this.clearIdleTimer();
|
|
3243
|
+
this.clearDeathTimer();
|
|
2689
3244
|
this.state = "disposed";
|
|
2690
3245
|
this.startPromise = null;
|
|
2691
3246
|
const proc = this.proc;
|
|
@@ -2710,6 +3265,7 @@ var PtySession = class {
|
|
|
2710
3265
|
}
|
|
2711
3266
|
async start() {
|
|
2712
3267
|
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`);
|
|
3268
|
+
this.clearDeathTimer();
|
|
2713
3269
|
if (this.startPromise === null) {
|
|
2714
3270
|
this.state = "starting";
|
|
2715
3271
|
this.startPromise = this.doStart().catch((err) => {
|
|
@@ -2759,18 +3315,24 @@ var PtySession = class {
|
|
|
2759
3315
|
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
3316
|
};
|
|
2761
3317
|
this.deathWaiters.push(waiter);
|
|
2762
|
-
const parser = createNonceFrameParser(initNonce, {
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3318
|
+
const parser = createNonceFrameParser(initNonce, {
|
|
3319
|
+
onEnv: (b64) => {
|
|
3320
|
+
const m = decodeEnvSnapshot(b64);
|
|
3321
|
+
if (m !== void 0) this.envBaseline = m;
|
|
3322
|
+
},
|
|
3323
|
+
onFrame: () => {
|
|
3324
|
+
clearTimeout(timer);
|
|
3325
|
+
this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
|
|
3326
|
+
if (this.state === "disposed") {
|
|
3327
|
+
reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' disposed during init`));
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
this.everReady = true;
|
|
3331
|
+
this.state = "ready";
|
|
3332
|
+
this.resetIdleTimer();
|
|
3333
|
+
resolve$1();
|
|
2768
3334
|
}
|
|
2769
|
-
|
|
2770
|
-
this.state = "ready";
|
|
2771
|
-
this.resetIdleTimer();
|
|
2772
|
-
resolve$1();
|
|
2773
|
-
} });
|
|
3335
|
+
});
|
|
2774
3336
|
this.onChunk = (c) => parser.feed(c);
|
|
2775
3337
|
this.write(buildInitCommand(initNonce));
|
|
2776
3338
|
});
|
|
@@ -2785,6 +3347,7 @@ var PtySession = class {
|
|
|
2785
3347
|
this.state = "dead";
|
|
2786
3348
|
this.startPromise = null;
|
|
2787
3349
|
this.clearIdleTimer();
|
|
3350
|
+
this.armDeathReaper();
|
|
2788
3351
|
const waiters = this.deathWaiters;
|
|
2789
3352
|
this.deathWaiters = [];
|
|
2790
3353
|
for (const w of waiters) w();
|
|
@@ -2792,7 +3355,14 @@ var PtySession = class {
|
|
|
2792
3355
|
resetIdleTimer() {
|
|
2793
3356
|
this.clearIdleTimer();
|
|
2794
3357
|
if (this.state === "disposed") return;
|
|
2795
|
-
this.
|
|
3358
|
+
this.idleSince = Date.now();
|
|
3359
|
+
this.idleTimer = setTimeout(() => {
|
|
3360
|
+
if (this.inFlight > 0) {
|
|
3361
|
+
this.resetIdleTimer();
|
|
3362
|
+
return;
|
|
3363
|
+
}
|
|
3364
|
+
this.dispose();
|
|
3365
|
+
}, this.opts.idleTtlSec * 1e3);
|
|
2796
3366
|
this.idleTimer.unref();
|
|
2797
3367
|
}
|
|
2798
3368
|
clearIdleTimer() {
|
|
@@ -2801,6 +3371,22 @@ var PtySession = class {
|
|
|
2801
3371
|
this.idleTimer = null;
|
|
2802
3372
|
}
|
|
2803
3373
|
}
|
|
3374
|
+
/** 意外死亡后的回收:留池只换取一次 reconnect 机会,不得超过一段 idle TTL(R2 复核 P0)。
|
|
3375
|
+
* 否则死会话永久占 label + 永久计入 10 配额,Agent 再也开不出新会话。 */
|
|
3376
|
+
armDeathReaper() {
|
|
3377
|
+
this.clearDeathTimer();
|
|
3378
|
+
if (!this.everReady || this.state !== "dead") return;
|
|
3379
|
+
this.deathTimer = setTimeout(() => {
|
|
3380
|
+
this.dispose();
|
|
3381
|
+
}, this.opts.idleTtlSec * 1e3);
|
|
3382
|
+
this.deathTimer.unref();
|
|
3383
|
+
}
|
|
3384
|
+
clearDeathTimer() {
|
|
3385
|
+
if (this.deathTimer !== null) {
|
|
3386
|
+
clearTimeout(this.deathTimer);
|
|
3387
|
+
this.deathTimer = null;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
2804
3390
|
killTree() {
|
|
2805
3391
|
const pid = this.proc?.pid;
|
|
2806
3392
|
this.proc = null;
|
|
@@ -2890,19 +3476,20 @@ var PtyPool = class {
|
|
|
2890
3476
|
const s = this.sessions.get(key);
|
|
2891
3477
|
return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
|
|
2892
3478
|
}
|
|
2893
|
-
|
|
3479
|
+
/** v2:创建一律新建(「同 label 同来源复用」废除——复用只走 get/get by id);disposed 残留由这里替换。 */
|
|
3480
|
+
create(opts) {
|
|
2894
3481
|
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
|
-
|
|
3482
|
+
const existing = this.sessions.get(opts.key);
|
|
3483
|
+
if (existing !== void 0 && existing.sessionState !== "disposed") throw new Error(`[E_INTERNAL] remote: pty pool key collision for ${opts.id}`);
|
|
3484
|
+
const s = new PtySession({
|
|
3485
|
+
...opts,
|
|
3486
|
+
...this.defaults,
|
|
3487
|
+
onDead: () => {
|
|
3488
|
+
this.sessions.delete(opts.key);
|
|
3489
|
+
opts.onDead?.();
|
|
3490
|
+
}
|
|
3491
|
+
});
|
|
3492
|
+
this.sessions.set(opts.key, s);
|
|
2906
3493
|
return s;
|
|
2907
3494
|
}
|
|
2908
3495
|
disposeAll() {
|
|
@@ -2945,15 +3532,203 @@ function resolveTarget(target) {
|
|
|
2945
3532
|
host: raw
|
|
2946
3533
|
};
|
|
2947
3534
|
}
|
|
3535
|
+
const TRANSPORTS = [
|
|
3536
|
+
"ssh",
|
|
3537
|
+
"docker",
|
|
3538
|
+
"incus"
|
|
3539
|
+
];
|
|
3540
|
+
function parseTargetForm(target) {
|
|
3541
|
+
if (target === void 0 || target.length === 0) return { form: "empty" };
|
|
3542
|
+
if (target === "pty") return { form: "pty-selector" };
|
|
3543
|
+
if (target.startsWith("pty:")) {
|
|
3544
|
+
const rest = target.slice(4);
|
|
3545
|
+
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")`);
|
|
3546
|
+
return {
|
|
3547
|
+
form: "pty-selector",
|
|
3548
|
+
idOrLabel: rest
|
|
3549
|
+
};
|
|
3550
|
+
}
|
|
3551
|
+
if (target === "ssh") return { form: "ssh-prefix" };
|
|
3552
|
+
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>`}`);
|
|
3553
|
+
if (target.startsWith("hub:")) {
|
|
3554
|
+
const segs = target.slice(4).split(":");
|
|
3555
|
+
if (segs[0] === void 0 || segs[0].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub takes a machine id`);
|
|
3556
|
+
if (segs.length === 1) return {
|
|
3557
|
+
form: "hub",
|
|
3558
|
+
machine: segs[0]
|
|
3559
|
+
};
|
|
3560
|
+
if (segs.length === 2 && segs[1] === "pty") return {
|
|
3561
|
+
form: "hub-pty",
|
|
3562
|
+
machine: segs[0]
|
|
3563
|
+
};
|
|
3564
|
+
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY takes no label (hub lane has no session id / parallelism this round)`);
|
|
3565
|
+
}
|
|
3566
|
+
for (const transport of TRANSPORTS) {
|
|
3567
|
+
if (!target.startsWith(`${transport}:`)) continue;
|
|
3568
|
+
const rest = target.slice(transport.length + 1);
|
|
3569
|
+
if (rest.length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — ${transport} takes a name`);
|
|
3570
|
+
const ptyAt = rest.lastIndexOf(":pty");
|
|
3571
|
+
if (ptyAt === -1) return {
|
|
3572
|
+
form: "direct",
|
|
3573
|
+
transport,
|
|
3574
|
+
name: rest
|
|
3575
|
+
};
|
|
3576
|
+
const base = rest.slice(0, ptyAt);
|
|
3577
|
+
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)`);
|
|
3578
|
+
const tail = rest.slice(ptyAt + 4);
|
|
3579
|
+
if (tail.startsWith(":")) {
|
|
3580
|
+
if (tail.length === 1) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
3581
|
+
return {
|
|
3582
|
+
form: "create-pty",
|
|
3583
|
+
transport,
|
|
3584
|
+
name: base,
|
|
3585
|
+
label: tail.slice(1)
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
if (tail.length > 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — unexpected ${JSON.stringify(tail)} after ":pty"`);
|
|
3589
|
+
return {
|
|
3590
|
+
form: "create-pty",
|
|
3591
|
+
transport,
|
|
3592
|
+
name: base
|
|
3593
|
+
};
|
|
3594
|
+
}
|
|
3595
|
+
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)`);
|
|
3596
|
+
return {
|
|
3597
|
+
form: "bare",
|
|
3598
|
+
name: target
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
2948
3601
|
|
|
2949
3602
|
//#endregion
|
|
2950
|
-
//#region src/remote/
|
|
2951
|
-
|
|
2952
|
-
|
|
3603
|
+
//#region src/remote/labels.ts
|
|
3604
|
+
/** label 字符集(与 BYO ssh label 同源);禁 `pty` 与会话 id 形态(防 selector 歧义)。 */
|
|
3605
|
+
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
3606
|
+
const PTY_ID_RE = /^pty-[a-z0-9]{4}$/;
|
|
3607
|
+
const RESERVED_LABELS = new Set(["pty"]);
|
|
3608
|
+
function validateLabel(label) {
|
|
3609
|
+
if (!LABEL_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: label must match ${LABEL_RE.source} (got ${JSON.stringify(label)})`);
|
|
3610
|
+
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)`);
|
|
2953
3611
|
}
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
3612
|
+
function describeEntry(e) {
|
|
3613
|
+
switch (e.kind) {
|
|
3614
|
+
case "ssh": return `ssh host ${e.name}`;
|
|
3615
|
+
case "ssh-byo": return `a BYO ssh identity (${e.name})`;
|
|
3616
|
+
case "docker": return `docker container ${e.container}`;
|
|
3617
|
+
case "incus": return `incus container ${e.container}`;
|
|
3618
|
+
case "hub": return `hub machine ${e.machine}`;
|
|
3619
|
+
case "pty": return `pty session ${e.id}${e.label !== void 0 ? ` (label ${e.label})` : ""}`;
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
/** 机器身份键:同一实体重扫后名字保持稳定(按身份匹配,不按名字)。 */
|
|
3623
|
+
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;
|
|
3624
|
+
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
3625
|
+
var LabelPool = class {
|
|
3626
|
+
/** 每 agent session 一个注册表:name → entry(机器 + 认领,到达序)。 */
|
|
3627
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3628
|
+
registry(agent) {
|
|
3629
|
+
let reg = this.sessions.get(agent);
|
|
3630
|
+
if (reg === void 0) {
|
|
3631
|
+
reg = /* @__PURE__ */ new Map();
|
|
3632
|
+
this.sessions.set(agent, reg);
|
|
3633
|
+
}
|
|
3634
|
+
return reg;
|
|
3635
|
+
}
|
|
3636
|
+
resolve(name$1, agent) {
|
|
3637
|
+
return this.sessions.get(agent)?.get(name$1);
|
|
3638
|
+
}
|
|
3639
|
+
/** 显式认领(BYO ssh / PTY label):任何已立名字占用即拒——名字是模型选的,不自动改名。 */
|
|
3640
|
+
claim(name$1, agent, claim) {
|
|
3641
|
+
const reg = this.registry(agent);
|
|
3642
|
+
const existing = reg.get(name$1);
|
|
3643
|
+
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`);
|
|
3644
|
+
reg.set(name$1, claim);
|
|
3645
|
+
}
|
|
3646
|
+
/** 生成不撞本 session 的 id(`pty-` PTY 会话 / `ssh-` BYO ssh 身份)。 */
|
|
3647
|
+
freshId(prefix, agent) {
|
|
3648
|
+
const reg = this.registry(agent);
|
|
3649
|
+
for (;;) {
|
|
3650
|
+
let id = prefix;
|
|
3651
|
+
for (let i = 0; i < 4; i++) id += ID_ALPHABET[Math.floor(Math.random() * 36)];
|
|
3652
|
+
if (!reg.has(id)) return id;
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
/**
|
|
3656
|
+
* 现场采集 merge(roster / 裸名 miss 触发):按 ssh → docker → incus → hub 顺序并入。
|
|
3657
|
+
* 已立名字永不挪动:同一实体(身份键相同)保持原槽;新实体撞已立名字 = latecomer,
|
|
3658
|
+
* 自动加 `<transport>-<name>`(再冲突 -2/-3)——无论对方是认领还是更早的机器。
|
|
3659
|
+
* 本轮扫描里消失的机器出表(名字释放);认领永远保留。
|
|
3660
|
+
*/
|
|
3661
|
+
mergeScan(agent, scan) {
|
|
3662
|
+
const reg = this.registry(agent);
|
|
3663
|
+
const renamed = [];
|
|
3664
|
+
const present = /* @__PURE__ */ new Map();
|
|
3665
|
+
for (const [name$1, e] of [...reg]) {
|
|
3666
|
+
const id = machineId(e);
|
|
3667
|
+
if (id === void 0) continue;
|
|
3668
|
+
reg.delete(name$1);
|
|
3669
|
+
present.set(id, e);
|
|
3670
|
+
}
|
|
3671
|
+
const place = (transport, raw, make) => {
|
|
3672
|
+
const id = `${transport}:${raw}`;
|
|
3673
|
+
const known = present.get(id);
|
|
3674
|
+
if (known !== void 0) {
|
|
3675
|
+
reg.set(known.name, known);
|
|
3676
|
+
return;
|
|
3677
|
+
}
|
|
3678
|
+
const fresh = make(raw);
|
|
3679
|
+
if (!reg.has(raw)) {
|
|
3680
|
+
reg.set(raw, fresh);
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
let candidate = `${transport}-${raw}`, n = 2;
|
|
3684
|
+
while (reg.has(candidate)) candidate = `${transport}-${raw}-${n++}`;
|
|
3685
|
+
reg.set(candidate, make(candidate));
|
|
3686
|
+
renamed.push([fresh, candidate]);
|
|
3687
|
+
};
|
|
3688
|
+
for (const host of scan.sshHosts) place("ssh", host, (name$1) => ({
|
|
3689
|
+
kind: "ssh",
|
|
3690
|
+
name: name$1,
|
|
3691
|
+
host
|
|
3692
|
+
}));
|
|
3693
|
+
for (const c of scan.docker) place("docker", c, (name$1) => ({
|
|
3694
|
+
kind: "docker",
|
|
3695
|
+
name: name$1,
|
|
3696
|
+
container: c
|
|
3697
|
+
}));
|
|
3698
|
+
for (const c of scan.incus) place("incus", c, (name$1) => ({
|
|
3699
|
+
kind: "incus",
|
|
3700
|
+
name: name$1,
|
|
3701
|
+
container: c
|
|
3702
|
+
}));
|
|
3703
|
+
for (const m of scan.hub) place("hub", m, (name$1) => ({
|
|
3704
|
+
kind: "hub",
|
|
3705
|
+
name: name$1,
|
|
3706
|
+
machine: m
|
|
3707
|
+
}));
|
|
3708
|
+
return { renamed };
|
|
3709
|
+
}
|
|
3710
|
+
/** 认领释放(会话 dispose/TTL;BYO 身份随 session 存亡);幂等。 */
|
|
3711
|
+
release(name$1, agent) {
|
|
3712
|
+
this.sessions.get(agent)?.delete(name$1);
|
|
3713
|
+
}
|
|
3714
|
+
/** roster 面:本 session 的全部条目(机器 + 认领,插入序)。 */
|
|
3715
|
+
list(agent) {
|
|
3716
|
+
return [...this.sessions.get(agent)?.values() ?? []];
|
|
3717
|
+
}
|
|
3718
|
+
/** driver dispose:全部 session 注册表清空。 */
|
|
3719
|
+
clear() {
|
|
3720
|
+
this.sessions.clear();
|
|
3721
|
+
}
|
|
3722
|
+
};
|
|
3723
|
+
|
|
3724
|
+
//#endregion
|
|
3725
|
+
//#region src/remote/status.ts
|
|
3726
|
+
function oneLine$1(s) {
|
|
3727
|
+
return s.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
|
|
3728
|
+
}
|
|
3729
|
+
/**
|
|
3730
|
+
* On-demand 探测(Ruling 17):调用瞬间拨一次,双重超时(ssh ConnectTimeout
|
|
3731
|
+
* 5s + 外层 hard 10s),结果即当时事实。绝不缓存、绝不在 boot 时扫。
|
|
2957
3732
|
*/
|
|
2958
3733
|
async function probeTarget(plan, opts = {}) {
|
|
2959
3734
|
const ct = opts.connectTimeoutSec ?? 5;
|
|
@@ -3020,21 +3795,11 @@ function renderProbe(p) {
|
|
|
3020
3795
|
case "container": return `probe: container ${p.status} (just now, on demand)`;
|
|
3021
3796
|
}
|
|
3022
3797
|
}
|
|
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);
|
|
3798
|
+
/** session card(§5.5,`pty:<id|label>` 无 cmd):connection/state/cwd/ttl 由调用方拼头行,这里渲染账本。 */
|
|
3799
|
+
function renderLedger(ledger) {
|
|
3800
|
+
if (ledger.length === 0) return "ledger: empty — no command has run in this session yet";
|
|
3801
|
+
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}`);
|
|
3802
|
+
return `ledger (last ${ledger.length}):\n${rows.join("\n")}`;
|
|
3038
3803
|
}
|
|
3039
3804
|
|
|
3040
3805
|
//#endregion
|
|
@@ -3126,22 +3891,24 @@ function literalHosts(text) {
|
|
|
3126
3891
|
wildcardBlocks
|
|
3127
3892
|
};
|
|
3128
3893
|
}
|
|
3129
|
-
/**
|
|
3130
|
-
|
|
3131
|
-
* renders its native error (honest vocabulary: never invent, never hide).
|
|
3132
|
-
*/
|
|
3133
|
-
async function renderRoster(sessions, opts = {}) {
|
|
3894
|
+
/** 三段本地扫描(各自独立降级,错误如实带回给 roster 呈现)。 */
|
|
3895
|
+
async function scanLocal(opts = {}) {
|
|
3134
3896
|
const runner = opts.runner ?? runOneShot;
|
|
3135
3897
|
const readSshConfig = opts.readSshConfig ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
|
|
3136
3898
|
const hard = opts.hardTimeoutSec ?? 10;
|
|
3137
|
-
|
|
3899
|
+
let ssh = {
|
|
3900
|
+
hosts: [],
|
|
3901
|
+
wildcardBlocks: 0
|
|
3902
|
+
};
|
|
3138
3903
|
try {
|
|
3139
|
-
|
|
3140
|
-
lines.push(`ssh hosts (~/.ssh/config): ${hosts.length > 0 ? hosts.join(", ") : "(none)"}${wildcardBlocks > 0 ? ` (+${wildcardBlocks} wildcard default blocks, not listed)` : ""}`);
|
|
3904
|
+
ssh = literalHosts(await readSshConfig());
|
|
3141
3905
|
} catch (error) {
|
|
3142
|
-
|
|
3906
|
+
ssh = {
|
|
3907
|
+
hosts: [],
|
|
3908
|
+
wildcardBlocks: 0,
|
|
3909
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3910
|
+
};
|
|
3143
3911
|
}
|
|
3144
|
-
lines.push(`byossh labels (this Agent, in memory): ${opts.byosshLabels?.map((label) => `byossh:${label}`).join(", ") || "(none)"}`);
|
|
3145
3912
|
const docker = await runner([
|
|
3146
3913
|
"docker",
|
|
3147
3914
|
"ps",
|
|
@@ -3149,13 +3916,6 @@ async function renderRoster(sessions, opts = {}) {
|
|
|
3149
3916
|
"--format",
|
|
3150
3917
|
"{{.Names}} {{.State}}"
|
|
3151
3918
|
], { 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
3919
|
const incus = await runner([
|
|
3160
3920
|
"incus",
|
|
3161
3921
|
"list",
|
|
@@ -3164,35 +3924,87 @@ async function renderRoster(sessions, opts = {}) {
|
|
|
3164
3924
|
"--columns",
|
|
3165
3925
|
"ns"
|
|
3166
3926
|
], { timeoutSec: hard });
|
|
3167
|
-
|
|
3168
|
-
|
|
3927
|
+
return {
|
|
3928
|
+
ssh,
|
|
3929
|
+
docker: docker.exit === 0 ? { rows: docker.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
|
|
3930
|
+
const [name$1, state] = l.split(" ");
|
|
3931
|
+
return {
|
|
3932
|
+
name: (name$1 ?? "").trim(),
|
|
3933
|
+
state: (state ?? "").trim()
|
|
3934
|
+
};
|
|
3935
|
+
}).filter((r) => r.name.length > 0) } : {
|
|
3936
|
+
rows: [],
|
|
3937
|
+
error: oneLine(docker.stderr) || `docker exited ${docker.exit}`,
|
|
3938
|
+
...docker.timedOut ? { timedOut: true } : {}
|
|
3939
|
+
},
|
|
3940
|
+
incus: incus.exit === 0 ? { rows: incus.stdout.trim().split(/\r?\n/).filter(Boolean).map((l) => {
|
|
3169
3941
|
const [name$1, state] = l.split(",");
|
|
3170
|
-
return
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3942
|
+
return {
|
|
3943
|
+
name: (name$1 ?? "").trim(),
|
|
3944
|
+
state: (state ?? "").trim()
|
|
3945
|
+
};
|
|
3946
|
+
}).filter((r) => r.name.length > 0) } : {
|
|
3947
|
+
rows: [],
|
|
3948
|
+
error: oneLine(incus.stderr) || `incus exited ${incus.exit}`,
|
|
3949
|
+
...incus.timedOut ? { timedOut: true } : {}
|
|
3950
|
+
}
|
|
3951
|
+
};
|
|
3952
|
+
}
|
|
3953
|
+
/** 统一池视图的 roster 呈现:机器三段 + BYO ssh 同位(built on)+ pty 会话段(caller-scoped)。 */
|
|
3954
|
+
function renderRoster(scan, entries, sessions, hubTargets) {
|
|
3955
|
+
const lines = [];
|
|
3956
|
+
if (hubTargets !== null) lines.push(`hub machines: ${hubTargets.map((t) => `hub:${t.id} (${t.label}${t.execEnabled ? "" : "; exec disabled"})`).join(", ") || "none"}`);
|
|
3957
|
+
if (scan.ssh.error !== void 0) lines.push(`ssh hosts: no readable ~/.ssh/config (${scan.ssh.error})`);
|
|
3958
|
+
else {
|
|
3959
|
+
const sshNames = entries.filter((e) => e.kind === "ssh").map((e) => e.name);
|
|
3960
|
+
const byoNames = entries.filter((e) => e.kind === "ssh-byo").map((e) => `${e.name} (built on)`);
|
|
3961
|
+
const all = [...sshNames, ...byoNames];
|
|
3962
|
+
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)` : ""}`);
|
|
3963
|
+
}
|
|
3964
|
+
const dockerStates = new Map(scan.docker.rows.map((r) => [r.name, r.state]));
|
|
3965
|
+
const dockerEntries = entries.filter((e) => e.kind === "docker");
|
|
3966
|
+
if (scan.docker.error !== void 0) lines.push(`docker containers: unavailable — ${scan.docker.timedOut ? `scan timed out` : scan.docker.error}`);
|
|
3967
|
+
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)"}`);
|
|
3968
|
+
const incusStates = new Map(scan.incus.rows.map((r) => [r.name, r.state]));
|
|
3969
|
+
const incusEntries = entries.filter((e) => e.kind === "incus");
|
|
3970
|
+
if (scan.incus.error !== void 0) lines.push(`incus containers: unavailable — ${scan.incus.timedOut ? `scan timed out` : scan.incus.error}`);
|
|
3971
|
+
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)"}`);
|
|
3972
|
+
lines.push(sessions.length > 0 ? `pty sessions: ${sessions.map((s) => {
|
|
3973
|
+
const id = s.identity;
|
|
3974
|
+
const snap = s.snapshot;
|
|
3975
|
+
const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
|
|
3976
|
+
return `pty:${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} [${state}]`;
|
|
3977
|
+
}).join(" · ")}` : "pty sessions: none");
|
|
3175
3978
|
return lines.join("\n");
|
|
3176
3979
|
}
|
|
3177
3980
|
|
|
3178
3981
|
//#endregion
|
|
3179
3982
|
//#region src/remote/driver.ts
|
|
3180
|
-
/**
|
|
3181
|
-
const
|
|
3182
|
-
/**
|
|
3983
|
+
/** R1:idle TTL 15 min——idle 随活动重置,在用会话永不超时(PtySession 双条件)。 */
|
|
3984
|
+
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
3985
|
+
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
3986
|
+
const REMOTE_PTY_SESSION_LIMIT = 10;
|
|
3987
|
+
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
3988
|
+
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
3989
|
+
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
3183
3990
|
var RemoteDriver = class {
|
|
3184
|
-
sshTargets = new SshTargets();
|
|
3185
3991
|
pool;
|
|
3992
|
+
labels = new LabelPool();
|
|
3186
3993
|
hubOwner = randomUUID();
|
|
3187
3994
|
hubProviders = /* @__PURE__ */ new Set();
|
|
3188
3995
|
processes = /* @__PURE__ */ new Set();
|
|
3189
3996
|
starting = /* @__PURE__ */ new Set();
|
|
3190
3997
|
fileAbort = new AbortController();
|
|
3191
3998
|
disposed = false;
|
|
3999
|
+
/** undefined = 未尝试;null = 资产缺失(shim-only);registry = 每 target sticky 状态机。 */
|
|
4000
|
+
helpers;
|
|
4001
|
+
runnerOf = /* @__PURE__ */ new WeakMap();
|
|
3192
4002
|
disposal;
|
|
4003
|
+
ptyIdleTtlSec;
|
|
3193
4004
|
constructor(opts = {}) {
|
|
3194
4005
|
this.opts = opts;
|
|
3195
|
-
this.
|
|
4006
|
+
this.ptyIdleTtlSec = opts.idleTtlSec ?? REMOTE_PTY_IDLE_TTL_SEC;
|
|
4007
|
+
this.pool = new PtyPool({ idleTtlSec: this.ptyIdleTtlSec });
|
|
3196
4008
|
}
|
|
3197
4009
|
async call(params, callCtx = {}) {
|
|
3198
4010
|
const started = Date.now();
|
|
@@ -3200,10 +4012,11 @@ var RemoteDriver = class {
|
|
|
3200
4012
|
this.assertOpen();
|
|
3201
4013
|
const prepared = await prepareRemoteInput(params);
|
|
3202
4014
|
this.assertOpen();
|
|
3203
|
-
const
|
|
3204
|
-
|
|
4015
|
+
const form = parseTargetForm(prepared.target);
|
|
4016
|
+
this.validate(form, prepared);
|
|
4017
|
+
const result = await this.execute(prepared, form, callCtx);
|
|
3205
4018
|
this.audit({
|
|
3206
|
-
target:
|
|
4019
|
+
target: result.target,
|
|
3207
4020
|
cmd: prepared.cmd,
|
|
3208
4021
|
cwd: result.cwd ?? void 0,
|
|
3209
4022
|
exit: result.exit,
|
|
@@ -3220,10 +4033,125 @@ var RemoteDriver = class {
|
|
|
3220
4033
|
throw error;
|
|
3221
4034
|
}
|
|
3222
4035
|
}
|
|
3223
|
-
/**
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
4036
|
+
/** 参数互斥矩阵(§2):spawn/ssh 是 BYO 入口(参数在场即 BYO),只与对应形态的 target 配对。 */
|
|
4037
|
+
validate(form, params) {
|
|
4038
|
+
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)})`);
|
|
4039
|
+
if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
|
|
4040
|
+
const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
|
|
4041
|
+
const hasSsh = params.ssh !== void 0;
|
|
4042
|
+
if (hasSpawn && hasSsh) throw new Error("[E_PARAMS] remote: spawn and ssh are two different BYO entry points — pass one, not both");
|
|
4043
|
+
const ptyForm = form.form === "pty-selector" || form.form === "empty" || form.form === "bare";
|
|
4044
|
+
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";
|
|
4045
|
+
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");
|
|
4046
|
+
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)");
|
|
4047
|
+
}
|
|
4048
|
+
async execute(params, form, callCtx) {
|
|
4049
|
+
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
4050
|
+
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
4051
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4052
|
+
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
4053
|
+
params.spawn !== void 0 && params.spawn.length;
|
|
4054
|
+
if (form.form === "hub" || form.form === "hub-pty") {
|
|
4055
|
+
const hub = this.requireHub();
|
|
4056
|
+
this.hubProviders.add(hub);
|
|
4057
|
+
if (form.form === "hub-pty") {
|
|
4058
|
+
if (!hasCmd) throw new Error("[E_PARAMS] remote: hub PTY takes cmd");
|
|
4059
|
+
const result$1 = await hub.pty(form.machine, params.cmd, this.hubOwner, {
|
|
4060
|
+
stdin: params.stdin,
|
|
4061
|
+
timeout: timeoutSec
|
|
4062
|
+
});
|
|
4063
|
+
const out$1 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
|
|
4064
|
+
return {
|
|
4065
|
+
...result$1,
|
|
4066
|
+
target: `hub:${form.machine}`,
|
|
4067
|
+
connection: `hub:${form.machine}`,
|
|
4068
|
+
stdout: `[hub:${form.machine} pty — no session id, no parallel, no background]\n${out$1.text}`,
|
|
4069
|
+
...out$1.stats ? { outputStats: {
|
|
4070
|
+
...result$1.outputStats,
|
|
4071
|
+
stdout: out$1.stats
|
|
4072
|
+
} } : {},
|
|
4073
|
+
...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
|
|
4074
|
+
};
|
|
4075
|
+
}
|
|
4076
|
+
const process$1 = this.own(await hub.start(form.machine, {
|
|
4077
|
+
...params,
|
|
4078
|
+
target: `hub:${form.machine}`,
|
|
4079
|
+
mode: "oneshot",
|
|
4080
|
+
timeout: timeoutSec
|
|
4081
|
+
}, this.hubOwner));
|
|
4082
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
4083
|
+
const result = await process$1.done;
|
|
4084
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4085
|
+
const out = tailWindow(stdout.text, cap, stdout.stats);
|
|
4086
|
+
const err = tailWindow(stderr.text, cap, stderr.stats);
|
|
4087
|
+
return {
|
|
4088
|
+
...result,
|
|
4089
|
+
mode: "oneshot",
|
|
4090
|
+
target: `hub:${form.machine}`,
|
|
4091
|
+
connection: `hub:${form.machine}`,
|
|
4092
|
+
cwd: null,
|
|
4093
|
+
reconnected: false,
|
|
4094
|
+
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4095
|
+
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4096
|
+
...out.stats ? { outputStats: {
|
|
4097
|
+
stdout: out.stats,
|
|
4098
|
+
stderr: err.stats
|
|
4099
|
+
} } : { outputStatsUnavailable: true },
|
|
4100
|
+
...out.truncated === void 0 ? {} : { truncated: out.truncated }
|
|
4101
|
+
};
|
|
4102
|
+
}
|
|
4103
|
+
const lane = this.createPtyLane(params, form, sessionKey);
|
|
4104
|
+
if (lane !== void 0) {
|
|
4105
|
+
const session = lane.session;
|
|
4106
|
+
if (!hasCmd) {
|
|
4107
|
+
await session.warm();
|
|
4108
|
+
return this.warmReceipt(session);
|
|
4109
|
+
}
|
|
4110
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
|
|
4111
|
+
}
|
|
4112
|
+
if (params.ssh !== void 0) {
|
|
4113
|
+
const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
|
|
4114
|
+
const name$1 = this.registerByoSsh(label, params.ssh, sessionKey);
|
|
4115
|
+
if (!hasCmd) return {
|
|
4116
|
+
mode: "oneshot",
|
|
4117
|
+
target: `ssh:${name$1}`,
|
|
4118
|
+
connection: `ssh:${name$1}`,
|
|
4119
|
+
byoTag: "byo-ssh",
|
|
4120
|
+
exit: null,
|
|
4121
|
+
cwd: null,
|
|
4122
|
+
timedOut: false,
|
|
4123
|
+
reconnected: false,
|
|
4124
|
+
stdout: `ssh:${name$1} registered (byo-ssh, no dialing). Reuse the label anywhere ssh: aliases work; a new Agent registers again.`,
|
|
4125
|
+
durationMs: 0
|
|
4126
|
+
};
|
|
4127
|
+
return await this.oneshot({
|
|
4128
|
+
kind: "ssh",
|
|
4129
|
+
host: normalizeSshConnection(params.ssh).host,
|
|
4130
|
+
connection: normalizeSshConnection(params.ssh)
|
|
4131
|
+
}, `ssh:${name$1}`, params, timeoutSec, cap);
|
|
4132
|
+
}
|
|
4133
|
+
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");
|
|
4134
|
+
if (form.form === "pty-selector") {
|
|
4135
|
+
const session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
4136
|
+
if (!hasCmd) return this.cardReceipt(session);
|
|
4137
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
|
|
4138
|
+
}
|
|
4139
|
+
if (form.form === "direct") {
|
|
4140
|
+
const plan$1 = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
|
|
4141
|
+
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec, cap);
|
|
4142
|
+
}
|
|
4143
|
+
if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
|
|
4144
|
+
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
4145
|
+
if (entry.kind === "pty") {
|
|
4146
|
+
const session = this.resolvePty(form.name, sessionKey);
|
|
4147
|
+
if (!hasCmd) return this.cardReceipt(session);
|
|
4148
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec, cap);
|
|
4149
|
+
}
|
|
4150
|
+
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec, cap);
|
|
4151
|
+
const plan = this.planOf(entry);
|
|
4152
|
+
return await this.oneshot(plan, form.name, params, timeoutSec, cap);
|
|
4153
|
+
}
|
|
4154
|
+
/** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
|
|
3227
4155
|
copy(request, signal, callCtx = {}) {
|
|
3228
4156
|
const combined = AbortSignal.any([signal, this.fileAbort.signal]);
|
|
3229
4157
|
const work = (async () => {
|
|
@@ -3295,13 +4223,18 @@ var RemoteDriver = class {
|
|
|
3295
4223
|
path: dst.path,
|
|
3296
4224
|
endpoint: localFileEndpoint()
|
|
3297
4225
|
}, combined);
|
|
3298
|
-
if (request.verify !== void 0
|
|
4226
|
+
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
4227
|
const planOf = (end) => end.kind === "cli" ? end.plan : end.kind === "local" ? { kind: "local" } : void 0;
|
|
3300
4228
|
const srcPlan = planOf(src), dstPlan = planOf(dst);
|
|
3301
4229
|
if (srcPlan === void 0 || dstPlan === void 0) throw new Error("copy: unreachable route resolution");
|
|
4230
|
+
const execArgvFor = (plan, script) => {
|
|
4231
|
+
const argv = helperShellArgv(plan, script);
|
|
4232
|
+
return this.opts.nativeArgvFor ? this.opts.nativeArgvFor(argv) : argv;
|
|
4233
|
+
};
|
|
3302
4234
|
return nativeCopy({
|
|
3303
4235
|
src: request.src,
|
|
3304
4236
|
dst: request.dst,
|
|
4237
|
+
...request.mkdir === void 0 ? {} : { mkdir: request.mkdir },
|
|
3305
4238
|
...request.argv === void 0 ? {} : { argv: request.argv },
|
|
3306
4239
|
...request.pullArgv === void 0 ? {} : { pullArgv: request.pullArgv },
|
|
3307
4240
|
...request.pushArgv === void 0 ? {} : { pushArgv: request.pushArgv }
|
|
@@ -3314,7 +4247,10 @@ var RemoteDriver = class {
|
|
|
3314
4247
|
plan: dstPlan,
|
|
3315
4248
|
path: dst.path
|
|
3316
4249
|
}
|
|
3317
|
-
}, combined, {
|
|
4250
|
+
}, combined, {
|
|
4251
|
+
wrapArgv: this.opts.nativeArgvFor,
|
|
4252
|
+
execArgvFor
|
|
4253
|
+
});
|
|
3318
4254
|
})();
|
|
3319
4255
|
this.starting.add(work);
|
|
3320
4256
|
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
@@ -3328,7 +4264,8 @@ var RemoteDriver = class {
|
|
|
3328
4264
|
const endpoint = async (value) => {
|
|
3329
4265
|
const parsed = parseFileEndpoint(value);
|
|
3330
4266
|
if (!parsed.target) return { path: parsed.path };
|
|
3331
|
-
|
|
4267
|
+
const form = parseTargetForm(parsed.target);
|
|
4268
|
+
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
4269
|
const plan = await this.resolveConnectionTarget(parsed.target, callCtx);
|
|
3333
4270
|
this.assertOpen();
|
|
3334
4271
|
combined.throwIfAborted();
|
|
@@ -3348,45 +4285,66 @@ var RemoteDriver = class {
|
|
|
3348
4285
|
return work;
|
|
3349
4286
|
}
|
|
3350
4287
|
/** 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]));
|
|
4288
|
+
start(input, signal, callCtx = {}) {
|
|
4289
|
+
const work = this.startPrepared(input, AbortSignal.any([signal, this.fileAbort.signal]), callCtx);
|
|
3353
4290
|
this.starting.add(work);
|
|
3354
4291
|
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
3355
4292
|
return work;
|
|
3356
4293
|
}
|
|
3357
|
-
async startPrepared(input, signal) {
|
|
4294
|
+
async startPrepared(input, signal, callCtx = {}) {
|
|
3358
4295
|
this.assertOpen();
|
|
3359
4296
|
signal.throwIfAborted();
|
|
3360
4297
|
const params = await prepareRemoteInput(input);
|
|
3361
4298
|
this.assertOpen();
|
|
3362
4299
|
signal.throwIfAborted();
|
|
3363
|
-
const
|
|
3364
|
-
|
|
4300
|
+
const form = parseTargetForm(params.target);
|
|
4301
|
+
this.validate(form, params);
|
|
4302
|
+
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");
|
|
4303
|
+
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
4304
|
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);
|
|
4305
|
+
if (form.form === "hub") process$1 = await this.hubOneshotProcess(form.machine, params);
|
|
4306
|
+
else if (form.form === "direct") {
|
|
4307
|
+
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
3375
4308
|
this.assertOpen();
|
|
3376
4309
|
signal.throwIfAborted();
|
|
3377
|
-
if (plan.kind === "pty") throw new Error("remote: background requires oneshot");
|
|
3378
4310
|
process$1 = await this.nativeProcess(plan, params, params.timeout, signal);
|
|
3379
|
-
}
|
|
4311
|
+
} else if (form.form === "bare") {
|
|
4312
|
+
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
4313
|
+
this.assertOpen();
|
|
4314
|
+
signal.throwIfAborted();
|
|
4315
|
+
if (entry.kind === "pty" || entry.kind === "hub") throw new Error("remote: background requires an ssh/docker/incus target");
|
|
4316
|
+
process$1 = await this.nativeProcess(this.planOf(entry), params, params.timeout, signal);
|
|
4317
|
+
} else throw new Error("remote: background oneshot takes a machine target");
|
|
3380
4318
|
this.own(process$1);
|
|
3381
4319
|
if (signal.aborted || this.disposed) process$1.cancel("owner closed during start");
|
|
3382
4320
|
return process$1;
|
|
3383
4321
|
}
|
|
3384
|
-
nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
4322
|
+
async nativeProcess(plan, params, timeoutSec, signal = this.fileAbort.signal) {
|
|
3385
4323
|
const cleanupTag = randomUUID().slice(0, 8);
|
|
4324
|
+
const helper = await this.ensureHelper(plan);
|
|
4325
|
+
if (helper !== null && !this.disposed) {
|
|
4326
|
+
const process$2 = startOneShot(buildHelperOneShotArgv(plan, helper.binPath, {
|
|
4327
|
+
tag: cleanupTag,
|
|
4328
|
+
cmd: params.cmd
|
|
4329
|
+
}), {
|
|
4330
|
+
stdin: params.stdin,
|
|
4331
|
+
timeoutSec
|
|
4332
|
+
});
|
|
4333
|
+
this.runnerOf.set(process$2, `superd-run ${helper.ver} (${helper.triple})`);
|
|
4334
|
+
process$2.done.then((result) => {
|
|
4335
|
+
if (!result.cancelled && !result.timedOut) return;
|
|
4336
|
+
startOneShot(buildHelperKillgroupArgv(plan, helper.binPath, cleanupTag), { timeoutSec: 15 }).done.catch(() => {});
|
|
4337
|
+
}).catch(() => {});
|
|
4338
|
+
const tracked = Promise.resolve(process$2);
|
|
4339
|
+
this.starting.add(tracked);
|
|
4340
|
+
tracked.then(() => this.starting.delete(tracked), () => this.starting.delete(tracked));
|
|
4341
|
+
return tracked;
|
|
4342
|
+
}
|
|
3386
4343
|
const process$1 = startOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(plan, params.cmd, { cleanupTag }), {
|
|
3387
4344
|
stdin: params.stdin,
|
|
3388
4345
|
timeoutSec
|
|
3389
4346
|
});
|
|
4347
|
+
this.runnerOf.set(process$1, "posix-shim");
|
|
3390
4348
|
process$1.done.then((result) => {
|
|
3391
4349
|
if (!result.cancelled && !result.timedOut) return;
|
|
3392
4350
|
const pidfile = `"\${TMPDIR:-/tmp}/superd-oneshot-${cleanupTag}.pgid"`;
|
|
@@ -3398,296 +4356,348 @@ var RemoteDriver = class {
|
|
|
3398
4356
|
work.then(() => this.starting.delete(work), () => this.starting.delete(work));
|
|
3399
4357
|
return work;
|
|
3400
4358
|
}
|
|
4359
|
+
/** Helper bootstrap is best-effort and sticky: any failure parks null for this driver
|
|
4360
|
+
* lifetime and the shim stays the path. First call to a target pays probe (+ upload). */
|
|
4361
|
+
async ensureHelper(plan) {
|
|
4362
|
+
if (this.helpers === void 0) {
|
|
4363
|
+
const assets = this.opts.helperAssetsFor?.() ?? helperAssets();
|
|
4364
|
+
this.helpers = assets === null ? null : new HelperRegistry(assets, (argv, options) => this.helperExec(argv, options), buildHelperProbeArgv, buildHelperUploadArgv);
|
|
4365
|
+
}
|
|
4366
|
+
if (this.helpers === null || this.disposed) return null;
|
|
4367
|
+
try {
|
|
4368
|
+
return await this.helpers.ensure(plan);
|
|
4369
|
+
} catch {
|
|
4370
|
+
return null;
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
async helperExec(argv, options) {
|
|
4374
|
+
this.assertOpen();
|
|
4375
|
+
const stdin = options?.stdin;
|
|
4376
|
+
if (stdin !== void 0) {
|
|
4377
|
+
const proc$1 = startBinaryProcess(argv, { timeoutSec: options?.timeoutSec });
|
|
4378
|
+
proc$1.stdin.on("error", () => {});
|
|
4379
|
+
proc$1.stdin.end(stdin);
|
|
4380
|
+
return {
|
|
4381
|
+
exit: (await proc$1.done).exit,
|
|
4382
|
+
stdout: ""
|
|
4383
|
+
};
|
|
4384
|
+
}
|
|
4385
|
+
const proc = startOneShot(argv, { timeoutSec: options?.timeoutSec });
|
|
4386
|
+
return {
|
|
4387
|
+
exit: (await proc.done).exit,
|
|
4388
|
+
stdout: proc.read("stdout", 0).text
|
|
4389
|
+
};
|
|
4390
|
+
}
|
|
3401
4391
|
own(process$1) {
|
|
3402
4392
|
this.processes.add(process$1);
|
|
3403
4393
|
process$1.done.then(() => this.processes.delete(process$1));
|
|
3404
4394
|
return process$1;
|
|
3405
4395
|
}
|
|
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) {
|
|
4396
|
+
/** 现场采集机器面并 merge 进本 session 注册表(ssh → docker → incus → hub,迟到者加前缀)。错误逐段吞掉(roster 另行如实呈现)。 */
|
|
4397
|
+
async refreshMachines(sessionKey) {
|
|
3413
4398
|
const runner = this.opts.rosterRunner ?? runOneShot;
|
|
3414
4399
|
const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
|
|
3415
|
-
let
|
|
4400
|
+
let sshHosts = [];
|
|
3416
4401
|
try {
|
|
3417
|
-
|
|
4402
|
+
sshHosts = literalHosts(await readCfg()).hosts;
|
|
3418
4403
|
} catch {}
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
};
|
|
3424
|
-
const docker = await
|
|
4404
|
+
const names = (argv) => (async () => {
|
|
4405
|
+
const r = await runner(argv, { timeoutSec: 10 });
|
|
4406
|
+
this.assertOpen();
|
|
4407
|
+
return r.exit === 0 ? r.stdout.trim().split(/\r?\n/).map((l) => l.split(/[\t,]/)[0].trim()).filter((n) => n.length > 0) : [];
|
|
4408
|
+
})().catch(() => []);
|
|
4409
|
+
const [docker, incus] = await Promise.all([names([
|
|
3425
4410
|
"docker",
|
|
3426
4411
|
"ps",
|
|
3427
4412
|
"-a",
|
|
3428
4413
|
"--format",
|
|
3429
4414
|
"{{.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([
|
|
4415
|
+
]), names([
|
|
3437
4416
|
"incus",
|
|
3438
4417
|
"list",
|
|
3439
4418
|
"--format",
|
|
3440
4419
|
"csv",
|
|
3441
4420
|
"--columns",
|
|
3442
4421
|
"n"
|
|
3443
|
-
]
|
|
4422
|
+
])]);
|
|
4423
|
+
const hub = this.opts.getHub?.()?.listTargets().map((t) => t.id) ?? [];
|
|
4424
|
+
this.labels.mergeScan(sessionKey, {
|
|
4425
|
+
sshHosts,
|
|
4426
|
+
docker,
|
|
4427
|
+
incus,
|
|
4428
|
+
hub
|
|
4429
|
+
});
|
|
4430
|
+
}
|
|
4431
|
+
/** 池寻址:命中即回;miss 现场采集一次再寻址(docker 容器名不经 roster 也可达)。 */
|
|
4432
|
+
async resolvePoolEntry(name$1, sessionKey) {
|
|
4433
|
+
const hit = this.labels.resolve(name$1, sessionKey);
|
|
4434
|
+
if (hit !== void 0) return hit;
|
|
4435
|
+
await this.refreshMachines(sessionKey);
|
|
3444
4436
|
this.assertOpen();
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
4437
|
+
const after = this.labels.resolve(name$1, sessionKey);
|
|
4438
|
+
if (after !== void 0) return after;
|
|
4439
|
+
return {
|
|
4440
|
+
kind: "ssh",
|
|
4441
|
+
name: name$1,
|
|
4442
|
+
host: name$1
|
|
3448
4443
|
};
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
4444
|
+
}
|
|
4445
|
+
planOf(entry) {
|
|
4446
|
+
switch (entry.kind) {
|
|
4447
|
+
case "ssh": return {
|
|
4448
|
+
kind: "ssh",
|
|
4449
|
+
host: entry.host
|
|
4450
|
+
};
|
|
4451
|
+
case "ssh-byo": return {
|
|
4452
|
+
kind: "ssh",
|
|
4453
|
+
host: entry.connection.host,
|
|
4454
|
+
connection: entry.connection
|
|
4455
|
+
};
|
|
4456
|
+
case "docker": return {
|
|
4457
|
+
kind: "docker",
|
|
4458
|
+
container: entry.container
|
|
4459
|
+
};
|
|
4460
|
+
case "incus": return {
|
|
4461
|
+
kind: "incus",
|
|
4462
|
+
container: entry.container
|
|
4463
|
+
};
|
|
4464
|
+
case "pty": throw new Error("remote: a PTY session is not a connection target");
|
|
4465
|
+
case "hub": throw new Error("remote: hub machines are not connection targets");
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
/** ssh:<name> 的池感知解析:BYO 身份 → 其 connection;否则按 config 别名/主机名。 */
|
|
4469
|
+
sshPlanFor(name$1, sessionKey) {
|
|
4470
|
+
const entry = this.labels.resolve(name$1, sessionKey);
|
|
4471
|
+
if (entry?.kind === "ssh-byo") return {
|
|
4472
|
+
kind: "ssh",
|
|
4473
|
+
host: entry.connection.host,
|
|
4474
|
+
connection: entry.connection
|
|
3452
4475
|
};
|
|
3453
4476
|
return {
|
|
3454
4477
|
kind: "ssh",
|
|
3455
4478
|
host: name$1
|
|
3456
4479
|
};
|
|
3457
4480
|
}
|
|
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 = {}) {
|
|
4481
|
+
/** BYO ssh 注册(v2:无 spec 对比——label 占用即拒;注册成功才认领)。返回实际名(label 或 s-id)。 */
|
|
4482
|
+
registerByoSsh(label, connection, sessionKey) {
|
|
3482
4483
|
this.assertOpen();
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
4484
|
+
const normalized = normalizeSshConnection(connection);
|
|
4485
|
+
if (label !== void 0) validateLabel(label);
|
|
4486
|
+
const name$1 = label ?? this.labels.freshId("ssh-", sessionKey);
|
|
4487
|
+
if (label !== void 0) {
|
|
4488
|
+
const existing = this.labels.resolve(label, sessionKey);
|
|
4489
|
+
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`);
|
|
4490
|
+
this.labels.claim(label, sessionKey, {
|
|
4491
|
+
kind: "ssh-byo",
|
|
4492
|
+
name: label,
|
|
4493
|
+
connection: normalized
|
|
4494
|
+
});
|
|
4495
|
+
return label;
|
|
3488
4496
|
}
|
|
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 {}
|
|
4497
|
+
this.labels.claim(name$1, sessionKey, {
|
|
4498
|
+
kind: "ssh-byo",
|
|
4499
|
+
name: name$1,
|
|
4500
|
+
connection: normalized
|
|
4501
|
+
});
|
|
4502
|
+
return name$1;
|
|
3516
4503
|
}
|
|
3517
|
-
/**
|
|
3518
|
-
|
|
4504
|
+
/** PTY 会话创建(native/spawn 同一形状):上限 → label 认领 → 惰性会话(失败由 onDead 释放 label)。 */
|
|
4505
|
+
createSession(opts, sessionKey) {
|
|
3519
4506
|
this.assertOpen();
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
kind: "
|
|
3530
|
-
|
|
3531
|
-
|
|
4507
|
+
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");
|
|
4508
|
+
if (live.length >= REMOTE_PTY_SESSION_LIMIT) {
|
|
4509
|
+
const listing = live.map(sessionLine).join(" · ");
|
|
4510
|
+
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}`);
|
|
4511
|
+
}
|
|
4512
|
+
const { label } = opts;
|
|
4513
|
+
if (label !== void 0) validateLabel(label);
|
|
4514
|
+
const id = this.labels.freshId("pty-", sessionKey);
|
|
4515
|
+
const entry = {
|
|
4516
|
+
kind: "pty",
|
|
4517
|
+
name: id,
|
|
4518
|
+
id,
|
|
4519
|
+
...label !== void 0 ? { label } : {},
|
|
4520
|
+
connection: opts.connection,
|
|
4521
|
+
byo: opts.byo
|
|
3532
4522
|
};
|
|
4523
|
+
if (label !== void 0) this.labels.claim(label, sessionKey, {
|
|
4524
|
+
...entry,
|
|
4525
|
+
name: label
|
|
4526
|
+
});
|
|
4527
|
+
const key = `${sessionKey}|p:${id}`;
|
|
4528
|
+
let session;
|
|
4529
|
+
try {
|
|
4530
|
+
session = this.pool.create({
|
|
4531
|
+
key,
|
|
4532
|
+
argv: opts.argv,
|
|
4533
|
+
id,
|
|
4534
|
+
...label !== void 0 ? { label } : {},
|
|
4535
|
+
connection: opts.connection,
|
|
4536
|
+
byo: opts.byo,
|
|
4537
|
+
builtOn: this.isBuiltOn(opts.connection, sessionKey),
|
|
4538
|
+
...opts.origin !== void 0 ? { origin: opts.origin } : {},
|
|
4539
|
+
onDead: () => {
|
|
4540
|
+
this.labels.release(id, sessionKey);
|
|
4541
|
+
if (label !== void 0) this.labels.release(label, sessionKey);
|
|
4542
|
+
}
|
|
4543
|
+
});
|
|
4544
|
+
} catch (error) {
|
|
4545
|
+
this.labels.release(id, sessionKey);
|
|
4546
|
+
if (label !== void 0) this.labels.release(label, sessionKey);
|
|
4547
|
+
throw error;
|
|
4548
|
+
}
|
|
4549
|
+
this.labels.claim(id, sessionKey, entry);
|
|
4550
|
+
return session;
|
|
4551
|
+
}
|
|
4552
|
+
ttlMinutes() {
|
|
4553
|
+
return Math.max(1, Math.round(this.ptyIdleTtlSec / 60));
|
|
4554
|
+
}
|
|
4555
|
+
/** connection 由 BYO ssh 身份建立 → card 面标 `(built on)`(与 roster 同词,R2 复核 P2-10)。 */
|
|
4556
|
+
isBuiltOn(connection, sessionKey) {
|
|
4557
|
+
return connection.startsWith("ssh:") && this.labels.resolve(connection.slice(4), sessionKey)?.kind === "ssh-byo";
|
|
4558
|
+
}
|
|
4559
|
+
/** PTY 创建形唯一实现(execute 与 background 预建共用):spawn BYO / ssh BYO / native `<transport>:pty`。 */
|
|
4560
|
+
createPtyLane(params, form, sessionKey) {
|
|
4561
|
+
if (params.spawn !== void 0 && params.spawn.length > 0) {
|
|
4562
|
+
const label = form.form === "pty-selector" && form.idOrLabel !== void 0 ? form.idOrLabel : form.form === "bare" ? form.name : void 0;
|
|
4563
|
+
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`);
|
|
4564
|
+
return { session: this.createSession({
|
|
4565
|
+
argv: (this.opts.spawnArgvFor ?? buildSpawnArgv)(params.spawn),
|
|
4566
|
+
connection: "BYOP",
|
|
4567
|
+
byo: true,
|
|
4568
|
+
...label !== void 0 ? { label } : {},
|
|
4569
|
+
origin: params.spawn
|
|
4570
|
+
}, sessionKey) };
|
|
4571
|
+
}
|
|
4572
|
+
if (params.ssh !== void 0 && form.form === "create-pty") {
|
|
4573
|
+
const name$1 = this.registerByoSsh(form.name, params.ssh, sessionKey);
|
|
4574
|
+
const connection = normalizeSshConnection(params.ssh);
|
|
4575
|
+
const plan = {
|
|
4576
|
+
kind: "ssh",
|
|
4577
|
+
host: connection.host,
|
|
4578
|
+
connection
|
|
4579
|
+
};
|
|
4580
|
+
return { session: this.createSession({
|
|
4581
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
4582
|
+
connection: `ssh:${name$1}`,
|
|
4583
|
+
byo: false,
|
|
4584
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
4585
|
+
}, sessionKey) };
|
|
4586
|
+
}
|
|
4587
|
+
if (form.form === "create-pty") {
|
|
4588
|
+
const plan = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
|
|
4589
|
+
return { session: this.createSession({
|
|
4590
|
+
argv: (this.opts.ptyArgvFor ?? buildPtyArgv)(plan),
|
|
4591
|
+
connection: `${form.transport}:${form.name}`,
|
|
4592
|
+
byo: false,
|
|
4593
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
4594
|
+
}, sessionKey) };
|
|
4595
|
+
}
|
|
3533
4596
|
}
|
|
3534
|
-
/**
|
|
3535
|
-
*
|
|
3536
|
-
async
|
|
4597
|
+
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
4598
|
+
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
4599
|
+
async preparePtyLane(params, callCtx = {}) {
|
|
3537
4600
|
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);
|
|
4601
|
+
const prepared = await prepareRemoteInput(params);
|
|
3542
4602
|
this.assertOpen();
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
if (
|
|
3554
|
-
if (
|
|
4603
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4604
|
+
const form = parseTargetForm(prepared.target);
|
|
4605
|
+
const target = (id$1) => ({
|
|
4606
|
+
target: `pty:${id$1}`,
|
|
4607
|
+
cmd: params.cmd,
|
|
4608
|
+
stdin: params.stdin,
|
|
4609
|
+
timeout: params.timeout,
|
|
4610
|
+
mode: "pty"
|
|
4611
|
+
});
|
|
4612
|
+
const lane = this.createPtyLane(prepared, form, sessionKey);
|
|
4613
|
+
if (lane === void 0 && form.form === "pty-selector") {
|
|
4614
|
+
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");
|
|
4615
|
+
const id$1 = this.resolvePty(form.idOrLabel, sessionKey).identity;
|
|
3555
4616
|
return {
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
4617
|
+
input: target(id$1.id),
|
|
4618
|
+
session: `pty:${id$1.id}`,
|
|
4619
|
+
connection: id$1.connection,
|
|
4620
|
+
...id$1.label !== void 0 ? { label: id$1.label } : {},
|
|
4621
|
+
cleanup: () => void 0
|
|
3559
4622
|
};
|
|
3560
4623
|
}
|
|
3561
|
-
if (
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
4624
|
+
if (lane === void 0) return void 0;
|
|
4625
|
+
const id = lane.session.identity;
|
|
4626
|
+
return {
|
|
4627
|
+
input: target(id.id),
|
|
4628
|
+
session: `pty:${id.id}`,
|
|
4629
|
+
connection: id.connection,
|
|
4630
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4631
|
+
cleanup: () => {
|
|
4632
|
+
lane.session.dispose();
|
|
4633
|
+
}
|
|
4634
|
+
};
|
|
4635
|
+
}
|
|
4636
|
+
/** `pty:<id|label>` 寻址:先 id 后 label;miss 给重建形式。 */
|
|
4637
|
+
resolvePty(selector, sessionKey) {
|
|
4638
|
+
const entry = this.labels.resolve(selector, sessionKey);
|
|
4639
|
+
if (entry?.kind === "pty") {
|
|
4640
|
+
const s = this.pool.get(`${sessionKey}|p:${entry.id}`);
|
|
4641
|
+
if (s !== void 0) return s;
|
|
3573
4642
|
}
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
4643
|
+
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`);
|
|
4644
|
+
}
|
|
4645
|
+
/** §6 close:默认撞 detached job 报错(列杀法);force = 杀组+关闭(披露 killed)。幂等 not-found。 */
|
|
4646
|
+
closePty(selector, sessionKey, force = false) {
|
|
4647
|
+
this.assertOpen();
|
|
4648
|
+
const entry = this.labels.resolve(selector, sessionKey);
|
|
4649
|
+
if (entry?.kind !== "pty") return {
|
|
4650
|
+
session: selector,
|
|
4651
|
+
connection: "",
|
|
4652
|
+
closed: false,
|
|
4653
|
+
reason: "not-found",
|
|
4654
|
+
killed: [],
|
|
4655
|
+
message: `no pty session is registered under ${JSON.stringify(selector)}`
|
|
4656
|
+
};
|
|
4657
|
+
const session = this.pool.get(`${sessionKey}|p:${entry.id}`);
|
|
4658
|
+
if (session === void 0) return {
|
|
4659
|
+
session: `pty:${entry.id}`,
|
|
4660
|
+
connection: entry.connection,
|
|
4661
|
+
closed: false,
|
|
4662
|
+
reason: "already-closed-or-reaped",
|
|
4663
|
+
killed: [],
|
|
4664
|
+
message: `pty:${entry.id} was already closed or reaped after ${this.ttlMinutes()} min idle`
|
|
3579
4665
|
};
|
|
3580
|
-
const
|
|
4666
|
+
const pending = session.detachedCommandId;
|
|
4667
|
+
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`);
|
|
4668
|
+
session.dispose();
|
|
3581
4669
|
return {
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
4670
|
+
session: `pty:${entry.id}`,
|
|
4671
|
+
connection: entry.connection,
|
|
4672
|
+
closed: true,
|
|
4673
|
+
...pending !== null ? { killed: [pending] } : { killed: [] }
|
|
3585
4674
|
};
|
|
3586
4675
|
}
|
|
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
|
-
};
|
|
4676
|
+
/** job 通道收养回填(timeout handoff 时 registry id ≠ commandId,账本记录两者)。 */
|
|
4677
|
+
attachPtyJob(commandId, jobId, sessionKey) {
|
|
4678
|
+
for (const { key } of this.pool.list()) {
|
|
4679
|
+
if (!key.startsWith(`${sessionKey ?? "no-session"}|`)) continue;
|
|
4680
|
+
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
3635
4681
|
}
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
if (resolved.kind === "pty") {
|
|
3641
|
-
routing.pty = { label: resolved.label };
|
|
3642
|
-
routing.mode = "pty";
|
|
3643
|
-
routing.display = `byop:${resolved.label}`;
|
|
3644
|
-
} else routing.plan = resolved;
|
|
3645
|
-
}
|
|
3646
|
-
if (routing.pty !== void 0) return await this.ptyCall(routing.pty, params, sessionKey, timeoutSec, cap);
|
|
3647
|
-
if (routing.mode === "oneshot") {
|
|
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, {
|
|
4682
|
+
}
|
|
4683
|
+
async dispatchPty(session, params, callCtx, timeoutSec, cap) {
|
|
4684
|
+
const commandId = callCtx.commandId ?? genCommandId();
|
|
4685
|
+
const r = await session.dispatch(params.cmd, {
|
|
3684
4686
|
stdin: params.stdin,
|
|
3685
|
-
timeoutSec
|
|
4687
|
+
timeoutSec,
|
|
4688
|
+
commandId,
|
|
4689
|
+
...callCtx.ptyBackground === true ? { background: true } : {}
|
|
3686
4690
|
});
|
|
3687
4691
|
const out = tailWindow(r.output, cap, r.outputStats?.stdout);
|
|
4692
|
+
const id = session.identity;
|
|
3688
4693
|
return {
|
|
3689
4694
|
mode: "pty",
|
|
3690
|
-
target:
|
|
4695
|
+
target: `pty:${id.id}`,
|
|
4696
|
+
session: `pty:${id.id}`,
|
|
4697
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4698
|
+
connection: id.connection,
|
|
4699
|
+
...id.byo ? { byoTag: "byo-pty" } : {},
|
|
4700
|
+
commandId: r.commandId,
|
|
3691
4701
|
exit: r.exit,
|
|
3692
4702
|
cwd: r.cwd,
|
|
3693
4703
|
timedOut: r.timedOut,
|
|
@@ -3695,42 +4705,210 @@ var RemoteDriver = class {
|
|
|
3695
4705
|
stdout: out.text,
|
|
3696
4706
|
outputStats: out.stats ? { stdout: out.stats } : void 0,
|
|
3697
4707
|
durationMs: r.durationMs,
|
|
3698
|
-
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
4708
|
+
...out.truncated !== void 0 ? { truncated: out.truncated } : {},
|
|
4709
|
+
...r.envDelta !== void 0 ? { envDelta: r.envDelta } : {},
|
|
4710
|
+
...r.running === true && r.job !== void 0 ? {
|
|
4711
|
+
running: true,
|
|
4712
|
+
ptyJob: r.job
|
|
4713
|
+
} : {},
|
|
4714
|
+
...this.ptyTtl(session.key)
|
|
3699
4715
|
};
|
|
3700
4716
|
}
|
|
3701
|
-
/**
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
4717
|
+
/** 预热(创建形无 cmd)回执:§3——无 commandId/envDelta;timeout 忽略。 */
|
|
4718
|
+
warmReceipt(session) {
|
|
4719
|
+
const id = session.identity;
|
|
4720
|
+
return {
|
|
4721
|
+
mode: "pty",
|
|
4722
|
+
target: `pty:${id.id}`,
|
|
4723
|
+
session: `pty:${id.id}`,
|
|
4724
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4725
|
+
connection: id.connection,
|
|
4726
|
+
...id.byo ? { byoTag: "byo-pty" } : {},
|
|
4727
|
+
created: true,
|
|
4728
|
+
exit: null,
|
|
4729
|
+
cwd: null,
|
|
4730
|
+
timedOut: false,
|
|
4731
|
+
reconnected: false,
|
|
4732
|
+
stdout: "",
|
|
4733
|
+
durationMs: 0,
|
|
4734
|
+
...this.ptyTtl(session.key)
|
|
4735
|
+
};
|
|
4736
|
+
}
|
|
4737
|
+
/** session card 回执(`pty:<id|label>` 无 cmd):connection/state/cwd + 最近 20 条账本(不带输出尾,§9 Q7)。 */
|
|
4738
|
+
cardReceipt(session) {
|
|
4739
|
+
const id = session.identity;
|
|
4740
|
+
const snap = session.snapshot;
|
|
4741
|
+
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)`;
|
|
4742
|
+
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)"}`;
|
|
3719
4743
|
return {
|
|
3720
4744
|
mode: "pty",
|
|
4745
|
+
target: `pty:${id.id}`,
|
|
4746
|
+
session: `pty:${id.id}`,
|
|
4747
|
+
...id.label !== void 0 ? { label: id.label } : {},
|
|
4748
|
+
connection: id.connection,
|
|
4749
|
+
...id.byo ? { byoTag: "byo-pty" } : {},
|
|
4750
|
+
exit: null,
|
|
4751
|
+
cwd: session.lastCwd,
|
|
4752
|
+
timedOut: false,
|
|
4753
|
+
reconnected: false,
|
|
4754
|
+
stdout: [head, renderLedger(session.ledgerSnapshot())].join("\n"),
|
|
4755
|
+
durationMs: 0,
|
|
4756
|
+
...this.ptyTtl(session.key)
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
async oneshot(plan, display, params, timeoutSec, cap) {
|
|
4760
|
+
this.assertOpen();
|
|
4761
|
+
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
4762
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
4763
|
+
const result = await process$1.done;
|
|
4764
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4765
|
+
const r = {
|
|
4766
|
+
...result,
|
|
4767
|
+
stdout: stdout.text,
|
|
4768
|
+
stderr: [stderr.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4769
|
+
outputStats: {
|
|
4770
|
+
stdout: stdout.stats,
|
|
4771
|
+
stderr: stderr.stats
|
|
4772
|
+
}
|
|
4773
|
+
};
|
|
4774
|
+
const out = tailWindow(r.stdout, cap, r.outputStats?.stdout);
|
|
4775
|
+
const err = r.stderr.length > 0 ? tailWindow(r.stderr, cap, r.outputStats?.stderr) : void 0;
|
|
4776
|
+
return {
|
|
4777
|
+
mode: "oneshot",
|
|
3721
4778
|
target: display,
|
|
3722
|
-
session: display,
|
|
3723
4779
|
exit: r.exit,
|
|
3724
|
-
cwd:
|
|
4780
|
+
cwd: null,
|
|
3725
4781
|
timedOut: r.timedOut,
|
|
3726
|
-
reconnected:
|
|
4782
|
+
reconnected: false,
|
|
3727
4783
|
stdout: out.text,
|
|
3728
|
-
...
|
|
3729
|
-
|
|
4784
|
+
...err !== void 0 ? { stderr: err.text } : {},
|
|
4785
|
+
...out.stats ? { outputStats: {
|
|
4786
|
+
stdout: out.stats,
|
|
4787
|
+
stderr: err?.stats ?? r.outputStats?.stderr
|
|
4788
|
+
} } : {},
|
|
3730
4789
|
durationMs: r.durationMs,
|
|
3731
|
-
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
4790
|
+
...out.truncated !== void 0 ? { truncated: out.truncated } : {},
|
|
4791
|
+
...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
|
|
3732
4792
|
};
|
|
3733
4793
|
}
|
|
4794
|
+
async hubOneshot(machine, display, params, timeoutSec, cap) {
|
|
4795
|
+
const process$1 = this.own(await this.hubOneshotProcess(machine, params));
|
|
4796
|
+
if (this.disposed) process$1.cancel("owner disposed");
|
|
4797
|
+
const result = await process$1.done;
|
|
4798
|
+
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
4799
|
+
const out = tailWindow(stdout.text, cap, stdout.stats);
|
|
4800
|
+
const err = tailWindow(stderr.text, cap, stderr.stats);
|
|
4801
|
+
return {
|
|
4802
|
+
...result,
|
|
4803
|
+
mode: "oneshot",
|
|
4804
|
+
target: display,
|
|
4805
|
+
connection: display,
|
|
4806
|
+
cwd: null,
|
|
4807
|
+
reconnected: false,
|
|
4808
|
+
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
4809
|
+
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
4810
|
+
...out.stats ? { outputStats: {
|
|
4811
|
+
stdout: out.stats,
|
|
4812
|
+
stderr: err.stats
|
|
4813
|
+
} } : { outputStatsUnavailable: true },
|
|
4814
|
+
...out.truncated === void 0 ? {} : { truncated: out.truncated }
|
|
4815
|
+
};
|
|
4816
|
+
}
|
|
4817
|
+
async hubOneshotProcess(machine, params) {
|
|
4818
|
+
const hub = this.requireHub();
|
|
4819
|
+
this.hubProviders.add(hub);
|
|
4820
|
+
return await hub.start(machine, {
|
|
4821
|
+
...params,
|
|
4822
|
+
target: `hub:${machine}`,
|
|
4823
|
+
mode: "oneshot"
|
|
4824
|
+
}, this.hubOwner);
|
|
4825
|
+
}
|
|
4826
|
+
/** Ruling P19 + §2.5:roster = 注意力入口——本地扫描刷新统一池,零拨号;机器面公共,会话面 caller-scoped。 */
|
|
4827
|
+
async roster(callCtx = {}) {
|
|
4828
|
+
this.assertOpen();
|
|
4829
|
+
const scan = await scanLocal({
|
|
4830
|
+
runner: this.opts.rosterRunner,
|
|
4831
|
+
readSshConfig: this.opts.sshConfigReader
|
|
4832
|
+
});
|
|
4833
|
+
this.assertOpen();
|
|
4834
|
+
this.labels.mergeScan(callCtx.sessionKey ?? "no-session", {
|
|
4835
|
+
sshHosts: scan.ssh.hosts,
|
|
4836
|
+
docker: scan.docker.rows.map((r) => r.name),
|
|
4837
|
+
incus: scan.incus.rows.map((r) => r.name),
|
|
4838
|
+
hub: this.opts.getHub?.()?.listTargets().map((t) => t.id) ?? []
|
|
4839
|
+
});
|
|
4840
|
+
const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
|
|
4841
|
+
const sessions = this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key }) => {
|
|
4842
|
+
const s = this.pool.get(key);
|
|
4843
|
+
return s === void 0 ? null : s;
|
|
4844
|
+
}).filter((s) => s !== null);
|
|
4845
|
+
const hub = this.opts.getHub?.();
|
|
4846
|
+
return renderRoster(scan, this.labels.list(callCtx.sessionKey ?? "no-session"), sessions, hub === void 0 ? null : hub.listTargets());
|
|
4847
|
+
}
|
|
4848
|
+
/** status probe(target 无 cmd):机器面 = probe + 该 connection 上的会话;pty selector = session card。 */
|
|
4849
|
+
async status(target, callCtx = {}) {
|
|
4850
|
+
this.assertOpen();
|
|
4851
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
4852
|
+
const form = parseTargetForm(target);
|
|
4853
|
+
if (form.form === "hub" || form.form === "hub-pty") {
|
|
4854
|
+
const row = this.requireHub().listTargets().find((t) => t.id === form.machine);
|
|
4855
|
+
if (!row) throw new Error(`unknown remote machine ${form.machine} — select a remote machine from the Hub roster; local execution is not available`);
|
|
4856
|
+
return `hub:${form.machine} — Hub remote machine (${row.label}); exec ${row.execEnabled ? "enabled" : "disabled"}; not probed`;
|
|
4857
|
+
}
|
|
4858
|
+
if (form.form === "pty-selector") {
|
|
4859
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
4860
|
+
return this.cardReceipt(this.resolvePty(form.idOrLabel, sessionKey)).stdout;
|
|
4861
|
+
}
|
|
4862
|
+
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");
|
|
4863
|
+
const name$1 = directName(form) || (form.form === "bare" ? form.name : "");
|
|
4864
|
+
const entry = await this.resolvePoolEntry(name$1, sessionKey);
|
|
4865
|
+
this.assertOpen();
|
|
4866
|
+
if (entry.kind === "pty") {
|
|
4867
|
+
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>");
|
|
4868
|
+
return this.cardReceipt(this.resolvePty(form.name, sessionKey)).stdout;
|
|
4869
|
+
}
|
|
4870
|
+
if (entry.kind === "hub") return this.status(`hub:${entry.machine}`, callCtx);
|
|
4871
|
+
const plan = this.planOf(entry);
|
|
4872
|
+
const head = `${target} — ${plan.kind === "ssh" ? "ssh host" : `${plan.kind} container`}${entry.kind === "ssh-byo" ? " (built on)" : ""}`;
|
|
4873
|
+
const runner = this.opts.probeRunner ?? runOneShot;
|
|
4874
|
+
const probe = await probeTarget(plan, { runner: (argv, options) => {
|
|
4875
|
+
this.assertOpen();
|
|
4876
|
+
return runner(argv, options);
|
|
4877
|
+
} });
|
|
4878
|
+
const prefix = `${sessionKey}|`;
|
|
4879
|
+
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}`);
|
|
4880
|
+
return [
|
|
4881
|
+
head,
|
|
4882
|
+
renderProbe(probe),
|
|
4883
|
+
onConnection.length > 0 ? `pty sessions on this connection: ${onConnection.map(sessionLine).join(" · ")}` : "pty sessions on this connection: none"
|
|
4884
|
+
].join("\n");
|
|
4885
|
+
}
|
|
4886
|
+
/** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径。 */
|
|
4887
|
+
audit(r) {
|
|
4888
|
+
try {
|
|
4889
|
+
this.opts.onAudit?.(r);
|
|
4890
|
+
} catch {}
|
|
4891
|
+
}
|
|
4892
|
+
/** Single target resolver for later copy/rsync; PTY sessions are not filesystem connections. */
|
|
4893
|
+
async resolveConnectionTarget(target, callCtx = {}) {
|
|
4894
|
+
this.assertOpen();
|
|
4895
|
+
const form = parseTargetForm(target);
|
|
4896
|
+
if (form.form === "pty-selector") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
4897
|
+
if (form.form === "hub" || form.form === "hub-pty") throw new Error("remote: hub endpoints resolve through the Hub file lane, not a CLI connection");
|
|
4898
|
+
if (form.form === "create-pty") throw new Error("remote: a PTY session is not a filesystem connection");
|
|
4899
|
+
if (form.form === "direct") return form.transport === "ssh" ? this.sshPlanFor(form.name, callCtx.sessionKey ?? "no-session") : resolveTarget(`${form.transport}:${form.name}`);
|
|
4900
|
+
if (form.form === "ssh-prefix" || form.form === "empty") throw new Error("[E_TARGET_FORMAT] remote: connection targets need a name");
|
|
4901
|
+
const entry = await this.resolvePoolEntry(form.name, callCtx.sessionKey ?? "no-session");
|
|
4902
|
+
this.assertOpen();
|
|
4903
|
+
return this.planOf(entry);
|
|
4904
|
+
}
|
|
4905
|
+
/** P2/P3:回执即状态快照——idle TTL 剩余随每次 pty 回执带回。 */
|
|
4906
|
+
ptyTtl(key) {
|
|
4907
|
+
const snap = this.pool.inspect(key);
|
|
4908
|
+
if (snap === void 0 || snap.state === "dead" || snap.state === "disposed") return {};
|
|
4909
|
+
if (snap.idleMs === null) return { ttlSec: this.ptyIdleTtlSec };
|
|
4910
|
+
return { ttlSec: Math.max(0, Math.round(this.ptyIdleTtlSec - snap.idleMs / 1e3)) };
|
|
4911
|
+
}
|
|
3734
4912
|
hasHub() {
|
|
3735
4913
|
return this.opts.getHub?.() !== void 0;
|
|
3736
4914
|
}
|
|
@@ -3745,8 +4923,8 @@ var RemoteDriver = class {
|
|
|
3745
4923
|
}
|
|
3746
4924
|
dispose() {
|
|
3747
4925
|
if (this.disposal) return this.disposal;
|
|
3748
|
-
this.sshTargets.clear();
|
|
3749
4926
|
this.disposed = true;
|
|
4927
|
+
this.labels.clear();
|
|
3750
4928
|
this.fileAbort.abort(/* @__PURE__ */ new Error("remote owner disposed"));
|
|
3751
4929
|
for (const process$1 of this.processes) process$1.cancel("remote owner disposed");
|
|
3752
4930
|
return this.disposal = (async () => {
|
|
@@ -3760,13 +4938,83 @@ var RemoteDriver = class {
|
|
|
3760
4938
|
})();
|
|
3761
4939
|
}
|
|
3762
4940
|
};
|
|
4941
|
+
/** direct 形态回退到 resolveTarget 可解析的名字(ssh:dev4 → dev4)。 */
|
|
4942
|
+
function directName(form) {
|
|
4943
|
+
return form.form === "direct" ? form.name : form.form === "create-pty" ? form.name : "";
|
|
4944
|
+
}
|
|
4945
|
+
/** roster/status 一行的会话描述:`p-ab12 (label build) ssh:dev4 idle 12s`。 */
|
|
4946
|
+
function sessionLine(s) {
|
|
4947
|
+
const id = s.identity;
|
|
4948
|
+
const snap = s.snapshot;
|
|
4949
|
+
const state = snap.busy ? "busy" : snap.state === "dead" ? "died" : `idle ${Math.round((snap.idleMs ?? 0) / 1e3)}s`;
|
|
4950
|
+
return `${id.id}${id.label !== void 0 ? ` (label ${id.label})` : ""} ${id.connection}${id.byo ? " (byo-pty)" : ""} ${state}`;
|
|
4951
|
+
}
|
|
3763
4952
|
|
|
3764
4953
|
//#endregion
|
|
3765
4954
|
//#region src/remote/jobs.ts
|
|
4955
|
+
/** PTY job 的单通道输出:流在 PTY 里天然合并,无独立 stderr 通道(P5 如实披露)。
|
|
4956
|
+
* read 是 byte-cursor 增量(Utf16TailRing.read 语义:逐字节游标 + lossy 报尾环逐出)。 */
|
|
4957
|
+
const ptyOutputSources = (holder) => [{
|
|
4958
|
+
channel: "stdout",
|
|
4959
|
+
read: (fromByte) => holder.job?.read(fromByte) ?? {
|
|
4960
|
+
text: "",
|
|
4961
|
+
nextOffset: fromByte,
|
|
4962
|
+
lossy: false
|
|
4963
|
+
}
|
|
4964
|
+
}];
|
|
4965
|
+
const ptyOutcome = (aborted, result) => ({
|
|
4966
|
+
status: aborted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
4967
|
+
detail: aborted ? `interrupted: ${result.exit === null ? "session died" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
4968
|
+
});
|
|
3766
4969
|
/** No preparation or discovery before the official registry's admission. */
|
|
3767
4970
|
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
4971
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
4972
|
+
const form = parseTargetForm(input.target);
|
|
4973
|
+
if (form.form === "pty-selector" || form.form === "create-pty" || form.form === "hub-pty" || input.spawn !== void 0 || input.mode === "pty") {
|
|
4974
|
+
if (form.form === "hub-pty") throw new Error("remote: hub-machine PTY has no background lane; use a direct ssh/docker/incus target or a pty session");
|
|
4975
|
+
const holder = {};
|
|
4976
|
+
return registry.start({
|
|
4977
|
+
kind: "remote",
|
|
4978
|
+
owner: exec.agent.id,
|
|
4979
|
+
label: `${input.target}: ${input.cmd}`,
|
|
4980
|
+
output: ptyOutputSources(holder),
|
|
4981
|
+
run(job) {
|
|
4982
|
+
const controller = new AbortController();
|
|
4983
|
+
return {
|
|
4984
|
+
done: (async () => {
|
|
4985
|
+
try {
|
|
4986
|
+
const r = await driver.call({
|
|
4987
|
+
...input,
|
|
4988
|
+
mode: "pty"
|
|
4989
|
+
}, {
|
|
4990
|
+
ptyBackground: true,
|
|
4991
|
+
commandId: job.id,
|
|
4992
|
+
sessionKey: exec.agent?.id ?? "no-session"
|
|
4993
|
+
});
|
|
4994
|
+
if (r.running !== true || r.ptyJob === void 0) return {
|
|
4995
|
+
status: "failed",
|
|
4996
|
+
detail: "pty dispatch did not hand off a running job"
|
|
4997
|
+
};
|
|
4998
|
+
holder.job = r.ptyJob;
|
|
4999
|
+
if (controller.signal.aborted) r.ptyJob.interrupt();
|
|
5000
|
+
const outcome = await r.ptyJob.done;
|
|
5001
|
+
return ptyOutcome(controller.signal.aborted, outcome);
|
|
5002
|
+
} catch (error) {
|
|
5003
|
+
return {
|
|
5004
|
+
status: controller.signal.aborted ? "killed" : "failed",
|
|
5005
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
5006
|
+
};
|
|
5007
|
+
}
|
|
5008
|
+
})(),
|
|
5009
|
+
cancel(reason) {
|
|
5010
|
+
if (controller.signal.aborted) return;
|
|
5011
|
+
controller.abort(reason);
|
|
5012
|
+
holder.job?.interrupt();
|
|
5013
|
+
}
|
|
5014
|
+
};
|
|
5015
|
+
}
|
|
5016
|
+
});
|
|
5017
|
+
}
|
|
3770
5018
|
let process$1;
|
|
3771
5019
|
const output = ["stdout", "stderr"].map((channel) => ({
|
|
3772
5020
|
channel,
|
|
@@ -3786,7 +5034,7 @@ function startRemoteJob(registry, input, exec, driver) {
|
|
|
3786
5034
|
return registry.start({
|
|
3787
5035
|
kind: "remote",
|
|
3788
5036
|
owner: exec.agent.id,
|
|
3789
|
-
label: `${input.target}: ${input.cmd
|
|
5037
|
+
label: `${input.target}: ${input.cmd}`,
|
|
3790
5038
|
output,
|
|
3791
5039
|
run() {
|
|
3792
5040
|
const controller = new AbortController();
|
|
@@ -3796,7 +5044,7 @@ function startRemoteJob(registry, input, exec, driver) {
|
|
|
3796
5044
|
process$1 = await driver.start({
|
|
3797
5045
|
...input,
|
|
3798
5046
|
mode: "oneshot"
|
|
3799
|
-
}, controller.signal);
|
|
5047
|
+
}, controller.signal, { sessionKey: exec.agent?.id ?? "no-session" });
|
|
3800
5048
|
if (controller.signal.aborted) process$1.cancel(controller.signal.reason);
|
|
3801
5049
|
const result = await process$1.done;
|
|
3802
5050
|
const diagnostic = process$1.read("stderr", 0).diagnostic;
|
|
@@ -3820,29 +5068,260 @@ function startRemoteJob(registry, input, exec, driver) {
|
|
|
3820
5068
|
}
|
|
3821
5069
|
});
|
|
3822
5070
|
}
|
|
5071
|
+
/** P4(2026-10-19)handoff 收养:同步 pty dispatch 超时后命令仍在跑——注册成 job,
|
|
5072
|
+
* job_output 即增量轮询、job_kill 即显式中断(P8 ^C+130-marker,会话存活)。 */
|
|
5073
|
+
function adoptRunningPtyJob(registry, exec, label, job) {
|
|
5074
|
+
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
5075
|
+
return registry.start({
|
|
5076
|
+
kind: "remote",
|
|
5077
|
+
owner: exec.agent.id,
|
|
5078
|
+
label,
|
|
5079
|
+
output: ptyOutputSources({ job }),
|
|
5080
|
+
run() {
|
|
5081
|
+
const controller = new AbortController();
|
|
5082
|
+
return {
|
|
5083
|
+
done: (async () => {
|
|
5084
|
+
if (controller.signal.aborted) job.interrupt();
|
|
5085
|
+
const outcome = await job.done;
|
|
5086
|
+
return ptyOutcome(controller.signal.aborted, outcome);
|
|
5087
|
+
})(),
|
|
5088
|
+
cancel(reason) {
|
|
5089
|
+
if (controller.signal.aborted) return;
|
|
5090
|
+
controller.abort(reason);
|
|
5091
|
+
job.interrupt();
|
|
5092
|
+
}
|
|
5093
|
+
};
|
|
5094
|
+
}
|
|
5095
|
+
});
|
|
5096
|
+
}
|
|
3823
5097
|
|
|
3824
5098
|
//#endregion
|
|
3825
5099
|
//#region src/remote/tool.ts
|
|
3826
5100
|
const NO_SESSION_KEY = "remote:no-session";
|
|
3827
5101
|
/**
|
|
3828
|
-
* The model-visible surface of the `remote` tool
|
|
3829
|
-
*
|
|
3830
|
-
* apostrophe escaping. Plain text only: no backticks and no "${" inside.
|
|
5102
|
+
* The model-visible surface of the `remote` tool (workbench round, plan
|
|
5103
|
+
* 2026-10-19-pty-workbench). ONE template literal — no backticks, no "${".
|
|
3831
5104
|
*/
|
|
3832
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus
|
|
3833
|
-
{}
|
|
3834
|
-
{target}
|
|
3835
|
-
{target, cmd}
|
|
3836
|
-
{target,
|
|
3837
|
-
{target:"
|
|
3838
|
-
{target:"
|
|
3839
|
-
|
|
5105
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote execution and file transfer over ssh / docker / incus, plus stateful PTY workbenches. Pick the call shape:
|
|
5106
|
+
{} → roster (local scan; refreshes the name pool: ssh aliases, BYO identities, docker/incus containers, hub machines, live pty sessions)
|
|
5107
|
+
{target} → status probe: machine reachability + its pty sessions, or a session card (connection, cwd, ledger of the last 20 commands) for pty:<id|label>
|
|
5108
|
+
{target, cmd} → run cmd — oneshot (stateless bash -lc) on any machine target
|
|
5109
|
+
{target, scriptPath:"/abs"} → LOCAL script over stdin to a fixed bash -s (oneshot only; no cmd/stdin/spawn mix)
|
|
5110
|
+
{target:"ssh:dev4:pty[:label]", cmd?} → create a PTY session on that machine and run cmd in it; no cmd = warm creation returning the id. Tab semantics: every creation call opens a NEW session — parallel is first-class (max 10 per Agent, ssh:dev4 and docker:corti alike).
|
|
5111
|
+
{target:"pty:<id|label>", cmd} → run cmd in a live session (cwd/env survive; the receipt carries session, connection, commandId, cwd, env delta, idle-left)
|
|
5112
|
+
{spawn, target?:"pty[:<label>]"|"<label>"|"", cmd?} → create a BYO pty from a spawn command (e.g. docker exec -it img bash; nested hops are dumb pipes). spawn present IS the BYO entry point; no target = auto id.
|
|
5113
|
+
{ssh:{...}, target?:"ssh[:<label>]"|"<label>"|"", cmd?} → register a BYO ssh identity (ssh present IS the entry point); the label then works everywhere ssh: aliases do (roster marks it "built on"); ssh:<label>:pty opens a session on it. key is an existing origin file, never inline material; nothing is persisted; a new Agent registers again.
|
|
5114
|
+
{action:"close", target:"pty:<id|label>", force?} → close a session. Idempotent (unknown id → closed:false). Default rejects while a job is running and names it (job_kill it first, or wait); force:true closes the tab and kills the group in one step.
|
|
5115
|
+
{action:"copy"|"rsync", src, dst, argv?} → file transfer (see the action/src/dst fields)
|
|
5116
|
+
Bare names address ONE unified pool (machines + identities + sessions — roster lists it; a machine losing its bare name to an earlier claim is shown prefixed docker-foo / incus-foo). Prefixes ssh: / docker: / incus: / hub: force a channel. Creation always claims a fresh name: a taken label answers [E_LABEL_IN_USE] — address it without creation params, or pick another (specs are never compared). PTY sessions idle out after 15 min (running commands exempt; reads do not reset the clock).
|
|
3840
5117
|
|
|
3841
|
-
oneshot:
|
|
5118
|
+
oneshot: stdout/stderr separate, exit code verbatim. Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run; first call uploads it over the same exec lane) and falls back to a POSIX wrapper everywhere else — the receipt field "runner" says which ran. Helper or wrapper, backgrounded children are reaped on completion and the whole group is killed on timeout/cancel; the endpoint needs bash only (no Python).
|
|
3842
5119
|
|
|
3843
|
-
|
|
5120
|
+
PTY is the stateful superset of oneshot: every command returns synchronously with exit + cwd + an env delta (NAMES only — added/changed/removed; values never leave the endpoint; fetch a value yourself if you need it). A command that outlives its timeout is NOT killed — it hands off as a running job: poll job_output <commandId>, cancel with job_kill (Ctrl-C first; the session survives). run_in_background uses the same lane and its jobId IS the commandId. A busy session rejects new commands ([E_SESSION_BUSY] names the running id and the ways out: job_output / job_kill / another session or oneshot).
|
|
3844
5121
|
|
|
3845
|
-
Output: pty tails at ~30k chars ("[truncated: …]" header) — redirect bulk output to a file. Hard retention 4,000,000 UTF-16 units per stream (Hub 4MiB bytes); outputStats reports the rest; restart cannot recover discarded output. stdin feeds the command
|
|
5122
|
+
Output: pty tails at ~30k chars ("[truncated: …]" header) — redirect bulk output to a file. Hard retention 4,000,000 UTF-16 units per stream (Hub 4MiB bytes); outputStats reports the rest; restart cannot recover discarded output. stdin feeds the command (pty: REPL-style right after the command line — only helps commands that read stdin there). timeout (seconds, default 120): oneshot interrupts then kills the whole group; pty hands off. Native transport errors pass through verbatim — self-correct from them.`;
|
|
5123
|
+
async function remoteExecute(ENV, args, exec) {
|
|
5124
|
+
if (args.action === "copy" || args.action === "rsync") {
|
|
5125
|
+
if (!args.src || !args.dst) throw new Error(`${args.action} requires src and dst absolute endpoints`);
|
|
5126
|
+
if ([
|
|
5127
|
+
args.cmd,
|
|
5128
|
+
args.scriptPath,
|
|
5129
|
+
args.stdin,
|
|
5130
|
+
args.spawn,
|
|
5131
|
+
args.ssh,
|
|
5132
|
+
args.target,
|
|
5133
|
+
args.timeout,
|
|
5134
|
+
args.run_in_background,
|
|
5135
|
+
args.force
|
|
5136
|
+
].some((value) => value !== void 0)) throw new Error(`${args.action} cannot be combined with execution/PTY/job arguments`);
|
|
5137
|
+
const KNOWN_PARAMS = new Set([
|
|
5138
|
+
"action",
|
|
5139
|
+
"force",
|
|
5140
|
+
"src",
|
|
5141
|
+
"dst",
|
|
5142
|
+
"argv",
|
|
5143
|
+
"pullArgv",
|
|
5144
|
+
"pushArgv",
|
|
5145
|
+
"verify",
|
|
5146
|
+
"mkdir",
|
|
5147
|
+
"run_in_background",
|
|
5148
|
+
"target",
|
|
5149
|
+
"ssh",
|
|
5150
|
+
"spawn",
|
|
5151
|
+
"cmd",
|
|
5152
|
+
"scriptPath",
|
|
5153
|
+
"stdin",
|
|
5154
|
+
"timeout"
|
|
5155
|
+
]);
|
|
5156
|
+
for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
|
|
5157
|
+
if (args.action === "rsync") {
|
|
5158
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
5159
|
+
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");
|
|
5160
|
+
const start = Date.now();
|
|
5161
|
+
const receipt$1 = await ENV.driver.rsync({
|
|
5162
|
+
src: args.src,
|
|
5163
|
+
dst: args.dst,
|
|
5164
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
5165
|
+
}, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
|
|
5166
|
+
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
5167
|
+
const rsyncDuration = Date.now() - start;
|
|
5168
|
+
return {
|
|
5169
|
+
kind: "rsync",
|
|
5170
|
+
text: formatTransferLine("rsync", receipt$1, rsyncDuration),
|
|
5171
|
+
receipt: receipt$1,
|
|
5172
|
+
exit: receipt$1.exit,
|
|
5173
|
+
durationMs: rsyncDuration
|
|
5174
|
+
};
|
|
5175
|
+
}
|
|
5176
|
+
const copyStart = Date.now();
|
|
5177
|
+
const receipt = await ENV.driver.copy({
|
|
5178
|
+
src: args.src,
|
|
5179
|
+
dst: args.dst,
|
|
5180
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
5181
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
5182
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
5183
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
5184
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
5185
|
+
}, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
|
|
5186
|
+
const asReceipt = receipt;
|
|
5187
|
+
const exits = asReceipt.exits ?? [];
|
|
5188
|
+
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
5189
|
+
const copyDuration = Date.now() - copyStart;
|
|
5190
|
+
return {
|
|
5191
|
+
kind: "copy",
|
|
5192
|
+
text: formatTransferLine("copy", receipt, copyDuration),
|
|
5193
|
+
receipt,
|
|
5194
|
+
exit: exits.length > 0 ? 0 : null,
|
|
5195
|
+
durationMs: copyDuration
|
|
5196
|
+
};
|
|
5197
|
+
}
|
|
5198
|
+
if (args.action === "close") {
|
|
5199
|
+
if (typeof args.target !== "string" || args.target.length === 0) throw new Error("close requires target pty:<id|label>");
|
|
5200
|
+
if ([
|
|
5201
|
+
args.cmd,
|
|
5202
|
+
args.scriptPath,
|
|
5203
|
+
args.stdin,
|
|
5204
|
+
args.spawn,
|
|
5205
|
+
args.ssh,
|
|
5206
|
+
args.timeout,
|
|
5207
|
+
args.run_in_background
|
|
5208
|
+
].some((value) => value !== void 0)) throw new Error("[E_PARAMS] close cannot be combined with execution/PTY/job arguments");
|
|
5209
|
+
const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, ENV.getSessionKey(exec), args.force === true);
|
|
5210
|
+
return {
|
|
5211
|
+
kind: "close",
|
|
5212
|
+
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"}`,
|
|
5213
|
+
exit: null,
|
|
5214
|
+
durationMs: 0,
|
|
5215
|
+
session: r$1.session,
|
|
5216
|
+
closed: r$1.closed,
|
|
5217
|
+
...r$1.reason !== void 0 ? { reason: r$1.reason } : {},
|
|
5218
|
+
killed: r$1.killed
|
|
5219
|
+
};
|
|
5220
|
+
}
|
|
5221
|
+
if ([
|
|
5222
|
+
args.src,
|
|
5223
|
+
args.dst,
|
|
5224
|
+
args.argv,
|
|
5225
|
+
args.pullArgv,
|
|
5226
|
+
args.pushArgv,
|
|
5227
|
+
args.verify,
|
|
5228
|
+
args.mkdir,
|
|
5229
|
+
args.force
|
|
5230
|
+
].some((value) => value !== void 0)) throw new Error("file/force fields require an action (copy / rsync / close)");
|
|
5231
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
5232
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
5233
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
5234
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: spawn must be a non-empty command string");
|
|
5235
|
+
if (args.scriptPath !== void 0 && (hasSpawn || /^pty:/.test(args.target ?? "") || /:pty/.test(args.target ?? ""))) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
5236
|
+
if (args.run_in_background) {
|
|
5237
|
+
if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
|
|
5238
|
+
const jobs = exec.agent?.ctx.get("jobs");
|
|
5239
|
+
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
5240
|
+
const lane = await ENV.driver.preparePtyLane({
|
|
5241
|
+
target: args.target,
|
|
5242
|
+
spawn: args.spawn,
|
|
5243
|
+
cmd: args.cmd,
|
|
5244
|
+
scriptPath: args.scriptPath,
|
|
5245
|
+
ssh: args.ssh,
|
|
5246
|
+
stdin: args.stdin,
|
|
5247
|
+
timeout: args.timeout
|
|
5248
|
+
}, { sessionKey: ENV.getSessionKey(exec) });
|
|
5249
|
+
let jobId;
|
|
5250
|
+
try {
|
|
5251
|
+
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
5252
|
+
target: args.target,
|
|
5253
|
+
spawn: args.spawn,
|
|
5254
|
+
cmd: args.cmd,
|
|
5255
|
+
scriptPath: args.scriptPath,
|
|
5256
|
+
ssh: args.ssh,
|
|
5257
|
+
stdin: args.stdin,
|
|
5258
|
+
timeout: args.timeout
|
|
5259
|
+
}, exec, ENV.driver);
|
|
5260
|
+
} catch (error) {
|
|
5261
|
+
lane?.cleanup();
|
|
5262
|
+
throw error;
|
|
5263
|
+
}
|
|
5264
|
+
const where = lane === void 0 ? "" : ` on ${lane.session}${lane.label !== void 0 ? ` (${lane.label})` : ""} (${lane.connection})`;
|
|
5265
|
+
return {
|
|
5266
|
+
kind: "job",
|
|
5267
|
+
jobId,
|
|
5268
|
+
exit: null,
|
|
5269
|
+
durationMs: 0,
|
|
5270
|
+
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
5271
|
+
...lane !== void 0 ? {
|
|
5272
|
+
session: lane.session,
|
|
5273
|
+
connection: lane.connection,
|
|
5274
|
+
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
5275
|
+
} : {}
|
|
5276
|
+
};
|
|
5277
|
+
}
|
|
5278
|
+
if (!hasCmd && args.scriptPath === void 0) {
|
|
5279
|
+
if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
|
|
5280
|
+
const t = args.target ?? "";
|
|
5281
|
+
if (hasSpawn || args.ssh !== void 0 || /^pty:/.test(t) || /:pty(:|$)/.test(t) && !t.startsWith("hub:")) return workbenchValue(await ENV.driver.call({
|
|
5282
|
+
target: args.target,
|
|
5283
|
+
spawn: args.spawn,
|
|
5284
|
+
ssh: args.ssh,
|
|
5285
|
+
cmd: "",
|
|
5286
|
+
stdin: void 0
|
|
5287
|
+
}, { sessionKey: ENV.getSessionKey(exec) }));
|
|
5288
|
+
if (!hasTarget) return {
|
|
5289
|
+
kind: "roster",
|
|
5290
|
+
text: await ENV.driver.roster({ sessionKey: ENV.getSessionKey(exec) }),
|
|
5291
|
+
exit: null,
|
|
5292
|
+
durationMs: 0
|
|
5293
|
+
};
|
|
5294
|
+
return {
|
|
5295
|
+
kind: "status",
|
|
5296
|
+
text: await ENV.driver.status(args.target, { sessionKey: ENV.getSessionKey(exec) }),
|
|
5297
|
+
exit: null,
|
|
5298
|
+
durationMs: 0
|
|
5299
|
+
};
|
|
5300
|
+
}
|
|
5301
|
+
const r = await ENV.driver.call({
|
|
5302
|
+
target: args.target,
|
|
5303
|
+
spawn: args.spawn,
|
|
5304
|
+
ssh: args.ssh,
|
|
5305
|
+
cmd: args.cmd,
|
|
5306
|
+
scriptPath: args.scriptPath,
|
|
5307
|
+
stdin: args.stdin,
|
|
5308
|
+
timeout: args.timeout
|
|
5309
|
+
}, { sessionKey: ENV.getSessionKey(exec) });
|
|
5310
|
+
const v = workbenchValue(r);
|
|
5311
|
+
if (r.running === true && r.ptyJob !== void 0) {
|
|
5312
|
+
const jobs = exec.agent?.ctx.get("jobs");
|
|
5313
|
+
if (jobs) {
|
|
5314
|
+
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
5315
|
+
v.jobId = jobId;
|
|
5316
|
+
v.running = true;
|
|
5317
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, ENV.getSessionKey(exec));
|
|
5318
|
+
} else {
|
|
5319
|
+
r.ptyJob.interrupt();
|
|
5320
|
+
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.";
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
return v;
|
|
5324
|
+
}
|
|
3846
5325
|
function createRemoteTool(driver, opts = {}) {
|
|
3847
5326
|
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
3848
5327
|
const tool = defineTool({
|
|
@@ -3851,8 +5330,16 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3851
5330
|
parameters: {
|
|
3852
5331
|
action: {
|
|
3853
5332
|
type: "string",
|
|
3854
|
-
enum: [
|
|
3855
|
-
|
|
5333
|
+
enum: [
|
|
5334
|
+
"copy",
|
|
5335
|
+
"rsync",
|
|
5336
|
+
"close"
|
|
5337
|
+
],
|
|
5338
|
+
description: "Optional action; omit for execution/status/roster. close takes target + optional force."
|
|
5339
|
+
},
|
|
5340
|
+
force: {
|
|
5341
|
+
type: "boolean",
|
|
5342
|
+
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."
|
|
3856
5343
|
},
|
|
3857
5344
|
src: {
|
|
3858
5345
|
type: "string",
|
|
@@ -3865,7 +5352,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3865
5352
|
argv: {
|
|
3866
5353
|
type: "array",
|
|
3867
5354
|
items: { type: "string" },
|
|
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."
|
|
5355
|
+
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], 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. 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."
|
|
3869
5356
|
},
|
|
3870
5357
|
pullArgv: {
|
|
3871
5358
|
type: "array",
|
|
@@ -3875,27 +5362,27 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3875
5362
|
pushArgv: {
|
|
3876
5363
|
type: "array",
|
|
3877
5364
|
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.
|
|
5365
|
+
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."
|
|
3879
5366
|
},
|
|
3880
5367
|
verify: {
|
|
3881
5368
|
type: "boolean",
|
|
3882
|
-
description: "copy only
|
|
5369
|
+
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."
|
|
3883
5370
|
},
|
|
3884
5371
|
mkdir: {
|
|
3885
5372
|
type: "boolean",
|
|
3886
|
-
description: "copy only,
|
|
5373
|
+
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."
|
|
3887
5374
|
},
|
|
3888
5375
|
run_in_background: {
|
|
3889
5376
|
type: "boolean",
|
|
3890
|
-
description: "
|
|
5377
|
+
description: "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. Hub-machine PTY has no background lane. Requires the official jobs service/controller."
|
|
3891
5378
|
},
|
|
3892
5379
|
target: {
|
|
3893
5380
|
type: "string",
|
|
3894
|
-
description: "Single entry point.
|
|
5381
|
+
description: "Single entry point. Machine targets: bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. PTY: append :pty to create (ssh:dev4:pty, ssh:dev4:pty:build — label optional, every creation opens a new session) or address a live one with pty:<id|label>. BYO: pass spawn (pty) or ssh (identity) and the label lives in the same pool. Omit target → roster (or BYO auto-id when spawn/ssh present)."
|
|
3895
5382
|
},
|
|
3896
5383
|
ssh: {
|
|
3897
5384
|
type: "object",
|
|
3898
|
-
description: "
|
|
5385
|
+
description: "BYO ssh registration — the field present IS the entry point. First call registers (no cmd = register only); later calls reuse the label with ssh omitted. No inline keys or arbitrary SSH options.",
|
|
3899
5386
|
additionalProperties: false,
|
|
3900
5387
|
properties: {
|
|
3901
5388
|
host: {
|
|
@@ -3923,28 +5410,23 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3923
5410
|
},
|
|
3924
5411
|
spawn: {
|
|
3925
5412
|
type: "string",
|
|
3926
|
-
description: "
|
|
5413
|
+
description: "BYO pty creation — the field present IS the 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)."
|
|
3927
5414
|
},
|
|
3928
5415
|
cmd: {
|
|
3929
5416
|
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
|
|
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."
|
|
5417
|
+
description: "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status/card; omit everything → roster."
|
|
3935
5418
|
},
|
|
3936
|
-
|
|
5419
|
+
scriptPath: {
|
|
3937
5420
|
type: "string",
|
|
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'."
|
|
5421
|
+
description: "LOCAL absolute script path. Read before any dialing, sent literally over stdin to fixed bash -s. Oneshot only (no PTY lanes); cannot combine with cmd, stdin or spawn. Hub inline cmd remains limited to 4096; script body is not argv."
|
|
3940
5422
|
},
|
|
3941
5423
|
stdin: {
|
|
3942
5424
|
type: "string",
|
|
3943
|
-
description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style)
|
|
5425
|
+
description: "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style). Requires cmd."
|
|
3944
5426
|
},
|
|
3945
5427
|
timeout: {
|
|
3946
5428
|
type: "number",
|
|
3947
|
-
description: "Per-command timeout in seconds (default 120
|
|
5429
|
+
description: "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)."
|
|
3948
5430
|
}
|
|
3949
5431
|
},
|
|
3950
5432
|
output: {
|
|
@@ -3961,7 +5443,8 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3961
5443
|
"roster",
|
|
3962
5444
|
"job",
|
|
3963
5445
|
"copy",
|
|
3964
|
-
"rsync"
|
|
5446
|
+
"rsync",
|
|
5447
|
+
"close"
|
|
3965
5448
|
]
|
|
3966
5449
|
},
|
|
3967
5450
|
text: {
|
|
@@ -3979,8 +5462,45 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3979
5462
|
truncated: { type: "integer" },
|
|
3980
5463
|
outputStats: { type: "json" },
|
|
3981
5464
|
outputStatsUnavailable: { type: "boolean" },
|
|
3982
|
-
session: {
|
|
3983
|
-
|
|
5465
|
+
session: {
|
|
5466
|
+
type: "string",
|
|
5467
|
+
description: "pty session id, e.g. pty:pty-7k2m"
|
|
5468
|
+
},
|
|
5469
|
+
label: { type: "string" },
|
|
5470
|
+
connection: {
|
|
5471
|
+
type: "string",
|
|
5472
|
+
description: "underlying connection: ssh:dev4 / docker:c / incus:x / hub:m4 / BYOP"
|
|
5473
|
+
},
|
|
5474
|
+
byoTag: { type: "string" },
|
|
5475
|
+
commandId: {
|
|
5476
|
+
type: "string",
|
|
5477
|
+
description: "ledger key; equals the jobId whenever a job exists"
|
|
5478
|
+
},
|
|
5479
|
+
envDelta: {
|
|
5480
|
+
type: "json",
|
|
5481
|
+
description: "names only: {added,changed,removed}"
|
|
5482
|
+
},
|
|
5483
|
+
created: { type: "boolean" },
|
|
5484
|
+
notice: { type: "string" },
|
|
5485
|
+
runner: {
|
|
5486
|
+
type: "string",
|
|
5487
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>) or posix-shim"
|
|
5488
|
+
},
|
|
5489
|
+
running: { type: "boolean" },
|
|
5490
|
+
ttlSec: { type: "integer" },
|
|
5491
|
+
closed: {
|
|
5492
|
+
type: "boolean",
|
|
5493
|
+
description: "close: true = the session is gone now"
|
|
5494
|
+
},
|
|
5495
|
+
reason: {
|
|
5496
|
+
type: "string",
|
|
5497
|
+
description: "close: not-found / already-closed-or-reaped"
|
|
5498
|
+
},
|
|
5499
|
+
killed: {
|
|
5500
|
+
type: "array",
|
|
5501
|
+
items: { type: "string" },
|
|
5502
|
+
description: "close force: the running commandIds that were killed"
|
|
5503
|
+
}
|
|
3984
5504
|
}
|
|
3985
5505
|
},
|
|
3986
5506
|
render: (_args, value) => [{
|
|
@@ -3989,168 +5509,98 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
3989
5509
|
}]
|
|
3990
5510
|
},
|
|
3991
5511
|
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;
|
|
5512
|
+
return remoteExecute({
|
|
5513
|
+
driver,
|
|
5514
|
+
getSessionKey
|
|
5515
|
+
}, args, exec);
|
|
4130
5516
|
}
|
|
4131
5517
|
});
|
|
4132
5518
|
const description = tool.description;
|
|
4133
5519
|
Object.defineProperty(tool, "description", {
|
|
4134
5520
|
enumerable: true,
|
|
4135
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self). {} includes the Hub directory even when empty. Uses authenticated Hub HTTP oneshot / persistent WS PTY; no SSH alias needed. Hub failures never fall back to SSH." : "")
|
|
5521
|
+
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self). {} includes the Hub directory even when empty. Uses authenticated Hub HTTP oneshot / persistent WS PTY (hub:<m4>:pty selects the PTY lane); no SSH alias needed. Hub failures never fall back to SSH." : "")
|
|
4136
5522
|
});
|
|
4137
5523
|
return tool;
|
|
4138
5524
|
}
|
|
5525
|
+
/** RemoteCallResult → RemoteToolValue(pty 工作台字段 + 既有字段)。 */
|
|
5526
|
+
function workbenchValue(r) {
|
|
5527
|
+
const v = {
|
|
5528
|
+
kind: "exec",
|
|
5529
|
+
text: r.stdout,
|
|
5530
|
+
exit: r.exit,
|
|
5531
|
+
durationMs: r.durationMs
|
|
5532
|
+
};
|
|
5533
|
+
if (r.cwd !== null && r.cwd !== void 0) v.cwd = r.cwd;
|
|
5534
|
+
if (r.stderr !== void 0) v.stderr = r.stderr;
|
|
5535
|
+
if (r.timedOut) v.timedOut = true;
|
|
5536
|
+
if (r.reconnected) v.reconnected = true;
|
|
5537
|
+
if (r.truncated !== void 0) v.truncated = r.truncated;
|
|
5538
|
+
if (r.outputStats !== void 0) v.outputStats = r.outputStats;
|
|
5539
|
+
if (r.outputStatsUnavailable) v.outputStatsUnavailable = true;
|
|
5540
|
+
if (r.session !== void 0) v.session = r.session;
|
|
5541
|
+
if (r.label !== void 0) v.label = r.label;
|
|
5542
|
+
if (r.connection !== void 0) v.connection = r.connection;
|
|
5543
|
+
if (r.byoTag !== void 0) v.byoTag = r.byoTag;
|
|
5544
|
+
if (r.commandId !== void 0) v.commandId = r.commandId;
|
|
5545
|
+
if (r.envDelta !== void 0) v.envDelta = r.envDelta;
|
|
5546
|
+
if (r.created === true) v.created = true;
|
|
5547
|
+
if (r.notice !== void 0) v.notice = r.notice;
|
|
5548
|
+
if (r.runner !== void 0) v.runner = r.runner;
|
|
5549
|
+
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
5550
|
+
return v;
|
|
5551
|
+
}
|
|
5552
|
+
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5553
|
+
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5554
|
+
function formatTransferLine(action, receipt, durationMs) {
|
|
5555
|
+
const r = receipt;
|
|
5556
|
+
const parts = [];
|
|
5557
|
+
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5558
|
+
parts.push(`${action} ${r.src} → ${r.dst}`);
|
|
5559
|
+
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5560
|
+
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5561
|
+
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5562
|
+
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5563
|
+
if (r.created === true) parts.push("created yes");
|
|
5564
|
+
else if (r.created === false) parts.push("created pre-existing");
|
|
5565
|
+
else if (r.created === "n-a") parts.push("created n-a");
|
|
5566
|
+
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5567
|
+
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5568
|
+
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5569
|
+
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5570
|
+
return parts.join(" · ");
|
|
5571
|
+
}
|
|
5572
|
+
function renderEnvDelta(d) {
|
|
5573
|
+
const bits = [];
|
|
5574
|
+
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
5575
|
+
if (d.changed.length > 0) bits.push(`~${d.changed.join(",~")}`);
|
|
5576
|
+
if (d.removed.length > 0) bits.push(`-${d.removed.join(",-")}`);
|
|
5577
|
+
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
5578
|
+
}
|
|
4139
5579
|
function renderValue(v) {
|
|
4140
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync") return v.text;
|
|
5580
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "copy" || v.kind === "rsync" || v.kind === "close") return v.text;
|
|
4141
5581
|
const parts = [];
|
|
4142
5582
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
4143
5583
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
5584
|
+
if (v.created === true && v.session !== void 0) parts.push(`[session created — address it as ${v.session}]`);
|
|
4144
5585
|
parts.push(v.text);
|
|
4145
5586
|
const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
|
|
4146
5587
|
const markers = [];
|
|
5588
|
+
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})` : ""}`);
|
|
5589
|
+
if (typeof v.connection === "string" && v.connection.length > 0 && v.session !== void 0) markers.push(`conn ${v.connection}`);
|
|
5590
|
+
if (typeof v.commandId === "string" && v.commandId.length > 0) markers.push(`cmd ${v.commandId}`);
|
|
5591
|
+
if (typeof v.cwd === "string" && v.cwd.length > 0) markers.push(`cwd ${v.cwd}`);
|
|
5592
|
+
const envTag = v.envDelta !== void 0 ? renderEnvDelta(v.envDelta) : void 0;
|
|
5593
|
+
if (envTag !== void 0) markers.push(`env ${envTag}`);
|
|
5594
|
+
if (typeof v.ttlSec === "number") markers.push(`idle-left ~${Math.max(1, Math.round(v.ttlSec / 60))}m`);
|
|
5595
|
+
if (typeof v.runner === "string" && v.runner.length > 0) markers.push(`runner ${v.runner}`);
|
|
4147
5596
|
if (v.timedOut === true) markers.push("interrupted");
|
|
5597
|
+
markers.push(`exit ${v.exit}`);
|
|
5598
|
+
markers.push(`${secs}s`);
|
|
5599
|
+
if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
|
|
4148
5600
|
if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
|
|
4149
5601
|
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable; returned tail only");
|
|
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}]`);
|
|
5602
|
+
parts.push(`[${markers.join(" · ")}]`);
|
|
5603
|
+
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
5604
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
4155
5605
|
return parts.join("\n");
|
|
4156
5606
|
}
|
|
@@ -4239,11 +5689,11 @@ const name = "dashr-remote";
|
|
|
4239
5689
|
const inject = [];
|
|
4240
5690
|
const Config = Schema.object({
|
|
4241
5691
|
execTimeoutSec: Schema.natural().min(1).default(120),
|
|
4242
|
-
idleTtlSec: Schema.natural().min(1).default(
|
|
5692
|
+
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC),
|
|
4243
5693
|
maxOutputChars: Schema.natural().min(1).default(3e4)
|
|
4244
5694
|
}).default({
|
|
4245
5695
|
execTimeoutSec: 120,
|
|
4246
|
-
idleTtlSec:
|
|
5696
|
+
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC,
|
|
4247
5697
|
maxOutputChars: 3e4
|
|
4248
5698
|
});
|
|
4249
5699
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
@@ -4258,4 +5708,4 @@ var plugin_default = {
|
|
|
4258
5708
|
};
|
|
4259
5709
|
|
|
4260
5710
|
//#endregion
|
|
4261
|
-
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS,
|
|
5711
|
+
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, Utf16TailRing, apply, checkAbsolute, checkSelection, plugin_default as default, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };
|