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/docs/remote-tools.md
CHANGED
|
@@ -4,31 +4,47 @@ Better Dash and Super Dash each provide the native `remote` tool and REPL route.
|
|
|
4
4
|
|
|
5
5
|
## Choose a call
|
|
6
6
|
|
|
7
|
+
`remote` declares its action; `remote_transfer` declares its mode. Nothing dispatches on which fields happen to be present.
|
|
8
|
+
|
|
7
9
|
| Goal | Input |
|
|
8
10
|
| --- | --- |
|
|
9
|
-
| List locally known targets without dialing | `{}` |
|
|
10
|
-
| Probe one target | `{target:"ssh:alias"}` |
|
|
11
|
-
| Register an Agent-local SSH identity (no dialing) | `{target:"ssh:build",ssh:{host:"host",user:"user",key:"/origin/key"}}`; omit `target` for an auto id (`ssh-xxxx`) |
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
|
|
|
17
|
-
|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
| Native
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
| List locally known targets without dialing | `remote {action:"status"}` |
|
|
12
|
+
| Probe one target / show a session card | `remote {action:"status",target:"ssh:alias"}`, `remote {action:"status",target:"pty:<id\|label>"}` |
|
|
13
|
+
| Register an Agent-local SSH identity (no dialing) | `remote {action:"add",target:"ssh:build",ssh:{host:"host",user:"user",key:"/origin/key"}}`; omit `target` for an auto id (`ssh-xxxx`) |
|
|
14
|
+
| Register a BYO PTY from a spawn command | `remote {action:"add",spawn:"docker exec -it img bash",target:"pty:build"}`; then address it like any session |
|
|
15
|
+
| Stateless command and stdin over an identity | `remote {action:"exec",target:"ssh:build",cmd:"cat",stdin:"hello"}` |
|
|
16
|
+
| Stream a local script to fixed `bash -s` | `remote {action:"exec",target:"ssh:alias",scriptPath:"/absolute/script.sh"}` |
|
|
17
|
+
| Create a persistent shell (tab semantics) | `remote {action:"exec",target:"ssh:alias:pty",cmd:"cd /tmp; export CHECK=ready"}`; append `:label` (`ssh:alias:pty:build`) to name it |
|
|
18
|
+
| Reuse a persistent shell | `remote {action:"exec",target:"pty:<id\|label>",cmd:"…"}` |
|
|
19
|
+
| Answer a prompt / feed a running command (password, OTP, token, choice, REPL line, `\u0003`/`\u0004`) | `remote {action:"input",target:"pty:<id\|label>",stdin:"the-answer\n"}` — optional `commandId` guards against stale prompts |
|
|
20
|
+
| Close a PTY session | `remote {action:"close",target:"pty:<id\|label>",force:true}` |
|
|
21
|
+
| Native CLI copy | `remote_transfer {mode:"copy",src:"/absolute/source",dst:"ssh:alias:/absolute/destination",argv:["-p"]}` |
|
|
22
|
+
| Native rsync | `remote_transfer {mode:"rsync",src:"/absolute/source/",dst:"ssh:alias:/existing/destination/",argv:["-a","--partial","--dry-run"]}` |
|
|
23
|
+
|
|
24
|
+
## PTY is a persistent, interactive terminal session
|
|
25
|
+
|
|
26
|
+
Prefer the PTY lane. A PTY dispatch returns synchronously with the same receipt a oneshot returns — output text, exit code, duration — and only adds state: `session`, `cwd`, `envDelta`, `commandId`, `ttlSec`. Nothing is withheld at read time; the terminal merges stderr into the output stream, which is the only receipt difference. What the session buys is durability: cwd, exported environment, shell variables, activated virtualenvs, aliases and an open REPL survive between calls, so a multi-step task becomes N cheap calls instead of N stateless logins, and a command that outlives its timeout hands off to a pollable job instead of being killed. It is also the only interactive lane: a running command can be observed and then fed more stdin (`input`), which a oneshot never offers.
|
|
27
|
+
|
|
28
|
+
Interactive flow — see a prompt, answer it, read the response:
|
|
29
|
+
|
|
30
|
+
1. Start the program: `exec` with `cmd` (foreground, `run_in_background`, or after a timeout handoff — identity is preserved either way). The running receipt carries the output so far plus `session` / `commandId` / `jobId`.
|
|
31
|
+
2. Watch for the prompt in that output; use `job_output <jobId>` when you need more.
|
|
32
|
+
3. Send the answer: `{action:"input", target:"pty:<id|label>", stdin:"the-answer\n"}`. `stdin` passes through verbatim — include the trailing `\n` to submit a line; `\u0003` is Ctrl-C and `\u0004` is Ctrl-D as raw characters; a literal backslash-x-0-4 stays four characters; `""` writes zero bytes (not EOF). The optional `commandId` must equal the running command's id; a stale id refuses the write instead of feeding the next command.
|
|
33
|
+
4. Read the response with `job_output` on the SAME job. The input receipt only confirms N bytes were written — it never means the password was accepted or the login succeeded; judge from the program's output and the job's exit.
|
|
34
|
+
|
|
35
|
+
Honest limits of the input lane: Ctrl-D is not a universal EOF (on a line without a trailing newline it first delivers that text — send it again on the emptied line to end a reader like `cat`); Ctrl-C only interrupts where the terminal mode allows; `job_kill` remains the explicit cancel; `input` never creates, dials, reconnects or replays — address a live session as `pty:<id|label>`, any transport. A command waiting for input IS the busy state — a new `cmd` gets `[E_SESSION_BUSY]`; feed the running command instead of queueing a second one. On a oneshot, `stdin` is startup-only: known data may ride the initial `exec`, but a oneshot cannot be fed after it starts and its timeout still cancels the whole group — a prompt there means cancel and switch to PTY. Heredocs and input redirection remain valid bulk-input alternatives; there is no global stdin ban. Cancelling is reliable (0.3.2-d): `job_kill` writes its status marker after the terminal's ^C input flush, so even a cancel that follows a bare Ctrl-C settles `killed · exit 130` with the session alive — and hub-lane control bytes cross the local raw half as bytes, interpreted by the peer pty, so ^C/^D behave like every other transport.
|
|
36
|
+
|
|
37
|
+
Use the stateless oneshot when a step is genuinely one-shot (a probe, a pipeline stage, a supervised build you will not revisit) or when the `runner` supervision guarantee matters. On dumb-pipe transports the supervisor is the pinned `superd-run` helper (or its POSIX wrapper) — receipts say which ran. On the Hub lane the peer IS our own server: supervision is native there (`bash -lc` in a detached group, TERM→2s→KILL, output rings, lease lifecycle) and receipts say `hub-lease`; the helper is a dumb-pipe artifact and is never uploaded over the Hub. Note that `exec` always executes something: a warm session is created by running a command (`true` is enough), never by omitting `cmd`.
|
|
38
|
+
|
|
39
|
+
There is no `mode` field: append `:pty` to a transport target to create, address a live shell as `pty:<id|label>`. `add` only registers (BYO identity or BYO PTY) and never runs a command. `scriptPath` cannot combine with cmd/stdin/PTY and is `exec`-only. Direct local command execution is not provided; use native local shell tools. Background jobs require the official jobs service/controller and are read/killed with `job_output`/`job_kill`; on PTY lanes a background job's jobId is the commandId (a timeout handoff keeps them as separate ledger fields). SSH keys/config stay on the origin; BYO identities are not persisted across new/cold Agents. PTY backlog recovery and live probes are not part of this release.
|
|
40
|
+
|
|
41
|
+
PTY state is durable and readable: every dispatch returns exit, cwd and `envDelta` (variable names and change markers only — values never leave the endpoint; fetch them yourself if needed). `close` is idempotent (`closed:false, reason:"not-found"|"already-closed-or-reaped"`); a session with a running job refuses close unless `force:true`, which first interrupts the running command — its job settles in the killed family (`interrupted: exit 130`, or `session closed` when the frame cannot arrive) — and then kills the group. A busy session rejects new commands instead of queueing (`[E_SESSION_BUSY]` names the running id and the ways out: `input` more stdin, `job_output`, `job_kill`, or another session/oneshot); open another PTY or use a oneshot for parallel work. Idle PTYs are reaped after 15 minutes; an Agent holds at most 10 live sessions across connections, and dead sessions do not count. `hub:<m4>:pty[:label]` opens the same session shape over the Hub WebSocket lane — identity, labels, input, parallelism and background work exactly like ssh/docker/incus/BYO.
|
|
42
|
+
|
|
43
|
+
Output returns IN FULL up to retention (0.3.2-e: the self-made 30K returned-tail window is removed — model-context sizing is the native agent runtime's job, e.g. the DSH spill policy or a foreign runtime's own guard). Native/PTY retains at most 4,000,000 UTF-16 units per stream; Hub oneshot 4 MiB bytes. Beyond retention the returned text is the retained tail, headed by a one-line disclosure banner, with `outputStats` giving total/retained/dropped/returned and `truncated` set to the original total. Read `outputStats` units and dropped/returned counters rather than treating character counts as bytes. Discarded output is not recoverable after restart.
|
|
28
44
|
|
|
29
45
|
## File operations: native CLI semantics
|
|
30
46
|
|
|
31
|
-
`
|
|
47
|
+
`remote_transfer` addresses targets once (`ssh:<name>`, `ssh:<label>` for a BYO identity, `docker:<name>`, `incus:<name>`, `hub:<machine>`, local paths) and hands the actual transfer to the native CLI. `mode:"copy"|"rsync"` is required and spans only transfer fields. `argv` is an ordered array of native option tokens — one element = one argv token, appended verbatim before the parsed `src`/`dst`. There is no option whitelist, no shell parsing, and no forced defaults: no hidden `--dry-run`, no forced archive/timeout, no automatic verification. A dry run means passing `--dry-run` yourself; unknown or incompatible options fail in the native CLI and its exit code and stderr come back as-is. Success of a native command is never reported as independently verified — `verified:false` on native copy receipts means exactly that.
|
|
32
48
|
|
|
33
49
|
### copy routes
|
|
34
50
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- 日期:2026-10-10(Asia/Hong_Kong)
|
|
4
4
|
- 状态:2026-10-10 user 已审阅并明确授权执行;Task0–7 已独立审阅通过;Task8 完整双包消费者验收进行中,L 实装暴露的 worker/SSH cleanup 缺陷已进入修复验证。用户亲验与发布尚未放行。
|
|
5
|
+
- 2026-10-25 surface 修订(user 指令,本轮实现):`remote` 的判别字段改为**必填** `action: exec|add|close|status`;文件传输字段(`src`/`dst`/`argv`/`pullArgv`/`pushArgv`/`verify`/`mkdir`)迁到独立工具 `remote_transfer`,判别字段为**必填** `mode: copy|rsync`。`add` 只注册 BYO 身份/终端,不执行命令;`exec` 必填 `target` 且必须执行东西(warm 会话用 `cmd:"true"`)。工具简介把 PTY 重新定位为「stateful oneshot」并鼓励优先使用。下文 §5 的旧 `action:"copy"|"rsync"` 调用形与「无 action」形仅作历史。
|
|
5
6
|
- 0.3.2-b 对齐(2026-10-19):`script` 已定名 `scriptPath`;`mode` 字段已移除(用 `<transport>:pty[:label]` 创建、`pty:<id|label>` 复用);`byossh:`/`byop:` 前缀已移除(`ssh`/`spawn` 参数在场即 BYO)。下文旧调用形仅作历史。
|
|
6
7
|
- 输入:[2026-10-09 方向稿](../../10_plans/2026-10-09-remote-tool-optimization-directions.md)及 2026-10-10 会话裁决。
|
|
7
8
|
- 执行计划:[2026-10-10-remote-tool-enhancement.md](../../superpowers/plans/2026-10-10-remote-tool-enhancement.md)。本规格优先于方向稿的冲突条款。
|
|
@@ -87,9 +87,9 @@ async function isPathUnder(path$1, root, caseSensitive = process.platform !== "w
|
|
|
87
87
|
*
|
|
88
88
|
* Per-call policy: `read-only` denies every mutation; `workspace-write` allows
|
|
89
89
|
* a mutation only when the target canonicalizes under the policy's workspace
|
|
90
|
-
* root or a platform temp area from the
|
|
91
|
-
* `danger-full-access` delegates unfenced. A
|
|
92
|
-
* `FS_SANDBOX_DENIED`.
|
|
90
|
+
* root, an explicit additional writable root, or a platform temp area from the
|
|
91
|
+
* shared `writableRoots` policy; `danger-full-access` delegates unfenced. A
|
|
92
|
+
* denial throws the structured `FS_SANDBOX_DENIED`.
|
|
93
93
|
*
|
|
94
94
|
* @module @deepseek-ai/dsh-fs-sandbox
|
|
95
95
|
*/
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { t as Schema } from "./index2.js";
|
|
2
2
|
import { Context, Service } from "@deepseek-ai/cordis";
|
|
3
3
|
import { ToolDefinition, ToolExecutionInput, ToolRunContext, ToolRuntime } from "@deepseek-ai/dsh-tools";
|
|
4
|
+
import { ToolExecutionPipeline } from "@deepseek-ai/dsh-tools/pipeline";
|
|
4
5
|
import { ContentBlock, HarnessError } from "@deepseek-ai/dsh-llm";
|
|
5
6
|
import { Scoped } from "@deepseek-ai/dsh-scope";
|
|
6
7
|
import { Agent } from "@deepseek-ai/dsh-agent";
|
|
@@ -622,10 +623,10 @@ declare module '@deepseek-ai/cordis' {
|
|
|
622
623
|
}
|
|
623
624
|
/**
|
|
624
625
|
* Capabilities the `eval` bridge closes over, mirroring upstream's
|
|
625
|
-
* `RunCodeBridgeOptions` (the `requireRuntime` idiom):
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
* the registry does not mint this tool for us.
|
|
626
|
+
* `RunCodeBridgeOptions` (the `requireRuntime` idiom): sub-dispatch rides a
|
|
627
|
+
* plugin-built `ToolExecutionPipeline` (0.2.2) rather than the pre-0.2.2
|
|
628
|
+
* registry-private staged scheduler, because — unlike upstream's PTC
|
|
629
|
+
* executor — the registry does not mint this tool for us.
|
|
629
630
|
*/
|
|
630
631
|
interface RunCellBridgeOptions {
|
|
631
632
|
/** Resolves `ctx.replRuntime` or throws the loud misconfiguration error (use-time read). */
|
|
@@ -649,17 +650,20 @@ interface RunCellBridgeOptions {
|
|
|
649
650
|
/**
|
|
650
651
|
* Build the `eval` {@link ToolDefinition}: required `cell` and
|
|
651
652
|
* `description` parameters, executed through the dispatch bridge described
|
|
652
|
-
* in the module doc. Sub-calls ride the
|
|
653
|
-
* (`prepare`/`dispatch`/`
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
653
|
+
* in the module doc. Sub-calls ride the plugin-built staged pipeline
|
|
654
|
+
* (`prepare`/`dispatch`/`complete` — the 0.2.2 `ToolExecutionPipeline`, the
|
|
655
|
+
* sanctioned successor of the retired `TOOL_RUNTIME_SCHEDULER` symbol) under
|
|
656
|
+
* the native concurrency contract; each sub-dispatch is logged for
|
|
657
|
+
* reconstruction (`tool/ptc-dispatch-start` / `tool/ptc-dispatch`) while only
|
|
658
|
+
* the outer curated result enters model history.
|
|
659
|
+
* @param registry - the host tool registry (bindings cover its registered
|
|
660
|
+
* tools; schema projection reads it live).
|
|
661
|
+
* @param pipeline - the staged invocation pipeline built over the same
|
|
662
|
+
* registry's mounting context (sub-calls dispatch through it).
|
|
659
663
|
* @param options - the bridge capabilities described above.
|
|
660
664
|
* @returns the registry-ready definition.
|
|
661
665
|
*/
|
|
662
|
-
declare function createRunCellTool(registry: ToolRuntime, options: RunCellBridgeOptions): ToolDefinition;
|
|
666
|
+
declare function createRunCellTool(registry: ToolRuntime, pipeline: ToolExecutionPipeline, options: RunCellBridgeOptions): ToolDefinition;
|
|
663
667
|
declare function apply(ctx: Context, config: Config): void;
|
|
664
668
|
declare const _default: {
|
|
665
669
|
name: string;
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,6 @@ import { t as Schema } from "./lib.js";
|
|
|
2
2
|
import { a as RESERVED_ERROR_MEMBERS, i as RESERVED_BINDING_GLOBALS, n as DUNDER_MEMBER, o as ReplRuntime, r as PORTABLE_RESERVED_WORDS, t as isFlatBindableName } from "./py-sdk2.js";
|
|
3
3
|
import { n as resolveKernelEnv } from "./kernel-env2.js";
|
|
4
4
|
import { t as captureAllTools } from "./native-capture.js";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
5
|
import { Context } from "@deepseek-ai/cordis";
|
|
7
6
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
7
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
@@ -11,7 +10,8 @@ import { spawn } from "node:child_process";
|
|
|
11
10
|
import { createServer } from "node:http";
|
|
12
11
|
import { createInterface } from "node:readline";
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
14
|
-
import {
|
|
13
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
14
|
+
import { ToolExecutionPipeline } from "@deepseek-ai/dsh-tools/pipeline";
|
|
15
15
|
import * as dshLlm from "@deepseek-ai/dsh-llm";
|
|
16
16
|
import { BlockAssembler, HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
17
17
|
import { scopeTarget } from "@deepseek-ai/dsh-scope";
|
|
@@ -2165,17 +2165,20 @@ function renderValue(value) {
|
|
|
2165
2165
|
/**
|
|
2166
2166
|
* Build the `eval` {@link ToolDefinition}: required `cell` and
|
|
2167
2167
|
* `description` parameters, executed through the dispatch bridge described
|
|
2168
|
-
* in the module doc. Sub-calls ride the
|
|
2169
|
-
* (`prepare`/`dispatch`/`
|
|
2170
|
-
*
|
|
2171
|
-
*
|
|
2172
|
-
*
|
|
2173
|
-
*
|
|
2174
|
-
*
|
|
2168
|
+
* in the module doc. Sub-calls ride the plugin-built staged pipeline
|
|
2169
|
+
* (`prepare`/`dispatch`/`complete` — the 0.2.2 `ToolExecutionPipeline`, the
|
|
2170
|
+
* sanctioned successor of the retired `TOOL_RUNTIME_SCHEDULER` symbol) under
|
|
2171
|
+
* the native concurrency contract; each sub-dispatch is logged for
|
|
2172
|
+
* reconstruction (`tool/ptc-dispatch-start` / `tool/ptc-dispatch`) while only
|
|
2173
|
+
* the outer curated result enters model history.
|
|
2174
|
+
* @param registry - the host tool registry (bindings cover its registered
|
|
2175
|
+
* tools; schema projection reads it live).
|
|
2176
|
+
* @param pipeline - the staged invocation pipeline built over the same
|
|
2177
|
+
* registry's mounting context (sub-calls dispatch through it).
|
|
2175
2178
|
* @param options - the bridge capabilities described above.
|
|
2176
2179
|
* @returns the registry-ready definition.
|
|
2177
2180
|
*/
|
|
2178
|
-
function createRunCellTool(registry, options) {
|
|
2181
|
+
function createRunCellTool(registry, pipeline, options) {
|
|
2179
2182
|
const { requireRuntime, maxParallel, shapeDispatchLog, warn } = options;
|
|
2180
2183
|
return defineTool({
|
|
2181
2184
|
name: EVAL_NAME,
|
|
@@ -2320,16 +2323,6 @@ function createRunCellTool(registry, options) {
|
|
|
2320
2323
|
parent: exec.token,
|
|
2321
2324
|
signal: runController.signal
|
|
2322
2325
|
};
|
|
2323
|
-
const scheduler = registry[TOOL_RUNTIME_SCHEDULER];
|
|
2324
|
-
if (scheduler === void 0) {
|
|
2325
|
-
let resolvedFrom;
|
|
2326
|
-
try {
|
|
2327
|
-
resolvedFrom = createRequire(import.meta.url).resolve("@deepseek-ai/dsh-tools");
|
|
2328
|
-
} catch {
|
|
2329
|
-
resolvedFrom = "(unresolvable from the plugin position)";
|
|
2330
|
-
}
|
|
2331
|
-
throw new Error(`${EVAL_NAME}: the tools service mounted by this composition lacks the TOOL_RUNTIME_SCHEDULER symbol as this plugin sees it — REPL sub-dispatch cannot run. Most likely a dual-copy dsh-tools: the symbol keying the mounted ToolRuntime instance is not the symbol this plugin imported (named methods like executionMode keep working across copies; only the symbol-keyed field goes missing). This plugin resolves @deepseek-ai/dsh-tools from: ${resolvedFrom}. Check the deployed plugin's node_modules for stray @deepseek-ai entries (only schemastery and cosmokit belong there)`);
|
|
2332
|
-
}
|
|
2333
2326
|
const outcome = await new Promise((resolve$1, reject) => {
|
|
2334
2327
|
let parked;
|
|
2335
2328
|
const settle = (result) => {
|
|
@@ -2375,7 +2368,7 @@ function createRunCellTool(registry, options) {
|
|
|
2375
2368
|
pendingQueue.push({
|
|
2376
2369
|
flight: Promise.resolve(),
|
|
2377
2370
|
settled: false,
|
|
2378
|
-
classify: () =>
|
|
2371
|
+
classify: () => pipeline.executionMode(input).kind,
|
|
2379
2372
|
abandon: () => {
|
|
2380
2373
|
reject(/* @__PURE__ */ new Error(`${EVAL_NAME} run is over (${String(runController.signal.reason)}); ${name$1} tool call abandoned`));
|
|
2381
2374
|
},
|
|
@@ -2393,9 +2386,9 @@ function createRunCellTool(registry, options) {
|
|
|
2393
2386
|
arguments: normalized.logged
|
|
2394
2387
|
});
|
|
2395
2388
|
try {
|
|
2396
|
-
const prepared = await
|
|
2389
|
+
const prepared = await pipeline.prepare(input);
|
|
2397
2390
|
if (prepared.kind === "dispatch") {
|
|
2398
|
-
this.flight =
|
|
2391
|
+
this.flight = pipeline.dispatch(prepared.exec).then((dispatchOutcome) => {
|
|
2399
2392
|
parked = {
|
|
2400
2393
|
kind: dispatchOutcome.kind,
|
|
2401
2394
|
exec: prepared.exec,
|
|
@@ -2422,7 +2415,7 @@ function createRunCellTool(registry, options) {
|
|
|
2422
2415
|
async commit() {
|
|
2423
2416
|
if (parked === void 0) return;
|
|
2424
2417
|
try {
|
|
2425
|
-
const result =
|
|
2418
|
+
const result = await pipeline.complete(parked.exec, parked);
|
|
2426
2419
|
for (const context of result.additionalContexts ?? []) exec.deferContext(context);
|
|
2427
2420
|
if (result.concludesTurn) exec.concludeTurn();
|
|
2428
2421
|
settle(result);
|
|
@@ -2552,7 +2545,8 @@ function apply(ctx, config) {
|
|
|
2552
2545
|
}
|
|
2553
2546
|
return runtimeCtx.get("workflowEngine");
|
|
2554
2547
|
};
|
|
2555
|
-
runtimeCtx
|
|
2548
|
+
const pipeline = new ToolExecutionPipeline(runtimeCtx);
|
|
2549
|
+
runtimeCtx.tools.register(createRunCellTool(registry, pipeline, {
|
|
2556
2550
|
requireRuntime,
|
|
2557
2551
|
maxParallel,
|
|
2558
2552
|
shapeDispatchLog,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hub PTY pump — the DUMB child of the canonical PTY session over a Hub
|
|
3
|
+
// machine (0.3.2-c). The session (better-dsh/src/remote/pty-session.ts)
|
|
4
|
+
// spawns `script -qfec 'stty -echo; … exec node <this file>' /dev/null`, so
|
|
5
|
+
// this process's stdin/stdout ride a host-side PTY; we forward those bytes
|
|
6
|
+
// over one WebSocket to the peer hub's dumb PTY executor
|
|
7
|
+
// (/api/superd/exec/pty: one WS = one `script -qfec bash /dev/null`, text
|
|
8
|
+
// frames = stream bytes, {"op":…} JSON frames = transport control, WS close
|
|
9
|
+
// = peer group-kill). All session intelligence (nonce framing, init
|
|
10
|
+
// handshake, env E-frames, two-stage interrupt, idle TTL) is in-band bytes
|
|
11
|
+
// produced by the SESSION — this pump never parses the stream, only the
|
|
12
|
+
// peer's control frames.
|
|
13
|
+
//
|
|
14
|
+
// Connection facts arrive via environment (never argv — credentials must not
|
|
15
|
+
// show up in `ps`):
|
|
16
|
+
// SUPERD_HUB_PTY_URL wss://…/api/superd/exec/pty (peer executor)
|
|
17
|
+
// SUPERD_HUB_PTY_COOKIE pairing cookie for the upgrade request
|
|
18
|
+
// SUPERD_HUB_PTY_WS ABSOLUTE path to a usable `ws` module (resolved by
|
|
19
|
+
// the caller before spawning; no resolution gamble
|
|
20
|
+
// here — a missing module is a creation-time error)
|
|
21
|
+
//
|
|
22
|
+
// Verbatim-asset discipline (helper-bin precedent): this file is copied
|
|
23
|
+
// byte-for-byte into lib/remote/ and the Super closure; no build step
|
|
24
|
+
// transforms it. Crash-proof by construction (mesh-gateway lesson): the
|
|
25
|
+
// 'error' listener is registered before anything can reject, and post-close
|
|
26
|
+
// sends are swallowed — the session's death path owns the teardown.
|
|
27
|
+
const PUMP_ENV_PREFIX = 'SUPERD_HUB_PTY_'
|
|
28
|
+
const [url, cookie, wsPath] = ['URL', 'COOKIE', 'WS'].map((k) => process.env[`SUPERD_HUB_PTY_${k}`])
|
|
29
|
+
|
|
30
|
+
// cookie may be legitimately empty (an unauthenticated test lane sends no header);
|
|
31
|
+
// the endpoint URL and the ws module path are the hard requirements.
|
|
32
|
+
if (url === undefined || url === '' || wsPath === undefined || wsPath === '') {
|
|
33
|
+
process.stderr.write(`hub-pty-pump: missing environment (${PUMP_ENV_PREFIX}URL/WS)\n`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let WebSocket
|
|
38
|
+
try {
|
|
39
|
+
const require = (await import('node:module')).createRequire(import.meta.url)
|
|
40
|
+
const mod = require(wsPath)
|
|
41
|
+
WebSocket = typeof mod === 'function' ? mod : mod.default
|
|
42
|
+
} catch (err) {
|
|
43
|
+
process.stderr.write(`hub-pty-pump: cannot load ws module at ${wsPath} — ${err instanceof Error ? err.message : String(err)}\n`)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
if (typeof WebSocket !== 'function') {
|
|
47
|
+
process.stderr.write('hub-pty-pump: the ws module did not yield a WebSocket constructor\n')
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const socket = new WebSocket(url, { headers: cookie ? { cookie } : {} })
|
|
52
|
+
let opened = false
|
|
53
|
+
let forward = true
|
|
54
|
+
|
|
55
|
+
socket.on('error', (err) => {
|
|
56
|
+
// Always-on listener: an ECONNREFUSED or a rejected 401 upgrade must be a
|
|
57
|
+
// reported death, never an uncaught exception.
|
|
58
|
+
process.stderr.write(`hub-pty-pump: ${opened ? 'socket error' : 'connect failed'} — ${err instanceof Error ? err.message : String(err)}\n`)
|
|
59
|
+
})
|
|
60
|
+
socket.on('open', () => { opened = true; pumpStdin() })
|
|
61
|
+
socket.on('message', (data) => {
|
|
62
|
+
if (!forward) return
|
|
63
|
+
const text = typeof data === 'string' ? data : Array.isArray(data) ? Buffer.concat(data).toString('utf8') : Buffer.from(data).toString('utf8')
|
|
64
|
+
// Transport control frames (peer: {"op":"exit"} / {"op":"spawn-error"}) are
|
|
65
|
+
// addressed to THIS pump, not to the shell stream — the exit frame is
|
|
66
|
+
// followed by a socket close, which settles us below.
|
|
67
|
+
if (text.startsWith('{"op":')) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(text)
|
|
70
|
+
if (typeof parsed.op === 'string') {
|
|
71
|
+
if (parsed.op === 'spawn-error' && typeof parsed.error === 'string') process.stderr.write(`hub-pty-pump: peer could not spawn the terminal — ${parsed.error}\n`)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
} catch { /* not JSON after all: fall through and forward */ }
|
|
75
|
+
}
|
|
76
|
+
process.stdout.write(text)
|
|
77
|
+
})
|
|
78
|
+
socket.on('close', () => {
|
|
79
|
+
forward = false
|
|
80
|
+
// Before open this is a failed connection (diag already on stderr); after
|
|
81
|
+
// open it is the peer session ending — either way the child exiting is the
|
|
82
|
+
// session's dead signal.
|
|
83
|
+
process.exit(opened ? 0 : 1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
function pumpStdin() {
|
|
87
|
+
process.stdin.setEncoding('utf8')
|
|
88
|
+
process.stdin.on('data', (chunk) => {
|
|
89
|
+
if (!opened) return
|
|
90
|
+
try { socket.send(chunk) } catch { /* closing: the close handler exits us */ }
|
|
91
|
+
})
|
|
92
|
+
// The session's kill path tears down the process group; a plain stdin end
|
|
93
|
+
// (no kill) still means nobody will type more — close politely so the peer
|
|
94
|
+
// reaps its side.
|
|
95
|
+
process.stdin.on('end', () => { try { socket.close() } catch { /* already closing */ } })
|
|
96
|
+
process.stdin.on('error', () => { /* EPIPE on teardown: the close path owns the exit */ })
|
|
97
|
+
}
|