@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 +4 -13
- package/package.json +1 -1
- package/src/client.js +337 -3
- package/src/executor.mjs +1 -1
- package/src/guard.js +12 -4
- package/src/tool.mjs +29 -22
- package/test/client-settings.test.mjs +251 -0
- package/test/executor.test.mjs +11 -3
- package/test/guard-entry.test.mjs +3 -0
- package/test/guard-lifecycle.test.mjs +110 -0
- package/test/path-guidance.test.mjs +51 -2
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`)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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'
|
|
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.
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
/**
|
|
148
|
-
const BASH_PATH_GUIDANCE = '
|
|
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
|
|
151
|
+
/** 工具描述只展示当前配置与常规调用方式。 */
|
|
152
|
+
export function shellDescription({ executor, backgroundEnabled }) {
|
|
152
153
|
const listing = executor.listShells()
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
+
})
|
package/test/executor.test.mjs
CHANGED
|
@@ -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,
|
|
447
|
-
assert.doesNotMatch(shell.description,
|
|
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 部分普通)逐字段解箱生效', () => {
|
|
@@ -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,
|
|
64
|
+
assert.match(text, /Windows-style paths \(C:\/\.\.\.\) in workdir and file-tool arguments/)
|
|
16
65
|
})
|
|
17
66
|
|
|
18
67
|
test('A2 仅 pwsh/cmd:不含措辞', () => {
|