dsh-pwsh-plugin 0.0.0-stage → 0.2.13
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 +1789 -0
- package/LICENSE +21 -0
- package/README.md +431 -2
- package/README.zh-CN.md +386 -0
- package/cordis.patch.yml +27 -0
- package/docs/llrun.md +199 -0
- package/icon.svg +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +7 -0
- package/package.json +57 -4
- package/plugin/auto-update.mjs +210 -0
- package/plugin/index.mjs +580 -0
- package/plugin/session-server.ps1 +225 -0
- package/plugin/session.mjs +223 -0
- package/plugin/update-apply.mjs +274 -0
- package/plugin/update-check.mjs +270 -0
- package/wsl/acceptance.sh +155 -0
- package/wsl/compaction-epoch.mjs +115 -0
- package/wsl/custom-bash.mjs +355 -0
- package/wsl/llrun +0 -0
- package/wsl/llrun.c +337 -0
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# dsh-pwsh-plugin
|
|
2
|
+
|
|
3
|
+
[English](README.md) · **简体中文**
|
|
4
|
+
|
|
5
|
+
给 DeepSeek Harness 的 `pwsh_run` 工具:PowerShell 脚本正文**经 stdin** 送进一个常驻 pwsh 7 进程 ——
|
|
6
|
+
路上不做任何转义、没有 32K 命令行上限、~400ms 冷启动只付一次,变量 / 当前目录 / 已加载模块跨调用保留。
|
|
7
|
+
|
|
8
|
+
它与内置 `pwsh` 工具**并存**,不替换。
|
|
9
|
+
|
|
10
|
+
同一仓库还带 `wsl/llrun`:一个 C 垫片,给 WSL 里的 `bash` 工具补上真正的写边界。
|
|
11
|
+
详见 [docs/llrun.md](docs/llrun.md)。
|
|
12
|
+
|
|
13
|
+
插件页会显示本包自己的图标、本地化标题与简介;界面语言是中文时,简介下方读的是这份
|
|
14
|
+
`README.zh-CN.md`,而不是英文那份。三处都声明在宿主真正去看的位置:图标走 `package.json` 的
|
|
15
|
+
`icon` 字段(`icon.svg`),文字走 `locale/en.json` 与 `locale/zh.json` —— 文件名不叫 `README` 的文档
|
|
16
|
+
只有靠 `meta.detail` 才读得到。
|
|
17
|
+
|
|
18
|
+
需要 **DSH 0.2.0-rc.2 或更新**(在 0.2.0-rc.2 与 0.2.1-rc.3 上验过)与 **PowerShell 7**。
|
|
19
|
+
|
|
20
|
+
## 为什么需要它
|
|
21
|
+
|
|
22
|
+
| 层 | 现在会发生什么 |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `bash` 工具 | 命令被包成 `/bin/bash -c "<cmd>"`。外层双引号**在 PowerShell 拿到之前**就把 `$` 展掉了,heredoc 死在同一层。 |
|
|
25
|
+
| 内置 `pwsh` 工具 | `$` 与 here-string 原样通过,但每次都是全新进程,而且整条命令走 Windows 命令行:32,000 字符正常,32,700 就 `spawn ENAMETOOLONG`。 |
|
|
26
|
+
| 本工具 | 脚本正文以 base64 走 stdin,两个问题都不存在。 |
|
|
27
|
+
|
|
28
|
+
单次实测 **91,741 字符**。完整实测数据与每一处设计取舍的理由都在 [FINDINGS.md](FINDINGS.md)。
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
这个包本身就是 **DSH bundle**:自带一份 `cordis.patch.yml`,会自己插入它需要的那几行。
|
|
33
|
+
所以装完就能用,不需要动你的 profile。
|
|
34
|
+
|
|
35
|
+
它插两行:
|
|
36
|
+
|
|
37
|
+
- `- id: pwsh-run` —— 工具本体。
|
|
38
|
+
- `- id: custom-bash` —— `bash` 工具的替代实现,仅 Windows 启用(`disabled: !!js process.platform !== 'win32'`)。
|
|
39
|
+
它指向包内的 `dsh-pwsh-plugin/wsl/custom-bash.mjs`,所以围栏用的就是随包发布的那份 `llrun` 二进制 ——
|
|
40
|
+
不用拷贝,也不用往 `/usr/local/bin` 里装东西。`remove` 会把两行和包一起拿走,profile 恢复原样。
|
|
41
|
+
|
|
42
|
+
它**不会**顺手禁用官方的 `tool-bash`:那一行本来就带 `disabled: !!js process.platform === 'win32'`,
|
|
43
|
+
在这里写死 `disabled: true` 会把 Linux 上的官方 bash 一起关掉。
|
|
44
|
+
|
|
45
|
+
**npm registry。** 也在 npm 上:`npm i dsh-pwsh-plugin`,或者让 DSH 装 registry 规格:
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> 注意 pnpm 的 `minimumReleaseAge` 默认 1440 分钟:刚发布的版本当天不会自动解析到,点「更新」会装到上一个版本。这是供应链策略,不是装错。
|
|
52
|
+
|
|
53
|
+
**侧栏。** Plugins → Add plugin → `https://github.com/citydirector/dsh-pwsh-plugin`,然后打开开关。
|
|
54
|
+
|
|
55
|
+
**命令行。** `desktop` profile 只能由桌面端自带的 CLI 管:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
$dsh = '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd'
|
|
59
|
+
$env:DSH_HOME = '<DSH_HOME>' # 桌面 App 自己用的那个数据根
|
|
60
|
+
|
|
61
|
+
# <version> 是任意已发布版本;最新的那个就是插件自己会装的那个
|
|
62
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
63
|
+
# 或者从本地检出装
|
|
64
|
+
& $dsh plugin --profile desktop add 'file:<检出目录>'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`add` 会把 `dsh-pwsh-plugin` 追加进 profile `package.json` 的 `dsh.profile.bundles`,`remove` 取回。
|
|
68
|
+
想验证效果就对一个非保留 profile 跑 `--dump-config`,能看到 `# == dsh-pwsh-plugin` 段和紧跟的
|
|
69
|
+
`- id: pwsh-run / name: dsh-pwsh-plugin`。
|
|
70
|
+
|
|
71
|
+
选 spec 之前要知道两件事:
|
|
72
|
+
|
|
73
|
+
- **registry 规格是拷贝,`file:` 是硬链接。** `dsh-pwsh-plugin@X.Y.Z` 装的是那次发布的一份拷贝 ——
|
|
74
|
+
改检出不会生效,得重新 `add`。`file:` 直接硬链接检出,改完立刻生效(`fsutil hardlink list` 能看到
|
|
75
|
+
两条路径),代价是 `package.json` 里钉死了绝对路径。开发期用 `file:`,长期用 registry 规格。
|
|
76
|
+
`github:citydirector/dsh-pwsh-plugin#<tag>` 仍然能装,效果与 registry 规格相同(都是拷贝),只是
|
|
77
|
+
绕开了 pnpm 的供应链策略。
|
|
78
|
+
- **bundle 那一行带不了 `?v=N`。** 它的 `name` 必须是包名,才能从 profile 的 `node_modules` 解析,
|
|
79
|
+
因此没有 URL 可变,ESM 继续用它已经缓存的那个模块。改 `node_modules` 下的文件,只有等宿主碰巧跑一次
|
|
80
|
+
重组合才可能被换掉 —— 开发期不能指望这个,用下面那种手写行并把 `?v=N` 提一号,或者重启。
|
|
81
|
+
|
|
82
|
+
**手写一行** —— 不想装成依赖时(开发期常用):
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
- insert:
|
|
86
|
+
- id: pwsh-run
|
|
87
|
+
name: '<路径>/plugin/index.mjs'
|
|
88
|
+
config:
|
|
89
|
+
pwshPath: 'pwsh'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
三条花了不少时间才确认的约束:
|
|
93
|
+
|
|
94
|
+
- **`desktop` 是保留 profile。** 便携版 `dsh.exe` 会答 `profile "desktop" is managed exclusively by the Electron application` 并退出 1;只有桌面安装里的 `dsh.cmd`(它以 `manageDesktopProfile: true` 起 desktop-host 的 CLI)能碰它。
|
|
95
|
+
- **`DSH_HOME` 必须指到便携版的数据根。** 默认是 `~/.dsh`,指错就报 `Open DeepSeek Harness Desktop once to initialize its profile` —— 它在找 `~/.dsh/profiles/desktop/package.json`。
|
|
96
|
+
- **别重复注册。** 从手写行换成 bundle(或反过来)时,**先删手写那条再装**。两层都会插入 `- id: pwsh-run`,
|
|
97
|
+
合成后的配置树里就有两条;注册按名字查重,第二个 `pwsh_run` 在启动时是硬错误。`--dump-config` 一眼就能看出重复 ——
|
|
98
|
+
切换后跑这一条。
|
|
99
|
+
|
|
100
|
+
`plugin/` 下三个文件(`index.mjs`、`session.mjs`、`session-server.ps1`)必须放在一起:
|
|
101
|
+
`index.mjs` 在加载时读那个 `.ps1`,再以 `-EncodedCommand` 送给子进程。
|
|
102
|
+
|
|
103
|
+
## 工具接口
|
|
104
|
+
|
|
105
|
+
`pwsh_run` 跑脚本;`pwsh_run_update` 查更新、必要时安装本插件的新版本 —— 见[自动更新检查](#自动更新检查)。
|
|
106
|
+
|
|
107
|
+
| 参数 | 必填 | 说明 |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `script` | 是 | PowerShell 脚本正文。**原样**送达 —— 不要转义 `$`,不要包 `pwsh -c`,不要加外层引号。 |
|
|
110
|
+
| `workdir` | 否 | 先 `Set-Location` 过去。会话有状态,后续调用会继承。路径无效则本次脚本不执行。 |
|
|
111
|
+
| `timeoutMs` | 否 | 单次超时,默认 120000。 |
|
|
112
|
+
|
|
113
|
+
输出:stdout 原样,warning 前缀 `WARNING: `,verbose 前缀 `VERBOSE: `,抛出的异常带 `[script threw]`
|
|
114
|
+
与最内层消息。错误(`Write-Error`、子进程的 stderr)排在 stdout **之后** —— 服务端用 `*>&1` 合并全部
|
|
115
|
+
流并按类型打标,所以 **stdout 与 stderr 的逐行时序不保留**(内置工具是分开两栏的)。需要顺序时自己
|
|
116
|
+
合并:`2>&1` 或 `*>&1`。
|
|
117
|
+
|
|
118
|
+
## 配置
|
|
119
|
+
|
|
120
|
+
| 键 | 默认 | 说明 |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `pwshPath` | `pwsh` | 可执行文件;默认经 `PATH` 解析。 |
|
|
123
|
+
| `timeoutMs` | `120000` | 单次调用默认超时。 |
|
|
124
|
+
| `idleMs` | `1800000` | 空闲会话回收(30 分钟)。 |
|
|
125
|
+
| `maxSessions` | `4` | 同时存活的会话上限(每个 agent 工作区一个)。 |
|
|
126
|
+
| `maxOutputBytes` | `8388608` | 单次调用的输出收集上限(8MB)。截断会明确报出来,不静默。 |
|
|
127
|
+
| `allowUnconfined` | `false` | `true` = 沙箱策略服务缺席时也起会话。 |
|
|
128
|
+
| `updateCheck` | `true` | 定期查有没有新发布的版本。查与装分开:这里只查,装由 `pwsh_run_update` 与自动更新做。 |
|
|
129
|
+
| `autoUpdate` | `true` | 自己装上更新,宿主有 HMR 时**不需要重启**即生效。 |
|
|
130
|
+
| `autoUpdateDelayMs` | `90000` | 自动检查前的等待,避免与启动流程抢 pnpm。 |
|
|
131
|
+
|
|
132
|
+
`allowUnconfined` 是唯一一条「明知故犯」的开关。正常部署下 `ctx.sandboxPolicy` 一定在;缺席说明组合
|
|
133
|
+
不完整,这时插件**拒绝启动**,而不是悄悄跑一个不受约束的会话。
|
|
134
|
+
|
|
135
|
+
热重载(`patchReload: live`)会重新 `apply`。工具注册与进程清理都挂在 `ctx.effect` 上,不会触发
|
|
136
|
+
`tool "pwsh_run" is already registered`。
|
|
137
|
+
|
|
138
|
+
## 沙箱
|
|
139
|
+
|
|
140
|
+
常驻进程在**启动时**整体包进 `ctx.sandbox.confine` 返回的受限 argv。Windows ACL 沙箱用的是
|
|
141
|
+
`WRITE_RESTRICTED` 受限令牌,而令牌是**进程级**的 —— 所以 confine 一次之后,该会话的每一次读 / 写都
|
|
142
|
+
受同一个 workspace 边界约束,与内置 `pwsh` 工具等价。
|
|
143
|
+
|
|
144
|
+
策略按**启动会话时的真实 session** 解析(`session` 必须传进 `sandboxPolicy.resolve`):会话的
|
|
145
|
+
`sandbox/mode` 优先,边界根取会话的 `header.cwd`。
|
|
146
|
+
|
|
147
|
+
一个值得记住的坑:早先传的是 ToolExecution(`agent` 挂在 `exec.agent` 上),`resolve` 拿不到
|
|
148
|
+
session,**静默退回部署默认值**(mode + 配置里的 `workspaceRoot`),边界于是跑到 profile 目录去了 ——
|
|
149
|
+
症状是「工作区内写不进去、profile 目录反而能写」。`test/sandboxtest.mjs` 的 `policy-carries-session`
|
|
150
|
+
用例专门钉住这个形状。
|
|
151
|
+
|
|
152
|
+
实测(`workspace-write`,cwd = 插件目录):工作区内写入成功;盘符根、`C:\Users\<user>\`、
|
|
153
|
+
`C:\Windows\Temp\`、`C:\ProgramData\`、profile 目录全部被拒。脚本仍是 `FullLanguage`,
|
|
154
|
+
`$global:` 与 cwd 照常跨调用保留。`danger-full-access` 下不 confine,与内置工具一样无边界。
|
|
155
|
+
|
|
156
|
+
**`read-only` 模式不启动。** 该模式下 pwsh 是 ConstrainedLanguage,连 runspace 都建不起来。工具会
|
|
157
|
+
明确报错并提示改用内置 `pwsh` 工具(它在 read-only 下可用)。
|
|
158
|
+
|
|
159
|
+
## 更新
|
|
160
|
+
|
|
161
|
+
默认情况下插件自己升级:启动 90 秒后查一次 npm registry 的 `latest` 标签,之后每 12 小时一次;查到
|
|
162
|
+
更高的版本就直接装上。宿主跑着 DSH 的 HMR 服务时,**装上的那一刻新代码就已经在跑** —— 不用重启,不用弹窗,用户什么
|
|
163
|
+
都不用做。
|
|
164
|
+
|
|
165
|
+
| 方式 | 行为 |
|
|
166
|
+
|---|---|
|
|
167
|
+
| 自动(`autoUpdate: true`,默认) | 查 → 装 → 就地生效。装失败按指数退避重试(5 分钟起、翻倍、封顶 2 小时);装成功过的版本不再重试。**有调用在跑时绝不装** —— 3 分钟后再看。宿主**没有** HMR 时它什么都不装,见下。 |
|
|
168
|
+
| `pwsh_run_update` 的 `action: "check"` | 只读一次 registry,报出 `state` / `running` / `newest release` / `install spec`。不装任何东西。 |
|
|
169
|
+
| `pwsh_run_update` 的 `action: "install"` | 同一条安装路径,只是由人按下去而不是由时钟按下去。有调用在跑时报 `install: postponed`;预检判定「装了也没用」时报 `install: restart-required` / `install: broken-release`(见下)。 |
|
|
170
|
+
|
|
171
|
+
安装走 `ctx.get('pluginManager')` —— 也就是「插件」页面背后那个服务 —— 由它跑 pnpm,失败时回滚
|
|
172
|
+
`package.json`。本插件**从不自己改自己的文件**:它是 profile 的 bundle 依赖,版本钉在
|
|
173
|
+
`package.json` 与 `pnpm-lock.yaml` 里,绕开包管理器写文件会立刻让 lockfile 与磁盘对不上。
|
|
174
|
+
|
|
175
|
+
**为什么能不重启就生效。** 插件的 `apply` 以及它起的定时器,都跑在 HMR 服务的事务上下文里
|
|
176
|
+
(`AsyncLocalStorage`)。在那里直接调 `installBundle`,pnpm 会跑完、磁盘会换成新版,紧接着的重组合
|
|
177
|
+
却被拒掉(`HMR transactions cannot be nested`),于是停在最难看清的状态:磁盘是新的、进程里是旧的。
|
|
178
|
+
先把上下文清空(`hmr.executing.run(undefined, …)`)再调,重组合才会真的发生;而磁盘上新装的文件与已
|
|
179
|
+
加载的模块已经对不上,HMR 于是驱逐旧模块、import 新模块。实测:从触发到新代码应答 137 ms。
|
|
180
|
+
|
|
181
|
+
**为什么先看一眼再装。** DSH 的插件管理器在跑 pnpm **之前**就会拒掉一类安装:要装的规格与 profile
|
|
182
|
+
里为该包记下的规格完全一致 —— 也就是 `already-installed`。有两种情形会走到这一步,而这两种本来就不该
|
|
183
|
+
发起安装:
|
|
184
|
+
|
|
185
|
+
- 这个版本已经装好了(终端里的 `dsh plugin add`、或者「插件」页面),只是进程还没重启。文件是新的,
|
|
186
|
+
缺的只是一次重启。
|
|
187
|
+
- 发版时忘了把 `package.json` 里的 `version` 抬上去。标签变了、声明的版本没变,于是每一次尝试装的都是
|
|
188
|
+
同一份字节、被同一句话拒掉 —— 一个永不结束、也从不吭声的循环。
|
|
189
|
+
|
|
190
|
+
所以安装之前,更新器先比对手上已有的三个事实:profile 记下的规格、已装包自己声明的版本、运行中的进程
|
|
191
|
+
报出的版本。磁盘上已经是新版的报 `install: restart-required`;标签存在但包内版本没抬的报
|
|
192
|
+
`install: broken-release` 并直说原因。`scripts/release-check.mjs` 在推标签**之前**拦第二种,运行时这道
|
|
193
|
+
预检则在**之后**、在每一台试图升级的机器上再拦一次。
|
|
194
|
+
|
|
195
|
+
**为什么非要等安静。** 安装会替换插件,而停掉本插件就要 dispose 掉它所有的常驻会话。若此刻有脚本正在
|
|
196
|
+
跑,那与直接断电无异:输出写了一半、调用结束时拿不到状态、会话状态全失。所以更新器先问插件「现在有没有
|
|
197
|
+
调用在跑」(与工具本身用同一套同步的检查加置位,中途塞不进新调用),有就整轮推迟。安装进行中到达的调用
|
|
198
|
+
则等它装完,而不是跟它抢。
|
|
199
|
+
|
|
200
|
+
宿主**没有** HMR 时,自动路径什么都不做。那种环境装下去只会「磁盘是新版、进程是旧版」—— 需要重启的
|
|
201
|
+
自动更新不算更新,只是一个会说谎的版本号。(`pwsh_run_update` 的 `install` 在这种情况下照样装,因为
|
|
202
|
+
是人按的、人能决定什么时候重启;结果里会诚实地写 `restart-required`。)
|
|
203
|
+
把自动路径关掉(`autoUpdate: false`)则在任何宿主上都回到旧行为:只查不装,在下次 `pwsh_run` 输出末尾
|
|
204
|
+
挂一行,让人来定。
|
|
205
|
+
|
|
206
|
+
若 profile 关掉了 `plugin-manager` 服务,`install` 会报 `no-manager`,改用终端升级:
|
|
207
|
+
|
|
208
|
+
```powershell
|
|
209
|
+
$env:DSH_HOME = '<DSH_HOME>'
|
|
210
|
+
& '<桌面端安装目录>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
注意 DSH 的默认组合关掉的是**工具** `plugin_manager`,而桌面 profile 上的**服务** `plugin-manager`
|
|
214
|
+
是活的 —— 两者不是一回事,这里只用服务。
|
|
215
|
+
|
|
216
|
+
发布源就是这个包在 npm registry 上的条目:`GET https://registry.npmjs.org/dsh-pwsh-plugin`,请求头带
|
|
217
|
+
`accept: application/vnd.npm.install-v1+json`。它的 `dist-tags.latest` 就是要推的版本,必须是纯
|
|
218
|
+
`X.Y.Z`;被标了 `deprecated` 的 `latest` 永不推送;没有 `dist-tags` 时回落到版本清单里的最高正式版。
|
|
219
|
+
返回 `404` 时报的是「not published on <registry>」而不是「没有新版本」—— 这两件事不同,只有一件是临时的。
|
|
220
|
+
|
|
221
|
+
安装规格用 registry 形式 `dsh-pwsh-plugin@X.Y.Z`,所以插件请求的字符串与 `dsh plugin add` 接受的是同一个。
|
|
222
|
+
|
|
223
|
+
| 环境变量 | 作用 |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `DSH_PWSH_UPDATE_REGISTRY` | registry 基址,默认 `https://registry.npmjs.org`。可指向镜像,或指向本机服务做预演 |
|
|
226
|
+
| `DSH_PWSH_UPDATE_DELAY_MS` | 首次检查延迟,默认 5000 |
|
|
227
|
+
|
|
228
|
+
```powershell
|
|
229
|
+
& $dsh plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
手动安装与插件自己做的事是同一条路径,同样由 pnpm 施加同样的策略。
|
|
233
|
+
|
|
234
|
+
查询失败不会抛进工具调用:记 `state: 'failed'`,经 `ctx.logger` 留一行,先前查到过的 `latest` 保留成
|
|
235
|
+
`stale: 'available'` —— 一次网络抖动不该让已知的新版本消失。
|
|
236
|
+
|
|
237
|
+
## `llrun` 垫片
|
|
238
|
+
|
|
239
|
+
`bash` 工具跑在 WSL 里,`/mnt/c`、`/mnt/d` 原本**任意位置都能写**:9p / drvfs 在 DSH 沙箱够不着的地方。
|
|
240
|
+
`wsl/llrun` 用 user namespace、landlock(ABI 7,白名单刻意不含 `/init`,于是任何 Windows 可执行文件都
|
|
241
|
+
起不来)与 `/var/tmp` 挂载封印把这件事变成闭环。`wsl/custom-bash.mjs` 把它接进 `bash` 工具,并按会话
|
|
242
|
+
权限选档。
|
|
243
|
+
|
|
244
|
+
不需要手动装:二进制随包发布,探测候选的最后一个就是它,所以一次 `plugin add` 就够。由于发布包在
|
|
245
|
+
Windows 上打,那份文件到手时是 `0644` —— 探测发现它属于当前用户却不可执行时,会自己补上执行位再试。
|
|
246
|
+
|
|
247
|
+
完整说明、`-r` 档语义表、54 项验收矩阵与接入方式:**[docs/llrun.md](docs/llrun.md)**。想在自己机器上
|
|
248
|
+
复验就 `bash wsl/acceptance.sh`(要在未被 `custom-bash` 包裹的 WSL 里跑:套第二层围栏会让四项断言
|
|
249
|
+
因错误的原因变红)—— 它的每个路径都从自身位置推导,并在结尾复核磁盘有没有泄漏。
|
|
250
|
+
|
|
251
|
+
## 故障处理
|
|
252
|
+
|
|
253
|
+
| 现象 | 怎么办 |
|
|
254
|
+
|---|---|
|
|
255
|
+
| `profile "desktop" is managed exclusively by the Electron application` | 用桌面安装里的 `dsh.cmd`,不要用便携版 `dsh.exe`。 |
|
|
256
|
+
| `Open DeepSeek Harness Desktop once to initialize its profile` | `DSH_HOME` 指错了;指到桌面 App 自己用的数据根。 |
|
|
257
|
+
| 启动时 `tool "pwsh_run" is already registered` | profile 的 patch 文件里手写了 `id: pwsh-run`,**而且** bundle patch 又插了一条。删掉手写那条。 |
|
|
258
|
+
| `refusing to start: ctx.sandboxPolicy is absent` | 组合里少了沙箱策略服务。补齐组合,或者确实要这样时设 `allowUnconfined: true`。 |
|
|
259
|
+
| `refusing to start: read-only mode` | 预期行为。read-only 会话里用内置 `pwsh` 工具。 |
|
|
260
|
+
| `could not confine the session: SetNamedSecurityInfoW failed (Win32 5)` | 工作区根目录的 DACL 没给你 `WRITE_DAC` + `WRITE_OWNER`(沙箱要在那里写 DACL 与完整性标签)。全控制权限两样都满足,`Modify` 不够。执行 `icacls "<根目录>" /grant "%USERNAME%":(OI)(CI)F`,或从用户目录下的目录起会话。 |
|
|
261
|
+
| 内置工具能写的地方本工具写不进 | 确认 `session` 真的传到了 `sandboxPolicy.resolve`(见「沙箱」);漏传会把边界静默挪到 profile 目录。 |
|
|
262
|
+
| 永远看不到更新提示 | 源环境变量必须在 **Windows** 侧设;WSL 不透传 `DSH_*`。 |
|
|
263
|
+
| 输出顺序看着不对 | stdout 与 stderr 按类型合并,不按时间。自己用 `2>&1` 合。 |
|
|
264
|
+
| `exit N` 的退出码拿不到 | runspace 语义 —— 用 `Write-Output` 或读 `$LASTEXITCODE`。 |
|
|
265
|
+
| 调用结束后子进程还占着文件 | 会话持有 `Process` 对象。等 `idleMs`,或者用一次超时强制重建。 |
|
|
266
|
+
| `Program 'node.exe' failed to run: 拒绝访问。` | `workspace-write` 下的正常现象:受限令牌建不了匿名管道,凡是「捕获子进程 stdio」的起法都失败,`& exe` 也在其中。改用 `Start-Process -RedirectStandardOutput/-RedirectStandardError <文件>`(能拿到输出,拿不到退出码)、`[Diagnostics.Process]::Start`(`UseShellExecute=$false`、不重定向,能拿退出码但子进程继承 stdout),或一个把 stdout / stderr / `%ERRORLEVEL%` 分别写进三个文件的 `.cmd` 包装器。工具也会把这段说明附在输出末尾。 |
|
|
267
|
+
|
|
268
|
+
## 已知边界
|
|
269
|
+
|
|
270
|
+
- **不支持 `read-only` 模式**(见上),用内置 `pwsh`。
|
|
271
|
+
- **沙箱策略服务缺席时拒绝启动**,除非 `allowUnconfined: true`。
|
|
272
|
+
- **超时会杀掉整个会话**(连带回收子进程树)。状态丢失,下次调用自动重建。
|
|
273
|
+
- **`exit N` 的退出码不可观测**(runspace 语义)。
|
|
274
|
+
- **stdout / stderr 的逐行时序不保留** —— 全部流经 `*>&1` 合并后按类型打标。
|
|
275
|
+
- **`[Console]::Error.WriteLine` 会被吞掉**(服务端为防协议污染把 `Console.Error` 指向内存流)。
|
|
276
|
+
子进程的 stderr、`Write-Error`、`throw` 都正常回来。
|
|
277
|
+
- **子进程的 std handle 已与协议流断开。** `Console::SetIn/SetOut` 只改 .NET 层,服务端还改写了三个 Win32 槽位:
|
|
278
|
+
stdin 接 `NUL`,stdout / stderr 接一个带 `FILE_FLAG_DELETE_ON_CLOSE` 的临时文件、每次调用前截回零。
|
|
279
|
+
于是子进程往继承来的 stdout 写东西不会再伪造出协议行(实测:子进程一句 `Write-Output '<<<BYE>>>'`
|
|
280
|
+
曾让会话当场死亡),读继承来的 stdin 也只会读到 EOF,而不是调用方的请求。
|
|
281
|
+
替上去的句柄必须是**可继承**的(`SECURITY_ATTRIBUTES.bInheritHandle`):用 `NULL` 安全描述符建的句柄
|
|
282
|
+
只存在于本进程,子进程按 `STARTUPINFO.hStdInput` 拿到的只是一个在那边没有意义的句柄**编号** ——
|
|
283
|
+
`GetFileType` 报 `UNKNOWN`,几十次里还有一次那个编号被别的对象复用,读出来是别人的字节。
|
|
284
|
+
stdout **故意不接 `NUL`**:那是字符设备,PowerShell 会认为输出没被重定向,沙箱拒绝的报错会变成一句
|
|
285
|
+
没用的 `StandardOutputEncoding` 提示。临时目录不可写时退回 `NUL`,并在第一次调用的警告里说明一次。
|
|
286
|
+
- **会话是串行的**:并发调用排成队列逐个执行(不会串输出,但也没有并行加速)。
|
|
287
|
+
- **不依赖 WSL。** `pwsh_run` 全程不碰 WSL,只有 `wsl/custom-bash.mjs` 才碰。没有可用的 `bashPath` 时,
|
|
288
|
+
该预设照样加载成功(可执行文件是惰性解析的),`bash` 工具每次调用报
|
|
289
|
+
`command "<路径>" is not an executable file` —— 清晰失败,不会静默降级。默认值是裸名 `bash`;
|
|
290
|
+
PATH 解不出来时,Windows 上会再试 `C:\Windows\System32\bash.exe`(同一个 WSL 垫片)才放弃。
|
|
291
|
+
设这道后备是因为进程可能继承到一份早于 PATH 修改的环境块,里面没有 `System32` ——
|
|
292
|
+
没有它就等于整个工具作废。显式写出的绝对 `bashPath` 只认那一个:写错了就该响亮地失败,
|
|
293
|
+
不该被悄悄换成另一份。
|
|
294
|
+
- **UAC 开启时的行为与本机不同。** 本机实测环境是 UAC 关闭、进程提权。UAC 开着时会话是标准用户,
|
|
295
|
+
此时 ACL 沙箱要求工作区根目录的 DACL 给你 **`WRITE_DAC` 与 `WRITE_OWNER`** —— 全控制两者都满足
|
|
296
|
+
(它写的完整性标签在 SACL 里)。用户目录下的目录天然继承这两样;在盘符根新建的目录只继承
|
|
297
|
+
`Modify`,单靠 `Modify` 会以 `SetNamedSecurityInfoW failed (Win32 5)` 失败。属主不是决定因素:
|
|
298
|
+
`icacls <根目录> /grant "%USERNAME%":(OI)(CI)F` 能救。届时是 fail-closed 报错,不会退化成无边界。
|
|
299
|
+
- 不支持交互式程序与 TTY。
|
|
300
|
+
- **升级会换掉会话,但绝不打断正在跑的调用。** 替换插件会先把它停掉,而停掉本插件就会销毁它的会话 ——
|
|
301
|
+
所以真正装上新版的那一次,常驻 pwsh 进程已经不在了,下次调用重新拉起。只存在于该会话里的东西(变量、
|
|
302
|
+
`Set-Location` 过的目录、import 进来的模块)随之丢失。实测 v0.2.3 → v0.2.4 的一次安装:`$PID` 由
|
|
303
|
+
24820 变成 15632,安装前设的 `$global:` 变量读回来是空的。更新器**不会**做的事是打断在跑的活:
|
|
304
|
+
自动循环与 `pwsh_run_update` 的 `install` 都会先问插件「现在有没有调用在跑」,有就整轮推迟
|
|
305
|
+
(循环 3 分钟后再看,工具报 `install: postponed` 且不动 profile),直到会话安静下来。安装进行中到达的
|
|
306
|
+
调用会先等它装完,而不是建一个马上要被销毁的会话。版本比会话状态值钱,但这并不等于「什么都没发生」
|
|
307
|
+
—— 别把不可重建的状态放在里面跨过升级。
|
|
308
|
+
|
|
309
|
+
## 开发
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
plugin/
|
|
313
|
+
index.mjs 工具注册、沙箱接线、超时
|
|
314
|
+
session.mjs 会话对象:分帧、队列、base64、启动 argv
|
|
315
|
+
session-server.ps1 PowerShell 侧:runspace、流打标、哨兵
|
|
316
|
+
update-check.mjs registry 查询与版本比较
|
|
317
|
+
update-apply.mjs 把钉住的规格交给 pluginManager,不自己下载
|
|
318
|
+
auto-update.mjs 自动路径:在 HMR 事务之外安装,失败退避
|
|
319
|
+
test/
|
|
320
|
+
support.mjs 定位 DSH 安装;Windows 路径转 /mnt 形式
|
|
321
|
+
itest.mjs 协议与会话本体
|
|
322
|
+
plugintest.mjs 插件完整路径(mock 上下文)
|
|
323
|
+
e2e-real-subprocess.mjs 真 DSH subprocess 运行时
|
|
324
|
+
sandboxtest.mjs 真 LocalSandboxProvider,边界 + fail-closed 用例
|
|
325
|
+
bashtest.mjs custom-bash 围栏分诊(mock runtime)
|
|
326
|
+
bash-e2e.mjs custom-bash 端到端(真 runtime + 真 llrun)
|
|
327
|
+
updatetest.mjs 更新检查与安装结果(本地 git 仓库)
|
|
328
|
+
autoupdatetest.mjs 自动更新循环、退避、空闲闸门,以及它依赖的 HMR 上下文逃逸
|
|
329
|
+
wiringtest.mjs 更新提示真的接到工具输出末尾、安装要等会话空闲
|
|
330
|
+
denialtest.mjs 沙箱拒绝提示只在该触发的报错上触发
|
|
331
|
+
metatest.mjs 插件页元数据:locale、icon,以及页面到底读哪份文档
|
|
332
|
+
wsl/
|
|
333
|
+
llrun.c llrun C 垫片与构建产物
|
|
334
|
+
custom-bash.mjs bundle 接进组合的 bash 工具替代实现(仅 Windows)
|
|
335
|
+
acceptance.sh 54 项验收矩阵
|
|
336
|
+
docs/llrun.md 垫片的完整说明
|
|
337
|
+
FINDINGS.md 全部实测数据与设计理由
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
cd <检出目录>
|
|
342
|
+
PWSH_BIN='pwsh' node test/itest.mjs # 17:协议与会话本体
|
|
343
|
+
PWSH_BIN='pwsh' node test/plugintest.mjs # 15:插件完整路径
|
|
344
|
+
node test/e2e-real-subprocess.mjs # 8:真 DSH subprocess
|
|
345
|
+
node test/sandboxtest.mjs # 7:真沙箱边界 + fail-closed
|
|
346
|
+
node test/bashtest.mjs # 25:custom-bash 围栏分诊、bash 后备、执行位自修复
|
|
347
|
+
node test/bash-e2e.mjs # 11:custom-bash 端到端
|
|
348
|
+
node test/updatetest.mjs # 96:更新检查、安装结果、安装前预检(只碰本地 git)
|
|
349
|
+
node test/autoupdatetest.mjs # 38:自动安装、退避、空闲闸门、安装前预检、HMR 上下文逃逸
|
|
350
|
+
node test/wiringtest.mjs # 21:更新提示接线、空闲闸门、安装前预检的接线
|
|
351
|
+
node test/denialtest.mjs # 11:沙箱拒绝提示(不起子进程,哪儿都能跑)
|
|
352
|
+
node test/metatest.mjs # 23:插件页元数据(不起子进程,哪儿都能跑)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
十一份脚本合计 **305 项**,都不需要 DSH 在跑 —— 只要 node 24、PowerShell 7,以及一个能借
|
|
356
|
+
`node_modules` 的 DSH 安装。需要 DSH 运行时的测试会自动找(`DSH_MODULES`、`DSH_MODULES_ASAR`,
|
|
357
|
+
或从工作目录逐级向上),找不到时打一行 `SKIP` 提示而不是失败。
|
|
358
|
+
|
|
359
|
+
### 发版
|
|
360
|
+
|
|
361
|
+
从 tag 发布由 [`.github/workflows/release.yml`](.github/workflows/release.yml) 负责:先跑
|
|
362
|
+
`scripts/release-check.mjs`,再用 npm 的 **trusted publishing** 执行 `npm publish --provenance` ——
|
|
363
|
+
runner 用自己那份短命的 GitHub 身份换一个只对「这个仓库 + 这个 workflow 文件」有效的令牌。仓库里没有
|
|
364
|
+
`NPM_TOKEN`,也不该加。两个连带后果值得记住:workflow 的文件名是信任契约的一部分(改名后每次发布都会
|
|
365
|
+
403,直到在 npm 上改设置);provenance 在那里是强制的,所以 tarball 会带上「由哪个提交构建」的签名声明。
|
|
366
|
+
|
|
367
|
+
`package.json` 里的 version 是本仓库唯一写死的东西,而更新检查拿它跟 registry 上的已发布版本比。
|
|
368
|
+
不升版本就发版,检查会永远报有新版:装上去的文件读到的还是旧数字,于是每轮都请求同一个版本,
|
|
369
|
+
退避一路爬到上限。
|
|
370
|
+
`npm version` 一次完成「改版本 + 提交 + 打 annotated tag」,所以这三者不会漂开:
|
|
371
|
+
|
|
372
|
+
```powershell
|
|
373
|
+
npm version patch -m 'chore(release): %s' # 改版本、提交、打 tag
|
|
374
|
+
git push origin main <tag>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
发版前跑一次 `node scripts/release-check.mjs`。工作区不干净、HEAD 上的 tag 与 `package.json` 对不上、
|
|
378
|
+
tag 指向的提交里版本不同、或者要重指一个已存在的 tag(那会把已发布的版本挪到新内容上),它都会拒绝。
|
|
379
|
+
HEAD 上还没有 tag 时,它把该打的标签名打印出来。
|
|
380
|
+
|
|
381
|
+
npm 要用 Windows 侧那份。WSL 里 `/usr/local/bin/npm` 指向 bun 的软链,`npm version` 会报
|
|
382
|
+
`Script not found "version"`。
|
|
383
|
+
|
|
384
|
+
## 许可
|
|
385
|
+
|
|
386
|
+
MIT —— 见 [LICENSE](LICENSE)。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# dsh-pwsh-plugin 的 bundle patch:装包即接线,卸包即还原,用户不必碰自己的 cordis.patch.yml。
|
|
2
|
+
#
|
|
3
|
+
# 名字一行用包名、一行用包内子路径,都不是 `./` 相对名:相对名会被改写成绝对 file URL
|
|
4
|
+
# 落进 --dump-config 输出,配置编辑器再把它抄进用户自己的 patch 文件,便携版一挪目录就废了。
|
|
5
|
+
# 包名始终从 profile 的 node_modules 解析,导出的子路径见 package.json 的 exports。
|
|
6
|
+
#
|
|
7
|
+
# 行 id 是承重的:插件的设置命名空间就是它的 profile 条目 id。
|
|
8
|
+
- insert:
|
|
9
|
+
- id: pwsh-run
|
|
10
|
+
name: dsh-pwsh-plugin
|
|
11
|
+
config:
|
|
12
|
+
pwshPath: pwsh
|
|
13
|
+
timeoutMs: 120000
|
|
14
|
+
idleMs: 1800000
|
|
15
|
+
maxSessions: 4
|
|
16
|
+
|
|
17
|
+
# Windows 上的 bash:官方 persistent bash 依赖 PTY,PTY 后端只支持 linux/darwin,
|
|
18
|
+
# 而官方 bash 工具在 win32 上被禁用,Windows 会话因此没有 bash。这一行补上它,
|
|
19
|
+
# 顺带把 llrun 围栏接进来(llrun 随包发布,见 wsl/custom-bash.mjs 的候选顺序)。
|
|
20
|
+
#
|
|
21
|
+
# 只在 win32 启用:其他平台用官方的 bash / persistent-bash,重复注册同名工具会被注册表拒绝。
|
|
22
|
+
# 不在这里禁用 tool-bash —— 平台条件已经互斥,硬禁会在 Linux 上把官方 bash 一起关掉。
|
|
23
|
+
- id: custom-bash
|
|
24
|
+
name: dsh-pwsh-plugin/wsl/custom-bash.mjs
|
|
25
|
+
disabled: !!js process.platform !== 'win32'
|
|
26
|
+
config:
|
|
27
|
+
timeoutMs: 300000
|
package/docs/llrun.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# wsl/llrun — the `bash` tool's write boundary
|
|
2
|
+
|
|
3
|
+
DSH's `bash` tool runs in WSL. On `/mnt/c` and `/mnt/d` (9p / drvfs) it can write **anywhere** — the DSH
|
|
4
|
+
sandbox does not reach across that mount. `wsl/llrun` is a small C shim that inserts three boundaries in
|
|
5
|
+
front of the shell, turning that hole into a closed loop. `wsl/custom-bash.mjs` wires it into the
|
|
6
|
+
`bash` tool (see [Wiring](#wiring-custom-bashmjs)).
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
llrun [--no-isolate] [--no-seal] [-r] [-C <dir>] <workspace|-> <prog> [args...]
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Argument | Meaning |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `workspace` | Writable directory (WSL path). `-` = current directory. `/` is refused outright. |
|
|
15
|
+
| `-r` | **Strict read-only** (maps to DSH's `read-only` mode). Every directory except `/dev` drops to read-only; see the table below. |
|
|
16
|
+
| `-C <dir>` | `chdir` after landlock is applied, so the script's `$0` and line numbers stay clean. |
|
|
17
|
+
| `--no-isolate` | Skip socket masking (docker becomes reachable — this drops a whole layer); also loses the `/var/tmp` seal. |
|
|
18
|
+
| `--no-seal` | Disable only the `/var/tmp` seal; socket masking stays. |
|
|
19
|
+
|
|
20
|
+
## The three boundaries
|
|
21
|
+
|
|
22
|
+
1. **User + mount namespace.** Binds `/run/docker.sock`, `/var/run/docker.sock`,
|
|
23
|
+
`/run/containerd/containerd.sock` and `/run/podman/podman.sock` onto `/dev/null`, then covers
|
|
24
|
+
`/etc/ssh/ssh_config.d` with an empty directory (a userns makes the ownership check there fail).
|
|
25
|
+
2. **landlock (ABI 7).** System directories keep full access; `/mnt/c` `/mnt/d` `/mnt/e` become read-only;
|
|
26
|
+
the workspace stays writable. **The whitelist does not contain `/init`** — WSL's interop relay cannot
|
|
27
|
+
execute, so `cmd.exe`, `pwsh.exe` and any absolute-path PE are blocked along with it. This is the
|
|
28
|
+
linchpin of the whole scheme; adding `/init` back means having no boundary at all.
|
|
29
|
+
3. **`/var/tmp` mount seal.** Inside the userns, `bind` the directory onto itself and `remount,ro`.
|
|
30
|
+
It has to be a mount: **landlock unions parent and child rules**, so a full-access `/var` swallows the
|
|
31
|
+
stricter read-only rule for `/var/tmp` (`/dev` + `/dev/shm` behave the same way — both stay writable).
|
|
32
|
+
Only a mount-level ro can seal a subdirectory, and `remount` needs `CAP_SYS_ADMIN`, which is only
|
|
33
|
+
available inside the userns — which is why `--no-isolate` cannot seal it.
|
|
34
|
+
`-r` additionally seals `/dev/shm` (plain mode leaves it alone, see below).
|
|
35
|
+
|
|
36
|
+
## read-only (`-r`) semantics
|
|
37
|
+
|
|
38
|
+
| Operation | Default | `-r` strict |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Read / list / execute | allowed | **allowed** (without read access, not even `ls` or `cat` starts) |
|
|
41
|
+
| Write / create / delete / rename / truncate | workspace and non-Windows mounts allowed | **all denied** |
|
|
42
|
+
| Write `/dev/null` | allowed | **allowed** (character device, must keep full access) |
|
|
43
|
+
| Write `/tmp` `/home` `/etc` `/var/log` | allowed | **denied** |
|
|
44
|
+
| Write `/dev/shm` `/var/tmp` | allowed | **denied** (mount seal) |
|
|
45
|
+
| Touch mtime / `chmod` | allowed | **still allowed** (inherent landlock blind spot — the kernel has no such permission concept) |
|
|
46
|
+
|
|
47
|
+
**What `-r` really costs.** `/tmp` is read-only, so **any tool that needs temporary files breaks**:
|
|
48
|
+
`gcc` gives `Cannot create temporary file in /tmp/: Permission denied` (`Aborted`). `python3`, `git`,
|
|
49
|
+
`curl`, `dpkg`, `ssh` and `bun` all work. With `/dev/shm` sealed, Python's
|
|
50
|
+
`multiprocessing.Semaphore` / `SharedMemory` raise `OSError: [Errno 30]` and `Manager` raises
|
|
51
|
+
`EOFError` — an inherent cost of the read-only tier, not a defect.
|
|
52
|
+
|
|
53
|
+
## Build and install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd wsl && gcc -O2 -Wall -Wextra -o llrun llrun.c
|
|
57
|
+
mkdir -p <DSH_HOME>/bin && cp llrun <DSH_HOME>/bin/llrun && chmod 755 <DSH_HOME>/bin/llrun
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`custom-bash.mjs` does not hard-code one path. It probes, in this order, and takes the first executable:
|
|
61
|
+
|
|
62
|
+
| # | Candidate | Note |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| 1 | `$LLRUN` or `$DSH_LLRUN` | explicit override; also useful for a per-session binary |
|
|
65
|
+
| 2 | `<DSH_HOME>/bin/llrun` | **recommended** — inside your own data root, survives upgrades with it |
|
|
66
|
+
| 3 | `/usr/local/bin/llrun` | only if you can write there (`sudo`) |
|
|
67
|
+
| 4 | `~/.local/bin/llrun` | no `sudo`, but must be a path the shell user can execute |
|
|
68
|
+
| 5 | the copy next to `custom-bash.mjs` | ships with the package — the only candidate a fresh install has |
|
|
69
|
+
|
|
70
|
+
**The packaged copy arrives without an executable bit.** `npm pack` runs on Windows when the release is
|
|
71
|
+
cut, and Windows has no such concept: every entry lands as `0644`. So the probe does not stop at `-x`. If a
|
|
72
|
+
candidate exists, belongs to the shell user (`-O`) and is not executable yet, it runs `chmod +x` on it and
|
|
73
|
+
retries — the file is ours, inside the user's own `node_modules`, so this costs nothing and needs no
|
|
74
|
+
install script (pnpm does not run those by default anyway). Ownership is the gate: a `llrun` owned by
|
|
75
|
+
someone else is left untouched, and reported as 「exists but is not executable」 rather than as missing.
|
|
76
|
+
|
|
77
|
+
The result is cached across calls, so installing the binary later takes effect on the next call — no DSH
|
|
78
|
+
restart. The probe runs once over `bash -s` and returns a single exit code, so a missing candidate costs a
|
|
79
|
+
few milliseconds and never changes the tool's behaviour. Setting `llrunPath` in the config skips the probe
|
|
80
|
+
and accepts exactly that one path.
|
|
81
|
+
|
|
82
|
+
## Acceptance matrix (54 assertions, all green, repeated runs)
|
|
83
|
+
|
|
84
|
+
`bash wsl/acceptance.sh` → `TOTAL pass=54 fail=0 skip=0`, `leaks=0`.
|
|
85
|
+
|
|
86
|
+
The script is machine-independent: it derives the workspace from its own location, discovers the Windows
|
|
87
|
+
user directory under `/mnt/*/Users/*/`, and only asserts against directories that actually exist. It also
|
|
88
|
+
re-checks the disk at the end (`leaks=N`) so a silently-succeeding write cannot pass unnoticed.
|
|
89
|
+
|
|
90
|
+
Run it from a WSL shell that is **not** itself inside the fence. Under `custom-bash` the shell is already
|
|
91
|
+
wrapped in `llrun`, and nesting a second user namespace turns four assertions red and leaves a `/dev/shm`
|
|
92
|
+
probe behind — a false failure, not a regression. `wsl.exe -e bash` from a plain Windows prompt is enough.
|
|
93
|
+
|
|
94
|
+
- **Write boundary**: outside targets (drive root, `Documents`, the portable data root, the profile dir,
|
|
95
|
+
`C:\Users\<user>`) all DENIED with disk re-check absent; workspace, `/tmp` and `/home` writable;
|
|
96
|
+
`/var/tmp` sealed but readable.
|
|
97
|
+
- **Windows executables**: `cmd.exe`, `powershell.exe`, `notepad.exe` and a direct `/init` call all BLOCKED.
|
|
98
|
+
- **Docker escape**: `docker ps` BLOCKED, raw socket connect BLOCKED; `--no-isolate` reverses it (CONNECTED).
|
|
99
|
+
- **Toolchain**: `git`, `node`, `python3`, `gcc`, `dpkg -l`, `curl https`, `ssh` all normal.
|
|
100
|
+
- **`-r` tier**: 8 write assertions DENIED (workspace, `/tmp`, `/home`, `/dev/shm`, `/var/tmp`, `/etc`,
|
|
101
|
+
`/var/log`, `mktemp`); 6 read assertions OK; delete and `mkdir` DENIED; `python3` / `git` / `curl` /
|
|
102
|
+
`dpkg` / `ssh` stay alive, `gcc` unusable as expected.
|
|
103
|
+
- **Switches**: `--no-seal` and `--no-isolate` both open `/var/tmp` as designed.
|
|
104
|
+
- **Verbatim pass-through**: exit codes, heredoc literals, Chinese text and quoting all survive unchanged.
|
|
105
|
+
|
|
106
|
+
**Overhead**: bare bash 2-3 ms per call → llrun 8-9 ms per call, about **+6 ms**.
|
|
107
|
+
|
|
108
|
+
## Known costs
|
|
109
|
+
|
|
110
|
+
- **`sudo` is unusable** (`PR_SET_NO_NEW_PRIVS` refuses setuid elevation), so `apt-get install` is out;
|
|
111
|
+
read-only queries like `dpkg -l` and `apt list` still work.
|
|
112
|
+
- `dbus-send --session` / `systemctl --user` work (restored by the same-uid mapping; the early 0→1000
|
|
113
|
+
mapping broke them).
|
|
114
|
+
- **mtime and `chmod` are not restricted**: landlock's permission vocabulary has no concept for them, so
|
|
115
|
+
even the read-only tier can retimestamp or re-permission an existing file (content, creation, deletion
|
|
116
|
+
and rename are all blocked). A kernel-level blind spot, not an implementation gap.
|
|
117
|
+
- **`-r` does not stop damage inside the workspace** — it guards against writing *outward*, not against
|
|
118
|
+
emptying the workspace. The workspace is writable by design in the default tier, and `rm -rf` has full
|
|
119
|
+
authority there. Only discipline prevents that.
|
|
120
|
+
- **`/dev/shm` must stay writable in the default tier**: it is the POSIX shared-memory mount, and sealing
|
|
121
|
+
it crashes Python `multiprocessing` `Semaphore` / `SharedMemory`. Only `-r` seals it (a read-only run
|
|
122
|
+
should not spawn multi-process work anyway).
|
|
123
|
+
- **The ssh mask uses a fixed path, `/tmp/.llrun-empty`** (since v6). An earlier `mkdtemp` version leaked
|
|
124
|
+
one directory per call — 421 of them had accumulated in testing.
|
|
125
|
+
- **No Windows executable can be called from inside WSL any more** (scoop, pwsh, node.exe all BLOCKED).
|
|
126
|
+
|
|
127
|
+
## Wiring (`custom-bash.mjs`)
|
|
128
|
+
|
|
129
|
+
`wsl/custom-bash.mjs` is what the bundle wires in. The package's own `cordis.patch.yml` carries:
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
- id: custom-bash
|
|
133
|
+
name: dsh-pwsh-plugin/wsl/custom-bash.mjs
|
|
134
|
+
disabled: !!js process.platform !== 'win32'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A **bare subpath**, resolved from the profile's `node_modules`. That matters: the loader rewrites a `./` or
|
|
138
|
+
absolute `name` inside an inserted entry into an absolute `file://` URL and copies it into the composed
|
|
139
|
+
tree (`anchorInsertedPluginNames` in `dsh-app-boot`), which pins the install path — move the data directory
|
|
140
|
+
and the row breaks. A bare subpath resolves fresh on every boot instead. It needs
|
|
141
|
+
`"./wsl/custom-bash.mjs"` in the package `exports`; the `llrun` binary itself is not exported and does not
|
|
142
|
+
need to be, because the plugin finds it through `import.meta.url`.
|
|
143
|
+
|
|
144
|
+
So: install the bundle, and the `bash` tool is fenced. Uninstall it, and the `custom-bash` row goes with it —
|
|
145
|
+
there is no separate copy to keep in sync and nothing to delete by hand.
|
|
146
|
+
|
|
147
|
+
If you wire it by hand instead, the row is:
|
|
148
|
+
|
|
149
|
+
```yaml
|
|
150
|
+
- id: custom-bash
|
|
151
|
+
name: '<path>/wsl/custom-bash.mjs'
|
|
152
|
+
disabled: !!js process.platform !== 'win32'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Do not also disable `tool-bash` from a patch. That row already carries
|
|
156
|
+
`disabled: !!js process.platform === 'win32'`; a hard `disabled: true` would turn the stock bash off on
|
|
157
|
+
Linux as well.
|
|
158
|
+
|
|
159
|
+
**Commands go in over stdin**: `argv = [bash, '-s']` plus `stdio.stdin = { data: command }`. It used to be
|
|
160
|
+
`[bash, '-c', command]`, where backticks, `$` and complex quoting were all eaten by the Windows
|
|
161
|
+
`bash.exe` layer first — the root cause of a workspace-deletion incident (a `\n` lost its backslash in the
|
|
162
|
+
inner bash, gluing two commands together).
|
|
163
|
+
|
|
164
|
+
**Mode triage** (`sandboxPolicy.resolve` must be given `session`, otherwise it silently falls back to the
|
|
165
|
+
deployment default):
|
|
166
|
+
|
|
167
|
+
| Session permission | Process started |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `danger-full-access` | bare `bash -s`, no fence (DSH's established semantics) |
|
|
170
|
+
| `workspace-write` | `llrun <workspace> /bin/bash -s` |
|
|
171
|
+
| `read-only` | `llrun -r <workspace> /bin/bash -s` |
|
|
172
|
+
| `sandboxPolicy` absent | **throws, refuses to run**, unless `fence: false` is configured |
|
|
173
|
+
| no candidate executable | **throws, refuses to run** — never silently degrades to unfenced; the message lists every probed path |
|
|
174
|
+
| a candidate exists but cannot be executed | **throws**, naming that exact path — not reported as 「not found」 |
|
|
175
|
+
|
|
176
|
+
`workspace` comes from the policy's `workspaceRoot` (falling back to the session cwd), with Windows paths
|
|
177
|
+
translated to `/mnt/<drive>/...`. A POSIX `workdir` goes to llrun's `-C` (chdir after landlock); a Windows
|
|
178
|
+
one still becomes the process cwd.
|
|
179
|
+
|
|
180
|
+
Configuration:
|
|
181
|
+
|
|
182
|
+
```yaml
|
|
183
|
+
- id: custom-bash
|
|
184
|
+
config:
|
|
185
|
+
llrunPath: '/mnt/c/Users/<you>/.local/bin/llrun' # omit to probe the candidates
|
|
186
|
+
fence: true # false = no fence (not recommended)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`llrunPath` is only for overriding. Left out, the plugin probes `$LLRUN` / `$DSH_LLRUN`,
|
|
190
|
+
`<DSH_HOME>/bin/llrun`, `/usr/local/bin/llrun`, `~/.local/bin/llrun`, and finally the copy that sits next
|
|
191
|
+
to `custom-bash.mjs` inside the package — first executable wins, and the result is cached, so a fresh
|
|
192
|
+
install works without a restart. Yours come first: a system-level `llrun` may be newer, the packaged one only
|
|
193
|
+
guarantees that the fence is there at all.
|
|
194
|
+
|
|
195
|
+
Tests: `node test/bashtest.mjs` (25, mock runtime) + `node test/bash-e2e.mjs` (11, real runtime + real llrun).
|
|
196
|
+
Three of the 25 pin the executable-bit repair: a `0644` copy is made executable and the call succeeds, an
|
|
197
|
+
unusable copy is named in the error, and an unusable candidate earlier in the list does not shadow a usable
|
|
198
|
+
one later. They only assert the mode on POSIX hosts — NTFS reports every file as `0666`, so there the
|
|
199
|
+
assertion is that the call still works.
|
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48" fill="none" role="img" aria-label="pwsh_run">
|
|
2
|
+
<path d="M10 13v22" stroke="#4d6bfe" stroke-width="4" stroke-linecap="round"/>
|
|
3
|
+
<path d="M17 18l7 6-7 6" stroke="#4d6bfe" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
<path d="M27 31h11" stroke="#d98c1f" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
+
</svg>
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Persistent pwsh (pwsh_run)",
|
|
4
|
+
"description": "Run PowerShell script text in a persistent pwsh 7 session: nothing is escaped on the way in, there is no 32K command-line cap, and variables, location, and loaded modules survive from one call to the next. Ships wsl/llrun, a write boundary for the bash tool."
|
|
5
|
+
}
|
|
6
|
+
}
|