better-dsh 0.2.5-b → 0.2.5-c
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/50_test-reports/2026-10-03-pnpm11/345/271/264/351/276/204/351/227/250/351/224/201/346/226/207/344/273/266/347/233/270/344/275/215/345/256/236/350/257/201.md +54 -0
- package/docs/50_test-reports/2026-10-03-remote-BYO-PTY-label/351/207/215/346/236/204/347/254/254/344/270/200/344/272/272/347/247/260/345/256/236/346/265/213/346/212/245/345/221/212.md +447 -0
- package/docs/specs/remote-pty-label/spec.md +126 -0
- package/lib/mobile/plugin.d.ts +1 -1
- package/lib/remote/plugin.js +139 -44
- package/lib/url-schemes/index.d.ts +11 -11
- package/lib/web-password.d.ts +7 -7
- package/lib/{web-trust-PIXuF0bN.d.ts → web-trust-CL9P6jVe.d.ts} +7 -7
- package/lib/web-trust.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# pnpm 11 年龄门:`minimumReleaseAgeExclude` 对"锁文件校验相位"完全无效(2026-10-03 实证)
|
|
2
|
+
|
|
3
|
+
- **触发**:user 在 homepc(prod)执行 `dsh plugin --profile web add better-dsh@0.2.5-b`,被拦:
|
|
4
|
+
```
|
|
5
|
+
✗ Lockfile failed supply-chain policy check (431 entries in 2.6s)
|
|
6
|
+
[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:
|
|
7
|
+
super-dsh@0.1.9 was published at 2026-10-02T07:45:36.963Z, within the minimumReleaseAge cutoff
|
|
8
|
+
```
|
|
9
|
+
即:**装 better-dsh 被 _另一个插件_ super-dsh@0.1.9 的年龄拦下**(该条目在 profile 锁文件里,发布未满 24h)。
|
|
10
|
+
- **环境**:pnpm 11.7.0,profile `~/.dsh/profiles/web`,core dsh 0.2.0-rc.2。
|
|
11
|
+
|
|
12
|
+
## 一、决定性 A/B(同一份 prod 锁文件,只改变量)
|
|
13
|
+
|
|
14
|
+
把 prod profile 的 `package.json` + `pnpm-lock.yaml` + `pnpm-workspace.yaml` 复制到 `.scratch/age-probe/`,
|
|
15
|
+
每个变体只往 `pnpm-workspace.yaml` 追加一行,跑 `pnpm install --lockfile-only`,只看策略结论行:
|
|
16
|
+
|
|
17
|
+
| 变体 | `minimumReleaseAgeExclude` 内容 | 结果 |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| **V1** 现状 | `better-dsh` + `super-dsh@0.1.5-a` + `super-dsh@0.1.9` | ✗ failed(复现现场报错) |
|
|
20
|
+
| **V2** | V1 + 裸名 `super-dsh` | ✗ failed |
|
|
21
|
+
| **V3** | V2 + 裸名 `better-dsh` | ✗ failed |
|
|
22
|
+
| **V4** | V1 + `minimumReleaseAge: 0` | **✓ passes** |
|
|
23
|
+
|
|
24
|
+
**结论**:在 pnpm 11.7.0 上,`minimumReleaseAgeExclude` —— 无论 `pkg@x.y.z` 形式还是裸包名 ——
|
|
25
|
+
**都不能豁免锁文件校验相位**;唯一有效旋钮是 `minimumReleaseAge` 本身(设 0,或设成小于该条目年龄的值)。
|
|
26
|
+
本仓 AGENTS.md 此前"持久形式 = 裸包名,全相位生效(scratch A5–A7 实证)"的结论**是错的,已订正**。
|
|
27
|
+
|
|
28
|
+
## 二、修复(prod 现场)
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# ~/.dsh/profiles/web/pnpm-workspace.yaml (追加)
|
|
32
|
+
minimumReleaseAge: 0
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- 备份:`pnpm-workspace.yaml.bak-minimumReleaseAge-20261003`。
|
|
36
|
+
- 重跑 user 原命令:`Packages: +8 -95`,`Done in 4.8s`,pin 落到 `0.2.5-b`。
|
|
37
|
+
- 装机核查:`version 0.2.5-b` / `deps: None, optional: None` / `lib/kernel-bridge.py` 在位 /
|
|
38
|
+
树里无 `zeromq` / lockfile integrity `sha512-M93FoCq…BYYrHQ==`(与 registry 发布物、与 dev3 拉到的**同一份**)。
|
|
39
|
+
|
|
40
|
+
## 三、影响面与建议
|
|
41
|
+
|
|
42
|
+
- **任何"发布后立刻装"的 profile 都会撞这道门**,而且拦的往往是**别的**插件条目(本例是 super-dsh)。
|
|
43
|
+
当前 prod profile 的 `minimumReleaseAgeExclude` 三条全是无效配置(留着无害,但别再依赖它)。
|
|
44
|
+
- 两条可行路线:① profile 级 `minimumReleaseAge: 0`(本次采用,最省事,代价是该 profile 放弃 24h 冷却);
|
|
45
|
+
② 等服务窗口(super-dsh@0.1.9 → 2026-10-03T07:45Z / 15:45 HKT 自然解锁)——但装完 better-dsh@0.2.5-b 后,
|
|
46
|
+
它自己又变成"未满 24h 条目",后续任何 pnpm 操作会**再撞一次**,所以路线 ② 只是把窗口推后。
|
|
47
|
+
- `dsh plugin add` 输出里的 `dshmarket@1.40.0 is incompatible with dsh 0.2.0-rc.2`(peer 不符)是**既有**状态
|
|
48
|
+
(untouched 分支:保留安装、启动时 deny,需 `dsh plugin allow-version` 豁免),与本次改动无关。
|
|
49
|
+
|
|
50
|
+
## 四、未做
|
|
51
|
+
|
|
52
|
+
- 未重启 `dsh.service`:prod 运行中的 daemon 仍持有 0.2.5-a 的代码,激活需重启(重启会中断当前 GUI 会话;
|
|
53
|
+
session 数据在盘上,刷新即回)。
|
|
54
|
+
- 未提交 AGENTS.md 订正与本报(git 侧待 user 指示)。
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# `remote` 工具(BYO-PTY label 重构)第一人称实测报告
|
|
2
|
+
|
|
3
|
+
- **日期**: 2026-10-03(矩阵 18:37–18:52 +08:00,UTC 10:37–10:52)
|
|
4
|
+
- **被测对象**: `better-dsh` 的 `remote` 工具(`src/remote/*`,11 模块)
|
|
5
|
+
- **代码身份**: 工作树 `better-dsh` @ HEAD `5aa48ea`(release 0.2.5-b)+ **未提交的 remote 重构改动**(6 文件,+296/−47:`driver.ts` / `pty-session.ts` / `status.ts` / `tool.ts` + 两个 spec)
|
|
6
|
+
- **实例**: Dev/Test 1 源码级 4999 rig(`.test/home/compat`,better-dsh 以 tarball 装入 profile);**本 agent 自己的 live session**
|
|
7
|
+
- **第一人称载体**: 本 session 直接派发 `remote` 工具,共 **60 处**工具调用记录(原生 `tool/call`+`tool/result`)
|
|
8
|
+
- **对照**: `docs/50_test-reports/2026-09-23-remote-framework实测报告.md`(18 case @ `c696fca`)、`…-第一人称复测报告.md`(@ `ac8720a`)
|
|
9
|
+
- **prod(3080)未触碰**;未改任何源码/构建产物;未重启 4999
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 一、被测代码身份(先证「所测即所构」)
|
|
14
|
+
|
|
15
|
+
| 锚点 | 值 |
|
|
16
|
+
|---|---|
|
|
17
|
+
| 构建产物 | `better-dsh/lib/remote/plugin.js`(18:28:44) |
|
|
18
|
+
| rig 装入物 | `.test/home/compat/profiles/web/node_modules/better-dsh/lib/remote/plugin.js`(18:29:06) |
|
|
19
|
+
| **md5(两者相同)** | `b9669d1bf34a4e975e2e55a38394a079` |
|
|
20
|
+
| 运行面证据 | 本 session 的 `remote` tool description 已含 `pty:<label>` / `[E_LABEL_CONFLICT]` / `[E_NO_SESSION]` 新文案;未提交新面活体生效 |
|
|
21
|
+
| 单测 | `npx vitest run test/remote/` → **11 files / 89 tests 全过**(原 78/78,本轮改动 +11) |
|
|
22
|
+
| 类型 | `npx tsc --noEmit` → **exit 0**(旧报告记录的 14 条基线错已归零) |
|
|
23
|
+
|
|
24
|
+
本次改动把 BYO-PTY 从「`{spawn, cmd}` 独立入口 + 池键 `s:<spawn原文>`」改为 **`{target:"pty:<label>", spawn?, cmd}`:label 自选、即复用入口;spawn 降级为「仅创建时出席」的伴生参数**,并新增池内活 label 作为裸名候选表末位、`pty:` 专用状态行、roster `pty:` 渲染与一族错误码。
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 二、结论速览
|
|
29
|
+
|
|
30
|
+
| 面 | 结果 |
|
|
31
|
+
|---|---|
|
|
32
|
+
| 三传输基线(ssh / docker / incus) | **全过**(oneshot 退出码、stderr 逐字、cwd/环境持久、超时打断、多行单帧、伪 marker 免疫、stdin、输出帽) |
|
|
33
|
+
| BYO-PTY 新面(建/复用/同源提示/冲突/no-session/裸名末位候选/mode 锁/标签字符集/状态行/roster) | **全过**(20+ 项,见 §四.D) |
|
|
34
|
+
| 路由优先级(ssh → docker → incus → 池内 pty → ssh 兜底) | **全过**,含 `corti`/`ctr-1` 撞名实测 |
|
|
35
|
+
| 参数守卫(9 个错误码) | **全过**,全部 fail-loud、零静默降级、提示可执行 |
|
|
36
|
+
| 失败路径观测 | **3 条记录在案**(§五 F1–F3),其中 F2 为真实 UX 缺陷 |
|
|
37
|
+
| 输出帽 | oneshot 30k 尾窗 ✅、pty 30k 尾窗 ✅(4M 环仅单测) |
|
|
38
|
+
| 审计 | 原生 `tool/call`+`tool/result` 60 条,无自定义事件(P17 决议保持一致) |
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 三、方法与边界
|
|
43
|
+
|
|
44
|
+
- 全部 45 项检查均由本 agent 在**真实运行时**里以 `remote` 工具派发;无 headless 代跑、无桩。
|
|
45
|
+
- 逐条取证:文本取 verbatim 关键片段,退出码/时长/cwd/`session`/`truncated` 取自工具返回的结构化字段。
|
|
46
|
+
- 未覆盖(诚实边界):idle TTL **600s 实时收割**(单测覆盖)、4M pty 环形帽(单测 + 旧报告 live)、`ws://`(spec 远期)、ssh→docker 嵌套穿透(dev4 无 docker);`E_SESSION_START` 取 30s init 超时真路径(已实测)。
|
|
47
|
+
- 现场只做了只读型远程命令(`echo` / `hostname` / `uname` / `cat /etc/os-release` / `cd` / `export` / `kill -9 $$` 自杀 / `head -c`);未改任何远端文件。
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 四、矩阵逐条(verbatim)
|
|
52
|
+
|
|
53
|
+
### A. roster 与状态探测
|
|
54
|
+
|
|
55
|
+
| # | 调用 | 结果 | 判定 |
|
|
56
|
+
|---|---|---|---|
|
|
57
|
+
| A1 | `{}` | 4 行:`ssh hosts (~/.ssh/config): github.com, dev2, dev3, mac, dev1, 137.131.54.174, dev4, ctr-1` / `docker containers: corti (running), jellyfin (running)` / `incus containers: ctr-1 (RUNNING)` / `live pty sessions: …` | ✅ |
|
|
58
|
+
| A2 | `{dev4}` | `probe: reachable in 2666ms (just now, on demand)` + `session: none — dials on first exec` | ✅ |
|
|
59
|
+
| A3 | `{incus:ctr-1}` | `probe: container RUNNING (just now, on demand)` | ✅ |
|
|
60
|
+
| A4 | `{docker:corti}` | `probe: container running (just now, on demand)` | ✅ |
|
|
61
|
+
| A5 | `{192.0.2.1}`(TEST-NET 黑洞) | `probe: unreachable — ssh: connect to host 192.0.2.1 port 22: Connection timed out`;词汇表全程无 `offline` | ✅ |
|
|
62
|
+
|
|
63
|
+
### B. oneshot(ssh / docker / incus 三传输)
|
|
64
|
+
|
|
65
|
+
| # | 调用 | 结果 | 判定 |
|
|
66
|
+
|---|---|---|---|
|
|
67
|
+
| B1 | dev4 `echo "A1 $(whoami)@$(hostname)"; git --version; python3 -V` | `A1 u1@dev4` / `git version 2.43.0` / `Python 3.12.3`,`exit 0 · 2.4s` | ✅ |
|
|
68
|
+
| B2 | dev4 `echo A2-start; exit 42; echo unreachable` | 输出 `A2-start`,**`exit 42`**(二进制退出码零扫描) | ✅ |
|
|
69
|
+
| B3 | docker:corti | `A3 f698ca647029` / `Debian GNU/Linux 13 (trixie)`,`0.1s` | ✅ |
|
|
70
|
+
| B4 | incus:ctr-1 | `A4 ctr-1` / `Ubuntu 26.04 LTS` / `7.0.0-34-generic` | ✅ |
|
|
71
|
+
| B5 | ssh 不存在的 host | `exit 255`,`[stderr] ssh: Could not resolve hostname no-such-host-xyz: Temporary failure in name resolution` | ✅ 逐字 |
|
|
72
|
+
| B6 | docker 不存在的容器 | `exit 1`,`[stderr] Error response from daemon: No such container: no-such-ctr` | ✅ 逐字 |
|
|
73
|
+
| B7 | incus 不存在的容器 | `exit 1`,`[stderr] Error: Failed to fetch instance "no-such-ctr" in project "default": Instance not found` | ✅ 逐字 |
|
|
74
|
+
| B8 | dev4 `cat` + `stdin:"STDIN-ONESHOT-OK"` | 输出恰 `STDIN-ONESHOT-OK` | ✅ |
|
|
75
|
+
| B9 | dev4 输出 50 015 字符 | 首行 `[truncated: showing last 30000 of 50015 chars]`,尾部 `CAP-TAIL-MARK` 在位,`exit 0 · truncated, original 50015 chars` | ✅ |
|
|
76
|
+
| B10 | incus `nohup sleep 5 >/dev/null 2>&1 & echo bg-ok` | 输出恰 `bg-ok`,stderr 空,`0.0s`(Non-Goal:使用者自负重定向) | ✅ |
|
|
77
|
+
| B11 | `{corti}`(docker 容器 vs 无同名 ssh) / `{ctr-1}`(ssh config vs incus 撞名) | `corti` → 容器内 `f698ca647029 /build`;`ctr-1` → ssh(hostname `ctr-1`)——优先级 ssh→docker→incus 与旧报告一致 | ✅ |
|
|
78
|
+
|
|
79
|
+
### C. pty(transport target)——持久状态与生命周期
|
|
80
|
+
|
|
81
|
+
| # | 调用 | 结果 | 判定 |
|
|
82
|
+
|---|---|---|---|
|
|
83
|
+
| C1 | dev4 pty `cd /tmp && export LABVAR=hello && pwd && hostname` | `/tmp` / `dev4`,`cwd /tmp` | ✅ |
|
|
84
|
+
| C2 | 次轮 `pwd; echo LABVAR=$LABVAR; date +%T` | `/tmp` / `LABVAR=hello` / `18:38:09`(跨轮持久) | ✅ |
|
|
85
|
+
| C3 | `sleep 300; echo NEVER-REACHED` + `timeout:2` | 输出空,**`exit 130 · 2.2s · interrupted`**,`cwd /tmp` 保持 | ✅ |
|
|
86
|
+
| C4 | 打断后 `echo ALIVE; pwd; echo LABVAR=…` | `ALIVE / /tmp / LABVAR=hello`(会话未被杀死) | ✅ |
|
|
87
|
+
| C5 | `"echo a\necho b"` | 一帧 `a\nb` | ✅ |
|
|
88
|
+
| C6 | `printf '\033]133;D;deadbeefdeadbeef;99\007'; echo real-after-marker` | `real-after-marker`,**`exit 0`(非 99)** | ✅ |
|
|
89
|
+
| C7 | `read X; echo "GOT:[$X]"` + `stdin:"REPL-VALUE\n"` | `GOT:[REPL-VALUE]` | ✅ |
|
|
90
|
+
| C8 | 200 015 字符(pty 收集) | `[truncated: showing last 30000 of 200015 chars]`,尾部 `PTY-TAIL-MARK` 在位,`exit 0` | ✅ |
|
|
91
|
+
| C9 | `kill -9 $$` → 次轮 `echo back; pwd` | 首个 `exit null · 0.2s`;次轮首行逐字 `[remote: session reconnected to fresh shell; cwd reset to default]` + `back /home/u1` | ✅ |
|
|
92
|
+
| C10 | 黑洞主机 pty | `Error: [E_SESSION_START] remote pty session 'session-a7b0aad7-…|t:192.0.2.1' did not initialize in 30s`(30s 硬门) | ✅ 见 F1 |
|
|
93
|
+
|
|
94
|
+
### D. BYO-PTY(本轮新面,逐条)
|
|
95
|
+
|
|
96
|
+
| # | 调用 | 结果 | 判定 |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| D1 | `{target:"pty:alpha", spawn:"ssh dev4", cmd:"hostname; cd /var/tmp && export BVAR=zeta && pwd"}` | `dev4 / /var/tmp`,结构字段 `cwd=/var/tmp · pty:alpha`(**新:返回值带 session label**) | ✅ |
|
|
99
|
+
| D2 | `{target:"pty:alpha", cmd:"pwd; echo BVAR=$BVAR; whoami"}`(**无 spawn**) | `/var/tmp / BVAR=zeta / u1`(label 即复用入口) | ✅ |
|
|
100
|
+
| D3 | 同 label 再给**同源** spawn | 首行引导:`[remote: pty:alpha already runs that exact command and is persistent — reuse it with { target: "pty:alpha", cmd: "…" }; spawn is only needed to create.]`,命令照跑 | ✅ |
|
|
101
|
+
| D4 | 同 label 给**异源** spawn(`ssh dev3`)+ `cmd:"echo SHOULD-NOT-RUN"` | `Error: [E_LABEL_CONFLICT] … nothing was run.` + `existing spawn: "ssh dev4"` / `given spawn: "ssh dev3"` + 两条修复路径;**命令未执行** | ✅ |
|
|
102
|
+
| D5 | `{target:"pty:beta", spawn:"docker exec -it corti bash", …}` | 容器内 `f698ca647029` / `Debian 13`,`cwd /root` | ✅ |
|
|
103
|
+
| D6 | pty:beta `kill -9 $$` → 次轮 | `exit null` → 次轮 `[remote: session reconnected…]` + 容器 hostname(**按原 spawn 自愈重连**) | ✅ |
|
|
104
|
+
| D7 | 标签字符集正例 `pty:lab.2_x` | 创建成功,`session: idle 5s (connected)` | ✅ |
|
|
105
|
+
| D8 | `{target:"pty:corti", spawn:"docker exec -it corti bash"}` 存在时调 `{target:"corti", cmd…}` | 命中 **docker 容器**(容器内 `/build`),不命中活 pty 会话——「撞名靠前者胜」 | ✅ 见 F3 |
|
|
106
|
+
| D9 | `{target:"alpha", cmd:"echo BARE-HIT; pwd; echo BVAR=$BVAR"}`(裸名,无 mode) | `BARE-HIT / /var/tmp / BVAR=zeta`,结构字段 `pty:alpha`(**候选表末位命中**) | ✅ |
|
|
107
|
+
| D10 | `{target:"alpha", mode:"oneshot", cmd:…}` | `exit 255 · ssh: Could not resolve hostname alpha…`——显式 oneshot **不吃** pty 候选,旧行为逐位保留 | ✅ |
|
|
108
|
+
| D11 | `{target:"pty:probe-none"}` / `{target:"pty:alpha"}` / `{target:"pty:deadspawn"}` | `session: none — no live PTY under this label`(**不会自建**,与 transport 的 "dials on first exec" 明确区分)/ `idle 0s (connected)` / `died — next exec cold-starts a fresh shell` | ✅ |
|
|
109
|
+
| D12 | `{}`(池内有会话时) | `live pty sessions: dev4 [ready, idle 4s], pty:alpha [ready, idle 0s], pty:beta [ready, idle 4s]`——transport 会话裸名、BYO 会话 `pty:` 前缀,池内部标记(`sessionKey|p:`)零泄漏 | ✅ |
|
|
110
|
+
|
|
111
|
+
### E. 参数守卫(fail-loud)
|
|
112
|
+
|
|
113
|
+
| # | 输入 | 输出(verbatim 要点) |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| E1 | `pty:bad label` / `pty:`(空标签) | `[E_BAD_LABEL] … must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ (got "bad label"/"")` |
|
|
116
|
+
| E2 | `pty:alpha` + `mode:"oneshot"` | `[E_BAD_MODE] remote: "pty:alpha" is a PTY selector — mode is locked to 'pty'` |
|
|
117
|
+
| E3 | `pty:ghost`(从未创建、无 spawn) | `[E_NO_SESSION] … it is reaped after the idle TTL (600s)` + `Create it: …` |
|
|
118
|
+
| E4 | `dev4` + `spawn` | `[E_PARAMS] remote: spawn requires a "pty:<label>" target …` |
|
|
119
|
+
| E5 | 仅 `{spawn, cmd}`(无 target) | 同上 E_PARAMS(spawn 不能独立成形) |
|
|
120
|
+
| E6 | `{target:"dev4", stdin:"…"}`(无 cmd) | `[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd — status probes take no input` |
|
|
121
|
+
| E7 | `timeout:0` | `[E_BAD_TIMEOUT] … must be a positive number of seconds (got 0)` |
|
|
122
|
+
| E8 | `mode:"bogus"` | 宿主 schema 先拦:`Error: invalid arguments: "mode" must be one of ["oneshot","pty"]` |
|
|
123
|
+
| E9 | `pty:deadspawn` + spawn 坏命令(`ssh … no-such-host-xyz`) | `Error: [E_SESSION_DIED] remote pty session 'session-a7b0aad7-…\|p:deadspawn' died before initializing` |
|
|
124
|
+
| E10 | 死标签**复用**(无 spawn)/ 换 spawn | 复用 → 同 E_SESSION_DIED(重跑同一坏 spawn);换 spawn → E_LABEL_CONFLICT —— **标签被锁死**(F2) |
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 五、问题与观察(按严重度排序)
|
|
129
|
+
|
|
130
|
+
### F1(中)`E_SESSION_DIED` / `E_SESSION_START` 泄漏池内部键,且丢掉原生传输错误
|
|
131
|
+
|
|
132
|
+
实测两条:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
Error: [E_SESSION_DIED] remote pty session 'session-a7b0aad7-b3fc-4bff-949d-d12d4ab9d2b4|p:deadspawn' died before initializing
|
|
136
|
+
Error: [E_SESSION_START] remote pty session 'session-a7b0aad7-b3fc-4bff-949d-d12d4ab9d2b4|t:192.0.2.1' did not initialize in 30s
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- 直接暴露内部池键(agent session id + `|p:` / `|t:` 内部标记 + pty label 或 target)。同一份代码在 roster 渲染处专门做了「池 key 内部标记剥离」(D12 实测),此处漏做——模型面两条规则不一致。
|
|
140
|
+
- 两条错误都**没有** `; transport stderr tail: …` 尾注(`doStart()` 里该分支存在),因为 BYO/ssh 的传输错误经 pty 从 `script`/`sshd` 走 **stdout**,而 `diag` 只收子进程 stderr ⇒ 原生原因(`Could not resolve hostname` / `Connection timed out`)在错误面消失。与「Native transport errors pass through verbatim」的对外承诺相邻面不一致:oneshot 逐字透传(B5–B7 实测),pty 初始化失败只给一个内部键。
|
|
141
|
+
- 建议:错误面统一走 `renderXXX` 的模型面形态(`pty:deadspawn — …`),并把 init 期内 pty 输出尾部也纳入 `diag`(或明确注明「无原生细节」)。
|
|
142
|
+
|
|
143
|
+
### F2(中)一次坏的 spawn 会把 label **锁死**,无自救入口
|
|
144
|
+
|
|
145
|
+
E9→E10 实测链条:
|
|
146
|
+
|
|
147
|
+
1. `{target:"pty:deadspawn", spawn:"ssh … no-such-host-xyz", cmd:…}` → E_SESSION_DIED;
|
|
148
|
+
2. 再复用(不给 spawn)→ **同一条 E_SESSION_DIED**(dispatch 对 `state==='dead'` 走 `start()`,重跑**冻结的**首建 argv);
|
|
149
|
+
3. 改给可用 spawn → **E_LABEL_CONFLICT**(origin 不同即拒),且提示 `To reuse it: … (drop spawn)` 对死会话是**无效建议**。
|
|
150
|
+
|
|
151
|
+
代码面进一步:`markDead()` 会 `clearIdleTimer()`,而 idle timer 仅在 `onFrame`(初始化成功)里 `resetIdleTimer()` —— **died-before-init 的会话没有任何收割定时器**,`PtyPool.disposeAll/onDead` 也不会触发,`getOrCreate` 只替换 `disposed` 态。⇒ 该池项在**进程生命周期内**留存,label 事实上永久不可复用(重启 rig 或换 label 是唯二出路)。单测只有「ready 后 idle TTL 收割」一条(`idle TTL disposes the session and drops it from the pool`),无 died-before-init 收割用例。
|
|
152
|
+
(诚实边界:600s 实时收割未跑;「永久」为代码推论 + 观察到的「复用仍死、换源被拒」。)
|
|
153
|
+
|
|
154
|
+
建议:a) `markDead()` 对 **init 期死亡**的会话安排一次 dispose/出池(或 `getOrCreate` 把 `dead` 且 `origin!==undefined` 的 BYO 项视同可替换);b) E_LABEL_CONFLICT 提示分叉:`existing.state==='dead'` 时改说「该 label 的首建命令已失败,请换 label 或先释放」。
|
|
155
|
+
|
|
156
|
+
### F3(低)裸名会被同名实体遮蔽,`pty:` 是唯一显式入口
|
|
157
|
+
|
|
158
|
+
`pty:corti` 活会话存在时,`{target:"corti"}` 仍命中 docker 容器(D8)——这与 description 的「on a collision the earlier source wins」完全一致,属**文档化行为**,不是 bug;但模型若用裸名做「按名找人」很容易把手伸进容器/主机而非自己的 BYO 会话。D9/D10 证明「裸名命中 pty」仅在三个 transport 都不撞名时成立。建议在 description 的裸名顺序里把这一后果写得更醒目(或让 `pty:` 成为文档首推写法)。
|
|
159
|
+
|
|
160
|
+
### F4(信息)轮次观察(不阻塞)
|
|
161
|
+
|
|
162
|
+
- 旧报告 §六.1「oneshot 等 ssh 会话通道」本轮未复测(B10 用 incus 0.0s 返回);该披露措辞仍开放。
|
|
163
|
+
- `E_SESSION_START` 的 30s 硬门在黑洞主机上如期触发(C10),无挂死、无残留(`{192.0.2.1}` 后续 status 报 `session: died`,语义自洽)。
|
|
164
|
+
- `pty` 面 stdout/stderr 合流(spec §4.2 诚实代价)保持不变。
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 六、单测 / 类型基线(工作树 @ 5aa48ea + 未提交改动)
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
npx vitest run test/remote/
|
|
172
|
+
Test Files 11 passed (11)
|
|
173
|
+
Tests 89 passed (89) Duration 24.55s
|
|
174
|
+
npx tsc --noEmit → exit 0(零错误)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
旧报告基线为「78/78(11 spec)+ tsc 14 既有错」;本轮改动新增 11 个用例(`driver.spec` +`tool.spec` 为主),类型面已归零。
|
|
178
|
+
|
|
179
|
+
## 七、审计与清理
|
|
180
|
+
|
|
181
|
+
- **审计**:本 session 日志 `session-a7b0aad7-b3fc-4bff-949d-d12d4ab9d2b4/session.v4.jsonl.zstd` 含 **60** 处 `"name":"remote"` 记录(原生 `tool/call`+`tool/result`);插件未注册任何自定义 session 事件(与 P17 决议一致)。
|
|
182
|
+
- **清理**:未改源码/产物、未重启 rig、未动 prod;远端只执行只读命令,无残留文件。内存池会话(`dev4` / `pty:alpha` / `pty:beta` / `pty:lab.2_x` / `pty:deadspawn` / `192.0.2.1`)留在本 agent 进程池内——ready 者由 600s idle TTL 收割,died-before-init 者按 F2 留存;工具面目前无显式 dispose 入口(可归入 F2 的修复面)。
|
|
183
|
+
|
|
184
|
+
## 八、遗留与后续建议
|
|
185
|
+
|
|
186
|
+
1. **F2 优先**:给 died-before-init 的 BYO 池项以出池路径(收割或可替换),并让 E_LABEL_CONFLICT 对死会话给有效建议。
|
|
187
|
+
2. **F1**:E_SESSION_* 错误面去内部键 + 补原生原因(或明示缺失)。
|
|
188
|
+
3. 仍未覆盖:600s TTL 真机收割(单测覆盖)、4M pty 环形帽真机(单测 + 旧报告 live)、`ws://`(远期)、ssh→docker 嵌套穿透。
|
|
189
|
+
4. 发布纪律:本轮为**未提交工作树**的第一人称实测,按 AGENTS.md §〇,这**不构成** npm publish 前置条件;若要把该重构发包,需先固化提交/版本号,再由 user 明确放行。
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 九、复核轮 v2(2026-10-03 19:00–19:06 +08:00)— `byop:` 改名 + F1/F2 修复后重测
|
|
194
|
+
|
|
195
|
+
> 阅读提示:§一–§八 记的是**改名前的构建**(选择器写作 `pty:`);本轮该选择器已改名为 `byop:`,其余面基本同构。
|
|
196
|
+
|
|
197
|
+
### 9.1 本轮被测身份
|
|
198
|
+
|
|
199
|
+
| 锚点 | 值 |
|
|
200
|
+
|---|---|
|
|
201
|
+
| 代码 | `better-dsh` @ HEAD `5aa48ea`(0.2.5-b)+ **更新后的未提交改动**(7 文件,+357/−55:`driver` / `pty-session` / `status` / `tool` + 2 spec,另 root `AGENTS.md`) |
|
|
202
|
+
| 规格 | 新增 **`docs/specs/remote-pty-label/spec.md`**(118 行,8 条 Requirement + 14 个 Scenario)——本轮首次有正式 spec 可对照 |
|
|
203
|
+
| 构建产物 | `better-dsh/lib/remote/plugin.js` 18:56:50,md5 **`ca2924024a724b508ca0d37bac9b81c6`** |
|
|
204
|
+
| rig 装入物 | profile 内同路径文件 18:57:11,**md5 相同**(所测即所构) |
|
|
205
|
+
| 实例 | rig 于 19:00 前后重启(`.scratch/dsh-4999-test123.log` 第三个 boot token `XDt0ul…`);本 session 继续、池从零起 |
|
|
206
|
+
|
|
207
|
+
**本轮改动点(对着 §八 建议看)**:
|
|
208
|
+
|
|
209
|
+
1. **选择器改名 `pty:` → `byop:`**,`byop:<label>` 成为 BYO-PTY 唯一入口,`spawn` 降级为「仅创建时出席」的伴生参数;
|
|
210
|
+
2. 新增 **`displayKey()`**(`pty-session.ts`)为「池键 → 模型面名字」的**唯一**映射,roster 与错误面共用;
|
|
211
|
+
3. `PtySession` 增 `everReady`;**init 期死亡(从未 ready)即 `dispose()` 出池**,ready 过的死亡仍走 reconnect;
|
|
212
|
+
4. description 补一句「A spawn that fails to come up leaves nothing behind — the label stays free」;
|
|
213
|
+
5. 单测 `driver.spec` +`tool.spec` 扩到 92 例。
|
|
214
|
+
|
|
215
|
+
### 9.2 两条旧发现复核
|
|
216
|
+
|
|
217
|
+
| 旧编号 | 结论 | 本轮 verbatim 证据 |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| **F1**(E_SESSION_* 泄漏池内部键) | **键泄漏已修**;(native cause 仍缺 → 见下) | `Error: [E_SESSION_DIED] remote pty session 'byop:deadspawn' died before initializing` · `Error: [E_SESSION_START] remote pty session '192.0.2.1' did not initialize in 30s`——只出现模型面名字,`session-<uuid>` 与 `\|p:` / `\|t:` **零出现** |
|
|
220
|
+
| **F1 残留**(原生原因丢失) | **未修,降为 low** | 两条错误均**无** `; transport stderr tail:` 尾注(init 期 pty 报文走 stdout,`diag` 只收子进程 stderr);`Native transport errors pass through verbatim` 在 pty 初始化失败面仍不成立 |
|
|
221
|
+
| **F2**(坏 spawn 锁死 label) | **全修** | ① 失败后 `{byop:deadspawn}` status = `session: none — no live PTY under this label`(**已出池**,不再是 `died`);② 无 spawn 复用 = `[E_NO_SESSION]`(不再重跑冻结 argv);③ 换 spawn 新建 = **成功**(`REPURPOSE-OK / dev4 / /home/u1 · byop:deadspawn`),无 `[E_LABEL_CONFLICT]` |
|
|
222
|
+
| **F3**(裸名被同名实体遮蔽) | 保持文档化行为 | 活着的 `byop:corti` 在池内时,`{corti}` 仍命中 docker 容器(`SHADOW-CORTI / f698ca647029 / /build`)——`byop:` 是唯一显式入口 |
|
|
223
|
+
| ready-death 半支(spec「已 ready 的会话死亡仍可重连」) | ✅ | `byop:beta` `kill -9 $$` → `exit null`;status 仍 `session: died — next exec cold-starts a fresh shell`(**留池**);下次调用 `[remote: session reconnected to fresh shell; cwd reset to default]` + 容器 hostname |
|
|
224
|
+
|
|
225
|
+
### 9.3 新发现 F4(low–medium):roster 不按 agent 作用域过滤
|
|
226
|
+
|
|
227
|
+
双 agent 实测(本 agent 派一个子 agent 做探针,同 daemon):
|
|
228
|
+
|
|
229
|
+
- 子 agent 调 `{target:"byop:alpha"}`(本 agent 的活 label)→ `[E_NO_SESSION]`;**寻址层隔离成立** ✅
|
|
230
|
+
- 子 agent 建 `{target:"byop:shared", spawn:"ssh dev4"}` → 正常创建(其自己的命名空间)
|
|
231
|
+
- 本 agent 调 `{target:"byop:shared"}` → `[E_NO_SESSION]`(反向也隔离)✅
|
|
232
|
+
- **但**本 agent 的 `{}` roster 里出现了它:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
live pty sessions: byop:deadspawn [ready, …], byop:beta [ready, …], dev4 […], byop:alpha […],
|
|
236
|
+
byop:lab.2_x […], byop:corti […], byop:shared [ready, idle 5s]
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
即:roster 面 `this.pool.list()` 不带 sessionKey 过滤,于是**广告了调用方无法使用的 label**(照着用必得 `[E_NO_SESSION]`),并把他 agent 的 label 名暴露给本 agent。spec 只固定了「池键按 agent 隔离」与「B 拿 E_NO_SESSION」,**未写 roster 作用域**——属 spec 空白 + 面间不一致。建议:roster 按 `callCtx.sessionKey` 过滤(或在 spec 里显式承认全局可见)。
|
|
240
|
+
|
|
241
|
+
### 9.4 矩阵 v2(重跑 43 项)
|
|
242
|
+
|
|
243
|
+
| 组 | 项 | 结果 | 备注 |
|
|
244
|
+
|---|---|---|---|
|
|
245
|
+
| A roster / status(roster、byop:probe-none、dev4、incus、docker、192.0.2.1) | 6 | ✅ | `byop:` 状态行 `session: none — no live PTY under this label` |
|
|
246
|
+
| B oneshot(ssh/docker/incus、`exit 42`、stdin、ssh/docker/incus 三错误) | 8 | ✅ | 与 §四.B 同名用例逐条复现,stderr 仍逐字 |
|
|
247
|
+
| C pty transport(cwd/env 持久、`timeout:2` 打断 `exit 130 · interrupted`、打断后存活、多行单帧、伪 OSC marker `exit 0`、pty stdin、reconnect) | 9 | ✅ | |
|
|
248
|
+
| D BYO-PTY(建、无 spawn 复用、同源 spawn notice、异源 `E_LABEL_CONFLICT`、docker spawn、自愈重连、`byop:lab.2_x` 字符集、status、roster 渲染、裸名 `alpha`/`beta` 末位命中) | 10 | ✅ | 结果字段一律 `· byop:<label>` |
|
|
249
|
+
| E 守卫(`E_BAD_LABEL`×2、`E_BAD_MODE`、`E_NO_SESSION`、`E_PARAMS`×2、`E_STDIN_WITHOUT_CMD`、`E_BAD_TIMEOUT`、schema 拒 `mode:"bogus"`) | 9 | ✅ | 全部 fail-loud,提示形状均为 `byop:<label>` |
|
|
250
|
+
| F 路由/撞名(`alpha`+`mode:oneshot` 逃逸、`corti`→docker、`ctr-1`→ssh) | 3 | ✅ | 优先级 ssh→docker→incus→池内 pty→ssh 兜底一位不挪 |
|
|
251
|
+
| G 双 agent 隔离 | 2 | ⚠️ | 寻址 ✅ / roster ❌(F4) |
|
|
252
|
+
| **未重跑** | 3 | — | oneshot 30k 帽、pty 30k 帽、4M 环形帽:`oneshot.ts` / `tailWindow` / `nonce-framing.ts` 本轮未改,§四 的 live 证据仍有效 |
|
|
253
|
+
|
|
254
|
+
**改名破坏性观察**:旧写法的 `{target:"pty:alpha", spawn:…}` 现在**不再**是 PTY 选择器——`pty:alpha` 落裸名解析 → ssh 兜底,且 `spawn` 与它同席时报 `[E_PARAMS] spawn requires a "byop:<label>" target`。属未发布 API 的干净改名(无兼容包袱),但 prompt / 文档 / 记忆里所有 `pty:` 字样需同步。
|
|
255
|
+
|
|
256
|
+
### 9.5 单测 / 类型(v2)
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
npx vitest run test/remote/ → Test Files 11 passed (11) · Tests 92 passed (92)
|
|
260
|
+
npx tsc --noEmit → exit 0
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
较 v1 的 89 例 +3(覆盖 F1 `displayKey` 与 F2 出池/重建)。
|
|
264
|
+
|
|
265
|
+
### 9.6 附 A:`remote` 工具的 JSON Schema(权威版,由 `defineTool` 实构)
|
|
266
|
+
|
|
267
|
+
生成方式:以 stub driver 调 `createRemoteTool()`,序列化 `ToolDefinition.parameters`(编译后的输入 schema)与 `.output.schema`(字面声明)——`npx tsx .scratch/remote-schema-dump.ts`。
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"name": "remote",
|
|
272
|
+
"parameters": {
|
|
273
|
+
"type": "object",
|
|
274
|
+
"properties": {
|
|
275
|
+
"target": { "type": "string", "description": "Single entry point. Bare name / IP / domain matches in order: ssh config, then docker, then incus, then a live pty label (a collision goes to the earlier source). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel. For \"byop:<label>\", pass spawn the first time to create it and omit spawn afterwards to reuse it. Omit cmd → status probe." },
|
|
276
|
+
"spawn": { "type": "string", "description": "Only valid together with a \"byop:<label>\" target, and required the first time that label is created: …" },
|
|
277
|
+
"cmd": { "type": "string", "description": "One raw shell string parsed by the remote bash (multi-line = one compound; exit = last command's). Omit with target → status probe; omit everything → roster." },
|
|
278
|
+
"mode": { "type": "string", "enum": ["oneshot", "pty"], "description": "… A \"byop:<label>\" target locks mode to 'pty'." },
|
|
279
|
+
"stdin": { "type": "string", "description": "Input fed to the command. oneshot: piped to stdin. pty: written right after the command line (REPL-style) — only helps commands that read stdin there. Requires cmd." },
|
|
280
|
+
"timeout": { "type": "number", "description": "Per-command timeout in seconds (default 120, row-configurable). pty: Ctrl-C first, then kill; an interrupt does not kill the session." }
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"output": {
|
|
284
|
+
"type": "object",
|
|
285
|
+
"additionalProperties": false,
|
|
286
|
+
"required": ["kind", "text"],
|
|
287
|
+
"properties": {
|
|
288
|
+
"kind": { "type": "string", "enum": ["exec", "status", "roster"] },
|
|
289
|
+
"text": { "type": "string" },
|
|
290
|
+
"exit": { "oneOf": [{ "type": "integer" }, { "type": "null" }] },
|
|
291
|
+
"durationMs": { "type": "integer" },
|
|
292
|
+
"cwd": { "type": "string" },
|
|
293
|
+
"stderr": { "type": "string" },
|
|
294
|
+
"timedOut": { "type": "boolean" },
|
|
295
|
+
"reconnected":{ "type": "boolean" },
|
|
296
|
+
"truncated": { "type": "integer" },
|
|
297
|
+
"session": { "type": "string" },
|
|
298
|
+
"notice": { "type": "string" }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
(输入面的 description 为节省篇幅有截断,`spawn`/`mode` 两条完整原文见 §一 与 §9.1;文档字符串的逐字全文在 `better-dsh/src/remote/tool.ts`。)
|
|
305
|
+
|
|
306
|
+
**schema 面的两个结构性事实**(值得记):
|
|
307
|
+
|
|
308
|
+
1. **输入参数是隐式开放对象**:编译结果只有 `type: "object"` + `properties`,**没有 `required` 数组、也没有 `additionalProperties: false`**。原因是 `remote` 的六个参数确实全部可选(`{}`=roster、`{target}`=status、`{target,cmd}`=oneshot);「哪个组合合法」由 `execute` 内的三闸守卫(`E_PARAMS` / `E_STDIN_WITHOUT_CMD` / `E_BAD_MODE`)在**运行期**表达,schema 只能表达单字段约束(`mode` 的 enum)。
|
|
309
|
+
2. **输出面相反,是封闭且必填的**:`additionalProperties: false` + `required: ["kind", "text"]`——与 `defineTool` 的 `output.schema` 必须显式声明开放性的契约一致(`output` 里每个属性写 `required: true` 就被投影进 `required` 数组)。
|
|
310
|
+
|
|
311
|
+
### 9.7 复核轮结论
|
|
312
|
+
|
|
313
|
+
- **F2 关闭**(出池 + label 自由,两个 spec Scenario 都实测通过);**F1 的键泄漏关闭、native-cause 残留降为 low**;**新记 F4(roster 未按 agent 过滤)**。
|
|
314
|
+
- 43 项重跑全过;92/92 单测;`tsc` 0 错。
|
|
315
|
+
- 与 v1 相同的发布纪律仍然适用:**未提交工作树**,不构成 publish 前置条件。
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## 十、增量复核 v3(2026-10-03 19:17–19:26 +08:00)— **只测改动面**
|
|
320
|
+
|
|
321
|
+
用户裁决:本轮只测有调整的面,不做全量回归。
|
|
322
|
+
|
|
323
|
+
### 10.1 范围声明与基线可及性
|
|
324
|
+
|
|
325
|
+
**不重跑**(附理由,不含糊带过):
|
|
326
|
+
|
|
327
|
+
| 不重跑项 | 理由 |
|
|
328
|
+
|---|---|
|
|
329
|
+
| oneshot 三 transport 矩阵 / `exit 42` / stdin / 原生错误透传 | `oneshot.ts` / `transports.ts` / `target.ts` 本轮零改动(mtime 停在 09-24) |
|
|
330
|
+
| 30k 输出帽(oneshot + pty 两处)、4M pty 环 | oneshot 帽在 `oneshot.ts`;pty 帽与 **4M 环都在 `pty-session.ts`(`OUTPUT_HARD_CAP` L45,采集处 L110/L111)**;帧协议在 `nonce-framing.ts`。本轮三者均未触碰采集/帽子路径(`pty-session.ts` 的改动是 v2 轮的 init 失败出池,与本面无关);v1/v2 活体证据仍有效 |
|
|
331
|
+
| pty 多行 / OSC 免疫 / `exit 130` / 重连 | 同上,本轮无差异被触发 |
|
|
332
|
+
| idle TTL 600s 现场回收 | 仅单测覆盖(§9.5),本轮仍不现场验证 |
|
|
333
|
+
|
|
334
|
+
**基线可及性声明(方法学)**:§9.1 记录的 §九 被测物 `ca292402…` 已在本轮被同路径同版本号覆盖,**无法逐字节 diff**。因此本轮 delta 不靠「两包对差」推出,而由三条独立证据合成:① 源文件 mtime(`driver/roster/tool.ts` 19:17 一批、两个 spec 19:17:48)与工作树 vs `HEAD` 的 diff;② 本轮新增/改写的单测所固定的契约;③ `docs/specs/remote-pty-label/spec.md` 19:18:00 的 Requirement/Scenario。每个 delta 项的「改动前」状态取 §一–§九 已记录的旧行为,逐条对照,不做无法证实的字节级断言。
|
|
335
|
+
|
|
336
|
+
### 10.2 被测身份
|
|
337
|
+
|
|
338
|
+
| 项 | 值 |
|
|
339
|
+
|---|---|
|
|
340
|
+
| 源码树 | `better-dsh` @ `5aa48ea` + 未提交工作树(8 文件 +397/−61,含根 `AGENTS.md` 2 行) |
|
|
341
|
+
| 构建产物 | `better-dsh/lib/remote/plugin.js` 19:18:41,md5 **`ecc5fd44f6ac5753b3edbdd0d0bf4371`** |
|
|
342
|
+
| 交付包 | `.scratch/better-dsh-0.2.5-b+byop.tgz` 19:18:52 → profile 装入 19:18:58,**md5 与构建产物一致** |
|
|
343
|
+
| rig | 4999 于 19:19:01 以该装入物重启(本轮第 4 个 boot token `GeAQIRcl…`),插件池冷启 |
|
|
344
|
+
| 身份证据 | 构建产物与 rig 装入物 **md5 相等**(`ecc5fd44…`),且 unit `ActiveEnterTimestamp=19:19:01` 即加载该装入物的那次 boot。<br>(附注:`{spawn, cmd}`(无 target)返回 `E_PARAMS` 只能区分 HEAD 与 v2+——该组合自引入 label 选择器那一轮起就是 `E_PARAMS`,见 §四.E 行 E5——**不能**区分 v2 与 v3,故不作为本轮身份证据。) |
|
|
345
|
+
|
|
346
|
+
### 10.3 delta 矩阵(26 项,全部第一人称在 4999 活体执行)
|
|
347
|
+
|
|
348
|
+
| # | 调用 | 本轮期望 | 实测 | 结论 |
|
|
349
|
+
|---|---|---|---|---|
|
|
350
|
+
| A1 | `{spawn:"bash", cmd:"echo hi"}`(无 target) | `E_PARAMS`:spawn 降格为修饰符,非独立入口 | 同(报文含 `spawn requires a "byop:<label>" target`) | ✓ 新行为(旧=直接建会话) |
|
|
351
|
+
| A2 | `{target:"dev4", spawn:"bash", cmd}` | `E_PARAMS`:spawn 仅配合 `byop:` | 同 | ✓ |
|
|
352
|
+
| A3 | `{target:"byop:", spawn:"bash", cmd}` | `E_BAD_LABEL` | `…label must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ (got "")` | ✓ |
|
|
353
|
+
| A4 | `{target:"byop:has space", …}` | `E_BAD_LABEL` | 同型(`got "has space"`) | ✓ |
|
|
354
|
+
| A5 | `{target:"byop:lab3", spawn:"bash", cmd, mode:"oneshot"}` | `E_BAD_MODE`:锁 pty | `"byop:lab3" is a PTY selector — mode is locked to 'pty'` | ✓ |
|
|
355
|
+
| B1 | 建 `byop:d1`(spawn `bash`;`cd /tmp`) | 建成,退出行带 `· byop:d1` | `[exit 0 · 0.0s · cwd /tmp · byop:d1]` | ✓ 新 `session` 字段渲染在位 |
|
|
356
|
+
| B2 | 复用 `{target:"byop:d1", cmd}` | env/cwd 存活、无 notice | `live` + `/tmp`,无 notice | ✓ |
|
|
357
|
+
| B3 | 同 label **重复给同一 spawn** | 复用 + `notice` 教学行 + 仍执行 | notice 行「already runs that exact command…」+ `live` | ✓ 新 `notice` 字段 |
|
|
358
|
+
| B4 | 同 label 给**不同** spawn(`bash -l`) | `E_LABEL_CONFLICT`,零执行 | 四行冲突报文(existing / given / To reuse / To start new) | ✓ |
|
|
359
|
+
| B4v | B4 之后读 `$MARK3` | 仍 `live`(未被 clobber) | `MARK3=[live]` | ✓ 冲突确实没跑命令 |
|
|
360
|
+
| B5 | 未知 label 复用 | `E_NO_SESSION` + Create it 形态 | 同 | ✓ |
|
|
361
|
+
| B6 | 未知 label status | `byop:never — BYO pty session` + `session: none — no live PTY under this label` | 同 | ✓ 零拨号 |
|
|
362
|
+
| B7 | 活 label status | `session: idle 0s (connected)` | `byop:d1 — BYO pty session` + 同 | ✓ |
|
|
363
|
+
| C1 | 建 `byop:bareX` 后以**裸名** `bareX`(未指定 mode)跑 | 命中候选表末位 PTY:懒复用成立 | `baremark`,退出行 `· byop:bareX` | ✓ 新候选 |
|
|
364
|
+
| C2 | 裸名 `bareX` + `mode:"oneshot"` | 不吃末位候选,走 ssh 兜底原生错误、无 `session` 标记 | `[exit 255 · 0.0s]` + `ssh: Could not resolve hostname barex…`,无 pty 标记 | ✓ 旧行为逐位保留 |
|
|
365
|
+
| C3 | 裸名 `bareX` status | 同样落到 PTY 候选 | `byop:bareX — BYO pty session` + idle | ✓ status 与 exec 同一张候选表 |
|
|
366
|
+
| D1 | 我(sessionKey A)建 `byop:d1`/`byop:bareX` 后空调用 | 只列我的会话 | `live pty sessions: byop:d1 [ready, idle 4s], byop:bareX [ready, idle 13s]` | ✓ |
|
|
367
|
+
| D2 | 子 agent 建 `byop:subdelta` **之后**我再空调用 | 仍只列我的两条,**不含 subdelta** | 同上(读数 11s / 24s) | ✓ **即 F4 的反证** |
|
|
368
|
+
| D3 | 子 agent 空调用 | 只列 `byop:subdelta`,不含我的两条 | `live pty sessions: byop:subdelta [ready, idle 1s]` | ✓ |
|
|
369
|
+
| D4 | 我寻址 `byop:subdelta` | `E_NO_SESSION` | 同 | ✓ |
|
|
370
|
+
| D5 | 子 agent 寻址 `byop:d1` / `byop:bareX` | 双向 `E_NO_SESSION` | 两条同型报文 | ✓ 隔离是双向的 |
|
|
371
|
+
| D6 | 静态三段(ssh/docker/incus)双侧 | 全局可见、内容一致 | 双方各自列出同一 ssh hosts / docker / incus 三段 | ✓ 设计内(静态公共配置) |
|
|
372
|
+
| E1 | 重生成权威 JSON Schema(`.scratch/remote-schema-v3.json`) | 输出面 **9→11** 属性;`required` / `additionalProperties` 不变;输入面不变 | 见 §10.4 | ✓ |
|
|
373
|
+
| E2 | `npx vitest run test/remote/` | 全绿 | 11 文件 / **94 tests passed**(§九 基线 92,+2) | ✓ |
|
|
374
|
+
| E3 | `npx tsc --noEmit` | 0 错 | exit 0 | ✓ |
|
|
375
|
+
| E4 | status 回归抽查(改动行 `resolveBareName` 换了返回型) | ssh / docker 两分支照旧 | `dev4 — ssh host` + probe + `session: none — dials on first exec`;`corti — docker container` 同型 | ✓ |
|
|
376
|
+
|
|
377
|
+
### 10.4 关键证据(verbatim)
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
# A1 —— spawn 不再是入口(同时是本轮活体身份自证)
|
|
381
|
+
[E_PARAMS] remote: spawn requires a "byop:<label>" target — e.g. { target: "byop:dev3", spawn: "ssh dev3", cmd: "…" }; it is not a standalone entry point
|
|
382
|
+
|
|
383
|
+
# B3 —— 同 spawn 重复:notice 行(新字段)在前,命令照跑
|
|
384
|
+
[remote: byop:d1 already runs that exact command and is persistent — reuse it with { target: "byop:d1", cmd: "…" }; spawn is only needed to create.]
|
|
385
|
+
live
|
|
386
|
+
|
|
387
|
+
[exit 0 · 0.0s · cwd /tmp · byop:d1]
|
|
388
|
+
|
|
389
|
+
# B4 —— 异 spawn 冲突:零执行
|
|
390
|
+
[E_LABEL_CONFLICT] remote: byop:d1 already exists and was created by a different command — nothing was run.
|
|
391
|
+
existing spawn: "bash"
|
|
392
|
+
given spawn: "bash -l"
|
|
393
|
+
To reuse it: { target: "byop:d1", cmd: "…" } (drop spawn — spawn only ever creates)
|
|
394
|
+
To start new: pick another label, e.g. { target: "byop:d12", spawn: "bash -l", cmd: "…" }
|
|
395
|
+
|
|
396
|
+
# C2 —— 显式 oneshot 的逃生门:不吃末位候选,旧行为逐位保留
|
|
397
|
+
[exit 255 · 0.0s]
|
|
398
|
+
[stderr]
|
|
399
|
+
ssh: Could not resolve hostname barex: Temporary failure in name resolution
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
双侧 roster(D2 / D3,同一时刻、同一进程、不同 agent session):
|
|
403
|
+
|
|
404
|
+
```
|
|
405
|
+
# 我(sessionKey A)——D2:子 agent 的 byop:subdelta 已在池中,但不在我的名单里
|
|
406
|
+
ssh hosts (~/.ssh/config): github.com, dev2, dev3, mac, dev1, 137.131.54.174, dev4, ctr-1
|
|
407
|
+
docker containers: corti (running), jellyfin (running)
|
|
408
|
+
incus containers: ctr-1 (RUNNING)
|
|
409
|
+
live pty sessions: byop:d1 [ready, idle 11s], byop:bareX [ready, idle 24s]
|
|
410
|
+
|
|
411
|
+
# 子 agent(sessionKey B)——D3:看不到我的两条
|
|
412
|
+
live pty sessions: byop:subdelta [ready, idle 1s]
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
E1 输出面(`remote-schema-v3.json`,11 属性 + `required`/`additionalProperties` 不变):
|
|
416
|
+
|
|
417
|
+
```json
|
|
418
|
+
{ "type": "object", "additionalProperties": false,
|
|
419
|
+
"properties": { "kind": {"type":"string","enum":["exec","status","roster"]}, "text": {"type":"string"},
|
|
420
|
+
"exit": …, "durationMs": …, "cwd": …, "stderr": …, "timedOut": …, "reconnected": …, "truncated": …,
|
|
421
|
+
"session": { "type": "string" }, "notice": { "type": "string" } },
|
|
422
|
+
"required": ["kind", "text"] }
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
(输入面与 §9.6 一致:6 属性、**无 `required`**、无 `additionalProperties`——合法性仍由运行期守卫表达。)
|
|
426
|
+
|
|
427
|
+
### 10.5 F4 关闭
|
|
428
|
+
|
|
429
|
+
§9.3 记的 F4(roster 不按 agent 作用域过滤,low–medium)**关闭**:
|
|
430
|
+
|
|
431
|
+
- **实现**:`roster()` 以 `${sessionKey}|` 前缀过滤池,名字渲染收敛到单一定义 `displayKey(key)`;`ssh/docker/incus` 三段保持全局。
|
|
432
|
+
- **活体双侧反证**:D2 —— 子 agent 的 `byop:subdelta` 是**活会话**(其 roster 自见),而我的 roster 在同一时刻不含它;D5 —— 双向寻址皆 `E_NO_SESSION`。这正是 §9.3 记录的失败场景(当时我的名单里出现了别人的 label)在本轮的对照复现。
|
|
433
|
+
- **spec 同步**:`spec.md` 19:18:00 已含 `Requirement: roster 的活性面按 agent session 作用域`,并把「静态公共配置 vs 操作环境」的取舍写进理由,与实现逐条对齐。
|
|
434
|
+
|
|
435
|
+
### 10.6 观察与残留
|
|
436
|
+
|
|
437
|
+
- **F1(native-cause 残留,low)**:维持——本轮未触碰该段(init 期 pty 输出走 stdout、`diag` 只捞子进程 stderr)。
|
|
438
|
+
- **F3(裸名遮蔽,设计内)**:不变,且本轮把它的边界钉得更死——PTY label 只在候选表**末位**参与(C1/C3),撞名时 ssh/docker/incus 仍胜出;spec Scenario 明写「命中顺序即优先级」。
|
|
439
|
+
- **观察·构建卫生(附注不入发现账)**:仓库根 `better-dsh/better-dsh-0.2.5-b.tgz`(36,833 B,mtime 19:01:14)包内 `lib/remote/plugin.js` 是 **pre-refactor(spawn 键时代)** 产物——`byop:` 命中 0、`startsWith('pty:')` 命中 0(label 选择器从未发布过,故本就不该有)。**这是正确状态而非陈旧物**:它是 `5aa48ea "release 0.2.5-b"` 提交的、**被 git track 的发布记录**(`git check-ignore` 不忽略、`git ls-files 'better-dsh/*.tgz'` 列出五个、`git log -1 -- <file>` 指向该 release commit);mtime 19:01:14 来自一次 `git checkout --` 还原,不是 pack 时间。本轮 rig 的真实交付包装是 `.scratch/better-dsh-0.2.5-b+byop.tgz`(见 §10.2),二者各司其职——**不得重 pack 或删除仓库根那一份**。<br>(附注仍成立:`better-dsh` scripts 无 `prepack`,`npm pack` 只打包当下 `lib/`、不重建。)
|
|
440
|
+
- **观察·产品面(信息)**:退出行的 `· byop:<label>` 标签对**显式** `byop:` 调用是冗余(target 本就是模型写的),对 C1 那种**裸名懒复用**才是承载信息——它把「以为在打 ssh、实际落到 PTY」显式化。记录,不建议改。
|
|
441
|
+
- 未重跑项与理由见 §10.1。
|
|
442
|
+
|
|
443
|
+
### 10.7 结论
|
|
444
|
+
|
|
445
|
+
- 本轮 delta 的四个面(`spawn` 降格 / `byop:` status 零拨号 / 裸名末位 PTY 候选 / roster 调用方作用域)+ 输出结构面(`session`、`notice` 两字段)**全部实测通过:26/26**;`test/remote/` 94/94、`tsc` exit 0。
|
|
446
|
+
- **F4 关闭**;spec 与实现同步(19:18:00 的 spec 已覆盖三条新契约),无文档漂移。
|
|
447
|
+
- 发布纪律不变:本轮仍是**未提交工作树**,实测通过 ≠ 放行,**不构成 publish 前置条件**(AGENTS.md §〇:需 user 明确单次授权)。
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# remote-pty-label Specification
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`remote` 工具的持久化 BYO-PTY 通道从「`spawn` 命令原文即会话键」改为「`target` 选择器寻址的具名会话」。旧实现的池键就是整条 spawn 文本,于是「同一条命令」与「同一个会话」被绑死:改一个 token 就**静默**新开一个 shell(cwd/env 全丢、零提示),roster 又拿整整一条 shell 命令当会话名。本规格固定新契约——`byop:<label>` 是唯一入口、label 由调用方(模型)自选并因此成为复用入口、`spawn` 降格为该 label 首次创建时的伴生参数——并据此定义创建/复用/冲突三分支、失败面,以及裸名候选表的末位新增项。
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
### Requirement: 单入口寻址
|
|
10
|
+
|
|
11
|
+
`target` SHALL 是 `remote` 工具的唯一分流入口。以 `byop:` 开头的 target SHALL 被解析为 PTY 选择器,冒号之后的全部内容为 label。`spawn` SHALL 仅在与 `byop:<label>` target 同时出现时合法——它不再是独立入口。
|
|
12
|
+
|
|
13
|
+
#### Scenario: spawn 不能独立成调用
|
|
14
|
+
|
|
15
|
+
- **WHEN** 调用只给 `spawn`(无 target,或 target 不是 `byop:` 选择器)
|
|
16
|
+
- **THEN** 系统返回 `[E_PARAMS]` 并在消息内给出正确形状 `{ target: "byop:<label>", spawn: "…", cmd: "…" }`,且不创建、不触碰任何会话
|
|
17
|
+
|
|
18
|
+
#### Scenario: label 字符集
|
|
19
|
+
|
|
20
|
+
- **WHEN** label 不匹配 `^[A-Za-z0-9][A-Za-z0-9._-]*$`(空 label、含空白、含 `:` 等)
|
|
21
|
+
- **THEN** 系统返回 `[E_BAD_LABEL]` 并在消息内给出合法形状,且不触碰会话池
|
|
22
|
+
|
|
23
|
+
#### Scenario: PTY 选择器锁定 mode
|
|
24
|
+
|
|
25
|
+
- **WHEN** `byop:<label>` target 与显式 `mode:"oneshot"` 同时出现
|
|
26
|
+
- **THEN** 系统返回 `[E_BAD_MODE]`,而不是静默降级或静默改写 mode
|
|
27
|
+
|
|
28
|
+
### Requirement: label 即复用入口,命名空间按 agent 隔离
|
|
29
|
+
|
|
30
|
+
PTY 会话 SHALL 以 `` `${agentSessionKey}|p:${label}` `` 为池键。`spawn` 原文 SHALL 作为创建来源(origin)随会话保存,仅用于同 label 重复给 spawn 时的比对。label 首次创建之后,仅给 `{ target: "byop:<label>", cmd }` SHALL 复用该会话,cwd/env 存活。
|
|
31
|
+
|
|
32
|
+
#### Scenario: 创建后仅凭 label 复用
|
|
33
|
+
|
|
34
|
+
- **WHEN** 先调用 `{target:"byop:dev3", spawn:"bash", cmd:"cd /tmp && export MARK=live"}`,再调用 `{target:"byop:dev3", cmd:'echo "$MARK" && pwd'}`
|
|
35
|
+
- **THEN** 第二次调用在**同一个** shell 内执行,输出含 `live`、cwd 为 `/tmp`,且结果带 `session: "byop:dev3"`
|
|
36
|
+
|
|
37
|
+
#### Scenario: 两个 agent 的同名 label 互不可见
|
|
38
|
+
|
|
39
|
+
- **WHEN** agent A 创建了 `byop:dev3`,agent B 以同一 label 且不给 spawn 调用
|
|
40
|
+
- **THEN** B 得到 `[E_NO_SESSION]`,A 的会话不受任何影响
|
|
41
|
+
|
|
42
|
+
### Requirement: 同 label 重复 spawn 的三分支
|
|
43
|
+
|
|
44
|
+
对已存在的 label 再次给 `spawn` 时,系统 SHALL 将其与创建来源比对:相同即复用并在结果内附教学提示;不同即拒绝,且 SHALL NOT 执行任何命令。
|
|
45
|
+
|
|
46
|
+
#### Scenario: 同一 spawn → 复用 + 提示
|
|
47
|
+
|
|
48
|
+
- **WHEN** 对活着的 `byop:dev3` 再次传入与创建时逐字相同的 `spawn`
|
|
49
|
+
- **THEN** 调用在既有会话内执行,结果的 `notice` 说明该 PTY 是持久化的、后续可省略 `spawn`(只写 label)
|
|
50
|
+
|
|
51
|
+
#### Scenario: 不同 spawn → 冲突,什么都不跑
|
|
52
|
+
|
|
53
|
+
- **WHEN** 对活着的 `byop:dev3` 传入与创建时不同的 `spawn`
|
|
54
|
+
- **THEN** 系统返回 `[E_LABEL_CONFLICT]`,消息内含既有 spawn 原文与「丢掉 spawn 复用它 / 换一个 label 新建」两条出路,**cmd 不执行**,既有会话状态不变
|
|
55
|
+
|
|
56
|
+
### Requirement: 无会话面 fail-loud 且不保留 spawn
|
|
57
|
+
|
|
58
|
+
label 从未创建、或已被 idle TTL(默认 600s)回收时,不带 `spawn` 的调用 SHALL 返回 `[E_NO_SESSION]`。spawn 命令 SHALL NOT 被跨会话保留(无墓碑、无自动重建)。
|
|
59
|
+
|
|
60
|
+
#### Scenario: 回收后必须重贴 spawn
|
|
61
|
+
|
|
62
|
+
- **WHEN** `byop:dev3` 因 idle TTL 出池后调用 `{target:"byop:dev3", cmd:"echo hi"}`
|
|
63
|
+
- **THEN** 系统返回 `[E_NO_SESSION]`,消息内给出带 `spawn` 的重建形状,并说明 spawn 未被保留
|
|
64
|
+
|
|
65
|
+
### Requirement: 初始化失败不留任何东西
|
|
66
|
+
|
|
67
|
+
首建命令未能初始化(`[E_SESSION_DIED]` / `[E_SESSION_START]`)时,该 PTY 会话 SHALL 出池:label 与 TTY SHALL NOT 被建立——这里从来没有过一个可用会话。**已 ready 过**的会话死亡 SHALL 留在池内(那是 reconnect 语义),不受本需求约束。
|
|
68
|
+
|
|
69
|
+
#### Scenario: 失败的 spawn 之后 label 仍然自由
|
|
70
|
+
|
|
71
|
+
- **WHEN** `{target:"byop:x", spawn:"…", cmd:"…"}` 的首建命令未能初始化
|
|
72
|
+
- **THEN** 随后不带 spawn 的复用得到 `[E_NO_SESSION]`(而非重跑冻结的 argv 再失败一次),且带**任意** spawn 的调用 SHALL 正常新建——不得被创建来源比对拦成 `[E_LABEL_CONFLICT]`
|
|
73
|
+
|
|
74
|
+
#### Scenario: 已 ready 的会话死亡仍可重连
|
|
75
|
+
|
|
76
|
+
- **WHEN** 一个曾经初始化成功的 `byop:<label>` shell 死亡后被复用
|
|
77
|
+
- **THEN** 该会话留在池内、按原创建命令重连并附 reconnect 提示
|
|
78
|
+
### Requirement: 状态面与 roster 以 label 呈现
|
|
79
|
+
|
|
80
|
+
`{target:"byop:<label>"}`(无 cmd)SHALL 只读池内状态、零拨号。roster SHALL 以 `byop:<label>` 命名会话,SHALL NOT 泄露 spawn 命令。
|
|
81
|
+
|
|
82
|
+
#### Scenario: 探测不存在的 label
|
|
83
|
+
|
|
84
|
+
- **WHEN** 对无会话的 `byop:<label>` 做 status 调用
|
|
85
|
+
- **THEN** 返回 `session: none — no live PTY under this label`(不是 transport target 的 "dials on first exec",也不报错)
|
|
86
|
+
|
|
87
|
+
#### Scenario: roster 只给 label
|
|
88
|
+
|
|
89
|
+
- **WHEN** 调用方自己以 `UNIQUESPAWNMARKER bash` 创建了 `byop:dev3`
|
|
90
|
+
- **THEN** roster 包含 `live pty sessions: byop:dev3`,且文本中不出现 `UNIQUESPAWNMARKER`
|
|
91
|
+
|
|
92
|
+
### Requirement: roster 的活性面按 agent session 作用域
|
|
93
|
+
|
|
94
|
+
「live pty sessions」段 SHALL 只列调用方 agent session 自己的会话(池键前缀 = 调用方 sessionKey)。`ssh hosts` / `docker containers` / `incus containers` 三段名字候选 SHALL 保持全局可见。理由:名字候选是**静态公共配置**(谁都能 `ssh dev4`),活会话却是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,而两个 agent 各开一条线的代价可忽略。BYO-PTY 天然是活的,因此自然落进这个 scope,无需另立规则。
|
|
95
|
+
|
|
96
|
+
#### Scenario: 两个 agent 各自只看见自己的活会话
|
|
97
|
+
|
|
98
|
+
- **WHEN** agent `a1` 建了 `byop:mine`、agent `a2` 建了 `byop:theirs`,随后各自调 `{}`
|
|
99
|
+
- **THEN** `a1` 的 roster 含 `byop:mine` 且不含 `byop:theirs`(反之亦然),而两边 `ssh hosts` / `docker containers` / `incus containers` 三段内容相同
|
|
100
|
+
### Requirement: 模型面不得泄露池内部键
|
|
101
|
+
|
|
102
|
+
面向模型的文本(roster、status、错误消息)SHALL 只出现模型面名字(`byop:<label>`、裸 transport target),SHALL NOT 出现池键(`` `${agentSessionKey}|p:<label>` `` / `|t:<target>`)。key→名字 SHALL 是单一定义(`displayKey`),roster 与错误面共用同一条规则。
|
|
103
|
+
|
|
104
|
+
#### Scenario: 初始化失败的错误消息
|
|
105
|
+
|
|
106
|
+
- **WHEN** agent `sess-abc` 的 `byop:x` 初始化失败
|
|
107
|
+
- **THEN** 错误消息含 `'byop:x'`,且不含 `sess-abc`、不含 `|p:`
|
|
108
|
+
|
|
109
|
+
### Requirement: 裸名候选表末位新增 PTY label
|
|
110
|
+
|
|
111
|
+
裸名解析顺序 SHALL 保持 ssh config 字面 host → docker → incus **一位不挪**,并在这三段之后、ssh 兜底之前追加「池内活 PTY label」候选。该候选 SHALL 仅在调用面未显式指定 `mode:"oneshot"` 时参与匹配。命中顺序即优先级:同名冲突时更靠前的来源胜出。
|
|
112
|
+
|
|
113
|
+
#### Scenario: 无重名时裸 label 可达(懒复用)
|
|
114
|
+
|
|
115
|
+
- **WHEN** 池内有 `byop:lab`,ssh config / docker / incus 均无 `lab`,且调用 `{target:"lab", cmd:"…"}`(未指定 mode)
|
|
116
|
+
- **THEN** 调用落在该 PTY 会话上(mode 为 `pty`),结果 `session` 为 `"byop:lab"`
|
|
117
|
+
|
|
118
|
+
#### Scenario: 重名时 ssh host 优先
|
|
119
|
+
|
|
120
|
+
- **WHEN** ssh config 存在 `Host lab`,同时池内有 `byop:lab`
|
|
121
|
+
- **THEN** 裸名 `lab` 仍解析为 ssh transport(现有优先级逐位不变);要进 PTY 通道必须显式写 `byop:lab`
|
|
122
|
+
|
|
123
|
+
#### Scenario: 显式 oneshot 不吃 PTY 候选
|
|
124
|
+
|
|
125
|
+
- **WHEN** 调用显式带 `mode:"oneshot"` 且 target 为裸名
|
|
126
|
+
- **THEN** 该调用不走 PTY 末位候选,裸名在 `mode:"oneshot"` 下的旧行为逐位保留
|
package/lib/mobile/plugin.d.ts
CHANGED
package/lib/remote/plugin.js
CHANGED
|
@@ -158,6 +158,13 @@ function createNonceFrameParser(nonce, events) {
|
|
|
158
158
|
//#region src/remote/pty-session.ts
|
|
159
159
|
/** spec §五.2 逐字。 */
|
|
160
160
|
const RECONNECT_NOTICE = "[remote: session reconnected to fresh shell; cwd reset to default]";
|
|
161
|
+
/** 池键 → 模型面名字:剥掉 agent 前缀与内部标记(`t:` → 裸 target;`p:` → `byop:<label>`)。
|
|
162
|
+
* 这是**唯一**的 key→名字映射规则,roster 与错误面共用——错误面不得回吐内部键:
|
|
163
|
+
* 2026-10-03 实测报告 F1,`E_SESSION_*` 曾把 `session-<uuid>|p:<label>` 整条吐给模型。 */
|
|
164
|
+
function displayKey(key) {
|
|
165
|
+
const raw = key.includes("|") ? key.slice(key.indexOf("|") + 1) : key;
|
|
166
|
+
return raw.startsWith("t:") ? raw.slice(2) : raw.startsWith("p:") ? `byop:${raw.slice(2)}` : raw;
|
|
167
|
+
}
|
|
161
168
|
const DEFAULT_INIT_TIMEOUT_SEC = 30;
|
|
162
169
|
const INTERRUPT_GRACE_MS = 3e3;
|
|
163
170
|
/** pty 收集内存安全帽(Ruling P15,与 oneshot HARD_CAP 对齐):模型面只见尾窗,保留尾部。 */
|
|
@@ -173,12 +180,18 @@ var PtySession = class {
|
|
|
173
180
|
diag = "";
|
|
174
181
|
inFlight = 0;
|
|
175
182
|
lastSettleAt = 0;
|
|
183
|
+
/** 是否曾成功初始化(ready 过)。决定死亡语义:ready 过 → reconnect(留池);从未 ready → 出池(见 start())。 */
|
|
184
|
+
everReady = false;
|
|
176
185
|
constructor(opts) {
|
|
177
186
|
this.opts = opts;
|
|
178
187
|
}
|
|
179
188
|
get sessionState() {
|
|
180
189
|
return this.state;
|
|
181
190
|
}
|
|
191
|
+
/** 创建来源(spawn 原文);仅 `byop:<label>` 会话有。 */
|
|
192
|
+
get origin() {
|
|
193
|
+
return this.opts.origin;
|
|
194
|
+
}
|
|
182
195
|
/** status 面快照:state + busy(有命令在跑)+ idleMs(上次 settle 至今)+ pid(进程组头,kill 兜底可观测)。 */
|
|
183
196
|
get snapshot() {
|
|
184
197
|
return {
|
|
@@ -284,12 +297,13 @@ var PtySession = class {
|
|
|
284
297
|
} catch {}
|
|
285
298
|
}
|
|
286
299
|
async start() {
|
|
287
|
-
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.key}' was disposed — the pool replaces disposed sessions; this reference is stale`);
|
|
300
|
+
if (this.state === "disposed") throw new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' was disposed — the pool replaces disposed sessions; this reference is stale`);
|
|
288
301
|
if (this.startPromise === null) {
|
|
289
302
|
this.state = "starting";
|
|
290
303
|
this.startPromise = this.doStart().catch((err) => {
|
|
291
304
|
this.startPromise = null;
|
|
292
305
|
this.markDead();
|
|
306
|
+
if (!this.everReady) this.dispose();
|
|
293
307
|
throw err;
|
|
294
308
|
});
|
|
295
309
|
}
|
|
@@ -321,20 +335,21 @@ var PtySession = class {
|
|
|
321
335
|
await new Promise((resolve$1, reject) => {
|
|
322
336
|
const timer = setTimeout(() => {
|
|
323
337
|
this.killTree();
|
|
324
|
-
reject(/* @__PURE__ */ new Error(`[E_SESSION_START] remote pty session '${this.opts.key}' did not initialize in ${initTimeoutSec}s` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
|
|
338
|
+
reject(/* @__PURE__ */ new Error(`[E_SESSION_START] remote pty session '${displayKey(this.opts.key)}' did not initialize in ${initTimeoutSec}s` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
|
|
325
339
|
}, initTimeoutSec * 1e3);
|
|
326
340
|
const waiter = () => {
|
|
327
341
|
clearTimeout(timer);
|
|
328
|
-
reject(/* @__PURE__ */ new Error(`[E_SESSION_DIED] remote pty session '${this.opts.key}' died before initializing` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
|
|
342
|
+
reject(/* @__PURE__ */ new Error(`[E_SESSION_DIED] remote pty session '${displayKey(this.opts.key)}' died before initializing` + (this.diag.length > 0 ? `; transport stderr tail: ${this.diag}` : "")));
|
|
329
343
|
};
|
|
330
344
|
this.deathWaiters.push(waiter);
|
|
331
345
|
const parser = createNonceFrameParser(initNonce, { onFrame: () => {
|
|
332
346
|
clearTimeout(timer);
|
|
333
347
|
this.deathWaiters = this.deathWaiters.filter((w) => w !== waiter);
|
|
334
348
|
if (this.state === "disposed") {
|
|
335
|
-
reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${this.opts.key}' disposed during init`));
|
|
349
|
+
reject(/* @__PURE__ */ new Error(`[E_SESSION_DISPOSED] remote pty session '${displayKey(this.opts.key)}' disposed during init`));
|
|
336
350
|
return;
|
|
337
351
|
}
|
|
352
|
+
this.everReady = true;
|
|
338
353
|
this.state = "ready";
|
|
339
354
|
this.resetIdleTimer();
|
|
340
355
|
resolve$1();
|
|
@@ -395,19 +410,26 @@ var PtyPool = class {
|
|
|
395
410
|
inspect(key) {
|
|
396
411
|
return this.sessions.get(key)?.snapshot;
|
|
397
412
|
}
|
|
398
|
-
/** roster 面:池内全部会话(key
|
|
413
|
+
/** roster 面:池内全部会话(key = `${sessionKey}|t:<target>` 或 `${sessionKey}|p:<label>`)。 */
|
|
399
414
|
list() {
|
|
400
415
|
return [...this.sessions.entries()].map(([key, s]) => ({
|
|
401
416
|
key,
|
|
402
417
|
snapshot: s.snapshot
|
|
403
418
|
}));
|
|
404
419
|
}
|
|
405
|
-
|
|
420
|
+
/** 池内会话本体(driver 判「有/无 + origin」用;status/roster 面走 inspect/list)。
|
|
421
|
+
* disposed 视同出池 —— 与 getOrCreate 的替换条件同源,两处不得漂移。 */
|
|
422
|
+
get(key) {
|
|
423
|
+
const s = this.sessions.get(key);
|
|
424
|
+
return s === void 0 || s.sessionState === "disposed" ? void 0 : s;
|
|
425
|
+
}
|
|
426
|
+
getOrCreate(key, argv, origin) {
|
|
406
427
|
let s = this.sessions.get(key);
|
|
407
428
|
if (s === void 0 || s.sessionState === "disposed") {
|
|
408
429
|
s = new PtySession({
|
|
409
430
|
key,
|
|
410
431
|
argv,
|
|
432
|
+
origin,
|
|
411
433
|
...this.defaults,
|
|
412
434
|
onDead: () => this.sessions.delete(key)
|
|
413
435
|
});
|
|
@@ -545,6 +567,11 @@ function renderSession(s) {
|
|
|
545
567
|
if (s.busy) return "session: busy (a command is running)";
|
|
546
568
|
return `session: idle ${Math.round((s.idleMs ?? 0) / 1e3)}s (connected)`;
|
|
547
569
|
}
|
|
570
|
+
/** PTY 会话状态行(BYO-PTY 面):无会话 = 这个 label 下没有活会话,且**不会自建**——
|
|
571
|
+
* 与 transport target 的 "dials on first exec" 是两回事,故不复用那句。 */
|
|
572
|
+
function renderPtySession(s) {
|
|
573
|
+
return s === void 0 ? "session: none — no live PTY under this label" : renderSession(s);
|
|
574
|
+
}
|
|
548
575
|
|
|
549
576
|
//#endregion
|
|
550
577
|
//#region src/remote/ssh-config.ts
|
|
@@ -763,6 +790,8 @@ function buildSpawnArgv(spawnCommand) {
|
|
|
763
790
|
|
|
764
791
|
//#endregion
|
|
765
792
|
//#region src/remote/driver.ts
|
|
793
|
+
/** PTY label 字符集:只禁空白 / `:` / 控制字符(roster 一行一 label,带换行的 label 渲染即烂)。 */
|
|
794
|
+
const PTY_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
766
795
|
/** 双轨调度器(spec §七 Phase 2):参数校验 → 路由 → oneshot 子进程 / pty 会话池。 */
|
|
767
796
|
var RemoteDriver = class {
|
|
768
797
|
pool;
|
|
@@ -795,10 +824,11 @@ var RemoteDriver = class {
|
|
|
795
824
|
}
|
|
796
825
|
/** Ruling 16/17:on-demand 探测 + 会话层,两行如实陈述。 */
|
|
797
826
|
/**
|
|
798
|
-
* Ruling P20
|
|
799
|
-
* 撞名 ssh
|
|
827
|
+
* Ruling P20 + PTY 候选表末位:裸名按名匹配——ssh config 字面 host → docker → incus(本地廉价扫描,
|
|
828
|
+
* 撞名 ssh 优先),三段全不中再看池内活 PTY label,最后才是默认 ssh(交原生错误)。
|
|
829
|
+
* 位置即优先级:现有三段一位不挪。选择器前缀的 target 不经此路径。
|
|
800
830
|
*/
|
|
801
|
-
async resolveBareName(name$1) {
|
|
831
|
+
async resolveBareName(name$1, sessionKey, usePtyLabels = true) {
|
|
802
832
|
const runner = this.opts.rosterRunner ?? runOneShot;
|
|
803
833
|
const readCfg = this.opts.sshConfigReader ?? (async () => await readFile(join(homedir(), ".ssh", "config"), "utf8"));
|
|
804
834
|
try {
|
|
@@ -831,33 +861,43 @@ var RemoteDriver = class {
|
|
|
831
861
|
kind: "incus",
|
|
832
862
|
container: name$1
|
|
833
863
|
};
|
|
864
|
+
if (usePtyLabels && this.pool.get(`${sessionKey}|p:${name$1}`) !== void 0) return {
|
|
865
|
+
kind: "pty",
|
|
866
|
+
label: name$1
|
|
867
|
+
};
|
|
834
868
|
return {
|
|
835
869
|
kind: "ssh",
|
|
836
870
|
host: name$1
|
|
837
871
|
};
|
|
838
872
|
}
|
|
839
|
-
/** Ruling P19:roster = 注意力入口——本地廉价扫描(ssh config/docker ps/incus list + 池内活会话),零拨号。
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
873
|
+
/** Ruling P19:roster = 注意力入口——本地廉价扫描(ssh config/docker ps/incus list + 池内活会话),零拨号。
|
|
874
|
+
* 活的 pty 会话按调用方 agent session 过滤(2026-10-03 裁决):ssh/docker/incus 是**公共静态配置**,
|
|
875
|
+
* 而活会话是**操作环境**——复用别的 agent 留下的 shell 等于继承未知状态,且两个 agent 各开一条线
|
|
876
|
+
* 的代价可忽略。BYO-PTY 天然是活的,于是自然落进这个 scope,无需另立规则。 */
|
|
877
|
+
async roster(callCtx = {}) {
|
|
878
|
+
const prefix = `${callCtx.sessionKey ?? "no-session"}|`;
|
|
879
|
+
return await renderRoster(this.pool.list().filter(({ key }) => key.startsWith(prefix)).map(({ key, snapshot }) => ({
|
|
880
|
+
target: displayKey(key),
|
|
881
|
+
state: snapshot.state,
|
|
882
|
+
idleSec: snapshot.idleMs !== null ? Math.round(snapshot.idleMs / 1e3) : null
|
|
883
|
+
})), this.opts.rosterRunner !== void 0 ? { runner: this.opts.rosterRunner } : {});
|
|
849
884
|
}
|
|
850
885
|
async status(target, callCtx = {}) {
|
|
851
|
-
const
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
const
|
|
886
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
887
|
+
const label = target.startsWith("byop:") ? target.slice(5) : void 0;
|
|
888
|
+
if (label !== void 0) return this.ptyStatus(target, label, sessionKey);
|
|
889
|
+
const resolved = /^(docker|incus|ssh):/.test(target) ? resolveTarget(target) : await this.resolveBareName(target, sessionKey);
|
|
890
|
+
if (resolved.kind === "pty") return this.ptyStatus(`byop:${resolved.label}`, resolved.label, sessionKey);
|
|
855
891
|
return [
|
|
856
|
-
|
|
857
|
-
renderProbe(
|
|
858
|
-
renderSession(this.pool.inspect(
|
|
892
|
+
`${target} — ${resolved.kind === "ssh" ? "ssh host" : `${resolved.kind} container`}`,
|
|
893
|
+
renderProbe(await probeTarget(resolved, { runner: this.opts.probeRunner })),
|
|
894
|
+
renderSession(this.pool.inspect(`${sessionKey}|t:${target}`))
|
|
859
895
|
].join("\n");
|
|
860
896
|
}
|
|
897
|
+
/** PTY 状态行:纯池内读取,零拨号(label 从来不是可拨目标)。 */
|
|
898
|
+
ptyStatus(display, label, sessionKey) {
|
|
899
|
+
return [`${display} — BYO pty session`, renderPtySession(this.pool.inspect(`${sessionKey}|p:${label}`))].join("\n");
|
|
900
|
+
}
|
|
861
901
|
/** Ruling 13:审计 best-effort——钩子抛错不得影响命令路径(成功被毒化成失败+双重审计)。 */
|
|
862
902
|
audit(r) {
|
|
863
903
|
try {
|
|
@@ -867,16 +907,24 @@ var RemoteDriver = class {
|
|
|
867
907
|
route(params) {
|
|
868
908
|
const hasTarget = params.target !== void 0 && params.target.length > 0;
|
|
869
909
|
const hasSpawn = params.spawn !== void 0 && params.spawn.length > 0;
|
|
870
|
-
if (hasTarget && hasSpawn) throw new Error("[E_PARAMS] remote:
|
|
871
|
-
if (!hasTarget && !hasSpawn) throw new Error("[E_PARAMS] remote: target or spawn is required (e.g. { target: \"dev4\", cmd: \"git status\" })");
|
|
910
|
+
if (!hasTarget && !hasSpawn) throw new Error("[E_PARAMS] remote: target is required (e.g. { target: \"dev4\", cmd: \"git status\" }); spawn is a modifier of a \"byop:<label>\" target and cannot stand alone");
|
|
872
911
|
if (params.timeout !== void 0 && (!Number.isFinite(params.timeout) || params.timeout <= 0)) throw new Error(`[E_BAD_TIMEOUT] remote: timeout must be a positive number of seconds (got ${JSON.stringify(params.timeout)})`);
|
|
873
912
|
if (params.stdin !== void 0 && (params.cmd === void 0 || params.cmd.length === 0)) throw new Error("[E_STDIN_WITHOUT_CMD] remote: stdin requires cmd");
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
913
|
+
if (hasTarget && params.target.startsWith("byop:")) {
|
|
914
|
+
const label = params.target.slice(5);
|
|
915
|
+
if (!PTY_LABEL_RE.test(label)) throw new Error(`[E_BAD_LABEL] remote: PTY label must match ${PTY_LABEL_RE.source} (got ${JSON.stringify(label)}) — e.g. target: "byop:dev3"`);
|
|
916
|
+
if (params.mode !== void 0 && params.mode !== "pty") throw new Error(`[E_BAD_MODE] remote: "${params.target}" is a PTY selector — mode is locked to 'pty'`);
|
|
917
|
+
return {
|
|
918
|
+
mode: "pty",
|
|
919
|
+
display: params.target,
|
|
920
|
+
pty: {
|
|
921
|
+
label,
|
|
922
|
+
...hasSpawn ? { spawn: params.spawn } : {}
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn requires a \"byop:<label>\" target — e.g. { target: \"byop:dev3\", spawn: \"ssh dev3\", cmd: \"…\" }; it is not a standalone entry point");
|
|
927
|
+
const mode = params.mode ?? "oneshot";
|
|
880
928
|
const direct = /^(docker|incus|ssh):/.test(params.target);
|
|
881
929
|
return {
|
|
882
930
|
mode,
|
|
@@ -885,9 +933,18 @@ var RemoteDriver = class {
|
|
|
885
933
|
};
|
|
886
934
|
}
|
|
887
935
|
async execute(params, routing, callCtx) {
|
|
888
|
-
|
|
936
|
+
const sessionKey = callCtx.sessionKey ?? "no-session";
|
|
937
|
+
if (routing.bare !== void 0) {
|
|
938
|
+
const resolved = await this.resolveBareName(routing.bare, sessionKey, params.mode !== "oneshot");
|
|
939
|
+
if (resolved.kind === "pty") {
|
|
940
|
+
routing.pty = { label: resolved.label };
|
|
941
|
+
routing.mode = "pty";
|
|
942
|
+
routing.display = `byop:${resolved.label}`;
|
|
943
|
+
} else routing.plan = resolved;
|
|
944
|
+
}
|
|
889
945
|
const timeoutSec = params.timeout ?? this.opts.execTimeoutSec ?? 120;
|
|
890
946
|
const cap = this.opts.maxOutputChars ?? 3e4;
|
|
947
|
+
if (routing.pty !== void 0) return await this.ptyCall(routing.pty, params, sessionKey, timeoutSec, cap);
|
|
891
948
|
if (routing.mode === "oneshot") {
|
|
892
949
|
const r$1 = await runOneShot((this.opts.oneshotArgvFor ?? buildOneShotArgv)(routing.plan, params.cmd), {
|
|
893
950
|
stdin: params.stdin,
|
|
@@ -908,8 +965,8 @@ var RemoteDriver = class {
|
|
|
908
965
|
...out$1.truncated !== void 0 ? { truncated: out$1.truncated } : {}
|
|
909
966
|
};
|
|
910
967
|
}
|
|
911
|
-
const argv =
|
|
912
|
-
const key = `${
|
|
968
|
+
const argv = (this.opts.ptyArgvFor ?? buildPtyArgv)(routing.plan);
|
|
969
|
+
const key = `${sessionKey}|t:${routing.display}`;
|
|
913
970
|
const r = await this.pool.getOrCreate(key, argv).dispatch(params.cmd, {
|
|
914
971
|
stdin: params.stdin,
|
|
915
972
|
timeoutSec
|
|
@@ -927,6 +984,38 @@ var RemoteDriver = class {
|
|
|
927
984
|
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
928
985
|
};
|
|
929
986
|
}
|
|
987
|
+
/** BYO-PTY 通道:label 即池键(agent 前缀之外),spawn 只在 label 尚不存在时被接受。
|
|
988
|
+
* 三分支 —— 建(无会话)/ 复用(无 spawn,或有 spawn 且与创建来源相同)/ **冲突(有 spawn 且来源不同:不执行任何命令)**。 */
|
|
989
|
+
async ptyCall(pty, params, sessionKey, timeoutSec, cap) {
|
|
990
|
+
const display = `byop:${pty.label}`;
|
|
991
|
+
const key = `${sessionKey}|p:${pty.label}`;
|
|
992
|
+
const existing = this.pool.get(key);
|
|
993
|
+
let session = existing;
|
|
994
|
+
let notice;
|
|
995
|
+
if (existing !== void 0 && pty.spawn !== void 0 && existing.origin !== pty.spawn) throw new Error(`[E_LABEL_CONFLICT] remote: ${display} already exists and was created by a different command — nothing was run.\n existing spawn: ${JSON.stringify(existing.origin)}\n given spawn: ${JSON.stringify(pty.spawn)}\n To reuse it: { target: "${display}", cmd: "…" } (drop spawn — spawn only ever creates)\n To start new: pick another label, e.g. { target: "byop:${pty.label}2", spawn: ${JSON.stringify(pty.spawn)}, cmd: "…" }`);
|
|
996
|
+
if (session === void 0) {
|
|
997
|
+
if (pty.spawn === void 0) throw new Error(`[E_NO_SESSION] remote: no live PTY under ${display} — one exists only after a call that supplies spawn, and it is reaped after the idle TTL (600s).\n Create it: { target: "${display}", spawn: "…", cmd: "…" } (the spawn command is not retained; the label is yours to choose)`);
|
|
998
|
+
session = this.pool.getOrCreate(key, (this.opts.spawnArgvFor ?? buildSpawnArgv)(pty.spawn), pty.spawn);
|
|
999
|
+
} else if (pty.spawn !== void 0) notice = `[remote: ${display} already runs that exact command and is persistent — reuse it with { target: "${display}", cmd: "…" }; spawn is only needed to create.]`;
|
|
1000
|
+
const r = await session.dispatch(params.cmd, {
|
|
1001
|
+
stdin: params.stdin,
|
|
1002
|
+
timeoutSec
|
|
1003
|
+
});
|
|
1004
|
+
const out = tailWindow(r.output, cap);
|
|
1005
|
+
return {
|
|
1006
|
+
mode: "pty",
|
|
1007
|
+
target: display,
|
|
1008
|
+
session: display,
|
|
1009
|
+
exit: r.exit,
|
|
1010
|
+
cwd: r.cwd,
|
|
1011
|
+
timedOut: r.timedOut,
|
|
1012
|
+
reconnected: r.reconnected,
|
|
1013
|
+
stdout: out.text,
|
|
1014
|
+
...notice !== void 0 ? { notice } : {},
|
|
1015
|
+
durationMs: r.durationMs,
|
|
1016
|
+
...out.truncated !== void 0 ? { truncated: out.truncated } : {}
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
930
1019
|
async dispose() {
|
|
931
1020
|
await this.pool.disposeAll();
|
|
932
1021
|
}
|
|
@@ -939,15 +1028,15 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
939
1028
|
const getSessionKey = opts.getSessionKey ?? ((exec) => exec.agent?.id ?? NO_SESSION_KEY);
|
|
940
1029
|
return defineTool({
|
|
941
1030
|
name: "remote",
|
|
942
|
-
description: "Remote execution over three transports (ssh / docker / incus). Pick the call shape first:\n {}
|
|
1031
|
+
description: "Remote execution over three transports (ssh / docker / incus) plus named persistent BYO shells. Pick the call shape first:\n {} → roster: known target names from a local scan (no dialing)\n {target} → on-demand status probe of that one target (for \"byop:<label>\": its session state)\n {target, cmd} → run cmd — oneshot by default; mode \"pty\" for a persistent shell\n {target:\"byop:<label>\", spawn, cmd} → BYO-PTY: create the persistent shell you named <label>\n {target:\"byop:<label>\", cmd} → reuse that shell (spawn is only needed to create)\nBYO-PTY labels are yours to choose, and the label alone is the whole reuse mechanism: \"byop:dev3\" is created once with `spawn` (the command that starts an interactive shell, e.g. \"docker exec -it img bash\"), and from then on you address it as {target:\"byop:dev3\", cmd} with no spawn. Repeating the same spawn for a live label is reused and the result tells you to drop it; repeating a *different* spawn is refused with [E_LABEL_CONFLICT] and nothing runs — reuse the label, or pick a new one. A label that was never created, or was reaped after the idle TTL, answers [E_NO_SESSION]: spawn is not retained, so pass it again to re-create. A spawn that fails to come up (e.g. a bad host) leaves nothing behind either — the label stays free, so just retry with a corrected spawn. Bare names match in order — ssh config, then docker, then incus, then a live pty label (on a collision the earlier source wins; a name matching none of them goes to ssh and its native error surfaces). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel when you must be explicit. oneshot: stateless `bash -lc`, stdout/stderr separate, the exit code is the remote process's own. pty: one persistent shell per (agent, target) — cwd/env survive across calls, streams merge, Ctrl-C interrupts without killing the session, idle sessions are reaped after 600s (a reconnect notice precedes the fresh shell). Output is tail-truncated at ~30k chars with a \"[truncated: …]\" header — redirect bulk output to a file yourself. `stdin` feeds the command; `timeout` (seconds) interrupts then kills. Native transport errors pass through verbatim — self-correct from them. Background processes must redirect their own output (`cmd > log 2>&1 &`). Status probes report \"reachable\"/\"unreachable — <native error>\" or the factual container state plus the session layer (\"idle Ns\" / \"busy\" / \"none — dials on first exec\"), always probed at call time.",
|
|
943
1032
|
parameters: {
|
|
944
1033
|
target: {
|
|
945
1034
|
type: "string",
|
|
946
|
-
description: "Bare name / IP / domain matches
|
|
1035
|
+
description: "Single entry point. Bare name / IP / domain matches in order: ssh config, then docker, then incus, then a live pty label (a collision goes to the earlier source). \"byop:<label>\" / \"docker:<name>\" / \"incus:<name>\" / \"ssh:<name>\" force a channel. For \"byop:<label>\", pass spawn the first time to create it and omit spawn afterwards to reuse it. Omit cmd → status probe."
|
|
947
1036
|
},
|
|
948
1037
|
spawn: {
|
|
949
1038
|
type: "string",
|
|
950
|
-
description: "
|
|
1039
|
+
description: "Only valid together with a \"byop:<label>\" target, and required the first time that label is created: the full command that starts an interactive shell (e.g. \"docker exec -it img bash\"). It is not retained — pass it again if the label answers [E_NO_SESSION]. Requires cmd; mode is locked to pty; nested hops are dumb pipes (only the innermost bash frames)."
|
|
951
1040
|
},
|
|
952
1041
|
cmd: {
|
|
953
1042
|
type: "string",
|
|
@@ -956,7 +1045,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
956
1045
|
mode: {
|
|
957
1046
|
type: "string",
|
|
958
1047
|
enum: ["oneshot", "pty"],
|
|
959
|
-
description: "'oneshot' (default): stateless `bash -lc`, stdout/stderr separate, exit code is the process's own. 'pty': one persistent shell per (agent, target), cwd/env survive, streams merge, idle TTL 600s."
|
|
1048
|
+
description: "'oneshot' (default): stateless `bash -lc`, stdout/stderr separate, exit code is the process's own. 'pty': one persistent shell per (agent, target), cwd/env survive, streams merge, idle TTL 600s. A \"byop:<label>\" target locks mode to 'pty'."
|
|
960
1049
|
},
|
|
961
1050
|
stdin: {
|
|
962
1051
|
type: "string",
|
|
@@ -991,7 +1080,9 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
991
1080
|
stderr: { type: "string" },
|
|
992
1081
|
timedOut: { type: "boolean" },
|
|
993
1082
|
reconnected: { type: "boolean" },
|
|
994
|
-
truncated: { type: "integer" }
|
|
1083
|
+
truncated: { type: "integer" },
|
|
1084
|
+
session: { type: "string" },
|
|
1085
|
+
notice: { type: "string" }
|
|
995
1086
|
}
|
|
996
1087
|
},
|
|
997
1088
|
render: (_args, value) => [{
|
|
@@ -1009,7 +1100,7 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1009
1100
|
if (hasSpawn) throw new Error("[E_PARAMS] remote: spawn is an exec channel — pass cmd (there is nothing to probe for BYO-PTY)");
|
|
1010
1101
|
if (!hasTarget) return {
|
|
1011
1102
|
kind: "roster",
|
|
1012
|
-
text: await driver.roster(),
|
|
1103
|
+
text: await driver.roster({ sessionKey: getSessionKey(exec) }),
|
|
1013
1104
|
exit: null,
|
|
1014
1105
|
durationMs: 0
|
|
1015
1106
|
};
|
|
@@ -1039,6 +1130,8 @@ function createRemoteTool(driver, opts = {}) {
|
|
|
1039
1130
|
if (r.timedOut) v.timedOut = true;
|
|
1040
1131
|
if (r.reconnected) v.reconnected = true;
|
|
1041
1132
|
if (r.truncated !== void 0) v.truncated = r.truncated;
|
|
1133
|
+
if (r.session !== void 0) v.session = r.session;
|
|
1134
|
+
if (r.notice !== void 0) v.notice = r.notice;
|
|
1042
1135
|
return v;
|
|
1043
1136
|
}
|
|
1044
1137
|
});
|
|
@@ -1047,6 +1140,7 @@ function renderValue(v) {
|
|
|
1047
1140
|
if (v.kind === "status" || v.kind === "roster") return v.text;
|
|
1048
1141
|
const parts = [];
|
|
1049
1142
|
if (v.reconnected === true) parts.push(RECONNECT_NOTICE);
|
|
1143
|
+
if (typeof v.notice === "string" && v.notice.length > 0) parts.push(v.notice);
|
|
1050
1144
|
parts.push(v.text);
|
|
1051
1145
|
const secs = ((v.durationMs ?? 0) / 1e3).toFixed(1);
|
|
1052
1146
|
const markers = [];
|
|
@@ -1054,7 +1148,8 @@ function renderValue(v) {
|
|
|
1054
1148
|
if (v.truncated !== void 0) markers.push(`truncated, original ${v.truncated} chars`);
|
|
1055
1149
|
const suffix = markers.length > 0 ? ` · ${markers.join(" · ")}` : "";
|
|
1056
1150
|
const cwdTag = typeof v.cwd === "string" && v.cwd.length > 0 ? ` · cwd ${v.cwd}` : "";
|
|
1057
|
-
|
|
1151
|
+
const sessionTag = typeof v.session === "string" && v.session.length > 0 ? ` · ${v.session}` : "";
|
|
1152
|
+
parts.push(`[exit ${v.exit} · ${secs}s${suffix}${cwdTag}${sessionTag}]`);
|
|
1058
1153
|
if (v.stderr !== void 0 && v.stderr.length > 0) parts.push("[stderr]", v.stderr);
|
|
1059
1154
|
return parts.join("\n");
|
|
1060
1155
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import { ToolDefinition } from "@deepseek-ai/dsh-tools";
|
|
3
|
-
import * as
|
|
3
|
+
import * as _deepseek_ai_schemastery11 from "@deepseek-ai/schemastery";
|
|
4
4
|
|
|
5
5
|
//#region src/url-schemes/tools/write.d.ts
|
|
6
6
|
|
|
@@ -50,12 +50,12 @@ declare const inject: string[];
|
|
|
50
50
|
* real patch-line config. Schema-level defaults (both ON — the service is
|
|
51
51
|
* opt-out) survive every overlay layer, per the v0.2.2a ruling.
|
|
52
52
|
*/
|
|
53
|
-
declare const Config:
|
|
54
|
-
urlSchemes:
|
|
55
|
-
hashline:
|
|
53
|
+
declare const Config: _deepseek_ai_schemastery11.default<Schemastery.ObjectS<NoInfer<{
|
|
54
|
+
urlSchemes: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
55
|
+
hashline: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
56
56
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
57
|
-
urlSchemes:
|
|
58
|
-
hashline:
|
|
57
|
+
urlSchemes: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
58
|
+
hashline: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
59
59
|
}>>, "plain">;
|
|
60
60
|
/** Register the four URL-aware tools on one agent's own scope layer. */
|
|
61
61
|
/**
|
|
@@ -75,12 +75,12 @@ declare function apply(ctx: Context, config: UrlSchemesConfig | undefined): void
|
|
|
75
75
|
declare const _default: {
|
|
76
76
|
name: string;
|
|
77
77
|
inject: string[];
|
|
78
|
-
Config:
|
|
79
|
-
urlSchemes:
|
|
80
|
-
hashline:
|
|
78
|
+
Config: _deepseek_ai_schemastery11.default<Schemastery.ObjectS<NoInfer<{
|
|
79
|
+
urlSchemes: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
80
|
+
hashline: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
81
81
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
82
|
-
urlSchemes:
|
|
83
|
-
hashline:
|
|
82
|
+
urlSchemes: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
83
|
+
hashline: _deepseek_ai_schemastery11.default<boolean, boolean, "defined">;
|
|
84
84
|
}>>, "plain">;
|
|
85
85
|
apply: typeof apply;
|
|
86
86
|
};
|
package/lib/web-password.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import { CredentialRecord } from "@deepseek-ai/dsh-credentials";
|
|
3
3
|
import { IncomingMessage } from "node:http";
|
|
4
|
-
import * as
|
|
4
|
+
import * as _deepseek_ai_schemastery0 from "@deepseek-ai/schemastery";
|
|
5
5
|
|
|
6
6
|
//#region src/web-password.d.ts
|
|
7
7
|
|
|
@@ -123,10 +123,10 @@ interface WebPasswordConfig {
|
|
|
123
123
|
/** Absolute browser-session lifetime in days. Default: 30 (the native default). */
|
|
124
124
|
cookieMaxAgeDays?: number;
|
|
125
125
|
}
|
|
126
|
-
declare const Config:
|
|
127
|
-
cookieMaxAgeDays:
|
|
126
|
+
declare const Config: _deepseek_ai_schemastery0.default<Schemastery.ObjectS<NoInfer<{
|
|
127
|
+
cookieMaxAgeDays: _deepseek_ai_schemastery0.default<number, number, "defined">;
|
|
128
128
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
129
|
-
cookieMaxAgeDays:
|
|
129
|
+
cookieMaxAgeDays: _deepseek_ai_schemastery0.default<number, number, "defined">;
|
|
130
130
|
}>>, "plain">;
|
|
131
131
|
/** Cordis plugin name; also the scope of this row's credential record. */
|
|
132
132
|
declare const name = "dashr-web-password";
|
|
@@ -145,10 +145,10 @@ declare function apply(ctx: Context, config?: WebPasswordConfig): void;
|
|
|
145
145
|
declare const _default: {
|
|
146
146
|
name: string;
|
|
147
147
|
inject: string[];
|
|
148
|
-
Config:
|
|
149
|
-
cookieMaxAgeDays:
|
|
148
|
+
Config: _deepseek_ai_schemastery0.default<Schemastery.ObjectS<NoInfer<{
|
|
149
|
+
cookieMaxAgeDays: _deepseek_ai_schemastery0.default<number, number, "defined">;
|
|
150
150
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
151
|
-
cookieMaxAgeDays:
|
|
151
|
+
cookieMaxAgeDays: _deepseek_ai_schemastery0.default<number, number, "defined">;
|
|
152
152
|
}>>, "plain">;
|
|
153
153
|
apply: typeof apply;
|
|
154
154
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
-
import * as
|
|
2
|
+
import * as _deepseek_ai_schemastery5 from "@deepseek-ai/schemastery";
|
|
3
3
|
|
|
4
4
|
//#region src/web-trust.d.ts
|
|
5
5
|
|
|
@@ -73,10 +73,10 @@ interface TrustConfig {
|
|
|
73
73
|
trustedPageAuthorities?: readonly string[];
|
|
74
74
|
}
|
|
75
75
|
/** Row config schema (schema-level default derives from `DSH_TRUSTED_HOSTS`). */
|
|
76
|
-
declare const Config:
|
|
77
|
-
trustedPageAuthorities:
|
|
76
|
+
declare const Config: _deepseek_ai_schemastery5.default<Schemastery.ObjectS<NoInfer<{
|
|
77
|
+
trustedPageAuthorities: _deepseek_ai_schemastery5.default<string[], string[], "defined">;
|
|
78
78
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
79
|
-
trustedPageAuthorities:
|
|
79
|
+
trustedPageAuthorities: _deepseek_ai_schemastery5.default<string[], string[], "defined">;
|
|
80
80
|
}>>, "plain">;
|
|
81
81
|
/** Cordis plugin name. */
|
|
82
82
|
declare const name = "dashr-web-trust";
|
|
@@ -95,10 +95,10 @@ declare function apply(ctx: Context, config: TrustConfig | undefined): void;
|
|
|
95
95
|
declare const _default: {
|
|
96
96
|
name: string;
|
|
97
97
|
inject: string[];
|
|
98
|
-
Config:
|
|
99
|
-
trustedPageAuthorities:
|
|
98
|
+
Config: _deepseek_ai_schemastery5.default<Schemastery.ObjectS<NoInfer<{
|
|
99
|
+
trustedPageAuthorities: _deepseek_ai_schemastery5.default<string[], string[], "defined">;
|
|
100
100
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
101
|
-
trustedPageAuthorities:
|
|
101
|
+
trustedPageAuthorities: _deepseek_ai_schemastery5.default<string[], string[], "defined">;
|
|
102
102
|
}>>, "plain">;
|
|
103
103
|
apply: typeof apply;
|
|
104
104
|
};
|
package/lib/web-trust.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as _default, c as buildTrustScript, d as name, i as TrustConfig, l as deriveDefaultPageAuthorities, n as Config, o as apply, r as MobileConfig, s as buildMobileScript, t as BootScriptRow, u as inject } from "./web-trust-
|
|
1
|
+
import { a as _default, c as buildTrustScript, d as name, i as TrustConfig, l as deriveDefaultPageAuthorities, n as Config, o as apply, r as MobileConfig, s as buildMobileScript, t as BootScriptRow, u as inject } from "./web-trust-CL9P6jVe.js";
|
|
2
2
|
export { BootScriptRow, Config, MobileConfig, TrustConfig, apply, buildMobileScript, buildTrustScript, _default as default, deriveDefaultPageAuthorities, inject, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "better-dsh",
|
|
3
|
-
"version": "0.2.5-
|
|
3
|
+
"version": "0.2.5-c",
|
|
4
4
|
"description": "Make your dsh ready for serious coding tasks. (Tools x Schemas)^REPL. `skill://`, `ctx://`, `agent://`, `dvc://`, `dsh://`, IPython REPL, Context as Variables, cross compactions recallable, full context revive. `hash-edit`, `dvc://browser`, subagent as function, workflow as function.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|