@mzzsfy/dsh-shell-select 0.4.3 → 0.4.5

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/README.md CHANGED
@@ -7,7 +7,7 @@ Windows 宿主 shell 链接管:接管根组合中的官方 `pwsh` 工具与执
7
7
  ## 工作方式
8
8
 
9
9
  - **运行时接管**:bundle 补丁不静态禁用官方 `tool-pwsh`、`pwsh-sandbox` 两行——boot 期官方 pwsh 链先服务,主行(`ctx.shell` 执行器 + `shell` 工具 + systemPrompt 段)apply 落定后经 `entry.update` 运行时禁用官方两行(dsh-market 同款,内存态不持久),再挂载执行器接管。本包任意死法(import 崩/apply 崩/被禁/卸载)下官方行保持启用,官方 pwsh 链自服务,系统不出现 shell 全灭窗口;重启后由行树声明态收敛。web 行(`/api/shell-select/*` 数据通道,`inject: ['webServer', 'settings']` 声明式门控——服务就绪才激活)与 guard 哨兵行同 patch 插入。
10
- - 配置事实源 = 主行 Config(dsh 0.1.7 settings 面以 profile 条目为存储,ns `shell-select`);配置仅经宿主 settings JSON(settings.yaml / settings 面)读写,浏览器侧不注入宿主设置页 UI,写路径 `settings.replace`,配置变更由 cordis 行重载活生效(工具描述随行重建即时更新)。
10
+ - 配置事实源 = 主行 Config(dsh 0.1.7 settings 面以 profile 条目为存储,ns `shell-select`);设置中的 **Shell 管理** 页面经现有 API 读写宿主 settings,写路径 `settings.replace` 后刷新执行面(工具描述与默认客户端即时更新),也可通过宿主配置编辑器维护。
11
11
  - client 半区 `dsh.client.inject` 声明(官方机制,dsh-client-file-upload 等官方包同款):保证 client 运行时模块表中 `primitives`/`slots` 面在场;primitives 另有 try/catch 降级自绘兜底。
12
12
  - 本包执行器完整实现官方 `ctx.shell` seam(`resolve`/`run`/`start`/`runFor`/`startFor`/`entryFor`/`sandboxMode`),结构官方 PwshLocalExecutor 同构(实例状态/方法全公有——cordis 服务代理会把经 `ctx.shell` 调用的方法 `this` 重定向到阴影对象,`#` 私有触发品牌检查错误)。
13
13
  - **不管理会话预设**:bundle 补丁仅插入主行、guard、web,不声明 `shell-select` 预设,不覆写 `agent-preset-registry` 默认值。宿主及用户选择的预设保持原样,已存活代理及其子代理可继续持有旧预设修订;部署若显式指定已删除的 `shell-select` 预设,应通过宿主预设选择入口改选现存预设,本包不保留别名或迁移路径。
@@ -22,6 +22,7 @@ Windows 宿主 shell 链接管:接管根组合中的官方 `pwsh` 工具与执
22
22
  `shell` 工具(官方 `pwsh` 工具逐调用镜像):
23
23
 
24
24
  - 参数:`command`、`description`、`shell`(客户端 id,缺省用默认)、`timeoutMs`、`workdir`、`run_in_background`,沙箱升权面与官方一致。
25
+ - 模型主描述先说明当前默认 `shell`,再按配置逐项列出客户端 id 与工作环境(WSL 附已配置发行版,不可用项标记 `unavailable`);存在可用 Bash 时统一追加 Windows 路径提示。主描述只讲常规调用,不包含拒绝重试流程;系统提示保留退出失败与 Windows 中断处理纪律,不重复客户端选择和独立进程说明。拒绝返回及权限执行契约不变。
25
26
  - 输出标记与官方逐字同构:`[exit code: N]`、`[killed by signal: X]`、`[timed out after …]`、`[sandbox: …]`、截断 spill 提示;terminal 卡退出 pill 照常复原。
26
27
 
27
28
  ## 会话卡(tool.call.toolview)
@@ -58,7 +59,7 @@ shell-select:
58
59
  default: pwsh
59
60
  ```
60
61
 
61
- 出厂默认即上述三客户端。配置仅经 settings JSON 编辑(本包不向宿主设置页注入任何 UI);浏览器侧仅保留 `GET /api/shell-select/config` 名册读取,供会话卡徽章解析客户端命名。第三方实现:任意可执行文件 + 自定义 args 模板。
62
+ 出厂默认即上述三客户端。设置中的 **Shell 管理** 沿用客户端卡片:在卡片内单选默认客户端,可添加/移除条目、探测路径及扫描本机、配置登录壳与 WSL 发行版。参数为空格分隔的文本,含空格的单项用引号包裹,例如 `-c "echo hello" {command}`,普通引号中的 Windows 反斜线不解码为控制字符,控制字符参数使用显式 `$'…'` 转义。环境按每行 `K=V` 编辑,普通值可含空格、等号和反斜线;含换行或以双引号开头的值显示为双引号字符串,仅此形式解析 `\n` 等转义。命令黑名单逐条添加/删除并显示行级正则错误,非法正则阻止保存;保留模式原有空白与换行,空字符串规则不参与拦截。没有编辑时保存按钮禁用;保存不会拆分未编辑的参数、改写正则或把自动探测路径写回声明值。保存后回读完整配置,执行预算保持不变,回读失败明确提示已保存但刷新失败。第三方实现:任意可执行文件 + 自定义 args 模板。
62
63
 
63
64
  env 优先级(同键高右):内置覆盖集(NO_COLOR/PAGER/GIT_PAGER)< 条目 `env` < 调用方环境(spec.env + 会话 `DSH_*` 事实)。wsl 形把条目 env 与调用方环境全部键(WSLENV 本身除外)追加进 WSLENV 白名单,过 WSL 边界不静默失效。
64
65
 
@@ -97,14 +98,4 @@ env 优先级(同键高右):内置覆盖集(NO_COLOR/PAGER/GIT_PAGER)< 条目 `e
97
98
 
98
99
  ## dsh 版本兼容
99
100
 
100
- 宿主要求仍为 ≥0.1.7-rc.1;当前补丁不再引用预设声明模块。以下为历史兼容验收记录,删除预设后的行为以当前「工作方式」为准。
101
-
102
- - 0.2.0-rc.2:完全适配(设置面通过;LLM 工具调用格 2026-10-08 经 echo-upstream -tool 场景真机解锁:放行路径 [shell: <id>] 尾标 ✓,deny 拦截 spawn 未发生 ✓——拦截文案被宿主工具输出校验吞为 INVALID_TOOL_OUTPUT,登记 FIND-020-3 P3 不修)
103
- - 0.1.7-rc.2:不崩溃底线通过(全家桶 crash-only)
104
- - 0.1.5-rc.3:未承诺(本包宿主要求 ≥0.1.7-rc.1,该版本不安装;全家桶在该版本不启动,详见仓库 docs/兼容性测试/逐包测试进度.md)
105
-
106
- 历史验收(Round 30,2026-09-27):0.1.7-rc.2 设置面 7 格过(自动探测/自定义路径/坏路径徽标/黑名单往返含行号拦截/默认热更落盘/preset 接管入册);运行时接管(Round 29)9291 keep 宿主实证 runtime-disable→fiber 激活+INV-6 残影稳定。
107
-
108
- 更细的逐包结论见仓库 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
109
-
110
- 测试:`node --test "test/*.test.mjs"`(包目录内)。
101
+ 按仓库动态窗口验证(scripts/compat/window.mjs),不预置任何固定版本槽:主测 = 渠道 dist-tag 最新 rc,完全适配口径;基线 = npm latest,不崩溃底线口径;主测与基线同版时收敛单槽。本包声明 `dsh.hostMin = "0.1.7-rc.1"`:低于该代际的宿主 API 面未验证,compat 装载层自动排除。逐轮实测结论见 `docs/兼容性测试/逐包测试进度.md` 与本包 `兼容性测试.md`。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-shell-select",
3
3
  "description": "Windows shell 接管:替换默认 pwsh 工具为可配置多客户端的 shell 工具(git bash/WSL/cmd/pwsh,自定义路径,默认客户端,模型按名选择);支持命令 deny 黑名单、会话终端卡增强和直接 pwsh 接管徽章,接管失败自动回退官方 pwsh 链",
4
- "version": "0.4.3",
4
+ "version": "0.4.5",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1,8 +1,7 @@
1
1
  // dsh-shell-select Client 半区:tool.call.toolview shell 族卡片(官方 BashRow/
2
2
  // terminalCardModel 同构 + 复制/折行/客户端徽章增强)。
3
3
  // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory});
4
- // 客户端名册经 webServer 路由 /api/shell-select/config 读取(配置写入走宿主
5
- // settings JSON,浏览器侧无设置页)。
4
+ // 客户端名册与 Shell 管理设置页经 /api/shell-select/* 读写宿主配置。
6
5
 
7
6
  window.__ModuleLoader__.load({
8
7
  id: '@mzzsfy/dsh-shell-select',
@@ -50,6 +49,8 @@ window.__ModuleLoader__.load({
50
49
 
51
50
  const API = {
52
51
  config: '/api/shell-select/config',
52
+ probe: '/api/shell-select/probe',
53
+ detect: '/api/shell-select/detect',
53
54
  }
54
55
 
55
56
  async function api(path, options) {
@@ -158,7 +159,7 @@ window.__ModuleLoader__.load({
158
159
  })
159
160
  .catch((error) => {
160
161
  pathMapRequested = false
161
- console.warn('shell-select: pathmap fetch failed', error)
162
+ console.warn('shell-select: pathmap fetch failed: ' + (error instanceof Error ? error.message : String(error)))
162
163
  })
163
164
  }
164
165
  // LOGIC-END loadPathMap
@@ -202,6 +203,44 @@ window.__ModuleLoader__.load({
202
203
  }
203
204
 
204
205
  const CSS = [
206
+ '.sls-card { display:flex; flex-direction:column; gap:10px; }',
207
+ '.sls-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
208
+ '.sls-entry { display:flex; flex-direction:column; gap:6px; padding:10px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); border-radius:8px; }',
209
+ '.sls-entry__head { display:flex; align-items:center; gap:8px; }',
210
+ '.sls-grid { display:grid; grid-template-columns: 90px 1fr; gap:4px 8px; align-items:center; }',
211
+ '.sls-grid__label { opacity:.75; font-size:12px; }',
212
+ '.sls-input { padding:4px 8px; border-radius:6px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); background:transparent; color:inherit; min-width:0; }',
213
+ '.sls-input--wide { width:100%; box-sizing:border-box; }',
214
+ '.sls-btn { padding:3px 10px; border-radius:6px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
215
+ '.sls-btn:disabled { opacity:.5; cursor:default; }',
216
+ '.sls-btn--primary { border-color: transparent; background: var(--sls-accent, #4b7bcc); color:#fff; }',
217
+ '.sls-badge { font-size:11px; padding:1px 8px; border-radius:999px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); opacity:.85; }',
218
+ '.sls-badge--ok { color:#2e9e5b; border-color:#2e9e5b; }',
219
+ '.sls-badge--bad { color:#c44; border-color:#c44; }',
220
+ '.sls-badge--default { color:#fff; background:var(--sls-accent, #4b7bcc); border-color:transparent; }',
221
+ '.sls-hint { font-size:12px; opacity:.7; white-space:pre-wrap; overflow-wrap:anywhere; }',
222
+ '.sls-error { font-size:12px; color:#c44; white-space:pre-wrap; }',
223
+ '.sls-args { font-size:12px; font-family:var(--sls-mono, monospace); }',
224
+ '.sls-rules { display:flex; flex-direction:column; gap:6px; padding:10px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); border-radius:8px; }',
225
+ '.sls-rules__head { display:flex; align-items:center; gap:8px; }',
226
+ '.sls-rules__title { font-weight:500; }',
227
+ '.sls-rules__note { font-size:12px; opacity:.7; }',
228
+ '.sls-rule { display:flex; align-items:center; gap:6px; }',
229
+ '.sls-rule__no { flex:none; width:18px; text-align:right; font-size:12px; opacity:.45; user-select:none; }',
230
+ '.sls-rule__input { flex:1; min-width:0; padding:4px 8px; border-radius:6px; border:1px solid var(--sls-border, rgba(128,128,128,.35)); background:transparent; color:inherit; font-size:12px; font-family:var(--sls-mono, monospace); resize:vertical; }',
231
+ '.sls-rule__input:focus-visible { outline:2px solid var(--sls-accent, #4b7bcc); outline-offset:1px; }',
232
+ '.sls-rule--bad .sls-rule__input { border-color:#c44; }',
233
+ '.sls-rule__err { flex:none; max-width:40%; font-size:11px; color:#c44; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
234
+ '.sls-rule__del { flex:none; padding:2px 8px; }',
235
+ '.sls-switch { display:inline-flex; align-items:center; cursor:pointer; }',
236
+ '.sls-switch input[type="checkbox"] { position:absolute; opacity:0; width:1px; height:1px; }',
237
+ '.sls-switch__track { position:relative; width:34px; height:19px; border-radius:999px; box-sizing:border-box; background:var(--sls-track, rgba(128,128,128,.35)); border:1px solid var(--sls-border, rgba(128,128,128,.35)); transition:background .15s, border-color .15s; }',
238
+ '.sls-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%; background:var(--sls-thumb, rgba(128,128,128,.6)); transform:translateY(-50%); transition:left .15s, background .15s; }',
239
+ '.sls-switch:hover .sls-switch__track { border-color:var(--sls-accent, #4b7bcc); }',
240
+ '.sls-switch input[type="checkbox"]:checked + .sls-switch__track { background:var(--sls-accent, #4b7bcc); border-color:var(--sls-accent, #4b7bcc); }',
241
+ '.sls-switch input[type="checkbox"]:checked + .sls-switch__track .sls-switch__thumb { left:17px; background:var(--sls-bg-base, #fff); }',
242
+ '.sls-switch input[type="checkbox"]:focus-visible + .sls-switch__track { outline:2px solid var(--sls-accent, #4b7bcc); outline-offset:1px; }',
243
+ '.sls-switch input[type="checkbox"]:disabled + .sls-switch__track { opacity:.5; }',
205
244
  // tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强。
206
245
  // 色彩对齐官方 ToolRow:标题 label-secondary、icon 盒 label-tertiary(实证自
207
246
  // 官方行 computed style);token 缺失时回退 inherit 不致不可读
@@ -614,9 +653,304 @@ window.__ModuleLoader__.load({
614
653
  )
615
654
  }
616
655
 
656
+ const SHELL_KINDS = { pwsh: 'PowerShell', bash: 'POSIX bash(git bash / msys2 / cygwin)', cmd: 'CMD', wsl: 'WSL bash' }
657
+
658
+ function argsTextOf(args) {
659
+ return args.map((arg) => {
660
+ if (/[\u0000-\u001f\u007f]/.test(arg)) {
661
+ const body = arg.replace(/[\u0000-\u001f\u007f'\\]/g, (char) => {
662
+ if (char === "'" || char === '\\') return '\\' + char
663
+ return '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0')
664
+ })
665
+ return "$'" + body + "'"
666
+ }
667
+ return arg !== '' && !/[\s'"\\]/.test(arg) ? arg : '"' + arg.replace(/["\\]/g, (char) => '\\' + char) + '"'
668
+ }).join(' ')
669
+ }
670
+
671
+ // 普通引号只转义当前引号与反斜线;控制字符仅在显式 ANSI-C 引号内解码,不执行 shell。
672
+ function parseArgsText(text) {
673
+ const args = []
674
+ const controls = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v' }
675
+ let token = '', started = false
676
+ for (let index = 0; index < text.length; index += 1) {
677
+ const char = text[index]
678
+ if (/\s/.test(char)) {
679
+ if (started) args.push(token)
680
+ token = ''
681
+ started = false
682
+ continue
683
+ }
684
+ started = true
685
+ const ansi = char === '$' && text[index + 1] === "'"
686
+ if (!ansi && char !== '"' && char !== "'") {
687
+ token += char
688
+ continue
689
+ }
690
+ if (ansi) index += 1
691
+ const quote = text[index]
692
+ index += 1
693
+ for (; index < text.length && text[index] !== quote; index += 1) {
694
+ const current = text[index]
695
+ const next = text[index + 1]
696
+ if (current === '\\' && (next === quote || next === '\\')) {
697
+ token += next
698
+ index += 1
699
+ } else if (ansi && current === '\\' && Object.hasOwn(controls, next)) {
700
+ token += controls[next]
701
+ index += 1
702
+ } else if (ansi && current === '\\' && next === 'u' && /^[\da-f]{4}$/i.test(text.slice(index + 2, index + 6))) {
703
+ token += String.fromCharCode(parseInt(text.slice(index + 2, index + 6), 16))
704
+ index += 5
705
+ } else {
706
+ token += current
707
+ }
708
+ }
709
+ if (index >= text.length) throw new Error('参数引号未闭合')
710
+ }
711
+ if (started) args.push(token)
712
+ return args
713
+ }
714
+
715
+ function envTextOf(env) {
716
+ return Object.entries(env).map(([key, value]) => `${key}=${/[\r\n]|^"/.test(value) ? JSON.stringify(value) : value}`).join('\n')
717
+ }
718
+
719
+ function parseEnvText(text) {
720
+ const pairs = []
721
+ for (const line of text.split('\n')) {
722
+ if (line.trim() === '') continue
723
+ const separator = line.indexOf('=')
724
+ const key = separator < 0 ? '' : line.slice(0, separator).trim()
725
+ if (key === '') throw new Error('环境变量每行必须为 K=V')
726
+ const value = line.slice(separator + 1)
727
+ const parsed = value.startsWith('"') ? JSON.parse(value) : value
728
+ if (typeof parsed !== 'string') throw new Error('环境变量双引号值必须是字符串')
729
+ pairs.push([key, parsed])
730
+ }
731
+ return Object.fromEntries(pairs)
732
+ }
733
+
734
+ function denyPatternIssue(rule) {
735
+ if (rule === '') return undefined
736
+ try { new RegExp(rule, 'i') } catch (error) { return error.message }
737
+ return undefined
738
+ }
739
+
740
+ function editableSection(section) {
741
+ const { resolved, ...config } = section
742
+ return {
743
+ config,
744
+ entries: config.shells.map((entry, index) => {
745
+ const argsText = argsTextOf(entry.args ?? [])
746
+ const envText = envTextOf(entry.env ?? {})
747
+ const fact = resolved?.shells.find((item) => item.id === entry.id)
748
+ return { ...entry, key: `loaded-${index}`, argsText, originalArgsText: argsText,
749
+ envText, originalEnvText: envText, available: fact?.available, resolved: fact?.path }
750
+ }),
751
+ nextKey: config.shells.length,
752
+ defaultId: config.default,
753
+ rules: (config.deny ?? []).map((value, index) => ({ key: `rule-${index}`, value })),
754
+ nextRuleKey: (config.deny ?? []).length,
755
+ dirty: false,
756
+ }
757
+ }
758
+
759
+ function ShellSelectApp() {
760
+ const [form, setForm] = useState(null)
761
+ const [busy, setBusy] = useState(false)
762
+ const [notice, setNotice] = useState('')
763
+ const [revision, setRevision] = useState(0)
764
+
765
+ useEffect(() => {
766
+ let disposed = false
767
+ setNotice('')
768
+ api(API.config).then((section) => {
769
+ if (!disposed) setForm(editableSection(section))
770
+ }).catch((error) => { if (!disposed) setNotice('加载失败:' + error.message) })
771
+ return () => { disposed = true }
772
+ }, [revision])
773
+
774
+ const editForm = (update) => {
775
+ if (busy) return
776
+ setForm((current) => ({ ...update(current), dirty: true }))
777
+ }
778
+ const patchEntry = (key, changes) => editForm((current) => ({
779
+ ...current,
780
+ defaultId: changes.id !== undefined && current.entries.find((entry) => entry.key === key)?.id === current.defaultId
781
+ ? changes.id : current.defaultId,
782
+ entries: current.entries.map((entry) => entry.key === key ? { ...entry, ...changes, available: undefined, resolved: undefined } : entry),
783
+ }))
784
+ const removeEntry = (key) => editForm((current) => {
785
+ const entries = current.entries.filter((entry) => entry.key !== key)
786
+ return { ...current, entries, defaultId: entries.some((entry) => entry.id === current.defaultId) ? current.defaultId : entries[0]?.id ?? '' }
787
+ })
788
+ const addEntry = () => editForm((current) => ({
789
+ ...current, nextKey: current.nextKey + 1,
790
+ entries: [...current.entries, { key: `added-${current.nextKey}`, id: '', kind: 'bash', path: '', argsText: '', envText: '', login: false, distro: '' }],
791
+ }))
792
+ const patchRule = (key, value) => editForm((current) => ({ ...current,
793
+ rules: current.rules.map((rule) => rule.key === key ? { ...rule, value } : rule),
794
+ }))
795
+ const addRule = () => editForm((current) => ({ ...current, nextRuleKey: current.nextRuleKey + 1,
796
+ rules: [...current.rules, { key: `rule-${current.nextRuleKey}`, value: '' }],
797
+ }))
798
+ const removeRule = (key) => editForm((current) => ({ ...current, rules: current.rules.filter((rule) => rule.key !== key) }))
799
+ const save = async () => {
800
+ if (busy || !form.dirty) return
801
+ setBusy(true)
802
+ setNotice('')
803
+ try {
804
+ const shells = form.entries.map((entry) => {
805
+ // 显示未变时直接复用原值,避免表单控件归一换行等行为改写已有配置。
806
+ const args = entry.argsText === entry.originalArgsText ? entry.args ?? [] : parseArgsText(entry.argsText)
807
+ const env = entry.envText === entry.originalEnvText ? entry.env ?? {} : parseEnvText(entry.envText)
808
+ return { id: entry.id.trim(), kind: entry.kind, path: entry.path.trim(), args, login: entry.login === true, distro: entry.distro ?? '', env }
809
+ })
810
+ const ids = shells.map((entry) => entry.id)
811
+ if (ids.length === 0 || ids.some((id) => id === '')) throw new Error('至少保留一个客户端,id 不能为空')
812
+ if (new Set(ids).size !== ids.length) throw new Error('客户端 id 重复')
813
+ const defaultId = form.defaultId.trim()
814
+ if (!ids.includes(defaultId)) throw new Error('默认客户端不在列表中')
815
+ for (const [index, rule] of form.rules.entries()) {
816
+ const issue = denyPatternIssue(rule.value)
817
+ if (issue !== undefined) throw new Error(`第 ${index + 1} 条正则非法:${issue}`)
818
+ }
819
+ const deny = form.rules.map((rule) => rule.value).filter((rule) => rule !== '')
820
+ const result = await api(API.config, { method: 'POST', body: JSON.stringify({ ...form.config, shells, default: defaultId, deny }) })
821
+ clientCatalog = { default: result.resolved.default, byId: Object.fromEntries(result.resolved.shells.map((entry) => [entry.id, entry.id])) }
822
+ setForm((current) => ({ ...current, dirty: false }))
823
+ setNotice('已保存,默认客户端与工具描述即时生效')
824
+ try {
825
+ const section = await api(API.config)
826
+ setForm(editableSection(section))
827
+ } catch (error) { setNotice('已保存,但刷新失败:' + error.message + ';请重新加载') }
828
+ } catch (error) {
829
+ setNotice('保存失败:' + error.message)
830
+ } finally { setBusy(false) }
831
+ }
832
+ const probe = async (entry) => {
833
+ if (busy) return
834
+ setBusy(true)
835
+ setNotice('')
836
+ try {
837
+ const result = await api(API.probe, { method: 'POST', body: JSON.stringify({ path: entry.path }) })
838
+ setForm((current) => ({ ...current, entries: current.entries.map((item) => item.key === entry.key
839
+ ? { ...item, available: result.exists, resolved: entry.path } : item) }))
840
+ setNotice(result.exists ? '路径存在' : '路径不存在')
841
+ } catch (error) { setNotice('探测失败:' + error.message) }
842
+ finally { setBusy(false) }
843
+ }
844
+ const detect = async (kind) => {
845
+ if (busy) return
846
+ setBusy(true)
847
+ setNotice('')
848
+ try {
849
+ const result = await api(API.detect, { method: 'POST', body: JSON.stringify({ kinds: [kind] }) })
850
+ setNotice(result.found.length > 0 ? kind + ' 候选:' + result.found.map((item) => item.path).join(' ; ') : kind + ' 未发现候选')
851
+ } catch (error) { setNotice('探测失败:' + error.message) }
852
+ finally { setBusy(false) }
853
+ }
854
+ const reload = h('button', { type: 'button', className: 'sls-btn', disabled: busy, onClick: () => {
855
+ if (busy) return
856
+ setForm(null)
857
+ setNotice('')
858
+ setRevision((value) => value + 1)
859
+ } }, '重新加载')
860
+ if (form === null) return h('div', { className: 'sls-card', 'aria-busy': !notice },
861
+ h('div', { role: notice ? 'alert' : 'status', className: 'sls-hint' }, notice || '加载中…'), notice ? reload : null)
862
+
863
+ const activeRules = form.rules.filter((rule) => rule.value !== '').length
864
+ return h('div', { className: 'sls-card', 'aria-busy': busy },
865
+ h('div', { className: 'sls-row' }, h('span', { className: 'sls-hint' },
866
+ '命令行客户端清单。路径留空自动探测;模型可通过工具的 shell 参数选择任一条目,缺省用默认。')),
867
+ form.entries.map((entry, index) => {
868
+ const input = (label, name, placeholder, type = 'input') => h(type, {
869
+ className: 'sls-input sls-input--wide' + (name === 'argsText' || name === 'envText' ? ' sls-args' : ''),
870
+ 'aria-label': `客户端 ${index + 1} ${label}`, value: entry[name] ?? '', disabled: busy,
871
+ rows: type === 'textarea' ? 2 : undefined, placeholder,
872
+ onChange: (event) => patchEntry(entry.key, { [name]: event.target.value }),
873
+ })
874
+ return h('div', { className: 'sls-entry', key: entry.key },
875
+ h('div', { className: 'sls-entry__head' },
876
+ h('label', { className: 'sls-row' },
877
+ h('input', { type: 'radio', name: 'sls-default', 'aria-label': `客户端 ${index + 1} 默认`,
878
+ checked: form.defaultId === entry.id && entry.id.length > 0, disabled: busy || entry.id.trim() === '',
879
+ title: '设为默认客户端(模型缺省 shell 参数时使用)',
880
+ onChange: () => editForm((current) => ({ ...current, defaultId: entry.id })),
881
+ }), h('span', { className: 'sls-hint' }, '默认')),
882
+ h('span', { className: 'sls-badge' + (entry.available === true ? ' sls-badge--ok' : entry.available === false ? ' sls-badge--bad' : ''),
883
+ title: entry.resolved ? '解析路径:' + entry.resolved : '路径尚未解析',
884
+ }, entry.available === true ? '可用' : entry.available === false ? '不可用' : '未知'),
885
+ entry.id === form.defaultId && entry.id !== '' ? h('span', { className: 'sls-badge sls-badge--default' }, '默认') : null,
886
+ h('span', { style: { flex: 1 } }),
887
+ h('button', { type: 'button', className: 'sls-btn', disabled: busy || entry.path.trim() === '', title: '检查该路径是否存在', onClick: () => probe(entry) }, '探测路径'),
888
+ h('button', { type: 'button', className: 'sls-btn', disabled: busy, 'aria-label': `移除客户端 ${index + 1}`, title: '移除该条目', onClick: () => removeEntry(entry.key) }, '移除')),
889
+ h('div', { className: 'sls-grid' },
890
+ h('span', { className: 'sls-grid__label' }, 'id'),
891
+ input('id', 'id', 'pwsh / git-bash / …(模型看到的 shell 参数值,亦作显示名)'),
892
+ h('span', { className: 'sls-grid__label' }, '形态'),
893
+ h('div', { className: 'sls-row' },
894
+ h('select', { className: 'sls-input', 'aria-label': `客户端 ${index + 1} 形态`, value: entry.kind, disabled: busy,
895
+ onChange: (event) => patchEntry(entry.key, { kind: event.target.value }),
896
+ }, Object.entries(SHELL_KINDS).map(([kind, label]) => h('option', { key: kind, value: kind }, label))),
897
+ h('button', { type: 'button', className: 'sls-btn', disabled: busy, title: '扫描本机常见安装位置', onClick: () => detect(entry.kind) }, '扫描本机')),
898
+ h('span', { className: 'sls-grid__label' }, '路径'),
899
+ input('路径', 'path', '留空自动探测,如 C:\\Program Files\\Git\\bin\\bash.exe'),
900
+ h('span', { className: 'sls-grid__label' }, '参数'),
901
+ input('参数', 'argsText', '留空用形态默认;空格分隔,引号包住含空格参数,{command} 为命令占位'),
902
+ h('span', { className: 'sls-hint', style: { gridColumn: '2' } }, "空格参数加引号;控制字符用 $'…' 转义,仅解析不执行。"),
903
+ ...(entry.kind === 'bash' ? [
904
+ h('span', { className: 'sls-grid__label' }, '登录壳'),
905
+ h('label', { className: 'sls-row sls-switch', title: '-lc 登录壳拉起 /etc/profile(只注入 /usr/bin;/mingw64/bin 需在下方的环境里配 MSYSTEM=MINGW64)' },
906
+ h('input', { type: 'checkbox', 'aria-label': `客户端 ${index + 1} 登录壳`, checked: entry.login === true, disabled: busy,
907
+ onChange: (event) => patchEntry(entry.key, { login: event.target.checked }),
908
+ }), h('span', { className: 'sls-switch__track' }, h('span', { className: 'sls-switch__thumb' })),
909
+ h('span', { className: 'sls-hint' }, '-lc 登录壳')),
910
+ ] : []),
911
+ ...(entry.kind === 'wsl' ? [h('span', { className: 'sls-grid__label' }, '发行版'), input('发行版', 'distro', '留空用默认发行版,如 Ubuntu-22.04')] : []),
912
+ h('span', { className: 'sls-grid__label' }, '环境'),
913
+ input('环境', 'envText', '每行 K=V,如 MSYSTEM=MINGW64;wsl 形自动经 WSLENV 透传', 'textarea'),
914
+ h('span', { className: 'sls-hint', style: { gridColumn: '2' } }, '每行 K=V;换行值用双引号及 \\n,双引号内反斜线写 \\\\。')))
915
+ }),
916
+ h('div', { className: 'sls-rules' },
917
+ h('div', { className: 'sls-rules__head' }, h('span', { className: 'sls-rules__title' }, '命令黑名单'),
918
+ h('span', { className: 'sls-badge' + (activeRules > 0 ? ' sls-badge--ok' : ' sls-badge--bad') }, activeRules > 0 ? activeRules + ' 条规则' : '未生效')),
919
+ h('span', { className: 'sls-rules__note' }, '每条规则为一条正则,对整条命令全文匹配(大小写不敏感),命中即拒,无豁免;精细放行写在模式内用前瞻,如 rm -rf\\s+(?!\\S*node_modules)。'),
920
+ form.rules.length === 0 ? h('span', { className: 'sls-rules__note' }, '未配置,命中拦截不生效,模型可执行任意命令。') : form.rules.map((rule, index) => {
921
+ const issue = denyPatternIssue(rule.value)
922
+ const errorId = `sls-${rule.key}-error`
923
+ return h('div', { className: 'sls-rule' + (issue !== undefined ? ' sls-rule--bad' : ''), key: rule.key },
924
+ h('span', { className: 'sls-rule__no' }, index + 1),
925
+ h('textarea', { className: 'sls-rule__input', rows: Math.max(1, rule.value.split('\n').length), 'aria-label': `黑名单第 ${index + 1} 条`,
926
+ 'aria-invalid': issue !== undefined, 'aria-describedby': issue !== undefined ? errorId : undefined,
927
+ value: rule.value, disabled: busy, placeholder: '正则,如 ^format\\s', onChange: (event) => patchRule(rule.key, event.target.value),
928
+ }),
929
+ issue !== undefined ? h('span', { id: errorId, className: 'sls-rule__err', title: issue }, issue) : null,
930
+ h('button', { type: 'button', className: 'sls-btn sls-rule__del', disabled: busy, 'aria-label': `删除规则 ${index + 1}`, title: '删除该条规则', onClick: () => removeRule(rule.key) }, '删除'))
931
+ }),
932
+ h('div', { className: 'sls-row' }, h('button', { type: 'button', className: 'sls-btn', disabled: busy, onClick: addRule }, '添加规则'))),
933
+ h('div', { className: 'sls-row' },
934
+ h('button', { type: 'button', className: 'sls-btn', disabled: busy, onClick: addEntry }, '添加客户端'),
935
+ h('span', { style: { flex: 1 } }),
936
+ form.dirty ? h('span', { className: 'sls-hint' }, '有未保存改动') : null,
937
+ reload,
938
+ h('button', { type: 'button', className: 'sls-btn sls-btn--primary', disabled: busy || !form.dirty, onClick: save }, '保存')),
939
+ notice ? h('div', { role: notice.includes('失败') ? 'alert' : 'status', className: 'sls-hint' }, notice) : null,
940
+ )
941
+ }
942
+
617
943
  return {
618
944
  inject: ['slots'],
619
945
  apply(ctx) {
946
+ const NAV_ICON = { 'Shell 管理': 'terminal' }
947
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
948
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
949
+ else window.__navicIconQueue = [NAV_ICON]
950
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
951
+ { name: 'settings.section', id: 'shell-select', order: 82, label: 'Shell 管理' },
952
+ () => React.createElement(ShellSelectApp),
953
+ ))
620
954
  installPathRescue(ctx)
621
955
  ctx.effect(() => {
622
956
  const style = document.createElement('style')
package/src/executor.mjs CHANGED
@@ -255,7 +255,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
255
255
  this.ctx.systemPrompt.section({
256
256
  name: 'tool:shell',
257
257
  order: this.ctx.systemPrompt.getSectionOrder('TOOL_PWSH'),
258
- text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. Commands run in fresh processes: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Pass the `shell` argument only when the default client cannot run the command; the available clients are listed in the tool description.',
258
+ text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.',
259
259
  })
260
260
  }
261
261
 
package/src/guard.js CHANGED
@@ -208,6 +208,9 @@ export async function installGuard(ctx, {
208
208
  const dead = detectDeadState(ctx.loader, { applyState: shellSelectApplyState })
209
209
  if (dead === true && mounted.size === 0 && mounting === null) await mountOfficial()
210
210
  else if (dead === false && (mounted.size > 0 || mounting !== null)) await unmountOfficial()
211
+ } catch {
212
+ // 停机竞态:fiber 已停而末次 tick 先于 effect 清理触发,loader 访问必抛;
213
+ // rejection 逃逸会被宿主 fail-loud 判 fatal load failure 杀进程,静默放行
211
214
  } finally {
212
215
  sweeping = false
213
216
  }
@@ -246,10 +249,15 @@ export async function installGuard(ctx, {
246
249
  ctx.logger.info?.(`shell-select/guard: 初始判定 dead=${initial}`)
247
250
  if (initial === true) await mountOfficial()
248
251
 
249
- const timer = setInterval(() => {
250
- void sweep()
251
- }, sweepIntervalMs)
252
- timer.unref?.()
252
+ // 定时器随 fiber 清理:裸 setInterval 会在行停用后残留 tick,停机序列中
253
+ // 访问 loader 抛错并经宿主 fail-loud 升级为进程级 fatal
254
+ ctx.effect(() => {
255
+ const timer = setInterval(() => {
256
+ void sweep()
257
+ }, sweepIntervalMs)
258
+ timer.unref?.()
259
+ return () => clearInterval(timer)
260
+ }, 'shell-select/guard sweep timer')
253
261
  return undefined
254
262
  }
255
263
 
package/src/tool.mjs CHANGED
@@ -144,27 +144,34 @@ function foregroundOutputProperties() {
144
144
  }
145
145
  }
146
146
 
147
- /** bash 路径措辞:bash 输出的 MSYS 拼写不得回流进工具参数(宿主文件面只认 Windows 形式)。 */
148
- const BASH_PATH_GUIDANCE = 'Bash clients accept Windows-style paths (C:/...): use that form for `workdir` and file-tool arguments — MSYS spellings like `/c/Users/...` or `/tmp` only work inside the bash command itself, never in tool arguments.'
147
+ /** Bash 命令内路径与宿主工具参数使用不同拼写。 */
148
+ const BASH_PATH_GUIDANCE = 'Use Windows-style paths (C:/...) in workdir and file-tool arguments; MSYS paths (/c/..., /tmp) are valid only inside Bash commands.'
149
+ const SHELL_ENVIRONMENTS = { pwsh: 'PowerShell', bash: 'Bash', cmd: 'Windows cmd.exe', wsl: 'WSL' }
149
150
 
150
- /** 工具描述:客户端清单与方言随配置动态变化。 */
151
- export function shellDescription({ executor, backgroundEnabled, escalationModes }) {
151
+ /** 工具描述只展示当前配置与常规调用方式。 */
152
+ export function shellDescription({ executor, backgroundEnabled }) {
152
153
  const listing = executor.listShells()
153
- const lines = listing.shells
154
- .map((entry) => `${entry.id} (${entry.kind}${entry.available ? '' : ', executable not found'})`)
155
- .join(', ')
156
- const base = 'Execute a command in one of the configured shell clients and return its stdout/stderr. '
157
- + `Available clients: ${lines}. The default client is "${listing.default}"; pass \`shell\` only when this command needs a different client (dialects differ: pwsh = PowerShell, bash/wsl = POSIX, cmd = cmd.exe). `
158
- + 'Each call runs in a fresh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
159
- + 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
160
- + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
161
- + (backgroundEnabled
162
- ? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
163
- : 'Background execution is not available; long-running commands must finish within the timeout.')
164
- const bashPresent = listing.shells.some((candidate) => candidate.kind === 'bash' && candidate.available)
165
- const text = bashPresent ? `${base} ${BASH_PATH_GUIDANCE}` : base
166
- if (escalationModes.length === 0) return text
167
- return text + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
154
+ const clients = listing.shells.map((entry) => {
155
+ const distro = entry.kind === 'wsl' && entry.distro ? ` (${entry.distro})` : ''
156
+ const availability = entry.available ? '' : ' (unavailable)'
157
+ return `- ${entry.id} — ${SHELL_ENVIRONMENTS[entry.kind]}${distro}${availability}`
158
+ }).join('\n')
159
+ const bashPresent = listing.shells.some((entry) => entry.kind === 'bash' && entry.available)
160
+ return [
161
+ `Execute commands using shell=${JSON.stringify(listing.default)} by default and return stdout/stderr.`,
162
+ 'Omit shell to use the default; set it to one of:',
163
+ clients,
164
+ '',
165
+ 'Each call starts a fresh process; cwd, variables, and functions do not persist.',
166
+ 'Use workdir to set the working directory.',
167
+ ...bashPresent ? [BASH_PATH_GUIDANCE] : [],
168
+ '',
169
+ ...backgroundEnabled ? [
170
+ 'For long-running commands, set run_in_background=true.',
171
+ 'Use job_output to read background output and job_kill to stop the job.',
172
+ ] : ['Background execution is not available; long-running commands must finish within the timeout.'],
173
+ 'Long output is truncated; the full-output file path is reported when available.',
174
+ ].join('\n')
168
175
  }
169
176
 
170
177
  /** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
@@ -207,7 +214,7 @@ export function registerShellTool(ctx, { executor }) {
207
214
  const makeDefinition = (toolName, pinned) => (faces) => defineTool({
208
215
  name: toolName,
209
216
  description: pinned === undefined
210
- ? shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes })
217
+ ? shellDescription({ executor: faces.executor, backgroundEnabled })
211
218
  : pwshDescription(),
212
219
  parameters: {
213
220
  command: {
@@ -224,7 +231,7 @@ export function registerShellTool(ctx, { executor }) {
224
231
  },
225
232
  ...pinned === undefined ? { shell: {
226
233
  type: 'string',
227
- description: 'Shell client id from the tool description list (e.g. pwsh, git-bash, cmd). Omit to use the configured default client.',
234
+ description: 'Override the default shell using an id listed above.',
228
235
  } } : {},
229
236
  timeoutMs: {
230
237
  type: 'number',
@@ -242,7 +249,7 @@ export function registerShellTool(ctx, { executor }) {
242
249
  sandbox_permissions: {
243
250
  type: 'string',
244
251
  enum: [...escalationModes],
245
- description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
252
+ description: 'Wider sandbox access mode for this command; requires justification and user approval.',
246
253
  },
247
254
  justification: {
248
255
  type: 'string',
@@ -0,0 +1,251 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+ const section = () => ({
7
+ default: 'git-bash', deny: ['^format\\s', ' foo ', ' ', 'echo\nsecret'], timeoutMs: 120000,
8
+ shells: [
9
+ { id: 'git-bash', kind: 'bash', path: '', args: ['-c', 'echo two words', '{command}', '', 'a"b', "a'b", 'C:\\path', 'line\nbreak', 'tab\tcr\r', "$'literal'", 'C:\\tail\\'], env: { NOTE: 'a=b c', MULTI: 'line1\nOTHER=two', PATH: 'C:\\temp\\file', LITERAL: '\\n', QUOTED: '"literal"', CR: 'first\rsecond', TRAIL: 'C:\\temp\\' }, login: true, distro: '' },
10
+ { id: 'pwsh', kind: 'pwsh', path: '', args: [], env: {}, login: false, distro: '' },
11
+ ],
12
+ resolved: { default: 'git-bash', shells: [{ id: 'git-bash', available: true, path: 'C:/Git/bash.exe' }, { id: 'pwsh', available: true }] },
13
+ })
14
+
15
+ async function page({ failLoad = false, failSave = false, failReadBack = false } = {}) {
16
+ const state = [], effects = [], registrations = [], requests = []
17
+ let cursor = 0, effectCursor = 0, loaded
18
+ const React = {
19
+ createElement: (type, props, ...children) => typeof type === 'function' ? type(props ?? {}) : { type, props: props ?? {}, children },
20
+ useState(initial) {
21
+ const at = cursor++
22
+ if (!(at in state)) state[at] = typeof initial === 'function' ? initial() : initial
23
+ return [state[at], (next) => { state[at] = typeof next === 'function' ? next(state[at]) : next }]
24
+ },
25
+ useEffect(fn, deps) {
26
+ const at = effectCursor++
27
+ const prior = effects[at]
28
+ if (!prior || deps.some((value, index) => value !== prior.deps[index])) {
29
+ prior?.cleanup?.()
30
+ effects[at] = { fn, deps, pending: true }
31
+ }
32
+ },
33
+ }
34
+ const window = { __ModuleLoader__: { load: (value) => { loaded = value } } }
35
+ const document = { documentElement: { lang: 'zh' }, createElement: () => ({ setAttribute() {}, remove() {} }), head: { appendChild() {} } }
36
+ let current = section()
37
+ const fetch = async (path, options = {}) => {
38
+ requests.push({ path, ...options })
39
+ if (path.endsWith('/probe')) return { ok: true, json: async () => ({ exists: true }) }
40
+ if (path.endsWith('/detect')) return { ok: true, json: async () => ({ found: [{ path: 'C:/Git/bash.exe' }] }) }
41
+ if (failReadBack && options.method !== 'POST' && requests.some((row) => row.method === 'POST')) return { ok: false, status: 503, json: async () => ({ error: '回读故障' }) }
42
+ if (failLoad && options.method !== 'POST') return { ok: false, status: 503, json: async () => ({ error: '加载故障' }) }
43
+ if (options.method === 'POST') {
44
+ if (failSave) return { ok: false, status: 400, json: async () => ({ error: '写入故障' }) }
45
+ current = { ...JSON.parse(options.body), resolved: current.resolved }
46
+ return { ok: true, json: async () => ({ ok: true, resolved: current.resolved }) }
47
+ }
48
+ return { ok: true, json: async () => structuredClone(current) }
49
+ }
50
+ new Function('window', 'document', 'navigator', 'fetch', source)(window, document, {}, fetch)
51
+ loaded.factory((name) => { if (name === 'react') return React; throw new Error(name) }).apply({
52
+ effect: (fn) => fn(), slots: { inject: (_name, fn) => fn(), register: (meta, component) => { const entry = { meta, component }; registrations.push(entry); return () => registrations.splice(registrations.indexOf(entry), 1) } },
53
+ })
54
+ const view = registrations.find(({ meta }) => meta.name === 'settings.section' && meta.id === 'shell-select')
55
+ assert.ok(view, '设置导航缺少 shell-select 页面')
56
+ assert.equal(view.meta.label, 'Shell 管理')
57
+ const render = () => { cursor = 0; effectCursor = 0; return view.component() }
58
+ const flush = async () => {
59
+ for (const effect of effects) if (effect.pending) { effect.pending = false; effect.cleanup = effect.fn() }
60
+ await new Promise(setImmediate)
61
+ }
62
+ const initial = render()
63
+ await flush()
64
+ const nodes = (tree) => {
65
+ const found = []
66
+ const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (!node || typeof node !== 'object') return; found.push(node); node.children?.forEach(walk) }
67
+ walk(tree)
68
+ return found
69
+ }
70
+ const button = (tree, label) => nodes(tree).find((node) => node.type === 'button' && node.children.includes(label))
71
+ const field = (tree, label) => nodes(tree).find((node) => node.props['aria-label'] === label)
72
+ const dirty = () => {
73
+ const input = field(render(), '客户端 1 id')
74
+ input.props.onChange({ target: { value: input.props.value } })
75
+ }
76
+ return { initial, render, flush, nodes, button, field, dirty, requests, restoreLoad: () => { failLoad = false } }
77
+ }
78
+
79
+ // Given 插件启用;When 打开设置;Then 页面注册并加载当前配置而非出厂值。
80
+ test('设置入口:注册 Shell 管理并展示当前客户端、默认值与加载状态', async () => {
81
+ const p = await page()
82
+ assert.match(JSON.stringify(p.initial), /加载中/)
83
+ const tree = p.render()
84
+ assert.equal(p.field(tree, '客户端 1 默认').props.checked, true)
85
+ assert.equal(p.field(tree, '客户端 1 默认').props.type, 'radio')
86
+ assert.equal(p.field(tree, '客户端 1 id').props.value, 'git-bash')
87
+ assert.equal(p.button(tree, '保存').props.disabled, true)
88
+ assert.equal(p.field(tree, '客户端 1 参数').type, 'input')
89
+ assert.match(p.field(tree, '客户端 1 环境').props.value, /^NOTE=a=b c\nMULTI=/)
90
+ assert.equal(p.field(tree, '黑名单第 2 条').props.value, ' foo ')
91
+ assert.equal(p.field(tree, '默认客户端'), undefined)
92
+ p.field(tree, '客户端 2 默认').props.onChange()
93
+ assert.equal(p.field(p.render(), '客户端 2 默认').props.checked, true)
94
+ assert.equal(p.button(p.render(), '保存').props.disabled, false)
95
+ })
96
+
97
+ // Given 已有含空格参数和环境值;When 保存;Then 完整保留客户端字段、deny 与顶层配置。
98
+ test('保存:参数、环境、黑名单及预算字段无损往返', async () => {
99
+ const p = await page()
100
+ p.dirty()
101
+ await p.button(p.render(), '保存').props.onClick()
102
+ assert.equal(p.button(p.render(), '保存').props.disabled, true)
103
+ const written = JSON.parse(p.requests.find((row) => row.method === 'POST').body)
104
+ assert.deepEqual(written.shells, section().shells)
105
+ assert.deepEqual(written.deny, section().deny)
106
+ assert.equal(written.timeoutMs, 120000)
107
+ assert.equal(written.default, 'git-bash')
108
+ assert.ok(!('resolved' in written))
109
+ assert.match(JSON.stringify(p.render()), /已保存/)
110
+ })
111
+
112
+ // Given 多个客户端;When 改默认 id 或删除默认项;Then 默认值始终指向现有客户端。
113
+ test('编辑:重命名及删除默认客户端同步更新默认值', async () => {
114
+ const p = await page()
115
+ p.field(p.render(), '客户端 1 id').props.onChange({ target: { value: 'bash-local' } })
116
+ assert.equal(p.field(p.render(), '客户端 1 默认').props.checked, true)
117
+ p.nodes(p.render()).find((node) => node.props['aria-label'] === '移除客户端 1').props.onClick()
118
+ assert.equal(p.field(p.render(), '客户端 1 默认').props.checked, true)
119
+ })
120
+
121
+ test('编辑:多个同类客户端可新增并保存独立 id', async () => {
122
+ const p = await page()
123
+ p.button(p.render(), '添加客户端').props.onClick()
124
+ p.field(p.render(), '客户端 3 id').props.onChange({ target: { value: 'bash-extra' } })
125
+ await p.button(p.render(), '保存').props.onClick()
126
+ const payload = JSON.parse(p.requests.find((row) => row.method === 'POST').body)
127
+ assert.deepEqual(payload.shells.map((entry) => entry.id), ['git-bash', 'pwsh', 'bash-extra'])
128
+ })
129
+
130
+ // Given 非法配置;When 保存;Then 错误可见且不发写请求。
131
+ for (const [label, value, error] of [
132
+ ['客户端 2 id', 'git-bash', '重复'],
133
+ ['客户端 1 参数', '"unterminated', '引号'],
134
+ ['客户端 1 环境', 'invalid', '环境'],
135
+ ['黑名单第 1 条', '[', '正则'],
136
+ ]) test('非法输入阻止保存: ' + label, async () => {
137
+ const p = await page()
138
+ p.field(p.render(), label).props.onChange({ target: { value } })
139
+ await p.button(p.render(), '保存').props.onClick()
140
+ assert.equal(p.requests.filter((row) => row.method === 'POST').length, 0)
141
+ assert.match(JSON.stringify(p.render()), new RegExp(error))
142
+ })
143
+
144
+ test('加载失败:显示错误及重新加载操作', async () => {
145
+ const p = await page({ failLoad: true })
146
+ assert.match(JSON.stringify(p.render()), /加载故障/)
147
+ assert.ok(p.button(p.render(), '重新加载'))
148
+ p.restoreLoad()
149
+ p.button(p.render(), '重新加载').props.onClick()
150
+ p.render()
151
+ await p.flush()
152
+ assert.equal(p.field(p.render(), '客户端 1 默认').props.checked, true)
153
+ })
154
+
155
+ test('原规则交互:添加空行不生成空正则,逐行删除与编辑可保存', async () => {
156
+ const p = await page()
157
+ p.button(p.render(), '添加规则').props.onClick()
158
+ assert.equal(p.field(p.render(), '黑名单第 5 条').props.value, '')
159
+ p.field(p.render(), '黑名单第 1 条').props.onChange({ target: { value: '^danger' } })
160
+ p.field(p.render(), '删除规则 2').props.onClick()
161
+ await p.button(p.render(), '保存').props.onClick()
162
+ const payload = JSON.parse(p.requests.find((row) => row.method === 'POST').body)
163
+ assert.deepEqual(payload.deny, ['^danger', ' ', 'echo\nsecret'])
164
+ })
165
+
166
+ test('原参数环境文本:引号内空格与 K=V 值可编辑,不暴露 JSON', async () => {
167
+ const p = await page()
168
+ p.field(p.render(), '客户端 1 参数').props.onChange({ target: { value: '-c "echo new words" {command}' } })
169
+ p.field(p.render(), '客户端 1 环境').props.onChange({ target: { value: 'NOTE=a=b c\nOTHER= two words ' } })
170
+ await p.button(p.render(), '保存').props.onClick()
171
+ const shell = JSON.parse(p.requests.find((row) => row.method === 'POST').body).shells[0]
172
+ assert.deepEqual(shell.args, ['-c', 'echo new words', '{command}'])
173
+ assert.deepEqual(shell.env, { NOTE: 'a=b c', OTHER: ' two words ' })
174
+ })
175
+
176
+ test('编辑再还原:参数与环境原始数组和换行值仍无损', async () => {
177
+ const p = await page()
178
+ for (const label of ['客户端 1 参数', '客户端 1 环境']) {
179
+ const original = p.field(p.render(), label).props.value
180
+ p.field(p.render(), label).props.onChange({ target: { value: 'temporary' } })
181
+ p.field(p.render(), label).props.onChange({ target: { value: original } })
182
+ }
183
+ await p.button(p.render(), '保存').props.onClick()
184
+ const shell = JSON.parse(p.requests.find((row) => row.method === 'POST').body).shells[0]
185
+ assert.deepEqual(shell.args, section().shells[0].args)
186
+ assert.deepEqual(shell.env, section().shells[0].env)
187
+ })
188
+
189
+ test('修改单个环境变量:其他含换行与反斜线的值保持不变', async () => {
190
+ const p = await page()
191
+ const original = p.field(p.render(), '客户端 1 环境').props.value
192
+ p.field(p.render(), '客户端 1 环境').props.onChange({ target: { value: original.replace('NOTE=a=b c', 'NOTE=changed value') } })
193
+ await p.button(p.render(), '保存').props.onClick()
194
+ const posted = p.requests.find((row) => row.method === 'POST')
195
+ assert.ok(posted, '编辑普通变量不应使已有多行值阻止保存')
196
+ assert.deepEqual(JSON.parse(posted.body).shells[0].env, { ...section().shells[0].env, NOTE: 'changed value' })
197
+ })
198
+
199
+ test('双引号 Windows 参数:路径反斜线不能被当成 JSON 控制字符', async () => {
200
+ const p = await page()
201
+ const path = 'C:\\Program Files\\Git\\bin\\bash.exe'
202
+ p.field(p.render(), '客户端 1 参数').props.onChange({ target: { value: '-c "' + path + '" {command}' } })
203
+ await p.button(p.render(), '保存').props.onClick()
204
+ const posted = p.requests.find((row) => row.method === 'POST')
205
+ assert.ok(posted)
206
+ assert.deepEqual(JSON.parse(posted.body).shells[0].args, ['-c', path, '{command}'])
207
+ })
208
+
209
+ test('参数修改保真:只追加参数仍保留原有空项、引号、反斜线与控制字符', async () => {
210
+ const p = await page()
211
+ const original = p.field(p.render(), '客户端 1 参数').props.value
212
+ p.field(p.render(), '客户端 1 参数').props.onChange({ target: { value: original + ' extra' } })
213
+ await p.button(p.render(), '保存').props.onClick()
214
+ const posted = p.requests.find((row) => row.method === 'POST')
215
+ assert.ok(posted)
216
+ assert.deepEqual(JSON.parse(posted.body).shells[0].args, [...section().shells[0].args, 'extra'])
217
+ })
218
+
219
+ test('扫描不产生未保存改动:保存仍禁用', async () => {
220
+ const p = await page()
221
+ await p.button(p.render(), '扫描本机').props.onClick()
222
+ assert.equal(p.button(p.render(), '保存').props.disabled, true)
223
+ })
224
+
225
+ test('探测:独立调用路径与扫描接口,不提交配置', async () => {
226
+ const p = await page()
227
+ p.field(p.render(), '客户端 1 路径').props.onChange({ target: { value: 'C:/Git/bash.exe' } })
228
+ await p.button(p.render(), '探测路径').props.onClick()
229
+ assert.match(JSON.stringify(p.render()), /路径存在/)
230
+ await p.button(p.render(), '扫描本机').props.onClick()
231
+ assert.match(JSON.stringify(p.render()), /C:\/Git\/bash.exe/)
232
+ assert.equal(p.requests.filter((row) => row.path.endsWith('/config') && row.method === 'POST').length, 0)
233
+ })
234
+
235
+ test('提交成功但回读失败:明确已保存,不误报提交失败', async () => {
236
+ const p = await page({ failReadBack: true })
237
+ p.dirty()
238
+ await p.button(p.render(), '保存').props.onClick()
239
+ assert.equal(p.requests.filter((row) => row.method === 'POST').length, 1)
240
+ const text = JSON.stringify(p.render())
241
+ assert.match(text, /已保存.*刷新失败.*回读故障/)
242
+ assert.doesNotMatch(text, /保存失败/)
243
+ })
244
+
245
+ test('保存失败:保留输入并显示服务端错误', async () => {
246
+ const p = await page({ failSave: true })
247
+ p.field(p.render(), '客户端 1 id').props.onChange({ target: { value: 'edited' } })
248
+ await p.button(p.render(), '保存').props.onClick()
249
+ assert.equal(p.field(p.render(), '客户端 1 id').props.value, 'edited')
250
+ assert.match(JSON.stringify(p.render()), /写入故障/)
251
+ })
@@ -443,12 +443,18 @@ test('构造:boxed volatile 字段(宿主 resolve 产物)解箱生效,不回落
443
443
  assert.deepEqual(listing.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
444
444
  assert.equal(executor.entryFor(undefined).id, 'git-bash')
445
445
  const shell = registered.tools.find((tool) => tool.name === 'shell')
446
- assert.match(shell.description, /git-bash \(bash\)$|default client is "git-bash"/)
447
- assert.doesNotMatch(shell.description, /cmd \(cmd\)/)
446
+ assert.match(shell.description, /^Execute commands using shell="git-bash" by default/)
447
+ assert.doesNotMatch(shell.description, /^- cmd —/m)
448
+ assert.equal(shell.parameters.properties.shell.description, 'Override the default shell using an id listed above.')
449
+ assert.doesNotMatch(shell.parameters.properties.sandbox_permissions.description, /retry/)
450
+ const prompt = registered.promptSections.find((section) => section.name === 'tool:shell').text
451
+ assert.match(prompt, /investigate failures/)
452
+ assert.match(prompt, /after an interruption/)
453
+ assert.doesNotMatch(prompt, /fresh processes|default client|Pass the `shell`/)
448
454
  })
449
455
 
450
456
  test('refresh:boxed volatile 字段换源同样解箱生效', () => {
451
- const { ctx } = stubCtx()
457
+ const { ctx, registered } = stubCtx()
452
458
  const executor = new ShellSelectExecutor(ctx, baseConfig())
453
459
  executor.refresh(boxFields({
454
460
  shells: baseConfig().shells,
@@ -458,6 +464,8 @@ test('refresh:boxed volatile 字段换源同样解箱生效', () => {
458
464
  const listing = executor.listShells()
459
465
  assert.equal(listing.default, 'git-bash')
460
466
  assert.equal(listing.shells.length, 3)
467
+ assert.match(registered.tools.findLast((tool) => tool.name === 'shell').description,
468
+ /^Execute commands using shell="git-bash" by default/)
461
469
  })
462
470
 
463
471
  test('构造:混合形态(部分字段 boxed 部分普通)逐字段解箱生效', () => {
@@ -45,6 +45,9 @@ function stubCtx({ rows, importOfficial }) {
45
45
  },
46
46
  })
47
47
  },
48
+ effect(execute) {
49
+ return execute()
50
+ },
48
51
  }
49
52
  return { ctx, log, mounts, listeners }
50
53
  }
@@ -0,0 +1,110 @@
1
+ // guard 生命周期:定时器随 fiber 清理(effect),停机竞态异常不逃逸为
2
+ // unhandledRejection——宿主 fail-loud 会把这类逃逸判为 fatal load failure
3
+ // 杀掉整个进程(dsh-maintain 重启停机窗口实测事故)。
4
+
5
+ import { test } from 'node:test'
6
+ import assert from 'node:assert/strict'
7
+ import { setTimeout as sleep } from 'node:timers/promises'
8
+ import { installGuard } from '../src/guard.js'
9
+ import { MAIN_ROW_ID } from '../src/guard-state.mjs'
10
+
11
+ const OFFICIAL_MODULE = (name) => ({ __name: name, name, apply() {} })
12
+ const makeImport = async (packageName) => OFFICIAL_MODULE(packageName)
13
+
14
+ function row({ id, disabled = false, running = false }) {
15
+ return {
16
+ options: { id },
17
+ get disabled() {
18
+ return disabled
19
+ },
20
+ fiber: running ? { uid: 'f' } : undefined,
21
+ }
22
+ }
23
+
24
+ function deadWorld() {
25
+ return [
26
+ row({ id: MAIN_ROW_ID, disabled: true }),
27
+ row({ id: 'tool-pwsh', disabled: true }),
28
+ row({ id: 'pwsh-sandbox', disabled: true }),
29
+ ]
30
+ }
31
+
32
+ function stubCtx({ rows, resolveCount = { value: 0 } }) {
33
+ const log = []
34
+ const mounts = []
35
+ const effects = []
36
+ const listeners = new Map()
37
+ const ctx = {
38
+ logger: {
39
+ warn: (msg) => log.push(['warn', msg]),
40
+ error: (msg) => log.push(['error', msg]),
41
+ info: (msg) => log.push(['info', msg]),
42
+ },
43
+ get loader() {
44
+ return {
45
+ resolve(id) {
46
+ resolveCount.value += 1
47
+ const found = rows.find((candidate) => candidate.options.id === id)
48
+ if (found) return found
49
+ throw new Error('not found')
50
+ },
51
+ }
52
+ },
53
+ on(event, handler) {
54
+ listeners.set(event, handler)
55
+ },
56
+ effect(execute, label) {
57
+ const dispose = execute()
58
+ effects.push({ label, dispose })
59
+ return dispose
60
+ },
61
+ plugin(module, config) {
62
+ mounts.push({ moduleName: module.__name, config })
63
+ return Promise.resolve({
64
+ dispose: () => {
65
+ mounts.length = 0
66
+ return Promise.resolve()
67
+ },
68
+ })
69
+ },
70
+ }
71
+ return { ctx, log, mounts, effects, resolveCount }
72
+ }
73
+
74
+ test('定时器随 fiber 清理:effect dispose 后不再扫描', async () => {
75
+ const sweepIntervalMs = 20
76
+ const { ctx, mounts, effects, resolveCount } = stubCtx({ rows: deadWorld() })
77
+ await installGuard(ctx, { importOfficial: makeImport, sweepIntervalMs })
78
+ assert.equal(mounts.length, 2)
79
+ const timerEffect = effects.find((entry) => entry.label.includes('sweep'))
80
+ assert.ok(timerEffect, 'sweep 定时器必须注册为 effect')
81
+ assert.equal(typeof timerEffect.dispose, 'function')
82
+ timerEffect.dispose()
83
+ await sleep(sweepIntervalMs * 3)
84
+ const settled = resolveCount.value
85
+ await sleep(sweepIntervalMs * 3)
86
+ assert.equal(resolveCount.value, settled, 'dispose 后不得再有 sweep 扫描')
87
+ })
88
+
89
+ test('sweep 异常不逃逸:停机竞态下 loader 抛错不产生 unhandledRejection', async () => {
90
+ const sweepIntervalMs = 20
91
+ const { ctx, mounts } = stubCtx({ rows: deadWorld() })
92
+ await installGuard(ctx, { importOfficial: makeImport, sweepIntervalMs })
93
+ assert.equal(mounts.length, 2)
94
+ // 模拟 fiber 停用后 ctx.loader 访问抛 inactive context 错误
95
+ Object.defineProperty(ctx, 'loader', {
96
+ get() {
97
+ throw new Error('cannot get required service "loader" in inactive context')
98
+ },
99
+ })
100
+ const rejections = []
101
+ const onRejection = (error) => rejections.push(error)
102
+ process.on('unhandledRejection', onRejection)
103
+ try {
104
+ await sleep(sweepIntervalMs * 4)
105
+ assert.equal(mounts.length, 2, '异常不得破坏已挂载状态')
106
+ assert.deepEqual(rejections, [])
107
+ } finally {
108
+ process.off('unhandledRejection', onRejection)
109
+ }
110
+ })
@@ -6,13 +6,62 @@ import assert from 'node:assert/strict'
6
6
  import { shellDescription } from '../src/tool.mjs'
7
7
 
8
8
  const FACES = { backgroundEnabled: true, escalationModes: [] }
9
- const entry = (id, kind, available) => ({ id, kind, available })
9
+ const entry = (id, kind, available, distro = '') => ({ id, kind, available, distro })
10
+
11
+ // Given 用户自定义多个客户端且删除 pwsh;When 生成描述;Then 默认值优先,各 id 独立呈现环境。
12
+ test('动态客户端:默认值优先,多个 Bash 与 WSL 发行版独立列出', () => {
13
+ const executor = { listShells: () => ({ default: 'bash-alt', shells: [
14
+ entry('git-bash', 'bash', true), entry('bash-alt', 'bash', true),
15
+ entry('ubuntu', 'wsl', true, 'Ubuntu'), entry('linux', 'wsl', true),
16
+ entry('legacy', 'cmd', false),
17
+ ] }) }
18
+ const text = shellDescription({ executor, ...FACES })
19
+ assert.ok(text.startsWith('Execute commands using shell="bash-alt" by default and return stdout/stderr.'))
20
+ assert.match(text, /Omit shell to use the default; set it to one of:/)
21
+ assert.match(text, /^- git-bash — Bash$/m)
22
+ assert.match(text, /^- bash-alt — Bash$/m)
23
+ assert.match(text, /^- ubuntu — WSL \(Ubuntu\)$/m)
24
+ assert.match(text, /^- linux — WSL$/m)
25
+ assert.match(text, /^- legacy — Windows cmd\.exe \(unavailable\)$/m)
26
+ assert.doesNotMatch(text, /pwsh|PowerShell/)
27
+ })
28
+
29
+ // Given 仅有一个 PowerShell 客户端;When 配置默认值更新;Then 描述同步变化且无额外客户端。
30
+ test('单客户端:默认值及工作环境来自当前配置', () => {
31
+ let listing = { default: 'office', shells: [entry('office', 'pwsh', true)] }
32
+ const executor = { listShells: () => listing }
33
+ assert.match(shellDescription({ executor, ...FACES }), /shell="office"[\s\S]*^- office — PowerShell$/m)
34
+ listing = { default: 'console', shells: [entry('console', 'cmd', true)] }
35
+ const text = shellDescription({ executor, ...FACES })
36
+ assert.match(text, /shell="console"[\s\S]*^- console — Windows cmd\.exe$/m)
37
+ assert.doesNotMatch(text, /office|PowerShell/)
38
+ })
39
+
40
+ // Given 升权面存在;When 生成模型主描述;Then 只讲常规调用,不注入拒绝重试流程。
41
+ test('主描述:常规调用说明不随升权面增加拒绝流程', () => {
42
+ const executor = { listShells: () => ({ default: 'ps', shells: [entry('ps', 'pwsh', true)] }) }
43
+ const text = shellDescription({ executor, ...FACES, escalationModes: ['workspace-write'] })
44
+ assert.equal(text, shellDescription({ executor, ...FACES }))
45
+ assert.match(text, /fresh process/)
46
+ assert.match(text, /run_in_background=true/)
47
+ assert.match(text, /job_output.*job_kill/)
48
+ assert.match(text, /full-output file path/)
49
+ assert.doesNotMatch(text, /retry|denial|sandbox_permissions|DSH_\*|exit code/)
50
+ })
51
+
52
+ // Given 后台执行未启用;When 生成描述;Then 不建议后台调用。
53
+ test('后台不可用:不展示后台参数或任务工具', () => {
54
+ const executor = { listShells: () => ({ default: 'ps', shells: [entry('ps', 'pwsh', true)] }) }
55
+ const text = shellDescription({ executor, ...FACES, backgroundEnabled: false })
56
+ assert.doesNotMatch(text, /run_in_background|job_output|job_kill/)
57
+ assert.match(text, /within the timeout/)
58
+ })
10
59
 
11
60
  test('A1 含可用 bash 条目:描述含 Windows-style 路径措辞', () => {
12
61
  const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('git-bash', 'bash', true)] }) }
13
62
  const text = shellDescription({ executor, ...FACES })
14
63
  assert.match(text, /Windows-style paths/)
15
- assert.match(text, /`workdir`/)
64
+ assert.match(text, /Windows-style paths \(C:\/\.\.\.\) in workdir and file-tool arguments/)
16
65
  })
17
66
 
18
67
  test('A2 仅 pwsh/cmd:不含措辞', () => {