@lixiangzhong/dsh-shell-secrets 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lixiangzhong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # @lixiangzhong/dsh-shell-secrets
2
+
3
+ 把**启动 dsh 的那个环境里**名字匹配 `KEY|PASSWORD|SECRET|TOKEN`(大小写不敏感)的变量,
4
+ 以**原始变量名**重新注入 dsh 的 bash 子进程。
5
+
6
+ > ## ⚠️ 先读这一段
7
+ >
8
+ > 这个插件**有意绕过** harness 的凭证 scrub。启用之后,匹配到的变量会进入**每一个**
9
+ > bash 子进程 —— 包括 `npm install` 的 postinstall、以及模型运行的任意第三方二进制。
10
+ > 只要模型执行 `env`,这些值就会出现在 tool result(也就是会话日志和模型上下文)里。
11
+ >
12
+ > 请在**受信 workspace + 受信会话**里使用;不要把值直接 `echo`/`env` 到会被记录的输出里
13
+ > (验证时用 `env | cut -d= -f1 | grep ...` 只列名字)。插件本身无法对输出脱敏。
14
+
15
+ ```bash
16
+ export GITHUB_TOKEN=ghp_xxx PGPASSWORD=secret
17
+ dsh web
18
+ # 之后模型通过 bash 工具执行:
19
+ # gh auth status # 直接读 $GITHUB_TOKEN,无需改写
20
+ # psql -h db -U app # 直接读 $PGPASSWORD
21
+ ```
22
+
23
+ ## 它解决的问题
24
+
25
+ harness 的 subprocess 层有一条固定的凭证 scrub:
26
+
27
+ ```
28
+ scrubbedParentEnv() // 剔除 /KEY|PASSWORD|SECRET|TOKEN/i 以及所有 DSH_*
29
+ childEnv(extra) = { ...scrubbedParentEnv(), ...extra } // extra 不再过滤
30
+ ```
31
+
32
+ 所以 `~/.dsh/.env`、启动环境里的密钥都进不了 bash 子进程。官方的插件接缝 `ctx.shellEnv`
33
+ 只能注册 `DSH_*` 键(命令里得写 `$DSH_GITHUB_TOKEN`),`gh`/`mysql`/`aws` 这类自己读
34
+ 环境变量的 CLI 就用不上。本插件改为接管 `ctx.shell`,往文档化的
35
+ `ShellExecSpec.env`("merged after the credential scrub")里注入原始变量名。
36
+
37
+ ## 安装 / 卸载
38
+
39
+ 两条路径,**二选一**,不要同时用(`deploy.mjs` 检测到 bundle 安装时会提示)。
40
+
41
+ ### 方式 A:作为 dsh plugin(bundle 形式,推荐)
42
+
43
+ 本包在 `package.json` 里声明了 `dsh.bundle.patch`,所以 `dsh plugin` 会把它自动加进
44
+ `dsh.profile.bundles` 层,不需要手改 profile 的任何 YAML:
45
+
46
+ ```bash
47
+ # 从 npm 安装(发布后)
48
+ dsh plugin --profile web add @lixiangzhong/dsh-shell-secrets
49
+ dsh plugin --profile web update @lixiangzhong/dsh-shell-secrets # 升级
50
+
51
+ # 从本地源码 / tarball 安装(开发或内部分发)
52
+ dsh plugin --profile web add file:/Users/lxz/gopath/src/github.com/lixiangzhong/dsh-shell-secrets
53
+ dsh plugin --profile web add file:./dsh-shell-secrets-0.1.0.tgz
54
+
55
+ # 卸载(同时移除依赖、bundle 层、node_modules)
56
+ dsh plugin --profile web remove @lixiangzhong/dsh-shell-secrets
57
+
58
+ # 改完本地源码后刷新已安装拷贝
59
+ dsh plugin --profile web remove @lixiangzhong/dsh-shell-secrets && \
60
+ dsh plugin --profile web add file:/Users/lxz/gopath/src/github.com/lixiangzhong/dsh-shell-secrets
61
+
62
+ node deploy.mjs --check # 两种安装方式都适用(只看合成后的行)
63
+ ```
64
+
65
+ 要点:
66
+
67
+ - 从 npm 装的是**版本化的 registry 依赖**,`dsh plugin … update` 就能升级;本地 `file:` 目录
68
+ 依赖没有版本概念,升级=remove + add(见下一条)。
69
+ - **本地安装必须用 `file:`,不能用 `link:`**。`link:` 会把包软链到源码目录,Node 按 realpath
70
+ 解析 `@deepseek-ai/dsh-bash-sandbox` 时会从源码目录向上找而失败(实测 `MODULE_NOT_FOUND`);
71
+ `file:` 是真实目录(pnpm 硬链接),realpath 落在 `~/.dsh/profiles` 内,能解析到 harness
72
+ 自己那份模块(实测 hoisted 与 `.pnpm` 两种布局都解析到 npx 安装目录)。
73
+ - 目录依赖是**硬链接**:原地修改源码会立刻反映到已安装副本;但编辑器"写临时文件再 rename"
74
+ 的保存方式会换 inode 断链,所以**刷新请用上面的 remove + add**(实测 `pnpm update` 与
75
+ `pnpm install --force` 对目录依赖都会报 "Already up to date" 而不重新拷贝)。
76
+ - `bundle` 列表在 **dsh 启动时**读取,装/卸之后要重启 dsh 才生效。
77
+ - **pnpm 版本/store 必须与 profile 现有的 `node_modules` 匹配**。`dsh plugin` 只是转发给
78
+ PATH 上的 `pnpm`;如果 profile 的 `node_modules` 是用另一个大版本的 pnpm 装的,会直接失败:
79
+
80
+ ```
81
+ ERR_PNPM_UNEXPECTED_STORE Unexpected store location
82
+ The dependencies at "~/.dsh/profiles/web/node_modules" are currently linked from the store at
83
+ "~/.dsh/Library/pnpm/store/v11"; pnpm now wants to use the store at ".../store/v10"
84
+ ```
85
+
86
+ 两条出路:把匹配的 pnpm 放到 PATH 前面(本机 store v11 ↔ `~/Library/pnpm/bin/pnpm` 11.8.0):
87
+
88
+ ```bash
89
+ PATH="$HOME/Library/pnpm/bin:$PATH" dsh plugin --profile web add file:<此目录>
90
+ ```
91
+
92
+ 或者在 profile 目录里用当前的 pnpm 重建一次 `node_modules`(`cd ~/.dsh/profiles/web && pnpm install`)。
93
+ - 从 npm 装时,**pnpm 也要指对 registry**:本机全局 registry 是腾讯镜像(`npm_config_registry`
94
+ 环境变量,优先级高于 `~/.npmrc`),刚发布的包在镜像上通常还没同步,会 `404`。给这条命令显式指定:
95
+
96
+ ```bash
97
+ PATH="$HOME/Library/pnpm/bin:$PATH" dsh plugin --profile web add @lixiangzhong/dsh-shell-secrets \
98
+ --registry=https://registry.npmjs.org
99
+ ```
100
+
101
+ (`dsh plugin` 会把参数原样转发给 pnpm,所以 pnpm 自己的 flag 也能直接写。)
102
+ - 包里的 `peerDependencies` 把 harness 包声明为**可选** peer:只为表达耦合关系,配合 profile
103
+ 自带的 `autoInstallPeers: false`,pnpm 不会偷偷装第二份 harness 包(否则会出现两个 cordis /
104
+ 两个 `Service` 实例,服务注册行为会错乱)。
105
+ - 装好后 `dsh.profile.bundles` 会变成 `['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app',
106
+ 'dsh-shell-secrets']`,profile 的 `cordis.patch.yml` 应保持干净的 `[]`。
107
+
108
+ ### 方式 B:deploy.mjs(拷贝 + 手工挂载,不依赖 pnpm)
109
+
110
+ 适合不想让 pnpm 参与、或要装进多个 profile 的场景:
111
+
112
+ ```bash
113
+ node deploy.mjs # 拷贝到 ~/.dsh/profiles/web/plugins/dsh-shell-secrets + 写入挂载行
114
+ node deploy.mjs --check # dsh --profile web --dump-config 校验挂载结果
115
+ node deploy.mjs --remove # 回滚:移除挂载行 + 删除插件目录
116
+ PROFILE=tui node deploy.mjs # 换 profile(默认 web)
117
+ ```
118
+
119
+ 它做两件事:
120
+
121
+ 1. **拷贝**(不是软链)插件到 `~/.dsh/profiles/<profile>/plugins/dsh-shell-secrets/`。
122
+ 必须是拷贝:`@deepseek-ai/dsh-bash-sandbox` 要从 `~/.dsh/profiles/node_modules`(指向
123
+ npx 安装目录的软链)解析到 harness **同一份模块实例**,软链会让 Node 按 realpath 从
124
+ 工作区向上找包而失败,两个模块实例混用会出问题。
125
+ 2. 在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 的 `# >>> dsh-shell-secrets` /
126
+ `# <<< dsh-shell-secrets` 标记段内写入:
127
+
128
+ ```yaml
129
+ - id: bash-sandbox
130
+ disabled: true # 不关掉官方行就会有两个 shell 服务,cordis 会大声报重复服务
131
+ - insert:
132
+ - id: shell-secrets
133
+ name: ./plugins/dsh-shell-secrets/lib/index.js
134
+ config:
135
+ timeoutMs: 60000 # 与官方 bash-sandbox 行同值
136
+ disabled: !!js process.platform === 'win32'
137
+ ```
138
+
139
+ ### 方式 A 用的 bundle patch
140
+
141
+ `cordis.patch.yml`(包内,`dsh.bundle.patch` 指向它)内容与上面等价,只是 `name` 相对包自身:
142
+
143
+ ```yaml
144
+ - id: bash-sandbox
145
+ disabled: true
146
+ - insert:
147
+ - id: shell-secrets
148
+ name: ./lib/index.js # 锚定成 <包目录>/lib/index.js
149
+ config:
150
+ timeoutMs: 60000
151
+ disabled: !!js process.platform === 'win32'
152
+ ```
153
+
154
+ ## 配置(全部来自启动环境)
155
+
156
+ | 变量 | 默认 | 说明 |
157
+ | --- | --- | --- |
158
+ | `DSH_SHELL_SECRETS_PATTERN` | `KEY\|PASSWORD\|SECRET\|TOKEN`(i) | 自定义匹配正则(与 scrub 同语义是刻意的) |
159
+ | `DSH_SHELL_SECRETS_INCLUDE` | 空 | 逗号分隔 glob(`*`/`?`),**优先于** pattern,如 `*_TOKEN,AWS_*` |
160
+ | `DSH_SHELL_SECRETS_EXCLUDE` | 空 | 逗号分隔 glob 排除项,如 `MONKEY,KEYBOARD_*`(修子串误伤) |
161
+ | `DSH_SHELL_SECRETS_MAX_BYTES` | `8192` | 单值上限,超长跳过 |
162
+ | `DSH_SHELL_SECRETS_DISABLE` | 空 | 非空即停用注入(不卸载插件) |
163
+
164
+ 规则细节:
165
+
166
+ - **`DSH_*` 永不注入**:维持 harness "ambient `DSH_*` 被丢弃" 的不变量,避免伪造 harness 事实。
167
+ - 空值/纯空白、超长值跳过;含换行的值仍注入(保真优先)但会在装载日志里提醒。
168
+ - 非法正则/非法上限会让**装配失败并抛错**:宁可 dsh 启动失败,也不要静默不注入。
169
+ - 未命中的变量不会进日志(否则整个环境变量表都会变成噪音)。
170
+
171
+ 日志只打印**名字与原因,绝不打印值**。
172
+
173
+ ## 生效时机
174
+
175
+ | 改了什么 | 怎么生效 |
176
+ | --- | --- |
177
+ | 启动环境里的密钥变量 | 重启 dsh |
178
+ | `DSH_SHELL_SECRETS_*` 配置 | 重启 dsh |
179
+ | `cordis.patch.yml` | web profile 是 `patchReload: live`,保存即热生效 |
180
+ | 插件 JS 代码 | 必须重启 dsh(ESM 模块缓存) |
181
+
182
+ 代码改动后记得 `node deploy.mjs` 重新拷贝。
183
+
184
+ ## 验证
185
+
186
+ ```bash
187
+ # 启动 dsh 的 shell 里
188
+ export MY_TEST_PASSWORD=probe-ok MY_TEST_TOKEN=probe-token
189
+ dsh web
190
+ ```
191
+
192
+ 之后在 bash 工具里:
193
+
194
+ ```bash
195
+ echo "${MY_TEST_PASSWORD-unset}" # probe-ok(原始名字可用)
196
+ # 只列名字,别 grep 值:tool result 会把它写进会话日志与模型上下文!
197
+ env | cut -d= -f1 | grep -E '_TOKEN$|PASSWORD$|SECRET$|_KEY$' | sort
198
+ env | cut -d= -f1 | grep '^DSH_' # 只应有 DSH_HOME/DSH_SHELL/DSH_SESSION_ID/DSH_WEB_URL
199
+ ```
200
+
201
+ 单元测试(纯逻辑,零依赖):
202
+
203
+ ```bash
204
+ node --test
205
+ ```
206
+
207
+ ## 实现注意事项(踩过的坑)
208
+
209
+ - **执行器类里不能用 `#private` 字段/私有方法。** cordis 的 `getTraceable()` 会把服务包成
210
+ Proxy,方法调用时 `this` 被替换成 shadow 代理,私有字段的 brand 校验会直接失败:
211
+ `Error: Receiver must be an instance of class SecretsBashExecutor`,且每个命令都失败。
212
+ 官方服务一律用普通字段/方法,照做即可(本插件用 `this.secrets` + `mergeSecrets()`)。
213
+ - **挂载会在运行中的 dsh 上热生效**(web profile 是 `patchReload: live`)。如果新代码有致命
214
+ 缺陷,这个会话的 bash 会立刻不可用;实测**把标记段删掉不足以卸载**已挂载的执行器
215
+ (服务行的卸载不会自动回滚),此时只能重启 dsh 恢复。所以:改执行器代码后建议先重启再挂载,
216
+ 或者接受一次"重启即恢复"的窗口。
217
+ - 插件 JS 改动受 ESM 模块缓存影响,**同一个进程内换文件内容不会重新导入**(同 URL 命中缓存)。
218
+
219
+ ## 兼容性与已测版本
220
+
221
+ 本插件绑的是 harness 内部接口,所以发布版带一个**装载期自检**(`compatibilityIssues()`):
222
+
223
+ | 检查项 | 失败时 |
224
+ |---|---|
225
+ | `SandboxBashExecutor` 是否仍是类,且带 `resolve/run/start` | `logger.error` 报警(不阻断启动) |
226
+ | 本插件解析到的 `@deepseek-ai/dsh-bash-sandbox` 与 harness 自己用的是否**同一份**(防止 profile 装出第二份 harness 包 → 两个 cordis 实例) | `logger.error` 报警(不阻断启动) |
227
+
228
+ 自检**刻意不抛异常**:宁可 dsh 正常启动 + 日志醒目报错,也不要因为误报把用户的 dsh 弄成起不来。
229
+ 真正的致命问题(导出的不是类)会在 import 期直接炸。
230
+
231
+ 已实测环境:
232
+
233
+ | dsh | Node | 平台 | 结果 |
234
+ |---|---|---|---|
235
+ | 0.1.5-rc.1(npx 安装) | v26.7.0 | macOS (darwin) | 前台 + 后台路径均注入成功;`deploy.mjs --check` 通过;单测 13/13 |
236
+
237
+ 未覆盖:`minimal` 预设 / `sdk-minimal` profile 的持久 PTY shell;Windows pwsh。
238
+
239
+ dsh 升级后请重跑:
240
+
241
+ ```bash
242
+ node --test && node deploy.mjs --check
243
+ env | cut -d= -f1 | grep -E '_TOKEN$|PASSWORD$|SECRET$|_KEY$' # 只列名字确认注入仍在
244
+ ```
245
+
246
+ ## 发布到 npm
247
+
248
+ ```bash
249
+ npm version patch # 或 minor/major
250
+ npm publish # publishConfig 已固定 registry=npmjs、access=public
251
+ ```
252
+
253
+ - **登录也要指 registry**。若生效 registry 是只读镜像(本机 `npm_config_registry` 指向腾讯镜像),
254
+ `npm login` 会打到镜像上并报 `E409 ... user registration disabled`。二选一:
255
+
256
+ ```bash
257
+ # a) 用 npmjs 的 Access Token(推荐,尤其是账号开了 2FA 时)
258
+ # 在 https://www.npmjs.com/settings/<用户名>/tokens 生成 Granular Access Token(Read and write),
259
+ # 然后写进 ~/.npmrc(token 按 host 存,不受 registry 配置影响):
260
+ # //registry.npmjs.org/:_authToken=npm_xxxxxxxx
261
+ npm whoami --registry=https://registry.npmjs.org # 应输出你的用户名
262
+
263
+ # b) 或者直接登录 npmjs(npm 11 默认 web 登录;也可加 --auth-type=legacy 走用户名/密码/OTP)
264
+ npm login --registry=https://registry.npmjs.org
265
+ ```
266
+
267
+ 注意:环境变量 `npm_config_registry` 优先级高于 project/user `.npmrc`,所以"在项目里放
268
+ `.npmrc` 改 registry"在这里**无效**,只能用 CLI flag 或按 host 存的 token。
269
+ - `publishConfig.registry` 显式指向 `https://registry.npmjs.org`:本机 `npm_config_registry`
270
+ 是只读镜像时,不加这个会发布失败。
271
+ - 发布前自查:`npm pack --dry-run`(应只含 `lib/`、`cordis.patch.yml`、`README.md`、`LICENSE`、
272
+ `package.json`)、`node --test`。
273
+ - 发布后把安装方式切到 registry 版本(pnpm 也要指 registry,见上文;镜像对新包有同步延迟):
274
+
275
+ ```bash
276
+ PATH="$HOME/Library/pnpm/bin:$PATH" dsh plugin --profile web remove @lixiangzhong/dsh-shell-secrets
277
+ PATH="$HOME/Library/pnpm/bin:$PATH" dsh plugin --profile web add @lixiangzhong/dsh-shell-secrets \
278
+ --registry=https://registry.npmjs.org
279
+ npm view @lixiangzhong/dsh-shell-secrets version --registry=https://registry.npmjs.org
280
+ ```
281
+
282
+ ## 风险与边界
283
+
284
+ 1. **这是有意绕过凭证 scrub**:匹配到的变量会进入**每一个** bash 子进程,包括
285
+ `npm install` 的 postinstall 与任意第三方二进制。只在受信 workspace 使用。
286
+ 2. 值会随命令输出进入模型可见的 tool result(例如模型跑 `env`)。插件无法对输出脱敏。
287
+ 3. 绑定了 harness 内部结构:`SandboxBashExecutor` 默认导出、`run`/`start` 语义、
288
+ `ShellExecSpec.env` 的"scrub 之后再合并"约定、`bash-sandbox` 行的存在,
289
+ 以及 `ctx.shell` 服务名。dsh 升级后请重新跑一遍:
290
+ ```bash
291
+ node --test && node deploy.mjs --check
292
+ ```
293
+ 并确认 `env` 里密钥仍在。
294
+ 4. 若将来 harness 给 `ShellExecSpec` 增加了插件级 env 贡献接缝,应迁移过去并删掉
295
+ 执行器替换(少一层内部耦合)。
296
+ 5. **不覆盖**:`minimal` 预设 / `sdk-minimal` profile 的持久 PTY shell(走 `terminals`
297
+ 服务,不经 `ctx.shell`);Windows 的 pwsh(`SandboxPwshExecutor` 同样已导出,可同构补充)。
298
+
299
+ ## 目录
300
+
301
+ ```
302
+ lib/secrets.js 密钥选择纯逻辑(可单测)
303
+ lib/index.js SecretsBashExecutor extends SandboxBashExecutor
304
+ test/secrets.test.js node --test
305
+ deploy.mjs 部署/回滚/校验
306
+ cordis.patch.yml 作为独立 bundle 安装(dsh plugin add file:<dir>)时用的行
307
+ ```
@@ -0,0 +1,22 @@
1
+ # dsh-shell-secrets 作为独立 bundle 安装时的 patch 行。
2
+ #
3
+ # 只有走 `dsh plugin --profile <profile> add file:<此目录>` 这条安装路径时,本文件才会
4
+ # 被当作 bundle 层加载;此时 patch 里的相对 `name` 会被锚定到本文件所在目录。
5
+ #
6
+ # 常规用法是 deploy.mjs:它把插件拷进 profile 并把等价的行写进
7
+ # ~/.dsh/profiles/<profile>/cordis.patch.yml(那条路径能保证 @deepseek-ai/* 解析到
8
+ # harness 自己的模块实例,subclass 才安全)。
9
+
10
+ # 官方 bash-sandbox 行必须先关掉:两个实现会争同一个 `shell` 服务,
11
+ # cordis 对此会大声报重复服务(这是刻意的失败模式,不要靠顺序去赌)。
12
+ - id: bash-sandbox
13
+ disabled: true
14
+
15
+ - insert:
16
+ - id: shell-secrets
17
+ name: ./lib/index.js
18
+ # 与官方 bash-sandbox 行同值,替换后行为保持不变。
19
+ config:
20
+ timeoutMs: 60000
21
+ # 本期只覆盖 bash;Windows 的 pwsh 走 SandboxPwshExecutor,可同构补充。
22
+ disabled: !!js process.platform === 'win32'
package/lib/index.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * dsh-shell-secrets:把启动环境里 KEY/PASSWORD/SECRET/TOKEN 类变量带回 bash 子进程。
3
+ *
4
+ * 为什么用"替换执行器"而不是官方 shellEnv 接缝:官方 `ctx.shellEnv` 只允许注册
5
+ * `DSH_*` 键,命令里就得写 `$DSH_GITHUB_TOKEN`,`gh`/`mysql`/`aws` 这类自己读环境变量
6
+ * 的 CLI 无法直接使用。这里改为接管 `ctx.shell`(服务名 `shell`),在文档化的
7
+ * `ShellExecSpec.env` 上注入原始变量名:
8
+ *
9
+ * ShellExecSpec.env —— "Ordinary environment entries for the command, merged after
10
+ * the credential scrub"(@deepseek-ai/dsh-shell 类型文档)
11
+ *
12
+ * 合并顺序(dsh-bash-local 的 spawnSpec):
13
+ * { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }
14
+ * 因此本插件的值在 scrub 之后生效,同时又顶不掉 harness 自己的 `DSH_*`(那是 dshEnv)。
15
+ *
16
+ * 装载方式:profile 用户层先 `disabled: true` 掉官方 bash-sandbox 行(否则两个实现争
17
+ * 同一个 `shell` 服务,cordis 会报重复服务),再 insert 本模块。详见 README.md。
18
+ *
19
+ * @module dsh-shell-secrets
20
+ */
21
+ import { realpathSync } from 'node:fs'
22
+ import { createRequire } from 'node:module'
23
+
24
+ import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
25
+
26
+ import { collectEnvSecrets, describeNames, readOptionsFromEnv } from './secrets.js'
27
+
28
+ /** 配置前缀说明:本插件的开关都从启动环境读取,不需要额外 schema。 */
29
+ export const OPTION_PREFIX = 'DSH_SHELL_SECRETS_'
30
+
31
+ /** 本插件绑定的 harness 包:既做 peer 声明,也做下面的同源自检。 */
32
+ export const HARNESS_PACKAGE = '@deepseek-ai/dsh-bash-sandbox'
33
+
34
+ /**
35
+ * 从某个模块/入口出发解析 harness 包的真实路径。
36
+ * @param from - 模块 URL 或文件路径。
37
+ * @returns 真实路径;解析不到时 undefined。
38
+ */
39
+ function resolveHarnessPackage(from) {
40
+ try {
41
+ return realpathSync(createRequire(from).resolve(HARNESS_PACKAGE))
42
+ } catch {
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ /** 本模块解析到的 harness 包路径。 */
48
+ const OWN_HARNESS_PATH = resolveHarnessPackage(import.meta.url)
49
+
50
+ /**
51
+ * 装载期兼容性自检。
52
+ *
53
+ * 发布出去的包会装到版本各异的 dsh 上,而本插件绑的是 harness 内部接口:
54
+ * `SandboxBashExecutor` 的 `resolve/run/start`、以及 `ShellExecSpec.env` 的
55
+ * "在凭证 scrub 之后再合并"约定。这里只检查**可检测**的部分:
56
+ *
57
+ * 1. 基类是否还提供 `resolve/run/start`;
58
+ * 2. harness 自己用的包与本插件解析到的是不是同一份(profile 若没有
59
+ * `autoInstallPeers: false`,pnpm 可能装出第二份 harness 包 → 两个 cordis 实例)。
60
+ *
61
+ * 刻意**只报错不抛异常**:自检失败时宁可让 dsh 正常启动、日志里醒目报错,也不要
62
+ * 因为误报把用户的 dsh 弄成起不来。真正致命的问题(导出的不是类)在 import 期就会炸。
63
+ * @param base - 导入到的基类。
64
+ * @returns 问题描述数组,空数组表示通过。
65
+ */
66
+ function compatibilityIssues(base) {
67
+ const issues = []
68
+ if (typeof base !== 'function') return [`${HARNESS_PACKAGE} 的导出不是类`]
69
+ for (const method of ['resolve', 'run', 'start']) {
70
+ if (typeof base.prototype?.[method] !== 'function') {
71
+ issues.push(`基类缺少 ${method}(),harness 内部接口可能已变`)
72
+ }
73
+ }
74
+ const entry = process.argv[1]
75
+ if (entry !== undefined && OWN_HARNESS_PATH !== undefined) {
76
+ const harnessPath = resolveHarnessPackage(entry)
77
+ if (harnessPath !== undefined && harnessPath !== OWN_HARNESS_PATH) {
78
+ issues.push(`本插件解析到 ${OWN_HARNESS_PATH},harness 自己是 ${harnessPath},即装了两份 ${HARNESS_PACKAGE}`)
79
+ }
80
+ }
81
+ return issues
82
+ }
83
+
84
+ /** 自检结果(模块级算一次)。 */
85
+ const COMPATIBILITY_ISSUES = compatibilityIssues(SandboxBashExecutor)
86
+
87
+ /**
88
+ * 带密钥注入的 bash 执行器。
89
+ *
90
+ * 只覆写 `run`(前台)与 `start`(后台任务)两个文档化的抽象方法,其余行为
91
+ * (resolve 的 workdir/超时/策略、沙箱收敛、onProcessDone 的沙箱事实、sandboxMode
92
+ * getter)全部继承官方实现;`static Config` 同样继承,profile 行的配置项不变。
93
+ *
94
+ * 注意(踩过的坑):**不要在这个类里用 `#private` 字段或私有方法**。cordis 的
95
+ * `getTraceable()` 会把服务包成 Proxy,方法调用时 `this` 被替换成 shadow 代理,
96
+ * 私有字段的 brand 校验会直接失败(报 "Receiver must be an instance of class ...",
97
+ * 而且是每个命令都失败)。官方服务一律用普通字段/方法,照做即可。
98
+ */
99
+ export class SecretsBashExecutor extends SandboxBashExecutor {
100
+ /**
101
+ * 装载期一次性快照。
102
+ *
103
+ * 密钥来源是"启动 dsh 的那个环境",它在进程生命周期内不再变化;一次性快照让
104
+ * 装载日志与后续每次注入严格一致,也避免每个命令都去遍历环境变量。
105
+ */
106
+ secrets
107
+
108
+ /**
109
+ * @param ctx - cordis 上下文。
110
+ * @param config - profile 行配置(继承 LocalBashExecutor 的 schema)。
111
+ * @throws 当 DSH_SHELL_SECRETS_* 配置非法时:宁可启动失败,也不要静默不注入。
112
+ */
113
+ constructor(ctx, config) {
114
+ super(ctx, config)
115
+
116
+ try {
117
+ this.secrets = collectEnvSecrets(process.env, readOptionsFromEnv(process.env))
118
+ } catch (error) {
119
+ const detail = error instanceof Error ? error.message : String(error)
120
+ throw new Error(`dsh-shell-secrets: ${detail}`)
121
+ }
122
+
123
+ // 只记名字与原因,绝不记值。
124
+ this.ctx.logger.info(`dsh-shell-secrets: ${describeNames(this.secrets)}`)
125
+ for (const issue of COMPATIBILITY_ISSUES) {
126
+ this.ctx.logger.error(`dsh-shell-secrets: 兼容性自检未通过 —— ${issue};注入可能失效,请核对 README 的「已测版本」表`)
127
+ }
128
+ if (this.secrets.names.length === 0) {
129
+ this.ctx.logger.warn(
130
+ `dsh-shell-secrets: 没有可注入的密钥变量(检查启动环境里的敏感名变量,或 ${OPTION_PREFIX}INCLUDE / ${OPTION_PREFIX}PATTERN 配置)`,
131
+ )
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 把密钥并入一次执行的显式环境。
137
+ * @param spec - 已解析的执行规格。
138
+ * @returns 新的规格;无密钥时原样返回,保持热路径零分配。
139
+ */
140
+ mergeSecrets(spec) {
141
+ if (this.secrets.names.length === 0) return spec
142
+ return { ...spec, env: { ...spec.env, ...this.secrets.values } }
143
+ }
144
+
145
+ /**
146
+ * 前台执行。
147
+ * @param spec - 已解析的执行规格。
148
+ * @returns 执行结果(与官方实现同形)。
149
+ */
150
+ run(spec) {
151
+ return super.run(this.mergeSecrets(spec))
152
+ }
153
+
154
+ /**
155
+ * 后台任务执行(run_in_background 与 job_* 控制走这条路径)。
156
+ * @param spec - 已解析的执行规格。
157
+ * @returns 进程句柄(与官方实现同形)。
158
+ */
159
+ start(spec) {
160
+ return super.start(this.mergeSecrets(spec))
161
+ }
162
+ }
163
+
164
+ export default SecretsBashExecutor
package/lib/secrets.js ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-shell-secrets 的密钥选择逻辑。
3
+ *
4
+ * 只做一件事:从一个环境变量快照里挑出"应当注入 bash 子进程"的密钥变量,并保持
5
+ * 原始变量名。纯函数、零依赖,可直接用 `node --test` 覆盖。
6
+ *
7
+ * 背景(为什么需要它):harness 的 subprocess 层会用 `scrubbedParentEnv()` 从子进程
8
+ * 环境里剔除名字匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 的变量,以及所有 `DSH_*`;而
9
+ * `ShellExecSpec.env` 是在那次 scrub **之后**才合并进子进程环境的。所以本插件只要把
10
+ * 同一批变量重新放进 `spec.env`,命令里就能继续用原来的名字($GITHUB_TOKEN 等)。
11
+ *
12
+ * @module dsh-shell-secrets/secrets
13
+ */
14
+
15
+ /** 默认匹配:与 harness 凭证 scrub 完全同一语义,保证"被剥掉的正好被补回来"。 */
16
+ export const DEFAULT_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
17
+
18
+ /** harness 托管命名空间前缀:ambient `DSH_*` 一律不回流,维持既有不变量。 */
19
+ export const RESERVED_PREFIX = 'DSH_'
20
+
21
+ /** 单个密钥值的默认长度上限(字节),超过则跳过,避免把整份证书塞进环境变量。 */
22
+ export const DEFAULT_MAX_BYTES = 8192
23
+
24
+ /** 实际未注入的原因码。 */
25
+ export const SKIP_REASONS = Object.freeze({
26
+ disabled: 'disabled',
27
+ excluded: 'excluded',
28
+ reserved: 'reserved-namespace',
29
+ empty: 'empty',
30
+ tooLong: 'too-long',
31
+ })
32
+
33
+ /** 已注入但值得提醒的原因码。 */
34
+ export const NOTE_REASONS = Object.freeze({ multiline: 'multiline' })
35
+
36
+ /**
37
+ * 把 `*`/`?` 形式的名字匹配规则编译成正则。
38
+ * @param glob - 例如 `*_TOKEN`、`AWS_*`、`KEYBOARD_*`。
39
+ * @returns 大小写不敏感、整体匹配的正则。
40
+ * @throws 当规则不是非空字符串时。
41
+ */
42
+ function compileGlob(glob) {
43
+ if (typeof glob !== 'string' || glob.trim().length === 0) {
44
+ throw new Error('名字匹配规则必须是非空字符串')
45
+ }
46
+ const source = glob
47
+ .trim()
48
+ .replace(/[.*+?^${}()|[\]\\]/g, (char) => (char === '*' || char === '?' ? char : `\\${char}`))
49
+ .replace(/\*/g, '.*')
50
+ .replace(/\?/g, '.')
51
+ return new RegExp(`^${source}$`, 'i')
52
+ }
53
+
54
+ /**
55
+ * 拆分逗号分隔的规则列表。
56
+ * @param raw - 环境变量原始值。
57
+ * @returns 去掉空白项后的规则数组。
58
+ */
59
+ function splitList(raw) {
60
+ if (typeof raw !== 'string') return []
61
+ return raw
62
+ .split(',')
63
+ .map((item) => item.trim())
64
+ .filter((item) => item.length > 0)
65
+ }
66
+
67
+ /**
68
+ * 读取布尔开关:非空即视为开启(与 harness 对 `DSH_TELEMETRY_DISABLED` 的约定一致)。
69
+ * @param raw - 环境变量原始值。
70
+ * @returns 是否开启。
71
+ */
72
+ function isEnabled(raw) {
73
+ return typeof raw === 'string' && raw.length > 0
74
+ }
75
+
76
+ /**
77
+ * 从启动环境解析插件配置。
78
+ *
79
+ * 配置只来自启动环境(与"密钥只来自启动环境"一致),所有非法值在装载期抛错,
80
+ * 避免"静默不注入"这种最难排查的失败模式。
81
+ * @param env - 启动环境快照。
82
+ * @returns 规格化的选项对象。
83
+ * @throws 当正则非法或长度上限非法时。
84
+ */
85
+ export function readOptionsFromEnv(env = process.env) {
86
+ const patternSource = env.DSH_SHELL_SECRETS_PATTERN
87
+ let pattern = DEFAULT_PATTERN
88
+ if (typeof patternSource === 'string' && patternSource.trim().length > 0) {
89
+ try {
90
+ pattern = new RegExp(patternSource, 'i')
91
+ } catch (error) {
92
+ const detail = error instanceof Error ? error.message : String(error)
93
+ throw new Error(`DSH_SHELL_SECRETS_PATTERN 不是合法正则: ${detail}`)
94
+ }
95
+ }
96
+
97
+ let maxBytes = DEFAULT_MAX_BYTES
98
+ const maxBytesSource = env.DSH_SHELL_SECRETS_MAX_BYTES
99
+ if (typeof maxBytesSource === 'string' && maxBytesSource.trim().length > 0) {
100
+ maxBytes = Number.parseInt(maxBytesSource, 10)
101
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
102
+ throw new Error(`DSH_SHELL_SECRETS_MAX_BYTES 必须是正整数: ${maxBytesSource}`)
103
+ }
104
+ }
105
+
106
+ return {
107
+ pattern,
108
+ include: splitList(env.DSH_SHELL_SECRETS_INCLUDE),
109
+ exclude: splitList(env.DSH_SHELL_SECRETS_EXCLUDE),
110
+ maxBytes,
111
+ disabled: isEnabled(env.DSH_SHELL_SECRETS_DISABLE),
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 从环境快照里挑出要注入的密钥变量。
117
+ *
118
+ * 选择顺序:显式 `include` 规则优先于默认正则;随后剔除 `DSH_*` 保留命名空间、
119
+ * `exclude` 规则、空值、超长值。未命中的变量完全忽略(不进 `skipped`,否则会把
120
+ * 整个环境变量表都变成诊断噪音)。
121
+ * @param env - 环境变量快照。
122
+ * @param options - {@link readOptionsFromEnv} 的结果或等价对象。
123
+ * @returns `{ values, names, skipped, notes }`;`values` 保持原始值不做任何修剪。
124
+ */
125
+ export function collectEnvSecrets(env, options = {}) {
126
+ const {
127
+ pattern = DEFAULT_PATTERN,
128
+ include = [],
129
+ exclude = [],
130
+ maxBytes = DEFAULT_MAX_BYTES,
131
+ disabled = false,
132
+ } = options
133
+
134
+ if (disabled) {
135
+ return { values: {}, names: [], skipped: [{ name: '*', reason: SKIP_REASONS.disabled }], notes: [] }
136
+ }
137
+
138
+ const includeRules = include.map(compileGlob)
139
+ const excludeRules = exclude.map(compileGlob)
140
+ const selected = []
141
+
142
+ for (const [name, value] of Object.entries(env)) {
143
+ if (typeof value !== 'string') continue
144
+
145
+ // 未命中者直接忽略:只有"本可以被注入"的名字才值得出现在诊断里。
146
+ const matched = includeRules.length > 0 ? includeRules.some((rule) => rule.test(name)) : pattern.test(name)
147
+ if (!matched) continue
148
+ selected.push([name, value])
149
+ }
150
+
151
+ const skipped = []
152
+ const notes = []
153
+ const entries = []
154
+
155
+ for (const [name, value] of selected) {
156
+ if (name.toUpperCase().startsWith(RESERVED_PREFIX)) {
157
+ skipped.push({ name, reason: SKIP_REASONS.reserved })
158
+ continue
159
+ }
160
+ if (excludeRules.some((rule) => rule.test(name))) {
161
+ skipped.push({ name, reason: SKIP_REASONS.excluded })
162
+ continue
163
+ }
164
+ if (value.trim().length === 0) {
165
+ skipped.push({ name, reason: SKIP_REASONS.empty })
166
+ continue
167
+ }
168
+ if (Buffer.byteLength(value, 'utf8') > maxBytes) {
169
+ skipped.push({ name, reason: SKIP_REASONS.tooLong })
170
+ continue
171
+ }
172
+ // 换行值仍然注入(保真优先),但记一条提醒:shell 里直接展开时可能被截断。
173
+ if (value.includes('\n')) notes.push({ name, reason: NOTE_REASONS.multiline })
174
+ entries.push([name, value])
175
+ }
176
+
177
+ entries.sort(([left], [right]) => left.localeCompare(right))
178
+ return {
179
+ values: Object.fromEntries(entries),
180
+ names: entries.map(([name]) => name),
181
+ skipped,
182
+ notes,
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 生成只含名字与原因的装载说明。
188
+ *
189
+ * 存在的意义就是"绝不打印值":调用方只需把它的返回值交给日志。
190
+ * @param result - {@link collectEnvSecrets} 的结果。
191
+ * @returns 单行说明文本。
192
+ */
193
+ export function describeNames(result) {
194
+ const parts = [`可注入密钥变量 ${result.names.length} 个: ${result.names.join(', ') || '(无)'}`]
195
+ if (result.skipped.length > 0) {
196
+ parts.push(`已跳过: ${result.skipped.map((item) => `${item.name}(${item.reason})`).join(', ')}`)
197
+ }
198
+ if (result.notes.length > 0) {
199
+ parts.push(`提醒: ${result.notes.map((item) => `${item.name}(${item.reason})`).join(', ')}`)
200
+ }
201
+ return parts.join(' | ')
202
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@lixiangzhong/dsh-shell-secrets",
3
+ "version": "0.1.0",
4
+ "description": "DSH bundle: re-inject KEY/PASSWORD/SECRET/TOKEN variables from the launching environment into dsh bash subprocesses under their original names",
5
+ "license": "MIT",
6
+ "author": "lixiangzhong",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lixiangzhong/dsh-shell-secrets.git"
10
+ },
11
+ "homepage": "https://github.com/lixiangzhong/dsh-shell-secrets#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/lixiangzhong/dsh-shell-secrets/issues"
14
+ },
15
+ "keywords": [
16
+ "dsh",
17
+ "deepseek-harness",
18
+ "dsh-plugin",
19
+ "dsh-bundle",
20
+ "cordis",
21
+ "bash",
22
+ "secrets",
23
+ "environment-variables",
24
+ "credentials"
25
+ ],
26
+ "type": "module",
27
+ "main": "lib/index.js",
28
+ "exports": {
29
+ ".": "./lib/index.js",
30
+ "./secrets": "./lib/secrets.js"
31
+ },
32
+ "files": [
33
+ "lib",
34
+ "cordis.patch.yml",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "test": "node --test",
40
+ "deploy": "node deploy.mjs",
41
+ "check": "node deploy.mjs --check"
42
+ },
43
+ "dsh": {
44
+ "bundle": {
45
+ "patch": "./cordis.patch.yml"
46
+ }
47
+ },
48
+ "peerDependencies": {
49
+ "@deepseek-ai/dsh-bash-sandbox": "*"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@deepseek-ai/dsh-bash-sandbox": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "registry": "https://registry.npmjs.org"
59
+ },
60
+ "engines": {
61
+ "node": ">=22"
62
+ }
63
+ }