dsh-pwsh-quoting-guard
DSH(DeepSeek Harness)插件:让模型写 PowerShell 时不再需要跟引号搏斗。
它提供两个模型可见工具 —— pwsh_script 与 run_argv —— 从结构上移除"命令字符串"这一层转义负担,数据通过结构化数组传递。适用于 Windows 上以 Windows PowerShell 5.1 为执行器的部署。
- 包名:
dsh-pwsh-quoting-guard - 平面:宿主组合(host composition)—— 向
tools/systemPrompt注册表贡献内容,消费宿主提供的subprocess - 依赖:零运行时依赖(本地 link 安装的包按真实路径解析导入,因此刻意不 import 任何
@deepseek-ai/*) - 版本:1.0.0(对应开发过程中的 v5)
1. 它解决什么问题
现象:模型执行 PowerShell 命令时,经常因为嵌套引号、$、%、反斜杠、含空格或非 ASCII 的路径而出错;而且失败往往以"一大坨报错 + 重试"的形式消耗 token。
根因(在本部署实测确认,逐条有证据):
| # | 事实 | 证据 |
|---|---|---|
| 1 | 官方 pwsh 工具的 command 是单个字符串,经 pwsh -Command <string> 执行 —— 引号/转义负担 100% 在模型侧 |
@deepseek-ai/dsh-tool-pwsh README |
| 2 | 本机 pwsh 根本不在 PATH,实际执行器是 Windows PowerShell 5.1 |
Get-Command pwsh 空;工具内 $PSVersionTable.PSVersion = 5.1.26100.4652 |
| 3 | PS 5.1 向原生程序传参时会吃掉内嵌双引号,且不报错 | node -e ... 'a"b''c' 返回 ab'c(期望 a"b'c),零报错;$PSNativeCommandArgumentPassing 是 PS 7.3+ 才有的开关 |
| 4 | PS 5.1 的 ConvertFrom-Json 不展开顶层 JSON 数组 |
@(ConvertFrom-Json '["a","b"]') 只有 1 个元素(且该元素是数组本身),$DSH_ARGS[0] 不是字符串 |
| 5 | 用 -EncodedCommand 传脚本会让 PS 5.1 把错误流序列化成 CLIXML |
一次单行报错从 322 字符涨到 504 字符(+53% token),-OutputFormat Text 实测无效 |
第 3 条尤其危险:结果错了但没有任何报错,token 指标完全看不见,只能靠人发现。
2. 两个工具
pwsh_script
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
script |
string | ✅ | PowerShell 正文,逐字传递,可多行。字面量数据请放进 args,不要写进正文。 |
args |
string[] | 逐个绑定为 $DSH_ARGS[0]、$DSH_ARGS[1] …… |
// 读取一个含空格/中文/$/%/单引号的路径
{
"script": "(Get-Content -LiteralPath $DSH_ARGS[0] -Raw).Trim()",
"args": ["E:\\deepseekwork\\插件\\.dsh-tmp\\$100 %TEMP% it's dir\\sample file.txt"]
}
run_argv
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
program |
string | ✅ | 可执行文件名(PATH 上)或绝对路径 |
args |
string[] | 参数向量,一个元素一个参数,逐字传递 | |
cwd |
string | 工作目录;默认会话工作区 |
// 在指定目录里跑 node,同时保证参数含引号也逐字保真
{
"program": "node",
"args": ["-e", "console.log(process.cwd(), process.argv.slice(1))", "a\"b'c"],
"cwd": "E:\\deepseekwork\\插件\\.dsh-tmp\\$100 %TEMP% it's dir"
}
3. 工作原理(为什么不会再出错)
- 正文作为单个 argv 元素交给
-Command。它不经过任何 shell、不做二次拼接、不被再次当作字符串嵌入 —— 与官方执行器同款配方。 - 数据走
args:插件用 PowerShell 数组字面量 把参数绑成$DSH_ARGS,只对单引号做加倍 —— 转义由插件代码完成,模型永远不转义。刻意不用ConvertFrom-Json(见根因 4)。 - 第 1 行 prelude 同时做三件事:钉住
[Console]::OutputEncoding/$OutputEncoding为 UTF-8、静默进度流、绑定$DSH_ARGS;全部放在同一行,因此模型自己写的行号仍然准确(报错会指到At line:1 char:188这样的真实位置)。 run_argv完全不经过 shell:ctx.subprocess.spawn(argv)是"argv 即最终 argv"的接缝,参数不会被解析、拆分或展开;cwd直接作为子进程工作目录。- 结果词表与官方
pwsh工具一致:stdout、可选[stderr]段、[output truncated; full output: <path>]、[exit code: N](0 不输出标记)、超时/中断标记。成功路径上两者的结果侧 token 完全相同。 - 环境对齐:注入
NO_COLOR=1、PAGER=cat、GIT_PAGER=cat,并通过shellEnv注册表带上受管的DSH_*(DSH_HOME、DSH_SHELL、DSH_SESSION_ID…)。 - 沙箱:
read-only/workspace-write下经ctx.sandbox.confine()包装,包装失败即 fail closed(绝不静默放开);danger-full-access下跳过包装(该模式下 ACL runner 本身拒绝danger-full-access,包装会导致 100% 失败)。
4. 安装与卸载
本地 link 安装(开发态):
dsh plugin --profile web add link:E:\deepseekwork\插件\dsh-pwsh-quoting-guard
该命令会:pnpm 安装依赖 → 识别包内 dsh.bundle.patch → 自动登记进 profile 的 dsh.profile.bundles。重启 dsh web 后生效(组合在启动时装载)。
卸载:
dsh plugin --profile web remove dsh-pwsh-quoting-guard
发布到 npm 后也可直接 dsh plugin --profile web add dsh-pwsh-quoting-guard。
重启后自检(三条,各一次即可)
// 1. 工具在不在:应看到 pwsh_script / run_argv 两个工具
{ "program": "git", "args": ["--version"] } // run_argv → git version 2.x
// 2. 中文/空格/含引号的路径,数据走 args,正文里不出现任何字面量
{ "script": "(Get-Content -LiteralPath $DSH_ARGS[0] -Raw).Trim()",
"args": ["E:\\some dir\\中文 目录\\sample file.txt"] }
// 3. 参数保真:期望输出 a"b'c(含内嵌双引号)
{ "program": "node", "args": ["-e", "console.log(process.argv[1])", "a\"b'c"] }
三条都通过即安装成功。若工具列表里没有它们,说明组合尚未重新装载 —— 重启 dsh web。
插件契约:inject 是硬性的(1.0.0 就是在这里翻车的)
Cordis 不允许在没有声明的情况下访问 ctx.<服务>。而且代价不是局部报错 —— 一行抛错会让整个插件树装载失败,dsh web 直接起不来:
dsh: plugin tree failed to load: failed to apply loader entry dsh-pwsh-quoting-guard:
cannot get property "tools" without inject
export const inject = ['subprocess', 'tools'] // 因为用到了 ctx.subprocess 与 ctx.tools
| 写法 | 是否需要 inject |
|---|---|
ctx.tools.register(...)、ctx.subprocess.spawn(...) |
✅ 必须声明 |
ctx.get('systemPrompt')、ctx.get('sandbox') 等可选读取 |
❌ 不需要 —— 这正是"服务可能不在"的表达方式 |
ctx.on(...)、ctx.effect(...)、ctx.provide(...) |
❌ 不是服务,是 Context API |
npm run check # 等价于 node scripts/check-inject.mjs
该检查列出每个 ctx.<服务> 访问及其真实行号、对照 inject 声明,缺一个就 exit 1;同时报告"声明了却没用上"的服务(那会让插件无谓地等待)。它已针对真实的故障版本做过阴性验证:对 lib/index.js.bak-before-inject-fix 运行会精确报出 line 247 ctx.tools -> add 'tools' to inject。
⚠️ 另外两点:
- 提示段名
pwsh-quoting-guard(order 106)在同一层内必须唯一:把本插件同时装进 profile 和某个 preset 会因重名而装载失败。 - 改动
lib/index.js后先跑一次npm run check,再重启。
结构与平面:lib/index.js 导出 Cordis 插件契约(name / inject / apply),cordis.patch.yml 声明插入的行。工具注册进宿主 tools 注册表、提示段注册进 systemPrompt,因此属于宿主平面,与 tool-pwsh、tool-bash 同一层。
5. 实测数据(token)
定价使用宿主自己的估算器(@deepseek-ai/dsh-token-meter/estimate:ceil(chars/4)+4,即 context 表所用口径),全部为真实进程实测。
单任务结果侧(tok)
| 任务 | 官方 pwsh |
pwsh_script / run_argv |
|---|---|---|
引号 + $ 正则 |
7 | 7 |
中文/空格/$/%/' 路径 |
12 | 12 |
| 多行 + 中文输出 | 10 | 10 |
| 错误路径 | 85 | 85(且无 CLIXML) |
| 外部程序 hostile argv | 5 —— 但结果是错的 | 6 —— 结果正确 |
成功路径两者相同(都是 PowerShell 自己的输出)。差异出现在失败与重试:失败的旧路线上,一次报错要多花 53%(85 → 130 tok)、一次参数失败要 236 tok。
固定开销(每个请求)
| 项目 | tok |
|---|---|
| 两个工具的 schema | 263 |
| 提示段(1 行) | 50 |
| 合计 | 313 tok/请求 |
盈亏平衡
按实测"一次失败 85–236 tok + 重试参数 17–44 tok"计,每个请求只要避免约 1–3 次含引号的失败调用即可回本。纯短命令(ls 级别)用官方 pwsh 更省。
6. 与官方 pwsh 工具的关系
并存,不替换。 官方 pwsh 仍负责:单行短命令、run_in_background 后台任务、sandbox_permissions 升级通道。
选择表("在目录 X 里跑程序 P,且参数含引号"):
| 路线 | 目录可控 | 参数保真 |
|---|---|---|
官方 pwsh + workdir |
✅ | ❌ 引号被吞 |
pwsh_script + Set-Location |
✅ | ❌ 引号被吞 |
run_argv(不带 cwd) |
❌ 只能会话工作区 | ✅ |
run_argv + cwd |
✅ | ✅ |
只有最后一行两者兼得 —— 这也是 cwd 只挂在 run_argv 上的原因。
7. 已知限制
- 无
run_in_background:长任务请用官方pwsh。 - 单次调用预算 300s(由部署的 timeout policy 执行);输出每流 64 KB,超出写 spill 文件(上限 64 MB)并给出路径。
pwsh_script有意不提供cwd:需要换目录时用Set-Location(文件与 cmdlet 操作完全正确),但经 PowerShell 向原生程序传参仍会失真 —— 那种情况请改用run_argv。- 参数值中含换行时,prelude 之后的行号会偏移对应行数(罕见;正确性不受影响)。
read-only模式下 PowerShell 处于 ConstrainedLanguage,非核心 .NET 静态调用会被拒(部署既有约束,与本插件无关)。- 依赖会话上下文解析默认工作目录:显式
cwd→ 会话工作区 →sandboxPolicy.workspaceRoot;三者都拿不到时给出教学式报错。
8. 故障排查
| 现象 | 原因 / 处理 |
|---|---|
dsh web 起不来,日志里 cannot get property "tools" without inject |
用了 ctx.<服务> 却没声明。1.0.0 的缺陷;1.0.1 已修。修法:export const inject = ['subprocess', 'tools'],并跑 npm run check |
dsh web 起不来,日志里提示段重名 |
同一层装了本插件两份(如 profile + preset 各一份)。只保留一处 |
| 工具列表里看不到两个工具 | 未重启:组合在 dsh web 启动时装载。重启后确认 |
每次调用都返回 exit code: 127 + windows-acl-run: unknown mode |
沙箱包装被套在 danger-full-access 模式上(≤v2 的缺陷)。确认运行的是 ≥v3 的代码 |
cannot resolve a PowerShell executable (tried pwsh, pwsh.exe, powershell.exe, powershell) |
PATH 上没有任何 PowerShell |
sandbox confinement failed under "read-only" ... |
受限模式下包装失败,插件拒绝不包装执行;改用官方 pwsh + sandbox_permissions |
$DSH_ARGS 只有一个元素、内容是所有参数拼起来 |
用了 ConvertFrom-Json 版本(≤v2)。≥v3 用数组字面量 |
报错里出现 <Objs Version="1.1.0.1" ...> 的大段 XML |
用了 -EncodedCommand 版本(≤v2)导致 CLIXML。≥v3 用 -Command |
9. 版本历史
| 版本 | 变化 | 结果 |
|---|---|---|
| v1 | -EncodedCommand + ConvertFrom-Json 前置 |
本环境 100% 失败(沙箱包装 exit 127) |
| v2 | danger-full-access 跳过 confine |
可用性恢复,但参数绑定错误 + CLIXML 噪音 |
| v3 | 改 -Command 单 argv + 数组字面量 + UTF-8 钉住 |
行为正确;但描述冗长(634 tok/请求) |
| v4 | 描述精简、去 cwd、提示段压一行 |
289 tok/请求(-56%) |
| v5 = 1.0.0 | run_argv 加回 cwd;注入 DSH_*;修正无 cwd 时的死胡同报错 |
313 tok/请求,逻辑正确 —— 但移植时漏声明 inject: ['tools'],导致 dsh web 无法启动 |
| 1.0.1 | 补上 inject 中的 tools;新增 scripts/check-inject.mjs 守卫(已对该故障版本做阴性验证);README 补"插件契约"一节 |
启动正常;三条自检实测通过 |
教训:动态 Cordis 插件用
harness.registerTool(ctx, …),从不直接访问ctx.tools;移植成永久插件改成ctx.tools.register(…)时,这个声明就漏了 —— 而静态桩测试因为桩上下文无条件暴露tools,照不出这个问题。npm run check正是为补上这个盲区而写。
10. 许可
MIT。
No comments yet. Be the first to write one.