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/FINDINGS.md
ADDED
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
# pwsh_run 可行性报告(实测结论)
|
|
2
|
+
|
|
3
|
+
> 结论先行:**方案可行,已做成可装载的插件**。三处必须偏离原方案;沙箱不降级(可做实);
|
|
4
|
+
> read-only 模式是唯一真边界;另外发现写边界漏洞其实在 bash 工具,不在 pwsh。
|
|
5
|
+
|
|
6
|
+
探针环境:Windows 11 + WSL2 Debian,pwsh 7.6.8(`<scoop>\apps\pwsh\current\pwsh.exe`),
|
|
7
|
+
node 24.22.1,全部数字均为实测,不是估算。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 一、问题诊断:到底哪一层吃掉了 `$` 和 heredoc
|
|
12
|
+
|
|
13
|
+
最初的方向是「命令先过 bash/cmd 再到 pwsh,中间被解析掉」——方向对,但**责任层要改**:
|
|
14
|
+
|
|
15
|
+
| 层 | 实测结果 |
|
|
16
|
+
|---|---|
|
|
17
|
+
| 原生 `pwsh` 工具 | **不吃 `$`、不吃 heredoc**。`Write-Output "hello $name"`、`@'...'@` 都完整正确 |
|
|
18
|
+
| `bash` 工具 | **就是这里**。DSH 把命令包成 `/bin/bash -c "<cmd>"`,外层双引号先展开 `$`;`<<'EOF'` 也在这一层失效 |
|
|
19
|
+
| 长度 | 原生 `pwsh` 的真实上限是 **Windows 命令行 32K**:32000 字符 OK,32700 报 `spawn ENAMETOOLONG` |
|
|
20
|
+
| 状态 | 原生 `pwsh` 每次全新进程,`$global:` 与 `Set-Location` 都不跨调用 |
|
|
21
|
+
|
|
22
|
+
证据:`pwsh -Command "Write-Output \"var=$abc\""` 经 bash 后输出 `var=`;同一段 heredoc 写进 `.sh` 文件再执行则完全正常 → 问题只在「DSH 传给 bash 的那一层」,不在 heredoc 语义本身。
|
|
23
|
+
另用 base64 隔离实验确认 bash 工具收命令**字节无损**(98 字节 payload 解码后 === 原文),所以不是截断。
|
|
24
|
+
|
|
25
|
+
**所以 `pwsh_run` 的真实价值是:绕开 bash 那一层、突破 32K、省掉每次 300–500ms 冷启动、保留状态**——
|
|
26
|
+
而「原生 pwsh 会吃 `$`」这个前提本身不成立。
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 二、原方案里必须改的三处
|
|
31
|
+
|
|
32
|
+
### 1. `-Command -` 不能当常驻进程的入口
|
|
33
|
+
`pwsh -Command -` 是「读到 stdin EOF 就退出」的一次性模式。要常驻必须自己 `while (ReadLine)` 循环,
|
|
34
|
+
但循环脚本本身就得以某种方式送进去。改用 **`-EncodedCommand`**(服务端脚本随插件分发,不落地临时文件)。
|
|
35
|
+
|
|
36
|
+
顺带把 `-EncodedCommand` 的上限测准了(原先估计的 32K 是**命令行**长度,不是脚本长度):
|
|
37
|
+
|
|
38
|
+
| 脚本长度 | base64 长度 | 结果 |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| 12000 字符 | 32128 | OK |
|
|
41
|
+
| 12500 字符 | 33464 | `pwsh.exe: Invalid argument` |
|
|
42
|
+
|
|
43
|
+
→ UTF-16LE + base64 膨胀 2.67×,**脚本上限约 12K 字符**。它只适合当「常驻进程起不来」时的兜底,
|
|
44
|
+
不能当主路径。
|
|
45
|
+
|
|
46
|
+
### 2. 「用 GUID 结束标记切分」→ 改成三段标记
|
|
47
|
+
单靠一个结束标记无法区分「跑完了」/「抛异常了」/「被中断了」/「脚本根本没解析」。实测协议最终是:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
客户端 → 服务端(每行一条):
|
|
51
|
+
<id> <timeoutSeconds> <base64(utf8 脚本)>
|
|
52
|
+
|
|
53
|
+
服务端 → 客户端:
|
|
54
|
+
<<<B:id>>> 本次输出开始
|
|
55
|
+
O:/E:/W:/V:/X:/F: + base64 输出流 / 错误 / 警告 / 详细 / 异常 / 致命
|
|
56
|
+
S:<base64> 哨兵:ok=跑完 / threw=抛异常 / ran=被 exit 或超时打断
|
|
57
|
+
<<<T:id:0|1>>> 结束标记,1 = 超时被打断
|
|
58
|
+
<<<E:id>>> 整条响应结束
|
|
59
|
+
<<<BYE>>> 进程正常退出
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 3. stdout/stderr 分开 → 顺序也要保真
|
|
63
|
+
`2>&1` 只合并 error 流,**warning / verbose 会丢**;改 `*>&1` 再按记录类型打标。
|
|
64
|
+
实测 `Write-Output 'L1'; Write-Error 'ERR'; Write-Output 'L2'; Write-Warning 'WARN'; Write-Output 'L3'`
|
|
65
|
+
→ O,E,O,W,O 顺序严格保真且流分离。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 三、六个坑(全部实测踩过,实现里都写了注释)
|
|
70
|
+
|
|
71
|
+
1. **单例 PowerShell 实例在 `throw` 之后会让下一次调用静默返回空输出**。
|
|
72
|
+
改为「每次调用新建 PowerShell 实例、共享同一 Runspace」(`$PS=[PowerShell]::Create(); $PS.Runspace=$RS`)。
|
|
73
|
+
2. **用户脚本一句 `[Console]::In.ReadLine()` 就能吃掉协议数据、让整个会话永久失联**。
|
|
74
|
+
必须把 `Console.In/Out/Error` 换成内存流,原始 stdin/stdout 只留给协议。
|
|
75
|
+
3. **PowerShell 变量名不区分大小写**:`$W` 与 `$w` 是同一个变量,曾把 StreamWriter 覆盖成 string,
|
|
76
|
+
报 `[System.String] does not contain a method named 'WriteLine'`。
|
|
77
|
+
4. **主 shell 定义的 function 在 runspace 里不可见**,base64 表达式必须内联。
|
|
78
|
+
5. **脚本内 `$ErrorActionPreference='Stop'` 会跨调用污染**,每次调用前强制重置为 `Continue`。
|
|
79
|
+
6. **`exit N` 的退出码拿不到**:runspace 内 exit 只结束当次实例,`EndInvoke` 不抛、
|
|
80
|
+
`$LASTEXITCODE` 为空、`catch [ExitException]` 与 `trap` 都捕获不到;finally 会执行。
|
|
81
|
+
→ 只能从哨兵知道「被 exit」,数字拿不到。**已如实写进工具描述**,让模型改用 `Write-Output`。
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 四、卖点实测量化
|
|
86
|
+
|
|
87
|
+
| 卖点 | 实测 |
|
|
88
|
+
|---|---|
|
|
89
|
+
| 零转义 | `$n='Skadi'; "hi $n"` → `hi Skadi`;`@' lit $n '@` 原样 |
|
|
90
|
+
| 无长度限制 | 81,798 字符脚本 61ms 跑完;挂载后再测 91,741 字符照常(原生 32,700 就挂) |
|
|
91
|
+
| 省启动 | 首调用约 370ms(含服务端 boot),此后往返 **1.3ms/次**(50 次共 64ms);原生每次 300–500ms |
|
|
92
|
+
| 状态保留 | `$plain` / `$global:` / `Set-Location` / `$env:` / `Import-Module` 全保留 |
|
|
93
|
+
| 超时中断 | `Start-Sleep 60` 用 3s 打断(3021ms);`while($true){}` 用 2s 打断(2015ms);**中断前的部分输出能交付** |
|
|
94
|
+
| 大输出 | 2MB 输出 8ms |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 五、沙箱:不降级(原判断已推翻)
|
|
99
|
+
|
|
100
|
+
### 0. 落地时踩的坑:策略「看起来有」,其实是部署默认值
|
|
101
|
+
|
|
102
|
+
挂载后第一轮实测就撞上怪异现象:`pwsh_run` 连**工作区内**都写不进去,
|
|
103
|
+
而 `<profile dir>\`(profile 目录)**反而能写**。边界整个错位了。
|
|
104
|
+
|
|
105
|
+
根因在插件解析策略的形状:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
// 错误版:把 ToolExecution 当成了 Agent
|
|
109
|
+
const resolvePolicy = (exec) => {
|
|
110
|
+
const agent = exec?.agent
|
|
111
|
+
return sandboxPolicy.resolve(agent === undefined ? {} : { session: agent.session })
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`acquire()` 收到的入参已经是 `exec.agent`(Agent 本身),于是 `exec.agent` 恒为 `undefined`
|
|
116
|
+
→ `resolve({})`。而 `SandboxPolicyService.resolve` 在没有 `session` 时**不报错,静默退回部署默认值**:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
const { session } = request
|
|
120
|
+
mode: request.mode ?? (session === void 0 ? void 0 : this.overrideOf(session)) ?? this.defaultMode,
|
|
121
|
+
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
即 mode=`workspace-write` + workspaceRoot=配置里的 `<profile dir>`。
|
|
125
|
+
边界没消失,只是跑到别的目录去了 —— 这比「没有沙箱」更难发现。
|
|
126
|
+
|
|
127
|
+
原生工具的写法(`dsh-tools/lib/index.js:2788-2792`)是:
|
|
128
|
+
`resolveSandboxPolicy: (exec) => policy.resolve(exec.agent === void 0 ? {} : { session: exec.agent.session })`
|
|
129
|
+
—— 传的是 **ToolExecution**;插件内部已经拆过一层,所以直接传 `{ session }` 才对:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const resolvePolicy = (agent) => {
|
|
133
|
+
const sandboxPolicy = ctx.get('sandboxPolicy')
|
|
134
|
+
if (sandboxPolicy === undefined) return null
|
|
135
|
+
const session = agent?.session
|
|
136
|
+
return sandboxPolicy.resolve(session === undefined ? {} : { session })
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**教训**:`resolve` 的静默降级是这类 bug 的温床。`test/sandboxtest.mjs` 的
|
|
141
|
+
`policy-carries-session` 用例专门钉住形状 —— 它让 `resolve` 在没有 session 时返回一个假的
|
|
142
|
+
fallback root,只要传错就必然断言失败。
|
|
143
|
+
|
|
144
|
+
另外一条:`patchReload: live` 重载**不会**重新求值同 URL 的 ESM 模块。
|
|
145
|
+
改 `plugin/index.mjs` 内容后必须给 patch 里的 `name` 加版本串(`...index.mjs?v=4`)
|
|
146
|
+
或改 entry `id`,否则看到的是改之前的旧行为 —— 会白排查很久。
|
|
147
|
+
|
|
148
|
+
### 1. 早先的结论是错的,原因找到了
|
|
149
|
+
最初以为「`ctx.sandbox.confine` 逐次包装 argv,所以常驻进程拿不到边界」。
|
|
150
|
+
实测推翻:Windows ACL 沙箱用 **`WRITE_RESTRICTED` 受限令牌**,令牌是**进程级**的。
|
|
151
|
+
常驻进程只要在**启动时**被 confine 一次,之后每一次读/写都受同一个 workspace 边界约束,
|
|
152
|
+
与原生工具等价。
|
|
153
|
+
|
|
154
|
+
做法:`sandbox.confine([pwsh, ...bootArgv], policy)` 返回的整条 `argv`
|
|
155
|
+
(`node.exe runner.js --workspace <ws> --temp <t> --mode <m> -- pwsh ...`)直接作为会话进程的 argv。
|
|
156
|
+
|
|
157
|
+
实测(受限常驻会话,mode=workspace-write,workspace=`<workspace>`):
|
|
158
|
+
|
|
159
|
+
| 目标 | 结果 |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `D:\_cf1.txt` | DENIED |
|
|
162
|
+
| `C:\Users\<user>\_cf1.txt` | DENIED |
|
|
163
|
+
| `C:\Windows\Temp\_cf1.txt` | DENIED |
|
|
164
|
+
| `C:\ProgramData\_cf1.txt` | DENIED |
|
|
165
|
+
| `<profile dir>\_cf1.txt` | DENIED |
|
|
166
|
+
| 工作区内 `_cf_ws.txt` | WROTE |
|
|
167
|
+
|
|
168
|
+
同时 `$ExecutionContext.SessionState.LanguageMode` = `FullLanguage`,`$global:` 与 cwd 仍跨调用保留。
|
|
169
|
+
|
|
170
|
+
### 2. read-only 模式启动不了(这条是真边界)
|
|
171
|
+
read-only 下 pwsh 以 **ConstrainedLanguage** 运行:
|
|
172
|
+
`[Text.UTF8Encoding]::new()`、`[PowerShell]::Create()`、`[RunspaceFactory]::CreateRunspace()`、
|
|
173
|
+
`[IO.StreamReader]::new()` 全部报 "Cannot create type / Method invocation is supported only on core types"。
|
|
174
|
+
常驻会话靠 runspace,所以起不来。
|
|
175
|
+
|
|
176
|
+
→ 实现里 read-only 直接拒绝启动并提示改用原生 pwsh 工具(原生工具在 read-only 下仍可用,跑 CL)。
|
|
177
|
+
|
|
178
|
+
### 3. 真正的写边界漏洞不在 pwsh,在 bash
|
|
179
|
+
`bash` 工具(WSL2 Debian,9p 挂载)**完全没有写边界**。实测以下全部 WROTE:
|
|
180
|
+
`/mnt/d/_probe.txt`、`/mnt/c/Users/<user>/`、`<DSH_HOME>/profiles/desktop/`、
|
|
181
|
+
`/mnt/c/Windows/Temp/`、`/mnt/d/Documents/`、`/mnt/c/ProgramData/`、`/tmp/`。
|
|
182
|
+
|
|
183
|
+
→ 之前遇到的「D 盘根目录、C 盘用户目录、profile 目录莫名出现文件」,最可能的来源是 bash 工具,
|
|
184
|
+
不是 pwsh。这一点与 pwsh_run 无关,但值得单独处置。
|
|
185
|
+
|
|
186
|
+
### 4. 子进程回收要靠杀整个会话
|
|
187
|
+
脚本内 `Start-Process cmd` 起的子进程,在 `$PS.Stop()` 之后仍然存活(实测 CHILD-ALIVE)。
|
|
188
|
+
只有 terminate 整个会话、靠 `ctx.subprocess` 的 Windows Job Object 才能连带清干净。
|
|
189
|
+
|
|
190
|
+
→ 实现里因此采用:**超时即 evict 整个会话**(状态丢失,诚实告知),而不是只 Stop 当前脚本。
|
|
191
|
+
端到端实测:`Start-Process cmd` 超时后子进程 pid 已消失。
|
|
192
|
+
|
|
193
|
+
### 5. `exit N` 退出码不可观测(见上)
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 六、与原生工具的关系:软路由,不做硬拦
|
|
198
|
+
|
|
199
|
+
- **并存,不替换。** 两者沙箱边界等价;原生 `pwsh` 保住审批、超时后转后台 job、read-only 可用;
|
|
200
|
+
`pwsh_run` 管转义敏感、超长、需保留状态的活。
|
|
201
|
+
- **硬拦的两条路都查清了,都不作基石**:
|
|
202
|
+
- `tools/pre-execute` 钩子:`PreToolDecision` 只有 `allow` / `deny` / `cancel` / `ask`,
|
|
203
|
+
**没有 replace 语义**;改 `exec.arguments` 是踩未文档化的洞(官方注释:call identity immutable)。
|
|
204
|
+
- per-agent / preset realm 注册同名 `pwsh`:类型上可行(`ScopedLayers.merge` 注释:Scoped tools shadow globals),
|
|
205
|
+
`custom-bash.mjs` 就是同款手法;但本机 presets 里 `agent.ctx.tools.register` **零先例**,风险高一档,
|
|
206
|
+
而且真要保留审批/截断就得自己重做一遍执行器。
|
|
207
|
+
- **不存在「拿到原实现再包装」的公开 API**(`get(name)` 拿得到 definition,但同名注册必须先自己占位)。
|
|
208
|
+
|
|
209
|
+
→ 结论:**先靠软路由**(工具描述 + AGENTS.md 一条规则)。若实际使用中模型仍频繁绕道 bash,
|
|
210
|
+
再考虑硬拦,且优先选 per-agent 同名注册这条已验证的路。
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 七、bash 写边界的硬拦:`llrun`(已做成、已验收、已接入)
|
|
215
|
+
|
|
216
|
+
第五节第 3 条指出 bash 工具完全没有写边界。三条路线的实测结论:Windows ACL 包 WSL bash 起不来
|
|
217
|
+
(`Bash/E_ACCESS_DENIED`)、`unshare -rm` 只读 bind 被 `cmd.exe` 与 `remount,rw` 双向逃逸、
|
|
218
|
+
软限制挡不住有意绕路。四条路里只有 **landlock** 是真边界。
|
|
219
|
+
|
|
220
|
+
### 1. 为什么 landlock 能成
|
|
221
|
+
|
|
222
|
+
landlock 是**进程级、不可回退、不需要 root** 的文件系统访问控制(syscall 444/445/446),
|
|
223
|
+
本机内核 `6.18.33.2-microsoft-standard-WSL2` 的 **ABI = 7**。
|
|
224
|
+
|
|
225
|
+
关键洞察是 **`/init` 不进白名单**:WSL 的所有 Windows interop 都要经 `/init` 中转,
|
|
226
|
+
它一被 landlock 挡住,`cmd.exe` / `pwsh.exe` / 任意绝对路径 PE **全部无法执行**。
|
|
227
|
+
反向验证过:白名单里加上 `/init` 后 `cmd.exe` 立刻 RAN,并且能写 `D:\` 与 profile 目录
|
|
228
|
+
—— interop 一开,所有写限制形同虚设。所以「不含 `/init`」不是可选项,是这个方案的定义。
|
|
229
|
+
|
|
230
|
+
### 2. 交付物
|
|
231
|
+
|
|
232
|
+
- 源码 `wsl/llrun.c`(约 260 行,中文注释),`gcc -O2 -Wall -Wextra` 零警告
|
|
233
|
+
- 二进制 `wsl/llrun`(17,168 字节),部署两份:`<DSH_HOME>\bin\llrun`(跟便携版走)
|
|
234
|
+
与 `~/.local/bin/llrun`(WSL 原生 fs),`chmod 755`
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
llrun [--no-isolate] [-r] [-C <dir>] <workspace|-> <prog> [args...]
|
|
238
|
+
--no-isolate 不做 socket 掩蔽(docker 口子保持连通,等于放弃这道防线)
|
|
239
|
+
-r 工作区只读(对应 DSH read-only 模式)
|
|
240
|
+
-C <dir> landlock 生效后 chdir(不污染脚本的 $0 与行号)
|
|
241
|
+
workspace 可写目录(WSL 路径);'-' = getcwd()
|
|
242
|
+
prog 目标程序,其后 argv 原样传递(不经过任何 shell)
|
|
243
|
+
```
|
|
244
|
+
退出码:exec 成功则完整透传目标进程;126 = 护栏初始化失败;127 = exec 失败。
|
|
245
|
+
`workspace` 为 `/` 直接拒绝。
|
|
246
|
+
|
|
247
|
+
### 3. 三道边界与顺序
|
|
248
|
+
|
|
249
|
+
1. **user + mount namespace 掩 socket**(必须先做,landlock 不可回退)
|
|
250
|
+
`unshare(CLONE_NEWUSER)` → `setgroups=deny` → 写 uid/gid map → `unshare(CLONE_NEWNS)`
|
|
251
|
+
→ `mount(MS_REC|MS_PRIVATE)` → 把 `/run/docker.sock`、`/run/docker.pid`、
|
|
252
|
+
`/var/run/docker.sock`、`/run/containerd/containerd.sock`、`/run/podman/podman.sock`
|
|
253
|
+
逐个 bind 成 `/dev/null`;再用 `mkdtemp` 造的空目录盖掉 `/etc/ssh/ssh_config.d`。
|
|
254
|
+
2. **landlock 白名单**
|
|
255
|
+
- 全权限:`/usr /bin /sbin /lib /lib64 /lib32 /etc /var /srv /media /root /tmp /dev
|
|
256
|
+
/proc /sys /run /opt /home /usr/local /snap /boot`
|
|
257
|
+
- 只读(EXECUTE|READ_FILE|READ_DIR):`/mnt/c /mnt/d /mnt/e /mnt/wsl /mnt/wslg /mnt`
|
|
258
|
+
- 工作区:`-r` 时只读,否则全权限
|
|
259
|
+
- `/init` 永不入列表
|
|
260
|
+
- `prctl(PR_SET_NO_NEW_PRIVS)` → `syscall(446)` → 可选 `chdir` → `execv`
|
|
261
|
+
3. **`/var/tmp` 挂载封印**(v8 起,`--no-seal` 可关)
|
|
262
|
+
userns 内 `mount(d,d,MS_BIND|MS_REC)` + `mount(NULL,d,MS_REMOUNT|MS_BIND|MS_RDONLY|MS_NOSUID|MS_NODEV)`。
|
|
263
|
+
为什么必须用 mount 而不是 landlock:**landlock 父子规则取并集**(见第 13 小节实测)。
|
|
264
|
+
`/dev` 必须留全权(`/dev/null` 是字符设备,landlock 无法单独授权),
|
|
265
|
+
所以 `-r` 档额外用 mount 封印 `/dev/shm`。封印失败只告警降级,不阻断执行。
|
|
266
|
+
4. **顺序硬约束**:`unshare(mount)` → 封印 → landlock 规则集 → `PR_SET_NO_NEW_PRIVS`
|
|
267
|
+
→ `syscall(446)` → 可选 `chdir` → `execv`。
|
|
268
|
+
landlock restrict 之后白名单外路径连 `stat` 都可能失败,封印必须排在它前面。
|
|
269
|
+
|
|
270
|
+
### 4. 三处踩坑(都是实测撞出来的)
|
|
271
|
+
|
|
272
|
+
- **`uid_map` 必须写同 uid 映射**(`1000 1000 1`),不能写 `0 1000 1`。
|
|
273
|
+
后者把调用者变成 ns 内 root,`$HOME` 下所有文件显示成 `65534`,
|
|
274
|
+
`ssh` 读 `~/.ssh` 直接 `Permission denied`。改成同 uid 后身份保持不变,
|
|
275
|
+
代价只是丢掉 root 映射 —— 而 root 本来就因 `NO_NEW_PRIVS` 不可用。
|
|
276
|
+
- **`getuid()/getgid()` 必须在 `unshare` 之前取**:进 userns 后返回 65534,拿它写 map 会 `Operation not permitted`。
|
|
277
|
+
- **`setgroups(0,NULL)` 走不通**(缺 `CAP_SETGID`,报 `Operation not permitted`),
|
|
278
|
+
所以「丢掉 docker 组」这条路死,必须改成掩 socket。
|
|
279
|
+
|
|
280
|
+
### 5. 验收矩阵(56 项全绿,连续三次一致)
|
|
281
|
+
|
|
282
|
+
`bash wsl/acceptance.sh` → `TOTAL pass=56 fail=0`。
|
|
283
|
+
|
|
284
|
+
| 项 | 结果 |
|
|
285
|
+
|---|---|
|
|
286
|
+
| 写盘符根 / 用户目录 / data 根 / profile | 全 **DENIED**(磁盘复核 absent) |
|
|
287
|
+
| 写工作区 / `/tmp` / `/home`(本用户) | **WROTE** |
|
|
288
|
+
| 写 `/var/tmp` | **DENIED**(Read-only file system);读正常 |
|
|
289
|
+
| 写 `/dev/shm`(默认档) | **WROTE**(POSIX 共享内存挂载点,必须保留) |
|
|
290
|
+
| 读 `/mnt/c`、`/mnt/d` | OK |
|
|
291
|
+
| `cmd.exe` / `pwsh.exe` / `notepad.exe` / 直接 `/init` | 全 **BLOCKED** |
|
|
292
|
+
| `docker ps` / 直连 `/var/run/docker.sock` | 默认档 **BLOCKED**;`--no-isolate` 档 CONNECTED |
|
|
293
|
+
| `git` / `node`(bun) / `python3` / `gcc` / `dpkg -l` / `apt list` / `curl https` | 全部 OK |
|
|
294
|
+
| `ssh -G` | 掩掉 `ssh_config.d` 后不再报 `Bad owner or permissions` |
|
|
295
|
+
| **`-r` 档写入**:工作区 / `/tmp` / `/home` / `/dev/shm` / `/var/tmp` / `/etc` / `/var/log` / `mktemp` | 8 项全 **DENIED** |
|
|
296
|
+
| **`-r` 档读取**:工作区 / `/tmp` / `/home` / `/etc` / `/mnt/d` / `/dev/null` 写 | 6 项全 **OK** |
|
|
297
|
+
| **`-r` 档工具链**:`python3` / `git` / `curl` / `dpkg` / `ssh` | 全 OK;**`gcc` 如预期失败**(`/tmp` 只读 → `Cannot create temporary file` → `Aborted`) |
|
|
298
|
+
| **`-r` 档删文件 / 建目录** | 全 DENIED,事后磁盘复核文件仍在 |
|
|
299
|
+
| `--no-seal` / `--no-isolate` | 均按预期放开 `/var/tmp` |
|
|
300
|
+
| `-C /tmp` | `pwd` = `/tmp`,且工作区仍可写 |
|
|
301
|
+
| 退出码 `exit 42` | 42 透传 |
|
|
302
|
+
| stdin heredoc 字面量、中文、单双引号 | 全部原样 |
|
|
303
|
+
| `workspace=/` | 126 拒绝 |
|
|
304
|
+
| **开销** | 裸 bash 2-3ms/次,llrun 8-9ms/次 → **约 +6ms/次** |
|
|
305
|
+
|
|
306
|
+
> 更正:早期版本(无 userns、无 ssh 掩蔽)测出 +0.5ms;加上 socket 掩蔽与 `mkdtemp+bind` 之后
|
|
307
|
+
> 才是 +6ms。这个数字以当前二进制为准。
|
|
308
|
+
|
|
309
|
+
### 6. 已知代价(使用前必须知道)
|
|
310
|
+
|
|
311
|
+
- **sudo 不可用**:`NO_NEW_PRIVS` 下 setuid 提权被内核拒绝
|
|
312
|
+
(`sudo: /usr/bin/sudo must be owned by uid 0 and have the setuid bit set`)。
|
|
313
|
+
同理 `apt-get install` 装不了包,`apt list` / `dpkg -l` 等只读查询照常。
|
|
314
|
+
- **dbus / systemd --user 正常**:同 uid 映射之前 `dbus-send --session` 会报
|
|
315
|
+
`Did not receive a reply`(`/run/user/1000/bus` 的属主视图不匹配),改成 `1000 1000 1`
|
|
316
|
+
之后恢复正常,`dbus-send --session --print-reply` 拿得到 `method return`,
|
|
317
|
+
`systemctl --user is-system-running` 返回 `degraded`(与裸 bash 一致)。
|
|
318
|
+
- **WSL 里再也不能调任何 Windows exe**(scoop / pwsh / node.exe 全 BLOCKED)。
|
|
319
|
+
这与 AGENTS.md 现有路由方向一致(那里已写明禁止在 bash/ops_bash 里调 powershell.exe)。
|
|
320
|
+
- `--no-isolate` 档不掩 socket,docker 能连 —— 但那时 docker 就是写 D 盘的逃逸口。
|
|
321
|
+
实测两档 7 项对照,**唯一差别就是 docker**(写边界、Windows exe、`/var/tmp`、工作区全部一致):
|
|
322
|
+
|
|
323
|
+
| 项目 | 默认档 | `--no-isolate` |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| 写 D 盘根 | DENIED | DENIED |
|
|
326
|
+
| 调 `cmd.exe` | BLOCKED | BLOCKED |
|
|
327
|
+
| `docker ps` | **BLOCKED** | **CONNECTED** |
|
|
328
|
+
| 写 `/var/tmp` | WROTE(v9 后默认档已封印成 DENIED) | WROTE |
|
|
329
|
+
| 写工作区 | WROTE | WROTE |
|
|
330
|
+
| `mount --bind` | MNT-DENIED | MNT-DENIED |
|
|
331
|
+
| `unshare -rm` | NS-DENIED | NS-DENIED |
|
|
332
|
+
|
|
333
|
+
结论:**`--no-isolate` 只开 docker 一个口子,其余边界都在,但 docker 本身足以绕出写 D 盘**;
|
|
334
|
+
同时它没有 CAP_SYS_ADMIN,所以既封不了子目录,也做不了 `/var/tmp` 封印。
|
|
335
|
+
- **`-r` 档的 `/tmp` 只读是硬代价**:`gcc` 编译失败(`Cannot create temporary file in /tmp/`),
|
|
336
|
+
任何依赖临时文件的工具都会挂。这是 read-only 语义的直接后果,不是缺陷 —— 需要编译就别用只读档。
|
|
337
|
+
- **`-r` 档封 `/dev/shm` 后 python 多进程崩**:`Semaphore` / `SharedMemory` → `OSError: [Errno 30]`,
|
|
338
|
+
`Manager` → `EOFError`。只读场景本就不该起多进程,默认档不受影响。
|
|
339
|
+
|
|
340
|
+
### 7. 接入方式(当时的接入草案,现已实现为 `wsl/custom-bash.mjs`)
|
|
341
|
+
|
|
342
|
+
`presets/shared/custom-bash.mjs` 的 `execute`(第 97-149 行)有两处要改:
|
|
343
|
+
|
|
344
|
+
**a. 命令必须走 stdin,不能走 `-c` 参数。**
|
|
345
|
+
现有 `argv: [shell, '-c', command]` 即使加引号包裹,反引号与复杂引号仍会在 Windows `bash.exe`
|
|
346
|
+
那一层被处理掉(实测 `unexpected EOF while looking for matching \`\``)。正确形态:
|
|
347
|
+
|
|
348
|
+
```js
|
|
349
|
+
const handle = ctx.subprocess.spawn({
|
|
350
|
+
argv: [shell, '-c', `exec ${shq(llrunPath)} ${shq(workspace)} /bin/bash -s`],
|
|
351
|
+
...cwd !== undefined ? { cwd } : {},
|
|
352
|
+
stdio: { stdin: { data: args.command }, stdout: { maxBytes: maxOutputBytes }, stderr: { maxBytes: maxOutputBytes } },
|
|
353
|
+
signal, graceMs: 3000,
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
**b. `workspace` 取会话工作区的 WSL 路径**:`resolveCwd` 返回的是 Windows 路径
|
|
358
|
+
(如 `D:\<workspace>`),要转成 `/mnt/d/<workspace>` 再传给 llrun。
|
|
359
|
+
`workdir` 的 POSIX 分支改用 `llrun -C <dir>`,Windows 分支继续用 spawn cwd。
|
|
360
|
+
|
|
361
|
+
**c. 读-only 模式传 `-r`。** 这样 bash 工具的三种沙箱模式才算真正对齐。
|
|
362
|
+
|
|
363
|
+
### 8. 残留风险
|
|
364
|
+
|
|
365
|
+
`--no-isolate` 档是给「明确要用 docker」的场景留的,接入时应做成配置项默认关闭,
|
|
366
|
+
不暴露给模型。否则模型一句 `llrun --no-isolate ... /bin/bash` 就能绕过全部边界。
|
|
367
|
+
|
|
368
|
+
### 9. 更正记录(v5)
|
|
369
|
+
|
|
370
|
+
早期版本(uid 映射写 `0 <uid> 1`)测出「dbus 连不上、sudo 报属主错误」,
|
|
371
|
+
改成同 uid 映射 `1000 1000 1` 后这两条**只剩 sudo**:
|
|
372
|
+
|
|
373
|
+
| 项 | `0 1000 1`(旧) | `1000 1000 1`(现) |
|
|
374
|
+
|---|---|---|
|
|
375
|
+
| `id` | `uid=0(root)` | `uid=1000(<user>)` |
|
|
376
|
+
| `~/.ssh` 可读 | 否(Permission denied) | **是** |
|
|
377
|
+
| `ssh_config.d` 属主检查 | 失败 | 靠空目录 bind 通过 |
|
|
378
|
+
| `dbus-send --session` | Did not receive a reply | **正常** |
|
|
379
|
+
| `systemctl --user` | 失败 | **正常(degraded)** |
|
|
380
|
+
| `sudo` | 属主 + setresuid 双重失败 | 仍失败(`NO_NEW_PRIVS` 拦 setuid) |
|
|
381
|
+
| `/etc/passwd` 属主 | 65534 | 仍 65534(未映射的 root) |
|
|
382
|
+
|
|
383
|
+
结论:**sudo / apt-get install 是 landlock 的固有代价**(`PR_SET_NO_NEW_PRIVS` 禁止 setuid 提权),
|
|
384
|
+
`/etc/passwd` 等 root 文件在 ns 内显示 65534 也是固有现象(只影响 `-ln` 的输出观感,读取不受影响)。
|
|
385
|
+
|
|
386
|
+
### 10. 快速切换与并发(实测)
|
|
387
|
+
|
|
388
|
+
mode 之间反复切换**不会出问题**——每次调用都是独立进程,`unshare`/`landlock` 都是进程级的,
|
|
389
|
+
没有任何跨调用的状态残留:
|
|
390
|
+
|
|
391
|
+
| 检查项 | 结果 |
|
|
392
|
+
|---|---|
|
|
393
|
+
| 10 轮 read↔write 交替 | 每轮 `read=DENIED write=WROTE`,无一漂移 |
|
|
394
|
+
| 8 条并发(4 read + 4 write) | write 全 WROTE;read 全 DENIED |
|
|
395
|
+
| 60 次切换后残留进程 | `llrun` 0 个、`bash` 僵尸 0 个 |
|
|
396
|
+
|
|
397
|
+
> read 档的 `touch 已存在文件` 会成功——这是 landlock 的语义(改 mtime 不需要 `MAKE_*`),不是竞态。
|
|
398
|
+
|
|
399
|
+
### 11. 两个已修的缺陷(v6)
|
|
400
|
+
|
|
401
|
+
**a. `mkdtemp` 目录泄漏**:v5 用 `mkdtemp("/tmp/.llrun-empty-XXXXXX")` 造 ssh 掩蔽源,
|
|
402
|
+
每次调用留一个空目录,实测累积 **421 个** `.llrun-empty-*` 垃圾。
|
|
403
|
+
改成固定路径 `/tmp/.llrun-empty`(`mkdir` + `EEXIST` 容忍)后,连续 20 次调用只留 **1 个**,不再增长。
|
|
404
|
+
ssh 不受影响(`ssh -G` 输出行数 92,与裸 bash 一致)。二进制 md5 `52e4af16993e5c39d0dc78b220367a50`。
|
|
405
|
+
|
|
406
|
+
**b. `-r` 档不够严(v9 已修)**:v8 及以前 `-r` 只把**工作区**降只读,
|
|
407
|
+
`/tmp` `/home` `/var/tmp` `/dev/shm` 全部照常可写,与 DSH read-only 的
|
|
408
|
+
「permits only required sinks such as `/dev/null`」不等价。
|
|
409
|
+
|
|
410
|
+
v9 改为严格只读(见第 14 小节):`FULL_ACCESS` 表整体降 `readExecute`,`/dev` 例外保留全权,
|
|
411
|
+
工作区同样降只读。**读权限全部保留** —— 没有它连 `ls`/`cat` 都起不来。
|
|
412
|
+
|
|
413
|
+
两个实现要点:`/dev/null` 是字符设备,`LANDLOCK_ADD_RULE` 返回 `EINVAL`,**整个 `/dev` 必须留全权**;
|
|
414
|
+
sink 权限位必须给到 **bit14 `TRUNCATE`**,否则 `>` 覆盖已存在文件会失败。
|
|
415
|
+
(v9 走的是全降只读 + `/dev` 例外 + mount 封 `/dev/shm`,不再需要 sink 组。)
|
|
416
|
+
|
|
417
|
+
### 12. landlock 的固有盲区:mtime 与 chmod
|
|
418
|
+
|
|
419
|
+
read-only 档下**改已存在文件的 mtime 与权限位仍然成功**,这与 9p 无关,ext4 上同样如此:
|
|
420
|
+
|
|
421
|
+
| 操作 | read-only 档 | 说明 |
|
|
422
|
+
|---|---|---|
|
|
423
|
+
| 写内容 | **DENIED** | `WRITE_FILE` 被拦 |
|
|
424
|
+
| 新建 / 删除 / 重命名 | **DENIED** | `MAKE_*` / `REMOVE_*` / `REFER` 被拦 |
|
|
425
|
+
| 改 mtime | **CHANGED** | landlock 无「改时间戳」这个权限位 |
|
|
426
|
+
| chmod | **CHANGED** | 同上,属主改自己文件的 mode 不受 landlock 约束 |
|
|
427
|
+
|
|
428
|
+
这是内核 ABI 7 的权限词汇表里就没有的概念,任何 landlock 方案都躲不掉。
|
|
429
|
+
影响有限(改不了内容、删不掉、移不走),但必须说清:**read-only 不是绝对只读**。
|
|
430
|
+
|
|
431
|
+
### 13. landlock 的父子规则是**并集**,不是交集(关键陷阱)
|
|
432
|
+
|
|
433
|
+
用独立 C 探针验证(`allow()` 父目录全权 + 子目录只读):
|
|
434
|
+
|
|
435
|
+
```
|
|
436
|
+
allow(rs, "/var", all); allow(rs, "/var/tmp", ro); → /var/tmp/_llt WROTE ← 父规则胜出
|
|
437
|
+
allow(rs, "/dev", all); allow(rs, "/dev/shm", ro); → /dev/shm/_llt WROTE
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
→ **父目录全权时,子目录更严格的规则被吞掉。** 这直接决定了三件事:
|
|
441
|
+
|
|
442
|
+
1. 为什么 `/dev/shm` 不是「选择保留」而是**必然可写**:`/dev/null` 是字符设备必须让整个 `/dev` 全权,
|
|
443
|
+
子规则 `/dev/shm` 只读被父规则吞掉。要真封只能靠 mount —— 这就是 v8 加 `/var/tmp` 封印的动机。
|
|
444
|
+
2. 为什么 `/var/tmp` 看似「顺带可写」:`/var` 因 dpkg 数据库必须全权,`/var/tmp` 被并集带过去。
|
|
445
|
+
(`/var/lib` 显示 DENIED 只是普通文件权限 755 root:root 造成的,不是 landlock。)
|
|
446
|
+
3. 封子目录**唯一可行路径 = `mount -o remount,ro`**,而它要 CAP_SYS_ADMIN → 只能在 userns 内做 →
|
|
447
|
+
**`--no-isolate` 档封不住任何子目录**,这是它不该暴露给模型的又一条理由。
|
|
448
|
+
|
|
449
|
+
### 14. `-r` 严格只读档(v9)与它的硬代价
|
|
450
|
+
|
|
451
|
+
实现:`FULL_ACCESS` 表整体降 `readExecute`(EXECUTE|READ_FILE|READ_DIR),
|
|
452
|
+
`/dev` 例外保留全权,工作区降只读,`/dev/shm` 与 `/var/tmp` 用 mount 封印。
|
|
453
|
+
`--no-seal` 可单独关掉封印(socket 掩蔽照旧)。
|
|
454
|
+
|
|
455
|
+
实测(`-r` 档):
|
|
456
|
+
|
|
457
|
+
| 操作 | 结果 |
|
|
458
|
+
|---|---|
|
|
459
|
+
| 写工作区 / `/tmp` / `/home` / `/etc` / `/var/log` | **DENIED**(Permission denied) |
|
|
460
|
+
| 写 `/dev/shm` / `/var/tmp` | **DENIED**(Read-only file system) |
|
|
461
|
+
| `mktemp -d` | **DENIED** |
|
|
462
|
+
| 删文件 / 重命名 / mkdir / truncate | **DENIED** |
|
|
463
|
+
| 读工作区 / `/tmp` / `/home` / `/etc` / `/mnt/d` / 写 `/dev/null` | **全部 OK** |
|
|
464
|
+
| `python3` / `git --version` / `curl` / `dpkg -l` / `ssh -G` / `bun` / `node` | **全部 OK** |
|
|
465
|
+
| `gcc` 编译 | **失败**:`Cannot create temporary file in /tmp/: Permission denied` → `Aborted` |
|
|
466
|
+
| python `multiprocessing.Semaphore` / `SharedMemory` | `OSError: [Errno 30] Read-only file system` |
|
|
467
|
+
| python `multiprocessing.Manager` | `EOFError` |
|
|
468
|
+
| python `threading` | `THR-OK` |
|
|
469
|
+
| 改 mtime / chmod | **仍 CHANGED**(第 12 小节的内核盲区) |
|
|
470
|
+
|
|
471
|
+
**结论:`-r` 是「读、查、下载、跑已有脚本」档,不是「编译、构建」档。**
|
|
472
|
+
`/tmp` 只读是 DSH read-only 语义的直接后果 —— 想要真只读就必须接受临时文件不可写。
|
|
473
|
+
|
|
474
|
+
### 15. `/var/tmp` 与 `/dev/shm` 的定性(这两个什么时候用到)
|
|
475
|
+
|
|
476
|
+
性质:`/var/tmp` = **ext4 磁盘**(重启保留);`/tmp` 与 `/dev/shm` = **tmpfs 内存**。
|
|
477
|
+
本机 `/var/tmp` 只有 2 个 `systemd-private-*`(polkit / systemd-logind),`TMPDIR` 未设置,
|
|
478
|
+
`tempfile.gettempdir()` = `/tmp`,git 无 `core.tempdir` → **封它零代价**(已封)。
|
|
479
|
+
|
|
480
|
+
`/dev/shm` 是 POSIX `shm_open` / 信号量的挂载点,**是硬需求**:
|
|
481
|
+
封掉后 `multiprocessing.Semaphore`(`_multiprocessing.SemLock`)与 `SharedMemory`(`/psm_*`)直接崩;
|
|
482
|
+
`Manager` 走 `/tmp` socket,但在 `/tmp` 也只读的 `-r` 档里同样崩(`EOFError`)。
|
|
483
|
+
默认档(`/tmp` 可写)不受影响,所以 `/dev/shm` 只在 `-r` 档被封印。
|
|
484
|
+
|
|
485
|
+
### 16. `llrun` 挡不住「把工作区删空」(必须诚实说明)
|
|
486
|
+
|
|
487
|
+
用与事故同构的目录复刻那条 `rm -rf` 命令(工作区内):
|
|
488
|
+
|
|
489
|
+
```
|
|
490
|
+
命令: rm -rf <victim>/_tmpncd <victim>; echo rc=$?
|
|
491
|
+
输出: rm: cannot remove '<victim>': Permission denied
|
|
492
|
+
rc=1
|
|
493
|
+
事后: 目录还在 yes / 剩余文件 0
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
→ **工作区在默认档本就可写,`rm -rf` 对其中文件有完整权限,内容照样被清空。**
|
|
497
|
+
只有目录本身因 cwd 占着才保住。围栏只防「往外写」,不防「把里面删空」。
|
|
498
|
+
对照项:围栏内 `touch /mnt/d/_ll_outside` → `Permission denied`(对外确实封住了)。
|
|
499
|
+
→ 防误删只能靠纪律:bash 工具里不拿 `\n` 分隔多行命令;`rm -rf` 不一次带多个路径;
|
|
500
|
+
删除一律走回收站(见第 17 小节)。
|
|
501
|
+
|
|
502
|
+
### 17. 接入 `custom-bash.mjs`(v10,已实施)
|
|
503
|
+
|
|
504
|
+
`wsl/custom-bash.mjs` 是 `bash` 工具的替代实现(v13 起由 bundle 自己的 patch 接进组合,
|
|
505
|
+
见第二十三节)。两个改动:**命令改走 stdin**、**按 sandbox mode 分诊挂 llrun**。
|
|
506
|
+
|
|
507
|
+
**为什么必须改 stdin**:原来 `argv = [bash, '-c', command]`。Windows 的 `bash.exe`(WSL 垫片)
|
|
508
|
+
会把 `-c` 后面的字符串再过一层自己的解析,反引号、`\n` 里的反斜杠、复杂引号都会被吃掉。
|
|
509
|
+
改成 `argv = [bash, '-s']` + `stdio.stdin = { data: command }` 之后脚本正文零解析。
|
|
510
|
+
这正是那次工作区被误删的直接根因:`rm -rf X/_tmp\ncd X && ...` 里的 `\n` 被内层 bash
|
|
511
|
+
消掉 `\` 后粘成 `_tmpncd`,紧跟的路径成了 `rm` 的第二个参数。
|
|
512
|
+
|
|
513
|
+
**mode 分诊**(`sandboxPolicy.resolve` 必须带 `session`,否则静默退回部署默认值):
|
|
514
|
+
|
|
515
|
+
| 会话权限 | 起的进程 | 实测 |
|
|
516
|
+
|---|---|---|
|
|
517
|
+
| `danger-full-access` | 裸 `bash -s` | 写 `/mnt/d/_probe_full.txt` → WROTE |
|
|
518
|
+
| `workspace-write` | `llrun <workspace> /bin/bash -s` | 工作区 WROTE;`/mnt/d/_probe_out.txt` → `Permission denied` |
|
|
519
|
+
| `read-only` | `llrun -r <workspace> /bin/bash -s` | 工作区与 `/tmp` 写入均 `Permission denied`;`ls` 正常 |
|
|
520
|
+
| `sandboxPolicy` 缺席 | **抛错拒绝**(`fence: false` 才放行) | `bash fence requested but ctx.sandboxPolicy is absent` |
|
|
521
|
+
| llrun 不可执行 | **抛错拒绝**,不静默降级 | `llrun is not executable at ...` |
|
|
522
|
+
|
|
523
|
+
`workspace` 取策略里的 `workspaceRoot`,Windows 路径转 `/mnt/<盘符>/...`;
|
|
524
|
+
`workdir` 传 POSIX 路径时交给 `-C`(landlock 之后 chdir),Windows 路径仍作进程 cwd。
|
|
525
|
+
|
|
526
|
+
端到端实测(真 `LocalSubprocessRuntime` + 真 llrun,`test/bash-e2e.mjs` 11 项):
|
|
527
|
+
`stdin` 模式、多行脚本、中文、工作区可写、区外被拒、`-C` chdir、`-r` 拒写、
|
|
528
|
+
full-access 不挂围栏、流分离 + `exit code: 7`、2000 行大输出 —— 全绿。
|
|
529
|
+
|
|
530
|
+
### 18. 删除纪律:回收站(Windows 与 WSL 是两套体系)
|
|
531
|
+
|
|
532
|
+
| 目标 | 做法 | 实测 |
|
|
533
|
+
|---|---|---|
|
|
534
|
+
| Windows 路径 | `[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($p,'OnlyErrorDialogs','SendToRecycleBin')` | 文件消失,`Shell.Application.Namespace(10)` 里查得到,`GetDetailsOf($i,1)` 返回 `OriginalLocation` 正确 |
|
|
535
|
+
| Windows 目录 | `DeleteDirectory($p,'OnlyErrorDialogs','SendToRecycleBin')` | 目录与子文件一并进回收站 |
|
|
536
|
+
| WSL 自己的路径 `/home/...` | `gio trash <path>` | `~/.local/share/Trash/{files,info}`,该文件系统正确的回收站,`gio trash --list` 可列 |
|
|
537
|
+
| **`/mnt/c`、`/mnt/d` 上的 Windows 文件** | **不能**用 `gio trash`,改走 FileSystem API | `gio trash` 只建 `<盘>:\.Trash-1000`,**不是 Windows 回收站**(见下)|
|
|
538
|
+
| `/tmp`、`/dev/shm` | —— | `gio: Trashing on system internal mounts is not supported`(系统内部挂载不支持回收站)|
|
|
539
|
+
|
|
540
|
+
**`.Trash-1000` 不是回收站**(被指出后实测,原判断已推翻):
|
|
541
|
+
|
|
542
|
+
| 检查项 | 实测结果 |
|
|
543
|
+
|---|---|
|
|
544
|
+
| `gio trash /mnt/d/x` 之后文件在哪 | `D:\.Trash-1000\files\x` + `info\x.trashinfo` |
|
|
545
|
+
| Windows 回收站里看得到吗 | **看不到** —— `Shell.Application.Namespace(10)` 的 37 条里查无 `_cleanup.sh` 等 |
|
|
546
|
+
| Explorer 里能还原吗 | **不能** —— 不在回收站命名空间 |
|
|
547
|
+
| 在 `D:\` 根可见吗 | **可见** —— `attrs=Directory`(不是系统隐藏),Shell 能列出,只是 `Shell.Application.Namespace('D:\\.Trash-1000')` 打不开 |
|
|
548
|
+
| 还原手段 | 只有命令行 `gio trash --restore 'trash:///%5Cmnt%5Cd%5C.Trash-1000%5Cfiles%5Cx'`;传普通路径报 `Location given doesn't start with trash:///` |
|
|
549
|
+
| 元数据 | `info/<name>.trashinfo` 里 `[Trash Info]` + `Path=`(相对挂载点)+ `DeletionDate=` |
|
|
550
|
+
|
|
551
|
+
**处置**:已把 `D:\.Trash-1000` 里 13 项、`C:\.Trash-1000` 里 1 项全部转进 Windows 真回收站(逐个 `SendToRecycleBin`),
|
|
552
|
+
清掉孤儿的 `.trashinfo`,再 `rmdir` 两个目录 —— 现在 `C:\.Trash-1000` 与 `D:\.Trash-1000` **都不存在**。
|
|
553
|
+
`C:\.Trash-1000` 是测试时用 `gio trash` 删 C 盘文件产生的,`C:` 与 `D:` 行为一致。
|
|
554
|
+
|
|
555
|
+
`DeleteFile` / `DeleteDirectory` 对不存在路径抛 `MethodInvocationException`,用前先 `Test-Path`。
|
|
556
|
+
|
|
557
|
+
实测坑:
|
|
558
|
+
- 查回收站不能用 `Where-Object` 过滤:`$rb.Items() | Where-Object { $rb.GetDetailsOf($_,1) -eq $p }` 报 `[System.__ComObject] does not contain a method named 'GetDetailsOf'`;`$_` 的 PSObject 包装让 COM 绑定失败。改为 `$items = @($rb.Items())` + `foreach ($i in $items) { $rb.GetDetailsOf($i,1) }` 即正常(实测 `COUNT=12`,`orig`/`del` 两列都能读)。
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## 十九、自动更新:只查、只提示、从不自己装(历史)
|
|
563
|
+
|
|
564
|
+
> 本节记录的是 0.2.2 时期的形态:发布源是 GitHub 标签、装由人触发。第三十三节之后发布源换成
|
|
565
|
+
> npm registry、自动路径会自己装,但下面的**判决逻辑与失败语义**(失败不抛、保留 stale、查与装分开的
|
|
566
|
+
> 理由、`plugin_manager` 工具与服务的区别)仍然成立。文中出现的 `DSH_PWSH_UPDATE_SOURCE` / `github:` 规格
|
|
567
|
+
> 与 WSLENV 陷阱都属于那一版,现已被 `DSH_PWSH_UPDATE_REGISTRY` 取代。
|
|
568
|
+
|
|
569
|
+
参考同作者的 `dsh-remote-access-proxy`(v1.4 起有更新检查,1454 行里第 102-107 / 225-233 / 244 / 982 / 1010 行是全部相关代码)。
|
|
570
|
+
本插件照搬了它的**判决逻辑与失败语义**,改掉了两处形态。
|
|
571
|
+
|
|
572
|
+
### 照搬的部分
|
|
573
|
+
|
|
574
|
+
| 设计 | 参考实现 | 本插件 |
|
|
575
|
+
|---|---|---|
|
|
576
|
+
| 版本契约 | `/^v(0\|\d*)\.(0\|\d*)\.(0\|\d*)$/`,预发布永不推送 | 同(`parseVersion` / `updateVerdict`)|
|
|
577
|
+
| 安装规格 | `${source.spec}#${tag}` —— 一个 tag 一个规格,装新版就是盖掉旧的 | `dsh-pwsh-plugin@X.Y.Z`(registry 形式,见第三十三节)|
|
|
578
|
+
| 源可覆盖 | `DSH_RAP_UPDATE_SOURCE`:`owner/repo` → GitHub;`git+<url>` → `git ls-remote` | `DSH_PWSH_UPDATE_REGISTRY`,指 registry 基址(第三十三节已从 GitHub 标签改为 npm dist-tags)|
|
|
579
|
+
| 首次延迟 / 周期 | 5s 之后每 12h,timer 都 `.unref?.()` | 同 |
|
|
580
|
+
| 失败不抛 | `state:'failed'` + 保留先前 `latest` + `stale:'available'` | 同,注释原话「一次网络抖动不该让已知的新版本消失」 |
|
|
581
|
+
| **查与装分开** | 页面把钉了 tag 的 spec 交给 DSH 的 `plugin_manager` | 输出末尾挂一行提示;装由 `pwsh_run_update` 显式触发,同样交给 `pluginManager` |
|
|
582
|
+
|
|
583
|
+
### 改掉的两处
|
|
584
|
+
|
|
585
|
+
1. **没有 UI 形态**。参考实现有 client 入口,用 `ctx.connection.fetch.register` 开了个 `/api/.../update-check` 路由给配置页面读。
|
|
586
|
+
本插件没有 client 入口,也不需要界面 —— 提示直接挂在 `pwsh_run` 的返回文本末尾(每次进程生命周期只挂一次,靠 `this.announced` 去重,否则每次调用都刷一句会很吵)。
|
|
587
|
+
2. **`announce` 是一次性的**(`get announce()`),不是持续可读的状态。原因同上:这是给模型看的一句注记,不是给界面轮询的字段。
|
|
588
|
+
|
|
589
|
+
### 为什么不自己装
|
|
590
|
+
|
|
591
|
+
热替换会 dispose 旧实例 —— 而「安装新版本」这件事正是要由**外部**发起:
|
|
592
|
+
插件在自己的 effect 里调安装 API 等于在自己的尸体上开刀,`dispose` 一旦跑起来,
|
|
593
|
+
后半段代码再也不会执行,日志也留不下。所以提示行只写「有新版本 + 装了它的 spec」,装由外部做。
|
|
594
|
+
|
|
595
|
+
外部有两条路:
|
|
596
|
+
|
|
597
|
+
1. **`dsh plugin` CLI**(本机可用的那条):
|
|
598
|
+
|
|
599
|
+
```powershell
|
|
600
|
+
$env:DSH_HOME = '<DSH_HOME>'
|
|
601
|
+
& '<desktop cli>\dsh.cmd' plugin --profile desktop add 'dsh-pwsh-plugin@<version>'
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
2. **`pwsh_run_update` 工具**(0.2.2 起):把钉住版本的 spec 交给 `ctx.get('pluginManager')`。
|
|
605
|
+
|
|
606
|
+
这里要区分**工具**与**服务**,早前我把两者混为一谈:
|
|
607
|
+
- 工具 `plugin_manager`:`dsh-base/cordis.patch.yml:15-17` 里 `tool-plugin-manager` 是 `disabled: true`,
|
|
608
|
+
profile 没覆盖 → 会话里确实没有这个工具(`Object.keys(tools).filter(/plugin/i)` 为空)。
|
|
609
|
+
它打开后自己会走 `approveEscalation({requestedMode:'danger-full-access', …})`。
|
|
610
|
+
- 服务 `plugin-manager`:同文件 `disabled: !!js "!ctx.get('profileContext')"` —— **桌面 profile 有
|
|
611
|
+
`profileContext`,服务是活的**,`installBundle` 可用。`dsh-host-plugin-inventory/lib/index.js:137`
|
|
612
|
+
就是 `ctx.get("pluginManager")` 这么取的。
|
|
613
|
+
|
|
614
|
+
所以「装」这一步可以走服务,不必打开那个需要 `danger-full-access` 审批的工具。
|
|
615
|
+
|
|
616
|
+
### 为什么不照抄 our-free-model 的自带下载器
|
|
617
|
+
|
|
618
|
+
`<profile>/node_modules/dsh-our-free-model/src/updater.js`(1056 行)做了整套:Ed25519 签名清单
|
|
619
|
+
(`PINNED_MANIFEST_PUBLIC_KEY`,SPKI base64)、逐文件 sha256、`backupPackage` / `installStaged` /
|
|
620
|
+
`verifyInstalled` / `restoreBackup`、`.ofm-new` 同目录改名、`renameWithRetry` 处理 Windows EPERM。
|
|
621
|
+
|
|
622
|
+
它能这么干是因为**它是独立包,安装目录里就是自己那几十个文件**。本插件是 profile 的 bundle 依赖:
|
|
623
|
+
文件在 `node_modules/dsh-pwsh-plugin`,版本钉在 profile 的 `package.json` 与 `pnpm-lock.yaml` 里。
|
|
624
|
+
绕开包管理器改文件,下一轮 `dsh plugin add/remove` 就会踩到 lockfile 与磁盘不一致的脏状态。
|
|
625
|
+
|
|
626
|
+
`installBundle` 的返回里 `application` 是 `'applied' | 'restart-required' | 'failed' | 'cancelled'`;
|
|
627
|
+
`change()` 失败**不抛**,折进 `result.application='failed'` + `result.error=managementError(error)`
|
|
628
|
+
(`{ code:'operation-error', diagnostic }`),只有拿不到 profile 锁或服务销毁时才 reject。
|
|
629
|
+
|
|
630
|
+
### `applied` 也要说重启
|
|
631
|
+
|
|
632
|
+
bundle 行的 `name` 就是包名,装新版前后 `resolve` 出**同一个 module URL**,而 ESM 按 URL 缓存。
|
|
633
|
+
实测:同进程内改文件后同 URL 再 import,拿到的是同一个模块对象(`same === true`);加 `?v=bust`
|
|
634
|
+
才拿到新代码。所以 `application` 的两种取值对「是否已经生效」没有区别 —— 都得重启。
|
|
635
|
+
|
|
636
|
+
### 陷阱:WSL 的 `bash` 工具里 `export DSH_PWSH_UPDATE_SOURCE=...` 是无效的
|
|
637
|
+
|
|
638
|
+
本地预演时踩到(`_skadi_temp` 里的探针已清):
|
|
639
|
+
|
|
640
|
+
```bash
|
|
641
|
+
export DSH_PWSH_UPDATE_SOURCE='git+file:///tmp/release'
|
|
642
|
+
node -e "console.log(process.env.DSH_PWSH_UPDATE_SOURCE)" # → undefined
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
根因是 WSL 的互操作规则:**只向 Windows 侧透传 `WSLENV` 里点名过的变量**,其余一律丢。
|
|
646
|
+
表现极具误导性 —— `updateSource()` 收到空串就静静退回默认 GitHub 仓库,
|
|
647
|
+
于是明明设了本地源,却看到 `GitHub answered HTTP 404`(仓库还没建,当然 404)。
|
|
648
|
+
|
|
649
|
+
三条实测:
|
|
650
|
+
|
|
651
|
+
| 写法 | node 里读到的 |
|
|
652
|
+
|---|---|
|
|
653
|
+
| `export DSH_PWSH_UPDATE_SOURCE=...` | `undefined` |
|
|
654
|
+
| `WSLENV=DSH_PWSH_UPDATE_SOURCE` + export | `"git+file:///tmp/release"` ✅ |
|
|
655
|
+
| `WSLENV='DSH_PWSH_UPDATE_SOURCE/p'` + export | `"git+file;\\wsl.localhost\Debian\tmp\release"` ❌ `/p` 会把路径翻译成 UNC,本地 git 源就废了 |
|
|
656
|
+
|
|
657
|
+
**结论:预演一律在 Windows 侧做**(`pwsh_run` 或原生 `pwsh`),不要试图从 WSL 跨过去。
|
|
658
|
+
README 里的预演脚本已改成 PowerShell 写法,并且照抄跑通过一次(`available / v0.2.0`)。
|
|
659
|
+
|
|
660
|
+
### 测试
|
|
661
|
+
|
|
662
|
+
`test/updatetest.mjs`(52 项)不联外网:发布源用 `mkdtempSync` 建的真 git 仓库 +
|
|
663
|
+
`git+file://` 规格,走完「读标签 → 比版本 → 下判决 → 出 spec → 去重 announce」全链路,
|
|
664
|
+
`finally` 里 `rmSync` 自清。失败路径(源不存在、自身版本读不出)各有钉住用例。
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## 二十、安装路径:`desktop` profile 只能由桌面端 CLI 管
|
|
670
|
+
|
|
671
|
+
要把插件装成 bundle(而不是手写 patch 行),必须走对入口。这块踩了三个坑,都实测过。
|
|
672
|
+
|
|
673
|
+
### 1. 便携版的 `dsh.exe` 管不了 `desktop`
|
|
674
|
+
|
|
675
|
+
```powershell
|
|
676
|
+
PS> & '<portable>\dsh.exe' plugin --profile desktop list
|
|
677
|
+
error: profile "desktop" is managed exclusively by the Electron application
|
|
678
|
+
dsh 已退出,退出码 1
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
源码在 `@deepseek-ai/dsh/lib/bin.js`(asar 内 `11772` 字节那版):
|
|
682
|
+
|
|
683
|
+
```js
|
|
684
|
+
function rejectElectronProfile(program, profile) {
|
|
685
|
+
if (profile.toLowerCase() === "desktop") program.error("error: profile \"desktop\" is managed exclusively by the Electron application");
|
|
686
|
+
}
|
|
687
|
+
// …
|
|
688
|
+
if (!manageDesktopProfile) rejectElectronProfile(plugin, options.profile);
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`manageDesktopProfile` 默认为 `false`,只有桌面安装里的 CLI 会传 `true`:
|
|
692
|
+
`<asar>\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js` 的 `runDesktopCli` →
|
|
693
|
+
`runCli({ manageDesktopProfile: true, packageManager: { command: process.execPath, args: ['--expose-internals', …/pnpm/bin/pnpm.mjs], env: { ELECTRON_RUN_AS_NODE: '1' } } })`。
|
|
694
|
+
启动器 `<desktop cli>\dsh.cmd` 干的就是这件事:
|
|
695
|
+
|
|
696
|
+
```bat
|
|
697
|
+
@echo off
|
|
698
|
+
setlocal DisableDelayedExpansion
|
|
699
|
+
set "ELECTRON_RUN_AS_NODE=1"
|
|
700
|
+
"%~dp0..\..\..\..\DeepSeek Harness.exe" --expose-internals "%~dp0..\..\..\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\cli.js" %*
|
|
701
|
+
exit /b %errorlevel%
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
### 2. 必须把 `DSH_HOME` 指到便携版的数据根
|
|
705
|
+
|
|
706
|
+
`resolveDshHome()` 的优先级是「显式配置 > `$DSH_HOME` > `~/.dsh`」。不设的话去找 `C:\Users\<user>\.dsh\profiles\desktop\package.json`,
|
|
707
|
+
而便携版的数据根是 `<DSH_HOME>`,于是报:
|
|
708
|
+
|
|
709
|
+
```
|
|
710
|
+
dsh: Error: Open DeepSeek Harness Desktop once to initialize its profile, then fully quit it before running dsh plugin --profile desktop.
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
源码 `@deepseek-ai/dsh/lib/plugin-BiveYA7h.js`:
|
|
714
|
+
|
|
715
|
+
```js
|
|
716
|
+
function requireDesktopProfile(dir) {
|
|
717
|
+
if (!existsSync(join(dir, "package.json"))) throw new Error("Open DeepSeek Harness Desktop once to initialize its profile, then fully quit it before running dsh plugin --profile desktop.");
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
设上就通了:
|
|
722
|
+
|
|
723
|
+
```powershell
|
|
724
|
+
$env:DSH_HOME = '<DSH_HOME>'
|
|
725
|
+
& '<desktop cli>\dsh.cmd' plugin --profile desktop list
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
```
|
|
729
|
+
Legend: production dependency, optional only, dev only
|
|
730
|
+
|
|
731
|
+
dsh-profile-desktop@0.1.0 <DSH_HOME>\profiles\desktop (PRIVATE)
|
|
732
|
+
│
|
|
733
|
+
│ dependencies:
|
|
734
|
+
├── <plugin-a>
|
|
735
|
+
├── <plugin-b>
|
|
736
|
+
├── …
|
|
737
|
+
└── <plugin-f>
|
|
738
|
+
|
|
739
|
+
6 packages
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
### 3. bundle patch 与手写 insert 会各插一份,不做去重
|
|
743
|
+
|
|
744
|
+
`applyEntryPatches()`(`@deepseek-ai/dsh-app-boot/lib/index.js:56-96`)对 `insert` 的处理就是 `data.push(...insert)`,
|
|
745
|
+
**按 id 去重只发生在 `buildMap()` 建索引时(后写覆盖索引),不发生在插入时**。
|
|
746
|
+
所以仓库里的 `cordis.patch.yml` 插一条、profile 的 `cordis.patch.yml` 手写一条,dump 出来就是两条同 id 的 `pwsh-run`:
|
|
747
|
+
|
|
748
|
+
```
|
|
749
|
+
pwsh-run 行数=2
|
|
750
|
+
|- id: pwsh-run
|
|
751
|
+
| name: dsh-pwsh-plugin
|
|
752
|
+
| timeoutMs: 120000
|
|
753
|
+
|- id: pwsh-run
|
|
754
|
+
| name: file:///D:/<workspace>/plugin/index.mjs?v=99
|
|
755
|
+
| timeoutMs: 999
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
两条都会被 mount(`applyEntryPatches` 只管插入,不去重),于是同一个插件实例化两次。
|
|
759
|
+
`@deepseek-ai/dsh-tools/lib/index.js:2634` 的同名注册是硬错误:
|
|
760
|
+
|
|
761
|
+
```js
|
|
762
|
+
this.tools = new NamedEntries((name) => new Error(scope === void 0
|
|
763
|
+
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's agent.ctx instead)`
|
|
764
|
+
: `tool "${name}" is already registered in this scope`));
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
> 这一条**是从源码推的,没有把重复 id 的 profile 真正 boot 起来验**(`_skt3` 启动后 20 秒无输出、按超时杀掉,
|
|
768
|
+
> `--dump-config` / `--dump-config-schema` 都 rc=0 —— 说明组合层不查重,冲突要等 mount 才爆)。
|
|
769
|
+
|
|
770
|
+
→ **装成 bundle 之后,把 profile 里手写的那条 `- id: pwsh-run` 删掉。**
|
|
771
|
+
|
|
772
|
+
### 实测过的安装命令
|
|
773
|
+
|
|
774
|
+
用临时 profile `_sktest` 演练了一遍(演练完 `SendToRecycleBin` 清掉):
|
|
775
|
+
|
|
776
|
+
```powershell
|
|
777
|
+
$env:DSH_HOME = '<DSH_HOME>'
|
|
778
|
+
& '<portable>\dsh.exe' plugin --profile _sktest add 'file:D:/<workspace>'
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
```
|
|
782
|
+
dsh: initialized profile _sktest at <DSH_HOME>\profiles\_sktest
|
|
783
|
+
dependencies:
|
|
784
|
+
+ dsh-pwsh-plugin file:../../../../<workspace>
|
|
785
|
+
Done in 88ms using pnpm v12.15.0
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
生成的 `profile/package.json` 里 `dsh.profile.bundles` 自动多了 `dsh-pwsh-plugin`,
|
|
789
|
+
`--dump-config` 里也能看到 bundle patch 插进来的那一行:
|
|
790
|
+
|
|
791
|
+
```
|
|
792
|
+
# == dsh-pwsh-plugin
|
|
793
|
+
- id: pwsh-run
|
|
794
|
+
name: dsh-pwsh-plugin
|
|
795
|
+
config:
|
|
796
|
+
pwshPath: pwsh
|
|
797
|
+
timeoutMs: 120000
|
|
798
|
+
idleMs: 1800000
|
|
799
|
+
maxSessions: 4
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
注意 `file:` 后面的路径要写正斜杠(`file:D:/…`)——README 与本文都用这个形态,且实测可用。
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
---
|
|
806
|
+
|
|
807
|
+
## 二十一、bundle 切换与更新检查的闭环实测
|
|
808
|
+
|
|
809
|
+
第十九节讲了更新检查怎么设计,第二十节讲了安装入口怎么选。这一节是两者接上之后的真机结果 ——
|
|
810
|
+
从「手写 patch 行」换成「bundle 依赖」,以及用**装进 profile 的那份模块**(不是工作区副本)验证更新判决。
|
|
811
|
+
|
|
812
|
+
### 1. 切换前必须先删手写段
|
|
813
|
+
|
|
814
|
+
`applyEntryPatches()` 的 `insert` 分支只是 `data.push(...insert)`,不去重(第二十节第 3 小节)。
|
|
815
|
+
所以顺序是「先删手写 → 再装 bundle」,反了就会在合成树里留下两条 `- id: pwsh-run`。
|
|
816
|
+
|
|
817
|
+
删掉之后 profile 的 `cordis.patch.yml` 从 1779 行降到 **1753** 行,`grep pwsh-run` 只剩 skadi-route
|
|
818
|
+
`residentTools` 里那个工具名(第 823 / 825 行)—— 那是在说「晋升后别过滤掉它」,与注册无关。
|
|
819
|
+
|
|
820
|
+
### 2. `--dump-config` 对 `desktop` 也拒绝
|
|
821
|
+
|
|
822
|
+
第二十节记的是 `plugin list` 拒绝,实测 `--dump-config` 一样拒绝:
|
|
823
|
+
|
|
824
|
+
```
|
|
825
|
+
PS> & '<desktop cli>\dsh.cmd' --profile desktop --dump-config
|
|
826
|
+
error: profile "desktop" is managed exclusively by the Electron application
|
|
827
|
+
rc=1
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
`rejectElectronProfile` 挂在 profile 解析入口上,不是挂在 `plugin add` 上。
|
|
831
|
+
想验 patch 合法性就借一个临时 profile:装同一个 bundle、把 desktop 的 `cordis.patch.yml` 拷进去,
|
|
832
|
+
`--dump-config` 应给出 `rc=0` 与 `- id: pwsh-run` 计数 **1**。验完把临时 profile 送回收站。
|
|
833
|
+
|
|
834
|
+
### 3. 装进去的东西与工作区逐字节一致
|
|
835
|
+
|
|
836
|
+
`github:` 是拷贝(第二十节),所以值得核对一次 —— 五个承重文件 md5 全同:
|
|
837
|
+
|
|
838
|
+
| 文件 | md5 前 8 位 |
|
|
839
|
+
|---|---|
|
|
840
|
+
| `plugin/index.mjs` | `E5B3E41A` |
|
|
841
|
+
| `plugin/session.mjs` | `A2ACF1A7` |
|
|
842
|
+
| `plugin/session-server.ps1` | `46ED2221` |
|
|
843
|
+
| `cordis.patch.yml` | `30666D67` |
|
|
844
|
+
| `package.json` | `71426A25` |
|
|
845
|
+
|
|
846
|
+
装完本会话就能用(工具面 390 个,`pwsh_run` 是可调函数),`$global:` 变量跨调用保留。
|
|
847
|
+
|
|
848
|
+
### 4. 更新检查:用装进 profile 的那份跑
|
|
849
|
+
|
|
850
|
+
工作区副本测过不等于线上那份对。直接 import
|
|
851
|
+
`file:///<DSH_HOME>/profiles/desktop/node_modules/dsh-pwsh-plugin/plugin/update-check.mjs`:
|
|
852
|
+
|
|
853
|
+
```
|
|
854
|
+
new UpdateChecker() → 从包自己的 package.json 读到 0.1.1
|
|
855
|
+
check() → 217–330 ms → {"state":"current","latest":"v0.1.1"}
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
再把候选标签喂给纯函数,验判决边界:
|
|
859
|
+
|
|
860
|
+
| 输入标签 | 判决 |
|
|
861
|
+
|---|---|
|
|
862
|
+
| `v0.1.0 v0.1.1 v0.2.0 v0.2.0-rc.1 nonsense` | `{"state":"available","latest":"v0.2.0"}`,spec = `github:citydirector/dsh-pwsh-plugin#v0.2.0` |
|
|
863
|
+
| 只有 `v0.1.0`(当前 0.1.1) | `{"state":"current","latest":"v0.1.0"}`,不误报 |
|
|
864
|
+
|
|
865
|
+
预发布 `v0.2.0-rc.1` 与非 `vX.Y.Z` 的标签都被跳过 —— 这是标签契约在起作用,不是巧合。
|
|
866
|
+
|
|
867
|
+
### 5. 真闭环:装旧版,让它自己查到新版
|
|
868
|
+
|
|
869
|
+
上面的判决是喂标签进去验的。真正想确认的是「**线上真有一个更新的版本时,装着的旧版会怎么说**」。
|
|
870
|
+
做法不是模拟,而是真装旧版:
|
|
871
|
+
|
|
872
|
+
```powershell
|
|
873
|
+
& $dsh plugin --profile desktop add 'github:citydirector/dsh-pwsh-plugin#v0.1.0' # 2.1s → 0.1.0
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
然后 import 装进 profile 的那份 `update-check.mjs`(此时它是 v0.1.0 的代码):
|
|
877
|
+
|
|
878
|
+
```
|
|
879
|
+
installed version : 0.1.0
|
|
880
|
+
check : {"state":"available","latest":"v0.1.1", …} 317 ms
|
|
881
|
+
document : {…, "running":"0.1.0", "spec":"github:citydirector/dsh-pwsh-plugin#v0.1.1"}
|
|
882
|
+
announce : {"latest":"v0.1.1","spec":"github:citydirector/dsh-pwsh-plugin#v0.1.1"}
|
|
883
|
+
announce again : undefined
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
三件事一次确认:查询命中真远端、给出的 spec 正好是能直接装的那条、`announce` 只吐一次(不会每次调用都刷)。
|
|
887
|
+
验完升回 `v0.1.1`,`package.json` 里的依赖行也随之回到 `#v0.1.1`。
|
|
888
|
+
|
|
889
|
+
> 这一步也顺带暴露了一个测试问题:`updatetest` 原本写死断言 `packageVersion() === '0.1.0'`,
|
|
890
|
+
> 版本一升就红。已改成「等于 `package.json` 声明的版本」+「三段版本号」两条(51 → 52 项)。
|
|
891
|
+
|
|
892
|
+
### 6. 版本推进
|
|
893
|
+
|
|
894
|
+
文档与示例 tag 必须一起动,否则照抄 README 装到旧版:`0.1.0` → `0.1.1`(`package.json` + 中英 README 的
|
|
895
|
+
安装示例 `#v0.1.1`),提交 `2c7543a`,annotated tag `v0.1.1` → `8a50a40`,main 与 tag 都已推送;
|
|
896
|
+
desktop profile 的依赖行同步为 `#v0.1.1`,重装后 `node_modules` 里的 `package.json` 读回 0.1.1。
|
|
897
|
+
|
|
898
|
+
### 7. bundle 模式改代码必须重启(已由缓存实验钉死)
|
|
899
|
+
|
|
900
|
+
第二十节说 bundle 行的 `name` 必须是包名、带不了 `?v=N`。这条**代价**原来是推断,现在实测过了。
|
|
901
|
+
|
|
902
|
+
做法:取 `require.resolve('dsh-pwsh-plugin')` 得到的真实 URL(`file:///<DSH_HOME>/profiles/desktop/node_modules/dsh-pwsh-plugin/plugin/index.mjs`),
|
|
903
|
+
同一进程里按顺序 import 三次,中间把文件里的 `export const name` 改掉:
|
|
904
|
+
|
|
905
|
+
| 步骤 | 结果 |
|
|
906
|
+
|---|---|
|
|
907
|
+
| 1. 普通 import | `pwsh-run` |
|
|
908
|
+
| 2. 改文件后用**同一个 URL** 再 import | `pwsh-run`,且返回的是**同一个模块对象**(`same === true`)|
|
|
909
|
+
| 3. 改文件后用 `<url>?v=bust` import | `PATCHED` —— 新 URL 才绕过缓存 |
|
|
910
|
+
|
|
911
|
+
结论:ESM 按 URL 缓存,磁盘上文件换了、URL 没换,进程里还是旧模块。
|
|
912
|
+
bundle 模式给不出新 URL,所以 `dsh plugin add …#vX.Y.Z` 装新版本之后(或本地 `file:` 硬链接改了之后),
|
|
913
|
+
**必须重启 DSH** 才会加载新代码 —— 这一点与手写 `file:` 行 + `?v=N` 的老手法正相反。
|
|
914
|
+
|
|
915
|
+
同一实验也顺手确认了恢复路径:把文件写回原内容后,五个承重文件(`plugin/index.mjs`、`session.mjs`、
|
|
916
|
+
`session-server.ps1`、`package.json`、`cordis.patch.yml`)与工作区 md5 全同。
|
|
917
|
+
|
|
918
|
+
## 二十二、llrun 候选探测与两种「非本机」环境(2026-10-24)
|
|
919
|
+
|
|
920
|
+
### 1. 默认路径改候选探测,且探测脚本必须走 stdin
|
|
921
|
+
|
|
922
|
+
原来 `wsl/custom-bash.mjs` 把 `DEFAULT_LLRUN_PATH` 写死成一个路径,且 docs 教人装到另外两个位置,
|
|
923
|
+
新用户第一条 `bash` 调用就会 fail-closed。现在按 `$LLRUN` / `$DSH_LLRUN` → `<DSH_HOME>/bin/llrun` →
|
|
924
|
+
`/usr/local/bin/llrun` → `~/.local/bin/llrun` 探测第一个可执行的,结果跨调用缓存(失败不缓存,装好即生效)。
|
|
925
|
+
|
|
926
|
+
落地时撞上一个**真坑**:探测脚本最初走 `bash -c <script>`,一直算出错误的候选序号。隔离实验:
|
|
927
|
+
|
|
928
|
+
| 写在 `bash -c` 正文里 | 期望 | WSL 垫片 `bash.exe` 实得 |
|
|
929
|
+
|---|---|---|
|
|
930
|
+
| `x=abc; echo "x=$x"` | `x=abc` | **`x=`**(赋值整个被吃掉)|
|
|
931
|
+
| `i=0; i=$((i+1)); echo "i=$i"` | `i=1` | **`i=`** |
|
|
932
|
+
| `echo "sub=$(echo hi)"` | `sub=hi` | `sub=hi`(命令替换正常)|
|
|
933
|
+
| `if [ -x "<path>" ]; then exit 1; fi; exit 0` | 1 | 1(控制流正常)|
|
|
934
|
+
|
|
935
|
+
`C:\Windows\System32\bash.exe`(WSL 垫片)会改写 `-c` 的正文,原生 Linux `/bin/bash` 不会。
|
|
936
|
+
同一层还有另一个症状:`bash -c 'echo "$@"' name a b` 在垫片下 `$@` 为空。
|
|
937
|
+
|
|
938
|
+
**结论:不要用 `bash -c` 给 WSL 传脚本,一律 `bash -s` + stdin。** 这正是主执行路径早就采用的手法
|
|
939
|
+
(头注释里记着「避开 bash.exe 的 argv 引号怪癖」),探测脚本只是漏用了。改成 stdin 后立刻正常。
|
|
940
|
+
|
|
941
|
+
探测结果只走退出码(0 = 全无,N = 第 N 个候选可执行),不读 stdout —— 收集缓冲区只在 stdio 声明为
|
|
942
|
+
collect 时才存在,不该把可用性押在它上面。
|
|
943
|
+
|
|
944
|
+
### 2. 「没有 WSL」:清晰失败,不是静默降级
|
|
945
|
+
|
|
946
|
+
`bashPath: 'C:/nonexistent/bash.exe'` 时 `apply()` 不抛错(可执行文件是惰性解析的),`bash` 工具照常注册;
|
|
947
|
+
真调一次才报 `subprocess-local: command "C:/nonexistent/bash.exe" is not an executable file`,
|
|
948
|
+
三种权限档一致。`pwsh_run` 本体全程不碰 WSL。
|
|
949
|
+
|
|
950
|
+
### 3.「UAC 正常」:决定因素是 DACL 的两条权限位,不是属主
|
|
951
|
+
|
|
952
|
+
本机 UAC 关闭(`EnableLUA = 0`),进程带完整令牌。用 `runas /trustlevel:0x20000` 造受限令牌
|
|
953
|
+
(只剩 `SeChangeNotifyPrivilege`,等价于 UAC 开着的标准用户)做对照,三组目录各测一次 `confine`:
|
|
954
|
+
|
|
955
|
+
| 目录 | 属主 | DACL(当前用户) | 受限令牌下 confine |
|
|
956
|
+
|---|---|---|---|
|
|
957
|
+
| `<drive>:\_uac_case_fresh`(新建成)| 当前用户 | 继承 `Modify` | **THREW** `SetNamedSecurityInfoW failed (Win32 5): grantWrite(...)` |
|
|
958
|
+
| 同上 + `icacls /grant <user>:(OI)(CI)F` | 当前用户 | 全控制 | **OK**(`enforcement=partial`)|
|
|
959
|
+
| `C:\Users\<user>\<dir>`(用户目录下)| 当前用户 | 继承 `FullControl` | **OK** |
|
|
960
|
+
|
|
961
|
+
对照全令牌(本机现状):三组都 OK,包括盘符根下只有 `Modify` 的目录 —— 完整令牌能自己改写 DACL,
|
|
962
|
+
受限令牌不能。这就是「本机能跑不代表开了 UAC 也能跑」的全部原因。
|
|
963
|
+
|
|
964
|
+
源码依据(`dsh-sandbox-windows-acl`):`grantWrite` 用 `SetNamedSecurityInfoW(path, 1, 20, …)` 同时写
|
|
965
|
+
DACL 与 SACL(低完整性标签),注释原文:`The directory must be owned by the caller AND grant
|
|
966
|
+
WRITE_OWNER (the label lives in the SACL; owner-implicit rights cover only READ_CONTROL and WRITE_DAC)`。
|
|
967
|
+
实测细化一句:**属主不是硬条件**(`icacls` 授权后属主仍是当前用户、但已能通过),真正卡住的是
|
|
968
|
+
`WRITE_DAC` + `WRITE_OWNER` 两条 —— 全控制两样都有,`Modify` 两样都没有。
|
|
969
|
+
|
|
970
|
+
受限令牌下的端到端复验(真 `LocalSubprocessRuntime` + 真 `LocalSandboxProvider` + 真插件 `apply`,
|
|
971
|
+
workspace 取用户目录下的一个临时目录):
|
|
972
|
+
|
|
973
|
+
| 断言 | 结果 |
|
|
974
|
+
|---|---|
|
|
975
|
+
| 工作区内 `Set-Content` 后读回 | `"IN"`,磁盘 `inside=true` |
|
|
976
|
+
| 盘符根下的 `<drive>:\_uac_out.txt` | `DENIED: UnauthorizedAccessException`,磁盘 `outside=false` |
|
|
977
|
+
| `$global:uacz` 跨调用 | `7` |
|
|
978
|
+
| `LanguageMode` | `FullLanguage` |
|
|
979
|
+
|
|
980
|
+
### 4. confine 失败给可操作的错误
|
|
981
|
+
|
|
982
|
+
原始错误只有一句 `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<path>)`,看不出该改哪个目录、
|
|
983
|
+
该给什么权限。`plugin/index.mjs` 现在把它翻成一段话:指出 workspace 根、说明需要 `WRITE_DAC` +
|
|
984
|
+
`WRITE_OWNER`(全控制满足)、给出 `icacls "<root>" /grant "%USERNAME%":(OI)(CI)F` 或换到用户目录下的目录。
|
|
985
|
+
`execute` 侧也不再套第二层 `pwsh_run could not start a session:` 前缀。
|
|
986
|
+
## 二十三、方案 D:bundle 自己接线,装完即用、卸载即还原(2026-10-25)
|
|
987
|
+
|
|
988
|
+
### 1. 为什么不是「改一份 shim 转发」(方案 C 被证伪)
|
|
989
|
+
|
|
990
|
+
方案 C 的设想是让 profile 里那份 `presets/shared/custom-bash.mjs` 改成一行 `export * from`,指向包内实现。
|
|
991
|
+
在全新 profile `_skc1` 里实测(`dsh plugin --profile _skc1 add 'github:citydirector/dsh-pwsh-plugin#v0.1.3'`):
|
|
992
|
+
|
|
993
|
+
| 检查项 | 结果 |
|
|
994
|
+
|---|---|
|
|
995
|
+
| profile 目录内容 | `.plugin-manager` `node_modules` `cordis.patch.yml` `package.json` `pnpm-lock.yaml` `pnpm-workspace.yaml` |
|
|
996
|
+
| `presets/` 是否存在 | **False** |
|
|
997
|
+
| 该 profile patch 里 `custom-bash` 计数 | **0** |
|
|
998
|
+
|
|
999
|
+
→ `presets/shared/custom-bash.mjs` 只是本机预设(skadi / skadi-route / skadi-ptc)的私有物,不是 DSH 的通用组件。
|
|
1000
|
+
方案 C 只对本机有效,换个用户就无从下手,达不到「别人不用麻烦」。
|
|
1001
|
+
|
|
1002
|
+
### 2. 方案 D:让 bundle 的 patch 自己插这一行
|
|
1003
|
+
|
|
1004
|
+
关键机制在 `dsh-app-boot/lib/index.js:3537`:
|
|
1005
|
+
|
|
1006
|
+
```js
|
|
1007
|
+
function anchorInsertedPluginNames(patches, file) {
|
|
1008
|
+
const base = dirname(resolve(file));
|
|
1009
|
+
const visit = (entry) => {
|
|
1010
|
+
if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../')))
|
|
1011
|
+
entry.name = pathToFileURL(resolve(base, entry.name)).href;
|
|
1012
|
+
if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit);
|
|
1013
|
+
};
|
|
1014
|
+
for (const patch of patches) patch.insert?.forEach(visit);
|
|
1015
|
+
return patches;
|
|
1016
|
+
}
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
`parsePatchList` 末尾 `return anchorInsertedPluginNames(parsed, file)`(index.js:3569),bundle 与用户 patch 走同一条路。
|
|
1020
|
+
**`./` 相对名按 patch 文件所在目录改写**,所以写在包根 patch 里的 `./wsl/custom-bash.mjs` 会落进
|
|
1021
|
+
`<profile>/node_modules/dsh-pwsh-plugin/wsl/`。
|
|
1022
|
+
|
|
1023
|
+
但这里有一个反直觉的取舍:**不要用 `./`**。它会被改写成绝对 `file://` URL 并抄进合成后的配置树,
|
|
1024
|
+
等于把安装路径钉死 —— 便携版挪一次目录、或换一台机器,那行就废。改用**裸子路径**:
|
|
1025
|
+
|
|
1026
|
+
```yaml
|
|
1027
|
+
- id: custom-bash
|
|
1028
|
+
name: dsh-pwsh-plugin/wsl/custom-bash.mjs
|
|
1029
|
+
disabled: !!js process.platform !== 'win32'
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
裸子路径不进改写分支,每次 boot 都从 profile 的 `node_modules` 重新解析。代价是
|
|
1033
|
+
`package.json` 的 `exports` 必须加上 `"./wsl/custom-bash.mjs": "./wsl/custom-bash.mjs"`,否则
|
|
1034
|
+
`ERR_PACKAGE_PATH_NOT_EXPORTED`。`wsl/llrun` 本身不需要导出 —— 插件靠 `import.meta.url` 找它。
|
|
1035
|
+
|
|
1036
|
+
### 3. 三个必须踩对的细节
|
|
1037
|
+
|
|
1038
|
+
**不要在 patch 里顺手禁用 `tool-bash`。** `dsh-base/cordis.patch.yml:269` 的 `tool-bash` 本来就带
|
|
1039
|
+
`disabled: !!js process.platform === 'win32'`,我们这行只在 win32 启用,两边已经互斥。
|
|
1040
|
+
再写一条 `- id: tool-bash / disabled: true` 会把 Linux 上的官方 bash 一起关掉。
|
|
1041
|
+
|
|
1042
|
+
**llrun 必须跟着包走。** 原来的候选全在系统目录(`$LLRUN` / `<DSH_HOME>/bin/llrun` /
|
|
1043
|
+
`/usr/local/bin/llrun` / `~/.local/bin/llrun`),新用户一个都没有,装上就是 fail-closed。
|
|
1044
|
+
现在末尾追加包内那份(`import.meta.url` → `toWslPath` → `<pkg>/wsl/llrun`),顺序是
|
|
1045
|
+
「用户装的在前,包内只保证开箱可用」:
|
|
1046
|
+
|
|
1047
|
+
```js
|
|
1048
|
+
function packagedLlrunPath() {
|
|
1049
|
+
try {
|
|
1050
|
+
// 目录 URL 带尾分隔符,去掉再转,免得拼出双斜杠。
|
|
1051
|
+
return toWslPath(fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, ''))
|
|
1052
|
+
} catch {
|
|
1053
|
+
return undefined
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
**llrun 在 git 里丢了可执行位。** `git ls-files -s wsl/llrun` 原本是 `100644`。本机侥幸能跑纯属 9p 挂载的
|
|
1059
|
+
功劳(`/proc/mounts`: `9p rw,noatime,aname=drvfs;path=D:\;uid=1000;...`,Windows 侧一律给 `rwxrwxrwx`);
|
|
1060
|
+
在 ext4 上模拟 clone(`chmod 644`)→ 探测 `test -x` 失败 → fail-closed。用
|
|
1061
|
+
`git update-index --chmod=+x wsl/llrun` 改成 `100755`(`core.filemode false` 时只改索引)。
|
|
1062
|
+
|
|
1063
|
+
### 4. 反证实验:负向用例为什么一开始没抓到
|
|
1064
|
+
|
|
1065
|
+
把 `wsl/llrun` 临时改名后跑负向探针,结果是 `UNEXPECTED: ran` —— 因为本机
|
|
1066
|
+
`/home/<user>/.local/bin/llrun` 真实存在,探测命中了它。捕获探测脚本的 stdin 才看清候选顺序。
|
|
1067
|
+
把 `HOME` 指到空目录后才真正走到包内那份(`RESULT: "PACKAGED-ONLY-HOME"`)。
|
|
1068
|
+
`test/bashtest.mjs` 的 `llrun-packaged-candidate` 因此必须同时挪开 `HOME`。
|
|
1069
|
+
|
|
1070
|
+
### 5. 装卸演练(临时 profile `_skx` / `_sky`,均已进回收站)
|
|
1071
|
+
|
|
1072
|
+
`_skx`(干净 profile,`add 'file:<检出>'`):
|
|
1073
|
+
|
|
1074
|
+
| 检查项 | 结果 |
|
|
1075
|
+
|---|---|
|
|
1076
|
+
| `--dump-config` | rc=0,`- id: pwsh-run` **1 条**、`- id: custom-bash` **1 条** |
|
|
1077
|
+
| `custom-bash` 的 name 渲染 | `dsh-pwsh-plugin/wsl/custom-bash.mjs`(裸子路径不被改写,符合预期) |
|
|
1078
|
+
| 解析与实际加载 | `resolve` → 包内真实路径;`import` → `name=custom-bash inject=["subprocess","tools"]` |
|
|
1079
|
+
| 卸载后 `--dump-config` | `pwsh-run` 与 `custom-bash` **各 0 条** |
|
|
1080
|
+
| 卸载后 `node_modules/dsh-pwsh-plugin` | 不存在 |
|
|
1081
|
+
| 卸载后 `pnpm-lock.yaml` 命中 | **0** |
|
|
1082
|
+
| 卸载后 profile `cordis.patch.yml` | 仍是初始的 `[]`(从未被写过) |
|
|
1083
|
+
|
|
1084
|
+
**卸载完美还原成立。**
|
|
1085
|
+
|
|
1086
|
+
`_sky`(克隆 desktop 的 patch + `presets/` 后再装 bundle):`id: custom-bash` 共 **4 条**(1 条 bundle + 3 条手写)。
|
|
1087
|
+
patch 层不去重(`applyEntryPatches` 的 insert 分支就是 `data.push(...insert)`),boot 时同名注册是硬错误
|
|
1088
|
+
→ **本机 desktop 直接装会撞双行陷阱,必须先删那三处手写行。**
|
|
1089
|
+
|
|
1090
|
+
那三处已在 2026-10-25 删掉(`profiles/desktop/cordis.patch.yml` 1769 → 1736 行),删后
|
|
1091
|
+
`grep 'id: custom-bash'` 在该文件里为 **0**,`pwsh-run` 也只剩 bundle 那一条。
|
|
1092
|
+
另一件事:三处手写在三个不同预设的 realm 里,删之前真 boot 20 秒 stderr 里 `already registered`
|
|
1093
|
+
命中 **0** —— `dsh-tools/lib/index.js:2874` 的注释说明硬错误只发生在同一层,跨 realm 同名注册是允许的。
|
|
1094
|
+
所以「双行陷阱」精确地说只发生在**同 scope 的 bundle 行与手写行**之间。
|
|
1095
|
+
|
|
1096
|
+
### 6. 参数顺序坑(再记一次)
|
|
1097
|
+
|
|
1098
|
+
`dsh plugin --profile X --dump-config` → `[ERROR] Unknown option: 'dump-config'`,rc=1。
|
|
1099
|
+
必须写成 `dsh.cmd --profile X --dump-config`,`--dump-config` 在 `plugin` 之外。
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
## 二十四、子进程与协议流:两个真实事故(2026-10-25)
|
|
1105
|
+
|
|
1106
|
+
### 1. 事故一:一句 `<<<BYE>>>` 就能杀掉会话
|
|
1107
|
+
|
|
1108
|
+
服务端原本只用 `Console::SetIn/SetOut/SetError` 把 .NET 层的三个流换掉。**Win32 的 std handle 槽位没有动**,
|
|
1109
|
+
新起的子进程从槽位继承的仍是那条协议管道。
|
|
1110
|
+
|
|
1111
|
+
实测(脚本里放一句 `Start-Process pwsh.exe -Command 'Write-Output (<<<BYE>>> 的字符拼接)'`):
|
|
1112
|
+
`pwsh_run` 当场返回 `[Error: session said BYE; a new session starts on the next call]`,连续三次全中。
|
|
1113
|
+
反向也成立:子进程 `[Console]::In.ReadLine()` 能读走客户端发来的协议行,读到的是**真实请求**。
|
|
1114
|
+
|
|
1115
|
+
有意思的是普通 `cmd /c echo` 与 node 的 `console.log` 反而不漏 —— 它们写的是继承来的 stdout 句柄本身;
|
|
1116
|
+
而 PowerShell 的调用运算符与 `Start-Process` 在不重定向时会先建管道,管道两端都还挂在协议流上。
|
|
1117
|
+
|
|
1118
|
+
**修法**:拿到协议流后改写三个槽位 —— stdin 接 `NUL`;stdout / stderr 接一个
|
|
1119
|
+
`FILE_FLAG_DELETE_ON_CLOSE` 的临时文件,每次调用前 `SetFilePointer(0)` + `SetEndOfFile` 截回零。
|
|
1120
|
+
|
|
1121
|
+
**为什么 stdout 不能接 `NUL`**(这一步踩过):`NUL` 是字符设备(`GetFileType` = 2),PowerShell 据此判定
|
|
1122
|
+
「输出没有被重定向」,于是 `& exe` 的报错从
|
|
1123
|
+
|
|
1124
|
+
Program 'node.exe' failed to run: 拒绝访问。
|
|
1125
|
+
|
|
1126
|
+
变成
|
|
1127
|
+
|
|
1128
|
+
Program 'node.exe' failed to run: StandardOutputEncoding is only supported when standard output is redirected.
|
|
1129
|
+
|
|
1130
|
+
沙箱边界最有用的那条诊断信息就没了。磁盘句柄(`GetFileType` = 1)三种场景下文案都不变,实测
|
|
1131
|
+
P0/P1/P2 三档(未接管 / 只换 stdout / 再换 stdin)完全一致。
|
|
1132
|
+
|
|
1133
|
+
**验证**(文件式 stdin/stdout 驱动改造后的服务端):协议流只剩 `O:UFJF` / `O:UE9TVA==` / `S:b2s=` 三行,
|
|
1134
|
+
伪造行消失;偷 stdin 的孙子进程拿到 `CHILD-SAW-EOF`。
|
|
1135
|
+
|
|
1136
|
+
**降级**:临时目录不可写时退回 `NUL`(协议流仍然干净,只是报错文案变成重定向那一句);
|
|
1137
|
+
接管失败时把原因写进 `$TakeoverNote`,随**第一次调用**以 `W:` 行交付一次,然后清空 —— 挂在每次调用上只是噪音。
|
|
1138
|
+
|
|
1139
|
+
### 2. 事故二:`& exe` 被拒时的报错看不出是沙箱
|
|
1140
|
+
|
|
1141
|
+
workspace-write 会话里 `& node --version` 报 `Program 'node.exe' failed to run: 拒绝访问。`。
|
|
1142
|
+
根因(P/Invoke 实测)是受限令牌 = Low 完整性 + `WRITE_RESTRICTED`,**建不了匿名管道**:
|
|
1143
|
+
`CreatePipe`(带不带 `SECURITY_ATTRIBUTES` 都一样)→ `ok=False err=5`,
|
|
1144
|
+
`[IO.Pipes.AnonymousPipeServerStream]::new` → access denied。命名管道反而能建,但 node 的 `stdio:'pipe'`
|
|
1145
|
+
走的是匿名管道,一样 `EPERM`。
|
|
1146
|
+
|
|
1147
|
+
所以凡是「捕获子进程 stdio」的起法全失,`& exe` 也在其中(调用运算符内部要建管道)。
|
|
1148
|
+
三条实测可用的绕法:`Start-Process -RedirectStandardOutput/-RedirectStandardError <文件>`(拿得到输出、
|
|
1149
|
+
拿不到退出码)、`[Diagnostics.Process]::Start`(`UseShellExecute=$false`、不重定向,拿得到退出码但子进程
|
|
1150
|
+
继承 stdout)、`.cmd` 包装器(stdout / stderr / `%ERRORLEVEL%` 各写一个文件,两样都拿得到)。
|
|
1151
|
+
|
|
1152
|
+
原始报错只说明「起不来」,看不出这是沙箱边界。现在 `describeExecDenial()` 识别
|
|
1153
|
+
`failed to run` + `拒绝访问|Access is denied|StandardOutputEncoding is only supported`,在输出末尾附上说明,
|
|
1154
|
+
并点明「不要在不重定向的情况下让子进程继承 stdout」。
|
|
1155
|
+
|
|
1156
|
+
误报同样要防:`Cannot find path`、只有前半句的 `failed to run: 找不到文件。`、空串都不触发。
|
|
1157
|
+
`test/denialtest.mjs` 用 11 项钉住识别与不识别两侧 —— 它**不起任何子进程**,所以在连 spawn 都 EPERM 的
|
|
1158
|
+
会话里照样能跑。
|
|
1159
|
+
|
|
1160
|
+
**这一节后来被推翻了两处**(详见第二十五节):三条绕法里只有 `.cmd` 那条与
|
|
1161
|
+
`[Diagnostics.Process]::Start` 在 `pwsh_run` 会话里真的可用;而且第一道门 `/failed to run/` 在真实路径上
|
|
1162
|
+
从不命中 —— 服务端把异常剥到最内层才交付。
|
|
1163
|
+
|
|
1164
|
+
### 3. 两个环境的报错文案不同,原因在 `Console.OutputEncoding`
|
|
1165
|
+
|
|
1166
|
+
同一条 `& '...\git.exe' --version`,在原生 `pwsh` 工具里报「拒绝访问」,在 `pwsh_run` 会话里报
|
|
1167
|
+
「StandardOutputEncoding is only supported when standard output is redirected」。差异不在沙箱,而在编码:
|
|
1168
|
+
|
|
1169
|
+
| 环境 | `[Console]::OutputEncoding` | 报错文案 |
|
|
1170
|
+
|---|---|---|
|
|
1171
|
+
| 原生 `pwsh` 工具 | `utf-8` | `failed to run: 拒绝访问。` |
|
|
1172
|
+
| `pwsh_run` 会话 | `gb2312`(取系统 ANSI 代码页)| `failed to run: StandardOutputEncoding is only supported when standard output is redirected.` |
|
|
1173
|
+
|
|
1174
|
+
PowerShell 在重定向场景下要把子进程输出按 `Console.OutputEncoding` 解码,非 UTF-8 时先撞上自己的编码检查,
|
|
1175
|
+
于是沙箱那句「拒绝访问」还没轮到就抛了。手动 `[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)`
|
|
1176
|
+
后文案**变回**「拒绝访问」,但子进程照样起不来 —— 两种文案都指向同一个边界。
|
|
1177
|
+
所以 `describeExecDenial` 两条都要认。
|
|
1178
|
+
|
|
1179
|
+
四档对照实验(`probe-mode.ps1`,M0 基线 / M1 仅 `Console::SetOut` / M2 再加磁盘槽位 / M3 换成 `NUL` 槽位)
|
|
1180
|
+
在**管道式**与**文件式** stdout 下各跑一遍,结论一致:`& exe` 的成败**只取决于令牌能不能建匿名管道**,
|
|
1181
|
+
与 .NET 层的 `SetOut`、与 Win32 槽位的句柄类型(`GetFileType` 3=管道 / 1=磁盘 / 2=字符设备)都无关。
|
|
1182
|
+
唯一的区别是「把输出接进管道」这一种写法(`& exe ... | ForEach-Object`)文案会变成「拒绝访问」——
|
|
1183
|
+
因为这条路径先走管道、再撞令牌。
|
|
1184
|
+
|
|
1185
|
+
## 二十五、可执行子进程被拒:从「提示」到「能装的包」(2026-10-25)
|
|
1186
|
+
|
|
1187
|
+
### 第一节那版提示在真实路径上从不触发
|
|
1188
|
+
|
|
1189
|
+
`describeExecDenial` 的第一道门原来是 `/failed to run/`。但服务端交付的根本不是这句:
|
|
1190
|
+
`plugin/session-server.ps1:112-114 / 150-152 / 170-172` 三处都有
|
|
1191
|
+
|
|
1192
|
+
```powershell
|
|
1193
|
+
while ($null -ne $__e.InnerException) { $__e = $__e.InnerException }
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
—— 异常被剥到**最内层**才交出去,于是 `& node --version` 交上来的是
|
|
1197
|
+
`StandardOutputEncoding is only supported when standard output is redirected.`,
|
|
1198
|
+
正则永远不命中。denialtest 的 9 个用例全用外层文案造样本,测试与实现一起错了。
|
|
1199
|
+
|
|
1200
|
+
改法(一处):第一道门变成 `/failed to run|StandardOutputEncoding is only supported/`,
|
|
1201
|
+
第二道门(`拒绝访问|Access is denied|StandardOutputEncoding…`)不动。denialtest 补 2 项
|
|
1202
|
+
「内层编码文案命中」与「只有拒绝访问、没有编码线索时不触发(太泛)」,9 → 11 项。
|
|
1203
|
+
|
|
1204
|
+
### 提示里的走法必须真的在本机跑得通
|
|
1205
|
+
|
|
1206
|
+
19 组探针(`$env:TEMP\pr*_<guid>`,跑完即删)的结论:
|
|
1207
|
+
|
|
1208
|
+
| 走法 | 结果 |
|
|
1209
|
+
|---|---|
|
|
1210
|
+
| `Start-Process … -NoNewWindow -PassThru -Wait -RedirectStandardOutput o -RedirectStandardError e` | 输出拿到了(`out=v24.22.1`),`.ExitCode` 恒为空,`Refresh()` 也救不回来 |
|
|
1211
|
+
| 同上**不加** `-NoNewWindow` | `$p` 是 `$null` → `You cannot call a method on a null-valued expression.` |
|
|
1212
|
+
| `[Diagnostics.Process]::Start` 带 `CreateNoWindow=$true` | rc=`-1073741502` = `0xC0000142` `STATUS_DLL_INIT_FAILED`,**弹模态错误框** |
|
|
1213
|
+
| `[Diagnostics.Process]::Start` 不设 `CreateNoWindow`、`UseShellExecute=$false` | rc 正确(`cmd /c exit 9` → 9) |
|
|
1214
|
+
| 同上但 `RedirectStandardOutput=$true` | `拒绝访问。`(受限令牌建不了匿名管道) |
|
|
1215
|
+
| **`cmd.exe /c "<exe> <args> > out 2> err"`** | **rc 与输出全对**:`node --version` → rc=0 / `v24.22.1`;`node --nonsense` → rc=9 / `node: bad option: --nonsense` |
|
|
1216
|
+
|
|
1217
|
+
所以提示改成两条可用走法 + 两条禁止。禁止那条尤其重要:`CreateNoWindow=$true` 会弹
|
|
1218
|
+
`0xC0000142` 模态框,用户在别的窗口干活时会被打断。
|
|
1219
|
+
|
|
1220
|
+
### `pwsh_run_update`
|
|
1221
|
+
|
|
1222
|
+
`action` 只有 `check` 与 `install`。`install` 走 `pluginManager.installBundle(spec, { requestId })`;
|
|
1223
|
+
返回 `failed` / `cancelled` 时把 `error.code` 与 `error.diagnostic` 原样带出,并附一行手装命令;
|
|
1224
|
+
`threw` 只覆盖「拿不到 profile 锁 / 服务销毁」两种。
|
|
1225
|
+
|
|
1226
|
+
一个容易写错的分支:`state === 'failed'`(源不可达)不能落进「已是最新」那一句。
|
|
1227
|
+
实测把源指到不存在的路径,旧写法输出的是
|
|
1228
|
+
`state: failed` + `No newer release.` —— 自相矛盾。改后输出
|
|
1229
|
+
`Could not reach the release source, so whether a newer version exists is unknown.`
|
|
1230
|
+
并且这条分支上 `doc.spec` 是 `undefined`,只能给模板而不是插值。
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
## 二十六、接管句柄必须是可继承的(2026-10-25)
|
|
1235
|
+
|
|
1236
|
+
第二十四节那版接管(`CreateFileW` 传 `IntPtr.Zero` 作安全属性)只解决了一半问题:
|
|
1237
|
+
协议流干净了,但交接给子进程的句柄**在子进程里根本不存在**。
|
|
1238
|
+
|
|
1239
|
+
### 现象
|
|
1240
|
+
|
|
1241
|
+
`test/itest.mjs` 的 `child-stdio-detached` 用例十二连跑会红一次:
|
|
1242
|
+
|
|
1243
|
+
FAIL child-stdio-detached :: sentinel=ok out=["POST"] stole=CHILD-STOLE:MZ? ?@ € ????L?This program cannot be run in DOS mode.
|
|
1244
|
+
|
|
1245
|
+
`stole` 里是一个**PE 可执行头**。子进程读继承来的 stdin,读到的不是 EOF,而是别的对象的字节。
|
|
1246
|
+
|
|
1247
|
+
### 探针结论(真实会话内,`Start-Process` 起子 pwsh 各 20–40 次)
|
|
1248
|
+
|
|
1249
|
+
| 项目 | 旧写法(`NULL` 安全属性) | 新写法(`bInheritHandle = true`) |
|
|
1250
|
+
|---|---|---|
|
|
1251
|
+
| 父进程 `GetStdHandle(-10)` | `1448`,`GetFileType` = 2(字符设备)| 同左 |
|
|
1252
|
+
| 子进程 `GetStdHandle(-10)` | `1448`(**同一个编号**),`GetFileType` = **0(UNKNOWN)** | 同一个合法句柄,`GetFileType` = 2 |
|
|
1253
|
+
| 子进程 `GetHandleInformation` | `ok=True`,`flags=0`(inherit 位没置)| `ok=True`,`flags=1` |
|
|
1254
|
+
| 子进程读 stdin | 20 次里 **19 次 0 字节、1 次 1 字节** | 30 次、40 次全 0 |
|
|
1255
|
+
|
|
1256
|
+
`GetFileType` 返回 `UNKNOWN` 是关键证据:句柄号在子进程里指向不到对象。Windows 只在
|
|
1257
|
+
`bInheritHandle` 为真时才把句柄复制进子进程的句柄表;用 `NULL` 安全属性建的句柄不可继承,
|
|
1258
|
+
于是子进程按 `STARTUPINFO.hStdInput` 拿到的是一个**悬空编号**,偶尔这个编号被别的对象(这里是某个
|
|
1259
|
+
已映射的可执行映像)复用,读出来就是别人的字节。
|
|
1260
|
+
|
|
1261
|
+
### 两个 PowerShell 侧的坑(都让第一版改法直接报错)
|
|
1262
|
+
|
|
1263
|
+
1. **`Marshal.SizeOf([type]'X+Y')` 会绑到 `SizeOf(object)` 重载**:
|
|
1264
|
+
`Type 'System.RuntimeType' cannot be marshaled as an unmanaged structure; no meaningful size or offset can be computed.`
|
|
1265
|
+
改法是把尺寸算在 C# 里:`public static int SecurityAttributesSize { get { return Marshal.SizeOf(typeof(SecurityAttributes)); } }`。
|
|
1266
|
+
2. **`Add-Type -PassThru` 返回的是类型数组**,`$K = Add-Type … -PassThru` 之后 `$K::CreateFileW` 报
|
|
1267
|
+
`[System.Object[]] does not contain a method named 'CreateFileW'`。改成 `$null = Add-Type …` 再按名字取 `[PwshRunStdio]`。
|
|
1268
|
+
这两处失败都不会让会话起不来 —— 接管段整体在 `try/catch` 里,只会在第一次调用时发一条 `W:` 警告。
|
|
1269
|
+
|
|
1270
|
+
### 钉住
|
|
1271
|
+
|
|
1272
|
+
`test/itest.mjs` 新增第 2 项 `std-handles-takeover`:起会话后第一次调用必须 `sentinel=ok` 且 **`warn` 为空**。
|
|
1273
|
+
它比 `child-stdio-detached` 更直接 —— 接管本身失败时立刻红,而不是等到那个万分之一的句柄复用。
|
|
1274
|
+
itest 15 → **16 项**,九份脚本合计 **162 项**。
|
|
1275
|
+
|
|
1276
|
+
## 二十七、无感自动更新:在 HMR 事务之外调 installBundle(2026-10-25)
|
|
1277
|
+
|
|
1278
|
+
第十九节的结论是「只查、只提示、从不自己装」,理由是「插件替换自己的文件,等于在执行的途中 dispose
|
|
1279
|
+
掉正在跑的那个实例」。**这条结论已被推翻**:装确实会让旧实例停下来,但 HMR 会在同一次重组合里把新
|
|
1280
|
+
模块 import 进来重新 apply —— 用户看到的是「什么都没发生,但它已经是新版」。
|
|
1281
|
+
|
|
1282
|
+
### 关键:上下文在 AsyncLocalStorage 里
|
|
1283
|
+
|
|
1284
|
+
`@deepseek-ai/dsh-hmr` 的 `Hmr` 用 `executing = new AsyncLocalStorage()` 标记「当前是否在事务里」:
|
|
1285
|
+
|
|
1286
|
+
```js
|
|
1287
|
+
runExclusive(operation) {
|
|
1288
|
+
if (this.executing.getStore()) return Promise.reject(new Error('HMR transactions cannot be nested'))
|
|
1289
|
+
return this.executing.run(true, operation)
|
|
1290
|
+
}
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
插件的 `apply` 是被 `reconcileProfile` 调起来的,本身就处在 `run(true, …)` 的异步延续里,所以
|
|
1294
|
+
**插件起的定时器里也带着这个标记**(探针实测:`apply store=true`、`tick store=true`)。于是从定时器
|
|
1295
|
+
里直接调 `installBundle` 会走到 `change()` → `runExclusive()`,被嵌套检查拒掉。
|
|
1296
|
+
|
|
1297
|
+
最坏的地方在于**拒绝发生在 pnpm 已经跑完之后**:
|
|
1298
|
+
|
|
1299
|
+
```
|
|
1300
|
+
install ms=2413 {"application":"failed","error":{"code":"operation-error",
|
|
1301
|
+
"diagnostic":"HMR transactions cannot be nested"}}
|
|
1302
|
+
磁盘 dependencies: - dsh-pwsh-plugin 0.2.3 + dsh-pwsh-plugin 0.2.2
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
磁盘成了新版、进程里还是旧版,而且 `pwsh_run_update action=check` 报的是**磁盘版本**(`packageVersion()`
|
|
1306
|
+
按 `import.meta.url` 现读 package.json),看起来一切正常。这是本轮最容易骗过自己的一种状态。
|
|
1307
|
+
|
|
1308
|
+
### 解法:先把上下文清空再调
|
|
1309
|
+
|
|
1310
|
+
```js
|
|
1311
|
+
export async function installReleaseLive(ctx, spec, options = {}) {
|
|
1312
|
+
const manager = ctx?.get?.('pluginManager')
|
|
1313
|
+
const hmr = ctx?.get?.('hmr')
|
|
1314
|
+
if (hmr === undefined || typeof hmr?.executing?.run !== 'function') {
|
|
1315
|
+
return await installRelease(manager, spec, { ...options, live: false })
|
|
1316
|
+
}
|
|
1317
|
+
return await hmr.executing.run(undefined, () => installRelease(manager, spec, { ...options, live: true }))
|
|
1318
|
+
}
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
`AsyncLocalStorage.run(undefined, cb)` 把 store 置空,**而且这个清空会跟着异步延续走下去**(探针里
|
|
1322
|
+
`await` 之后 `store` 仍是 `undefined`)。installBundle 于是能自己拿 `runExclusive`,走完
|
|
1323
|
+
`publishAndReload` → `reconcileProfile`。
|
|
1324
|
+
|
|
1325
|
+
重组合之所以真的换掉模块,是因为 `ModuleFiles.compare()` 拿 `inode:size:mtime` 与已加载的记录比对,
|
|
1326
|
+
新装的文件当然对不上 —— 与「bundle 行 URL 不变所以 ESM 缓存命中」无关,那一层判断在更前面就结束了。
|
|
1327
|
+
|
|
1328
|
+
### 端到端实测(决定性)
|
|
1329
|
+
|
|
1330
|
+
| 步骤 | 结果 |
|
|
1331
|
+
|---|---|
|
|
1332
|
+
| 在事务里直接调 `installBundle` | `application: failed`、`HMR transactions cannot be nested`,磁盘却已变 |
|
|
1333
|
+
| 在 `executing.run(undefined, …)` 里调 | `application: applied`、3558 ms 返回 |
|
|
1334
|
+
| 装完立即问新代码 | 装件 `index.mjs` 里人为加的 `HMRMARK` **出现在下一句输出里** |
|
|
1335
|
+
| 手工 `reconcileProfile` 路径 | `hmr.executing.run(undefined, () => hmr.runExclusive(() => hmr.reconcileProfile()))` → 137 ms,`{pending:[]}`,同样看到 `HMRMARK` |
|
|
1336
|
+
|
|
1337
|
+
### 循环的形状
|
|
1338
|
+
|
|
1339
|
+
`plugin/auto-update.mjs`:`FIRST_DELAY_MS = 90s`、`INTERVAL_MS = 12h`、`RETRY_BASE_MS = 5min`、
|
|
1340
|
+
`RETRY_MAX_MS = 2h`。一轮的动作是 `checker.check()` → `state === 'available'` → 用
|
|
1341
|
+
`checker.document().spec`(钉住 tag 的规格)装 → 成功清零退避、失败撤掉「装过」的记录并按
|
|
1342
|
+
`retryBaseMs * 2 ** (failures - 1)` 重排。
|
|
1343
|
+
|
|
1344
|
+
两个必要的细节:
|
|
1345
|
+
|
|
1346
|
+
1. **记住装过哪些版本**(`attempted` 这个 Set)。没有 HMR 的宿主上装完磁盘是新的、进程还是旧的,
|
|
1347
|
+
下一轮照样报 `available`,不记就会每 12 小时重装一次同一个版本。
|
|
1348
|
+
2. **关掉「只提示」那条定时器**。`UpdateChecker.start()` 自带 12 小时周期,自动更新循环也自己调
|
|
1349
|
+
`check()`,两边都开着等于每轮查两次。现在 `autoUpdate === true` 时不起 `checker.start()`。
|
|
1350
|
+
|
|
1351
|
+
提示语也随之改了口径:只有 `autoUpdate === false` 时才在 `pwsh_run` 输出末尾挂「有新版本」那一行,
|
|
1352
|
+
文案改成 `Auto-update is off in this profile (config.autoUpdate: false).` —— 开着自动更新还提示,
|
|
1353
|
+
等于让模型以为要人工介入。
|
|
1354
|
+
|
|
1355
|
+
### 测试
|
|
1356
|
+
|
|
1357
|
+
`test/autoupdatetest.mjs`(新,26 项,纯 node、无网络):发布源是临时目录里的 `git init` + `v0.0.1` /
|
|
1358
|
+
`v0.0.2` 两个 tag(`git+file://` 形态),假 HMR 用 `new AsyncLocalStorage()` 复刻嵌套检查,
|
|
1359
|
+
假 pluginManager 记录每次安装时 `executing.getStore()` 的值 —— 「事务里发起也必须清空上下文」这条
|
|
1360
|
+
行为因此被钉住,而不只是被注释声明。
|
|
1361
|
+
|
|
1362
|
+
`test/wiringtest.mjs` 跟着改了语义:提示那一节现在显式 `autoUpdate: false`,另加一节断言
|
|
1363
|
+
`autoUpdate: true` 时**不发**提示。
|
|
1364
|
+
|
|
1365
|
+
十份脚本合计 **190 项**(itest 16 / plugintest 15 / e2e 8 / sandboxtest 7 / bashtest 19 / bash-e2e 11 /
|
|
1366
|
+
updatetest 66 / autoupdatetest 26 / wiringtest 11 / denialtest 11)。第二十八节加了两节共 11 项,
|
|
1367
|
+
当前总数以那一节为准。
|
|
1368
|
+
|
|
1369
|
+
### 没有 HMR 就完全不装
|
|
1370
|
+
|
|
1371
|
+
第一版是无条件装的:没有 HMR 的宿主上,装完磁盘是新的、进程里仍是旧的,下一轮照样报 `available`,
|
|
1372
|
+
于是每 12 小时重装一次同一个版本(靠 `attempted` 这个 Set 挡住)。挡住之后仍然不对 ——
|
|
1373
|
+
`pwsh_run_update action=check` 报的是**磁盘**版本,用户会以为升级成功了。
|
|
1374
|
+
|
|
1375
|
+
所以循环里加了一道门:`hmrIsLive(ctx)` 为假时只记一行日志、不装。判断条件与 `installReleaseLive` 用的
|
|
1376
|
+
是同一组(`ctx.get('hmr')` 存在且 `executing.run` 是函数),两处必须一致,否则会出现「循环以为装得上、
|
|
1377
|
+
安装却退回非 live」的错配。人手动按 `install` 时不做这道门 —— 人知道要重启。
|
|
1378
|
+
|
|
1379
|
+
### 一条已知取舍
|
|
1380
|
+
|
|
1381
|
+
自动更新一旦跑起来,就没有「先问问」这一步 —— 这是本设计的目的(给不盯版本的人用)。不想要的人在
|
|
1382
|
+
profile 的插件配置里写 `autoUpdate: false`,行为即回到第十九节描述的样子。
|
|
1383
|
+
### 一处必须写进文档的代价:会话不挺过升级
|
|
1384
|
+
|
|
1385
|
+
「无感」这个词只对**代码**成立,对**会话**不成立。替换插件会先把它停掉,
|
|
1386
|
+
`plugin/index.mjs` 第 275 行的 `ctx.effect(() => () => { … session.dispose() })` 随即销毁所有会话,
|
|
1387
|
+
所以真正装上新版的那一次,常驻 pwsh 进程已经不在了。
|
|
1388
|
+
|
|
1389
|
+
实测(v0.2.3 → v0.2.4 的一次自动安装):
|
|
1390
|
+
|
|
1391
|
+
| 观测点 | 安装前 | 安装后 |
|
|
1392
|
+
|---|---|---|
|
|
1393
|
+
| `$PID` | 24820 | **15632** |
|
|
1394
|
+
| 安装前设的 `$global:probe` | `set-once` | **空** |
|
|
1395
|
+
|
|
1396
|
+
第一版判据写错了,得出过相反的结论:拿 `pwsh_run` 的描述里是否含 `HMR service` 当「新版在跑」的依据,
|
|
1397
|
+
而那句话其实在 `pwsh_run_update` 的描述里(`plugin/index.mjs:332`)—— 于是无论怎么装都读到
|
|
1398
|
+
`older`,看起来像「装了没生效」。换成 `pwsh_run_update` 的描述后同一实验立刻给出正确结果:
|
|
1399
|
+
降级到 v0.2.3 后运行态变旧、再升回 v0.2.4 后运行态变新。**探针的判据指错字段,是这一轮唯一浪费的
|
|
1400
|
+
时间**;记在这里,免得下次再犯。
|
|
1401
|
+
|
|
1402
|
+
另有一条相关观察:插件在自己发起的重组合里被 stop 掉,`await` 它的 fiber 时并不会把调用方一起拖死
|
|
1403
|
+
(探针 111 ms 就返回了),因为 reconciler 停的是旧 fiber,而发起的代码属于被驱逐的旧模块,仍在
|
|
1404
|
+
旧的执行栈上跑完。
|
|
1405
|
+
|
|
1406
|
+
## 二十八、自动更新不许打断正在跑的调用(2026-10-25)
|
|
1407
|
+
|
|
1408
|
+
### 问题
|
|
1409
|
+
|
|
1410
|
+
第二十七节把「无感」做实之后,出现一个新的危险:热替换会执行本插件注册的 effect 清理函数,
|
|
1411
|
+
而那里的第一件事是 `session.dispose()`。若此刻正有一条 `pwsh_run` 调用在跑,它会被连人带马掐断 ——
|
|
1412
|
+
脚本写了一半、调用结束时拿不到状态、会话里的变量与目录全失。用户的话说得很准:*「等于直接断电」*。
|
|
1413
|
+
|
|
1414
|
+
### 解法:安装闸门
|
|
1415
|
+
|
|
1416
|
+
`plugin/index.mjs` 里新增两个变量,作用域在 `apply` 内(热替换后是新实例,各有一份):
|
|
1417
|
+
|
|
1418
|
+
```js
|
|
1419
|
+
let activeRuns = 0
|
|
1420
|
+
let installing = false
|
|
1421
|
+
```
|
|
1422
|
+
|
|
1423
|
+
`pwsh_run` 的注册体变成一层薄包装,只负责加减计数,实体挪到 `runScript`:
|
|
1424
|
+
|
|
1425
|
+
```js
|
|
1426
|
+
async execute(args, exec) {
|
|
1427
|
+
activeRuns += 1
|
|
1428
|
+
try { return await runScript(args, exec) }
|
|
1429
|
+
finally { activeRuns -= 1 }
|
|
1430
|
+
}
|
|
1431
|
+
```
|
|
1432
|
+
|
|
1433
|
+
闸门本体:
|
|
1434
|
+
|
|
1435
|
+
```js
|
|
1436
|
+
const beginInstall = () => {
|
|
1437
|
+
if (installing || activeRuns > 0) return false
|
|
1438
|
+
installing = true
|
|
1439
|
+
return true
|
|
1440
|
+
}
|
|
1441
|
+
const endInstall = () => { installing = false }
|
|
1442
|
+
const installGate = { tryAcquire: beginInstall, release: endInstall }
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
检查与置位必须在同一个同步块里完成。若拆成「先问、await 点什么、再置位」,中间就能挤进一条新调用,
|
|
1446
|
+
于是刚判断完空闲就被打断 —— 这正是要防的时序空档。
|
|
1447
|
+
|
|
1448
|
+
### 三条接线
|
|
1449
|
+
|
|
1450
|
+
| 位置 | 行为 |
|
|
1451
|
+
|---|---|
|
|
1452
|
+
| 自动循环 `startAutoUpdate` | `installGate?.tryAcquire()` 返回 false 时整轮推迟,`busyRetryMs`(默认 3 分钟)后再看,而不是等 12 小时。「忙,先不装」只记一次日志。 |
|
|
1453
|
+
| `pwsh_run_update action: "install"` | 拿不到闸门就报 `install: postponed` 并说明原因,**不动 profile**。 |
|
|
1454
|
+
| 安装进行中到达的 `pwsh_run` | `waitForInstall()` 每 100 ms 轮询、最多等 120 秒;期间不会去建一个马上要被 dispose 的会话。 |
|
|
1455
|
+
|
|
1456
|
+
`tryAcquire` 用可选链调用,缺省时(`options.installGate` 未传)视为「不设闸门」—— 只有测试与
|
|
1457
|
+
其它复用方会这么用。
|
|
1458
|
+
|
|
1459
|
+
### 测试
|
|
1460
|
+
|
|
1461
|
+
`test/autoupdatetest.mjs` 新增 [3b] 节 6 项:忙时一次都不装、日志说清在等安静、忙说明只记一次、
|
|
1462
|
+
闲下来就装、装了才拿到闸门、装完把闸门还回去 —— 其中 `installGate` 是记录 `acquired` / `released`
|
|
1463
|
+
的假闸门。26 → 32 项。
|
|
1464
|
+
|
|
1465
|
+
`test/wiringtest.mjs` 新增 [1c] 节 5 项:用真 `pwsh` 起一条 `Start-Sleep -Milliseconds 1500` 的调用,
|
|
1466
|
+
在它跑着的时候按 `pwsh_run_update action: "install"`,断言 `install: postponed`、断言 profile
|
|
1467
|
+
一次都没被动过(假 `pluginManager` 的 `attempts` 为空);等那条调用结束后再按一次,断言这次真装上了。
|
|
1468
|
+
`boot()` 因此扩了两个能力:可注入服务(`services`),以及同时抓取 `pwsh_run` 与 `pwsh_run_update`
|
|
1469
|
+
两个注册。11 → 16 项。
|
|
1470
|
+
|
|
1471
|
+
回归:十份脚本合计 **201 项**(itest 16 / plugintest 15 / e2e 8 / sandboxtest 7 / bashtest 19 /
|
|
1472
|
+
bash-e2e 11 / updatetest 66 / autoupdatetest 32 / wiringtest 16 / denialtest 11);第二十九节又加了
|
|
1473
|
+
38 项,当前总数以那一节为准。
|
|
1474
|
+
|
|
1475
|
+
### 为什么不用「先提示、再等下一次」那套
|
|
1476
|
+
|
|
1477
|
+
那正是 `autoUpdate: false` 的行为,也正是用户不想要的那套:要人盯着、要人点。把「等安静」做进安装
|
|
1478
|
+
路径,既保住了无感,又不欠任何人一笔「刚才那条被打断了」的债。
|
|
1479
|
+
|
|
1480
|
+
## 二十九、装之前先看一眼:`already-installed` 的三种成因与预检(2026-10-25)
|
|
1481
|
+
|
|
1482
|
+
### 症状
|
|
1483
|
+
|
|
1484
|
+
`pwsh_run_update action: "install"` 有时会回一行 `install: failed` + `pwsh_run could not install the release
|
|
1485
|
+
(already-installed). …`。看着像出错,其实 profile 一个字节都没被改动 —— 这句来自 DSH 的插件管理器,
|
|
1486
|
+
不是本插件。
|
|
1487
|
+
|
|
1488
|
+
### 源码位置(asar 提取,逐字)
|
|
1489
|
+
|
|
1490
|
+
`@deepseek-ai/dsh-plugin-manager/lib/index.js:1991`:
|
|
1491
|
+
|
|
1492
|
+
```js
|
|
1493
|
+
if (this.ownerContext.get("hmr") !== void 0 && known !== void 0 && Object.hasOwn(before, known)) throw new ManagementFailure("already-installed");
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
抛出点在 `pnpm` **之前**,紧跟 `const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {}`。
|
|
1497
|
+
同文件 `:1865` 与 `:1870` 是 `inspect()` 里同名的 `refused("already-installed", …)` 分支。
|
|
1498
|
+
判定用的是 `specPackageName()`(`:1373-1379`)—— 把要装的 spec 与 profile 里为该包记下的 spec
|
|
1499
|
+
(经 `dependencySpec()` 规范化,`:135-144`)逐字比对。**两者完全一致就拒**。
|
|
1500
|
+
|
|
1501
|
+
### 三种成因(用真 `parseInstallSpec` 复刻过)
|
|
1502
|
+
|
|
1503
|
+
| 场景 | profile 里记的 spec | 要装的 spec | 会被拒吗 |
|
|
1504
|
+
|---|---|---|---|
|
|
1505
|
+
| 正常升级:进程旧、磁盘也旧 | `#v0.2.4` | `#v0.2.6` | 否 |
|
|
1506
|
+
| 终端 `dsh plugin add` 装过、进程没重启 | `#v0.2.6` | `#v0.2.6` | **是** |
|
|
1507
|
+
| 发版忘了升 `package.json` 的 `version` | `#v0.2.7` | `#v0.2.7` | **是** |
|
|
1508
|
+
|
|
1509
|
+
前两种都不该发起安装:第一种装完就会生效,第二种装了也是同一份字节。第三种最阴 —— 标签变了、声明
|
|
1510
|
+
的版本没变,于是每一轮检查都判 `available`、每一轮都想装、每一轮都被拒。`attempted` 这个「装过别
|
|
1511
|
+
再装」的集合建在 `startAutoUpdate()` 里,热替换重建后清空,所以退避会一路爬到 2 小时封顶,从此每 2 小时
|
|
1512
|
+
重装一次同样的字节,**且只写 `ctx.logger.info`** —— 本机 `data/logs` 里 2026-10-25 一条 pwsh-run 日志
|
|
1513
|
+
都没有落盘,对用户完全静默。
|
|
1514
|
+
|
|
1515
|
+
### 解法:安装前拿三个已有的事实比对
|
|
1516
|
+
|
|
1517
|
+
`plugin/update-apply.mjs` 新增纯函数 `checkInstallNeed({ latest, disk, running, spec, installed })`,
|
|
1518
|
+
`preflightInstall(ctx, checker, spec)` 负责从 `ctx.get("profileContext").dir` 读出 `installed`、
|
|
1519
|
+
从 `checker.version` 取 `running`、从 spec 的 `#` 之后取 `latest`。返回四种之一:
|
|
1520
|
+
|
|
1521
|
+
| 返回 | 含义 | 动作 |
|
|
1522
|
+
|---|---|---|
|
|
1523
|
+
| `null` | 值得装 | 照常安装 |
|
|
1524
|
+
| `already-current`(`ok: true`) | 磁盘与进程都已是这一版 | 不装 |
|
|
1525
|
+
| `restart-required`(`ok: true`) | 磁盘已是这一版,进程还跑旧的 | 不装,直接说「重启就生效」 |
|
|
1526
|
+
| `broken-release`(`ok: false`) | 标签存在但包内版本没抬,profile 已指向该标签 | 不装,直接说「这个发布是坏的」 |
|
|
1527
|
+
|
|
1528
|
+
`describeInstallOutcome` 的 `failed` 分支前面加了 `already-installed` 的兜底:即便预检漏网(例如别的会话
|
|
1529
|
+
抢先装好了),也不再报成失败,而是说「已经装好了,重启就能用」。
|
|
1530
|
+
|
|
1531
|
+
### 三处接线与测试
|
|
1532
|
+
|
|
1533
|
+
| 位置 | 做法 |
|
|
1534
|
+
|---|---|
|
|
1535
|
+
| `plugin/index.mjs` 的 `pwsh_run_update` install 分支 | 在 `beginInstall()` **之前**调 `preflightInstall`,非 `null` 就带着 `install: <outcome>` 直接返回 —— 连安装闸门都不碰 |
|
|
1536
|
+
| `plugin/auto-update.mjs` 的 tick | 预检非 `null` 时把该版本写进 `attempted`(这三种成因都不会自己好转),记一行日志,`ok === true` 时清空 `failures`,本轮结束 |
|
|
1537
|
+
| `describeInstallOutcome` | `already-installed` → `ok: true` 的措辞(兜底) |
|
|
1538
|
+
|
|
1539
|
+
- `test/updatetest.mjs` 新增 [14] 节 27 项(tag 解析、四种返回与文案、`readInstalledSpec` 五种读法含坏
|
|
1540
|
+
JSON、`preflightInstall` 的接线),69 → 96 项。
|
|
1541
|
+
- `test/autoupdatetest.mjs` 新增 [3c] 节 6 项(坏了不去装 / 日志点明 / 不反复重试;磁盘已是最新就不装 /
|
|
1542
|
+
说清要重启 / 同一版本不重复试),32 → 38 项。
|
|
1543
|
+
- `test/wiringtest.mjs` 新增 [1d] 节 5 项(假 profile 钉着坏发布的 tag → `install: broken-release` 且
|
|
1544
|
+
`attempts` 为 0;换成别的 tag → `install: applied` 且真调了一次安装器),16 → 21 项。
|
|
1545
|
+
|
|
1546
|
+
回归:十份脚本合计 **242 项**(itest 17 / plugintest 15 / e2e 8 / sandboxtest 7 / bashtest 19 /
|
|
1547
|
+
bash-e2e 11 / updatetest 96 / autoupdatetest 38 / wiringtest 21 / denialtest 11);第三十节又加了 1 项,
|
|
1548
|
+
当前总数以那一节为准。
|
|
1549
|
+
|
|
1550
|
+
### 两道防线,分工不同
|
|
1551
|
+
|
|
1552
|
+
`scripts/release-check.mjs` 在**推标签之前**跑:工作区不干净、HEAD 的 tag 与 `package.json` 不符、tag
|
|
1553
|
+
指向的提交里版本不同、要重指一个已存在的 tag —— 四种都拒。运行时这道预检则在**之后**,在每一台试图
|
|
1554
|
+
升级的机器上再拦一次。前者防住自己,后者防住别人(也包括自己某次绕过检查直接 `git tag`)。
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
## 三十、常驻会话里子进程的中文是乱码(2026-10-25)
|
|
1558
|
+
|
|
1559
|
+
### 症状
|
|
1560
|
+
同一条命令在两个工具里结果不同:
|
|
1561
|
+
|
|
1562
|
+
| 命令 | 原生 `pwsh` 工具 | `pwsh_run` |
|
|
1563
|
+
|---|---|---|
|
|
1564
|
+
| `node -e "console.log('中文 / café')"` | `中文 / café` | `涓枃 / caf茅` |
|
|
1565
|
+
| `cmd /c echo 中文` | `中文` | `中文` |
|
|
1566
|
+
|
|
1567
|
+
只有 **UTF-8 输出的原生 exe**(node、git、gh、python)坏掉,`cmd` 的 GBK 输出反而正常 —— 这说明不是
|
|
1568
|
+
管道坏了,是**解码用的代码页选错了**。
|
|
1569
|
+
|
|
1570
|
+
### 根因
|
|
1571
|
+
PowerShell 用 `[Console]::OutputEncoding` 解码原生 exe 的 stdout 字节。这个值默认跟随系统 ANSI 代码页,
|
|
1572
|
+
中文 Windows 上是 **gb2312 / 936**;而 node、git、gh 都按 UTF-8 写字节。原生的 `pwsh` 工具没这个问题,
|
|
1573
|
+
因为 `@deepseek-ai/dsh-pwsh-local/lib/index.js:100` 的 `ENCODING_PREAMBLE` 在每一行命令前塞了:
|
|
1574
|
+
|
|
1575
|
+
```js
|
|
1576
|
+
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
|
|
1577
|
+
```
|
|
1578
|
+
|
|
1579
|
+
常驻会话没有这句 —— `plugin/session-server.ps1` 只在协议流上用了 UTF-8(`$Utf8` 给 `StreamReader` /
|
|
1580
|
+
`StreamWriter`),从没碰过 `Console.OutputEncoding`。
|
|
1581
|
+
|
|
1582
|
+
### 修法
|
|
1583
|
+
`plugin/session-server.ps1` 开头、`$Utf8` 定义之后加两句(外加一段说明为什么):
|
|
1584
|
+
|
|
1585
|
+
```powershell
|
|
1586
|
+
[Console]::OutputEncoding = $Utf8
|
|
1587
|
+
$OutputEncoding = $Utf8
|
|
1588
|
+
```
|
|
1589
|
+
|
|
1590
|
+
安全:实测 `[Console]::SetOut($mem)` 之后设 `OutputEncoding` **不会**把 `Console.Out` 换回真 stdout
|
|
1591
|
+
(`ReferenceEquals($w1, $w3)` 为 `True`,`$mem.BaseStream.Length` 仍是 0)—— 第 2 条「Console 隔离」
|
|
1592
|
+
那层加固不受影响。
|
|
1593
|
+
|
|
1594
|
+
### 测试
|
|
1595
|
+
`test/itest.mjs` 新增第 9b 项 `child-utf8-output`:用 `process.execPath`(测试自己的 node)跑
|
|
1596
|
+
`console.log('中文 / café')`,断言输出里两个串都在。把服务端那两句删掉,只有这一行会红。
|
|
1597
|
+
itest 16 → **17**,十份合计 **243 项**。
|
|
1598
|
+
|
|
1599
|
+
### 一并记下的观察
|
|
1600
|
+
`[Console]::OutputEncoding` 在同一进程里被改过之后,改回来也没用 —— 会话是「设一次、全场生效」的模型,
|
|
1601
|
+
所以必须写在服务端启动处,不能靠每次调用前的前导。这与原生工具每行塞前导的做法不同:常驻会话只有一次
|
|
1602
|
+
启动机会,前导会跨调用累积副作用(第 5 条已知代价)。
|
|
1603
|
+
## 三十一、`bash` 的可执行文件要能在 PATH 坏掉时兜住(2026-10-26)
|
|
1604
|
+
|
|
1605
|
+
### 症状
|
|
1606
|
+
进程继承到的环境块可能早于某次 PATH 修改(`explorer.exe` 起于 10-22 21:21:53,10-26 的误删事故之后没有重建过环境),
|
|
1607
|
+
于是 `process.env.PATH` 只有 **615 字符**:user PATH 20 项 + 一个 pwsh 目录,**整个 Machine PATH 都没进来**。
|
|
1608
|
+
`bash` 工具因此每次都报 `subprocess-local: command "bash" was not found on PATH`,而 `C:\Windows\System32\bash.exe` 本身是好的:
|
|
1609
|
+
|
|
1610
|
+
```
|
|
1611
|
+
C:\Windows\System32\bash.exe -s → BASH-ABS-OK
|
|
1612
|
+
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/usr/lib/wsl/lib:...
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
注册表里的 Machine PATH 是完好的(453 字符,含 system32);`Start-Process -UseNewEnvironment` 从注册表重建后
|
|
1616
|
+
`len=1011 hasSystem32=True`,继承当前环境则是 `len=615 hasSystem32=False`。**坏的只有运行中进程的那份拷贝。**
|
|
1617
|
+
|
|
1618
|
+
### 修法
|
|
1619
|
+
`bashPath` 默认仍是裸名 `bash`,但解不出来时按顺序再试 `C:\Windows\System32\bash.exe`。三条约束:
|
|
1620
|
+
|
|
1621
|
+
1. **只在配置是裸名时启用后备。** 显式写了绝对 `bashPath` 就只认那一个 —— 写错了应当响亮地失败,不能被悄悄换成另一份。
|
|
1622
|
+
2. **成功才缓存,失败不缓存。** PATH 修好之后下一次调用立刻能用,不必重启 DSH。
|
|
1623
|
+
3. **裸名能解出来就不碰后备。** 多问一次会掩盖 PATH 的真实状态,也让「第一个胜出」这条语义变得不可测。
|
|
1624
|
+
|
|
1625
|
+
### 钉住(`test/bashtest.mjs` 新增三条)
|
|
1626
|
+
|
|
1627
|
+
| 断言 | 钉的是什么 |
|
|
1628
|
+
|---|---|
|
|
1629
|
+
| `bash-fallback-used` | 裸名失败 → 后备被调用 → 命令真的跑起来了 |
|
|
1630
|
+
| `bash-path-first` | 裸名成功时 `resolveExecutable` 只被问一次,值就是 `bash` |
|
|
1631
|
+
| `bash-explicit-not-fallback` | 显式绝对路径失败时直接抛错,且候选列表里只有它一个 |
|
|
1632
|
+
|
|
1633
|
+
### 一条诚实说明
|
|
1634
|
+
这道后备只救 `bash` 工具。`cmd`、`fsutil` 这类靠 PATH 找的可执行文件依旧找不到 —— 它们没有写死的备选位置。
|
|
1635
|
+
真正的解法是重建 `explorer.exe` 的环境块(注销重新登录),那一步要用户挑时机。
|
|
1636
|
+
## 三十二、npm 打包在 Windows 上丢执行位:探测自己补(2026-10-27)
|
|
1637
|
+
|
|
1638
|
+
### 症状
|
|
1639
|
+
发布包在 Windows 上打,tar 里没有「执行位」这个概念,于是 `wsl/llrun` 落到用户机器上是 **`0644`**:
|
|
1640
|
+
|
|
1641
|
+
```
|
|
1642
|
+
000644 17328 package/wsl/llrun ← 不可执行
|
|
1643
|
+
000644 13376 package/wsl/llrun.c
|
|
1644
|
+
000644 16706 package/wsl/custom-bash.mjs
|
|
1645
|
+
```
|
|
1646
|
+
|
|
1647
|
+
本机一直没人发现,是因为仓库在工作区 `/mnt/d`(9p 挂载一律 `rwxrwxrwx`),开发机上又真装过 llrun,
|
|
1648
|
+
前四个候选总有命中的。而在真实 Linux 上用 registry 装的用户,五个候选全落空,
|
|
1649
|
+
`bash` 工具在 workspace-write / read-only 下 **直接 fail-closed,完全不能用**。
|
|
1650
|
+
|
|
1651
|
+
### 第一次修法的错误
|
|
1652
|
+
第一版把「存在但不可执行」写成紧跟在每个候选后面的一段 `if [ -e ... ]; then exit 100+N`。
|
|
1653
|
+
后果是**一个无关的文件就能把后面能用的候选挡住** —— 探测按序号取第一个命中,第 101 号会先于第 2 号返回。实测:
|
|
1654
|
+
|
|
1655
|
+
```
|
|
1656
|
+
候选 = [/nope/llrun, /etc/hostname, <可用>]
|
|
1657
|
+
旧版 → exit=102 ← 报「第二个存在但不可执行」,可用的第三个根本没被问到
|
|
1658
|
+
新版 → exit=3 ← 可用那个胜出
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
改成**两遍扫描**:第一遍只看「能不能跑」,任何一个可用立即定下来;全部否定之后,第二遍才找
|
|
1662
|
+
「存在但不可执行」的那一个。这样顺序语义没被「诊断信息」污染。
|
|
1663
|
+
|
|
1664
|
+
### 最终实现(`wsl/custom-bash.mjs` 的 `llrunProbeScript`)
|
|
1665
|
+
第一遍每个候选两行:
|
|
1666
|
+
|
|
1667
|
+
```bash
|
|
1668
|
+
if [ -x "<path>" ]; then exit "$index"; fi
|
|
1669
|
+
if [ -f "<path>" ] && [ -O "<path>" ] && chmod +x "<path>" 2>/dev/null && [ -x "<path>" ]; then exit "$index"; fi
|
|
1670
|
+
```
|
|
1671
|
+
|
|
1672
|
+
`-O`(属主是自己)是这道门的关键:包内那份在我们自己的 `node_modules` 里,补执行位不需要任何权限;
|
|
1673
|
+
而 `/usr/local/bin` 那种 root 的文件、或别人的文件,一律不碰。失败就落到第二遍,被指名道姓地报出来。
|
|
1674
|
+
|
|
1675
|
+
### 实测(ext4 `/tmp`,不是 9p)
|
|
1676
|
+
|
|
1677
|
+
| 场景 | 结果 |
|
|
1678
|
+
|---|---|
|
|
1679
|
+
| `644` 且属主是自己 | `chmod` 成功,`644 → 755`,命令真的跑起来 |
|
|
1680
|
+
| `644` 但属主是 `nobody`(`/etc/hostname`) | 权限位不变,退出码 `101` |
|
|
1681
|
+
| 不可执行在前、可用在后 | 退出码 `2`(可用那个胜出,不被挡住) |
|
|
1682
|
+
| 全不存在 | 退出码 `0` |
|
|
1683
|
+
| 从 tar 解包(真 `0644`)后直接 new 一个插件实例 | `FRESH-INSTALL-FENCED-OK`,之后权限 `755` |
|
|
1684
|
+
|
|
1685
|
+
### 钉住(`test/bashtest.mjs` 22 → 25)
|
|
1686
|
+
`llrun-chmod-on-probe` / `llrun-unusable-named` / `llrun-unusable-later-wins`。
|
|
1687
|
+
第一项只在 POSIX 宿主上断言权限位:NTFS 上 `statSync().mode` 恒为 `0666`,那里只验「命令跑通了」。
|
|
1688
|
+
|
|
1689
|
+
### 顺带修掉的四处测试自身缺陷
|
|
1690
|
+
这一轮为了能在 WSL 里跑通整套,暴露了 bashtest 里几处**只在 Windows 侧成立**的写法:
|
|
1691
|
+
|
|
1692
|
+
| 缺陷 | 后果 | 修法 |
|
|
1693
|
+
|---|---|---|
|
|
1694
|
+
| `toWslPath(WS)` 在 WSL 侧返回 `undefined` | `echo x > undefined` 在工作区里造出一个叫 `undefined` 的文件,而「越界被拒」的断言看的是另一个路径 → **假绿** | 加 `asPosix()`,已是 POSIX 就原样用 |
|
|
1695
|
+
| 磁盘断言写死 `WS + '\\name'` | WSL 侧 `existsSync` 永远 false | 用 `join()` 拼,或按平台取路径形式 |
|
|
1696
|
+
| `workdir-windows` / 三个 bash 后备项 | WSL 侧起不了 `cwd=D:\\`、也没有 `bash.exe` | 非 win32 显式 `SKIP`,不冒充通过 |
|
|
1697
|
+
| llrun 的两行 `warning:` 混进合并输出 | 空输出、无超时两项误判 | `stripWarnings()` 剥掉 `llrun: warning:` 开头的行 |
|
|
1698
|
+
|
|
1699
|
+
另外 `full-no-fence` 一项在宿主本身已被围栏时无法验证(danger-full-access 也写不出去),
|
|
1700
|
+
改成先自己试写一次,写不出去就 `SKIP`。
|
|
1701
|
+
|
|
1702
|
+
### 验收
|
|
1703
|
+
十一份脚本在 Windows node 下 **305 项全绿**;bashtest 在 WSL 侧另跑到 `TOTAL pass=20 fail=0`(四项按平台跳过)。
|
|
1704
|
+
|
|
1705
|
+
---
|
|
1706
|
+
|
|
1707
|
+
## 三十三、发布与更新改走 npm registry(2026-10-28)
|
|
1708
|
+
|
|
1709
|
+
### 为什么换
|
|
1710
|
+
|
|
1711
|
+
原来更新检查读的是 GitHub 的 `GET /repos/<repo>/tags`,安装规格是 `github:owner/repo#vX.Y.Z`。
|
|
1712
|
+
包既然已经发在 npm 上(第三十二节之后),读 tag 就成了第二个真相源:同一个版本在两处分别可达,
|
|
1713
|
+
而两边谁先谁后没有任何约束。而且 `github:` 规格装的是仓库快照,不经过 pnpm 的供应链策略
|
|
1714
|
+
(`minimumReleaseAge`)—— 绕开安全网的路不该是默认路。
|
|
1715
|
+
|
|
1716
|
+
现在只有一个真相源:`dist-tags.latest`。
|
|
1717
|
+
|
|
1718
|
+
### 检查:读 abbreviated 文档
|
|
1719
|
+
|
|
1720
|
+
`GET https://registry.npmjs.org/dsh-pwsh-plugin`,请求头 `accept: application/vnd.npm.install-v1+json`。
|
|
1721
|
+
实测这份文档 200 时形状是:
|
|
1722
|
+
|
|
1723
|
+
```json
|
|
1724
|
+
{ "dist-tags": { "latest": "0.2.12" },
|
|
1725
|
+
"versions": { "0.0.0-stage": {...}, "0.2.12": { "version": "0.2.12", "dist": { "shasum": "1ca2d9f0…" } } } }
|
|
1726
|
+
```
|
|
1727
|
+
|
|
1728
|
+
三条判决规则:
|
|
1729
|
+
|
|
1730
|
+
| 情形 | 判决 |
|
|
1731
|
+
|---|---|
|
|
1732
|
+
| `dist-tags.latest` 是纯 `X.Y.Z` 且高于当前 | `available` |
|
|
1733
|
+
| `dist-tags.latest` 被标了 `deprecated` | `current`,并把被废弃的版本报出来(不推一个官方已劝退的版本)|
|
|
1734
|
+
| 没有 `dist-tags` | 从版本清单里挑最高**正式版**(预发布跳过)|
|
|
1735
|
+
|
|
1736
|
+
`404` 单独成一条文案:`not published on <registry>`。它不是「没有新版本」——一个是配置或发版出了问题,
|
|
1737
|
+
一个是正常工作状态,混在一起会把事故说成安静。
|
|
1738
|
+
|
|
1739
|
+
**abbreviated 文档里没有 `time` 字段**(实测 `time keys` 为空),所以检查阶段无法得知发布时间;
|
|
1740
|
+
冷却期只能靠 pnpm 自己的策略,见下。
|
|
1741
|
+
|
|
1742
|
+
### 安装:回读磁盘,不信返回值
|
|
1743
|
+
|
|
1744
|
+
`installBundle` 的返回里**没有 version 字段**(`application` 只有
|
|
1745
|
+
`'applied' | 'restart-required' | 'failed' | 'cancelled'`)。装到哪个版本只能从
|
|
1746
|
+
`<profileDir>/node_modules/dsh-pwsh-plugin/package.json` 回读。
|
|
1747
|
+
|
|
1748
|
+
这不是洁癖。pnpm 的 `minimumReleaseAge` 默认 1440 分钟(1 天,v11 起),今天发布的版本今天解析不到 ——
|
|
1749
|
+
于是「请求 0.2.13、装到 0.2.12」是**正常行为**,而 `installRelease` 若拿 `state.latest` 当结果,
|
|
1750
|
+
就会把一个没发生的事记成发生了。现在的做法:
|
|
1751
|
+
|
|
1752
|
+
- 回读 `installedVersion`;
|
|
1753
|
+
- 若它低于请求的版本,输出末尾追加一句 `pnpm installed X rather than Y: its minimumReleaseAge policy
|
|
1754
|
+
withholds versions that are too recent.`;
|
|
1755
|
+
- 自动路径把 `lastInstalled` 传进下一轮预检(`cooled`),若「冷却期落地的版本」仍低于 `latest`,
|
|
1756
|
+
把它从 `attempted` 里撤出来并在 1 小时后重试 —— 否则这个进程的生命周期会被钉死在那个旧版本上,
|
|
1757
|
+
而它明明已经装好了。
|
|
1758
|
+
|
|
1759
|
+
预检 `checkInstallNeed` 的 `cooling-down` 判据是:
|
|
1760
|
+
`cooled < latest && cooled >= (running ?? disk ?? '0.0.0')`。换句话说,只有当**已经装上的东西**比目标旧、
|
|
1761
|
+
且不比运行中的更旧时,才值得再发一次安装请求。
|
|
1762
|
+
|
|
1763
|
+
### 发布:OIDC trusted publishing
|
|
1764
|
+
|
|
1765
|
+
`.github/workflows/release.yml` 在 tag 上跑 `scripts/release-check.mjs`,然后
|
|
1766
|
+
`npm publish --provenance`,凭 npm 的 trusted publishing(runner 用 OIDC 换一个只对本仓库 + 本
|
|
1767
|
+
workflow 文件有效的令牌)。仓库里不放 `NPM_TOKEN`。
|
|
1768
|
+
|
|
1769
|
+
三点是实测/文档里挑出来的硬约束:
|
|
1770
|
+
|
|
1771
|
+
1. **workflow 文件名是信任契约的一部分。** 在 npm 的 Trusted publisher 设置里填的是 `release.yml`,
|
|
1772
|
+
改名或搬到子目录,每次发布都会 403,而且报错不会告诉你原因。
|
|
1773
|
+
2. **npm 11.5.1 起才支持。** Node 24 自带 11.x,workflow 里仍显式校验这条下限。
|
|
1774
|
+
3. **trusted publishing 只授权带 provenance 的发布**,所以 `--provenance` 不是可选项。
|
|
1775
|
+
|
|
1776
|
+
为什么不是「发版时手动 `npm publish`」:手动发布需要一个 bypass-2FA 的 granular token,而账号只有
|
|
1777
|
+
passkey(WebAuthn)、没有 TOTP 入口 —— 产不出 `--otp` 要的六位码。OIDC 把「谁能发」从「持有一个长期
|
|
1778
|
+
令牌的人」换成「这个仓库的这个 workflow」,同时消掉了令牌本身。
|
|
1779
|
+
|
|
1780
|
+
### 测试
|
|
1781
|
+
|
|
1782
|
+
`test/support.mjs` 里加了 `startRegistry`:用 `node:http` 起一个本机假 registry,记录每次请求的
|
|
1783
|
+
`accept` 头,可随时 `set(name, doc)`。**每个响应都带 `connection: close`** —— undici 会为 keep-alive
|
|
1784
|
+
空闲连接挂一个解析器超时计时器,而退避测试要压缩全局 `setTimeout`,那个计时器会被提前触发并抛
|
|
1785
|
+
`TypeError: Cannot read properties of undefined (reading 'deref') at Timeout.onParserTimeout`。
|
|
1786
|
+
让连接短命是唯一不用改被测代码的解法。
|
|
1787
|
+
|
|
1788
|
+
`updatetest` 118 项、`autoupdatetest` 46 项、`wiringtest` 24 项全部改走这个假 registry。
|
|
1789
|
+
|