Merge pull request #593 from omdsh-dev/feat/terminal-shell-quoted-args-probe

feat(terminal): 终端 shell 配置加固——引号解析、POSIX 预检、本地化报错
This commit is contained in:
Atlas Zeng
2026-09-08 22:28:22 +08:00
committed by GitHub
28 changed files with 1034 additions and 50 deletions
@@ -0,0 +1,79 @@
# 终端 Shell 配置加固(引号解析 + 双平台预检 + 友好报错)设计
日期:2026-09-08 | 状态:已确认(用户选定方案二) | 分支:`feat/terminal-shell-quoted-args-probe`
## 背景与现状
配置启动终端命令的功能已存在于 main(`fd9544f` #95 部署级 `config.shell`/`shellArgs` + pwsh 探测;`c973d6e` #125 设置页 `terminalShell`/`terminalShellArgs` 用户偏好,覆盖顺序:设置页 > yaml > 平台自动,UI 标签页与模型 `terminal_*` 工具两终端面共用)。本次不是新增功能,而是加固三处已知缺口:
1. **参数解析硬切**:`shellOverridesOf`(`src/index.ts:257`)用 `args.split(/\s+/)`,引号内空格被切碎(`-File "C:\my script\init.ps1"` 得到 3 个参数)。
2. **POSIX 无预检**:`resolveShellExecutable`(`src/pty-manager.ts:351`)仅 win32 做存在性探测(PATHEXT/PATH/System32/pwsh 目录,找不到抛 `SidebarError('pty-error', 'shell executable not found: …')` → WS catch `ws.close(1011, message)`);POSIX 直接透传 node-pty,配错路径只得到 `[process exited with code N]`。
3. **报错不可操作**:客户端 `TerminalView.tsx:247` 对任意 `1011 + reason` 显示原始英文 reason 横幅,没有本地化说明与"去设置改"指引;粘贴带引号路径(`"C:\Program Files\…\pwsh.exe"`)会因引号参与路径解析而失败;`shell.get` 返回启动时解析的 shell,设置页覆盖后新终端标签标题不跟随。
## 方案取舍
- **方案一(最小)**:只修引号参数解析。POSIX 配错仍不可诊断,引号路径仍炸——否。
- **方案二(选定)**:解析 + 双平台预检 + 本地化友好报错 + 标题跟随。不碰声明式设置契约。
- **方案三(全家桶)**:方案二 + 设置页实时 `shell.probe` inline 校验。校验型输入行需扩展 declarative settings 契约(`service.ts` + `docs/external-plugin-guide.md`),收益/成本比低(保存后开终端立即可见)——YAGNI,不做。
## 设计
### 1. 引号感知参数解析 `splitShellArgs`(`src/pty-manager.ts`)
- 空白分词;`'…'` / `"…"` 分组,组内字符**全部字面**——`\` 不是转义符(Windows 路径场景直接可用)。代价:参数值本身含引号无法表达,注释注明该取舍。
- 未闭合引号宽容处理:剩余输入并入当前 token(不抛错——设置页输入体验优先)。
- 空引号对 `""` 丢弃,不产生空参数。
- `shellOverridesOf` 中 `args.split(/\s+/).filter(Boolean)` 替换为 `splitShellArgs(args)`。
### 2. 路径去引号 `unquotePath`(`src/pty-manager.ts`)
- 长度 ≥2 且首尾为成对 `"` 或 `'` 时剥掉,否则原样返回。
- 统一入口:`shellOverridesOf` 与 `resolveShellExecutable` 首行均走它(单一实现防两处漂移)。
### 3. POSIX 预检(泛化 `resolveShellExecutable`)
- POSIX 分支:配置值含 `/` → `existsSync` 直查;裸名 → 沿 `env.PATH`(`:` 分隔,缺省回退 `/usr/bin:/bin`)逐目录探测。
- 找不到统一抛 `SidebarError('shell-not-found', 'shell executable not found: "…"')`(新错误码;消息保持英文技术文案——模型侧 `terminal_create` 工具报错复用同一消息,模型读英文更稳)。
- `ShellExecutableResolutionOptions` 的 `platform/env/exists` 注入口径不变,win32 逻辑不动。
### 4. 友好报错(host ↔ client 线协议)
- `SidebarError` 增加可选 `meta` 载荷,`shell-not-found` 时携带配置的 shell 原文。
- WS 升级 handler catch(`src/index.ts` 终端 upgrade)识别 `error.code === 'shell-not-found'` → close reason 发机器标记 `shell-not-found:<shellDisplayName(shell)>`(受 WS close reason 123 字节上限约束,超长截断显示名)。
- `TerminalView.tsx` `onclose` 在通用 1011 分支**之前**识别 `reason.startsWith('shell-not-found:')` → 本地化 fatal 横幅:`terminalShellNotFound`(「未找到配置的 Shell:<名>」)+ `terminalShellNotFoundHint`(「请检查 设置 → 侧边卡片 → 终端 的 Shell 路径」)。
- locale 新 key 落点:`locales.ts`(zh + en 两块)+ `locales-ja.ts`(仓库规则强制同步),其余语言文件走既有回退链。
### 5. 标题跟随
- `shell.get` 路由改为 `overrides.shell ?? 启动时解析值`,`shellDisplayName` 随之;仅影响新开标签的标题,不触碰已存在 pty。
## 错误流总览
```
设置页 terminalShell/Args(或 yaml config.shell/Args)
→ shellOverridesOf:unquotePath(shell) + splitShellArgs(args)
→ ptyManager.open / registry.create
→ resolveShellExecutable:win32 既有探测 ∥ POSIX 新探测
→ 找不到:SidebarError('shell-not-found', msg, meta:{shell})
├─ WS 路径:catch → close(1011, 'shell-not-found:<name>') → TerminalView 本地化横幅 + 设置指引
└─ 工具路径:terminal_create 抛英文消息 → 模型可见,自行纠正
```
## 测试计划
- `tests/pty-helpers.spec.ts`:`splitShellArgs` 语义矩阵(普通分词 / 单双引号 / 含空格路径 / 反斜杠字面 / 未闭合宽容 / 空引号丢弃 / 连续空白);`unquotePath`(成对剥、不成对留);POSIX 探测(fake `options.env.PATH` + `options.exists`:命中解析、绝对路径缺失抛 `shell-not-found`);win32 既有用例保持绿。
- `shellOverridesOf` 引号行为与 `shell.get` 跟随:就近扩展现有 spec,无宿主则新建。
- 客户端横幅为纯展示逻辑,已有 TerminalView spec 则扩展,否则不强行 e2e。
- 门禁:`pnpm typecheck` + `pnpm test`;不改 chunk 边界与 exclude 配置,mount e2e 不受影响。
## 实施偏差记录
- **报错文案合并为单 key**:设计时的 `terminalShellNotFound` + `terminalShellNotFoundHint` 两 key 合并为单个 `terminalShellNotFound`(含 `{name}` 占位符,一句话内含指引),避免跨语言的句子拼接问题。
- **POSIX 旧契约用例被有意改写**:`tests/pty-helpers.spec.ts` 原用例 `keeps POSIX bare shell resolution delegated to execvp` 锁定「POSIX 裸名直通 execvp」,与本设计冲突,已删除并由 `POSIX: resolves bare names along PATH…` 等新用例替代。
- **locale 落点为全部 21 词典**:设计原计划只写 zh/en/ja,实施时受 `tests/locales.spec.ts` 的「第三方词典键集必须与 zh 相等」门禁约束,`terminalShellNotFound` 实际落入 `locales.ts`(zh+en 双块)+ 19 个 `locales-*.ts`(共 21 文件 22 处),`chunks/locale.tsx` 的 `Record<CopyKey, string>` 类型检查兜底。
- **`resolveShellExecutable` 的 JSDoc 同步**:win32 错误码从 `pty-error` 变更为 `shell-not-found` 后,函数上方文档一并更新(commit `8704994`)。
- **本机全量测试基线**:`pnpm test` 在本机(Windows)存在 29 个**预存**环境性失败(5 个 client spec 的 jsdom `reading 'clear'` ×24、smoke 的 git 身份 ×2、git.spec 截断用例、agent-pty 的 node-pty「Signals not supported on windows」unhandled error)。经与 `origin/main` 同法全量对比确认**零新增失败**(main 30 个,feat 29 个——main 上多出的 `git-worktree` 用例为 flaky)。CI 以 CI 环境为准。
## 交付
- AGENTS 硬约束:`feat/*` 分支开发,commit 后 `gh pr create`,review 合并进 main;不直推。
@@ -0,0 +1,645 @@
# 终端 Shell 配置加固 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 设置页/yaml 配置的终端 shell 支持引号参数与带引号路径,POSIX 与 Windows 一致地预检可执行文件,配置错误时终端标签显示本地化的可操作报错,且终端标题跟随设置覆盖。
**Architecture:** 全部改动在插件仓库内:`pty-manager.ts` 新增两个纯函数(`unquotePath`/`splitShellArgs`)并把 `resolveShellExecutable` 的探测泛化到 POSIX(统一抛新错误码 `shell-not-found`,`SidebarError` 加可选 `meta`);`index.ts` 的 `shellOverridesOf` 改用新解析器、`shell.get` 返回覆盖后的有效 shell、`attachTerminal` 的 catch 把错误映射为机器可读 WS close reason `shell-not-found:<名>`;`TerminalView.tsx` 识别该前缀渲染本地化横幅(全部 21 个语言词典同步新 key)。
**Tech Stack:** TypeScript(tsdown 构建,vitest 测试,无新依赖)
**Spec:** `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`(本计划与其同读;偏差回填其「实施偏差记录」节)
## Global Constraints
- 禁止修改 DSH 源码(`~/.dsh/source/current` 零写入)。
- 代码改动走 `feat/*` 分支 + `gh pr create`,不直推 main。
- 不新增任何依赖;不改 `dependencies`/`peerDependencies`/`scripts`。
- **i18n 键集相等是硬门禁**:新 key 必须同时加进 `locales.ts`(zh + en 两块)与全部 19 个第三方词典(`locales-ja/-de/-fr/-pt/-ko/-ar/-hi/-id/-tr/-vi/-th/-ru/-it/-nl/-sv/-pl/-zh-HK/-zh-TW/-zh-MO.ts`);`chunks/locale.tsx` 的 `Record<CopyKey, string>` 类型检查 + `tests/locales.spec.ts` 双重拦截。
- 不改 vitest `exclude`;不改 chunk 边界(不新增 core bundle 的静态 import)。
- 测试命令一律 `pnpm exec vitest run <file>`(仓库用 vitest + tsconfig `allowImportingTsExtensions`)。
- 每个任务以 commit 结束,conventional commits(`feat(terminal): …` / `test(terminal): …`),正文可中文。
---
### Task 0: 建分支 + 提交设计与计划文档
**Files:**
- Create: 分支 `feat/terminal-shell-quoted-args-probe`
- 已存在待提交: `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`、`docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md`
- [ ] **Step 1: 确认工作树干净并建分支**
```bash
git status --porcelain # 期望为空(main 已与 origin/main 同步)
git checkout -b feat/terminal-shell-quoted-args-probe
```
- [ ] **Step 2: Commit 设计与实施计划**(文档随 feat 分支走 PR,保持 PR 自洽)
```bash
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md
git commit -m "docs(plans): 终端 shell 配置加固设计与实施计划"
```
---
### Task 1: `unquotePath` + `splitShellArgs` 纯函数(TDD)
**Files:**
- Modify: `src/pty-manager.ts`(文件末尾 `shellSpawnArgs` 之后追加)
- Test: `tests/pty-helpers.spec.ts`
**Interfaces:**
- Produces: `unquotePath(value: string): string`、`splitShellArgs(input: string): string[]`(均导出,Task 3 的 `shellOverridesOf` 消费)
- [ ] **Step 1: 写失败测试** — `tests/pty-helpers.spec.ts` 顶部 import 补两个名字(第 19-25 行的现有 import 块):
```ts
import {
defaultShell,
ensureSpawnHelper,
resolveShellExecutable,
shellDisplayName,
shellSpawnArgs,
splitShellArgs,
unquotePath,
} from '../src/pty-manager.ts'
```
在 `describe('pty helpers', () => {` 内、`'restores the spawn-helper executable bit idempotently'` 用例之后追加:
```ts
it('unquotes a paired surrounding quote from a configured shell path', () => {
expect(unquotePath('"C:\\Program Files\\PowerShell\\7\\pwsh.exe"'))
.toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
expect(unquotePath("'/usr/bin/my shell'")).toBe('/usr/bin/my shell')
// Unpaired or single characters stay verbatim.
expect(unquotePath('"mismatched')).toBe('"mismatched')
expect(unquotePath('pwsh.exe')).toBe('pwsh.exe')
expect(unquotePath('"')).toBe('"')
expect(unquotePath('')).toBe('')
})
it('splits shell args with quote-aware grouping', () => {
expect(splitShellArgs('-NoLogo -File "C:\\my init\\init.ps1"'))
.toEqual(['-NoLogo', '-File', 'C:\\my init\\init.ps1'])
// Single quotes group too, and preserve inner double quotes verbatim.
expect(splitShellArgs("-c 'echo \"hi\"'")).toEqual(['-c', 'echo "hi"'])
expect(splitShellArgs(' -l ')).toEqual(['-l'])
expect(splitShellArgs('')).toEqual([])
expect(splitShellArgs(' ')).toEqual([])
})
it('keeps backslashes literal inside quotes and tolerates an unclosed quote', () => {
// Backslash is NOT an escape (Windows paths): "C:\a\" ends with a slash.
expect(splitShellArgs('"C:\\a\\"')).toEqual(['C:\\a\\'])
// An unclosed quote folds the remainder into one token instead of erroring.
expect(splitShellArgs('"unclosed quote')).toEqual(['unclosed quote'])
// Empty quote pairs produce no empty-string argument.
expect(splitShellArgs('"" x')).toEqual(['x'])
})
```
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: FAIL — `splitShellArgs`/`unquotePath` 不是导出成员(`SyntaxError` 或 `undefined is not a function`)。
- [ ] **Step 3: 最小实现** — `src/pty-manager.ts` 末尾(`shellSpawnArgs` 函数之后)追加:
```ts
/**
* Strip ONE pair of surrounding quotes from a configured shell path. Users
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
* shorter values stay verbatim.
*/
export function unquotePath(value: string): string {
if (value.length >= 2) {
const first = value[0]
const last = value[value.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
}
return value
}
/**
* Split a settings-page shell-arguments string into argv with quote-aware
* grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
* LITERAL — a backslash is never an escape, so Windows paths survive intact
* (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
* The price is that an argument containing a literal quote character cannot
* be expressed; shell startup arguments never need one. An unclosed quote
* folds the remainder into the current token (settings input stays
* forgiving); an empty quote pair yields no argument.
*/
export function splitShellArgs(input: string): string[] {
const args: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let started = false
for (const ch of input) {
if (quote !== null) {
if (ch === quote) quote = null
else current += ch
continue
}
if (ch === '"' || ch === "'") {
quote = ch
started = true
continue
}
if (/\s/.test(ch)) {
if (started) {
args.push(current)
current = ''
started = false
}
continue
}
current += ch
started = true
}
if (started) args.push(current)
return args.filter(arg => arg !== '')
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: PASS(全部用例)。
- [ ] **Step 5: Commit**
```bash
git add src/pty-manager.ts tests/pty-helpers.spec.ts
git commit -m "feat(terminal): 引号感知的 shell 参数解析与路径去引号纯函数"
```
---
### Task 2: POSIX 预检 + `shell-not-found` 错误码(TDD)
**Files:**
- Modify: `src/wire.ts:10-36`(错误码联合 + 构造器 meta)
- Modify: `src/pty-manager.ts:351-400`(`resolveShellExecutable` POSIX 分支)
- Test: `tests/pty-helpers.spec.ts`
**Interfaces:**
- Consumes: Task 1 的 `unquotePath`
- Produces: `SidebarErrorCode` 新值 `'shell-not-found'`;`SidebarError` 构造器第 4 参 `meta?: Record<string, string>`;`resolveShellExecutable` 在 POSIX 找不到时抛 `SidebarError('shell-not-found', 'shell executable not found: "<configured>"', 400, { shell: configured })`(Task 3 的 WS catch 消费)
- [ ] **Step 1: 写失败测试** — `tests/pty-helpers.spec.ts` 追加(import 区补 `SidebarError`:`import { SidebarError } from '../src/wire.ts'`):
```ts
it('POSIX: resolves bare names along PATH and names a missing configured shell', () => {
const options = {
platform: 'linux' as const,
env: { PATH: '/usr/local/bin:/usr/bin' },
exists: (path: string) => path === join('/usr/bin', 'zsh'),
}
expect(resolveShellExecutable('zsh', options)).toBe(join('/usr/bin', 'zsh'))
const thrown = (() => {
try {
resolveShellExecutable('nope', options)
return undefined
} catch (error) {
return error
}
})()
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).code).toBe('shell-not-found')
expect((thrown as SidebarError).message).toBe('shell executable not found: "nope"')
expect((thrown as SidebarError).meta?.shell).toBe('nope')
})
it('POSIX: checks an absolute path exists and passes it through verbatim', () => {
expect(resolveShellExecutable('/explicit/zsh', { platform: 'linux', env: {}, exists: () => true }))
.toBe('/explicit/zsh')
expect(() => resolveShellExecutable('/missing/zsh', { platform: 'linux', env: {}, exists: () => false }))
.toThrow('shell executable not found: "/missing/zsh"')
})
it('unquotes the configured shell before resolving (Windows probe included)', () => {
const options = {
platform: 'win32' as const,
env: { PATH: 'C:\\Tools' },
exists: (path: string) => path.replaceAll('\\', '/') === 'C:/Tools/pwsh.exe',
}
expect(resolveShellExecutable('"pwsh.exe"', options).replaceAll('\\', '/')).toBe('C:/Tools/pwsh.exe')
})
```
**同时改写旧契约用例**(第 115-117 行 `'keeps POSIX bare shell resolution delegated to execvp'`)——该用例锁定的正是本任务**有意变更**的行为,整段替换为上面第一个新用例即可(删除旧用例)。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: FAIL — `'nope'` 原样返回而非抛错(旧 POSIX 直通行为),且 `SidebarError` 无 `meta` 属性。
- [ ] **Step 3: wire.ts 加错误码与 meta** — `src/wire.ts`:
```ts
/** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode =
| 'bad-request'
| 'not-found'
| 'forbidden'
| 'method-error'
| 'too-large'
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'pty-deps-missing'
| 'shell-not-found'
| 'job-error'
| 'sidechat-error'
| 'subagents-unavailable'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
/** One API failure with its wire code and HTTP status. */
export class SidebarError extends Error {
constructor(
readonly code: SidebarErrorCode,
message: string,
readonly status = 400,
/** Optional structured context (e.g. `{ shell }` for shell-not-found). */
readonly meta?: Record<string, string>,
) {
super(message)
}
}
```
- [ ] **Step 4: pty-manager.ts 泛化探测** — `resolveShellExecutable` 整体替换为:
```ts
export function resolveShellExecutable(
shell: string,
options: ShellExecutableResolutionOptions = {},
): string {
const configured = unquotePath(shell.trim())
const platform = options.platform ?? process.platform
if (configured === '') return configured
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const notFound = (): SidebarError =>
new SidebarError('shell-not-found', `shell executable not found: "${configured}"`, 400, { shell: configured })
if (platform === 'win32') {
const rawPathext = windowsEnv(env, 'PATHEXT')
const executableExts = (rawPathext ?? '.COM;.EXE')
.split(';')
.map(extension => extension.trim())
// node-pty ultimately calls CreateProcess; batch files need an
// intermediate cmd.exe and therefore are not valid shell executables.
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
const hasExtension = win32Path.extname(configured) !== ''
const names = hasExtension
? [configured]
: executableExts.map(extension => configured + extension.toLowerCase())
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
const candidates: string[] = []
if (hasPath) {
candidates.push(...names)
} else {
const path = windowsEnv(env, 'PATH')
if (path !== undefined) {
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
for (const name of names) candidates.push(win32Path.join(dir, name))
}
}
const systemRoot = windowsEnv(env, 'SystemRoot')
if (systemRoot !== undefined && systemRoot.trim() !== '') {
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
}
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
for (const dir of windowsPwshCandidateDirs(env)) {
candidates.push(win32Path.join(dir, 'pwsh.exe'))
}
}
}
for (const candidate of [...new Set(candidates)]) {
if (exists(candidate)) return candidate
}
throw notFound()
}
// POSIX: the previous pass-through delegated a wrong name to execvp and the
// pty died with a bare "[process exited with code N]". Probe like Windows:
// a path with a separator must exist; a bare name is searched along PATH
// (the colon form is fixed by the platform). A miss is a clear, actionable
// error instead of a cryptic exit code.
if (configured.includes('/')) {
if (!exists(configured)) throw notFound()
return configured
}
const path = env.PATH ?? '/usr/bin:/bin'
for (const dir of path.split(':').map(entry => entry.trim()).filter(Boolean)) {
const candidate = join(dir, configured)
if (exists(candidate)) return candidate
}
throw notFound()
}
```
确认 `src/pty-manager.ts` 已有 `join`(node:path)与 `SidebarError` import(`SidebarError` 来自 `./wire.ts`——若尚无此 import 则补 `import { SidebarError } from './wire.ts'`)。
- [ ] **Step 5: 跑测试确认通过**
Run: `pnpm exec vitest run tests/pty-helpers.spec.ts`
Expected: PASS。随后跑全量确认无连带破坏:`pnpm exec vitest run tests/smoke.spec.ts tests/tools.spec.ts`(POSIX 预检改变了裸名缺失时的行为——若 smoke/tools 有依赖直通的用例,按新契约修正其期望值并在 commit 正文注明)。
- [ ] **Step 6: Commit**
```bash
git add src/wire.ts src/pty-manager.ts tests/pty-helpers.spec.ts
git commit -m "feat(terminal): POSIX 也预检 shell 可执行文件,统一 shell-not-found 错误码"
```
---
### Task 3: host 接线——覆盖解析、shell.get 跟随、WS close reason(TDD)
**Files:**
- Modify: `src/index.ts:248-259`(`shellOverridesOf`)、`:562-566`(`shell.get`)、`:1306-1308`(`attachTerminal` catch)、新增导出函数 `wsCloseReasonOf`
- Test: `tests/smoke.spec.ts:823-881`(fake settings)+ 新用例
**Interfaces:**
- Consumes: Task 1 `unquotePath`/`splitShellArgs`;Task 2 `SidebarError.meta` + `'shell-not-found'`
- Produces: `shellOverridesOf` 返回 `{ shell?: string; shellArgs?: string[] }`(引号已处理);`wsCloseReasonOf(error: unknown): string`(导出,`shell-not-found` → `shell-not-found:<displayName≤100字符>`,其余 → message/String);WS close reason 线协议前缀 `shell-not-found:`(Task 4 客户端镜像)
- [ ] **Step 1: 写失败测试** — `tests/smoke.spec.ts`:
(a) fake settings 的 `register` 保留预置值(第 837-840 行)——替换为:
```ts
register(ns: string, schema: unknown) {
// Preserve a pre-seeded value: tests stage prefs through the `pre`
// map before the plugin mounts and registers the same namespace.
const existing = namespaces.get(ns)
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
```
(b) `'side card settings routes'` describe 内追加(`invoke`/`mountWithSettings` 复用):
```ts
it('shell.get reflects the settings-page override with the quotes stripped', async () => {
const route = mountWithSettings(createFakeSettings({
'dsh-better-sidebar': {
terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
terminalShellArgs: '-NoLogo',
},
}))
const result = await invoke(route, 'shell.get', {})
expect(result.ok).toBe(true)
expect(result.value).toMatchObject({
shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
name: 'pwsh',
})
})
it('maps a shell-not-found failure to the machine-readable close reason', async () => {
expect(wsCloseReasonOf(new SidebarError(
'shell-not-found',
'shell executable not found: "C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
400,
{ shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' },
))).toBe('shell-not-found:pwsh')
expect(wsCloseReasonOf(new Error('boom'))).toBe('boom')
expect(wsCloseReasonOf('plain')).toBe('plain')
})
```
import 区补:`wsCloseReasonOf` 取自 `'../src/index.ts'`(该文件已 import `apply`——并入同一 import 语句),`SidebarError` 取自 `'../src/wire.ts'`。
- [ ] **Step 2: 跑测试确认失败**
Run: `pnpm exec vitest run tests/smoke.spec.ts`
Expected: FAIL — `shell.get` 返回启动时 shell 而非覆盖值;`wsCloseReasonOf` 未导出。
- [ ] **Step 3: 实现 index.ts 三处**
(a) `shellOverridesOf`(第 248-259 行)——解析走新函数:
```ts
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
const settings = getSettings()
const value = settings?.get().value
if (value === null || typeof value !== 'object') return {}
const record = value as Record<string, unknown>
const shell = typeof record.terminalShell === 'string' ? unquotePath(record.terminalShell.trim()) : ''
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
return {
shell: shell === '' ? undefined : shell,
shellArgs: args === '' ? undefined : splitShellArgs(args),
}
}
```
(`unquotePath`/`splitShellArgs` 并入第 48 行现存的 `'./pty-manager.ts'` import。)
(b) `shell.get`(第 562-566 行)——注释与返回值更新:
```ts
// The effective terminal shell and its display name: the settings-page
// override when set (quotes stripped), else the boot-time resolution.
// The client titles terminal tabs with the name, so a changed setting is
// visible on the next opened tab without a plugin restart.
'shell.get': () => {
const effective = shellOverridesOf(getSettings).shell ?? terminalShell
return { shell: effective, name: shellDisplayName(effective) }
},
```
(c) `attachTerminal` 的 catch(第 1306-1308 行)改用映射函数,并在 `attachTerminal` 上方新增导出:
```ts
/**
* The WS close reason for a failed terminal attach. A missing configured
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
* capped — a WS close reason allows at most 123 bytes) that the client maps
* to a localized, actionable banner; every other failure keeps the raw
* message (the model-side tool errors read it verbatim).
*/
export function wsCloseReasonOf(error: unknown): string {
if (error instanceof SidebarError && error.code === 'shell-not-found') {
const name = shellDisplayName(String(error.meta?.shell ?? '')).slice(0, 100)
return `shell-not-found:${name}`
}
return error instanceof Error ? error.message : String(error)
}
```
catch 体改为:
```ts
} catch (error) {
ws.close(1011, wsCloseReasonOf(error))
}
```
- [ ] **Step 4: 跑测试确认通过**
Run: `pnpm exec vitest run tests/smoke.spec.ts`
Expected: PASS(含既有 `'serves the effective terminal shell and its display name'`——无设置时行为不变)。
- [ ] **Step 5: Commit**
```bash
git add src/index.ts tests/smoke.spec.ts
git commit -m "feat(terminal): shell 覆盖去引号进解析、标题跟随覆盖、close reason 机器标记"
```
---
### Task 4: 客户端本地化横幅 + 全语言词典(typecheck 守门)
**Files:**
- Modify: `src/client/TerminalView.tsx:62`(镜像常量)、`:224-250`(onclose 分支)
- Modify: `src/client/locales.ts`(zh 块 `:120` 后、en 块 `:569` 后各 1 key)
- Modify: 19 个第三方词典各 1 key:`locales-ja.ts:108`、`locales-de.ts`、`-fr`、`-pt`、`-ko`、`-ar`、`-hi`、`-id`、`-tr`、`-vi`、`-th`、`-ru`、`-it`、`-nl`、`-sv`、`-pl`、`-zh-HK`、`-zh-TW`、`-zh-MO`(均插在各自 `terminalDepsProfile` 行后)
**Interfaces:**
- Consumes: Task 3 的线协议前缀 `shell-not-found:`
- Produces: locale key `terminalShellNotFound`(占位符 `{name}`);`TerminalView` 的 fatal 横幅文本
- [ ] **Step 1: TerminalView 识别前缀** — `src/client/TerminalView.tsx` 第 62 行 `PTY_DEPS_MISSING` 常量后追加:
```ts
/**
* The WS close-reason prefix the host sends when the CONFIGURED shell was
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
* literal in lockstep). The view renders a localized, actionable banner.
*/
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
```
`socket.onclose`(第 224 行起)在 `PTY_DEPS_MISSING` 分支之后、通用 1011 分支(第 247 行)之前插入:
```ts
// The configured shell could not be found (settings page or yaml):
// a localized banner beats the raw English close reason.
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
return
}
```
- [ ] **Step 2: 加全语言 key**(21 个词典文件,各在 `terminalDepsProfile` 行后插 1 行;键集由 `chunks/locale.tsx` 的 `Record<CopyKey, string>` 与 `tests/locales.spec.ts` 双重把关,漏一个文件 typecheck 即红):
```ts
// locales.ts zh 块:
terminalShellNotFound: '未找到配置的 Shell:{name},请到 设置 → 侧边卡片 → 终端 检查 Shell 路径',
// locales.ts en 块:
terminalShellNotFound: 'Configured shell not found: {name} — check the shell path under Settings → Side card → Terminal',
// locales-ja.ts:
terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)',
// locales-de.ts:
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
// locales-fr.ts:
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
// locales-pt.ts:
terminalShellNotFound: 'Shell configurado não encontrado: {name} — verifique o caminho do shell em Configurações → Side card → Terminal',
// locales-ko.ts:
terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요',
// locales-ar.ts:
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
// locales-hi.ts:
terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें',
// locales-id.ts:
terminalShellNotFound: 'Shell yang dikonfigurasi tidak ditemukan: {name} — periksa path shell di Pengaturan → Side card → Terminal',
// locales-tr.ts:
terminalShellNotFound: 'Yapılandırılan kabuk bulunamadı: {name} — Ayarlar → Side card → Terminal altındaki kabuk yolunu denetleyin',
// locales-vi.ts:
terminalShellNotFound: 'Không tìm thấy shell đã cấu hình: {name} — kiểm tra đường dẫn shell trong Cài đặt → Side card → Terminal',
// locales-th.ts:
terminalShellNotFound: 'ไม่พบ shell ที่กำหนดไว้: {name} — ตรวจสอบพาธ shell ใน การตั้งค่า → Side card → Terminal',
// locales-ru.ts:
terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal',
// locales-it.ts:
terminalShellNotFound: 'Shell configurata non trovata: {name} — verifica il percorso della shell in Impostazioni → Side card → Terminal',
// locales-nl.ts:
terminalShellNotFound: 'Geconfigureerde shell niet gevonden: {name} — controleer het shell-pad onder Instellingen → Side card → Terminal',
// locales-sv.ts:
terminalShellNotFound: 'Konfigurerat skal hittades inte: {name} — kontrollera skalsökvägen under Inställningar → Side card → Terminal',
// locales-pl.ts:
terminalShellNotFound: 'Nie znaleziono skonfigurowanej powłoki: {name} — sprawdź ścieżkę powłoki w Ustawienia → Side card → Terminal',
// locales-zh-HK.ts:
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
// locales-zh-TW.ts:
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
// locales-zh-MO.ts:
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
```
- [ ] **Step 3: typecheck 守门**
Run: `pnpm typecheck`
Expected: PASS —— 若漏改任何一个第三方词典,`chunks/locale.tsx` 的 `checked(dict: Record<CopyKey, string>)` 直接编译失败。
- [ ] **Step 4: 跑 locale 契约测试**
Run: `pnpm exec vitest run tests/locales.spec.ts`
Expected: PASS(含 `keeps every shipped third-language dictionary key-set-equal to zh`)。
- [ ] **Step 5: Commit**
```bash
git add src/client/TerminalView.tsx src/client/locales.ts src/client/locales-*.ts
git commit -m "feat(terminal): 配置 shell 未找到时显示本地化可操作横幅(全语言)"
```
---
### Task 5: 全量门禁 + 设计文档偏差回填 + PR
**Files:**
- Modify: `docs/plans/2026-09-08-terminal-shell-config-hardening-design.md`(「实施偏差记录」节回填)
- [ ] **Step 1: 全量门禁**
Run: `pnpm typecheck && pnpm test`
Expected: PASS。若 `tests/tools.spec.ts` / e2e 有依赖旧 POSIX 直通行为的用例失败,按新契约修期望值并单独 commit(`test(terminal): 跟随 POSIX 预检新契约`)。
- [ ] **Step 2: 文档核对与偏差回填**
Run: `rg -n "terminalShell|shellArgs" docs/ README.md`
Expected: 仅设计/计划文档命中(`docs/external-plugin-guide.md` 不记载设置页 shell 行,无需同步;若出现命中则按 AGENTS §5 同步该文档)。回填设计文档「实施偏差记录」:① `terminalShellNotFoundHint` 并入单 key `terminalShellNotFound`(含 `{name}` 占位);② 旧用例 `keeps POSIX bare shell resolution delegated to execvp` 被有意改写;③ 新增 locale 键实际落点为全部 21 词典(不止 zh/en/ja)。Commit:
```bash
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md
git commit -m "docs(plans): 回填终端 shell 配置加固实施偏差"
```
- [ ] **Step 3: 推送并建 PR**
```bash
git push -u origin feat/terminal-shell-quoted-args-probe
gh pr create --title "feat(terminal): 终端 shell 配置加固——引号解析、POSIX 预检、本地化报错" --body-file - <<'EOF'
## 概要
配置启动终端命令的功能已存在(#95/#125),本 PR 加固三处缺口:
1. 设置页 shell 参数支持引号分组(`-File "C:\my init\init.ps1"` 正确成 3 个 argv);shell 路径自动剥成对引号
2. POSIX 预检可执行文件(对齐 Windows 既有探测),统一 `shell-not-found` 错误码
3. 配置错误时终端标签渲染本地化可操作横幅(21 语言),终端标题跟随设置覆盖
## 测试
- `tests/pty-helpers.spec.ts`:splitShellArgs/unquotePath 语义矩阵 + POSIX 探测
- `tests/smoke.spec.ts`:shell.get 覆盖跟随 + wsCloseReasonOf 映射
- `pnpm typecheck` + `pnpm test` 全绿
EOF
```
Expected: PR URL 返回。
- [ ] **Step 4: 汇报** — 向用户回报 PR 链接、改动文件清单、行为对照(改前/改后)。
+13
View File
@@ -61,6 +61,13 @@ const FAILURE_LIMIT = 3
*/
const PTY_DEPS_MISSING = 'pty-deps-missing'
/**
* The WS close-reason prefix the host sends when the CONFIGURED shell was
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
* literal in lockstep). The view renders a localized, actionable banner.
*/
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
@@ -242,6 +249,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
})
return
}
// The configured shell could not be found (settings page or yaml):
// a localized banner beats the raw English close reason.
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
return
}
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
+1
View File
@@ -107,6 +107,7 @@ export const ar: Record<string, string> = {
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
preview: 'معاينة',
toc: 'الفهرس',
edit: 'تحرير',
+1
View File
@@ -92,6 +92,7 @@ export const de: Record<string, string> = {
terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden',
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
terminalDepsProfile: ' (erkanntes Profil: {profile})',
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
preview: 'Vorschau',
toc: 'Inhaltsverzeichnis',
edit: 'Bearbeiten',
+1
View File
@@ -99,6 +99,7 @@ export const fr: Record<string, string> = {
terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty',
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
terminalDepsProfile: ' (profil détecté : {profile})',
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
preview: 'Aperçu',
toc: 'Sommaire',
edit: 'Modifier',
+1
View File
@@ -106,6 +106,7 @@ export const hi: Record<string, string> = {
terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल',
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें',
preview: 'पूर्वावलोकन',
toc: 'विषय-सूची',
edit: 'संपादित करें',
+1
View File
@@ -104,6 +104,7 @@ export const id: Record<string, string> = {
terminalDepsFailed: 'Dependensi terminal node-pty gagal dimuat',
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
terminalDepsProfile: ' (profil terdeteksi: {profile})',
terminalShellNotFound: 'Shell yang dikonfigurasi tidak ditemukan: {name} — periksa path shell di Pengaturan → Side card → Terminal',
preview: 'Pratinjau',
toc: 'Daftar Isi',
edit: 'Edit',
+1
View File
@@ -97,6 +97,7 @@ export const it: Record<string, string> = {
terminalDepsFailed: 'Caricamento della dipendenza del terminale node-pty non riuscito',
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
terminalDepsProfile: ' (rilevato profilo: {profile})',
terminalShellNotFound: 'Shell configurata non trovata: {name} — verifica il percorso della shell in Impostazioni → Side card → Terminal',
preview: 'Anteprima',
toc: 'Indice',
edit: 'Modifica',
+1
View File
@@ -106,6 +106,7 @@ export const ja: Record<string, string> = {
terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗',
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})',
terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)',
preview: 'プレビュー',
toc: '目次',
edit: '編集',
+1
View File
@@ -98,6 +98,7 @@ export const ko: Record<string, string> = {
terminalDepsFailed: '터미널 의존성 node-pty 로드 실패',
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})',
terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요',
preview: '미리보기',
toc: '목차',
edit: '편집',
+1
View File
@@ -104,6 +104,7 @@ export const nl: Record<string, string> = {
terminalDepsFailed: 'Terminalafhankelijkheid node-pty kon niet worden geladen',
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
terminalShellNotFound: 'Geconfigureerde shell niet gevonden: {name} — controleer het shell-pad onder Instellingen → Side card → Terminal',
preview: 'Voorbeeld',
toc: 'Inhoudsopgave',
edit: 'Bewerken',
+1
View File
@@ -108,6 +108,7 @@ export const pl: Record<string, string> = {
terminalDepsFailed: 'Zależność terminala node-pty nie mogła się załadować',
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
terminalDepsProfile: ' (wykryty profil: {profile})',
terminalShellNotFound: 'Nie znaleziono skonfigurowanej powłoki: {name} — sprawdź ścieżkę powłoki w Ustawienia → Side card → Terminal',
preview: 'Podgląd',
toc: 'Spis treści',
edit: 'Edytuj',
+1
View File
@@ -89,6 +89,7 @@ export const pt: Record<string, string> = {
terminalDepsFailed: 'A dependência do terminal node-pty falhou ao carregar',
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
terminalDepsProfile: ' (perfil detectado: {profile})',
terminalShellNotFound: 'Shell configurado não encontrado: {name} — verifique o caminho do shell em Configurações → Side card → Terminal',
preview: 'Pré-visualizar',
toc: 'Sumário',
edit: 'Editar',
+1
View File
@@ -102,6 +102,7 @@ export const ru: Record<string, string> = {
terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty',
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})',
terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal',
preview: 'Предпросмотр',
toc: 'Оглавление',
edit: 'Изменить',
+1
View File
@@ -89,6 +89,7 @@ export const sv: Record<string, string> = {
terminalDepsFailed: 'Terminalberoendet node-pty kunde inte laddas',
terminalDepsHint: 'Kör kommandot nedan i en terminal eller cmd på DSH-maskinen för att reparera det, försök sedan igen (node-pty hålls synkad med DSH-kärnversionen):',
terminalDepsProfile: ' (upptäckt profil: {profile})',
terminalShellNotFound: 'Konfigurerat skal hittades inte: {name} — kontrollera skalsökvägen under Inställningar → Side card → Terminal',
preview: 'Förhandsgranska',
toc: 'Innehållsförteckning',
edit: 'Redigera',
+1
View File
@@ -106,6 +106,7 @@ export const th: Record<string, string> = {
terminalDepsFailed: 'การอ้างอิงเทอร์มินัล node-pty โหลดล้มเหลว',
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
terminalShellNotFound: 'ไม่พบ shell ที่กำหนดไว้: {name} — ตรวจสอบพาธ shell ใน การตั้งค่า → Side card → Terminal',
preview: 'พรีวิว',
toc: 'สารบัญ',
edit: 'แก้ไข',
+1
View File
@@ -106,6 +106,7 @@ export const tr: Record<string, string> = {
terminalDepsFailed: 'Terminal bağımlılığı node-pty yüklenemedi',
terminalDepsHint: 'Onarmak için DSH makinesindeki bir terminalde veya cmd’de aşağıdaki komutu çalıştırın, sonra yeniden deneyin (node-pty, DSH çekirdek sürümüyle senkron kalır):',
terminalDepsProfile: ' (algılanan profil: {profile})',
terminalShellNotFound: 'Yapılandırılan kabuk bulunamadı: {name} — Ayarlar → Side card → Terminal altındaki kabuk yolunu denetleyin',
preview: 'Önizleme',
toc: 'İçindekiler',
edit: 'Düzenle',
+1
View File
@@ -106,6 +106,7 @@ export const vi: Record<string, string> = {
terminalDepsFailed: 'Dependency terminal node-pty tải thất bại',
terminalDepsHint: 'Chạy lệnh sau trong terminal hoặc cmd trên máy DSH để sửa, rồi nhấn thử lại (node-pty đồng bộ phiên bản với lõi DSH):',
terminalDepsProfile: ' (phát hiện profile: {profile})',
terminalShellNotFound: 'Không tìm thấy shell đã cấu hình: {name} — kiểm tra đường dẫn shell trong Cài đặt → Side card → Terminal',
preview: 'Xem trước',
toc: 'Mục lục',
edit: 'Chỉnh sửa',
+1
View File
@@ -121,6 +121,7 @@ export const zhHK: Record<string, string> = {
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
preview: '預覽',
toc: '目錄',
edit: '編輯',
+1
View File
@@ -121,6 +121,7 @@ export const zhMO: Record<string, string> = {
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
preview: '預覽',
toc: '目錄',
edit: '編輯',
+1
View File
@@ -121,6 +121,7 @@ export const zhTW: Record<string, string> = {
terminalDepsFailed: '終端依賴 node-pty 載入失敗',
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑',
preview: '預覽',
toc: '目錄',
edit: '編輯',
+2
View File
@@ -118,6 +118,7 @@ export const zh = {
terminalDepsFailed: '终端依赖 node-pty 加载失败',
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
terminalShellNotFound: '未找到配置的 Shell:{name},请到 设置 → 侧边卡片 → 终端 检查 Shell 路径',
preview: '预览',
toc: '目录',
edit: '编辑',
@@ -567,6 +568,7 @@ export const en: Record<keyof typeof zh, string> = {
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
terminalShellNotFound: 'Configured shell not found: {name} — check the shell path under Settings → Side card → Terminal',
preview: 'Preview',
toc: 'Table of contents',
edit: 'Edit',
+44 -9
View File
@@ -41,7 +41,7 @@ import { registerBundleRoute } from './bundle-route.ts'
import { launchExternal } from './open-external.ts'
import * as git from './git.ts'
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName, splitShellArgs, unquotePath } from './pty-manager.ts'
import { AgentPtyRegistry, armPtyResizeGate, tryResizePty, type AgentTerminalHandle } from './agent-pty.ts'
import {
DSH_NODE_PTY_RANGE,
@@ -250,11 +250,11 @@ function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): {
const value = settings?.get().value
if (value === null || typeof value !== 'object') return {}
const record = value as Record<string, unknown>
const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
const shell = typeof record.terminalShell === 'string' ? unquotePath(record.terminalShell.trim()) : ''
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
return {
shell: shell === '' ? undefined : shell,
shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
shellArgs: args === '' ? undefined : splitShellArgs(args),
}
}
@@ -559,11 +559,14 @@ function buildApi(
// Subagent live previews: one batch request per refresh; the route folds
// the newest text/tool activity of every running child in the tree.
'subagents.live': (payload) => subagentLiveApi.live(payload),
// The effective terminal shell and its display name. The client uses
// this to title terminal tabs with the shell name instead of a numbered
// "Terminal N" label; the shell itself is configured through
// `cordis.patch.yml` (`config.shell`) or resolved by the host default.
'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
// The effective terminal shell and its display name: the settings-page
// override when set (quotes stripped), else the boot-time resolution.
// The client titles terminal tabs with the name, so a changed setting is
// visible on the next opened tab without a plugin restart.
'shell.get': () => {
const effective = shellOverridesOf(getSettings).shell ?? terminalShell
return { shell: effective, name: shellDisplayName(effective) }
},
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -1176,6 +1179,38 @@ async function attachAgentList(
}
}
/**
* The WS close reason for a failed terminal attach. A missing configured
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
* validates with `Buffer.byteLength`) that the client maps to a localized,
* actionable banner; every other failure keeps the raw message (the
* model-side tool errors read it verbatim).
*/
export function wsCloseReasonOf(error: unknown): string {
if (error instanceof SidebarError && error.code === 'shell-not-found') {
const name = truncateUtf8Bytes(shellDisplayName(String(error.meta?.shell ?? '')), 100)
return `shell-not-found:${name}`
}
return error instanceof Error ? error.message : String(error)
}
/**
* Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point.
* A character-count `slice` does not bound the WS close reason: `ws` measures
* `Buffer.byteLength` against its 123-byte cap, and the resulting throw would
* replace the very error the reason describes.
*/
function truncateUtf8Bytes(value: string, maxBytes: number): string {
if (Buffer.byteLength(value) <= maxBytes) return value
let truncated = ''
for (const character of value) {
if (Buffer.byteLength(truncated + character) > maxBytes) break
truncated += character
}
return truncated
}
/**
* Wire one terminal socket to its pty: replay transcript, pump both ways.
* Two attach modes share the wire protocol:
@@ -1304,7 +1339,7 @@ async function attachTerminal(
}
})
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
ws.close(1011, wsCloseReasonOf(error))
}
}
+116 -37
View File
@@ -336,8 +336,10 @@ function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
/**
* Resolve the configured shell executable before handing it to node-pty.
*
* POSIX node-pty uses `execvp`, so bare commands already follow PATH and are
* passed through unchanged. Windows' native backend does not consistently
* POSIX and Windows are both probed BEFORE spawn so a wrong configured name
* becomes a stable, actionable `shell-not-found` error instead of a bare
* "[process exited with code N]" (POSIX execvp) or an opaque native string
* (Windows). Windows' native backend additionally does not consistently
* apply the shell's PATHEXT lookup to a bare value (`pwsh` / `cmd` can fail
* with the opaque `File not found:` error), so perform the lookup ourselves:
*
@@ -345,58 +347,79 @@ function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
* suffix when the user omitted `.exe`),
* - a bare name is searched through PATH, System32, and PowerShell's known
* install directories,
* - failure becomes a stable, actionable pty-error instead of a native
* backend string with no mention of the configured shell.
* - failure becomes a stable, actionable `shell-not-found` error instead of
* a native backend string with no mention of the configured shell.
*/
export function resolveShellExecutable(
shell: string,
options: ShellExecutableResolutionOptions = {},
): string {
const configured = shell.trim()
const configured = unquotePath(shell.trim())
const platform = options.platform ?? process.platform
if (platform !== 'win32' || configured === '') return configured
if (configured === '') return configured
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const rawPathext = windowsEnv(env, 'PATHEXT')
const executableExts = (rawPathext ?? '.COM;.EXE')
.split(';')
.map(extension => extension.trim())
// node-pty ultimately calls CreateProcess; batch files need an
// intermediate cmd.exe and therefore are not valid shell executables.
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
const notFound = (): SidebarError =>
new SidebarError('shell-not-found', `shell executable not found: "${configured}"`, 400, { shell: configured })
const hasExtension = win32Path.extname(configured) !== ''
const names = hasExtension
? [configured]
: executableExts.map(extension => configured + extension.toLowerCase())
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
const candidates: string[] = []
if (hasPath) {
candidates.push(...names)
} else {
const path = windowsEnv(env, 'PATH')
if (path !== undefined) {
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
for (const name of names) candidates.push(win32Path.join(dir, name))
if (platform === 'win32') {
const rawPathext = windowsEnv(env, 'PATHEXT')
const executableExts = (rawPathext ?? '.COM;.EXE')
.split(';')
.map(extension => extension.trim())
// node-pty ultimately calls CreateProcess; batch files need an
// intermediate cmd.exe and therefore are not valid shell executables.
.filter(extension => /^\.(?:com|exe)$/i.test(extension))
if (executableExts.length === 0) executableExts.push('.EXE', '.COM')
const hasExtension = win32Path.extname(configured) !== ''
const names = hasExtension
? [configured]
: executableExts.map(extension => configured + extension.toLowerCase())
const hasPath = win32Path.isAbsolute(configured) || /[\\/]/.test(configured)
const candidates: string[] = []
if (hasPath) {
candidates.push(...names)
} else {
const path = windowsEnv(env, 'PATH')
if (path !== undefined) {
for (const dir of path.split(';').map(entry => entry.trim()).filter(Boolean)) {
for (const name of names) candidates.push(win32Path.join(dir, name))
}
}
const systemRoot = windowsEnv(env, 'SystemRoot')
if (systemRoot !== undefined && systemRoot.trim() !== '') {
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
}
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
for (const dir of windowsPwshCandidateDirs(env)) {
candidates.push(win32Path.join(dir, 'pwsh.exe'))
}
}
}
const systemRoot = windowsEnv(env, 'SystemRoot')
if (systemRoot !== undefined && systemRoot.trim() !== '') {
for (const name of names) candidates.push(win32Path.join(systemRoot, 'System32', name))
}
if (/^pwsh(?:\.exe)?$/i.test(configured)) {
for (const dir of windowsPwshCandidateDirs(env)) {
candidates.push(win32Path.join(dir, 'pwsh.exe'))
}
for (const candidate of [...new Set(candidates)]) {
if (exists(candidate)) return candidate
}
throw notFound()
}
for (const candidate of [...new Set(candidates)]) {
// POSIX: the previous pass-through delegated a wrong name to execvp and the
// pty died with a bare "[process exited with code N]". Probe like Windows:
// a path with a separator must exist; a bare name is searched along PATH
// (the colon form is fixed by the platform). A miss is a clear, actionable
// error instead of a cryptic exit code.
if (configured.includes('/')) {
if (!exists(configured)) throw notFound()
return configured
}
const path = env.PATH ?? '/usr/bin:/bin'
for (const dir of path.split(':').map(entry => entry.trim()).filter(Boolean)) {
const candidate = join(dir, configured)
if (exists(candidate)) return candidate
}
throw new SidebarError('pty-error', `shell executable not found: "${configured}"`)
throw notFound()
}
/**
@@ -467,3 +490,59 @@ export function shellSpawnArgs(configured: string[] = []): string[] {
if (configured.length > 0) return [...configured]
return process.platform === 'win32' ? [] : ['-l']
}
/**
* Strip ONE pair of surrounding quotes from a configured shell path. Users
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
* shorter values stay verbatim.
*/
export function unquotePath(value: string): string {
if (value.length >= 2) {
const first = value[0]
const last = value[value.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) return value.slice(1, -1)
}
return value
}
/**
* Split a settings-page shell-arguments string into argv with quote-aware
* grouping: `'…'` / `"…"` group whitespace, and characters inside quotes are
* LITERAL — a backslash is never an escape, so Windows paths survive intact
* (`-File "C:\my init\init.ps1"` → three tokens, the last containing spaces).
* The price is that an argument containing a literal quote character cannot
* be expressed; shell startup arguments never need one. An unclosed quote
* folds the remainder into the current token (settings input stays
* forgiving); an empty quote pair yields no argument.
*/
export function splitShellArgs(input: string): string[] {
const args: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let started = false
for (const ch of input) {
if (quote !== null) {
if (ch === quote) quote = null
else current += ch
continue
}
if (ch === '"' || ch === "'") {
quote = ch
started = true
continue
}
if (/\s/.test(ch)) {
if (started) {
args.push(current)
current = ''
started = false
}
continue
}
current += ch
started = true
}
if (started) args.push(current)
return args.filter(arg => arg !== '')
}
+3
View File
@@ -17,6 +17,7 @@ export type SidebarErrorCode =
| 'git-error'
| 'pty-error'
| 'pty-deps-missing'
| 'shell-not-found'
| 'job-error'
| 'sidechat-error'
| 'subagents-unavailable'
@@ -30,6 +31,8 @@ export class SidebarError extends Error {
readonly code: SidebarErrorCode,
message: string,
readonly status = 400,
/** Optional structured context (e.g. `{ shell }` for shell-not-found). */
readonly meta?: Record<string, string>,
) {
super(message)
}
+68 -2
View File
@@ -22,7 +22,10 @@ import {
resolveShellExecutable,
shellDisplayName,
shellSpawnArgs,
splitShellArgs,
unquotePath,
} from '../src/pty-manager.ts'
import { SidebarError } from '../src/wire.ts'
describe('pty helpers', () => {
it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => {
@@ -112,8 +115,41 @@ describe('pty helpers', () => {
.toThrow('shell executable not found: "missing-shell"')
})
it('keeps POSIX bare shell resolution delegated to execvp', () => {
expect(resolveShellExecutable(' zsh ', { platform: 'linux', env: {}, exists: () => false })).toBe('zsh')
it('POSIX: resolves bare names along PATH and names a missing configured shell', () => {
const options = {
platform: 'linux' as const,
env: { PATH: '/usr/local/bin:/usr/bin' },
exists: (path: string) => path === join('/usr/bin', 'zsh'),
}
expect(resolveShellExecutable('zsh', options)).toBe(join('/usr/bin', 'zsh'))
const thrown = (() => {
try {
resolveShellExecutable('nope', options)
return undefined
} catch (error) {
return error
}
})()
expect(thrown).toBeInstanceOf(SidebarError)
expect((thrown as SidebarError).code).toBe('shell-not-found')
expect((thrown as SidebarError).message).toBe('shell executable not found: "nope"')
expect((thrown as SidebarError).meta?.shell).toBe('nope')
})
it('POSIX: checks an absolute path exists and passes it through verbatim', () => {
expect(resolveShellExecutable('/explicit/zsh', { platform: 'linux', env: {}, exists: () => true }))
.toBe('/explicit/zsh')
expect(() => resolveShellExecutable('/missing/zsh', { platform: 'linux', env: {}, exists: () => false }))
.toThrow('shell executable not found: "/missing/zsh"')
})
it('unquotes the configured shell before resolving (Windows probe included)', () => {
const options = {
platform: 'win32' as const,
env: { PATH: 'C:\\Tools' },
exists: (path: string) => path.replaceAll('\\', '/') === 'C:/Tools/pwsh.exe',
}
expect(resolveShellExecutable('"pwsh.exe"', options).replaceAll('\\', '/')).toBe('C:/Tools/pwsh.exe')
})
it('trims the configured shell and defaults it to auto for old documents', () => {
@@ -156,4 +192,34 @@ describe('pty helpers', () => {
expect(helper).toBeTruthy()
expect((statSync(helper!).mode & 0o111) !== 0).toBe(true)
})
it('unquotes a paired surrounding quote from a configured shell path', () => {
expect(unquotePath('"C:\\Program Files\\PowerShell\\7\\pwsh.exe"'))
.toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
expect(unquotePath("'/usr/bin/my shell'")).toBe('/usr/bin/my shell')
// Unpaired or single characters stay verbatim.
expect(unquotePath('"mismatched')).toBe('"mismatched')
expect(unquotePath('pwsh.exe')).toBe('pwsh.exe')
expect(unquotePath('"')).toBe('"')
expect(unquotePath('')).toBe('')
})
it('splits shell args with quote-aware grouping', () => {
expect(splitShellArgs('-NoLogo -File "C:\\my init\\init.ps1"'))
.toEqual(['-NoLogo', '-File', 'C:\\my init\\init.ps1'])
// Single quotes group too, and preserve inner double quotes verbatim.
expect(splitShellArgs("-c 'echo \"hi\"'")).toEqual(['-c', 'echo "hi"'])
expect(splitShellArgs(' -l ')).toEqual(['-l'])
expect(splitShellArgs('')).toEqual([])
expect(splitShellArgs(' ')).toEqual([])
})
it('keeps backslashes literal inside quotes and tolerates an unclosed quote', () => {
// Backslash is NOT an escape (Windows paths): "C:\a\" ends with a slash.
expect(splitShellArgs('"C:\\a\\"')).toEqual(['C:\\a\\'])
// An unclosed quote folds the remainder into one token instead of erroring.
expect(splitShellArgs('"unclosed quote')).toEqual(['unclosed quote'])
// Empty quote pairs produce no empty-string argument.
expect(splitShellArgs('"" x')).toEqual(['x'])
})
})
+45 -2
View File
@@ -9,7 +9,8 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSyn
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath } from '../src/index.ts'
import { apply, mediaTypeForPath, wsCloseReasonOf } from '../src/index.ts'
import { SidebarError } from '../src/wire.ts'
import { encodeHtmlUrl } from '../src/html-route.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
@@ -835,7 +836,10 @@ describe('side card settings routes', () => {
}
return {
register(ns: string, schema: unknown) {
namespaces.set(ns, { schema, value: undefined, revision: 0 })
// Preserve a pre-seeded value: tests stage prefs through the `pre`
// map before the plugin mounts and registers the same namespace.
const existing = namespaces.get(ns)
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
describe() {
@@ -933,6 +937,45 @@ describe('side card settings routes', () => {
expect(String((result.value as { name: unknown }).name).length).toBeGreaterThan(0)
})
it('shell.get reflects the settings-page override with the quotes stripped', async () => {
const route = mountWithSettings(createFakeSettings({
'dsh-better-sidebar': {
terminalShell: '"C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
terminalShellArgs: '-NoLogo',
},
}))
const result = await invoke(route, 'shell.get', {})
expect(result.ok).toBe(true)
expect(result.value).toMatchObject({
shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
name: 'pwsh',
})
})
it('maps a shell-not-found failure to the machine-readable close reason', async () => {
expect(wsCloseReasonOf(new SidebarError(
'shell-not-found',
'shell executable not found: "C:\\Program Files\\PowerShell\\7\\pwsh.exe"',
400,
{ shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' },
))).toBe('shell-not-found:pwsh')
expect(wsCloseReasonOf(new Error('boom'))).toBe('boom')
expect(wsCloseReasonOf('plain')).toBe('plain')
})
it('caps the close reason by UTF-8 bytes so the ws 123-byte limit holds', () => {
// 200 CJK characters are ~600 bytes: a character-count slice would still
// overflow the cap `ws` enforces with Buffer.byteLength.
const reason = wsCloseReasonOf(new SidebarError(
'shell-not-found',
'shell executable not found',
400,
{ shell: `/bin/${'终'.repeat(200)}` },
))
expect(reason.startsWith('shell-not-found:')).toBe(true)
expect(Buffer.byteLength(reason)).toBeLessThanOrEqual(123)
})
it('reads the resolved prefs and writes a patch through the seam', async () => {
const route = mountWithSettings(createFakeSettings())
const read = await invoke(route, 'settings.get', {})