dsh-pwsh-plugin 0.2.16 → 0.2.17

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/FINDINGS.md CHANGED
@@ -1877,4 +1877,39 @@ No authorization header was set for the request.
1877
1877
 
1878
1878
  十七份 → 八份,305 项 → **117 项**(itest 17 / plugintest 15 / e2e 8 / sandbox 7 / bash 25 / bash-e2e 11 /
1879
1879
  denial 11 / meta 23)。
1880
+ ## 三十五、WSL 里的 SSH agent:非交互 shell 不读 .bashrc(2026-10-30)
1881
+
1882
+ `bash` 工具跑的是 `bash -s`,非交互。Ubuntu 的默认 `~/.bashrc` 第 8-12 行有一句提前返回
1883
+ (`case $- in *i*) ;; *) return;; esac`),所以文件末尾那句 `eval $(wsl2-ssh-agent)` 永远执行不到,
1884
+ `SSH_AUTH_SOCK` 保持为空。在首行插一个 `echo` 标记即可确认:`bash -s` 的输出里没有它,
1885
+ `bash -i -s` 有。三条调用对照:`bash -i -s` 与 `bash -l -s`(经 `.profile`)有钥匙,`bash -s` 没有。
1886
+
1887
+ 围栏与此无关。python 直连 socket 的探测在裸 shell 和 llrun 内都返回 `CONNECTED`,`SO_PEERCRED` 的
1888
+ pid/uid/gid 相同,identities 请求回 541 字节的 type 12。四格矩阵(有无围栏 × 有无显式
1889
+ `SSH_AUTH_SOCK`)说明成败只取决于变量有没有给出来。
1890
+
1891
+ 围栏确实挡了一件事:它屏蔽 `/init`,于是 `wsl2-ssh-agent` 在围栏内起不来 —— 那个桥要调
1892
+ `powershell.exe` 去读 Windows 侧的 `\\.\pipe\openssh-ssh-agent`,在围栏内是 `Permission denied`。
1893
+ 所以守护进程只能在围栏外活,靠一个 systemd 用户单元持续拉起:
1894
+ `/home/lihao/.config/systemd/user/skadi-ssh-agent.service`,内容是
1895
+ `ExecStart=%h/.local/bin/wsl2-ssh-agent -foreground`、`ExecStartPre` 先删残留 socket、
1896
+ `Restart=on-failure`。实测四种保活方式里只有它稳:`eval "$(wsl2-ssh-agent)"` 与
1897
+ `setsid nohup ... &` 都在父 shell 退出后几十秒内消失,`systemd-run --user --scope` 直接不返回
1898
+ (占满超时),`--unit=... --collect` 的 PPID 是 1。
1899
+
1900
+ 插件侧的做法是给每条命令前置三行:
1901
+
1902
+ ```sh
1903
+ if [ -z "$SSH_AUTH_SOCK" ] && [ -S "$HOME/.ssh/wsl2-ssh-agent.sock" ]; then
1904
+ export SSH_AUTH_SOCK="$HOME/.ssh/wsl2-ssh-agent.sock"
1905
+ fi
1906
+ ```
1907
+
1908
+ 判据是 `-S`(真 socket)而不是 `-e`,且不覆盖用户自己设的值 —— 脚本内 `export` 仍然胜出,这条有断言
1909
+ 钉住。`sshAgentSocket: false` 关掉注入,其他字符串替换路径。端到端实测在围栏内拿到 8 把钥匙。
1910
+
1911
+ 顺带修掉一个实现缺陷:无围栏 + `workdir` 的分支原本用 `args.command` 重建脚本,会把前置探测丢掉;
1912
+ 现在基于已带前置的 `script` 再包 `cd`。断言 `agent-survives-workdir` 钉住这一条。
1913
+
1914
+ 计数因此从 117 涨到 **123**(bash 25 → 31)。`docs/llrun.md` 里的 25 与两份 README 的 117 同批改过。
1880
1915
 
package/README.md CHANGED
@@ -218,6 +218,13 @@ It needs no manual install: the binary ships inside the package and is the last
218
218
  `plugin add` is enough. Since releases are packed on Windows, that copy arrives as `0644` — the probe
219
219
  restores the executable bit itself before giving up, as long as the file belongs to the current user.
220
220
 
221
+ The same row also injects `SSH_AUTH_SOCK`. `bash -s` is non-interactive, so the `eval $(wsl2-ssh-agent)`
222
+ line at the bottom of `~/.bashrc` never runs and the variable stays empty — the fence is not the cause, a
223
+ socket named explicitly works inside it just as well. A three-line probe exports
224
+ `$HOME/.ssh/wsl2-ssh-agent.sock` when it is a real socket and nothing is set yet; `sshAgentSocket: false`
225
+ turns it off. The agent daemon itself must live **outside** the fence (it needs `/init`), typically as a
226
+ systemd user unit.
227
+
221
228
  Full description, the `-r` semantics table, the 54-assertion acceptance matrix and the wiring:
222
229
  **[docs/llrun.md](docs/llrun.md)**. Run `bash wsl/acceptance.sh` from a plain WSL shell (not from inside
223
230
  `custom-bash`, where nesting a second fence makes four assertions fail for the wrong reason) to re-verify on
@@ -282,13 +289,13 @@ PWSH_BIN='pwsh' node test/itest.mjs # 17: protocol and session core
282
289
  PWSH_BIN='pwsh' node test/plugintest.mjs # 15: full plugin path
283
290
  node test/e2e-real-subprocess.mjs # 8: real DSH subprocess
284
291
  node test/sandboxtest.mjs # 7: real sandbox boundary + fail-closed
285
- node test/bashtest.mjs # 25: custom-bash fence triage, bash fallback, exec-bit repair
292
+ node test/bashtest.mjs # 31: custom-bash fence triage, bash fallback, exec-bit repair
286
293
  node test/bash-e2e.mjs # 11: custom-bash end to end
287
294
  node test/denialtest.mjs # 11: sandbox-denial hint (no spawn, runs anywhere)
288
295
  node test/metatest.mjs # 23: plugin-page metadata (no spawn, runs anywhere)
289
296
  ```
290
297
 
291
- Eight scripts, **117 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
298
+ Eight scripts, **123 assertions**, none of which needs DSH to be running — just node 24, PowerShell 7 and a
292
299
  DSH install to borrow `node_modules` from. Tests that need a DSH runtime locate one automatically
293
300
  (`DSH_MODULES`, `DSH_MODULES_ASAR`, or by walking up from the working directory) and print a `SKIP` hint
294
301
  instead of failing when none is found.
package/README.zh-CN.md CHANGED
@@ -191,6 +191,11 @@ $env:DSH_HOME = '<DSH_HOME>'
191
191
  不需要手动装:二进制随包发布,探测候选的最后一个就是它,所以一次 `plugin add` 就够。由于发布包在
192
192
  Windows 上打,那份文件到手时是 `0644` —— 探测发现它属于当前用户却不可执行时,会自己补上执行位再试。
193
193
 
194
+ 同一行还会注入 `SSH_AUTH_SOCK`。`bash -s` 是非交互的,`~/.bashrc` 末尾那句 `eval $(wsl2-ssh-agent)`
195
+ 永远执行不到,变量因此是空的 —— 围栏不是原因,显式给出的 socket 在围栏内一样能用。现在每条命令前
196
+ 都带一段三行探测:`$HOME/.ssh/wsl2-ssh-agent.sock` 是真 socket 且当前没设过值时导出它;
197
+ `sshAgentSocket: false` 关掉。agent 守护进程本身必须在**围栏外**(它要 `/init`),通常做成 systemd 用户单元。
198
+
194
199
  完整说明、`-r` 档语义表、54 项验收矩阵与接入方式:**[docs/llrun.md](docs/llrun.md)**。想在自己机器上
195
200
  复验就 `bash wsl/acceptance.sh`(要在未被 `custom-bash` 包裹的 WSL 里跑:套第二层围栏会让四项断言
196
201
  因错误的原因变红)—— 它的每个路径都从自身位置推导,并在结尾复核磁盘有没有泄漏。
@@ -254,13 +259,13 @@ PWSH_BIN='pwsh' node test/itest.mjs # 17:协议与会话本体
254
259
  PWSH_BIN='pwsh' node test/plugintest.mjs # 15:插件完整路径
255
260
  node test/e2e-real-subprocess.mjs # 8:真 DSH subprocess
256
261
  node test/sandboxtest.mjs # 7:真沙箱边界 + fail-closed
257
- node test/bashtest.mjs # 25:custom-bash 围栏分诊、bash 后备、执行位自修复
262
+ node test/bashtest.mjs # 31:custom-bash 围栏分诊、bash 后备、执行位自修复、SSH agent
258
263
  node test/bash-e2e.mjs # 11:custom-bash 端到端
259
264
  node test/denialtest.mjs # 11:沙箱拒绝提示(不起子进程,哪儿都能跑)
260
265
  node test/metatest.mjs # 23:插件页元数据(不起子进程,哪儿都能跑)
261
266
  ```
262
267
 
263
- 八份脚本合计 **117 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
268
+ 八份脚本合计 **123 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
264
269
  `node_modules` 的 DSH 安装。需要 DSH 运行时的测试会自动找(`DSH_MODULES`、`DSH_MODULES_ASAR`,
265
270
  或从工作目录逐级向上),找不到时打一行 `SKIP` 提示而不是失败。
266
271
 
package/cordis.patch.yml CHANGED
@@ -25,3 +25,7 @@
25
25
  disabled: !!js process.platform !== 'win32'
26
26
  config:
27
27
  timeoutMs: 300000
28
+ # 非交互 shell 不读 ~/.bashrc,用户终端里能用的 SSH_AUTH_SOCK 在这里是空的。
29
+ # 这段探测让 bash 工具也能用 Windows 侧的 ssh-agent(socket 由 wsl2-ssh-agent 提供,
30
+ # 守护进程要另行保活,例如一个 systemd 用户单元)。设 false 关掉。
31
+ sshAgentSocket: $HOME/.ssh/wsl2-ssh-agent.sock
package/docs/llrun.md CHANGED
@@ -184,6 +184,7 @@ Configuration:
184
184
  config:
185
185
  llrunPath: '/mnt/c/Users/<you>/.local/bin/llrun' # omit to probe the candidates
186
186
  fence: true # false = no fence (not recommended)
187
+ sshAgentSocket: '$HOME/.ssh/wsl2-ssh-agent.sock' # false = do not inject
187
188
  ```
188
189
 
189
190
  `llrunPath` is only for overriding. Left out, the plugin probes `$LLRUN` / `$DSH_LLRUN`,
@@ -192,8 +193,27 @@ to `custom-bash.mjs` inside the package — first executable wins, and the resul
192
193
  install works without a restart. Yours come first: a system-level `llrun` may be newer, the packaged one only
193
194
  guarantees that the fence is there at all.
194
195
 
195
- Tests: `node test/bashtest.mjs` (25, mock runtime) + `node test/bash-e2e.mjs` (11, real runtime + real llrun).
196
+ Tests: `node test/bashtest.mjs` (31, mock runtime) + `node test/bash-e2e.mjs` (11, real runtime + real llrun).
196
197
  Three of the 25 pin the executable-bit repair: a `0644` copy is made executable and the call succeeds, an
197
198
  unusable copy is named in the error, and an unusable candidate earlier in the list does not shadow a usable
198
199
  one later. They only assert the mode on POSIX hosts — NTFS reports every file as `0666`, so there the
199
200
  assertion is that the call still works.
201
+
202
+ ### The SSH agent socket
203
+
204
+ `bash -s` is a **non-interactive** shell: it never reads `~/.bashrc`, so the usual
205
+ `eval $(wsl2-ssh-agent)` line at the bottom of that file never runs and `SSH_AUTH_SOCK` stays empty. The
206
+ fence is not the cause — a socket given explicitly works inside it just as well as outside.
207
+
208
+ `custom-bash` therefore prepends a three-line probe to every command:
209
+
210
+ ```sh
211
+ if [ -z "$SSH_AUTH_SOCK" ] && [ -S "$HOME/.ssh/wsl2-ssh-agent.sock" ]; then
212
+ export SSH_AUTH_SOCK="$HOME/.ssh/wsl2-ssh-agent.sock"
213
+ fi
214
+ ```
215
+
216
+ It tests `-S` (a real socket, not merely an existing path) and never overwrites a value you set yourself —
217
+ so a script that exports its own socket still wins. `sshAgentSocket: false` turns the injection off; any
218
+ other string replaces the path. The agent itself is still yours to keep running: the fence blocks
219
+ `/init`, so `wsl2-ssh-agent` cannot start **inside** it and must be a user service outside.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pwsh-plugin",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "description": "Persistent pwsh session tool (pwsh_run) for DeepSeek Harness: run PowerShell scripts with zero escaping, no 32K command-line cap, and state that survives across calls.",
6
6
  "icon": "./icon.svg",
@@ -22,6 +22,8 @@
22
22
  * 最后才用包内自带的 wsl/llrun —— 显式安装的优先,包里那份只负责开箱可用。
23
23
  * 取第一个可执行的;探测结果缓存,装好即生效,不必重启。
24
24
  * fence 是否按 sandbox 权限档挂围栏,默认 true。
25
+ * sshAgentSocket WSL 侧的 ssh-agent socket 路径,默认 '~/.ssh/wsl2-ssh-agent.sock';
26
+ * 给 false 关掉这段探测。见下方「SSH agent」一节。
25
27
  *
26
28
  * 沙箱(2026-10-23)
27
29
  * workspace-write / read-only 下经 `wsl/llrun` 起 bash:userns socket 掩蔽(docker 等)
@@ -40,6 +42,15 @@
40
42
  * - POSIX 路径的 `workdir` 不能当 Windows 进程 cwd(spawn 直接失败);
41
43
  * 有围栏时交给 llrun 的 `-C`(在 landlock 之后 chdir),无围栏时退化为命令内 `cd`。
42
44
  * - 默认工作目录取自 workingDirectory 服务而非 header.cwd:会话中途切目录后 header 不更新。
45
+ *
46
+ * SSH agent
47
+ * 非交互 shell 不读 ~/.bashrc(那是交互式专属),所以用户终端里能用的
48
+ * SSH_AUTH_SOCK 在这里天然是空的。而 WSLENV 转发也不可靠:Windows 侧的
49
+ * 变量要显式列进 WSLENV 才过界,且经 llrun 的 userns 之后仍然没有保证。
50
+ * 因此改成在脚本文本最前面插一段探测:socket 存在就 export,已设过则不动。
51
+ * 走 stdin 送进 `bash -s`,围栏内外是同一条路径,一处覆盖两种模式。
52
+ * 只 export 变量,不启动守护进程 —— 进程得由 systemd 用户单元之类的
53
+ * 围栏外机制维持,本工具无权也不该替用户起后台服务。
43
54
  */
44
55
 
45
56
  import { fileURLToPath } from 'node:url'
@@ -64,6 +75,30 @@ const DEFAULT_TIMEOUT_MS = 120000
64
75
  const DEFAULT_MAX_OUTPUT_BYTES = 64000
65
76
  const PROBE_TIMEOUT_MS = 8000
66
77
 
78
+ /**
79
+ * 默认的 wsl2-ssh-agent socket(rupor-github/wsl-ssh-agent 的默认路径)。
80
+ * 用 $HOME 而不是展开后的绝对路径:脚本在 WSL 里跑,家目录以那边的为准。
81
+ */
82
+ const DEFAULT_SSH_AGENT_SOCKET = '$HOME/.ssh/wsl2-ssh-agent.sock'
83
+
84
+ /**
85
+ * 生成 agent 探测片段。返回空串表示不需要插入。
86
+ *
87
+ * 三件事同时成立才 export:调用方没有自己设 SSH_AUTH_SOCK、socket 确实存在、
88
+ * 并且是个 socket(`-S` 而不是 `-e`)—— 残留的普通文件不该被当成可用的 agent。
89
+ * 已设过就不覆盖:用户显式指定的优先级永远高于我们的猜测,哪怕它指错了,
90
+ * 也该由用户看到错误而不是被静默改到别处。
91
+ */
92
+ export function sshAgentPreamble(socket) {
93
+ if (typeof socket !== 'string' || socket.length === 0) return ''
94
+ const quoted = '"' + socket.replaceAll('"', '\\"') + '"'
95
+ return [
96
+ 'if [ -z "$SSH_AUTH_SOCK" ] && [ -S ' + quoted + ' ]; then',
97
+ ' export SSH_AUTH_SOCK=' + quoted,
98
+ 'fi',
99
+ ].join('\n')
100
+ }
101
+
67
102
  /**
68
103
  * 本文件同目录的 llrun,也就是随插件包一起发布的那份。
69
104
  * 从包加载时它是真的存在(bundle 把这一行指进 node_modules),
@@ -191,6 +226,13 @@ export function apply(ctx, config) {
191
226
  const configuredLlrun = typeof config?.llrunPath === 'string' && config.llrunPath.length > 0 ? config.llrunPath : undefined
192
227
  const llrunCandidates = configuredLlrun === undefined ? defaultLlrunCandidates() : [configuredLlrun]
193
228
  const fenceEnabled = config?.fence !== false
229
+ // 显式给 false 表示完全关掉;给字符串就用它;其余(含缺省)用默认 socket。
230
+ const sshAgentSocket = config?.sshAgentSocket === false
231
+ ? ''
232
+ : typeof config?.sshAgentSocket === 'string' && config.sshAgentSocket.length > 0
233
+ ? config.sshAgentSocket
234
+ : DEFAULT_SSH_AGENT_SOCKET
235
+ const agentPreamble = sshAgentPreamble(sshAgentSocket)
194
236
 
195
237
  /**
196
238
  * bash 解析:按 bashCandidates 顺序问 subprocess 服务,第一个解出来的胜出。
@@ -289,7 +331,8 @@ export function apply(ctx, config) {
289
331
  const needFence = fenceEnabled && mode !== 'danger-full-access'
290
332
 
291
333
  let argv
292
- let script = args.command
334
+ // agent 探测排在用户脚本之前,两条路径共用同一段文本,围栏内外行为一致。
335
+ let script = agentPreamble.length > 0 ? agentPreamble + '\n' + args.command : args.command
293
336
  if (needFence) {
294
337
  const llrunPath = await resolveLlrun(shell, baseCwd)
295
338
  if (llrunPath === undefined) {
@@ -307,7 +350,9 @@ export function apply(ctx, config) {
307
350
  fenceArgs.push(shellQuote(workspace), '/bin/bash', '-s')
308
351
  argv = [shell, '-c', `exec ${llrunExecWord(llrunPath)} ${fenceArgs.join(' ')}`]
309
352
  } else {
310
- if (posixDir !== undefined) script = `cd ${shellQuote(posixDir)} && ${args.command}`
353
+ // 这里要基于已经带上前置片段的 script 再包 cd,不能回到 args.command ——
354
+ // 否则带 workdir 的调用会静默丢掉 agent 探测。
355
+ if (posixDir !== undefined) script = `cd ${shellQuote(posixDir)} && ${script}`
311
356
  argv = [shell, '-s']
312
357
  }
313
358