better-dsh 0.3.2 → 0.3.3
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/docs/remote-tools.md +36 -20
- package/docs/specs/remote-tool-enhancement/spec.md +1 -0
- package/lib/fs-aware/sandbox-plugin.js +3 -3
- package/lib/index.d.ts +16 -12
- package/lib/index.js +19 -25
- package/lib/remote/hub-pty-pump.mjs +97 -0
- package/lib/remote/plugin.d.ts +406 -203
- package/lib/remote/plugin.js +631 -319
- package/lib/url-schemes/index.d.ts +11 -11
- package/lib/web-password.d.ts +7 -7
- package/lib/web-password.js +1 -1
- package/lib/web-trust2.d.ts +11 -11
- package/lib/welcome-notice.d.ts +3 -3
- package/package.json +77 -61
package/lib/remote/plugin.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { t as Schema } from "../lib.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { Context } from "@deepseek-ai/cordis";
|
|
3
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
4
5
|
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -11,6 +12,7 @@ import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rename
|
|
|
11
12
|
import { pipeline } from "node:stream/promises";
|
|
12
13
|
import { Readable } from "node:stream";
|
|
13
14
|
import { createConnection, createServer } from "node:net";
|
|
15
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
14
16
|
|
|
15
17
|
//#region src/remote/archive.ts
|
|
16
18
|
const FILE_FRAME_BYTES = 64 * 1024;
|
|
@@ -1835,6 +1837,23 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
1835
1837
|
"/dev/null"
|
|
1836
1838
|
];
|
|
1837
1839
|
}
|
|
1840
|
+
/** Hub PTY(0.3.2-c;0.3.2-d F4 修正):会话子进程 = 本地 `script` PTY 托管的 WS 字节泵
|
|
1841
|
+
* (hub-pty-pump.mjs)。与 docker exec 的嵌套 PTY 同形,但泵是哑 node 子进程、不像
|
|
1842
|
+
* `ssh -tt` / `docker exec -it` 那样作为前台子进程自己把本地 tty 置 raw——所以本地包装必须
|
|
1843
|
+
* `stty raw`:canonical+ISIG 下 0x03 在本地变 SIGINT 杀泵、0x04 变 EOF 结束泵 stdin
|
|
1844
|
+
* (F4:input ^C/^D 与 job_kill 会打死整条会话)。raw 只作用于本地半段;对端 script PTY
|
|
1845
|
+
* 保持 canonical,^C/^D 在对端产生 SIGINT/EOF 正是我们要的语义(字节原样过线,解释权在
|
|
1846
|
+
* 对端)。kill 链 session 组杀 → 泵死 → WS 关 → 对端组杀,双向自然闭合。连接事实走 wrapper
|
|
1847
|
+
* 内 env 赋值(cookie 不进 argv、不落 ps)。 */
|
|
1848
|
+
function buildHubPtyArgv(endpoint, assets) {
|
|
1849
|
+
const node = assets.nodePath ?? process.execPath;
|
|
1850
|
+
return [
|
|
1851
|
+
"script",
|
|
1852
|
+
"-qfec",
|
|
1853
|
+
`stty raw -echo 2>/dev/null ; ${`export SUPERD_HUB_PTY_URL=${shQuote(endpoint.url)} SUPERD_HUB_PTY_COOKIE=${shQuote(endpoint.cookie)} SUPERD_HUB_PTY_WS=${shQuote(endpoint.wsPath)}`} ; exec ${shQuote(node)} ${shQuote(assets.pumpPath)}`,
|
|
1854
|
+
"/dev/null"
|
|
1855
|
+
];
|
|
1856
|
+
}
|
|
1838
1857
|
/** superd-run helper argv (spec: docs/specs/remote-rescope §5). Probe and upload are single
|
|
1839
1858
|
* `sh -c` hops (busybox/alpine/darwin-safe); run/killgroup over ssh go through exactly one
|
|
1840
1859
|
* remote-shell parse, same discipline as buildOneShotArgv. */
|
|
@@ -1955,22 +1974,25 @@ function stripAnsi(s) {
|
|
|
1955
1974
|
function normalizePtyText(s) {
|
|
1956
1975
|
return stripAnsi(s).replace(/\r\n/g, "\n").replace(/\r/g, "");
|
|
1957
1976
|
}
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1977
|
+
/** 0.3.2-e:自有 30K 返回窗口已删(原生 agent runtime 的 spill/输出保护接管模型上下文
|
|
1978
|
+
* 尺寸)。本 helper 只做 retention 披露:ring 留存(4M UTF-16 / 4MiB bytes)未驱逐时
|
|
1979
|
+
* 全量透传(returned=retained,无 banner);驱逐(dropped>0,仅 >4M 输出发生)时打一行
|
|
1980
|
+
* banner 且 truncated=原始总量。text 已是 ring 尾部,不再二次裁剪。 */
|
|
1981
|
+
function disclose(text, stats) {
|
|
1982
|
+
if (stats === void 0) return { text };
|
|
1983
|
+
const returned = stats.retained;
|
|
1984
|
+
const updated = { stats: {
|
|
1964
1985
|
...stats,
|
|
1965
1986
|
returned
|
|
1966
1987
|
} };
|
|
1967
|
-
if (
|
|
1968
|
-
text
|
|
1988
|
+
if (stats.dropped <= 0) return {
|
|
1989
|
+
text,
|
|
1969
1990
|
...updated
|
|
1970
1991
|
};
|
|
1992
|
+
const unit = stats.unit === "bytes" ? "bytes" : "chars";
|
|
1971
1993
|
return {
|
|
1972
|
-
text: `[
|
|
1973
|
-
truncated: total,
|
|
1994
|
+
text: `[retention tail: showing last ${stats.retained} of ${stats.total} ${unit}; dropped head ${stats.dropped}, retention limit ${stats.retentionLimit} ${stats.unit === "bytes" ? "bytes" : "UTF-16 code units"}]\n${text}`,
|
|
1995
|
+
truncated: stats.total,
|
|
1974
1996
|
...updated
|
|
1975
1997
|
};
|
|
1976
1998
|
}
|
|
@@ -2114,13 +2136,15 @@ const summaryOf = (cmd) => {
|
|
|
2114
2136
|
};
|
|
2115
2137
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
2116
2138
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
2139
|
+
/** F2(0.3.2-d):\x03 触发 ISIG 时内核 flush 输入队列——130-marker 必须等 flush 完成
|
|
2140
|
+
* 再入队,否则首字节被吞(实测 'rintf' not found → 会话死)。 */
|
|
2141
|
+
const MARKER_SETTLE_MS = 80;
|
|
2117
2142
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
2118
2143
|
const OUTPUT_HARD_CAP = 4e6;
|
|
2119
2144
|
var PtySession = class {
|
|
2120
2145
|
proc = null;
|
|
2121
2146
|
state = "cold";
|
|
2122
2147
|
startPromise = null;
|
|
2123
|
-
queue = Promise.resolve();
|
|
2124
2148
|
idleTimer = null;
|
|
2125
2149
|
onChunk = null;
|
|
2126
2150
|
deathWaiters = [];
|
|
@@ -2128,6 +2152,8 @@ var PtySession = class {
|
|
|
2128
2152
|
inFlight = 0;
|
|
2129
2153
|
/** P4/P5:当前占用前台的 detached job(background/超时 handoff)。非 null 时新命令拒绝(Q6)。 */
|
|
2130
2154
|
detachedJob = null;
|
|
2155
|
+
/** §3.2(2026-10-25):当前占用会话的命令——dispatch 同步占位,settle/cleanup 释放;input 的唯一写入对象。 */
|
|
2156
|
+
activeCommand = null;
|
|
2131
2157
|
/** 账本(§5):本会话最近 20 条命令(FIFO,超出丢最旧)。 */
|
|
2132
2158
|
ledger = [];
|
|
2133
2159
|
/** env 侧信道基线(init E 帧);undefined = 侧信道不可用(不报假 delta)。 */
|
|
@@ -2160,10 +2186,11 @@ var PtySession = class {
|
|
|
2160
2186
|
get detachedCommandId() {
|
|
2161
2187
|
return this.detachedJob?.commandId ?? null;
|
|
2162
2188
|
}
|
|
2163
|
-
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId
|
|
2189
|
+
/** job 通道收养后回填 jobId(timeout handoff 时 registry id ≠ commandId,账本如实记录两者);运行中的命令同步携带(input 回执用)。 */
|
|
2164
2190
|
attachJob(commandId, jobId) {
|
|
2165
2191
|
const entry = [...this.ledger].reverse().find((e) => e.commandId === commandId);
|
|
2166
2192
|
if (entry !== void 0) entry.jobId = jobId;
|
|
2193
|
+
if (this.activeCommand !== null && this.activeCommand.commandId === commandId) this.activeCommand.jobId = jobId;
|
|
2167
2194
|
}
|
|
2168
2195
|
/** Workbench 身份(§3):id + 可选 label + connection + byo 标记。 */
|
|
2169
2196
|
get identity() {
|
|
@@ -2197,28 +2224,55 @@ var PtySession = class {
|
|
|
2197
2224
|
async warm() {
|
|
2198
2225
|
await this.start();
|
|
2199
2226
|
}
|
|
2200
|
-
/**
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
2203
|
-
*
|
|
2227
|
+
/** 同一会话命令占位制(2026-10-25 interactive-stdin 轮):dispatch 入口**同步**建立
|
|
2228
|
+
* 命令占位(activeCommand + inFlight + 账本条目)——上一条命令仍在运行(同步等待 /
|
|
2229
|
+
* 等输入 / detached job)时新 cmd 立即 E_SESSION_BUSY;原「纯同步并发排 FIFO」语义
|
|
2230
|
+
* 废除(排队会让等输入的命令与补输入动作互相卡死,也给 busy 留下绕行窗口)。
|
|
2231
|
+
* P4/P5:超时不杀命令而是 handoff 成 running job;background 立即回执;P8 显式
|
|
2232
|
+
* interrupt 保留在 job.interrupt;死亡/冷态先重启(reconnect 语义不变)。 */
|
|
2204
2233
|
dispatch(cmd, dOpts) {
|
|
2234
|
+
if (this.state === "disposed") return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`));
|
|
2235
|
+
if (this.activeCommand !== null) {
|
|
2236
|
+
const handle = this.activeCommand.jobId ?? this.activeCommand.commandId;
|
|
2237
|
+
return Promise.reject(/* @__PURE__ */ new Error(`[E_SESSION_BUSY] remote: pty session '${this.opts.id}' still runs a command (${this.activeCommand.commandId}) — send it more stdin with the input action, follow its output via job_output ${handle} (a synchronous exec hands its job off there on timeout), cancel with job_kill ${handle}, or address other work to another session / oneshot`));
|
|
2238
|
+
}
|
|
2239
|
+
const commandId = dOpts.commandId;
|
|
2240
|
+
const entry = {
|
|
2241
|
+
commandId,
|
|
2242
|
+
summary: summaryOf(cmd),
|
|
2243
|
+
exit: null,
|
|
2244
|
+
durationMs: 0,
|
|
2245
|
+
running: true,
|
|
2246
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
2247
|
+
};
|
|
2248
|
+
this.ledger.push(entry);
|
|
2249
|
+
if (this.ledger.length > LEDGER_MAX) this.ledger.shift();
|
|
2250
|
+
this.activeCommand = {
|
|
2251
|
+
commandId,
|
|
2252
|
+
...dOpts.jobId !== void 0 ? { jobId: dOpts.jobId } : {}
|
|
2253
|
+
};
|
|
2254
|
+
this.inFlight++;
|
|
2205
2255
|
const attempt = async () => {
|
|
2206
2256
|
const started = Date.now();
|
|
2207
2257
|
const reconnected = this.state === "dead";
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
exit: null,
|
|
2216
|
-
durationMs: 0,
|
|
2217
|
-
running: true
|
|
2258
|
+
/** 占位撤销:命令从未真正启动(建连失败 / 建连中被 dispose)——释放 busy 与计数,
|
|
2259
|
+
* 撤掉账本条目(与旧时序等价:条目原本在 start 成功之后才建立)。 */
|
|
2260
|
+
const abandon = () => {
|
|
2261
|
+
this.activeCommand = null;
|
|
2262
|
+
this.inFlight--;
|
|
2263
|
+
const index = this.ledger.indexOf(entry);
|
|
2264
|
+
if (index >= 0) this.ledger.splice(index, 1);
|
|
2218
2265
|
};
|
|
2219
|
-
this.
|
|
2220
|
-
|
|
2221
|
-
|
|
2266
|
+
if (this.state !== "ready") try {
|
|
2267
|
+
await this.start();
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
abandon();
|
|
2270
|
+
throw error;
|
|
2271
|
+
}
|
|
2272
|
+
if (this.state === "disposed") {
|
|
2273
|
+
abandon();
|
|
2274
|
+
throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.id}' was disposed before dispatch`);
|
|
2275
|
+
}
|
|
2222
2276
|
return await new Promise((resolve$1) => {
|
|
2223
2277
|
const nonce = genNonce();
|
|
2224
2278
|
const ring = new Utf16TailRing(OUTPUT_HARD_CAP);
|
|
@@ -2227,6 +2281,7 @@ var PtySession = class {
|
|
|
2227
2281
|
let jobSettled = false;
|
|
2228
2282
|
let interruptTimer;
|
|
2229
2283
|
let killTimer;
|
|
2284
|
+
let markerTimer;
|
|
2230
2285
|
let jobDone = null;
|
|
2231
2286
|
let envNext;
|
|
2232
2287
|
const detach = () => {
|
|
@@ -2260,9 +2315,11 @@ var PtySession = class {
|
|
|
2260
2315
|
const cleanup = () => {
|
|
2261
2316
|
this.onChunk = null;
|
|
2262
2317
|
this.detachedJob = null;
|
|
2318
|
+
this.activeCommand = null;
|
|
2263
2319
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== deathWaiter);
|
|
2264
2320
|
if (interruptTimer !== void 0) clearTimeout(interruptTimer);
|
|
2265
2321
|
if (killTimer !== void 0) clearTimeout(killTimer);
|
|
2322
|
+
if (markerTimer !== void 0) clearTimeout(markerTimer);
|
|
2266
2323
|
};
|
|
2267
2324
|
const settle = (r) => {
|
|
2268
2325
|
if (jobSettled) return;
|
|
@@ -2317,7 +2374,9 @@ var PtySession = class {
|
|
|
2317
2374
|
const twoStage = () => {
|
|
2318
2375
|
interrupted = true;
|
|
2319
2376
|
this.write("");
|
|
2320
|
-
|
|
2377
|
+
markerTimer = setTimeout(() => {
|
|
2378
|
+
this.write(`printf '\\033]133;D;${nonce};130;%s\\007' "$PWD"\n`);
|
|
2379
|
+
}, MARKER_SETTLE_MS);
|
|
2321
2380
|
killTimer = setTimeout(() => {
|
|
2322
2381
|
this.killTree();
|
|
2323
2382
|
this.markDead();
|
|
@@ -2340,9 +2399,33 @@ var PtySession = class {
|
|
|
2340
2399
|
interruptTimer = setTimeout(() => detach(), dOpts.timeoutSec === void 0 ? 12e4 : dOpts.timeoutSec * 1e3);
|
|
2341
2400
|
});
|
|
2342
2401
|
};
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2402
|
+
return attempt();
|
|
2403
|
+
}
|
|
2404
|
+
/** §3.1(2026-10-25)interactive-stdin:向当前运行中的命令追加 stdin——只写既有通道
|
|
2405
|
+
* 与身份,不新建命令、账本条目或输出游标;不重连、不重放(dead/disposed 明确拒绝)。
|
|
2406
|
+
* 校验与写入全同步:多次 input 按调用顺序整段写入不交错;写入失败如实抛错(私有
|
|
2407
|
+
* write() 的静默忽略不适用于回执语义)。stdin:"" 是零字节写入,不是 EOF。 */
|
|
2408
|
+
input(options) {
|
|
2409
|
+
const fail = (code, hint) => Promise.reject(/* @__PURE__ */ new Error(`${code} remote: ${hint}`));
|
|
2410
|
+
if (this.state === "disposed") return fail("[E_SESSION_DISPOSED]", `pty session '${displayKey(this.opts.key)}' was disposed — input never reaches a disposed session`);
|
|
2411
|
+
if (this.state === "dead") return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' died — input never reconnects or replays; start a new command (exec reconnects a fresh shell) and re-send only what is still needed`);
|
|
2412
|
+
if (this.state === "starting") return fail("[E_SESSION_NOT_READY]", `pty session '${displayKey(this.opts.key)}' is still initializing — wait for the exec receipt, then input`);
|
|
2413
|
+
const active = this.activeCommand;
|
|
2414
|
+
if (active === null) return fail("[E_SESSION_NOT_RUNNING]", `pty session '${displayKey(this.opts.key)}' has no running command (state ${this.state}) — input writes into a command started by exec; it is never an implicit shell command`);
|
|
2415
|
+
if (options.commandId !== void 0 && options.commandId !== active.commandId) return fail("[E_COMMAND_MISMATCH]", `commandId ${JSON.stringify(options.commandId)} does not match the running command ${active.commandId} — input aimed at an earlier prompt is never written to the next command`);
|
|
2416
|
+
const stdin = this.proc?.stdin;
|
|
2417
|
+
if (stdin?.writable !== true) return fail("[E_SESSION_WRITE_FAILED]", `pty session '${displayKey(this.opts.key)}' has no writable stdin channel (state ${this.state}) — follow the running command via job_output for its settled result`);
|
|
2418
|
+
try {
|
|
2419
|
+
stdin.write(options.stdin);
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
return fail("[E_SESSION_WRITE_FAILED]", `writing stdin to pty session '${displayKey(this.opts.key)}' failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2422
|
+
}
|
|
2423
|
+
return Promise.resolve({
|
|
2424
|
+
session: displayKey(this.opts.key),
|
|
2425
|
+
commandId: active.commandId,
|
|
2426
|
+
...active.jobId !== void 0 ? { jobId: active.jobId } : {},
|
|
2427
|
+
writtenBytes: Buffer$1.byteLength(options.stdin, "utf8")
|
|
2428
|
+
});
|
|
2346
2429
|
}
|
|
2347
2430
|
/** TTL / row teardown:尽力 exit,1s 后杀组,出池。 */
|
|
2348
2431
|
dispose() {
|
|
@@ -2351,6 +2434,7 @@ var PtySession = class {
|
|
|
2351
2434
|
this.clearDeathTimer();
|
|
2352
2435
|
this.state = "disposed";
|
|
2353
2436
|
this.startPromise = null;
|
|
2437
|
+
this.detachedJob?.interrupt();
|
|
2354
2438
|
const proc = this.proc;
|
|
2355
2439
|
const close = this.procClose;
|
|
2356
2440
|
const group = this.reapGroup(proc?.pid, 1e3, close);
|
|
@@ -2361,7 +2445,7 @@ var PtySession = class {
|
|
|
2361
2445
|
]).then(() => {
|
|
2362
2446
|
if (this.proc === proc) this.proc = null;
|
|
2363
2447
|
});
|
|
2364
|
-
this.write("exit\n");
|
|
2448
|
+
if (this.detachedJob === null) this.write("exit\n");
|
|
2365
2449
|
this.opts.onDead?.();
|
|
2366
2450
|
return this.disposal;
|
|
2367
2451
|
}
|
|
@@ -2402,6 +2486,9 @@ var PtySession = class {
|
|
|
2402
2486
|
proc.stderr.setEncoding("utf8").on("data", (d) => {
|
|
2403
2487
|
this.diag = (this.diag + d).slice(-2e3);
|
|
2404
2488
|
});
|
|
2489
|
+
proc.stdin.on("error", (err) => {
|
|
2490
|
+
this.diag = (this.diag + `\n[remote: stdin write failed: ${err.message}]`).slice(-2e3);
|
|
2491
|
+
});
|
|
2405
2492
|
this.procClose = new Promise((resolve$1) => {
|
|
2406
2493
|
proc.once("close", () => {
|
|
2407
2494
|
this.markDead();
|
|
@@ -2669,7 +2756,15 @@ function parseTargetForm(target) {
|
|
|
2669
2756
|
form: "hub-pty",
|
|
2670
2757
|
machine: segs[0]
|
|
2671
2758
|
};
|
|
2672
|
-
|
|
2759
|
+
if (segs.length === 3 && segs[1] === "pty") {
|
|
2760
|
+
if (segs[2] === void 0 || segs[2].length === 0) throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — empty label after ":pty:"`);
|
|
2761
|
+
return {
|
|
2762
|
+
form: "hub-pty",
|
|
2763
|
+
machine: segs[0],
|
|
2764
|
+
label: segs[2]
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
throw new TargetFormatError(`[E_TARGET_FORMAT] remote: ${JSON.stringify(target)} — hub PTY is hub:<m4>:pty[:label]`);
|
|
2673
2768
|
}
|
|
2674
2769
|
for (const transport of TRANSPORTS) {
|
|
2675
2770
|
if (!target.startsWith(`${transport}:`)) continue;
|
|
@@ -3092,6 +3187,16 @@ function renderRoster(scan, entries, sessions, hubTargets) {
|
|
|
3092
3187
|
const REMOTE_PTY_IDLE_TTL_SEC = 900;
|
|
3093
3188
|
/** P3(user 2026-10-19):每 agent 10 个 PTY 会话,跨所有 connection 统一计;仅 error message 披露。 */
|
|
3094
3189
|
const REMOTE_PTY_SESSION_LIMIT = 10;
|
|
3190
|
+
/** hub 线 one-shot 监督披露(0.3.2-c):对端 = 我们自己的 hub server(bash -lc 于独立进程组,
|
|
3191
|
+
* Node 原生 TERM→2s→KILL + 输出环 + 租约生命周期);superd-run helper 是 dumb-pipe 专用物,
|
|
3192
|
+
* 从不经 hub 上传(non-goal,docs/10_plans/2026-10-25-hub-remote-parity.md §3 O1/O8)。 */
|
|
3193
|
+
const HUB_RUNNER = "hub-lease (peer bash -lc, peer Node supervision)";
|
|
3194
|
+
/** hub PTY 泵资产:pump 与本模块同目录(npm 包与 Super 闭包同构,helper.ts 同款纪律)。
|
|
3195
|
+
* ws 模块路径由 transport 侧(superd-hub 上下文,分叉的 proven 解析链)解析后经
|
|
3196
|
+
* ptyEndpoint 传入——闭包自身子树无可解析的 ws,按名解析不在闭包里做(0.3.2-c 定稿)。 */
|
|
3197
|
+
function hubPtyAssets() {
|
|
3198
|
+
return { pumpPath: join(dirname(fileURLToPath(import.meta.url)), "hub-pty-pump.mjs") };
|
|
3199
|
+
}
|
|
3095
3200
|
/** 账本键与 jobId 同构(同格式 `<kind>-<16hex>`);background 通道预传入 registry id 时两者字面相等。 */
|
|
3096
3201
|
const genCommandId = () => `remote-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
3097
3202
|
/** 双轨调度器:参数校验 → 目标语法(parseTargetForm)→ 统一 label 池 → oneshot 子进程 / pty 会话池。 */
|
|
@@ -3155,32 +3260,12 @@ var RemoteDriver = class {
|
|
|
3155
3260
|
}
|
|
3156
3261
|
async execute(params, form, callCtx) {
|
|
3157
3262
|
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
3158
|
-
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
3159
3263
|
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3160
3264
|
const hasCmd = params.cmd !== void 0 && params.cmd.length > 0;
|
|
3161
3265
|
params.spawn !== void 0 && params.spawn.length;
|
|
3162
|
-
if (form.form === "hub"
|
|
3266
|
+
if (form.form === "hub") {
|
|
3163
3267
|
const hub = this.requireHub();
|
|
3164
3268
|
this.hubProviders.add(hub);
|
|
3165
|
-
if (form.form === "hub-pty") {
|
|
3166
|
-
if (!hasCmd) throw new Error("[E_PARAMS] remote: hub PTY takes cmd");
|
|
3167
|
-
const result$1 = await hub.pty(form.machine, params.cmd, this.hubOwner, {
|
|
3168
|
-
stdin: params.stdin,
|
|
3169
|
-
timeout: timeoutSec
|
|
3170
|
-
});
|
|
3171
|
-
const out$1 = tailWindow(result$1.stdout, cap, result$1.outputStats?.stdout);
|
|
3172
|
-
return {
|
|
3173
|
-
...result$1,
|
|
3174
|
-
target: `hub:${form.machine}`,
|
|
3175
|
-
connection: `hub:${form.machine}`,
|
|
3176
|
-
stdout: `[hub:${form.machine} pty — no session id, no parallel, no background]\n${out$1.text}`,
|
|
3177
|
-
...out$1.stats ? { outputStats: {
|
|
3178
|
-
...result$1.outputStats,
|
|
3179
|
-
stdout: out$1.stats
|
|
3180
|
-
} } : {},
|
|
3181
|
-
...out$1.truncated === void 0 ? {} : { truncated: out$1.truncated }
|
|
3182
|
-
};
|
|
3183
|
-
}
|
|
3184
3269
|
const process$1 = this.own(await hub.start(form.machine, {
|
|
3185
3270
|
...params,
|
|
3186
3271
|
target: `hub:${form.machine}`,
|
|
@@ -3190,8 +3275,8 @@ var RemoteDriver = class {
|
|
|
3190
3275
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
3191
3276
|
const result = await process$1.done;
|
|
3192
3277
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
3193
|
-
const out =
|
|
3194
|
-
const err =
|
|
3278
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
3279
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
3195
3280
|
return {
|
|
3196
3281
|
...result,
|
|
3197
3282
|
mode: "oneshot",
|
|
@@ -3199,6 +3284,7 @@ var RemoteDriver = class {
|
|
|
3199
3284
|
connection: `hub:${form.machine}`,
|
|
3200
3285
|
cwd: null,
|
|
3201
3286
|
reconnected: false,
|
|
3287
|
+
runner: HUB_RUNNER,
|
|
3202
3288
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
3203
3289
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
3204
3290
|
...out.stats ? { outputStats: {
|
|
@@ -3215,7 +3301,7 @@ var RemoteDriver = class {
|
|
|
3215
3301
|
await session.warm();
|
|
3216
3302
|
return this.warmReceipt(session);
|
|
3217
3303
|
}
|
|
3218
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
3304
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3219
3305
|
}
|
|
3220
3306
|
if (params.ssh !== void 0) {
|
|
3221
3307
|
const label = form.form === "direct" || form.form === "bare" ? form.name : void 0;
|
|
@@ -3236,28 +3322,28 @@ var RemoteDriver = class {
|
|
|
3236
3322
|
kind: "ssh",
|
|
3237
3323
|
host: normalizeSshConnection(params.ssh).host,
|
|
3238
3324
|
connection: normalizeSshConnection(params.ssh)
|
|
3239
|
-
}, `ssh:${name$1}`, params, timeoutSec
|
|
3325
|
+
}, `ssh:${name$1}`, params, timeoutSec);
|
|
3240
3326
|
}
|
|
3241
3327
|
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");
|
|
3242
3328
|
if (form.form === "pty-selector") {
|
|
3243
3329
|
const session = this.resolvePty(form.idOrLabel, sessionKey);
|
|
3244
3330
|
if (!hasCmd) return this.cardReceipt(session);
|
|
3245
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
3331
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3246
3332
|
}
|
|
3247
3333
|
if (form.form === "direct") {
|
|
3248
3334
|
const plan$1 = form.transport === "ssh" ? this.sshPlanFor(form.name, sessionKey) : resolveTarget(`${form.transport}:${form.name}`);
|
|
3249
|
-
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec
|
|
3335
|
+
return await this.oneshot(plan$1, `${form.transport}:${form.name}`, params, timeoutSec);
|
|
3250
3336
|
}
|
|
3251
3337
|
if (form.form !== "bare") throw new Error("[E_INTERNAL] remote: unreachable target form after routing");
|
|
3252
3338
|
const entry = await this.resolvePoolEntry(form.name, sessionKey);
|
|
3253
3339
|
if (entry.kind === "pty") {
|
|
3254
3340
|
const session = this.resolvePty(form.name, sessionKey);
|
|
3255
3341
|
if (!hasCmd) return this.cardReceipt(session);
|
|
3256
|
-
return await this.dispatchPty(session, params, callCtx, timeoutSec
|
|
3342
|
+
return await this.dispatchPty(session, params, callCtx, timeoutSec);
|
|
3257
3343
|
}
|
|
3258
|
-
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec
|
|
3344
|
+
if (entry.kind === "hub") return await this.hubOneshot(entry.machine, `hub:${entry.machine}`, params, timeoutSec);
|
|
3259
3345
|
const plan = this.planOf(entry);
|
|
3260
|
-
return await this.oneshot(plan, form.name, params, timeoutSec
|
|
3346
|
+
return await this.oneshot(plan, form.name, params, timeoutSec);
|
|
3261
3347
|
}
|
|
3262
3348
|
/** Native-first copy routing (spec: remote-native-shortcut) — unchanged by the workbench round. */
|
|
3263
3349
|
copy(request, signal, callCtx = {}) {
|
|
@@ -3701,6 +3787,18 @@ var RemoteDriver = class {
|
|
|
3701
3787
|
...form.label !== void 0 ? { label: form.label } : {}
|
|
3702
3788
|
}, sessionKey) };
|
|
3703
3789
|
}
|
|
3790
|
+
if (form.form === "hub-pty") {
|
|
3791
|
+
const hub = this.requireHub();
|
|
3792
|
+
this.hubProviders.add(hub);
|
|
3793
|
+
const endpoint = hub.ptyEndpoint(form.machine);
|
|
3794
|
+
const argv = this.opts.hubPtyArgvFor !== void 0 ? this.opts.hubPtyArgvFor(endpoint) : buildHubPtyArgv(endpoint, hubPtyAssets());
|
|
3795
|
+
return { session: this.createSession({
|
|
3796
|
+
argv,
|
|
3797
|
+
connection: `hub:${form.machine}`,
|
|
3798
|
+
byo: false,
|
|
3799
|
+
...form.label !== void 0 ? { label: form.label } : {}
|
|
3800
|
+
}, sessionKey) };
|
|
3801
|
+
}
|
|
3704
3802
|
}
|
|
3705
3803
|
/** background PTY lane:会话先占位(不拨号)再进官方 jobs registry——首回执必须能寻址;
|
|
3706
3804
|
* registry 拒绝时把新建的会话拆掉(复用形的会话绝不拆)。返回 undefined = 走普通 oneshot job。 */
|
|
@@ -3788,15 +3886,50 @@ var RemoteDriver = class {
|
|
|
3788
3886
|
this.pool.get(key)?.attachJob(commandId, jobId);
|
|
3789
3887
|
}
|
|
3790
3888
|
}
|
|
3791
|
-
|
|
3889
|
+
/** §3.1(2026-10-25)interactive-stdin:input 动作——向已有 live PTY 的当前运行命令
|
|
3890
|
+
* 追加 stdin。只解析池内已存在的会话(pty:<id|label> / 裸 label 命中 pty 项),
|
|
3891
|
+
* 不触发发现、拨号、创建或重连;machine target 与创建形(:pty 后缀,含 hub:<m4>:pty)
|
|
3892
|
+
* 一律明确拒绝并给出可恢复指引。回执附 connection/label 供工具层渲染。 */
|
|
3893
|
+
async input(target, options, callCtx = {}) {
|
|
3894
|
+
this.assertOpen();
|
|
3895
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
3896
|
+
const form = parseTargetForm(target);
|
|
3897
|
+
let selector;
|
|
3898
|
+
if (form.form === "pty-selector") {
|
|
3899
|
+
if (form.idOrLabel === void 0) throw new Error("[E_PARAMS] remote: \"pty\" alone selects nothing — append an id/label (pty:pty-1a2b, pty:build)");
|
|
3900
|
+
selector = form.idOrLabel;
|
|
3901
|
+
} else if (form.form === "bare") {
|
|
3902
|
+
if (this.labels.resolve(form.name, sessionKey)?.kind !== "pty") throw new Error(`[E_NO_SESSION] remote: no live PTY session under ${JSON.stringify(form.name)} in this Agent's pool — input never dials, discovers or creates; run {action:"status"} to list sessions, then address one as pty:<id|label>`);
|
|
3903
|
+
selector = form.name;
|
|
3904
|
+
} else if (form.form === "create-pty" || form.form === "hub-pty") throw new Error(`[E_TARGET_FORMAT] remote: input writes into a live PTY session — "${target}" is a creation form; input never creates a session. Start the interactive program with {action:"exec", target:"${target}", cmd:…} first, then input into it`);
|
|
3905
|
+
else throw new Error(`[E_TARGET_FORMAT] remote: input writes into a live PTY session — "${target}" is a machine target and machines are dialed per call. Run the interactive program with exec on a :pty session (e.g. {action:"exec", target:"ssh:dev4:pty" or "hub:<m4>:pty", cmd:…}), then input into that session`);
|
|
3906
|
+
const session = this.resolvePty(selector, sessionKey);
|
|
3907
|
+
const receipt = await session.input(options);
|
|
3908
|
+
const id = session.identity;
|
|
3909
|
+
return {
|
|
3910
|
+
...receipt,
|
|
3911
|
+
connection: id.connection,
|
|
3912
|
+
...id.label !== void 0 ? { label: id.label } : {}
|
|
3913
|
+
};
|
|
3914
|
+
}
|
|
3915
|
+
async dispatchPty(session, params, callCtx, timeoutSec) {
|
|
3916
|
+
const hubConn = session.identity.connection;
|
|
3917
|
+
if (hubConn.startsWith("hub:")) {
|
|
3918
|
+
const hub = this.requireHub();
|
|
3919
|
+
this.hubProviders.add(hub);
|
|
3920
|
+
hub.ptyEndpoint(hubConn.slice(4));
|
|
3921
|
+
}
|
|
3792
3922
|
const commandId = callCtx.commandId ?? genCommandId();
|
|
3793
3923
|
const r = await session.dispatch(params.cmd, {
|
|
3794
3924
|
stdin: params.stdin,
|
|
3795
3925
|
timeoutSec,
|
|
3796
3926
|
commandId,
|
|
3797
|
-
...callCtx.ptyBackground === true ? {
|
|
3927
|
+
...callCtx.ptyBackground === true ? {
|
|
3928
|
+
background: true,
|
|
3929
|
+
jobId: commandId
|
|
3930
|
+
} : {}
|
|
3798
3931
|
});
|
|
3799
|
-
const out =
|
|
3932
|
+
const out = disclose(r.output, r.outputStats?.stdout);
|
|
3800
3933
|
const id = session.identity;
|
|
3801
3934
|
return {
|
|
3802
3935
|
mode: "pty",
|
|
@@ -3864,7 +3997,7 @@ var RemoteDriver = class {
|
|
|
3864
3997
|
...this.ptyTtl(session.key)
|
|
3865
3998
|
};
|
|
3866
3999
|
}
|
|
3867
|
-
async oneshot(plan, display, params, timeoutSec
|
|
4000
|
+
async oneshot(plan, display, params, timeoutSec) {
|
|
3868
4001
|
this.assertOpen();
|
|
3869
4002
|
const process$1 = this.own(await this.nativeProcess(plan, params, timeoutSec));
|
|
3870
4003
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
@@ -3879,8 +4012,8 @@ var RemoteDriver = class {
|
|
|
3879
4012
|
stderr: stderr.stats
|
|
3880
4013
|
}
|
|
3881
4014
|
};
|
|
3882
|
-
const out =
|
|
3883
|
-
const err = r.stderr.length > 0 ?
|
|
4015
|
+
const out = disclose(r.stdout, r.outputStats?.stdout);
|
|
4016
|
+
const err = r.stderr.length > 0 ? disclose(r.stderr, r.outputStats?.stderr) : void 0;
|
|
3884
4017
|
return {
|
|
3885
4018
|
mode: "oneshot",
|
|
3886
4019
|
target: display,
|
|
@@ -3899,13 +4032,13 @@ var RemoteDriver = class {
|
|
|
3899
4032
|
...this.runnerOf.get(process$1) === void 0 ? {} : { runner: this.runnerOf.get(process$1) }
|
|
3900
4033
|
};
|
|
3901
4034
|
}
|
|
3902
|
-
async hubOneshot(machine, display, params, timeoutSec
|
|
4035
|
+
async hubOneshot(machine, display, params, timeoutSec) {
|
|
3903
4036
|
const process$1 = this.own(await this.hubOneshotProcess(machine, params));
|
|
3904
4037
|
if (this.disposed) process$1.cancel("owner disposed");
|
|
3905
4038
|
const result = await process$1.done;
|
|
3906
4039
|
const stdout = process$1.read("stdout", 0), stderr = process$1.read("stderr", 0);
|
|
3907
|
-
const out =
|
|
3908
|
-
const err =
|
|
4040
|
+
const out = disclose(stdout.text, stdout.stats);
|
|
4041
|
+
const err = disclose(stderr.text, stderr.stats);
|
|
3909
4042
|
return {
|
|
3910
4043
|
...result,
|
|
3911
4044
|
mode: "oneshot",
|
|
@@ -3913,6 +4046,7 @@ var RemoteDriver = class {
|
|
|
3913
4046
|
connection: display,
|
|
3914
4047
|
cwd: null,
|
|
3915
4048
|
reconnected: false,
|
|
4049
|
+
runner: HUB_RUNNER,
|
|
3916
4050
|
stdout: (!stdout.stats && stdout.lossy ? "[truncated: peer returned only a tail; exact total/retained/dropped unavailable]\n" : "") + out.text,
|
|
3917
4051
|
stderr: [err.text, stderr.diagnostic].filter(Boolean).join("\n"),
|
|
3918
4052
|
...out.stats ? { outputStats: {
|
|
@@ -4070,16 +4204,20 @@ const ptyOutputSources = (holder) => [{
|
|
|
4070
4204
|
lossy: false
|
|
4071
4205
|
}
|
|
4072
4206
|
}];
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4207
|
+
/** 0.3.2-d CF:timedOut=true 且 aborted=false = 会话侧主动收束(close force / 行回收走的
|
|
4208
|
+
* dispose→interrupt 两段路)——同为 killed;exit 到手用 exit,只剩会话死亡时写 session closed。 */
|
|
4209
|
+
const ptyOutcome = (aborted, result) => {
|
|
4210
|
+
const interrupted = aborted || result.timedOut === true;
|
|
4211
|
+
return {
|
|
4212
|
+
status: interrupted ? "killed" : result.exit === null ? "failed" : "completed",
|
|
4213
|
+
detail: interrupted ? `interrupted: ${result.exit === null ? aborted ? "session died" : "session closed" : `exit ${result.exit}`}` : result.exit === null ? "session died" : `exit code: ${result.exit}`
|
|
4214
|
+
};
|
|
4215
|
+
};
|
|
4077
4216
|
/** No preparation or discovery before the official registry's admission. */
|
|
4078
4217
|
function startRemoteJob(registry, input, exec, driver) {
|
|
4079
4218
|
if (!exec.agent) throw new Error("remote: background jobs require a live Agent owner");
|
|
4080
4219
|
const form = parseTargetForm(input.target);
|
|
4081
4220
|
if (form.form === "pty-selector" || form.form === "create-pty" || form.form === "hub-pty" || input.spawn !== void 0 || input.mode === "pty") {
|
|
4082
|
-
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");
|
|
4083
4221
|
const holder = {};
|
|
4084
4222
|
return registry.start({
|
|
4085
4223
|
kind: "remote",
|
|
@@ -4205,116 +4343,157 @@ function adoptRunningPtyJob(registry, exec, label, job) {
|
|
|
4205
4343
|
|
|
4206
4344
|
//#endregion
|
|
4207
4345
|
//#region src/remote/tool.ts
|
|
4208
|
-
const NO_SESSION_KEY = "remote:no-session";
|
|
4346
|
+
const NO_SESSION_KEY$1 = "remote:no-session";
|
|
4209
4347
|
/**
|
|
4210
|
-
* The model-visible surface of the `remote` tool (
|
|
4211
|
-
*
|
|
4348
|
+
* The model-visible surface of the `remote` tool (0.3.2-d user-ruled restructure:
|
|
4349
|
+
* Actions / Targets / PTY / Oneshot / Examples — the PTY block is the user's verbatim
|
|
4350
|
+
* final draft; Examples receipts match renderValue byte-for-byte).
|
|
4351
|
+
* ONE template literal — no backticks, no "${".
|
|
4352
|
+
*
|
|
4353
|
+
* 语义目标(surface 就是产品):动作是唯一的派发维度;PTY 四词定位——stateful oneshot /
|
|
4354
|
+
* interact-able stdin / managed output / persistent env——回执形状与 oneshot 相同、只增状态,
|
|
4355
|
+
* 运行中的命令可分步观察后再补输(input 动作);多步与交互任务默认进 PTY。
|
|
4212
4356
|
*/
|
|
4213
|
-
const REMOTE_TOOL_DESCRIPTION = `Remote
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4357
|
+
const REMOTE_TOOL_DESCRIPTION = `Remote shell over ssh / docker / incus (Hub when configured): stateless oneshots + persistent interactive PTY sessions. Default to PTY for multi-step or interactive work; oneshot for isolated commands.
|
|
4358
|
+
|
|
4359
|
+
Actions (action is the only dispatch dimension; unknown fields are rejected):
|
|
4360
|
+
- exec {target, cmd | scriptPath, stdin?, timeout?, run_in_background?}: run a command. Machine target → oneshot; pty:<id|label> → run inside that live session; <machine>:pty[:label] → create a NEW session and run there.
|
|
4361
|
+
- add {ssh:{...} | spawn:"<cmd>", target?}: register a BYO endpoint (identity or interactive-shell command); no command runs. Returns the addressable label; a taken label answers [E_LABEL_IN_USE] — address existing names directly, never add again.
|
|
4362
|
+
- status {}: roster — the one unified pool (machines, identities, live PTY sessions).
|
|
4363
|
+
- status {target}: machine probe, or a session card (connection, cwd, ledger of the last 20 commands).
|
|
4364
|
+
- input {target:"pty:<id|label>", stdin, commandId?}: write stdin into the command currently running in that session — passwords, OTP, tokens, menu choices, REPL lines. Never creates a session, never dials.
|
|
4365
|
+
- close {target, force?}: close a PTY session; a running command refuses it (job_kill first) unless force:true kills it in the same step.
|
|
4366
|
+
|
|
4367
|
+
Targets:
|
|
4368
|
+
- ssh:<name> / docker:<name> / incus:<name> / bare pool name → stateless oneshot.
|
|
4369
|
+
- <machine>:pty[:label] → NEW PTY every call — creation is a target suffix, not a different tool.
|
|
4370
|
+
- pty:<id|label> (or its bare label) → REUSE the live session.
|
|
4371
|
+
After creation, reuse the returned session; repeating :pty opens another. Prefixes force the channel; bare names resolve through the roster.
|
|
4372
|
+
|
|
4373
|
+
PTY:
|
|
4374
|
+
- stateful oneshot, interact-able stdin, managed output, persistent env
|
|
4375
|
+
- timeout (default 120s) → command keeps running; returns job handle.
|
|
4376
|
+
- run_in_background → same job lane. jobId = commandId.
|
|
4377
|
+
- Read: job_output(jobId). Cancel: job_kill; try Ctrl-C first.
|
|
4378
|
+
- Busy session: input / poll / cancel / use another session.
|
|
4379
|
+
- exec.stdin = startup input; input = subsequent input.
|
|
4380
|
+
- input is verbatim: "\\n" submits; "\\u0003" Ctrl-C; "\\u0004" Ctrl-D; "" writes nothing. Ctrl-D follows terminal EOF rules.
|
|
4381
|
+
- commandId, if supplied, must match running command.
|
|
4382
|
+
- Input receipt confirms write only; read result via SAME job.
|
|
4383
|
+
- Output returns in full up to retention (4M chars per stream; Hub oneshot 4MiB bytes) — beyond it the retained tail plus outputStats. Redirect bulk output to files.
|
|
4384
|
+
- Idle expiry: 15 min; running commands exempt.
|
|
4385
|
+
|
|
4386
|
+
Oneshot:
|
|
4387
|
+
- stdout/stderr separate, exit code verbatim; no retained shell state. stdin is startup-only: there is no input after start — a prompt on a oneshot means cancel (job_kill) and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives.
|
|
4388
|
+
- Supervision prefers a pinned superd-run helper cached on the endpoint (~/.cache/superd/run), falls back to a POSIX shim — the receipt field "runner" says which ran. Either way children are reaped on completion and the whole group is killed on timeout/cancel. The endpoint needs bash only — no Python.
|
|
4389
|
+
- scriptPath (oneshot only): a LOCAL absolute script read before any dialing, sent literally over stdin to a fixed bash -s — the file carrier for multi-line scripts; pairs with neither cmd nor stdin.
|
|
4390
|
+
|
|
4391
|
+
Examples
|
|
4392
|
+
|
|
4393
|
+
Manage the persistent PTY session. One-shot style output, but stable: every command returns synchronously with exit, cwd, and an env delta.
|
|
4394
|
+
|
|
4395
|
+
No direct route to the endpoint? Compose the transport and land the session on the innermost machine — bring your own PTY. Here: borrow pc as a jump host to reach dev4.
|
|
4396
|
+
|
|
4397
|
+
remote { "action": "add", "spawn": "ssh -t pc ssh -t dev4", "target": "pty:dev4via" }
|
|
4225
4398
|
|
|
4226
|
-
|
|
4399
|
+
[session created — address it as pty:pty-irsq]
|
|
4400
|
+
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · idle-left ~15m · exit null · 0.0s]
|
|
4227
4401
|
|
|
4228
|
-
|
|
4402
|
+
remote { "action": "exec", "target": "pty:dev4via", "cmd": "hostname && sudo -n ss -tlnp | awk 'NR==1 || /LISTEN/' | head -5" }
|
|
4229
4403
|
|
|
4230
|
-
|
|
4404
|
+
dev4
|
|
4405
|
+
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
|
4406
|
+
LISTEN 0 4096 127.0.0.1:45207 0.0.0.0:* users:(("containerd",pid=1070,fd=14))
|
|
4407
|
+
LISTEN 0 4096 127.0.0.1:2019 0.0.0.0:* users:(("caddy",pid=5600,fd=4))
|
|
4408
|
+
LISTEN 0 4096 127.0.0.1:19514 0.0.0.0:* users:(("multica",pid=27030,fd=6))
|
|
4409
|
+
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:* users:(("systemd-resolve",pid=26758,fd=17))
|
|
4410
|
+
[pty:pty-irsq (dev4via) (byo-pty) · conn BYOP · cmd remote-0d89215d1cc64cdc · cwd /home/u1 · idle-left ~15m · exit 0 · 0.3s]
|
|
4411
|
+
|
|
4412
|
+
Reuse the same session for a long task — returns a jobId immediately; collect with job_output, cancel with job_kill:
|
|
4413
|
+
|
|
4414
|
+
remote { "action": "exec", "target": "pty:dev4via", "cmd": "for i in 1 2 3; do sleep 1; echo \\"tick $i\\"; done; echo done", "run_in_background": true }
|
|
4415
|
+
|
|
4416
|
+
Started remote-47676dff2c6443e8 on pty:pty-irsq (dev4via) (BYOP). Use job_output to read and job_kill to cancel; completion is reported by the host.
|
|
4417
|
+
|
|
4418
|
+
job_output { "job_id": "remote-47676dff2c6443e8", "wait": true }
|
|
4419
|
+
|
|
4420
|
+
tick 1
|
|
4421
|
+
tick 2
|
|
4422
|
+
tick 3
|
|
4423
|
+
done
|
|
4424
|
+
[status: completed, exit code: 0]
|
|
4425
|
+
|
|
4426
|
+
- Bring Your Own PTY fits when the endpoint has no direct route, or the connection/transport layer must be composed yourself (jump chains, docker exec, console servers).
|
|
4427
|
+
- It also strips one or two shell-escaping layers — commands go straight to the innermost bash, quotes behave naturally.
|
|
4428
|
+
|
|
4429
|
+
Register once — the label enters the pool and works everywhere: exec, pty, copy, rsync. Worth it whenever more than one operation type is involved. Keys stay local-file references; nothing persisted.
|
|
4430
|
+
|
|
4431
|
+
remote { "action": "add", "ssh": { "host": "10.0.0.8", "user": "u1", "port": 22, "key": "/home/u1/.ssh/id_key4" }, "target": "ssh:lab" }
|
|
4432
|
+
remote { "action": "exec", "target": "ssh:lab", "cmd": "uname -a" }
|
|
4433
|
+
remote_transfer { "mode": "rsync", "src": "ssh:lab:/var/log/app/", "dst": "/home/u1/app-logs/", "argv": ["-a"] }`;
|
|
4434
|
+
/** action 是唯一派发维度:每个动词声明自己的字段集,超出即报错(不做在场推断、不留别名)。 */
|
|
4435
|
+
const ACTION_PARAMS = {
|
|
4436
|
+
exec: new Set([
|
|
4437
|
+
"action",
|
|
4438
|
+
"target",
|
|
4439
|
+
"cmd",
|
|
4440
|
+
"stdin",
|
|
4441
|
+
"scriptPath",
|
|
4442
|
+
"timeout",
|
|
4443
|
+
"run_in_background"
|
|
4444
|
+
]),
|
|
4445
|
+
add: new Set([
|
|
4446
|
+
"action",
|
|
4447
|
+
"target",
|
|
4448
|
+
"ssh",
|
|
4449
|
+
"spawn"
|
|
4450
|
+
]),
|
|
4451
|
+
status: new Set(["action", "target"]),
|
|
4452
|
+
close: new Set([
|
|
4453
|
+
"action",
|
|
4454
|
+
"target",
|
|
4455
|
+
"force"
|
|
4456
|
+
]),
|
|
4457
|
+
input: new Set([
|
|
4458
|
+
"action",
|
|
4459
|
+
"target",
|
|
4460
|
+
"stdin",
|
|
4461
|
+
"commandId"
|
|
4462
|
+
])
|
|
4463
|
+
};
|
|
4464
|
+
const ACTION_HINT = {
|
|
4465
|
+
exec: "exec takes target plus cmd (or scriptPath) and the execution options; ssh/spawn belong to add",
|
|
4466
|
+
add: "add takes exactly one of ssh or spawn, plus an optional target label",
|
|
4467
|
+
status: "status takes only an optional target — omit it for the roster",
|
|
4468
|
+
close: "close takes target pty:<id|label> and optional force",
|
|
4469
|
+
input: "input takes target (a live PTY session), stdin, and an optional commandId guard — no cmd, no timeout, no creation"
|
|
4470
|
+
};
|
|
4231
4471
|
async function remoteExecute(ENV, args, exec) {
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
"force",
|
|
4248
|
-
"src",
|
|
4249
|
-
"dst",
|
|
4250
|
-
"argv",
|
|
4251
|
-
"pullArgv",
|
|
4252
|
-
"pushArgv",
|
|
4253
|
-
"verify",
|
|
4254
|
-
"mkdir",
|
|
4255
|
-
"run_in_background",
|
|
4256
|
-
"target",
|
|
4257
|
-
"ssh",
|
|
4258
|
-
"spawn",
|
|
4259
|
-
"cmd",
|
|
4260
|
-
"scriptPath",
|
|
4261
|
-
"stdin",
|
|
4262
|
-
"timeout"
|
|
4263
|
-
]);
|
|
4264
|
-
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`);
|
|
4265
|
-
if (args.action === "rsync") {
|
|
4266
|
-
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
4267
|
-
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");
|
|
4268
|
-
const start = Date.now();
|
|
4269
|
-
const receipt$1 = await ENV.driver.rsync({
|
|
4270
|
-
src: args.src,
|
|
4271
|
-
dst: args.dst,
|
|
4272
|
-
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
4273
|
-
}, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
|
|
4274
|
-
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
4275
|
-
const rsyncDuration = Date.now() - start;
|
|
4276
|
-
return {
|
|
4277
|
-
kind: "rsync",
|
|
4278
|
-
text: formatTransferLine("rsync", receipt$1, rsyncDuration),
|
|
4279
|
-
receipt: receipt$1,
|
|
4280
|
-
exit: receipt$1.exit,
|
|
4281
|
-
durationMs: rsyncDuration
|
|
4282
|
-
};
|
|
4283
|
-
}
|
|
4284
|
-
const copyStart = Date.now();
|
|
4285
|
-
const receipt = await ENV.driver.copy({
|
|
4286
|
-
src: args.src,
|
|
4287
|
-
dst: args.dst,
|
|
4288
|
-
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
4289
|
-
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
4290
|
-
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
4291
|
-
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
4292
|
-
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
4293
|
-
}, exec.signal, { sessionKey: ENV.getSessionKey(exec) });
|
|
4294
|
-
const asReceipt = receipt;
|
|
4295
|
-
const exits = asReceipt.exits ?? [];
|
|
4296
|
-
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
4297
|
-
const copyDuration = Date.now() - copyStart;
|
|
4472
|
+
const action = args.action;
|
|
4473
|
+
if (action === void 0) throw new Error("[E_PARAMS] remote: action is required — \"exec\" (run a command), \"add\" (register a BYO endpoint), \"status\" (roster / probe / session card), \"close\" (close a PTY session), \"input\" (append stdin to a running PTY command)");
|
|
4474
|
+
const allowed = ACTION_PARAMS[action];
|
|
4475
|
+
if (allowed === void 0) throw new Error(`[E_PARAMS] remote: unknown action ${JSON.stringify(String(action))} — one of exec / add / close / status / input`);
|
|
4476
|
+
for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`[E_PARAMS] remote: "${key}" does not belong to action "${action}" — ${ACTION_HINT[action]}`);
|
|
4477
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
4478
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
4479
|
+
const hasCmd = typeof args.cmd === "string" && args.cmd.length > 0;
|
|
4480
|
+
if (action === "status") {
|
|
4481
|
+
if (!hasTarget) return {
|
|
4482
|
+
kind: "roster",
|
|
4483
|
+
text: await ENV.driver.roster({ sessionKey }),
|
|
4484
|
+
exit: null,
|
|
4485
|
+
durationMs: 0
|
|
4486
|
+
};
|
|
4298
4487
|
return {
|
|
4299
|
-
kind: "
|
|
4300
|
-
text:
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
durationMs: copyDuration
|
|
4488
|
+
kind: "status",
|
|
4489
|
+
text: await ENV.driver.status(args.target, { sessionKey }),
|
|
4490
|
+
exit: null,
|
|
4491
|
+
durationMs: 0
|
|
4304
4492
|
};
|
|
4305
4493
|
}
|
|
4306
|
-
if (
|
|
4307
|
-
if (
|
|
4308
|
-
|
|
4309
|
-
args.cmd,
|
|
4310
|
-
args.scriptPath,
|
|
4311
|
-
args.stdin,
|
|
4312
|
-
args.spawn,
|
|
4313
|
-
args.ssh,
|
|
4314
|
-
args.timeout,
|
|
4315
|
-
args.run_in_background
|
|
4316
|
-
].some((value) => value !== void 0)) throw new Error("[E_PARAMS] close cannot be combined with execution/PTY/job arguments");
|
|
4317
|
-
const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, ENV.getSessionKey(exec), args.force === true);
|
|
4494
|
+
if (action === "close") {
|
|
4495
|
+
if (!hasTarget) throw new Error("[E_PARAMS] close requires target pty:<id|label>");
|
|
4496
|
+
const r$1 = ENV.driver.closePty(args.target.startsWith("pty:") ? args.target.slice(4) : args.target, sessionKey, args.force === true);
|
|
4318
4497
|
return {
|
|
4319
4498
|
kind: "close",
|
|
4320
4499
|
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"}`,
|
|
@@ -4326,42 +4505,71 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4326
4505
|
killed: r$1.killed
|
|
4327
4506
|
};
|
|
4328
4507
|
}
|
|
4329
|
-
if (
|
|
4330
|
-
|
|
4331
|
-
args.
|
|
4332
|
-
args.
|
|
4333
|
-
args.
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4508
|
+
if (action === "input") {
|
|
4509
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"input\" requires target — the live PTY session whose command is waiting (pty:<id|label> or its bare label)");
|
|
4510
|
+
if (args.stdin === void 0) throw new Error("[E_PARAMS] remote: action \"input\" requires stdin — the exact text to append; include a trailing \\n to submit a line, \"\\u0003\" for Ctrl-C, \"\\u0004\" for Ctrl-D (\"\" = zero bytes, not EOF)");
|
|
4511
|
+
if (typeof args.commandId === "string" && args.commandId.length === 0) throw new Error("[E_PARAMS] remote: input: commandId, when given, must be the non-empty id from the exec receipt / job_output");
|
|
4512
|
+
const r$1 = await ENV.driver.input(args.target, {
|
|
4513
|
+
stdin: args.stdin,
|
|
4514
|
+
...args.commandId !== void 0 ? { commandId: args.commandId } : {}
|
|
4515
|
+
}, { sessionKey });
|
|
4516
|
+
const handle = r$1.jobId ?? r$1.commandId;
|
|
4517
|
+
return {
|
|
4518
|
+
kind: "input",
|
|
4519
|
+
text: `wrote ${r$1.writtenBytes} byte${r$1.writtenBytes === 1 ? "" : "s"} into ${r$1.session} for command ${r$1.commandId} — this confirms the write, not the program's response; read the response with job_output ${handle}`,
|
|
4520
|
+
exit: null,
|
|
4521
|
+
durationMs: 0,
|
|
4522
|
+
session: r$1.session,
|
|
4523
|
+
connection: r$1.connection,
|
|
4524
|
+
...r$1.label !== void 0 ? { label: r$1.label } : {},
|
|
4525
|
+
commandId: r$1.commandId,
|
|
4526
|
+
...r$1.jobId !== void 0 ? { jobId: r$1.jobId } : {},
|
|
4527
|
+
writtenBytes: r$1.writtenBytes
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
4530
|
+
if (action === "add") {
|
|
4531
|
+
const hasSpawn = typeof args.spawn === "string" && args.spawn.length > 0;
|
|
4532
|
+
if (args.spawn !== void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: add: spawn must be a non-empty command string");
|
|
4533
|
+
if (args.ssh === void 0 && !hasSpawn) throw new Error("[E_PARAMS] remote: action \"add\" registers a BYO endpoint — pass ssh:{host,...} (identity) or spawn:\"<command>\" (PTY), exactly one");
|
|
4534
|
+
if (args.ssh !== void 0 && hasSpawn) throw new Error("[E_PARAMS] remote: add: ssh and spawn are two different BYO entry points — pass one, not both");
|
|
4535
|
+
const r$1 = await ENV.driver.call({
|
|
4536
|
+
...hasTarget ? { target: args.target } : {},
|
|
4537
|
+
...args.ssh === void 0 ? {} : { ssh: args.ssh },
|
|
4538
|
+
...hasSpawn ? { spawn: args.spawn } : {},
|
|
4539
|
+
cmd: ""
|
|
4540
|
+
}, { sessionKey });
|
|
4541
|
+
const v$1 = workbenchValue(r$1);
|
|
4542
|
+
v$1.created = true;
|
|
4543
|
+
if (!hasSpawn) v$1.notice = `registered — address it as ${r$1.session ?? r$1.target} from now on (the label, id and ssh alias all work)`;
|
|
4544
|
+
return v$1;
|
|
4545
|
+
}
|
|
4546
|
+
if (!hasTarget) throw new Error("[E_PARAMS] remote: action \"exec\" requires target — a machine (ssh:dev4, docker:corti, hub:<m4>, a pool name) or a PTY session (pty:<id|label>, a bare label, or <machine>:pty[:label] to create one). Status probes and the roster live under action \"status\".");
|
|
4547
|
+
const target = args.target;
|
|
4548
|
+
const ptyTarget = /^pty:/.test(target) || /:pty(:|$)/.test(target);
|
|
4549
|
+
if (args.scriptPath !== void 0) {
|
|
4550
|
+
if (args.scriptPath.length === 0) throw new Error("[E_PARAMS] remote: scriptPath must be a non-empty absolute path");
|
|
4551
|
+
if (ptyTarget) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath is oneshot-only — PTY lanes take cmd");
|
|
4552
|
+
if (hasCmd) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath and cmd are alternative carriers — pass one");
|
|
4553
|
+
if (args.stdin !== void 0) throw new Error("[E_SCRIPT_PARAMS] remote: scriptPath already occupies stdin — do not pass stdin with it");
|
|
4554
|
+
}
|
|
4555
|
+
if (args.stdin !== void 0 && !hasCmd) throw new Error("[E_STDIN_WITHOUT_CMD] remote: exec stdin is startup input and requires cmd — to feed a command that is already running in a PTY, use {action:\"input\", target:\"pty:<id|label>\", stdin}");
|
|
4556
|
+
if (!hasCmd && args.scriptPath === void 0) throw new Error("[E_PARAMS] remote: action \"exec\" must execute something — pass cmd, or scriptPath for a local script file");
|
|
4344
4557
|
if (args.run_in_background) {
|
|
4345
|
-
if (!hasCmd && args.scriptPath === void 0) throw new Error("remote: background requires cmd or scriptPath");
|
|
4346
4558
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
4347
4559
|
if (!jobs) throw new Error("remote: background requires the official DSH jobs service and job_output/job_kill controller; foreground execution remains available");
|
|
4348
4560
|
const lane = await ENV.driver.preparePtyLane({
|
|
4349
4561
|
target: args.target,
|
|
4350
|
-
spawn: args.spawn,
|
|
4351
4562
|
cmd: args.cmd,
|
|
4352
4563
|
scriptPath: args.scriptPath,
|
|
4353
|
-
ssh: args.ssh,
|
|
4354
4564
|
stdin: args.stdin,
|
|
4355
4565
|
timeout: args.timeout
|
|
4356
|
-
}, { sessionKey
|
|
4566
|
+
}, { sessionKey });
|
|
4357
4567
|
let jobId;
|
|
4358
4568
|
try {
|
|
4359
4569
|
jobId = startRemoteJob(jobs, lane !== void 0 ? lane.input : {
|
|
4360
4570
|
target: args.target,
|
|
4361
|
-
spawn: args.spawn,
|
|
4362
4571
|
cmd: args.cmd,
|
|
4363
4572
|
scriptPath: args.scriptPath,
|
|
4364
|
-
ssh: args.ssh,
|
|
4365
4573
|
stdin: args.stdin,
|
|
4366
4574
|
timeout: args.timeout
|
|
4367
4575
|
}, exec, ENV.driver);
|
|
@@ -4377,44 +4585,20 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4377
4585
|
durationMs: 0,
|
|
4378
4586
|
text: `Started ${jobId}${where}. Use job_output to read and job_kill to cancel; completion is reported by the host.`,
|
|
4379
4587
|
...lane !== void 0 ? {
|
|
4588
|
+
commandId: jobId,
|
|
4380
4589
|
session: lane.session,
|
|
4381
4590
|
connection: lane.connection,
|
|
4382
4591
|
...lane.label !== void 0 ? { label: lane.label } : {}
|
|
4383
4592
|
} : {}
|
|
4384
4593
|
};
|
|
4385
4594
|
}
|
|
4386
|
-
if (!hasCmd && args.scriptPath === void 0) {
|
|
4387
|
-
if (args.stdin !== void 0) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input");
|
|
4388
|
-
const t = args.target ?? "";
|
|
4389
|
-
if (hasSpawn || args.ssh !== void 0 || /^pty:/.test(t) || /:pty(:|$)/.test(t) && !t.startsWith("hub:")) return workbenchValue(await ENV.driver.call({
|
|
4390
|
-
target: args.target,
|
|
4391
|
-
spawn: args.spawn,
|
|
4392
|
-
ssh: args.ssh,
|
|
4393
|
-
cmd: "",
|
|
4394
|
-
stdin: void 0
|
|
4395
|
-
}, { sessionKey: ENV.getSessionKey(exec) }));
|
|
4396
|
-
if (!hasTarget) return {
|
|
4397
|
-
kind: "roster",
|
|
4398
|
-
text: await ENV.driver.roster({ sessionKey: ENV.getSessionKey(exec) }),
|
|
4399
|
-
exit: null,
|
|
4400
|
-
durationMs: 0
|
|
4401
|
-
};
|
|
4402
|
-
return {
|
|
4403
|
-
kind: "status",
|
|
4404
|
-
text: await ENV.driver.status(args.target, { sessionKey: ENV.getSessionKey(exec) }),
|
|
4405
|
-
exit: null,
|
|
4406
|
-
durationMs: 0
|
|
4407
|
-
};
|
|
4408
|
-
}
|
|
4409
4595
|
const r = await ENV.driver.call({
|
|
4410
4596
|
target: args.target,
|
|
4411
|
-
spawn: args.spawn,
|
|
4412
|
-
ssh: args.ssh,
|
|
4413
4597
|
cmd: args.cmd,
|
|
4414
4598
|
scriptPath: args.scriptPath,
|
|
4415
4599
|
stdin: args.stdin,
|
|
4416
4600
|
timeout: args.timeout
|
|
4417
|
-
}, { sessionKey
|
|
4601
|
+
}, { sessionKey });
|
|
4418
4602
|
const v = workbenchValue(r);
|
|
4419
4603
|
if (r.running === true && r.ptyJob !== void 0) {
|
|
4420
4604
|
const jobs = exec.agent?.ctx.get("jobs");
|
|
@@ -4422,7 +4606,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4422
4606
|
const jobId = adoptRunningPtyJob(jobs, exec, `${r.target}: ${args.cmd}`, r.ptyJob);
|
|
4423
4607
|
v.jobId = jobId;
|
|
4424
4608
|
v.running = true;
|
|
4425
|
-
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId,
|
|
4609
|
+
if (r.commandId !== void 0 && jobId !== r.commandId) ENV.driver.attachPtyJob(r.commandId, jobId, sessionKey);
|
|
4426
4610
|
} else {
|
|
4427
4611
|
r.ptyJob.interrupt();
|
|
4428
4612
|
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.";
|
|
@@ -4431,7 +4615,7 @@ async function remoteExecute(ENV, args, exec) {
|
|
|
4431
4615
|
return v;
|
|
4432
4616
|
}
|
|
4433
4617
|
function createRemoteTool(driver, opts = {}) {
|
|
4434
|
-
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
4618
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY$1);
|
|
4435
4619
|
const tool = defineTool({
|
|
4436
4620
|
name: "remote",
|
|
4437
4621
|
description: REMOTE_TOOL_DESCRIPTION,
|
|
@@ -4439,58 +4623,42 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4439
4623
|
action: {
|
|
4440
4624
|
type: "string",
|
|
4441
4625
|
enum: [
|
|
4442
|
-
"
|
|
4443
|
-
"
|
|
4444
|
-
"
|
|
4626
|
+
"exec",
|
|
4627
|
+
"add",
|
|
4628
|
+
"status",
|
|
4629
|
+
"close",
|
|
4630
|
+
"input"
|
|
4445
4631
|
],
|
|
4446
|
-
|
|
4632
|
+
required: true,
|
|
4633
|
+
description: "Required. exec = run a command (oneshot on a machine, or inside a PTY session); add = register a BYO ssh identity or BYO PTY into the pool; status = roster (no target) or probe/session card (target); close = close a PTY session; input = append stdin to the command currently running in a live PTY session."
|
|
4447
4634
|
},
|
|
4448
|
-
|
|
4449
|
-
type: "boolean",
|
|
4450
|
-
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."
|
|
4451
|
-
},
|
|
4452
|
-
src: {
|
|
4635
|
+
target: {
|
|
4453
4636
|
type: "string",
|
|
4454
|
-
description: "
|
|
4637
|
+
description: "exec: required. A machine (bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>), a live PTY session (pty:<id|label> or its bare label), or a machine with :pty[:label] to create a new session (ssh:dev4:pty, hub:<m4>:pty, ssh:dev4:pty:build). add: optional label for the BYO entry. status: optional — omit for the roster. close / input: pty:<id|label> (input never creates or dials — the session must already run the command)."
|
|
4455
4638
|
},
|
|
4456
|
-
|
|
4639
|
+
cmd: {
|
|
4457
4640
|
type: "string",
|
|
4458
|
-
description: "
|
|
4641
|
+
description: "exec: one raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Required unless scriptPath is given."
|
|
4459
4642
|
},
|
|
4460
|
-
|
|
4461
|
-
type: "
|
|
4462
|
-
|
|
4463
|
-
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."
|
|
4464
|
-
},
|
|
4465
|
-
pullArgv: {
|
|
4466
|
-
type: "array",
|
|
4467
|
-
items: { type: "string" },
|
|
4468
|
-
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
4469
|
-
},
|
|
4470
|
-
pushArgv: {
|
|
4471
|
-
type: "array",
|
|
4472
|
-
items: { type: "string" },
|
|
4473
|
-
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."
|
|
4643
|
+
stdin: {
|
|
4644
|
+
type: "string",
|
|
4645
|
+
description: "exec: startup input fed to cmd — oneshot: piped to stdin; pty: written right after the command line (REPL-style); requires cmd. input: the exact text to append to the RUNNING command — \"\" writes zero bytes (not EOF); include \"\\n\" to submit a line; \"\\u0003\" = Ctrl-C, \"\\u0004\" = Ctrl-D as raw characters."
|
|
4474
4646
|
},
|
|
4475
|
-
|
|
4476
|
-
type: "
|
|
4477
|
-
description: "
|
|
4647
|
+
commandId: {
|
|
4648
|
+
type: "string",
|
|
4649
|
+
description: "input only: optional guard — when given it must equal the running command's id (from the exec receipt or job_output); a stale id refuses the write instead of feeding the next command. Ignored by other actions."
|
|
4478
4650
|
},
|
|
4479
|
-
|
|
4480
|
-
type: "
|
|
4481
|
-
description: "
|
|
4651
|
+
timeout: {
|
|
4652
|
+
type: "number",
|
|
4653
|
+
description: "exec: per-command timeout in seconds (default 120). oneshot: interrupts then kills the whole group. pty: the command is NOT killed on timeout — it hands off to a running job you can poll (job_output) and cancel (job_kill = Ctrl-C first; the session survives an interrupt)."
|
|
4482
4654
|
},
|
|
4483
4655
|
run_in_background: {
|
|
4484
4656
|
type: "boolean",
|
|
4485
|
-
description: "oneshot AND pty
|
|
4486
|
-
},
|
|
4487
|
-
target: {
|
|
4488
|
-
type: "string",
|
|
4489
|
-
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)."
|
|
4657
|
+
description: "exec: oneshot AND pty — return a native jobId immediately, read with job_output, cancel with job_kill; output is available while running. On pty lanes the jobId IS the commandId — it tracks the running command inside a persistent session; job_kill sends Ctrl-C and the session survives. Requires the official jobs service/controller."
|
|
4490
4658
|
},
|
|
4491
4659
|
ssh: {
|
|
4492
4660
|
type: "object",
|
|
4493
|
-
description: "BYO ssh
|
|
4661
|
+
description: "add: BYO ssh identity to register into the pool (no dialing). Later exec calls address the returned label. No inline keys or arbitrary SSH options; nothing is persisted — a new Agent registers again.",
|
|
4494
4662
|
additionalProperties: false,
|
|
4495
4663
|
properties: {
|
|
4496
4664
|
host: {
|
|
@@ -4518,23 +4686,11 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4518
4686
|
},
|
|
4519
4687
|
spawn: {
|
|
4520
4688
|
type: "string",
|
|
4521
|
-
description: "BYO
|
|
4522
|
-
},
|
|
4523
|
-
cmd: {
|
|
4524
|
-
type: "string",
|
|
4525
|
-
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."
|
|
4526
|
-
},
|
|
4527
|
-
scriptPath: {
|
|
4528
|
-
type: "string",
|
|
4529
|
-
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."
|
|
4689
|
+
description: "add: BYO PTY entry point — the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). Pairs with target pty[:<label>] / a bare <label> / nothing (auto id). Nested hops are dumb pipes (only the innermost bash frames)."
|
|
4530
4690
|
},
|
|
4531
|
-
|
|
4532
|
-
type: "
|
|
4533
|
-
description: "
|
|
4534
|
-
},
|
|
4535
|
-
timeout: {
|
|
4536
|
-
type: "number",
|
|
4537
|
-
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)."
|
|
4691
|
+
force: {
|
|
4692
|
+
type: "boolean",
|
|
4693
|
+
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."
|
|
4538
4694
|
}
|
|
4539
4695
|
},
|
|
4540
4696
|
output: {
|
|
@@ -4550,9 +4706,8 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4550
4706
|
"status",
|
|
4551
4707
|
"roster",
|
|
4552
4708
|
"job",
|
|
4553
|
-
"
|
|
4554
|
-
"
|
|
4555
|
-
"close"
|
|
4709
|
+
"close",
|
|
4710
|
+
"input"
|
|
4556
4711
|
]
|
|
4557
4712
|
},
|
|
4558
4713
|
text: {
|
|
@@ -4584,6 +4739,10 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4584
4739
|
type: "string",
|
|
4585
4740
|
description: "ledger key; equals the jobId whenever a job exists"
|
|
4586
4741
|
},
|
|
4742
|
+
writtenBytes: {
|
|
4743
|
+
type: "integer",
|
|
4744
|
+
description: "input: UTF-8 bytes actually written; confirms the write, not the program response"
|
|
4745
|
+
},
|
|
4587
4746
|
envDelta: {
|
|
4588
4747
|
type: "json",
|
|
4589
4748
|
description: "names only: {added,changed,removed}"
|
|
@@ -4592,7 +4751,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4592
4751
|
notice: { type: "string" },
|
|
4593
4752
|
runner: {
|
|
4594
4753
|
type: "string",
|
|
4595
|
-
description: "oneshot supervision path: superd-run <ver> (<triple>) or
|
|
4754
|
+
description: "oneshot supervision path: superd-run <ver> (<triple>), posix-shim, or hub-lease (peer hub server supervises natively)"
|
|
4596
4755
|
},
|
|
4597
4756
|
running: { type: "boolean" },
|
|
4598
4757
|
ttlSec: { type: "integer" },
|
|
@@ -4626,7 +4785,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
4626
4785
|
const description = tool.description;
|
|
4627
4786
|
Object.defineProperty(tool, "description", {
|
|
4628
4787
|
enumerable: true,
|
|
4629
|
-
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self).
|
|
4788
|
+
get: () => description + (driver.hasHub() ? "\nHub transport available: hub:<m4> selects a paired remote machine (never self; the roster lists them). Hub failures never fall back to SSH.\n- hub:<m4> oneshot: the peer hub server is the runner itself — bash -lc in a detached group, TERM→2s→KILL cleanup; the receipt runner field says \"hub-lease\" (the superd-run helper is a dumb-pipe artifact, never uploaded over the Hub). Inline cmd is limited to 4096 chars; scriptPath is the file carrier and is not argv.\n- hub:<m4>:pty[:label] opens the SAME persistent interactive session as every other transport — id, label, input, busy, timeout handoff, background, close, session card, ledger, idle TTL, quota — carried by a local pump child over one authenticated WebSocket to the peer terminal." : "")
|
|
4630
4789
|
});
|
|
4631
4790
|
return tool;
|
|
4632
4791
|
}
|
|
@@ -4657,26 +4816,6 @@ function workbenchValue(r) {
|
|
|
4657
4816
|
if (r.ttlSec !== void 0) v.ttlSec = r.ttlSec;
|
|
4658
4817
|
return v;
|
|
4659
4818
|
}
|
|
4660
|
-
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
4661
|
-
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
4662
|
-
function formatTransferLine(action, receipt, durationMs) {
|
|
4663
|
-
const r = receipt;
|
|
4664
|
-
const parts = [];
|
|
4665
|
-
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
4666
|
-
parts.push(`${action} ${r.src} → ${r.dst}`);
|
|
4667
|
-
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
4668
|
-
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
4669
|
-
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
4670
|
-
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
4671
|
-
if (r.created === true) parts.push("created yes");
|
|
4672
|
-
else if (r.created === false) parts.push("created pre-existing");
|
|
4673
|
-
else if (r.created === "n-a") parts.push("created n-a");
|
|
4674
|
-
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
4675
|
-
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
4676
|
-
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
4677
|
-
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
4678
|
-
return parts.join(" · ");
|
|
4679
|
-
}
|
|
4680
4819
|
function renderEnvDelta(d) {
|
|
4681
4820
|
const bits = [];
|
|
4682
4821
|
if (d.added.length > 0) bits.push(`+${d.added.join(",+")}`);
|
|
@@ -4685,7 +4824,7 @@ function renderEnvDelta(d) {
|
|
|
4685
4824
|
return bits.length > 0 ? bits.join(" ") : void 0;
|
|
4686
4825
|
}
|
|
4687
4826
|
function renderValue(v) {
|
|
4688
|
-
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "
|
|
4827
|
+
if (v.kind === "status" || v.kind === "roster" || v.kind === "job" || v.kind === "close" || v.kind === "input") return v.text;
|
|
4689
4828
|
const parts = [];
|
|
4690
4829
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
4691
4830
|
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
@@ -4706,15 +4845,188 @@ function renderValue(v) {
|
|
|
4706
4845
|
markers.push(`${secs}s`);
|
|
4707
4846
|
if (v.running === true) markers.push(`still running — poll job_output${v.jobId !== void 0 ? ` ${v.jobId}` : ""}`);
|
|
4708
4847
|
if (v.truncated !== void 0) markers.push(`truncated, ${v.outputStatsUnavailable ? "returned peer tail" : "original"} ${v.truncated} ${v.outputStats?.stdout.unit === "bytes" ? "bytes" : "chars"}`);
|
|
4709
|
-
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable
|
|
4848
|
+
if (v.outputStatsUnavailable) markers.push("peer output totals/retention unavailable");
|
|
4710
4849
|
parts.push(`[${markers.join(" · ")}]`);
|
|
4711
4850
|
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");
|
|
4712
4851
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
4713
4852
|
return parts.join("\n");
|
|
4714
4853
|
}
|
|
4715
4854
|
|
|
4855
|
+
//#endregion
|
|
4856
|
+
//#region src/remote/transfer-tool.ts
|
|
4857
|
+
const NO_SESSION_KEY = "remote:no-session";
|
|
4858
|
+
/**
|
|
4859
|
+
* The model-visible surface of the `remote_transfer` tool (split out of
|
|
4860
|
+
* `remote`, 2026-10-25). ONE template literal — no backticks, no "${".
|
|
4861
|
+
*/
|
|
4862
|
+
const TRANSFER_TOOL_DESCRIPTION = `File transfer between any two supported endpoints. Every call declares its mode:
|
|
4863
|
+
mode:"copy" → tree copy. Native CLI per endpoint (scp for ssh, docker cp, incus file push/pull), a plain tree copy when both ends are local, and the Hub binary lane when a Hub machine is involved. Directories copy recursively; exit codes are authoritative.
|
|
4864
|
+
mode:"rsync" → rsync over SSH only: at least one endpoint remote SSH, the other local or SSH (both remote legs tunnel through this machine; identities never leave it). Hub and container endpoints are rejected — no silent copy fallback. Trailing slashes keep rsync semantics.
|
|
4865
|
+
|
|
4866
|
+
Endpoints are "[target:]absolute path". No target = the machine running this Agent. Targets are the same unified pool remote uses: a bare pool name, ssh:<name>, docker:<name>, incus:<name>, hub:<m4>. Examples: "/srv/app", "ssh:dev4:/srv/app", "docker:corti:/var/lib/x", "hub:m4:/srv". A path is never guessed: both operands must be absolute.
|
|
4867
|
+
|
|
4868
|
+
Native CLI options travel through argv verbatim — one element = one token, appended before src/dst, no shell parsing and no whitelist: rsync ["-a","--partial"], scp ["-p","-r"], docker cp ["-a","-L"], incus file push ["--recursive"]. Cross-endpoint copies that need two commands (SSH↔Docker, SSH↔Incus, Docker↔Incus) take pullArgv plus pushArgv instead of argv. rsync is a single SSH command, so it takes argv only; an explicit -e/--rsh overrides the wiring.
|
|
4869
|
+
|
|
4870
|
+
Options: verify (copy only) — the Hub↔local staged lane computes independent SHA-256 digests by default and verify:false skips them; native CLI routes have no independent hash tier, so verify is rejected there (self-verify through a oneshot digest command). mkdir (copy only, default true) — one best-effort pre-hop creates the destination PARENT directory; the operand itself is never pre-created, so each CLI's own layout semantics stay byte-identical.
|
|
4871
|
+
|
|
4872
|
+
Failures are loud: a non-zero native exit throws with the receipt instead of returning a normal-looking result. The receipt stays in the receipt field (route, exits, verification, bytes/entries, cleanup); text is one formatted line. For checksum-grade verification of a native route, run a digest command through remote oneshot or PTY instead of trusting the copy receipt.`;
|
|
4873
|
+
const KNOWN_PARAMS = new Set([
|
|
4874
|
+
"mode",
|
|
4875
|
+
"src",
|
|
4876
|
+
"dst",
|
|
4877
|
+
"argv",
|
|
4878
|
+
"pullArgv",
|
|
4879
|
+
"pushArgv",
|
|
4880
|
+
"verify",
|
|
4881
|
+
"mkdir"
|
|
4882
|
+
]);
|
|
4883
|
+
async function transferExecute(ENV, args, exec) {
|
|
4884
|
+
for (const key of Object.keys(args)) if (!KNOWN_PARAMS.has(key)) throw new Error(`remote_transfer: unknown parameter "${key}" — this surface has no legacy aliases; pass native CLI options through argv instead`);
|
|
4885
|
+
if (args.mode !== "copy" && args.mode !== "rsync") throw new Error(`[E_PARAMS] remote_transfer: mode is required — "copy" (tree copy through each endpoint's native CLI or the Hub lane) or "rsync" (SSH only)`);
|
|
4886
|
+
if (typeof args.src !== "string" || args.src.length === 0 || typeof args.dst !== "string" || args.dst.length === 0) throw new Error(`[E_PARAMS] remote_transfer: ${args.mode} requires src and dst absolute endpoints`);
|
|
4887
|
+
const sessionKey = ENV.getSessionKey(exec);
|
|
4888
|
+
if (args.mode === "rsync") {
|
|
4889
|
+
if (args.pullArgv !== void 0 || args.pushArgv !== void 0) throw new Error("remote_transfer: rsync takes argv only; pullArgv/pushArgv are copy-only fields");
|
|
4890
|
+
if (args.verify !== void 0 || args.mkdir !== void 0) throw new Error("remote_transfer: rsync cannot be combined with copy verify/mkdir options; the Hub/local staged lane is copy-only");
|
|
4891
|
+
const start$1 = Date.now();
|
|
4892
|
+
const receipt$1 = await ENV.driver.rsync({
|
|
4893
|
+
src: args.src,
|
|
4894
|
+
dst: args.dst,
|
|
4895
|
+
...args.argv === void 0 ? {} : { argv: args.argv }
|
|
4896
|
+
}, exec.signal, { sessionKey });
|
|
4897
|
+
if (receipt$1.ok === false || receipt$1.exit !== 0) throw new Error(JSON.stringify(receipt$1));
|
|
4898
|
+
const durationMs$1 = Date.now() - start$1;
|
|
4899
|
+
return {
|
|
4900
|
+
kind: "rsync",
|
|
4901
|
+
text: formatTransferLine("rsync", receipt$1, durationMs$1),
|
|
4902
|
+
receipt: receipt$1,
|
|
4903
|
+
exit: receipt$1.exit,
|
|
4904
|
+
durationMs: durationMs$1
|
|
4905
|
+
};
|
|
4906
|
+
}
|
|
4907
|
+
const start = Date.now();
|
|
4908
|
+
const receipt = await ENV.driver.copy({
|
|
4909
|
+
src: args.src,
|
|
4910
|
+
dst: args.dst,
|
|
4911
|
+
...args.verify === void 0 ? {} : { verify: args.verify },
|
|
4912
|
+
...args.mkdir === void 0 ? {} : { mkdir: args.mkdir },
|
|
4913
|
+
...args.argv === void 0 ? {} : { argv: args.argv },
|
|
4914
|
+
...args.pullArgv === void 0 ? {} : { pullArgv: args.pullArgv },
|
|
4915
|
+
...args.pushArgv === void 0 ? {} : { pushArgv: args.pushArgv }
|
|
4916
|
+
}, exec.signal, { sessionKey });
|
|
4917
|
+
const asReceipt = receipt;
|
|
4918
|
+
const exits = asReceipt.exits ?? [];
|
|
4919
|
+
if (asReceipt.ok === false || exits.some((code) => code !== 0)) throw new Error(JSON.stringify(receipt));
|
|
4920
|
+
const durationMs = Date.now() - start;
|
|
4921
|
+
return {
|
|
4922
|
+
kind: "copy",
|
|
4923
|
+
text: formatTransferLine("copy", receipt, durationMs),
|
|
4924
|
+
receipt,
|
|
4925
|
+
exit: exits.length > 0 ? 0 : null,
|
|
4926
|
+
durationMs
|
|
4927
|
+
};
|
|
4928
|
+
}
|
|
4929
|
+
function createRemoteTransferTool(driver, opts = {}) {
|
|
4930
|
+
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
4931
|
+
return defineTool({
|
|
4932
|
+
name: "remote_transfer",
|
|
4933
|
+
description: TRANSFER_TOOL_DESCRIPTION,
|
|
4934
|
+
parameters: {
|
|
4935
|
+
mode: {
|
|
4936
|
+
type: "string",
|
|
4937
|
+
enum: ["copy", "rsync"],
|
|
4938
|
+
required: true,
|
|
4939
|
+
description: "Required. copy = tree copy through each endpoint's native CLI, or the Hub binary lane; rsync = SSH only (argv only, no verify/mkdir)."
|
|
4940
|
+
},
|
|
4941
|
+
src: {
|
|
4942
|
+
type: "string",
|
|
4943
|
+
description: "Source [target:]absolute path. No target = the machine running this Agent."
|
|
4944
|
+
},
|
|
4945
|
+
dst: {
|
|
4946
|
+
type: "string",
|
|
4947
|
+
description: "Destination [target:]absolute path. No target = the machine running this Agent."
|
|
4948
|
+
},
|
|
4949
|
+
argv: {
|
|
4950
|
+
type: "array",
|
|
4951
|
+
items: { type: "string" },
|
|
4952
|
+
description: "Ordered native CLI options and values for one-command routes (e.g. rsync [\"-a\",\"--partial\",\"--dry-run\"], scp [\"-p\",\"-r\"], docker cp [\"-a\",\"-L\"], incus file push/pull). One element = one argv token, appended verbatim before src/dst; no shell parsing, no whitelist, no forced defaults — unknown options fail in the native CLI itself. rsync only (SSH endpoints): explicit -e/--rsh overrides the tool's SSH wiring. docker cp: pass -L to follow symlinks (a symlink source like /etc/os-release fails without it). Two-segment cross-endpoint copy uses pullArgv/pushArgv instead."
|
|
4953
|
+
},
|
|
4954
|
+
pullArgv: {
|
|
4955
|
+
type: "array",
|
|
4956
|
+
items: { type: "string" },
|
|
4957
|
+
description: "Options for the PULL half (source endpoint → origin staging) of a two-command cross-endpoint copy (e.g. SSH↔Docker, SSH↔Incus, Docker↔Incus). Mutually exclusive with argv; must be paired with pushArgv. Only options valid for the pull half's CLI."
|
|
4958
|
+
},
|
|
4959
|
+
pushArgv: {
|
|
4960
|
+
type: "array",
|
|
4961
|
+
items: { type: "string" },
|
|
4962
|
+
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."
|
|
4963
|
+
},
|
|
4964
|
+
verify: {
|
|
4965
|
+
type: "boolean",
|
|
4966
|
+
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."
|
|
4967
|
+
},
|
|
4968
|
+
mkdir: {
|
|
4969
|
+
type: "boolean",
|
|
4970
|
+
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."
|
|
4971
|
+
}
|
|
4972
|
+
},
|
|
4973
|
+
output: {
|
|
4974
|
+
schema: {
|
|
4975
|
+
type: "object",
|
|
4976
|
+
additionalProperties: false,
|
|
4977
|
+
properties: {
|
|
4978
|
+
kind: {
|
|
4979
|
+
type: "string",
|
|
4980
|
+
required: true,
|
|
4981
|
+
enum: ["copy", "rsync"]
|
|
4982
|
+
},
|
|
4983
|
+
text: {
|
|
4984
|
+
type: "string",
|
|
4985
|
+
required: true
|
|
4986
|
+
},
|
|
4987
|
+
receipt: { type: "json" },
|
|
4988
|
+
exit: { oneOf: [{ type: "integer" }, { type: "null" }] },
|
|
4989
|
+
durationMs: { type: "integer" }
|
|
4990
|
+
}
|
|
4991
|
+
},
|
|
4992
|
+
render: (_args, value) => [{
|
|
4993
|
+
type: "text",
|
|
4994
|
+
text: value.text
|
|
4995
|
+
}]
|
|
4996
|
+
},
|
|
4997
|
+
async execute(args, exec) {
|
|
4998
|
+
return transferExecute({
|
|
4999
|
+
driver,
|
|
5000
|
+
getSessionKey
|
|
5001
|
+
}, args, exec);
|
|
5002
|
+
}
|
|
5003
|
+
});
|
|
5004
|
+
}
|
|
5005
|
+
/** S8 (2026-10-17): copy/rsync render as one formatted line; the full receipt
|
|
5006
|
+
* stays in the receipt field — raw-JSON dumps are gone from text. */
|
|
5007
|
+
function formatTransferLine(mode, receipt, durationMs) {
|
|
5008
|
+
const r = receipt;
|
|
5009
|
+
const parts = [];
|
|
5010
|
+
if (r.ok === false) parts.push(`✗ ${r.reason ?? "failed"}`);
|
|
5011
|
+
parts.push(`${mode} ${r.src} → ${r.dst}`);
|
|
5012
|
+
if (r.route !== void 0 && r.route !== "") parts.push(`route ${r.route}`);
|
|
5013
|
+
if (r.exits !== void 0) parts.push(`exits [${r.exits.join(",")}]`);
|
|
5014
|
+
else if (r.exit !== void 0) parts.push(`exit ${r.exit}`);
|
|
5015
|
+
if (r.verification !== void 0) parts.push(`verification ${r.verification}`);
|
|
5016
|
+
if (r.created === true) parts.push("created yes");
|
|
5017
|
+
else if (r.created === false) parts.push("created pre-existing");
|
|
5018
|
+
else if (r.created === "n-a") parts.push("created n-a");
|
|
5019
|
+
if (r.cleanup !== void 0 && r.cleanup !== "none-required") parts.push(`cleanup ${r.cleanup}`);
|
|
5020
|
+
if (typeof r.bytes === "number") parts.push(`${r.bytes} bytes / ${r.entries} entries`);
|
|
5021
|
+
parts.push(`${(durationMs / 1e3).toFixed(1)}s`);
|
|
5022
|
+
if (r.note !== void 0 && r.note.length > 0) parts.push(`(${r.note})`);
|
|
5023
|
+
return parts.join(" · ");
|
|
5024
|
+
}
|
|
5025
|
+
|
|
4716
5026
|
//#endregion
|
|
4717
5027
|
//#region src/remote/agent-registration.ts
|
|
5028
|
+
/** The pair a row contributes: execution/PTY and file transfer are separate tools. */
|
|
5029
|
+
const TOOL_NAMES = ["remote", "remote_transfer"];
|
|
4718
5030
|
/** Host listeners attempt once per official creation edge, never on vacancy. */
|
|
4719
5031
|
function installAgentRemote(ctx, config) {
|
|
4720
5032
|
return ctx.on("agent/created", ({ agent }) => {
|
|
@@ -4724,12 +5036,14 @@ function installAgentRemote(ctx, config) {
|
|
|
4724
5036
|
...config,
|
|
4725
5037
|
getHub: () => agent.ctx.get("superdRemoteTransport")
|
|
4726
5038
|
});
|
|
4727
|
-
|
|
5039
|
+
const registered = [];
|
|
4728
5040
|
try {
|
|
4729
|
-
|
|
5041
|
+
registered.push(tools.register(createRemoteTool(driver, { getSessionKey: () => agent.id })));
|
|
5042
|
+
registered.push(tools.register(createRemoteTransferTool(driver, { getSessionKey: () => agent.id })));
|
|
4730
5043
|
} catch (error) {
|
|
5044
|
+
for (const undo of registered.splice(0)) undo();
|
|
4731
5045
|
driver.dispose();
|
|
4732
|
-
if (error instanceof Error && error.message ===
|
|
5046
|
+
if (TOOL_NAMES.find((name$1) => error instanceof Error && error.message === `tool "${name$1}" is already registered in this scope` && tools.get(name$1, agent) !== void 0) !== void 0) return;
|
|
4733
5047
|
throw error;
|
|
4734
5048
|
}
|
|
4735
5049
|
const stopService = agent.ctx.on("internal/service", (name$1) => {
|
|
@@ -4741,7 +5055,7 @@ function installAgentRemote(ctx, config) {
|
|
|
4741
5055
|
const cleanup = () => {
|
|
4742
5056
|
if (disposal) return disposal;
|
|
4743
5057
|
disposal = driver.dispose();
|
|
4744
|
-
|
|
5058
|
+
for (const undo of registered.splice(0)) undo();
|
|
4745
5059
|
stopService();
|
|
4746
5060
|
ownerUndo?.();
|
|
4747
5061
|
agentUndo?.();
|
|
@@ -4797,12 +5111,10 @@ const name = "dashr-remote";
|
|
|
4797
5111
|
const inject = [];
|
|
4798
5112
|
const Config = Schema.object({
|
|
4799
5113
|
execTimeoutSec: Schema.natural().min(1).default(120),
|
|
4800
|
-
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
4801
|
-
maxOutputChars: Schema.natural().min(1).default(3e4)
|
|
5114
|
+
idleTtlSec: Schema.natural().min(1).default(REMOTE_PTY_IDLE_TTL_SEC)
|
|
4802
5115
|
}).default({
|
|
4803
5116
|
execTimeoutSec: 120,
|
|
4804
|
-
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
4805
|
-
maxOutputChars: 3e4
|
|
5117
|
+
idleTtlSec: REMOTE_PTY_IDLE_TTL_SEC
|
|
4806
5118
|
});
|
|
4807
5119
|
/** MUST stay an arrow(RM0 plugin.ts 同注:function 声明会被 cordis 当构造器 new 掉,返回的 disposer 丢失)。 */
|
|
4808
5120
|
const apply = (ctx, config) => {
|
|
@@ -4816,4 +5128,4 @@ var plugin_default = {
|
|
|
4816
5128
|
};
|
|
4817
5129
|
|
|
4818
5130
|
//#endregion
|
|
4819
|
-
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 };
|
|
5131
|
+
export { ByteTailRing, Config, FILE_FRAME_BYTES, FILE_MAX_ENTRIES, FILE_MAX_METADATA, PROCESS_CLEANUP_BOUND_MS, PtySession, Utf16TailRing, apply, buildHubPtyArgv, checkAbsolute, checkSelection, plugin_default as default, disclose, hubPtyAssets, inject, localFileEndpoint, name, readUtf8Tail, runFileWorker, runRsync, sshArgv, startBinaryProcess, startOneShot, startRemoteJob };
|