From de0eac54b7a58ffc2e3108de3674b84b3cf79c44 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:29 +0800 Subject: [PATCH 1/9] =?UTF-8?q?docs(plans):=20=E7=BB=88=E7=AB=AF=20shell?= =?UTF-8?q?=20=E9=85=8D=E7=BD=AE=E5=8A=A0=E5=9B=BA=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-terminal-shell-config-hardening-design.md | 75 ++ ...08-terminal-shell-config-hardening-plan.md | 645 ++++++++++++++++++ 2 files changed, 720 insertions(+) create mode 100644 docs/plans/2026-09-08-terminal-shell-config-hardening-design.md create mode 100644 docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md diff --git a/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md b/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md new file mode 100644 index 0000000..fe71f11 --- /dev/null +++ b/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md @@ -0,0 +1,75 @@ +# 终端 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:`(受 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:') → 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 不受影响。 + +## 实施偏差记录 + +- (实施时回填) + +## 交付 + +- AGENTS 硬约束:`feat/*` 分支开发,commit 后 `gh pr create`,review 合并进 main;不直推。 diff --git a/docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md b/docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md new file mode 100644 index 0000000..9e1192a --- /dev/null +++ b/docs/plans/2026-09-08-terminal-shell-config-hardening-plan.md @@ -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` 类型检查 + `tests/locales.spec.ts` 双重拦截。 +- 不改 vitest `exclude`;不改 chunk 边界(不新增 core bundle 的静态 import)。 +- 测试命令一律 `pnpm exec vitest run `(仓库用 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`;`resolveShellExecutable` 在 POSIX 找不到时抛 `SidebarError('shell-not-found', 'shell executable not found: ""', 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, + ) { + 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:`,其余 → 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 + 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:`, + * 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` 与 `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)` 直接编译失败。 + +- [ ] **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 链接、改动文件清单、行为对照(改前/改后)。 From 829ad49ddf7f7fe8eb958e36e49a7e226b29de00 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:30 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(terminal):=20=E5=BC=95=E5=8F=B7?= =?UTF-8?q?=E6=84=9F=E7=9F=A5=E7=9A=84=20shell=20=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=B8=8E=E8=B7=AF=E5=BE=84=E5=8E=BB=E5=BC=95?= =?UTF-8?q?=E5=8F=B7=E7=BA=AF=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pty-manager.ts | 56 +++++++++++++++++++++++++++++++++++++++ tests/pty-helpers.spec.ts | 32 ++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 5d5712d..156ecb6 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -467,3 +467,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 !== '') +} diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts index a02cefd..02f80f0 100644 --- a/tests/pty-helpers.spec.ts +++ b/tests/pty-helpers.spec.ts @@ -22,6 +22,8 @@ import { resolveShellExecutable, shellDisplayName, shellSpawnArgs, + splitShellArgs, + unquotePath, } from '../src/pty-manager.ts' describe('pty helpers', () => { @@ -156,4 +158,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']) + }) }) From 26706bc3e9cf96ef0810a84dad13f856974b0c31 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:30 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(terminal):=20POSIX=20=E4=B9=9F?= =?UTF-8?q?=E9=A2=84=E6=A3=80=20shell=20=E5=8F=AF=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E7=BB=9F=E4=B8=80=20shell-not-found?= =?UTF-8?q?=20=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pty-manager.ts | 87 ++++++++++++++++++++++++--------------- src/wire.ts | 3 ++ tests/pty-helpers.spec.ts | 38 ++++++++++++++++- 3 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 156ecb6..88896de 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -352,51 +352,72 @@ 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() } /** diff --git a/src/wire.ts b/src/wire.ts index ccea91c..3002543 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -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, ) { super(message) } diff --git a/tests/pty-helpers.spec.ts b/tests/pty-helpers.spec.ts index 02f80f0..87cf049 100644 --- a/tests/pty-helpers.spec.ts +++ b/tests/pty-helpers.spec.ts @@ -25,6 +25,7 @@ import { 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', () => { @@ -114,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', () => { From 870499430b5ff9cd5d4d630130f656f5e6022450 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:30 +0800 Subject: [PATCH 4/9] =?UTF-8?q?docs(terminal):=20resolveShellExecutable=20?= =?UTF-8?q?JSDoc=20=E8=B7=9F=E9=9A=8F=20shell-not-found=20=E6=96=B0?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pty-manager.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 88896de..6ea0014 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -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,8 +347,8 @@ 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, From 8aa9441627dfc2473ed2f624b97bd052ab7a90ac Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:30 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat(terminal):=20shell=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=8E=BB=E5=BC=95=E5=8F=B7=E8=BF=9B=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E3=80=81=E6=A0=87=E9=A2=98=E8=B7=9F=E9=9A=8F=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E3=80=81close=20reason=20=E6=9C=BA=E5=99=A8=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 36 +++++++++++++++++++++++++++--------- tests/smoke.spec.ts | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index be48e16..4ac489a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 - 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,21 @@ 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:`, + * 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) +} + /** * Wire one terminal socket to its pty: replay transcript, pump both ways. * Two attach modes share the wire protocol: @@ -1304,7 +1322,7 @@ async function attachTerminal( } }) } catch (error) { - ws.close(1011, error instanceof Error ? error.message : String(error)) + ws.close(1011, wsCloseReasonOf(error)) } } diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 99a834d..54a3770 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -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,32 @@ 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('reads the resolved prefs and writes a patch through the seam', async () => { const route = mountWithSettings(createFakeSettings()) const read = await invoke(route, 'settings.get', {}) From e8761ee851f745d602c2c4ad026ec944d45c54f2 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:30 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(terminal):=20=E9=85=8D=E7=BD=AE=20shel?= =?UTF-8?q?l=20=E6=9C=AA=E6=89=BE=E5=88=B0=E6=97=B6=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=8F=AF=E6=93=8D=E4=BD=9C=E6=A8=AA?= =?UTF-8?q?=E5=B9=85=EF=BC=88=E5=85=A8=E8=AF=AD=E8=A8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/client/TerminalView.tsx | 13 +++++++++++++ src/client/locales-ar.ts | 1 + src/client/locales-de.ts | 1 + src/client/locales-fr.ts | 1 + src/client/locales-hi.ts | 1 + src/client/locales-id.ts | 1 + src/client/locales-it.ts | 1 + src/client/locales-ja.ts | 1 + src/client/locales-ko.ts | 1 + src/client/locales-nl.ts | 1 + src/client/locales-pl.ts | 1 + src/client/locales-pt.ts | 1 + src/client/locales-ru.ts | 1 + src/client/locales-sv.ts | 1 + src/client/locales-th.ts | 1 + src/client/locales-tr.ts | 1 + src/client/locales-vi.ts | 1 + src/client/locales-zh-HK.ts | 1 + src/client/locales-zh-MO.ts | 1 + src/client/locales-zh-TW.ts | 1 + src/client/locales.ts | 2 ++ 21 files changed, 34 insertions(+) diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 5c47604..ab03083 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -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 @@ -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 !== '') { diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 605f467..cc66eec 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -107,6 +107,7 @@ export const ar: Record = { terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty', terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):', terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})', + terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal', preview: 'معاينة', toc: 'الفهرس', edit: 'تحرير', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index 0a3495c..d34b382 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -92,6 +92,7 @@ export const de: Record = { 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', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 9e4c23c..567fffd 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -99,6 +99,7 @@ export const fr: Record = { 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', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index f78e1c7..3c0ee0b 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -106,6 +106,7 @@ export const hi: Record = { terminalDepsFailed: 'टर्मिनल निर्भरता node-pty लोड विफल', terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):', terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})', + terminalShellNotFound: 'कॉन्फ़िगर किया गया shell नहीं मिला: {name} — Settings → Side card → Terminal में shell पथ जाँचें', preview: 'पूर्वावलोकन', toc: 'विषय-सूची', edit: 'संपादित करें', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 33fc653..45a1002 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -104,6 +104,7 @@ export const id: Record = { 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', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index f2629ce..a7ab143 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -97,6 +97,7 @@ export const it: Record = { 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', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 7f91f50..a967059 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -106,6 +106,7 @@ export const ja: Record = { terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗', terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):', terminalDepsProfile: '(検出された profile:{profile})', + terminalShellNotFound: '設定されたシェルが見つかりません:{name}(設定 → サイドカード → ターミナル のシェルパスを確認してください)', preview: 'プレビュー', toc: '目次', edit: '編集', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index f27bf13..4e8b4f1 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -98,6 +98,7 @@ export const ko: Record = { terminalDepsFailed: '터미널 의존성 node-pty 로드 실패', terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):', terminalDepsProfile: ' (profile 감지됨: {profile})', + terminalShellNotFound: '설정된 셸을 찾을 수 없습니다: {name} — 설정 → 사이드 카드 → 터미널에서 셸 경로를 확인하세요', preview: '미리보기', toc: '목차', edit: '편집', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 96d976e..8106e22 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -104,6 +104,7 @@ export const nl: Record = { 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', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index f9d5999..dd62423 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -108,6 +108,7 @@ export const pl: Record = { 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', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 03e022a..08c0d25 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -89,6 +89,7 @@ export const pt: Record = { 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', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index c75518c..10711b8 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -102,6 +102,7 @@ export const ru: Record = { terminalDepsFailed: 'Не удалось загрузить зависимость терминала node-pty', terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):', terminalDepsProfile: ' (обнаружен profile: {profile})', + terminalShellNotFound: 'Настроенная оболочка не найдена: {name} — проверьте путь к оболочке в Настройки → Side card → Terminal', preview: 'Предпросмотр', toc: 'Оглавление', edit: 'Изменить', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 5b08a7b..8c1262f 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -89,6 +89,7 @@ export const sv: Record = { 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', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 009ceac..fcdfa5d 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -106,6 +106,7 @@ export const th: Record = { terminalDepsFailed: 'การอ้างอิงเทอร์มินัล node-pty โหลดล้มเหลว', terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):', terminalDepsProfile: ' (ตรวจพบ profile: {profile})', + terminalShellNotFound: 'ไม่พบ shell ที่กำหนดไว้: {name} — ตรวจสอบพาธ shell ใน การตั้งค่า → Side card → Terminal', preview: 'พรีวิว', toc: 'สารบัญ', edit: 'แก้ไข', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 633374a..f7d015e 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -106,6 +106,7 @@ export const tr: Record = { 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', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 03df336..11044a8 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -106,6 +106,7 @@ export const vi: Record = { 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', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index ba55f2e..b898c5a 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -121,6 +121,7 @@ export const zhHK: Record = { terminalDepsFailed: '終端依賴 node-pty 載入失敗', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsProfile: '(偵測到 profile:{profile})', + terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑', preview: '預覽', toc: '目錄', edit: '編輯', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 21de407..52d3e8d 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -121,6 +121,7 @@ export const zhMO: Record = { terminalDepsFailed: '終端依賴 node-pty 載入失敗', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsProfile: '(偵測到 profile:{profile})', + terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑', preview: '預覽', toc: '目錄', edit: '編輯', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index a1dfd60..0601580 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -121,6 +121,7 @@ export const zhTW: Record = { terminalDepsFailed: '終端依賴 node-pty 載入失敗', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsProfile: '(偵測到 profile:{profile})', + terminalShellNotFound: '未找到配置的 Shell:{name},請到 設定 → 側邊卡片 → 終端 檢查 Shell 路徑', preview: '預覽', toc: '目錄', edit: '編輯', diff --git a/src/client/locales.ts b/src/client/locales.ts index 07fca34..2c0083e 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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', From e847771954fe77cfc1bcd48af5d6926d22e10085 Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Tue, 8 Sep 2026 21:21:31 +0800 Subject: [PATCH 7/9] =?UTF-8?q?docs(plans):=20=E5=9B=9E=E5=A1=AB=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=20shell=20=E9=85=8D=E7=BD=AE=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E5=81=8F=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-08-terminal-shell-config-hardening-design.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md b/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md index fe71f11..8952bb9 100644 --- a/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md +++ b/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md @@ -68,7 +68,11 @@ ## 实施偏差记录 -- (实施时回填) +- **报错文案合并为单 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` 类型检查兜底。 +- **`resolveShellExecutable` 的 JSDoc 同步**:win32 错误码从 `pty-error` 变更为 `shell-not-found` 后,函数上方文档一并更新(commit `cbbed88`)。 +- **本机全量测试基线**:`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 环境为准。 ## 交付 From 81919516d81cf6623d83acc058b337c35f0b3769 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 8 Sep 2026 22:20:15 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(terminal):=20WS=20close=20reason=20?= =?UTF-8?q?=E6=8C=89=20UTF-8=20=E5=AD=97=E8=8A=82=E6=88=AA=E6=96=AD?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E8=B6=85=20123=20=E5=AD=97=E8=8A=82?= =?UTF-8?q?=E6=8A=9B=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ws 用 Buffer.byteLength 校验 close reason 的 123 字节上限(ws/lib/sender.js), 原 slice(0, 100) 按 UTF-16 code unit 截断,非 ASCII shell 名可达 316 字节 (实测 200 个 CJK 字符),ws.close() 会抛 RangeError 顶掉原始错误。 改为按字节截断且不切碎 code point,并补回归测试。 --- src/index.ts | 25 +++++++++++++++++++++---- tests/smoke.spec.ts | 13 +++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4ac489a..4638174 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1182,18 +1182,35 @@ 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:`, - * 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). + * 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 = shellDisplayName(String(error.meta?.shell ?? '')).slice(0, 100) + 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: diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 54a3770..3ec800c 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -963,6 +963,19 @@ describe('side card settings routes', () => { 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', {}) From ad22abcfe639660a37a69b5daccb2f19082f2018 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 8 Sep 2026 22:20:15 +0800 Subject: [PATCH 9/9] =?UTF-8?q?docs(plans):=20=E4=BF=AE=E6=AD=A3=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E5=81=8F=E5=B7=AE=E8=AE=B0=E5=BD=95=E7=9A=84=20commit?= =?UTF-8?q?=20=E5=BC=95=E7=94=A8=EF=BC=88cbbed88=20=E2=86=92=208704994?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-09-08-terminal-shell-config-hardening-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md b/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md index 8952bb9..8d71ab6 100644 --- a/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md +++ b/docs/plans/2026-09-08-terminal-shell-config-hardening-design.md @@ -71,7 +71,7 @@ - **报错文案合并为单 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` 类型检查兜底。 -- **`resolveShellExecutable` 的 JSDoc 同步**:win32 错误码从 `pty-error` 变更为 `shell-not-found` 后,函数上方文档一并更新(commit `cbbed88`)。 +- **`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 环境为准。 ## 交付