# dsh-gitbash 设计文档 > 状态:**已定稿(2026-10-18)**。实现以本文为准;契约改动必须先改本文。 > 基线:DSH `0.2.1-rc.2`(桌面运行时 + 源码 checkout 双重核对)。最低支持 `0.2.1`。 --- ## 1. 目标与边界 ### 1.1 目标 在 Windows 上把 DSH 的模型终端工具从官方 `pwsh` 换成 **bash**,并保留官方终端工具的全部行为。bash 来源**同时支持并可选**: - **Git for Windows**(`C:\Program Files\Git` 等,默认优先); - **MSYS2**(`C:\msys64` 等,可选 `UCRT64` / `MINGW64` / `CLANG64` / `CLANGARM64` / `MINGW32` / `MSYS` 环境)。 两套系统的 `MSYSTEM`、`PATH`、环境根不同,插件按“shell profile”区分,而不是只换一个 `bash.exe` 路径。 - 新会话的模型工具目录里只有 `bash`,没有 `pwsh`;`tool:pwsh` 提示词被压掉。 - `command` / `description` / `timeoutMs` / `workdir` / `run_in_background` / 沙箱提权的参数与语义与官方 `tool-bash` 对齐。 - 前台任务可被 `job_kill` 停止;超时转后台(promote)与官方一致。 - cwd 跟随 `workingDirectory`(会话里的 `cd`);`tool/result.meta.cwd` 写入真实执行目录。 - 结果文本、退出码标记、终端卡片、PTC 目录与官方 `tool-bash` 对齐。 - 设置页默认只显示 Shell 选择、兼容模式与一行安全提示;路径/自动发现/测试/MSYS2 环境/诊断折叠在“高级与诊断”;配置持久化、简单。 - 只支持 Windows;非 win32 不注册任何行为。 ### 1.2 非目标 - 不改 DSH 源码/安装文件,不 fork preset,不接管 `ctx.shell`(pwsh 保留给内部调用/其它工具)。 - 不自动选择 WSL 的 `bash.exe`;不把 WSL 当 Git Bash。 - **Cygwin** 只做“可识别、默认不推荐”的 best-effort(不在首批验收范围;发现列表里标记为 experimental)。 - 不在本仓库实现持久 PTY(`minimal` preset 的 persistent shell 不保留;本插件是 one-shot `bash -c`)。 --- ## 2. 平台硬约束(必须如实面对) ### 2.1 实测结论 Git Bash(MSYS2 运行时)**无法在 DSH 官方 Windows ACL 沙箱里启动**。本机实测(0.2.1-rc.2,Windows 10 19045): | 令牌 | 结果 | |---|---| | 官方受限令牌(WRITE_RESTRICTED + restricting SIDs) | `bash.exe`: `*** fatal error - CreateFileMapping S-1-5-21-…-1001.1, Win32 error 5`;`ls.exe`: `NtCreateDirectoryObject(\BaseNamedObjects\msys-2.0S5-…) 0xC0000022`;MSYS2 bash: `couldn't create signal pipe, Win32 error 5`。**且 `bash.exe` 可能以 exit 0 结束、无输出**。 | | 受限令牌 + 当前用户 SID 加入 restricting 列表 | bash 可运行;但**用户拥有的工作区外文件也可写/可删**(实测 `C:\Users\River\...` 写入成功)——沙箱被实质击穿。 | | 上述令牌再降到 Low 完整性 | bash 又死在 `BaseNamedObjects` 创建(`0xC0000022`)。 | 根因是 MSYS2 启动时必须创建**带当前用户 SID 的命名对象**(共享内存、signals 管道、`BaseNamedObjects` 子目录);WRITE_RESTRICTED 令牌把这些 SID 标成 deny-only,Low 完整性又禁止向 Medium 的对象命名空间写入。二者不可兼得。 ### 2.2 决策:体验优先 + 显式标注 所有者 2026-10-18 拍板:**体验优先**。因此: - `danger-full-access`:bash 正常执行。 - `workspace-write`:**默认**仍启用 bash,但以**完全访问**执行;设置页红字警告;结果文本与 `sandbox.mode` 如实写 `danger-full-access`。 - `read-only`:**拒绝执行**,并给出可操作提示(切到更宽模式 / 用官方 `sandbox_permissions` 提权)。read-only 的“不写入”承诺不能被悄悄破坏。 - `strict` 模式(可选):`workspace-write` 下也拒绝,仅 `danger-full-access` 可用。给安全敏感用户。 - **绝不静默降级**:每次 bash 结果都带一行说明“本次以完全访问执行”;设置页常驻红字;工具描述与 system prompt 都写清。 - 文件工具(`read`/`write`/`edit`/`glob`/`grep` 等)**仍由官方沙箱保护**,不受本插件影响。 > 这就是本插件唯一的安全代价,也是 Windows 上所有可用 Git Bash 插件的共同代价;本插件的区别是把代价**显式化、可关闭、可审计**,而不是偷偷绕过。 --- ## 3. 总体架构 ```text agent/created (serial, awaited) └─ 在 agent.ctx 里(每个会话独立,子代理继承后跳过) ├─ tools.restrict({ deny: ['pwsh'] }) 隐藏 preset 注册的 pwsh ├─ tools.register(bashTool) 注册本插件的 bash └─ systemPrompt.section(...) 压掉 tool:pwsh,写 tool:bash │ bashTool.execute ──► resolve cwd ──► resolve sandbox mode ──► spawn bash -c │ │ │ └─ danger-full-access / compat → 不 confine ├─ ctx.jobs.start / wait / read / kill / remove(后台、前台可见、promote) ├─ ctx.subprocess.spawn(collect stdout/stderr、spill、grace、terminate) └─ output.render + presentationMeta.cwd(终端卡片 / PTC / meta) ``` ### 3.1 为什么用 `agent/created` + scoped `tools.restrict` - DSH 0.2.1 的 preset 是 `@deepseek-ai/dsh-web-app` 的 patch 文件;没有受支持的“改一个 preset 的某一行”的插件 API(`preset:` patch 只有文档没有实现)。重述整个 preset 会把未来官方新增的行全部丢掉。 - `agent.ctx.tools.restrict({ deny: ['pwsh'] })` 是官方 per-agent 作用域机制,只过滤**继承来的**工具,不影响 host / 其他 agent / 子代理(子代理继承父层结果后,`restrict` 因“未知工具”抛错,直接跳过)。 - `systemPrompt.section({ name: 'tool:pwsh', text: '' })` 同名 scoped section 会 shadow 外层;空文本被丢弃。 - 监听器在 `agent/created` 上是 **serial + awaited**,注册在返回前完成,模型第一轮就能看到 bash。 - 设置只在**新会话**读取一次(运行中的会话不换工具/提示词,避免 KV cache 与历史工具调用错位)。 ### 3.2 模块划分 ```text src/ ├── index.ts # host 半区入口:apply、Config、设置读取、路由/替换装配 ├── shared/ │ └── protocol.ts # host/client 共享的 wire 常量与类型(单一事实来源) ├── host/ │ ├── settings.ts # Config(schemastery、volatile)+ 解析/校验 │ ├── discover.ts # Git/MSYS2/Cygwin profile 发现、WSL 排除、环境枚举、校验、版本探测 │ ├── env.ts # profile 专属 MSYSTEM/PATH/ENV 覆盖、POSIX↔Windows 路径 │ ├── escalation.ts # 官方 escalation 语义的本地窄化实现(逐字) │ ├── executor.ts # ctx.subprocess.spawn 封装:deadline、collect、kill、result │ ├── jobs.ts # processSources / processJob / ringDelta / promote │ ├── render.ts # 结果文本、退出码标记、截断、兼容模式提示 │ ├── bash-tool.ts # defineTool 等价定义:params/output/execute/present* │ ├── replace.ts # agent/created:restrict + register + prompt shadow │ └── routes.ts # /api/dsh-gitbash/{state,discover,validate} └── client/ ├── index.ts # 注册 settings.general.item ├── row.tsx # 设置行 UI ├── store.ts # configForms + /api 状态 ├── locales.ts # zh/en └── types.ts # 客户端契约窄化 ``` --- ## 4. 契约(0.2.1) ### 4.1 替换 - 事件:`agent/created`,payload `{ agent, source, signal? }`;监听器可 async,serial。 - 能力探测(缺一即跳过,不抛): - `process.platform === 'win32'` - `agent.ctx` 存在 - `agent.ctx.get('tools')` 的 `register` / `restrict` 是函数 - `agent.ctx.get('systemPrompt')` 的 `section` 是函数 - host `ctx.get('subprocess')?.spawn` 是函数 - 动作顺序(失败全部回滚): 1. `tools.restrict({ deny: ['pwsh'] })`(抛错 = 该 agent 已无 pwsh,跳过) 2. `tools.register(bashTool)` 3. `systemPrompt.section({ name: 'tool:pwsh', order: getSectionOrder('TOOL_PWSH'), text: '' })` 4. `systemPrompt.section({ name: 'tool:bash', order: getSectionOrder('TOOL_BASH'), text: <官方那句> })` 5. compat 模式追加一段 `tool:bash:windows` 安全提示 - 所有 disposer 注册到 `agent.ctx.effect`;`agent/created` 本身在 agent 释放时自动解绑。 ### 4.2 工具参数 | 参数 | 类型 | 说明 | |---|---|---| | `description` | string, required | 5–10 词主动语态描述(UI 展示) | | `command` | string, required | bash 命令(`bash -c`) | | `timeoutMs` | number | 执行器默认 120000、上限 600000;`promoteOnTimeout` 时超时转后台 | | `workdir` | string | 绝对或相对会话 cwd;相对路径按会话 cwd 解析 | | `run_in_background` | boolean | 仅在 jobs 可用时出现;无超时 | | `sandbox_permissions` | enum `workspace-write`/`danger-full-access` | 仅在 `ctx.shell.sandboxMode` 存在时出现 | | `justification` | string | 与 `sandbox_permissions` 成对,非空一句 | 参数根是开放的(未知键被忽略);执行只读命名字段。 > **Schema 形态(真机踩过)**:`parameters` 与 `output.schema` 都是**标准 JSON Schema** > —— `required` 是**数组**,写在对象节点上;叶子节点不写 `required: true`。 > 官方工具用 `defineTool` 的 DSL(叶子 `required: true`)并在注册时编译;本插件无法 import > `defineTool`,返回普通对象,**不能依赖那次编译**。真机曾因此报 > `Invalid schema for function 'bash'`;测试需断言 `parameters.type === 'object'`、 > `parameters.required` 是数组、`output.schema.oneOf` 的每个分支 `required` 是数组。 ### 4.3 结果值(closed oneOf) 输出 schema 与参数一样是**标准 JSON Schema**;三个分支都 `additionalProperties: false`, `required` 是数组。`compat` / `sessionMode` 是本插件附加的可选字段(Web UI 忽略未知字段, PTC SDK 可见),用于在结果里携带兼容模式事实。 ```ts type BashValue = | { kind: 'background'; jobId: string; cwd: string } | { kind: 'promoted'; cwd: string; jobId: string; timeoutMs: number; output: string } | { kind: 'foreground'; cwd: string; exitCode: number | null; signal: string | null; timedOut: boolean; aborted: boolean; stopped?: string; timeoutMs: number; stdout: { text: string; truncated: boolean; spillPath?: string }; stderr: { text: string; truncated: boolean; spillPath?: string }; sandbox?: { mode: string; denied: boolean; enforcement?: string; runnerFailed?: boolean } } ``` - `output.presentationMeta(args, value) => ({ cwd: value.cwd })`,0.2.1 会写入 `tool/result.meta`;Web 终端卡片优先读它。 - `output.render` 产出**恰好一个 text block**: - background:`started background job ` - promoted:`\n[still running after ms; moved to background job ]\n` + 交接提示 - foreground:stdout + `[stderr]` 段 + 标记行 - 标记(逐字):`[exit code: N]`、`[killed by signal: X]`、`[timed out after Nms]`、`[stopped: reason]`、`[sandbox: file access denied under mode]`、`[sandbox: escalation available — retry this exact command once with sandbox_permissions ...]`。 - **0.2.1 Web UI 不消费 `presentCall`/`presentResult`**;卡片由工具名 `bash`、参数、结果文本标记、`meta.cwd` 反推。presenter 仍实现,供外部 UI bridge。 ### 4.4 执行器语义 - `ctx.subprocess.spawn({ argv: [bashPath, '-c', command], cwd, stdio: { stdin:'ignore', stdout: collect(64_000, spill 64 MiB), stderr: collect(64_000, spill 64 MiB) }, graceMs: 3000, signal, env })`。 - 超时:`onExpiry:'kill'`(无 jobs / 前台)用定时器 + `terminate()`;`onExpiry:'none'`(jobs promote)交给 `jobs.wait`。 - 首因分类:`timedOut` 与 `aborted` 互斥;abort 先到就是 aborted。 - 进程结束分类:`signal !== null` → killed;否则 completed。 - **Windows 信号归一化**:Git Bash/MSYS2 被信号杀死时,Windows 退出码是 `signalNumber << 8`(`SIGTERM` → 3840,`SIGKILL` → 2304),而 Node 在 Windows 上始终报 `signal: null`。执行器把 `>255` 且低 8 位为 0 的退出码按 `os.constants.signals` 反查为信号名,`exitCode` 置 null;正常 bash `exit N` 被截断到 0..255,`0xC0000005` 等崩溃码低位非 0,仍按退出码报告。 - 输出:`readFrom(0)`;`truncated = lossy`;`spillPath` 透传;stdout/stderr 分段合并。 - **启动失败检测**:exit 0 但 stderr 命中 `*** fatal error - ` / `couldn't create signal pipe` → 视为启动失败(`exitCode` 改 1 + 说明),防止 MSYS2 的 exit-0 假成功。 - 生成失败(ENOENT/EACCES)→ 明确错误:"Git Bash 无法启动:;请在设置里重新发现/校验路径"。 ### 4.5 Jobs - `kind: 'bash'`(官方 `JobKindMap` 已有);`label` = 原始 command;`owner` = `agent.id`;`toolCall: { callId, rootCallId }`;`output` = 懒读取的 stdout/stderr 两个 source。 - 前台在有 jobs 时也注册为 job(可在运行中被 kill);在 wait 内 settle → `remove`,模型看不到 id。 - wait 超时且进程已启动 → `promoted`:做**一次** consuming `read` 作为已产生输出,job 继续跑。 - wait 超时但进程还没启动 → 合成 `foreground`:`timedOut:true, exitCode:null`,文本 `(no output)\n[timed out after ms]\n[exit code: null]`。 - `promoteOnTimeout` 需要 `enableRunInBackground && registry && admission 成功`;admission 失败 → warn + 退回 deadline kill。 - 后台取消:`AbortController` → `terminate()`;`done` 不 reject,异常映射为 `failed`。 ### 4.6 沙箱模式解析 - `effective = ctx.sandboxPolicy.resolve({ session }).mode`;无该服务 → `danger-full-access`。 - `sandbox_permissions` + `justification`:走官方提权语义(严格更宽、`ctx.approval.request`、固定文案)。批准后的 mode 只作用于本次调用。 - 本插件**不调用** `ctx.sandbox.confine`:MSYS2 在官方 ACL 令牌下无法启动。bash 以当前用户 Medium 令牌直接 spawn。 - 结果 `sandbox`: - 实际完全访问 → `{ mode: 'danger-full-access', denied: false }` - 文本追加 `[bash: running outside the DSH file sandbox — session mode ]`(compat 时) - read-only:抛错,提示升级到 `workspace-write`(compat 下仍以完全访问执行)或 `danger-full-access`;不得静默。 --- ## 5. 发现与校验 ### 5.1 Shell profile 模型(两套系统一等公民) 用户选的不是一个裸 `bash.exe`,而是一个 **shell profile**: ```ts interface BashProfile { id: string // flavor|root|msystem 的稳定键 flavor: 'git' | 'msys2' | 'cygwin' path: string // bash.exe 绝对路径 root: string // 安装根:...\Git / ...\msys64 / ...\cygwin64 msystem?: 'MINGW64' | 'UCRT64' | 'CLANG64' | 'CLANGARM64' | 'MINGW32' | 'MSYS' environmentRoot?: string // MSYS2 的 ...\ucrt64 / ...\mingw64 / ...\usr label: string // UI 文案:Git for Windows 2.56 / MSYS2 UCRT64 version?: string } ``` - **Git for Windows**:一个安装 = 一个 profile,`msystem='MINGW64'`,`path` 固定用 `\usr\bin\bash.exe`。用户填 `\bin\bash.exe` 时自动归一化到 `usr\bin`(Git 2.56 的 `bin` launcher 会把 `MSYSTEM` 重置为 UCRT64,必须绕开)。 - **MSYS2**:一个安装可枚举多个 profile(每个环境一个):`UCRT64`、`MINGW64`、`CLANG64`、`CLANGARM64`、`MINGW32`、`MSYS`;`path` 都是 `\usr\bin\bash.exe`,但 `msystem` 与 `environmentRoot` 不同。**两个 MSYS2 环境共用同一个 bash.exe,所以 `bashPath` 单独不足以区分,必须同时持久化 `msystem`。** - **Cygwin**:best-effort,`flavor='cygwin'`,无 `MSYSTEM`;默认排在 Git/MSYS2 之后,UI 标注 experimental。 ### 5.2 候选来源(按优先级) 1. 用户配置的 `bashPath` + `msystem`(最高优先;无效则**不**回退,明确报错)。 2. 注册表 `HKLM\SOFTWARE\GitForWindows` / `HKCU\SOFTWARE\GitForWindows` 的 `InstallPath` → Git profile。 3. 已知根: - Git:`%ProgramFiles%\Git`、`%ProgramFiles(x86)%\Git`、`%LocalAppData%\Programs\Git`、`C:\Git`、`D:\Git`; - MSYS2:`C:\msys64`、`D:\msys64`、`%LocalAppData%\msys64`、`C:\msys32`; - Cygwin:`C:\cygwin64`、`C:\cygwin`。 4. `where.exe git` → 从 `git.exe` 反推安装根(`usr\bin` 与 `ucrt64`/`mingw64`/`clang64`/... 环境 bin 都按三层推导);`where.exe bash` → 每一行(排除 WSL 后)识别 flavor。手填 Git `bin\bash.exe` 会归一化到 `usr\bin`。 5. `PATH` 中每个目录下的 `bash.exe` / `git.exe`。 每个根先识别 flavor(MSYS2 marker 优先:`etc\msys2-release`、`msys2.exe`、`pacman.exe`、`mingw64\bin`;Git marker:`cmd\git.exe`、`mingw64\bin\git.exe`),再枚举 profile。按 `realpath + msystem + flavor` 去重。 ### 5.3 硬排除 - `%WINDIR%\System32\bash.exe`、`Sysnative`、`WindowsApps\`、`\wsl$\`、任何 `wsl.exe`。 - 文件名必须是 `bash` / `bash.exe`(大小写不敏感)。 - 必须是真实存在的**文件**(不是目录/悬空链接)。 - MSYS2 profile 的 `environmentRoot\bin` 必须存在;否则该 profile 无效。 ### 5.4 版本探测(只在“自动发现”/“测试”时) - `spawnSync(path, ['--version'], { timeout: 2000, windowsHide: true })`。 - 成功输出匹配 `GNU bash, version X.Y.Z...`;记录版本。 - Git/MSYS2 的 `bash --version` 都写 `x86_64-pc-cygwin`,**不能靠版本字符串区分 flavor**;flavor 由安装 marker 判定。 - 失败 → 候选标记 `valid:false` + 原因;不阻断其它候选。 - **`/state` 路由不扫描、不 spawn**(设置行打开必须瞬时)。 ### 5.5 路径与 cwd - 绝对 POSIX 盘符路径 `/c/...`、`/d/...`、`/cygdrive/c/...` → 转 Windows `C:\...`(作为 spawn cwd / meta.cwd)。 - 相对路径 → `path.resolve(sessionCwd, workdir)`。 - `/tmp` → `os.tmpdir()`;其它非盘符绝对 POSIX 路径(`/home/...`)→ 明确报错,要求 Windows 路径(避免猜错 MSYS 映射)。 - `pwd` 在 bash 里仍是 `/d/...`;模型看到 POSIX,`meta.cwd` 是 Windows 绝对路径(Web UI 要求)。 ### 5.6 环境(按 profile 区分) - 公共:`NO_COLOR=1`、`TERM=dumb`、`PAGER=cat`、`GIT_PAGER=cat`、`CHERE_INVOKING=1`。 - **Git for Windows**:`MSYSTEM=MINGW64`;`PATH = \bin;\usr\bin`,再追加存在的 `\ucrt64\bin` / `\mingw64\bin` / `\mingw32\bin`,最后 ``。 - **MSYS2**:`MSYSTEM=`;`MSYS2_PATH_TYPE=inherit`;`PATH = \bin;\usr\bin;\usr\local\bin;`;`MSYS` 环境则 `\usr\bin;\usr\local\bin;`。 - **Cygwin**:不写 `MSYSTEM`;`PATH = \bin;`。 - `dshEnv = ctx.shellEnv.collect(exec)` 最后合并,保证 `DSH_*` 不被覆盖。 - **清除继承的 `PWD`/`OLDPWD`**:宿主进程可能导出 Windows 形式(如 `D:\work`); bash 在 cwd 匹配时会信任它,导致 `$PWD`/`pwd` 变成 `D:/...`。`composeEnv` 用显式 `undefined` tombstone 让 subprocess 服务删除这两个环境项,由 bash 从真实 cwd 推导 POSIX 路径。 - 不覆盖 `HOME`、`TMP`、`TEMP`、`LANG`(尊重用户 dotfiles 与区域设置)。 - 若 `msystem` 与 path 的 flavor 不匹配(例如 Git 配 `UCRT64`),**拒绝执行**并提示重新选择,而不是猜。 ### 5.7 选择与持久化 - Config:`bashPath`(空 = auto)+ `msystem`(`auto` 或具体值)。 - `auto` 的推导:Git → `MINGW64`;MSYS2 → 按存在的环境根优先 `UCRT64 > MINGW64 > CLANG64 > MSYS`;Cygwin → 空。 - “自动发现”的默认选中:Git for Windows 优先;没有 Git 再选 MSYS2 的最佳环境。用户可显式选 MSYS2。 - UI 选择某个 profile 时,同时写入 `bashPath` 与 `msystem`;手填路径时自动识别 flavor,MSYS2 多环境则由 `msystem` 下拉决定。 - 配置了 MSYS2 的 `msystem` 但该环境根不存在 → 无效,设置页红字,替换跳过。 --- ## 6. 设置与 UX ### 6.1 配置(entry Config,namespace = entry id `dsh-gitbash`) | 字段 | 类型 | 默认 | 说明 | |---|---|---|---| | `bashPath` | string, volatile | `''` | 空 = 自动发现;非空必须通过校验 | | `msystem` | `'auto' \| 'MINGW64' \| 'UCRT64' \| 'CLANG64' \| 'CLANGARM64' \| 'MINGW32' \| 'MSYS'`, volatile | `'auto'` | 仅 MSYS2 需要;Git 忽略并强制 MINGW64 | | `compat` | `'auto' \| 'strict'`, volatile | `'auto'` | auto:workspace-write 以完全访问跑;strict:只允许 danger-full-access | - 只有 `.volatile()` 字段进设置表单;值由 profile `cordis.patch.yml` 持久化。 - 新会话生效;运行中会话不换工具。 - 跨字段校验(路径存在/非 WSL)放在**使用点**(`agent/created` 与路由),schema 不表达。 ### 6.2 设置行(`settings.general.item`,id `git-bash`,order 60) 默认只显示三层,保持和官方 General 行相近的密度: 1. **标题 + 生效 profile 徽章**(如 `Git for Windows` / `MSYS2 UCRT64`)。 2. **Shell 选择**:`自动(推荐)` + 已缓存 profile(Git / 各 MSYS2 环境), 直接选中即写 `bashPath + msystem`;旁边一个 `刷新` 执行带版本探测的发现。 候选来自 `/state` 的缓存,打开设置不需要等待扫描。 3. **兼容选择**(auto/strict)+ **一行**红字安全提示;完整解释放在 `title` 与 README。 折叠的 **高级与诊断** 里才是:手动路径 + 使用/恢复自动/测试、MSYS2 环境下拉、 平台 / `ctx.shell.sandboxMode` / 后台任务 / 最近替换。这样默认界面不展示诊断表格, 但排障信息仍在一处。 - Shell 选择按 `path + msystem` 匹配当前配置;Git 的 `bin\bash.exe` 已归一化到 `usr\bin\bash.exe`,避免 launcher 重置 `MSYSTEM`。 - 所有控件在 `configForms` 不可写时禁用并显示原因;不阻塞设置页打开。 - 三个原生 `