29 KiB
终端 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 + tsconfigallowImportingTsExtensions)。 - 每个任务以 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: 确认工作树干净并建分支
git status --porcelain # 期望为空(main 已与 origin/main 同步)
git checkout -b feat/terminal-shell-quoted-args-probe
- Step 2: Commit 设计与实施计划(文档随 feat 分支走 PR,保持 PR 自洽)
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 块):
import {
defaultShell,
ensureSpawnHelper,
resolveShellExecutable,
shellDisplayName,
shellSpawnArgs,
splitShellArgs,
unquotePath,
} from '../src/pty-manager.ts'
在 describe('pty helpers', () => { 内、'restores the spawn-helper executable bit idempotently' 用例之后追加:
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函数之后)追加:
/**
* 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
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(resolveShellExecutablePOSIX 分支) - 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'):
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:
/** 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整体替换为:
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
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(attachTerminalcatch)、新增导出函数wsCloseReasonOf - Test:
tests/smoke.spec.ts:823-881(fake settings)+ 新用例
Interfaces:
-
Consumes: Task 1
unquotePath/splitShellArgs;Task 2SidebarError.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 行)——替换为:
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 复用):
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 行)——解析走新函数:
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 行)——注释与返回值更新:
// 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 上方新增导出:
/**
* 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 体改为:
} 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
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常量后追加:
/**
* 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 行)之前插入:
// 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 即红):
// 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
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:
git add docs/plans/2026-09-08-terminal-shell-config-hardening-design.md
git commit -m "docs(plans): 回填终端 shell 配置加固实施偏差"
- Step 3: 推送并建 PR
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 链接、改动文件清单、行为对照(改前/改后)。