dsh-plugin-shell-selector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tofu-zyc (TOFU BOX)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # dsh-plugin-shell-selector
2
+
3
+ 在 Web 界面 **设置 → Shell** 页面选择 AI 执行命令时使用的 shell。
4
+
5
+ 本插件**不实现 shell 执行器**,也不覆盖任何内置行。它只做两件事:读取现有执行器的实时事实,以及通过 `settings.mutate('pwsh-sandbox', …)` 写入执行器**本来就支持**的 `pwshPath` volatile Config 字段。
6
+
7
+ ## 选项与真实状态
8
+
9
+ | 选项 | 作用 | 本机只读探测结果 |
10
+ | --- | --- | --- |
11
+ | 自动(保留当前默认) | 清除用户层 `pwshPath`,回落到内置解析顺序 | 可用 → `…\WindowsApps\Microsoft.PowerShell_7.6.6.0_x64__8wekyb3d8bbwe\pwsh.exe`(PowerShell 7.6.6) |
12
+ | PowerShell 7 | 写入解析到的 `pwsh.exe` | 可用 → 同上 |
13
+ | Windows PowerShell 5.1 | 写入 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` | 可用(页面显示 5.1 语法兼容提示) |
14
+ | Git Bash | — | **不可选**:已检测到 `C:\Program Files\Git\bin\bash.exe`,但受限运行尚未验证 |
15
+
16
+ ## 为什么改动是真实的
17
+
18
+ `@deepseek-ai/dsh-pwsh-local` 在 `pwsh-sandbox` profile 条目读取其 volatile `pwshPath`,并在后续 `argv()` 调用时解析该值。因此通过设置表单更新配置后,下一次 AI 命令便使用所选程序,**无需重启、无需替换执行器**。
19
+
20
+ 由此保留的行为全部是内置实现:
21
+
22
+ - 沙箱(`ctx.sandbox` 经 `dsh-pwsh-sandbox` 包装 argv)、审批、workdir、timeout、取消、退出码、stdout/stderr、后台 jobs 一律不变;
23
+ - 插件不调用 `child_process`,不拼接 shell 字符串,不添加 argv 前缀,不启停任何行;
24
+ - 执行中的进程不受影响:它们的 argv 在启动时已确定,改动只作用于之后启动的命令。
25
+
26
+ `probe()` 报告的"当前实际执行"直接来自 `ctx.shell.pwshPath`(执行器自己解析出的路径),不是本插件的二次推导。
27
+
28
+ ### 校验
29
+
30
+ 执行器把配置的 `pwshPath` 视为可信、自身不校验,所以校验由本插件在**写入之前**完成:
31
+
32
+ - 用 `lstat` 判定(与内置 `candidateExists` 同语义),因此 Microsoft Store 执行别名(symlink)被正确接受;
33
+ - 拒绝目录、缺失、空路径;
34
+ - 含空格的路径(如 `C:\Program Files\...`)作为单个 argv 元素传递,不做任何字符串插值。
35
+
36
+ 若清除 `pwshPath`(选"自动")后内置解析仍失败,`shell.pwshPath` 会回落到按 PATH 解析 `pwsh` —— 与内置行为一致。
37
+
38
+ ### 已知限制:自动档的预览路径
39
+
40
+ "自动"档的预览由本插件按内置解析顺序推导(`pwsh7Locations` 加 5.1 回落),**没有考虑组合层(composition base)的 `pwshPath`**。若某个部署在 `pwsh-sandbox` 行配置里写死了 `pwshPath`,选"自动"实际会回落到那个组合层值,而预览显示的是探针推导值 —— 两者会不一致,有误导风险。
41
+
42
+ 当前部署未在组合层设置 `pwshPath`,因此两者一致,暂无影响。此问题留待后续修正(例如读取 `describe()` 的 `base` 层并与探针结果区分显示)。
43
+
44
+ 注意页面上的"当前实际执行"始终取自 `ctx.shell.pwshPath`,所以即使预览有偏差,**生效值显示的仍是真值**。
45
+
46
+ ## 语法提示一致性
47
+
48
+ `pwsh` 工具的名称与描述是静态的,运行期没有接缝可改。本版本提供或允许选择的三档**全部是 PowerShell**,因此工具名 `pwsh`、`pwsh -Command` 与 `$env:NAME` 提示始终成立。
49
+
50
+ 唯一的偏差是 PowerShell 5.1 缺少 `&&`、`||`、三元 `? :` 与 `??`。这一点通过两种方式如实说明,**不修改工具本身**:
51
+
52
+ 1. 页面在 5.1 选项下始终显示兼容提示;
53
+ 2. 当生效 shell 确实是 5.1 时,注册一条运行时上下文(`shell-selector/dialect`)告知模型真实方言;其余情况返回空字符串,不产生任何上下文。
54
+
55
+ ## Git Bash:为什么不可选
56
+
57
+ 三条独立事实:
58
+
59
+ 1. **win32 上没有 bash 执行器。** `@deepseek-ai/dsh-base` 的 `bash-sandbox` / `tool-bash` 行在 Windows 上 `disabled`,而 `ctx.shell` 每个 context 只允许一个实现(第二个注册直接抛错)。真要支持必须自写执行器并关掉 `pwsh-sandbox` 行。
60
+ 2. **PATH 上的 `bash` 是 WSL 启动器。** 本机 `bash` → `C:\WINDOWS\system32\bash.exe`,它在另一个文件系统命名空间里执行,等于用 WSL VM 边界替换 DSH 文件沙箱。因此本插件**只按 Git for Windows 显式路径解析,从不查 PATH**(有专门的单元测试守住这一点)。
61
+ 3. **受限运行未经验证。** Windows runner(`dsh-sandbox-windows-acl`)本身是 argv 无关且 fail-closed 的(`[node, runner.js, --workspace, --temp, --mode, --, <argv…>]`;任何 runner 侧失败打印 `windows-acl-run:` 并 exit 127,绝不无沙箱 spawn),所以它*能*包住 `bash.exe -c`。但受限令牌只授予工作区与私有临时目录写权限,且两种受限模式下程序都无法打开命名管道,而 MSYS2 的 `fork()` 模拟依赖命名管道。
62
+
63
+ 在没有证据之前,Git Bash 保持**禁用并显示原因**,绝不以无沙箱方式运行,也绝不把 `pwshPath` 指向 bash 假装切换。
64
+
65
+ ### 状态:待验证,且当前没有可执行的探测方案
66
+
67
+ 曾有一版独立探测脚本(`tools/gitbash-sandbox-probe.mjs`),**已被删除且从未执行**。它不安全,已确认的问题:
68
+
69
+ - 以"stderr 里没有 `windows-acl-run` 签名"判定成功不可靠 —— 非零退出、`spawnError`、超时都可能被误判为通过;
70
+ - `child.kill()` 在 Windows 上无法可靠清理整个进程树,并可能跳过 runner 的 DACL 撤销;
71
+ - 它以整个仓库作为 `--workspace`,且未清理临时目录、未把 Bash 的临时目录指向专有 temp。
72
+
73
+ 更关键的是:**`ctx.sandbox` 与 `ctx.subprocess` 只存在于宿主进程内,外部脚本无法安全取得。** 因此不存在安全的独立脚本实现,本仓库不再提供探测脚本,也**不会运行任何裸命令冒充安全验证**。
74
+
75
+ Git Bash 的状态是**待验证**:既不是"不可用",也不是"已完成"。
76
+
77
+ 将来若要真正验证,必须在宿主内走生产 API(此处仅记录约束,**未实现、未验证**):`ctx.sandbox.confine(['<gitBash>', '-c', <script>], policy, signal)` 取得受管 argv(ACL 授权与撤销归沙箱 provider),再交给 `ctx.subprocess.spawn({ argv, cwd: <专用 probe workspace>, stdio, graceMs, signal })`(进程树取消归受管 subprocess,不使用裸 kill);成功判定必须是 `exitCode === 0` **且**每个阶段的确切 marker 都出现,超时按真实 deadline 标志判定;probe workspace 与 temp 都必须是专用目录,不复用仓库。这需要在插件上增加一个仅用于验证的 Remote,超出当前版本范围。
78
+
79
+ 即使将来探测通过,开放 Git Bash 还需要另一步:**语法诚实性**。`tool-pwsh` 在 `apply()` 时一次性构建 schema,`tool-bash` 行在 win32 被禁用,且换行是组合期决定、无法随设置热切。要让模型拿到 bash 语法,必须新增一个按设置重建 schema 的 tool。
80
+
81
+ ## 界面语言
82
+
83
+ 页面跟随 **设置 → 通用 → 语言** 的当前语言(`@deepseek-ai/dsh-client-locale` 的 active locale),不硬编码中文;`bind` 在调用时读取 active locale,切换语言后页面与菜单项标签都会跟着变。
84
+
85
+ 字典按 **locale id** 注册。浏览器端内置 id 是 `zh` 与 `en`(`LOCALE_IDS`),`bind(ns)` 沿 active locale 的回退链查找(`zh` → `en`)。注册时会枚举实时 catalog,因此语言包引入的 `zh-CN`、`zh-Hans` 等变体同样拿到中文字典。
86
+
87
+ > **已修复的运行缺陷**:早先版本把中文字典注册在 `zh-CN` 下。active id 实际是 `zh`,该字典不可达,回退链于是落到英文字典,界面整体显示英文。现在以 `zh` 为准。注意 `dsh-client-locale` 内部把 `document.lang` 的 `zh` 映射为 `zh-CN`,那是该插件的 DOM 行为,**不能**当作字典键使用。
88
+
89
+ ## 安装
90
+
91
+ 由 Lead 统一安装(本插件不自行安装):
92
+
93
+ ```
94
+ plugin_manager action=install_bundle target=D:\program\dsh-plugins\dsh-plugin-shell-selector
95
+ ```
96
+
97
+ 依赖 `@deepseek-ai/dsh-typert-protocol`(workspace 内已可解析),无构建脚本、无安装脚本。
98
+
99
+ ## 测试
100
+
101
+ ```sh
102
+ npm test # node --test tests/*.test.mjs
103
+ ```
104
+
105
+ **已实际执行**:30 个用例全部通过。覆盖候选顺序、含空格路径、目录/缺失/Store 别名判定、WSL 启动器拒绝、显式 PS7 不静默回落 5.1、Git Bash 始终不可选、非 Windows 平台、当前选项派生、四种拒绝路径均不产生写入、`mutate` 参数与 revision、`ctx.shell.pwshPath` 优先于二次推导、方言提示条件、两侧契约(Remote 方法名与客户端寻址一致、`settings.section` 注册参数、中英文字典键完全对齐且覆盖宿主可能发出的每个 key),以及**中文 locale 渲染与回退**(见下)。
106
+
107
+ 这些用例不是静态检查:测试里的 locale 替身复刻了真实的回退规则(内置 id 为 `zh`/`en`,`zh` 回退到 `en`,`bind` 沿 **active** locale 的回退链查找)。把实现改回"只注册 `zh-CN`"会使 6 个用例失败,其中就有"zh 生效时必须渲染中文而非英文"—— 该回归已被实际验证过。
108
+
109
+ 探测脚本已删除,未执行任何探测。
110
+
111
+ ## 尚未验证
112
+
113
+ - `pwsh-sandbox` 条目已在 `settings.describe()` 中显示,隔离实例 3099 的设置页已通过无头浏览器在 5.1 与自动之间切换并恢复;
114
+ - 生产安装后的实时行为尚需切换后验证:`ctx.shell.pwshPath` 是否立即变化,以及下一条命令是否真的换了可执行文件;
115
+ - 页面在隔离 0.1.7 浏览器中已无 pageerror;中文显示由中英字典单测覆盖。
116
+
117
+ 中文界面已在隔离的 0.1.7-alpha.2 无头浏览器中确认可用。生产切换后仍应抽查下一条命令的实际执行器,不能用界面选择代替执行验证。
package/client.js ADDED
@@ -0,0 +1,263 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-plugin-shell-selector',
3
+ factory: require => {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const NS = 'shell-selector'
7
+ const SERVICE = 'shellSelector'
8
+
9
+ // Every key exists in both dictionaries: the page never renders a missing
10
+ // translation, and a host-supplied Chinese fallback is only used when the
11
+ // host reported no key at all.
12
+ //
13
+ // Dictionaries are keyed by LOCALE ID. `@deepseek-ai/dsh-client-locale` ships
14
+ // `zh` and `en` (`LOCALE_IDS`), and `bind(ns)` walks the active locale's
15
+ // fallback chain — `zh` -> `en`. A dictionary registered only under a region
16
+ // tag such as `zh-CN` therefore matches nothing, and Chinese silently renders
17
+ // as English. `zh` is the id that must exist; region variants are covered by
18
+ // registration-time enumeration below.
19
+ const CHINESE = {
20
+ title: 'Shell',
21
+ intro: '选择 AI 执行命令时使用的 shell。改动会真实作用于之后启动的命令,并影响本机所有会话。',
22
+ effective: '当前实际执行',
23
+ sandbox: '沙箱默认模式',
24
+ refresh: '刷新',
25
+ unavailable: '不可用',
26
+ applying: '正在切换…',
27
+ 'notice.applied': '已切换。下一条命令将使用所选 shell。',
28
+ custom: '当前值由本页之外的配置写入,本页不会覆盖它;选择任一项即可接管。',
29
+ readonly: '设置存储不可写,无法切换。',
30
+ absent: '未找到 shell 执行器,请确认 PowerShell 执行器已加载后重启。',
31
+ retry: '重试',
32
+ 'option.auto': '自动(保留当前默认)',
33
+ 'option.pwsh7': 'PowerShell 7',
34
+ 'option.pwsh5': 'Windows PowerShell 5.1',
35
+ 'option.gitbash': 'Git Bash',
36
+ 'reason.windowsOnly': 'Shell 选择器仅适用于 Windows 部署。',
37
+ 'reason.autoPathLookup': '未找到已知安装位置,将按 PATH 解析 pwsh。',
38
+ 'reason.autoLanded7': '自动优先使用 PowerShell 7。',
39
+ 'reason.autoLanded51': '未安装 PowerShell 7,自动回落到 Windows PowerShell 5.1。',
40
+ 'reason.noPwsh7': '未检测到 PowerShell 7(pwsh.exe)。',
41
+ 'reason.noPwsh5': '未检测到 Windows PowerShell 5.1(%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe)。',
42
+ 'reason.noGitBash': '未检测到 Git for Windows(%ProgramFiles%\\Git\\bin\\bash.exe)。',
43
+ 'reason.gitBashUnverified': '已安装,但尚未验证它能在 Windows 文件沙箱下受限运行,因此暂不可选。',
44
+ 'reason.wslBash': '解析结果是 WSL 启动器而非 Git Bash,已拒绝。',
45
+ 'reason.pwsh5Dialect': 'PowerShell 5.1 语法与 7 不同,见下方兼容提示。',
46
+ 'note.running': '已在运行的后台命令不受影响:它们仍使用启动时的 shell。改动只作用于之后启动的命令。',
47
+ 'note.pwsh5': '兼容提示(必须阅读):Windows PowerShell 5.1 不支持 && 与 || 管道链、三元运算符 ? : 和 ?? 空合并运算符。命令与脚本需改用 ; 串联,并用 if (...) { } else { } 分支。',
48
+ 'note.gitbash': '为什么不可选:Windows 文件沙箱以受限令牌运行命令,只授予工作区与私有临时目录写权限,且两种受限模式下程序都无法打开命名管道,而 MSYS2 的 fork 模拟依赖命名管道。另外 PATH 上的 bash 是 WSL 启动器(另一个文件系统命名空间),不是 Git Bash,因此本插件只按 Git for Windows 显式路径解析。在受限运行验证完成之前不会提供该选项,也不会以无沙箱方式运行。',
49
+ }
50
+
51
+ const ENGLISH = {
52
+ title: 'Shell',
53
+ intro: 'Choose the shell the AI runs commands in. The change takes effect for commands started afterwards and applies to every session on this machine.',
54
+ effective: 'Currently executing',
55
+ sandbox: 'Default sandbox mode',
56
+ refresh: 'Refresh',
57
+ unavailable: 'Unavailable',
58
+ applying: 'Switching…',
59
+ 'notice.applied': 'Switched. The next command uses the selected shell.',
60
+ custom: 'The current value was written outside this page and is not overwritten here; picking any option takes it over.',
61
+ readonly: 'The settings store is not writable, so the shell cannot be switched.',
62
+ absent: 'No shell executor was found. Confirm the PowerShell executor is loaded, then restart.',
63
+ retry: 'Retry',
64
+ 'option.auto': 'Automatic (keep the current default)',
65
+ 'option.pwsh7': 'PowerShell 7',
66
+ 'option.pwsh5': 'Windows PowerShell 5.1',
67
+ 'option.gitbash': 'Git Bash',
68
+ 'reason.windowsOnly': 'The shell selector applies to Windows deployments only.',
69
+ 'reason.autoPathLookup': 'No known install location was found, so `pwsh` resolves through PATH.',
70
+ 'reason.autoLanded7': 'Automatic prefers PowerShell 7.',
71
+ 'reason.autoLanded51': 'PowerShell 7 is not installed, so automatic falls back to Windows PowerShell 5.1.',
72
+ 'reason.noPwsh7': 'PowerShell 7 (pwsh.exe) was not detected.',
73
+ 'reason.noPwsh5': 'Windows PowerShell 5.1 was not detected (%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe).',
74
+ 'reason.noGitBash': 'Git for Windows was not detected (%ProgramFiles%\\Git\\bin\\bash.exe).',
75
+ 'reason.gitBashUnverified': 'Installed, but it has not been verified to run confined under the Windows file sandbox, so it is not selectable.',
76
+ 'reason.wslBash': 'The resolved executable was the WSL launcher rather than Git Bash, so it was refused.',
77
+ 'reason.pwsh5Dialect': 'PowerShell 5.1 syntax differs from 7; see the compatibility note below.',
78
+ 'note.running': 'Background commands already running are unaffected: they keep the shell they started with. Only commands started afterwards change.',
79
+ 'note.pwsh5': 'Compatibility note (required reading): Windows PowerShell 5.1 has no `&&` or `||` pipeline chains, no ternary `? :`, and no `??`. Chain with `;` and branch with `if (...) { } else { }`.',
80
+ 'note.gitbash': 'Why this is unavailable: the Windows file sandbox runs commands under a restricted token whose write capabilities cover only the workspace and a private temp directory, and programs cannot open named pipes in either confined mode, while MSYS2 fork emulation depends on them. The `bash` on PATH is also the WSL launcher (a separate filesystem namespace), not Git Bash, so only explicit Git for Windows paths are resolved. The option stays unavailable until confined execution is verified, and it is never run unsandboxed.',
81
+ }
82
+
83
+ /** The locale ids the browser client ships; `zh` is mandatory, not `zh-CN`. */
84
+ const BUILT_IN_LOCALE_IDS = ['zh', 'en']
85
+
86
+ /** Pick the dictionary a locale id should receive, by its primary subtag. */
87
+ function dictionaryFor(id) {
88
+ const primary = String(id).toLowerCase().split('-')[0]
89
+ if (primary === 'zh') return CHINESE
90
+ if (primary === 'en') return ENGLISH
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * Register the dictionary pair for the built-in ids plus every id the live
96
+ * catalog exposes, so a language-pack locale such as `zh-CN` or `zh-Hans`
97
+ * resolves Chinese instead of falling through to English.
98
+ * @returns a disposer removing every registration this call made.
99
+ */
100
+ function registerDictionaries(locale) {
101
+ const ids = new Set(BUILT_IN_LOCALE_IDS)
102
+ for (const definition of locale.getSnapshot().locales) {
103
+ if (definition !== null && typeof definition === 'object' && typeof definition.id === 'string') ids.add(definition.id)
104
+ }
105
+ const disposers = []
106
+ for (const id of ids) {
107
+ const dict = dictionaryFor(id)
108
+ if (dict !== undefined) disposers.push(locale.register(NS, id, dict))
109
+ }
110
+ return () => { for (const dispose of disposers) dispose() }
111
+ }
112
+
113
+ const css = `
114
+ .ss-page{display:flex;flex-direction:column;gap:14px;padding:4px 0 24px;color:inherit}
115
+ .ss-page fieldset{border:1px solid #8886;border-radius:10px;padding:14px;min-width:0;display:flex;flex-direction:column;gap:10px}
116
+ .ss-page legend{font-weight:600;padding:0 6px}
117
+ .ss-option{display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid #8885;border-radius:8px;cursor:pointer}
118
+ .ss-option-off{opacity:.6;cursor:default}
119
+ .ss-option input{margin-top:4px;flex:none}
120
+ .ss-option-body{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1}
121
+ .ss-option-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
122
+ .ss-badge{font-size:11px;border:1px solid #8888;border-radius:999px;padding:1px 7px;opacity:.85}
123
+ .ss-path{font-size:12px;overflow-wrap:anywhere;opacity:.9}
124
+ .ss-note{font-size:12px;opacity:.78;line-height:1.6;margin:0}
125
+ .ss-warn{color:#d5533d}
126
+ .ss-error{color:#d5533d;white-space:pre-wrap;font-size:12px}
127
+ .ss-ok{font-size:12px;opacity:.85}
128
+ .ss-head{display:flex;flex-direction:column;gap:5px}
129
+ .ss-actions{display:flex;gap:8px;flex-wrap:wrap}
130
+ .ss-page button{padding:7px 11px;border:1px solid #8888;border-radius:7px;background:transparent;color:inherit;cursor:pointer;font:inherit}
131
+ .ss-page button:disabled{opacity:.5;cursor:default}
132
+ `
133
+
134
+ /**
135
+ * The Remote envelope: `{ ok: true, value }` or `{ ok: false, error }`.
136
+ * Unwrapped once here so every caller sees the value or a thrown error.
137
+ */
138
+ const unwrap = response => {
139
+ if (!response || response.ok !== true) throw new Error(response?.error?.message ?? '请求失败')
140
+ return response.value
141
+ }
142
+
143
+ /**
144
+ * Re-render on language change and return the current translator. The
145
+ * snapshot is read through the store so a switch is reflected immediately.
146
+ */
147
+ function useTranslate(locale) {
148
+ const subscribe = React.useCallback(callback => locale.subscribe(callback), [locale])
149
+ const snapshot = React.useCallback(() => locale.getSnapshot(), [locale])
150
+ React.useSyncExternalStore(subscribe, snapshot, snapshot)
151
+ return locale.bind(NS)
152
+ }
153
+
154
+ function Page({ remote, call, locale, close }) {
155
+ const translate = useTranslate(locale)
156
+ const t = (key, fallback) => {
157
+ const value = translate(key)
158
+ return value === undefined || value === null || value === '' || value === key ? (fallback ?? key) : value
159
+ }
160
+ const [view, setView] = React.useState(null)
161
+ const [error, setError] = React.useState('')
162
+ const [notice, setNotice] = React.useState('')
163
+ const [busy, setBusy] = React.useState('')
164
+
165
+ const refresh = React.useCallback(async () => {
166
+ const next = await call('probe', {})
167
+ setView(next)
168
+ return next
169
+ }, [call])
170
+
171
+ React.useEffect(() => {
172
+ let live = true
173
+ refresh().then(() => { if (live) setError('') }, problem => { if (live) setError(problem.message) })
174
+ return () => { live = false }
175
+ }, [refresh])
176
+
177
+ async function choose(kind) {
178
+ if (view === null) return
179
+ setBusy(kind); setError(''); setNotice('')
180
+ try {
181
+ const next = await call('select', { kind, revision: view.revision })
182
+ setView(next)
183
+ setNotice(t('notice.applied'))
184
+ } catch (problem) {
185
+ setError(problem.message)
186
+ } finally {
187
+ setBusy('')
188
+ }
189
+ }
190
+
191
+ async function retry() {
192
+ setError(''); setNotice('')
193
+ try { await refresh() } catch (problem) { setError(problem.message) }
194
+ }
195
+
196
+ if (view === null) {
197
+ return h('div', { className: 'ss-page' },
198
+ h('p', { className: 'ss-note' }, error === '' ? t('applying') : error),
199
+ error === '' ? null : h('div', { className: 'ss-actions' }, h('button', { onClick: () => void retry() }, t('retry'))))
200
+ }
201
+
202
+ const locked = view.writable !== true || busy !== ''
203
+ const rows = view.options.map(option => {
204
+ const selected = view.current.kind === option.kind
205
+ const reason = option.reasonKey ? t(option.reasonKey, option.reason) : option.reason
206
+ const detail = option.detailKey ? t(option.detailKey) : ''
207
+ const notes = []
208
+ if (detail !== '') notes.push(h('p', { className: 'ss-note', key: 'detail' }, detail))
209
+ if (reason) notes.push(h('p', { className: 'ss-note ss-warn', key: 'reason' }, reason))
210
+ if (option.kind === 'pwsh5') notes.push(h('p', { className: 'ss-note', key: 'pwsh5' }, t('note.pwsh5')))
211
+ if (option.kind === 'gitbash') notes.push(h('p', { className: 'ss-note', key: 'gitbash' }, t('note.gitbash')))
212
+ return h('label', { key: option.kind, className: option.available ? 'ss-option' : 'ss-option ss-option-off' },
213
+ h('input', {
214
+ type: 'radio',
215
+ name: 'shell-kind',
216
+ checked: selected,
217
+ disabled: !option.available || locked,
218
+ onChange: () => void choose(option.kind),
219
+ }),
220
+ h('div', { className: 'ss-option-body' },
221
+ h('div', { className: 'ss-option-head' },
222
+ h('strong', null, t(option.labelKey, option.kind)),
223
+ option.available ? null : h('span', { className: 'ss-badge' }, t('unavailable')),
224
+ option.path ? h('code', { className: 'ss-path' }, option.path) : null),
225
+ notes.length > 0 ? h('div', null, notes) : null))
226
+ })
227
+
228
+ return h('div', { className: 'ss-page' },
229
+ h('p', { className: 'ss-note' }, t('intro')),
230
+ h('div', { className: 'ss-head' },
231
+ h('p', { className: 'ss-note' }, `${t('effective')}: `, h('code', null, view.executable ?? '—')),
232
+ h('p', { className: 'ss-note' }, `${t('sandbox')}: `, h('code', null, view.sandboxMode ?? '—')),
233
+ view.current.kind === 'custom' ? h('p', { className: 'ss-note ss-warn' }, t('custom')) : null,
234
+ view.namespaceRegistered !== true ? h('p', { className: 'ss-note ss-warn' }, t('absent')) : null,
235
+ view.writable !== true ? h('p', { className: 'ss-note ss-warn' }, t('readonly')) : null,
236
+ view.supported !== true ? h('p', { className: 'ss-note ss-warn' }, t('reason.windowsOnly')) : null),
237
+ h('fieldset', null, h('legend', null, t('title')), ...rows),
238
+ h('p', { className: 'ss-note' }, t('note.running')),
239
+ busy === '' ? null : h('p', { className: 'ss-ok' }, t('applying')),
240
+ notice === '' ? null : h('p', { className: 'ss-ok', role: 'status' }, notice),
241
+ error === '' ? null : h('p', { className: 'ss-error', role: 'alert' }, error),
242
+ h('div', { className: 'ss-actions' },
243
+ h('button', { disabled: busy !== '', onClick: () => void retry() }, t('refresh')),
244
+ typeof close === 'function' ? h('button', { onClick: () => close() }, t('title')) : null))
245
+ }
246
+
247
+ return {
248
+ inject: ['slots', 'connection', 'remote', 'remote.settings', 'locale'],
249
+ apply(ctx) {
250
+ const style = document.createElement('style')
251
+ style.textContent = css
252
+ document.head.appendChild(style)
253
+ ctx.effect(() => () => style.remove())
254
+ ctx.effect(() => registerDictionaries(ctx.locale))
255
+ const t = ctx.locale.bind(NS)
256
+ const call = async (method, args) => unwrap(await ctx.connection.rpc.call('/api', `${SERVICE}/${method}`, { args }))
257
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
258
+ { name: 'settings.section', id: NS, order: 14, label: () => t('title') },
259
+ props => h(Page, { ...props, remote: ctx.remote, call, locale: ctx.locale })))
260
+ },
261
+ }
262
+ },
263
+ })
@@ -0,0 +1,8 @@
1
+ # One insert over the existing profile composition. This bundle deliberately does
2
+ # NOT override, disable, or replace any shipped row: the shell executor row
3
+ # (`pwsh-sandbox`) keeps providing `ctx.shell`, and this plugin only reads its
4
+ # live facts and writes the already-supported `shell.pwshPath` setting.
5
+ - insert:
6
+ - id: shell-selector
7
+ name: dsh-plugin-shell-selector
8
+ inject: [settings]
package/index.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Host half of the Shell selector.
3
+ *
4
+ * This plugin owns no execution path. It reads the live facts off the composed
5
+ * shell executor (`ctx.shell`) and the settings service, and its only write is
6
+ * the already-supported `shell.pwshPath` field, which `dsh-pwsh-local` re-resolves
7
+ * in its own `onChange` hook. Consequently the sandbox, approval, workdir,
8
+ * timeout, cancellation, exit/stdout/stderr, and background-job behaviour are the
9
+ * shipped ones, untouched — no `child_process`, no argv prefix, no row override.
10
+ *
11
+ * Command syntax stays honest too: the `pwsh` tool schema is correct for every
12
+ * option this version can select. When the effective shell is Windows PowerShell
13
+ * 5.1 the executor's own facts no longer match the schema's PowerShell 7
14
+ * examples, so one runtime-context line states the real dialect. It adds nothing
15
+ * for PowerShell 7, and it does not modify the tool.
16
+ *
17
+ * @module dsh-plugin-shell-selector
18
+ */
19
+
20
+ import { RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
21
+ import { ShellSelectorError, dialectHint, executeSelection, probeShells } from './shells.js'
22
+
23
+ export const name = 'dsh-plugin-shell-selector'
24
+
25
+ /** PowerShell sandbox Config is read through the profile-entry settings service; the executor is an optional read. */
26
+ export const inject = ['settings']
27
+
28
+ /** Remote namespace; the page calls `/api` with `<SERVICE>/<method>`. */
29
+ export const SERVICE = 'shellSelector'
30
+
31
+ /** Placement of the dialect note among the registered runtime contexts. */
32
+ const DIALECT_CONTEXT_ORDER = 45
33
+
34
+ /** Map a planning failure onto the Remote envelope the client unwraps. */
35
+ function toRemoteError(error) {
36
+ if (error instanceof ShellSelectorError) return new RemoteError(error.code, error.message)
37
+ return new RemoteError('shell-selector/failed', error instanceof Error ? error.message : String(error))
38
+ }
39
+
40
+ /**
41
+ * Remote surface behind the settings page. Constructing it registers
42
+ * `ctx.shellSelector`; the page holds no direct service access.
43
+ */
44
+ export class ShellSelectorService extends TypertRemoteService {
45
+ /** @param ctx - the plugin context carrying the `settings` injection. */
46
+ constructor(ctx) {
47
+ super(ctx, SERVICE)
48
+ }
49
+
50
+ /**
51
+ * Live executor facts plus the selectable options.
52
+ * @returns the page payload; `executable` is the path the next command will use.
53
+ */
54
+ probe() {
55
+ return probeShells({
56
+ settings: this.ctx.settings,
57
+ shell: this.ctx.get('shell'),
58
+ env: process.env,
59
+ platform: process.platform,
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Validate one requested option, write it, and return the refreshed payload.
65
+ * @param kind - `auto`, `pwsh7`, `pwsh5`, or `gitbash`.
66
+ * @param revision - the `shell` namespace revision the caller read.
67
+ * @returns the payload after the write.
68
+ */
69
+ async select(kind, revision) {
70
+ try {
71
+ await executeSelection({
72
+ kind,
73
+ revision,
74
+ settings: this.ctx.settings,
75
+ env: process.env,
76
+ platform: process.platform,
77
+ })
78
+ } catch (error) {
79
+ throw toRemoteError(error)
80
+ }
81
+ return this.probe()
82
+ }
83
+ }
84
+
85
+ Object.defineProperty(ShellSelectorService.prototype, '@deepseek-ai/dsh-typert-protocol/remote-methods', {
86
+ value: Object.freeze({
87
+ version: 1,
88
+ methods: Object.freeze(['probe', 'select'].map(method =>
89
+ Object.freeze({ method, invocation: Object.freeze({ kind: 'direct' }) }))),
90
+ }),
91
+ })
92
+
93
+ /**
94
+ * Register the Remote service and the dialect context.
95
+ * @param ctx - plugin context.
96
+ */
97
+ export function apply(ctx) {
98
+ // Constructing the service is the registration; nothing else needs the handle.
99
+ new ShellSelectorService(ctx)
100
+
101
+ const systemPrompt = ctx.get('systemPrompt')
102
+ if (systemPrompt === undefined) return
103
+ // An empty string renders as no context at all, so this contributes only while
104
+ // the effective shell really is Windows PowerShell 5.1.
105
+ ctx.effect(() => systemPrompt.context({
106
+ name: 'shell-selector/dialect',
107
+ order: DIALECT_CONTEXT_ORDER,
108
+ text: () => dialectHint(ctx.get('shell')?.pwshPath, process.env),
109
+ }))
110
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "dsh-plugin-shell-selector",
3
+ "version": "0.1.0",
4
+ "description": "在 设置 → Shell 页面选择 AI 命令执行所用的 shell(自动 / PowerShell 7 / Windows PowerShell 5.1 / Git Bash),带真实可用性检测。",
5
+ "license": "MIT",
6
+ "keywords": ["dsh-plugin", "deepseek-harness", "dsh", "shell", "powershell"],
7
+ "publishConfig": { "registry": "https://registry.npmjs.org" },
8
+ "repository": { "type": "git", "url": "git+https://github.com/tofu-zyc/dsh-plugin-tofu-box.git", "directory": "dsh-plugin-shell-selector" },
9
+ "bugs": { "url": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/issues" },
10
+ "homepage": "https://github.com/tofu-zyc/dsh-plugin-tofu-box/tree/main/dsh-plugin-shell-selector",
11
+ "type": "module",
12
+ "main": "index.js",
13
+ "exports": { ".": "./index.js", "./client": "./client.js", "./package.json": "./package.json" },
14
+ "peerDependencies": {
15
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.6-alpha.1"
16
+ },
17
+ "dsh": {
18
+ "bundle": { "patch": "./cordis.patch.yml" },
19
+ "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes"] }
20
+ },
21
+ "scripts": { "test": "node --test tests/*.test.mjs" },
22
+ "files": ["index.js", "shells.js", "client.js", "cordis.patch.yml", "README.md"]
23
+ }
package/shells.js ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Shell catalogue, executable resolution, and selection planning for the
3
+ * settings-page Shell selector.
4
+ *
5
+ * The feature has exactly ONE source of truth: the shipped PowerShell executor's
6
+ * `pwshPath` field in the `pwsh-sandbox` profile entry. The shipped executor
7
+ * reads its volatile `pwshPath` reference before each new command, so writing
8
+ * this one field changes the next spawned shell without touching the sandbox,
9
+ * approval, jobs, timeouts, or other shipped rows.
10
+ *
11
+ * Nothing here guesses the live value: `probeShells` reports
12
+ * `ctx.shell.pwshPath`, the executor's own resolved executable. The candidate
13
+ * lists below only describe what each option WOULD resolve to.
14
+ *
15
+ * Windows-only by construction; paths are built with `win32` so the split and
16
+ * join rules match the platform being targeted rather than the host running the
17
+ * tests.
18
+ *
19
+ * @module dsh-plugin-shell-selector/shells
20
+ */
21
+
22
+ import { lstatSync } from 'node:fs'
23
+ import { win32 } from 'node:path'
24
+
25
+ /** The PowerShell sandbox profile entry whose Config owns `pwshPath`. */
26
+ export const SHELL_NAMESPACE = process.platform === 'win32' ? 'pwsh-sandbox' : 'bash-sandbox'
27
+
28
+ /** The one field this plugin writes. */
29
+ export const SHELL_PATH_FIELD = 'pwshPath'
30
+
31
+ /** The four selectable shells, in page order. */
32
+ export const SHELL_KINDS = Object.freeze(['auto', 'pwsh7', 'pwsh5', 'gitbash'])
33
+
34
+ /** Locale dictionary keys used as the stable option labels. */
35
+ export const SHELL_LABEL_KEYS = Object.freeze({
36
+ auto: 'option.auto',
37
+ pwsh7: 'option.pwsh7',
38
+ pwsh5: 'option.pwsh5',
39
+ gitbash: 'option.gitbash',
40
+ })
41
+
42
+ /**
43
+ * A selection failure that the Remote half maps onto a `RemoteError`. The `code`
44
+ * is a stable identifier; `message` is already user-facing.
45
+ */
46
+ export class ShellSelectorError extends Error {
47
+ /**
48
+ * @param code - stable failure identifier, e.g. `shell-selector/readonly`.
49
+ * @param message - user-facing explanation.
50
+ */
51
+ constructor(code, message) {
52
+ super(message)
53
+ this.name = 'ShellSelectorError'
54
+ this.code = code
55
+ }
56
+ }
57
+
58
+ /** Whether a path names the same executable, ignoring case and separators. */
59
+ function samePath(left, right) {
60
+ if (typeof left !== 'string' || typeof right !== 'string') return false
61
+ const normalize = value => win32.normalize(value).replace(/[\\/]+$/, '').toLowerCase()
62
+ return normalize(left) === normalize(right)
63
+ }
64
+
65
+ /** Split a Windows `PATH` value into usable directory entries. */
66
+ function pathEntries(env) {
67
+ const entries = []
68
+ for (const entry of String(env.PATH ?? '').split(';')) {
69
+ const trimmed = entry.trim().replace(/^"|"$/g, '')
70
+ if (trimmed.length > 0) entries.push(trimmed)
71
+ }
72
+ return entries
73
+ }
74
+
75
+ /**
76
+ * The PowerShell 7 locations the shipped executor probes, in its order: the
77
+ * machine-wide PowerShell 7 install, then every `PATH` entry.
78
+ *
79
+ * Mirrors `candidatePwshPaths` in `@deepseek-ai/dsh-pwsh-local` so the
80
+ * "automatic" option previews the same executable the executor would pick.
81
+ *
82
+ * @param env - environment to probe; defaults to the process environment.
83
+ * @returns candidate `pwsh.exe` paths in resolution order.
84
+ */
85
+ export function pwsh7Locations(env = process.env) {
86
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
87
+ const candidates = [win32.join(programFiles, 'PowerShell', '7', 'pwsh.exe')]
88
+ for (const entry of pathEntries(env)) candidates.push(win32.join(entry, 'pwsh.exe'))
89
+ return candidates
90
+ }
91
+
92
+ /**
93
+ * An additional PowerShell 7 location: the per-user Microsoft Store execution
94
+ * alias. The shipped probe reaches it only when its directory is on `PATH`,
95
+ * which is not guaranteed; an EXPLICIT "PowerShell 7" choice should still find a
96
+ * Store install, so this candidate is appended for that option alone.
97
+ *
98
+ * @param env - environment to probe.
99
+ * @returns the Store alias path, or `undefined` without a `LOCALAPPDATA`.
100
+ */
101
+ export function pwsh7StoreAlias(env = process.env) {
102
+ const localAppData = env.LOCALAPPDATA
103
+ if (typeof localAppData !== 'string' || localAppData.length === 0) return undefined
104
+ return win32.join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe')
105
+ }
106
+
107
+ /**
108
+ * The canned Windows PowerShell 5.1 executable shipped inside Windows.
109
+ *
110
+ * @param env - environment to probe.
111
+ * @returns the absolute `powershell.exe` path.
112
+ */
113
+ export function pwsh5Path(env = process.env) {
114
+ const systemRoot = env.SystemRoot ?? 'C:\\Windows'
115
+ return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
116
+ }
117
+
118
+ /**
119
+ * Git for Windows install locations, most specific first.
120
+ *
121
+ * `PATH` is deliberately NOT consulted. On a machine with WSL enabled, the
122
+ * `bash` on `PATH` is `%SystemRoot%\System32\bash.exe`, the WSL launcher, which
123
+ * executes inside a separate Linux filesystem namespace. Using it would silently
124
+ * replace the harness file sandbox with the WSL VM boundary, so only explicit
125
+ * Git for Windows install roots are considered.
126
+ *
127
+ * @param env - environment to probe.
128
+ * @returns candidate Git Bash paths in resolution order.
129
+ */
130
+ export function gitBashLocations(env = process.env) {
131
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
132
+ const candidates = [win32.join(programFiles, 'Git', 'bin', 'bash.exe')]
133
+ const programFilesX86 = env['ProgramFiles(x86)']
134
+ if (typeof programFilesX86 === 'string' && programFilesX86.length > 0) {
135
+ candidates.push(win32.join(programFilesX86, 'Git', 'bin', 'bash.exe'))
136
+ }
137
+ const localAppData = env.LOCALAPPDATA
138
+ if (typeof localAppData === 'string' && localAppData.length > 0) {
139
+ candidates.push(win32.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'))
140
+ }
141
+ return candidates
142
+ }
143
+
144
+ /**
145
+ * Whether a path is the WSL launcher rather than a Git for Windows executable.
146
+ *
147
+ * @param path - path to classify.
148
+ * @param env - environment supplying `SystemRoot`.
149
+ * @returns whether `path` is `%SystemRoot%\System32\bash.exe`.
150
+ */
151
+ export function isWslLauncher(path, env = process.env) {
152
+ return samePath(path, win32.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'bash.exe'))
153
+ }
154
+
155
+ /**
156
+ * Whether a path is Windows PowerShell 5.1 rather than PowerShell 7.
157
+ *
158
+ * @param path - an executor executable path.
159
+ * @param env - environment supplying `SystemRoot`.
160
+ * @returns whether the path names the in-box 5.1 executable.
161
+ */
162
+ export function isWindowsPowerShell51(path, env = process.env) {
163
+ if (typeof path !== 'string' || path.length === 0) return false
164
+ const lowered = win32.normalize(path).toLowerCase()
165
+ return lowered.endsWith(win32.join('windowspowershell', 'v1.0', 'powershell.exe').toLowerCase())
166
+ || samePath(path, pwsh5Path(env))
167
+ }
168
+
169
+ /**
170
+ * Classify one candidate path the way the shipped executor does.
171
+ *
172
+ * `lstat` deliberately does not follow reparse points, so a Microsoft Store
173
+ * execution alias is seen as the link it is instead of failing on the target's
174
+ * ACL — the same reason `dsh-pwsh-local` uses `lstatSync`.
175
+ *
176
+ * @param path - candidate executable path.
177
+ * @param lstat - stat function; injectable for tests.
178
+ * @returns `executable`, `directory`, `missing`, or `other`.
179
+ */
180
+ export function executableState(path, lstat = lstatSync) {
181
+ try {
182
+ const info = lstat(path)
183
+ if (info.isDirectory()) return 'directory'
184
+ return info.isFile() || info.isSymbolicLink() ? 'executable' : 'other'
185
+ } catch {
186
+ return 'missing'
187
+ }
188
+ }
189
+
190
+ /** First candidate that exists as a spawnable file, or `undefined`. */
191
+ function firstExecutable(candidates, lstat) {
192
+ for (const candidate of candidates) if (executableState(candidate, lstat) === 'executable') return candidate
193
+ return undefined
194
+ }
195
+
196
+ /**
197
+ * Resolve one option into the executable it would use and whether it is usable.
198
+ *
199
+ * @param kind - one of {@link SHELL_KINDS}.
200
+ * @param env - environment to probe.
201
+ * @param lstat - stat function; injectable for tests.
202
+ * @param platform - target platform; defaults to the process platform.
203
+ * @returns the option's preview: kind, availability, resolved path, reasons.
204
+ */
205
+ export function describeOption(kind, env = process.env, lstat = lstatSync, platform = process.platform) {
206
+ const labelKey = SHELL_LABEL_KEYS[kind]
207
+ if (platform !== 'win32') {
208
+ return { kind, labelKey, available: false, path: null, detailKey: null, reasonKey: 'reason.windowsOnly', reason: 'Shell 选择器仅适用于 Windows 部署。' }
209
+ }
210
+ if (kind === 'auto') {
211
+ const candidates = [...pwsh7Locations(env), pwsh5Path(env)]
212
+ const found = firstExecutable(candidates, lstat)
213
+ if (found === undefined) {
214
+ // Matches the shipped fallback: no known location, so `pwsh` resolves via PATH.
215
+ return { kind, labelKey, available: true, path: 'pwsh', detailKey: 'reason.autoPathLookup', reasonKey: null, reason: null }
216
+ }
217
+ const landedOn51 = isWindowsPowerShell51(found, env)
218
+ return {
219
+ kind,
220
+ labelKey,
221
+ available: true,
222
+ path: found,
223
+ detailKey: landedOn51 ? 'reason.autoLanded51' : 'reason.autoLanded7',
224
+ reasonKey: null,
225
+ reason: null,
226
+ }
227
+ }
228
+ if (kind === 'pwsh7') {
229
+ const alias = pwsh7StoreAlias(env)
230
+ const candidates = alias === undefined ? pwsh7Locations(env) : [...pwsh7Locations(env), alias]
231
+ const found = firstExecutable(candidates, lstat)
232
+ if (found === undefined) {
233
+ // An explicit choice must never silently become a different shell.
234
+ return { kind, labelKey, available: false, path: null, detailKey: null, reasonKey: 'reason.noPwsh7', reason: '未检测到 PowerShell 7(pwsh.exe)。' }
235
+ }
236
+ return { kind, labelKey, available: true, path: found, detailKey: null, reasonKey: null, reason: null }
237
+ }
238
+ if (kind === 'pwsh5') {
239
+ const found = pwsh5Path(env)
240
+ if (executableState(found, lstat) !== 'executable') {
241
+ return { kind, labelKey, available: false, path: null, detailKey: null, reasonKey: 'reason.noPwsh5', reason: '未检测到 Windows PowerShell 5.1。' }
242
+ }
243
+ return { kind, labelKey, available: true, path: found, detailKey: 'reason.pwsh5Dialect', reasonKey: null, reason: null }
244
+ }
245
+ // Git Bash. Installed is not the same as usable: the Windows sandbox runs the
246
+ // command under a WRITE_RESTRICTED token whose write capabilities cover only
247
+ // the workspace and a private temp directory, and named pipes are unavailable
248
+ // in both confined modes while MSYS2's fork emulation depends on them. Until
249
+ // the confinement probe has actually been executed and reviewed, this option
250
+ // stays unavailable rather than being offered as a working choice.
251
+ const found = firstExecutable(gitBashLocations(env), lstat)
252
+ if (found === undefined) {
253
+ return { kind, labelKey, available: false, path: null, detailKey: null, reasonKey: 'reason.noGitBash', reason: '未检测到 Git for Windows(Program Files\\Git\\bin\\bash.exe)。' }
254
+ }
255
+ if (isWslLauncher(found, env)) {
256
+ // Unreachable through the explicit candidate list; kept as a hard guard so a
257
+ // future change to that list can never hand the AI a WSL shell.
258
+ return { kind, labelKey, available: false, path: null, detailKey: null, reasonKey: 'reason.wslBash', reason: '解析结果是 WSL 启动器而非 Git Bash,已拒绝。' }
259
+ }
260
+ return {
261
+ kind,
262
+ labelKey,
263
+ available: false,
264
+ path: found,
265
+ detailKey: 'reason.gitBashUnverified',
266
+ reasonKey: 'reason.gitBashUnverified',
267
+ reason: '已安装,但尚未验证它能在 Windows 文件沙箱下受限运行,因此暂不可选。',
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Build the full option list.
273
+ *
274
+ * @param env - environment to probe.
275
+ * @param lstat - stat function; injectable for tests.
276
+ * @param platform - target platform.
277
+ * @returns one entry per {@link SHELL_KINDS} member, in page order.
278
+ */
279
+ export function buildCatalog(env = process.env, lstat = lstatSync, platform = process.platform) {
280
+ return SHELL_KINDS.map(kind => describeOption(kind, env, lstat, platform))
281
+ }
282
+
283
+ /** Find this plugin's PowerShell sandbox profile section among the described entries. */
284
+ function findShellSection(settings) {
285
+ if (settings === undefined || settings === null || typeof settings.describe !== 'function') return undefined
286
+ const described = settings.describe({ redactSecrets: true })
287
+ if (!Array.isArray(described)) return undefined
288
+ return described.find(entry => entry !== null && typeof entry === 'object' && entry.ns === SHELL_NAMESPACE)
289
+ }
290
+
291
+ /**
292
+ * Derive which option the current stored user layer represents.
293
+ *
294
+ * "Automatic" is the absence of a user-layer `pwshPath`, not a value that
295
+ * happens to equal what automatic would resolve to, so the page can keep
296
+ * showing the shipped default after an unrelated value changes.
297
+ *
298
+ * @param section - the described `shell` namespace, or `undefined`.
299
+ * @param env - environment to probe.
300
+ * @param lstat - stat function.
301
+ * @returns `{ kind, source, path? }`.
302
+ */
303
+ export function currentSelection(section, env = process.env, lstat = lstatSync) {
304
+ const user = section?.user
305
+ const hasUser = user !== null && typeof user === 'object' && Object.hasOwn(user, SHELL_PATH_FIELD)
306
+ if (!hasUser) return { kind: 'auto', source: 'default' }
307
+ const declared = user[SHELL_PATH_FIELD]
308
+ if (typeof declared !== 'string') return { kind: 'custom', source: 'user' }
309
+ if (samePath(declared, pwsh5Path(env))) return { kind: 'pwsh5', source: 'user' }
310
+ const alias = pwsh7StoreAlias(env)
311
+ const candidates = alias === undefined ? pwsh7Locations(env) : [...pwsh7Locations(env), alias]
312
+ const ps7 = firstExecutable(candidates, lstat)
313
+ if (ps7 !== undefined && samePath(declared, ps7)) return { kind: 'pwsh7', source: 'user' }
314
+ return { kind: 'custom', source: 'user', path: declared }
315
+ }
316
+
317
+ /**
318
+ * Read the live executor facts and the selectable options.
319
+ *
320
+ * `executable` is authoritative: it is the path the executor will spawn on the
321
+ * next command, read from `ctx.shell.pwshPath` rather than re-derived here.
322
+ *
323
+ * @param input - settings service, optional shell service, environment, platform.
324
+ * @returns the page payload.
325
+ */
326
+ export function probeShells({ settings, shell, env = process.env, platform = process.platform, lstat = lstatSync }) {
327
+ const section = findShellSection(settings)
328
+ return {
329
+ platform,
330
+ supported: platform === 'win32',
331
+ writable: settings?.writable === true,
332
+ namespaceRegistered: section !== undefined,
333
+ executableBound: shell !== undefined && shell !== null,
334
+ executable: shell?.pwshPath ?? null,
335
+ sandboxMode: shell?.sandboxMode ?? null,
336
+ revision: section?.revision ?? null,
337
+ current: currentSelection(section, env, lstat),
338
+ options: buildCatalog(env, lstat, platform),
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Validate one requested option and produce the settings edit it requires.
344
+ *
345
+ * Validation happens here, before any write, because the executor trusts a
346
+ * configured `pwshPath` as-is and never checks it itself.
347
+ *
348
+ * @param input - the requested kind plus settings service, environment, platform.
349
+ * @returns the `mutate` operations and the validated option.
350
+ * @throws {ShellSelectorError} when the request cannot be honoured.
351
+ */
352
+ export function planSelection({ kind, settings, env = process.env, platform = process.platform, lstat = lstatSync }) {
353
+ if (!SHELL_KINDS.includes(kind)) {
354
+ throw new ShellSelectorError('shell-selector/invalid', `未知的 Shell 选项:${String(kind)}`)
355
+ }
356
+ if (settings === undefined || settings === null) {
357
+ throw new ShellSelectorError('shell-selector/unavailable', '设置服务不可用。')
358
+ }
359
+ if (findShellSection(settings) === undefined) {
360
+ throw new ShellSelectorError('shell-selector/absent', '未注册 shell 设置命名空间,请确认 shell 执行器已加载。')
361
+ }
362
+ if (settings.writable !== true) {
363
+ throw new ShellSelectorError('shell-selector/readonly', '当前设置存储不可写。')
364
+ }
365
+ const option = describeOption(kind, env, lstat, platform)
366
+ if (!option.available) {
367
+ throw new ShellSelectorError('shell-selector/unavailable', option.reason ?? '该选项当前不可用。')
368
+ }
369
+ const ops = kind === 'auto'
370
+ // Unset, never `set(undefined)`: removing the user-layer field re-inherits the
371
+ // composition base, which is exactly "keep the current default".
372
+ ? [{ op: 'unset', path: [SHELL_PATH_FIELD] }]
373
+ : [{ op: 'set', path: [SHELL_PATH_FIELD], value: option.path }]
374
+ return { ops, option }
375
+ }
376
+
377
+ /**
378
+ * Apply one selection through path-addressed settings edits, so fields this
379
+ * plugin does not own (timeouts, output caps, `cwd`) are never restated.
380
+ *
381
+ * @param input - the requested kind, the revision the caller read, and services.
382
+ * @returns the validated option.
383
+ * @throws {ShellSelectorError} when the request cannot be honoured.
384
+ */
385
+ export async function executeSelection(input) {
386
+ const { ops, option } = planSelection(input)
387
+ await input.settings.mutate(SHELL_NAMESPACE, ops, input.revision ?? undefined)
388
+ return option
389
+ }
390
+
391
+ /**
392
+ * The dialect note the model needs when the effective shell is not the one the
393
+ * `pwsh` tool's schema describes. Empty for PowerShell 7 and for every
394
+ * non-PowerShell path, so nothing is added unless it is true.
395
+ *
396
+ * @param executable - the executor's live executable path.
397
+ * @param env - environment supplying `SystemRoot`.
398
+ * @returns a model-facing sentence, or an empty string.
399
+ */
400
+ export function dialectHint(executable, env = process.env) {
401
+ if (!isWindowsPowerShell51(executable, env)) return ''
402
+ return 'The command shell is Windows PowerShell 5.1, not PowerShell 7. It has no `&&`, `||`, ternary `? :`, '
403
+ + 'or `??`. Chain commands with `;` and branch with `if (...) { } else { }`.'
404
+ }