Skip to content
dsh.fish
Bundle

dsh-pwsh-quoting-guard

DSH 插件:pwsh_script 与 run_argv 两个工具,从结构上消除 Windows PowerShell 的命令字符串引号转义错误。 · Two DSH tools that remove the PowerShell command-string quoting layer: a multi-line body passed as one argv element with structured $DSH_ARGS data, plus a shell-free argv runner.

Source
fengbai2233
License
MIT
Updated
Updated 8 hours ago

Readme

# 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]` …… |

```jsonc
// 读取一个含空格/中文/$/%/单引号的路径
{
  "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 | | 工作目录;默认会话工作区 |

```jsonc
// 在指定目录里跑 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. 工作原理(为什么不会再出错)

1. **正文作为单个 argv 元素**交给 `-Command`。它不经过任何 shell、不做二次拼接、不被再次当作字符串嵌入 —— 与官方执行器同款配方。
2. **数据走 `args`**:插件用 PowerShell **数组字面量** 把参数绑成 `$DSH_ARGS`,只对单引号做加倍 —— **转义由插件代码完成,模型永远不转义**。刻意不用 `ConvertFrom-Json`(见根因 4)。
3. **第 1 行 prelude** 同时做三件事:钉住 `[Console]::OutputEncoding` / `$OutputEncoding` 为 UTF-8、静默进度流、绑定 `$DSH_ARGS`;全部放在同一行,因此**模型自己写的行号仍然准确**(报错会指到 `At line:1 char:188` 这样的真实位置)。
4. **`run_argv` 完全不经过 shell**:`ctx.subprocess.spawn(argv)` 是"argv 即最终 argv"的接缝,参数不会被解析、拆分或展开;`cwd` 直接作为子进程工作目录。
5. **结果词表与官方 `pwsh` 工具一致**:stdout、可选 `[stderr]` 段、`[output truncated; full output: <path>]`、`[exit code: N]`(0 不输出标记)、超时/中断标记。成功路径上两者的结果侧 token 完全相同。
6. **环境对齐**:注入 `NO_COLOR=1`、`PAGER=cat`、`GIT_PAGER=cat`,并通过 `shellEnv` 注册表带上受管的 `DSH_*`(`DSH_HOME`、`DSH_SHELL`、`DSH_SESSION_ID` …)。
7. **沙箱**:`read-only` / `workspace-write` 下经 `ctx.sandbox.confine()` 包装,包装失败即 **fail closed**(绝不静默放开);`danger-full-access` 下跳过包装(该模式下 ACL runner 本身拒绝 `danger-full-access`,包装会导致 100% 失败)。

---

## 4. 安装与卸载

本地 link 安装(开发态):

```powershell
dsh plugin --profile web add link:E:\deepseekwork\插件\dsh-pwsh-quoting-guard
```

该命令会:pnpm 安装依赖 → 识别包内 `dsh.bundle.patch` → 自动登记进 profile 的 `dsh.profile.bundles`。**重启 `dsh web` 后生效**(组合在启动时装载)。

卸载:

```powershell
dsh plugin --profile web remove dsh-pwsh-quoting-guard
```

发布到 npm 后也可直接 `dsh plugin --profile web add dsh-pwsh-quoting-guard`。

### 重启后自检(三条,各一次即可)

```jsonc
// 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
```

```js
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 |

```powershell
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。

Install

dsh plugin --profile web add github:fengbai2233/dsh-pwsh-quoting-guard

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source