@mzzsfy/dsh-shell-select 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/cordis.patch.yml +18 -0
- package/package.json +3 -2
- package/src/client.js +149 -47
- package/src/executor.mjs +3 -1
- package/test/client-card.test.mjs +63 -8
- package/test/client-id.test.mjs +36 -7
- package/test/config-entry.test.mjs +1 -1
- package/test/executor-seam.test.mjs +21 -6
- package/test/executor.test.mjs +35 -18
- package/test/preset-drift.test.mjs +174 -0
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ Windows shell 链接管:禁用官方 `pwsh` 工具与执行器,替换为可配
|
|
|
6
6
|
|
|
7
7
|
- bundle 补丁按 id 禁用官方 `tool-pwsh`、`pwsh-sandbox` 两行,插入本包主行(`ctx.shell` 执行器 + `shell` 工具 + 设置节 + 浏览器设置页路由)与 guard 哨兵行;卸载本包即补丁消失,官方行自动复位。
|
|
8
8
|
- client 半区 `dsh.client.inject` 声明(官方机制,dsh-client-file-upload 等官方包同款):保证 client 运行时模块表中 `primitives`/`slots` 面在场;primitives 另有 try/catch 降级自绘兜底。
|
|
9
|
-
- 本包执行器完整实现官方 `ctx.shell` seam(`resolve`/`run`/`start`/`runFor`/`startFor`/`entryFor`/`sandboxMode`),结构官方 PwshLocalExecutor 同构(实例状态/方法全公有——cordis 服务代理会把经 `ctx.shell` 调用的方法 `this` 重定向到阴影对象,`#` 私有触发品牌检查错误)。
|
|
9
|
+
- 本包执行器完整实现官方 `ctx.shell` seam(`resolve`/`run`/`start`/`runFor`/`startFor`/`entryFor`/`sandboxMode`),结构官方 PwshLocalExecutor 同构(实例状态/方法全公有——cordis 服务代理会把经 `ctx.shell` 调用的方法 `this` 重定向到阴影对象,`#` 私有触发品牌检查错误)。
|
|
10
|
+
- preset 面收口:web 面把 agent 平面后移到每会话 agent preset,内置 standard 预设在 win32 重新声明官方 `pwsh` 工具行,bundle 补丁对 preset 组合树不可达。本包自带预设 `shell-select`(standard 全量副本 − tool-pwsh 行,`presets/shell-select/`),经补丁覆写 `agent-presets` 行接入:win32 默认预设切到本包预设(POSIX 保持 standard,行为与上游一致),`roots` 指向本包 presets 目录;`agent-presets.default` 的 settings 值恒优先于补丁缺省,用户显式选择不被劫持。上游预设漂移由 `test/preset-drift.test.mjs` 逐行守卫,升级后按官方实文对表。
|
|
10
11
|
- 沙箱语义官方同构:`danger-full-access` 直跑;受限模式经 `ctx.sandbox.confine` 包装并按官方方言分类拒绝/runner 失败,权限模型不变。
|
|
11
12
|
- 死态自愈:用户 patch 层禁用主行的窗口内,guard 哨兵(id 含 `/`,市场不写该层)代挂官方 `dsh-tool-pwsh` + `dsh-pwsh-sandbox` 恢复 host 面服务与 boot 组合(预载失败退避重试,至多 3 次),任一方复活先卸代挂。哨兵/复活让位语义逐项同构 `dsh-llm-pi-gateway`。边界:cordis 服务按 fiber 树解析,代挂对 agent preset 作用域不可见,死态窗口新会话的 preset tool-pwsh 行拒挂(报错清晰)属预期。
|
|
12
13
|
- POSIX 上本包全部行停用,官方 bash 链不受影响。
|
|
@@ -59,8 +60,8 @@ env 优先级(同键高右):内置覆盖集(NO_COLOR/PAGER/GIT_PAGER)< 条目 `e
|
|
|
59
60
|
|
|
60
61
|
- 配置节顶层 `deny`:正则字符串数组,对模型提交的整条命令文本匹配(大小写不敏感),命中即拒绝——**deny 绝对,无豁免语义**;空数组 = 不拦截(出厂默认)。
|
|
61
62
|
- 精细放行在模式内用正则前瞻表达:`deny: ['rm -rf\\s+(?!\\S*node_modules)']` 禁 rm -rf 但放行清依赖目录;默认放行系统下独立 allow 列表的唯一语义就是覆盖 deny,与"deny 绝对"矛盾,故不设。
|
|
62
|
-
- 拦截点在执行器 runFor/startFor 入口:经 `ctx.shell`
|
|
63
|
-
- 定位是防误触护栏而非安全边界:整文本正则挡不住间接包装(编码/嵌套壳)
|
|
63
|
+
- 拦截点在执行器 runFor/startFor 入口:经 `ctx.shell` 代理的消费方(如 hooks 桥)与 preset 预设切换前的官方 pwsh 工具同样受管。拒绝以模型可见标记返回:`[blocked by shell-select: matches deny pattern …]`,后台任务在启动前同步拒绝,不产生僵尸任务。
|
|
64
|
+
- 定位是防误触护栏而非安全边界:整文本正则挡不住间接包装(编码/嵌套壳),真正的边界始终是访问模式与沙箱。坏正则条目容错跳过,不瘫执行链;设置页为逐条规则编辑器(每条规则独立行,行级即时校验并标错,保存时按行定位拦截非法正则)。
|
|
64
65
|
|
|
65
66
|
### bash 形探测与 msys2
|
|
66
67
|
|
package/cordis.patch.yml
CHANGED
|
@@ -14,6 +14,17 @@
|
|
|
14
14
|
# 哨兵自愈与复活让位语义逐项同构 packages/dsh-llm-pi-gateway 的 guard/takeover,
|
|
15
15
|
# 差异仅两处:官方代挂对象为 tool-pwsh + pwsh-sandbox 两行;行配置读官方
|
|
16
16
|
# `shell` settings 节(pwsh-sandbox 经其 Config 消费,保持官方 pwshPath 配置)。
|
|
17
|
+
#
|
|
18
|
+
# preset 面:web 面把 agent 平面后移到每会话 agent preset,内置 standard 预设
|
|
19
|
+
# 在 win32 重新声明 tool-pwsh 行,bundle patch 对 preset 组合树不可达。收口方式:
|
|
20
|
+
# 本包自带 preset(standard 全量副本 − tool-pwsh 行,见 presets/shell-select/),
|
|
21
|
+
# 经 agent-presets 行覆写接入——roots 指向本包 presets 目录(default 'user' 承接
|
|
22
|
+
# schema 缺省),default 在 win32 切到本包预设、POSIX 保持 standard(本包行在
|
|
23
|
+
# POSIX 全停用,行为须与上游逐字一致)。路径表达式由 ctx.baseUrl(profile 目录)
|
|
24
|
+
# 推 node_modules/@mzzsfy/dsh-shell-select/presets/:bundle 必经 profile node_modules
|
|
25
|
+
# 解析(resolveBundleDir 顺序),本 patch 在执行即证目录在。default 仅为 settings
|
|
26
|
+
# `agent-presets.default` 缺省(config.default 是其 base),用户显式选择恒优先;
|
|
27
|
+
# 上游行更名/缺行时 patch 告警跳过,行为自然回落 standard。
|
|
17
28
|
|
|
18
29
|
- id: pwsh-sandbox
|
|
19
30
|
disabled: true
|
|
@@ -21,6 +32,13 @@
|
|
|
21
32
|
- id: tool-pwsh
|
|
22
33
|
disabled: true
|
|
23
34
|
|
|
35
|
+
- id: agent-presets
|
|
36
|
+
name: '@deepseek-ai/dsh-agent-presets'
|
|
37
|
+
config:
|
|
38
|
+
default: !!js "process.platform === 'win32' ? 'shell-select' : 'standard'"
|
|
39
|
+
roots:
|
|
40
|
+
- path: !!js decodeURIComponent(new URL('node_modules/@mzzsfy/dsh-shell-select/presets/', ctx.baseUrl).pathname).replace(/^\/([A-Za-z]:)/, '$1')
|
|
41
|
+
|
|
24
42
|
- insert:
|
|
25
43
|
- id: shell-select
|
|
26
44
|
name: '@mzzsfy/dsh-shell-select'
|
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,自定义路径,默认客户端,模型按名选择)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/executor.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"@deepseek-ai/dsh-subprocess": "0.1.5-rc.2",
|
|
60
60
|
"@deepseek-ai/dsh-timeout": "0.1.5-rc.2",
|
|
61
61
|
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
62
|
-
"@deepseek-ai/schemastery": "3.18.2"
|
|
62
|
+
"@deepseek-ai/schemastery": "3.18.2",
|
|
63
|
+
"js-yaml": "4.3.2"
|
|
63
64
|
},
|
|
64
65
|
"files": [
|
|
65
66
|
"src",
|
package/src/client.js
CHANGED
|
@@ -65,6 +65,31 @@ window.__ModuleLoader__.load({
|
|
|
65
65
|
return payload
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// 工具卡客户端名册缓存:GET /config 单飞拉取,shell 徽章按 id→用户命名解析。
|
|
69
|
+
// 失败置 null(徽章不渲染),不重试——下次页面加载自然重取
|
|
70
|
+
let clientCatalog = null
|
|
71
|
+
let clientCatalogPromise = null
|
|
72
|
+
// LOGIC-BEGIN ensureClientCatalog
|
|
73
|
+
function ensureClientCatalog() {
|
|
74
|
+
if (clientCatalogPromise === null) {
|
|
75
|
+
clientCatalogPromise = api(API.config).then((section) => {
|
|
76
|
+
const byId = {}
|
|
77
|
+
for (const entry of section.resolved?.shells ?? []) byId[entry.id] = entry.name
|
|
78
|
+
clientCatalog = { default: section.resolved?.default, byId }
|
|
79
|
+
}).catch(() => { })
|
|
80
|
+
}
|
|
81
|
+
return clientCatalogPromise
|
|
82
|
+
}
|
|
83
|
+
// LOGIC-END ensureClientCatalog
|
|
84
|
+
// 显式 shell 参数按 id 取用户命名;缺省落 default 客户端的用户命名(配置即当前解析事实)
|
|
85
|
+
// LOGIC-BEGIN clientDisplayName
|
|
86
|
+
function clientDisplayName(requested) {
|
|
87
|
+
if (clientCatalog === null) return undefined
|
|
88
|
+
const id = requested !== undefined && requested !== '' ? requested : clientCatalog.default
|
|
89
|
+
return id !== undefined ? clientCatalog.byId[id] : undefined
|
|
90
|
+
}
|
|
91
|
+
// LOGIC-END clientDisplayName
|
|
92
|
+
|
|
68
93
|
const CSS = [
|
|
69
94
|
'.sls-card { display:flex; flex-direction:column; gap:10px; }',
|
|
70
95
|
'.sls-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
|
|
@@ -84,23 +109,38 @@ window.__ModuleLoader__.load({
|
|
|
84
109
|
'.sls-hint { font-size:12px; opacity:.7; }',
|
|
85
110
|
'.sls-error { font-size:12px; color:#c44; white-space:pre-wrap; }',
|
|
86
111
|
'.sls-args { font-size:12px; font-family:var(--sls-mono, monospace); }',
|
|
87
|
-
//
|
|
88
|
-
'.sls-
|
|
112
|
+
// 命令黑名单:逐条规则编辑器(每条规则独立行,行级即时校验)
|
|
113
|
+
'.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; }',
|
|
114
|
+
'.sls-rules__head { display:flex; align-items:center; gap:8px; }',
|
|
115
|
+
'.sls-rules__title { font-weight:500; }',
|
|
116
|
+
'.sls-rules__note { font-size:12px; opacity:.7; }',
|
|
117
|
+
'.sls-rule { display:flex; align-items:center; gap:6px; }',
|
|
118
|
+
'.sls-rule__no { flex:none; width:18px; text-align:right; font-size:12px; opacity:.45; user-select:none; }',
|
|
119
|
+
'.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); }',
|
|
120
|
+
'.sls-rule__input:focus { outline:none; border-color:var(--sls-accent, #4b7bcc); }',
|
|
121
|
+
'.sls-rule--bad .sls-rule__input { border-color:#c44; }',
|
|
122
|
+
'.sls-rule__err { flex:none; max-width:40%; font-size:11px; color:#c44; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
|
|
123
|
+
'.sls-rule__del { flex:none; padding:2px 8px; }',
|
|
124
|
+
// tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强。
|
|
125
|
+
// 色彩对齐官方 ToolRow:标题 label-secondary、icon 盒 label-tertiary(实证自
|
|
126
|
+
// 官方行 computed style);token 缺失时回退 inherit 不致不可读
|
|
127
|
+
'.sls-tv { font-size:13px; line-height:1.45; color:var(--dsw-alias-label-secondary, inherit); }',
|
|
89
128
|
'.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
|
|
90
129
|
'.sls-tv__row--exp { cursor:pointer; user-select:none; }',
|
|
91
|
-
'.sls-tv__lead { display:flex; align-items:center; gap:4px; color:inherit; }',
|
|
130
|
+
'.sls-tv__lead { display:flex; align-items:center; gap:4px; color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
92
131
|
'.sls-tv__chev { opacity:.45; transition:transform .15s ease; }',
|
|
93
132
|
'.sls-tv__row[data-open="1"] .sls-tv__chev { transform:rotate(-90deg); }',
|
|
94
133
|
'.sls-tv__sr { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }',
|
|
95
|
-
'.sls-tv__title { font-weight:
|
|
96
|
-
'.sls-tv__sep { width:
|
|
97
|
-
'.sls-tv__sum {
|
|
98
|
-
'.sls-tv__sum--err { color
|
|
134
|
+
'.sls-tv__title { font-weight:400; }',
|
|
135
|
+
'.sls-tv__sep { width:2px; height:2px; border-radius:1px; background:var(--dsw-alias-label-caption, currentColor); opacity:.8; flex:none; }',
|
|
136
|
+
'.sls-tv__sum { color:var(--dsw-alias-label-tertiary, inherit); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
137
|
+
'.sls-tv__sum--err { color:var(--dsw-alias-state-error-primary, #d4553f); }',
|
|
99
138
|
'.sls-tv__body { margin:6px 0 4px; border:1px solid rgba(128,128,128,.28); border-radius:8px; overflow:hidden; }',
|
|
100
139
|
'.sls-tv__head { display:flex; align-items:center; gap:8px; padding:6px 10px; border-bottom:1px solid rgba(128,128,128,.18); background:rgba(128,128,128,.05); }',
|
|
101
140
|
'.sls-tv__cwd { font-family:var(--sls-mono, monospace); font-size:12px; opacity:.7; }',
|
|
102
141
|
'.sls-tv__badge { font-size:11px; padding:0 7px; border-radius:999px; border:1px solid rgba(128,128,128,.35); opacity:.85; flex:none; }',
|
|
103
142
|
'.sls-tv__pill { font-size:12px; color:#d4553f; flex:none; }',
|
|
143
|
+
'.sls-tv__pill--bg { color:var(--dsw-alias-label-tertiary, inherit); }',
|
|
104
144
|
'.sls-tv__sp { flex:1; }',
|
|
105
145
|
'.sls-tv__copy { display:flex; align-items:center; gap:2px; flex:none; }',
|
|
106
146
|
'.sls-tv__copyBtn { padding:2px 8px; border:1px solid rgba(128,128,128,.35); border-radius:6px; background:transparent; color:inherit; cursor:pointer; font-size:12px; }',
|
|
@@ -142,7 +182,7 @@ window.__ModuleLoader__.load({
|
|
|
142
182
|
}
|
|
143
183
|
|
|
144
184
|
// 保存负载:仅取设置 schema 字段,剥离探测态
|
|
145
|
-
function toSection(entries, defaultId,
|
|
185
|
+
function toSection(entries, defaultId, denyRules) {
|
|
146
186
|
return {
|
|
147
187
|
shells: entries.map((entry) => ({
|
|
148
188
|
id: entry.id.trim(),
|
|
@@ -155,16 +195,25 @@ window.__ModuleLoader__.load({
|
|
|
155
195
|
env: parseEnvText(entry.envText),
|
|
156
196
|
})),
|
|
157
197
|
default: defaultId,
|
|
158
|
-
deny:
|
|
198
|
+
deny: denyRules.filter((rule) => rule.length > 0),
|
|
159
199
|
}
|
|
160
200
|
}
|
|
161
201
|
|
|
162
|
-
//
|
|
163
|
-
// LOGIC-BEGIN
|
|
164
|
-
function
|
|
165
|
-
|
|
202
|
+
// 逐条规则的正则校验:返回非法条目 [{index, error}](index 为规则序,供行级标错与保存拦截复用)
|
|
203
|
+
// LOGIC-BEGIN denyPatternIssues
|
|
204
|
+
function denyPatternIssues(rules) {
|
|
205
|
+
const issues = []
|
|
206
|
+
for (let index = 0; index < rules.length; index += 1) {
|
|
207
|
+
if (rules[index].length === 0) continue
|
|
208
|
+
try {
|
|
209
|
+
new RegExp(rules[index])
|
|
210
|
+
} catch (error) {
|
|
211
|
+
issues.push({ index, error: String(error?.message ?? error) })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return issues
|
|
166
215
|
}
|
|
167
|
-
// LOGIC-END
|
|
216
|
+
// LOGIC-END denyPatternIssues
|
|
168
217
|
|
|
169
218
|
// 环境文本(每行 K=V)→ 记录;空行与缺 = 的行忽略,值保留原样含空格与 =。
|
|
170
219
|
// LOGIC-BEGIN parseEnvText
|
|
@@ -190,7 +239,7 @@ window.__ModuleLoader__.load({
|
|
|
190
239
|
}
|
|
191
240
|
// LOGIC-END invalidEnvLines
|
|
192
241
|
|
|
193
|
-
// 服务器清单 → 编辑态(argsText 汇成一串便于编辑;envText 每行 K=V;
|
|
242
|
+
// 服务器清单 → 编辑态(argsText 汇成一串便于编辑;envText 每行 K=V;deny 为逐条规则数组)
|
|
194
243
|
function toEntries(section) {
|
|
195
244
|
return section.shells.map((entry) => ({
|
|
196
245
|
id: entry.id,
|
|
@@ -206,9 +255,9 @@ window.__ModuleLoader__.load({
|
|
|
206
255
|
}))
|
|
207
256
|
}
|
|
208
257
|
|
|
209
|
-
// 名单数组 →
|
|
258
|
+
// 名单数组 → 编辑态数组(去空白行;加载侧解析)
|
|
210
259
|
function patternText(list) {
|
|
211
|
-
return (Array.isArray(list) ? list : []).
|
|
260
|
+
return (Array.isArray(list) ? list : []).map((rule) => String(rule).trim()).filter((rule) => rule.length > 0)
|
|
212
261
|
}
|
|
213
262
|
|
|
214
263
|
function ShellSelectApp() {
|
|
@@ -217,7 +266,7 @@ window.__ModuleLoader__.load({
|
|
|
217
266
|
const [dirty, setDirty] = useState(false)
|
|
218
267
|
const [notice, setNotice] = useState(null)
|
|
219
268
|
const [busy, setBusy] = useState(false)
|
|
220
|
-
const [
|
|
269
|
+
const [denyRules, setDenyRules] = useState([])
|
|
221
270
|
|
|
222
271
|
useEffect(() => {
|
|
223
272
|
let disposed = false
|
|
@@ -225,7 +274,7 @@ window.__ModuleLoader__.load({
|
|
|
225
274
|
if (disposed) return
|
|
226
275
|
setEntries(toEntries(section))
|
|
227
276
|
setDefaultId(section.default)
|
|
228
|
-
|
|
277
|
+
setDenyRules(patternText(section.deny))
|
|
229
278
|
}).catch((error) => {
|
|
230
279
|
if (!disposed) setNotice('加载失败:' + error.message)
|
|
231
280
|
})
|
|
@@ -251,11 +300,26 @@ window.__ModuleLoader__.load({
|
|
|
251
300
|
if (defaultId === target.id) setDefaultId(entries.length > 1 ? entries[0].id : '')
|
|
252
301
|
}
|
|
253
302
|
|
|
303
|
+
const patchRule = (index, value) => {
|
|
304
|
+
setDirty(true)
|
|
305
|
+
setDenyRules(denyRules.map((rule, at) => (at === index ? value : rule)))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const addRule = () => {
|
|
309
|
+
setDirty(true)
|
|
310
|
+
setDenyRules([...denyRules, ''])
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const removeRule = (index) => {
|
|
314
|
+
setDirty(true)
|
|
315
|
+
setDenyRules(denyRules.filter((_, at) => at !== index))
|
|
316
|
+
}
|
|
317
|
+
|
|
254
318
|
const save = async () => {
|
|
255
319
|
setBusy(true)
|
|
256
320
|
setNotice(null)
|
|
257
321
|
try {
|
|
258
|
-
const section = toSection(entries, defaultId,
|
|
322
|
+
const section = toSection(entries, defaultId, denyRules)
|
|
259
323
|
const ids = section.shells.map((entry) => entry.id)
|
|
260
324
|
if (ids.some((id) => id.length === 0)) throw new Error('存在空 id 条目')
|
|
261
325
|
if (new Set(ids).size !== ids.length) throw new Error('id 重复:' + ids.join(', '))
|
|
@@ -263,16 +327,14 @@ window.__ModuleLoader__.load({
|
|
|
263
327
|
const invalid = entries.map((entry) => ({ entry, lines: invalidEnvLines(entry.envText) }))
|
|
264
328
|
.find(({ lines }) => lines.length > 0)
|
|
265
329
|
if (invalid !== undefined) throw new Error(`环境变量行缺少 =(条目 ${invalid.entry.id || '(未命名)'}):${invalid.lines.join(' ; ')}`)
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (badPatterns.length > 0) {
|
|
270
|
-
throw new Error(`拒绝名单正则非法:${badPatterns[0]}`)
|
|
330
|
+
const issue = denyPatternIssues(section.deny)[0]
|
|
331
|
+
if (issue !== undefined) {
|
|
332
|
+
throw new Error(`命令黑名单第 ${issue.index + 1} 条正则非法:${issue.error}`)
|
|
271
333
|
}
|
|
272
334
|
const payload = await api(API.config, { method: 'POST', body: JSON.stringify(section) })
|
|
273
335
|
setEntries(toEntries({ shells: payload.resolved.shells }))
|
|
274
336
|
setDefaultId(payload.resolved.default)
|
|
275
|
-
|
|
337
|
+
setDenyRules(patternText(payload.resolved.deny))
|
|
276
338
|
setDirty(false)
|
|
277
339
|
setNotice('已保存,工具描述与默认客户端即时生效')
|
|
278
340
|
} catch (error) {
|
|
@@ -427,15 +489,37 @@ window.__ModuleLoader__.load({
|
|
|
427
489
|
}),
|
|
428
490
|
),
|
|
429
491
|
)),
|
|
430
|
-
h('div', { className: 'sls-
|
|
431
|
-
h('
|
|
432
|
-
|
|
433
|
-
className: 'sls-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
492
|
+
h('div', { className: 'sls-rules' },
|
|
493
|
+
h('div', { className: 'sls-rules__head' },
|
|
494
|
+
h('span', { className: 'sls-rules__title' }, '命令黑名单'),
|
|
495
|
+
h('span', { className: 'sls-badge' + (denyRules.length > 0 ? ' sls-badge--ok' : ' sls-badge--bad') },
|
|
496
|
+
denyRules.length > 0 ? denyRules.length + ' 条规则' : '未生效'),
|
|
497
|
+
),
|
|
498
|
+
h('span', { className: 'sls-rules__note' },
|
|
499
|
+
'每条规则为一条正则,对整条命令全文匹配(大小写不敏感),命中即拒,无豁免;精细放行写在模式内用前瞻,如 rm -rf\\s+(?!\\S*node_modules)。'),
|
|
500
|
+
denyRules.length === 0
|
|
501
|
+
? h('span', { className: 'sls-rules__note' }, '未配置,命中拦截不生效,模型可执行任意命令。')
|
|
502
|
+
: denyRules.map((rule, index) => {
|
|
503
|
+
const issue = denyPatternIssues([rule])[0]
|
|
504
|
+
return h('div', { className: 'sls-rule' + (issue !== undefined ? ' sls-rule--bad' : ''), key: index },
|
|
505
|
+
h('span', { className: 'sls-rule__no' }, index + 1),
|
|
506
|
+
h('input', {
|
|
507
|
+
className: 'sls-rule__input',
|
|
508
|
+
value: rule,
|
|
509
|
+
placeholder: '正则,如 ^format\\s',
|
|
510
|
+
onChange: (event) => patchRule(index, event.target.value),
|
|
511
|
+
}),
|
|
512
|
+
issue !== undefined ? h('span', { className: 'sls-rule__err', title: issue.error }, issue.error) : null,
|
|
513
|
+
h('button', {
|
|
514
|
+
className: 'sls-btn sls-rule__del',
|
|
515
|
+
title: '删除该条规则',
|
|
516
|
+
onClick: () => removeRule(index),
|
|
517
|
+
}, '删除'),
|
|
518
|
+
)
|
|
519
|
+
}),
|
|
520
|
+
h('div', { className: 'sls-row' },
|
|
521
|
+
h('button', { className: 'sls-btn', onClick: addRule }, '添加规则'),
|
|
522
|
+
),
|
|
439
523
|
),
|
|
440
524
|
h('div', { className: 'sls-row' },
|
|
441
525
|
h('button', { className: 'sls-btn', disabled: busy, onClick: addEntry }, '添加客户端'),
|
|
@@ -536,27 +620,31 @@ window.__ModuleLoader__.load({
|
|
|
536
620
|
} catch { /* 结构外形态走 generic */ }
|
|
537
621
|
const command = typeof args?.command === 'string' && args.command.trim() !== '' ? args.command : ''
|
|
538
622
|
const description = typeof args?.description === 'string' && args.description.trim() !== '' ? args.description : undefined
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const shellName = typeof args?.shell === 'string' && args.shell !== '' ? args.shell : undefined
|
|
623
|
+
const cwdFull = displayCwd(typeof args?.workdir === 'string' ? args.workdir : undefined, sessionCwd)
|
|
624
|
+
const cwdDir = cwdFull !== undefined ? lastSegment(cwdFull) : undefined
|
|
625
|
+
// shell 名 = 用户在配置页起的条目名;args.shell 缺省时按 default 客户端解析(配置当前读数)
|
|
626
|
+
const shellName = clientDisplayName(typeof args?.shell === 'string' ? args.shell : undefined)
|
|
544
627
|
if (command === '') return { kind: 'generic', command: '', summary: undefined, output: null }
|
|
545
628
|
|
|
546
629
|
if (!settled) {
|
|
547
630
|
// running persistent(进行中无描述):回退行但状态点用进行中灰,非 warning 黄
|
|
548
631
|
if (description === undefined) return { kind: 'generic', command, summary: undefined, output: null, running: true }
|
|
549
|
-
return { kind: 'terminal', status: 'running', command, description, cwdDir, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
|
|
632
|
+
return { kind: 'terminal', status: 'running', command, description, cwdDir, cwdFull, shellName, output: undefined, exitCode: undefined, signal: undefined, code: undefined }
|
|
550
633
|
}
|
|
551
634
|
|
|
552
635
|
const contentText = (block.content ?? []).map((part) => (part.type === 'text' ? part.text : '')).filter((text) => text !== '').join('\n')
|
|
636
|
+
// 后台 ack:独立卡呈现命令全文与任务号徽标,ack 原文作输出(不再落 generic 简版行)
|
|
637
|
+
if (args.run_in_background === true) {
|
|
638
|
+
const jobId = /started background job (\S+)/.exec(contentText)?.[1]
|
|
639
|
+
return { kind: 'background', status: 'background', command, description, cwdDir, cwdFull, shellName, output: contentText, jobId }
|
|
640
|
+
}
|
|
553
641
|
// 空结果落 generic:官方 singleResultText 无文本即回通用卡,避免空输出伪 done 终端卡
|
|
554
|
-
if (contentText === '' || block.isError === true || block.error !== undefined ||
|
|
642
|
+
if (contentText === '' || block.isError === true || block.error !== undefined || description === undefined || hasSpillNotice(contentText)) {
|
|
555
643
|
return { kind: 'generic', command, summary: description, output: contentText }
|
|
556
644
|
}
|
|
557
645
|
const tail = parseExitTail(contentText)
|
|
558
646
|
const status = tail.signal !== undefined ? 'signaled' : tail.exitCode !== 0 ? 'failed' : 'done'
|
|
559
|
-
return { kind: 'terminal', status, command, description, cwdDir, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
|
|
647
|
+
return { kind: 'terminal', status, command, description, cwdDir, cwdFull, shellName, output: tail.output, exitCode: tail.exitCode, signal: tail.signal, code: undefined }
|
|
560
648
|
}
|
|
561
649
|
// LOGIC-END shellCardModel
|
|
562
650
|
|
|
@@ -571,6 +659,7 @@ window.__ModuleLoader__.load({
|
|
|
571
659
|
function statusTextOf(status, en) {
|
|
572
660
|
switch (status) {
|
|
573
661
|
case 'running': return en ? 'Running' : '运行中'
|
|
662
|
+
case 'background': return en ? 'Background' : '后台运行'
|
|
574
663
|
case 'failed': case 'signaled': return en ? 'Failed' : '失败'
|
|
575
664
|
default: return null
|
|
576
665
|
}
|
|
@@ -579,6 +668,12 @@ window.__ModuleLoader__.load({
|
|
|
579
668
|
function headMetaOf(model, en) {
|
|
580
669
|
switch (model.status) {
|
|
581
670
|
case 'running': return { dot: 'ongoing', label: en ? 'Running' : '运行中', pill: undefined }
|
|
671
|
+
case 'background': return {
|
|
672
|
+
dot: 'ongoing',
|
|
673
|
+
label: en ? 'Background' : '后台运行',
|
|
674
|
+
pill: model.jobId !== undefined ? (en ? `bg ${model.jobId}` : `后台 ${model.jobId}`) : undefined,
|
|
675
|
+
pillTone: 'bg',
|
|
676
|
+
}
|
|
582
677
|
case 'done': return { dot: 'done', label: en ? 'Done' : '已完成', pill: undefined }
|
|
583
678
|
case 'failed': return { dot: 'error', label: en ? 'Failed' : '失败', pill: en ? `exit ${model.exitCode}` : `退出码 ${model.exitCode}` }
|
|
584
679
|
case 'signaled': return { dot: 'error', label: en ? 'Failed' : '失败', pill: en ? `signal ${model.signal}` : `信号 ${model.signal}` }
|
|
@@ -659,10 +754,10 @@ window.__ModuleLoader__.load({
|
|
|
659
754
|
TOOLVIEW_ICONS.StateDot({ state: meta.dot }),
|
|
660
755
|
h('span', { className: 'sls-tv__sr' }, meta.label),
|
|
661
756
|
),
|
|
662
|
-
model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd' }, model.cwdDir) : null,
|
|
663
|
-
model.shellName !== undefined ? h('span', { className: 'sls-tv__badge' }, model.shellName) : null,
|
|
757
|
+
model.cwdDir !== undefined ? h('span', { className: 'sls-tv__cwd', title: 'pwd: ' + (model.cwdFull ?? model.cwdDir) }, model.cwdDir) : null,
|
|
758
|
+
model.shellName !== undefined ? h('span', { className: 'sls-tv__badge', title: en ? 'Shell client' : 'Shell 客户端' }, model.shellName) : null,
|
|
664
759
|
h('span', { className: 'sls-tv__sp' }),
|
|
665
|
-
meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' }, meta.pill) : null,
|
|
760
|
+
meta.pill !== undefined ? h('span', { className: 'sls-tv__pill' + (meta.pillTone === 'bg' ? ' sls-tv__pill--bg' : '') }, meta.pill) : null,
|
|
666
761
|
h('span', { className: 'sls-tv__copy' },
|
|
667
762
|
h(CopyButton, {
|
|
668
763
|
label: copyCommandLabel,
|
|
@@ -687,8 +782,15 @@ window.__ModuleLoader__.load({
|
|
|
687
782
|
function ShellToolRow(props) {
|
|
688
783
|
const { block, cwd, inspect } = props
|
|
689
784
|
const en = detectEnglish()
|
|
690
|
-
const [open, setOpen] = useState(false)
|
|
691
785
|
const model = shellCardModel(block, cwd)
|
|
786
|
+
// 后台 ack 卡默认展开:内容即命令全文,收起只剩一行摘要失去可读性
|
|
787
|
+
const [open, setOpen] = useState(model.kind === 'background')
|
|
788
|
+
const [, setCatalogReady] = useState(false)
|
|
789
|
+
useEffect(() => {
|
|
790
|
+
let disposed = false
|
|
791
|
+
ensureClientCatalog().then(() => { if (!disposed) setCatalogReady(true) })
|
|
792
|
+
return () => { disposed = true }
|
|
793
|
+
}, [])
|
|
692
794
|
if (model.kind === 'generic') return h(GenericShellRow, { model, inspect, en })
|
|
693
795
|
const expandable = true
|
|
694
796
|
const srStatus = statusTextOf(model.status, en)
|
package/src/executor.mjs
CHANGED
|
@@ -83,8 +83,10 @@ function assertPositiveFinite(name, value) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// yaml 无引号标量会把 `\` 字面落盘,任何一层再转义都让路径翻倍(C:\\ 实测):
|
|
86
|
-
// 入口统一归一,保证进 schema 的 path
|
|
86
|
+
// 入口统一归一,保证进 schema 的 path 就是干净值。
|
|
87
|
+
// 仅 Windows 宿主生效:posix 路径以 / 分隔,归一会把合法路径毁成反斜杠字面(CI linux 实测)
|
|
87
88
|
function normalizeWin32Path(path) {
|
|
89
|
+
if (process.platform !== 'win32') return path
|
|
88
90
|
if (typeof path !== 'string' || path.length === 0) return path
|
|
89
91
|
return path.replace(/\\{2,}/g, '\\').replace(/\//g, '\\')
|
|
90
92
|
}
|
|
@@ -18,10 +18,16 @@ function extractLogic(name, deps = {}) {
|
|
|
18
18
|
return new Function(...keys, 'return (' + match[1].trim() + ')')(...keys.map((key) => deps[key]))
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function cardModel() {
|
|
21
|
+
function cardModel(catalog = null) {
|
|
22
22
|
const lastSegment = extractLogic('lastSegment')
|
|
23
23
|
const displayCwd = extractLogic('displayCwd', { lastSegment })
|
|
24
|
-
return extractLogic('shellCardModel', {
|
|
24
|
+
return extractLogic('shellCardModel', {
|
|
25
|
+
displayCwd,
|
|
26
|
+
lastSegment,
|
|
27
|
+
parseExitTail: extractLogic('parseExitTail'),
|
|
28
|
+
hasSpillNotice: extractLogic('hasSpillNotice'),
|
|
29
|
+
clientDisplayName: extractLogic('clientDisplayName', { clientCatalog: catalog }),
|
|
30
|
+
})
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
function parseEnvText() {
|
|
@@ -77,14 +83,63 @@ test('S12b running 完整参数:terminal running 卡', () => {
|
|
|
77
83
|
assert.equal(model.status, 'running')
|
|
78
84
|
})
|
|
79
85
|
|
|
80
|
-
test('
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
SESSION_CWD
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
+
test('S15 shell 徽章命名:显式参数按 id 取用户命名,缺省落 default 客户端命名', () => {
|
|
87
|
+
const catalog = { default: 'git-bash', byId: { 'git-bash': 'Git Bash', pwsh: 'PowerShell' } }
|
|
88
|
+
const explicit = cardModel(catalog)(
|
|
89
|
+
settledBlock(JSON.stringify({ command: 'ls', description: 'list', shell: 'pwsh' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
90
|
+
assert.equal(explicit.shellName, 'PowerShell')
|
|
91
|
+
const fallback = cardModel(catalog)(
|
|
92
|
+
settledBlock(ARGS, 'out\n[exit code: 0]'), SESSION_CWD)
|
|
93
|
+
assert.equal(fallback.shellName, 'Git Bash')
|
|
94
|
+
const unknown = cardModel(catalog)(
|
|
95
|
+
settledBlock(JSON.stringify({ command: 'ls', description: 'list', shell: 'ghost' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
96
|
+
assert.equal(unknown.shellName, undefined)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('S15b cwd 悬浮全路径:model 携带 cwdFull', () => {
|
|
100
|
+
const model = cardModel()(
|
|
101
|
+
settledBlock(JSON.stringify({ command: 'git status', description: 'Show working tree status', workdir: 'sub' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
102
|
+
assert.equal(model.cwdDir, 'sub')
|
|
103
|
+
assert.equal(model.cwdFull, 'C:\\repo\\sub')
|
|
104
|
+
const noWorkdir = cardModel()(
|
|
105
|
+
settledBlock(JSON.stringify({ command: 'git status', description: 'Show working tree status' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
106
|
+
assert.equal(noWorkdir.cwdFull, SESSION_CWD)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('S16 cwd 悬浮说明:可见文本为 basename,title 为 pwd: + 全路径(渲染守卫)', () => {
|
|
110
|
+
// BDD:Given shell 调用带 workdir,When 悬浮 cwd 元数据,Then 可见文本为 basename,title 为 'pwd: <全路径>' 单段说明
|
|
111
|
+
assert.match(source, /title: 'pwd: ' \+ \(model\.cwdFull \?\? model\.cwdDir\)/)
|
|
112
|
+
assert.match(source, /\}, model\.cwdDir\)/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('S17 后台 ack:派生 background 卡,命令在场且解析出 jobId', () => {
|
|
116
|
+
// BDD:Given run_in_background 调用已定,When 派生卡片模型,Then kind=background,jobId 取自 ack 标记,命令与 ack 原文保留
|
|
117
|
+
const model = cardModel()(settledBlock(
|
|
118
|
+
JSON.stringify({ command: 'node server.js', description: 'Boot server', run_in_background: true }),
|
|
119
|
+
'started background job shell-11'), SESSION_CWD)
|
|
120
|
+
assert.equal(model.kind, 'background')
|
|
121
|
+
assert.equal(model.jobId, 'shell-11')
|
|
122
|
+
assert.equal(model.command, 'node server.js')
|
|
123
|
+
assert.equal(model.output, 'started background job shell-11')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('S17b 后台 ack 异常文本:仍 background,jobId 缺省不冒充', () => {
|
|
127
|
+
const model = cardModel()(settledBlock(
|
|
128
|
+
JSON.stringify({ command: 'x', description: 'd', run_in_background: true }), 'other'), SESSION_CWD)
|
|
129
|
+
assert.equal(model.kind, 'background')
|
|
130
|
+
assert.equal(model.jobId, undefined)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('S17c 后台卡渲染面:状态文案与中性任务号徽标(渲染守卫)', () => {
|
|
134
|
+
assert.match(source, /case 'background':/)
|
|
135
|
+
assert.match(source, /sls-tv__pill--bg/)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('S12c isError 与空结果仍走 generic(回归)', () => {
|
|
86
139
|
const errored = cardModel()(settledBlock(ARGS, 'boom', { isError: true }), SESSION_CWD)
|
|
87
140
|
assert.equal(errored.kind, 'generic')
|
|
141
|
+
const empty = cardModel()(settledBlock(ARGS, ''), SESSION_CWD)
|
|
142
|
+
assert.equal(empty.kind, 'generic')
|
|
88
143
|
})
|
|
89
144
|
|
|
90
145
|
test('S13 注册面:shell/pwsh/bash 三 key 且 priority -1(文本守卫)', () => {
|
package/test/client-id.test.mjs
CHANGED
|
@@ -58,11 +58,40 @@ test('扩展字段守卫:login/distro/env 进设置页数据链(wholesale replac
|
|
|
58
58
|
assert.match(source, /发行版/)
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
-
test('deny
|
|
62
|
-
|
|
63
|
-
assert.match(source, /
|
|
64
|
-
assert.match(source,
|
|
65
|
-
assert.match(source,
|
|
66
|
-
|
|
67
|
-
assert.
|
|
61
|
+
test('deny 黑名单进设置页数据链:逐条规则编辑器(deny 绝对,allow 已移除)', () => {
|
|
62
|
+
// 数据链:state 数组 → 保存直落 deny;加载侧 patternText 按条解析(Trim 去空白)
|
|
63
|
+
assert.match(source, /denyRules/)
|
|
64
|
+
assert.match(source, /deny: denyRules/)
|
|
65
|
+
assert.match(source, /patternText\(section\.deny\)/)
|
|
66
|
+
// 行级即时校验 LOGIC 段 + 保存侧复用
|
|
67
|
+
assert.match(source, /denyPatternIssues/)
|
|
68
|
+
assert.match(source, /LOGIC-BEGIN denyPatternIssues/)
|
|
69
|
+
// 交互结构:规则行 + 删除 + 添加 + 空态提示
|
|
70
|
+
assert.match(source, /sls-rule/)
|
|
71
|
+
assert.match(source, /添加规则/)
|
|
72
|
+
assert.match(source, /未配置.*命中拦截不生效/)
|
|
73
|
+
// 术语与僵尸防线
|
|
74
|
+
assert.match(source, /命令黑名单/)
|
|
75
|
+
assert.doesNotMatch(source, /denyText/)
|
|
76
|
+
assert.doesNotMatch(source, /allowText|豁免名单|拒绝名单/)
|
|
68
77
|
})
|
|
78
|
+
|
|
79
|
+
// LOGIC 段行为测试:行级正则校验(提取形态照 client-card.test.mjs)
|
|
80
|
+
function extractLogic(name) {
|
|
81
|
+
const pattern = new RegExp('// LOGIC-BEGIN ' + name + '\\n([\\s\\S]*?)\\n\\s*// LOGIC-END ' + name)
|
|
82
|
+
const match = source.match(pattern)
|
|
83
|
+
if (!match) throw new Error('client.js 缺少 LOGIC 段: ' + name)
|
|
84
|
+
return new Function('return (' + match[1].trim() + ')')()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test('denyPatternIssues:合法与空规则无问题', () => {
|
|
88
|
+
const issues = extractLogic('denyPatternIssues')(['^format\\s', 'rm -rf\\s+(?!\\S*node_modules)', ''])
|
|
89
|
+
assert.deepEqual(issues, [])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('denyPatternIssues:非法规则定位到行(行号 = 规则序)', () => {
|
|
93
|
+
const issues = extractLogic('denyPatternIssues')(['^format\\s', 'rm -rf\\s+(?!', 'shutdown'])
|
|
94
|
+
assert.equal(issues.length, 1)
|
|
95
|
+
assert.equal(issues[0].index, 1)
|
|
96
|
+
assert.match(issues[0].error, /Invalid|regular|正则/)
|
|
97
|
+
})
|
|
@@ -31,7 +31,7 @@ test('S10 args 模板条目接管 argv,distro 不出现', () => {
|
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
test('S6 entryFor 透传 env 与 distro;spawnSpec env 含条目键', () => {
|
|
34
|
-
const cmdPath =
|
|
34
|
+
const cmdPath = process.execPath
|
|
35
35
|
const config = Config({
|
|
36
36
|
shells: [{ id: 'c', name: 'CMD', kind: 'cmd', path: cmdPath, env: { MSYSTEM: 'MINGW64' } }, { id: 'p', name: 'pwsh', kind: 'pwsh' }],
|
|
37
37
|
default: 'c',
|
|
@@ -79,10 +79,25 @@ function shadow(executor) {
|
|
|
79
79
|
return Object.create(executor)
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
|
|
83
|
+
// 跨平台显式 path:node 自身在所有平台存在,注入后 entryFor/listShells 不依赖真实 shell
|
|
84
|
+
// (CI linux 无 pwsh/cmd/git-bash,出厂 auto 探测必失败)
|
|
85
|
+
const NODE_EXE = process.execPath
|
|
86
|
+
function baseConfig() {
|
|
87
|
+
return {
|
|
88
|
+
shells: [
|
|
89
|
+
{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: NODE_EXE },
|
|
90
|
+
{ id: 'git-bash', name: 'Git Bash', kind: 'bash', path: NODE_EXE },
|
|
91
|
+
{ id: 'cmd', name: 'CMD', kind: 'cmd', path: NODE_EXE },
|
|
92
|
+
],
|
|
93
|
+
default: 'pwsh',
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
test('官方 run seam:存在且走默认客户端,spec 已解析形态(danger 直跑)', async () => {
|
|
83
98
|
const spawn = stubSpawn('seam-out', '')
|
|
84
99
|
const { ctx } = stubCtx({ spawn })
|
|
85
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
100
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
86
101
|
assert.equal(typeof executor.run, 'function')
|
|
87
102
|
const result = await executor.run(executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
|
|
88
103
|
assert.equal(spawn.calls.length, 1)
|
|
@@ -94,7 +109,7 @@ test('官方 run seam:存在且走默认客户端,spec 已解析形态(danger
|
|
|
94
109
|
test('官方 start seam:存在且返回 ShellProcess 形态句柄', async () => {
|
|
95
110
|
const spawn = stubSpawn('bg', '')
|
|
96
111
|
const { ctx } = stubCtx({ spawn })
|
|
97
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
112
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
98
113
|
assert.equal(typeof executor.start, 'function')
|
|
99
114
|
const proc = executor.start(executor.resolve({ command: 'sleep 1', workdir: process.cwd() }))
|
|
100
115
|
assert.equal(proc.status, 'running')
|
|
@@ -104,7 +119,7 @@ test('官方 start seam:存在且返回 ShellProcess 形态句柄', async () =>
|
|
|
104
119
|
|
|
105
120
|
test('cordis 阴影 receiver:公开面调用不触发私有品牌错误', () => {
|
|
106
121
|
const { ctx } = stubCtx()
|
|
107
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
122
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
108
123
|
const mirrored = shadow(executor)
|
|
109
124
|
// 官方 tool-pwsh 的调用面:resolve/run/start + apply 期 sandboxMode
|
|
110
125
|
assert.doesNotThrow(() => mirrored.resolve({ command: 'x' }))
|
|
@@ -119,7 +134,7 @@ test('cordis 阴影 receiver:公开面调用不触发私有品牌错误', () =>
|
|
|
119
134
|
test('cordis 阴影 receiver:受限模式 run 全链路(confine + 结果分类)', async () => {
|
|
120
135
|
const spawn = stubSpawn('', 'file access denied here', 1)
|
|
121
136
|
const { ctx } = stubCtx({ spawn, sandboxMode: 'read-only' })
|
|
122
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
137
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
123
138
|
const mirrored = shadow(executor)
|
|
124
139
|
const result = await mirrored.run(mirrored.resolve({ command: 'dir', workdir: process.cwd() }))
|
|
125
140
|
assert.equal(spawn.calls[0].argv[0], 'WRAPPED')
|
|
@@ -129,8 +144,8 @@ test('cordis 阴影 receiver:受限模式 run 全链路(confine + 结果分类)'
|
|
|
129
144
|
|
|
130
145
|
test('sandboxMode:透出部署默认模式,策略缺席时 undefined', () => {
|
|
131
146
|
const { ctx } = stubCtx({ sandboxMode: 'workspace-write' })
|
|
132
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
147
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
133
148
|
assert.equal(executor.sandboxMode, 'workspace-write')
|
|
134
|
-
const bare = new ShellSelectExecutor({ ...ctx, sandboxPolicy: undefined },
|
|
149
|
+
const bare = new ShellSelectExecutor({ ...ctx, sandboxPolicy: undefined }, baseConfig())
|
|
135
150
|
assert.equal(bare.sandboxMode, undefined)
|
|
136
151
|
})
|
package/test/executor.test.mjs
CHANGED
|
@@ -88,9 +88,23 @@ function stubCtx({ withSettings = true, sandboxMode = 'danger-full-access', spaw
|
|
|
88
88
|
return { ctx, registered, disposers }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// 跨平台显式 path:node 自身在所有平台存在,注入后 entryFor/listShells 不依赖真实 shell
|
|
92
|
+
// (CI linux 无 pwsh/cmd/git-bash,出厂 auto 探测必失败)
|
|
93
|
+
const NODE_EXE = process.execPath
|
|
94
|
+
function baseConfig() {
|
|
95
|
+
return {
|
|
96
|
+
shells: [
|
|
97
|
+
{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: NODE_EXE },
|
|
98
|
+
{ id: 'git-bash', name: 'Git Bash', kind: 'bash', path: NODE_EXE },
|
|
99
|
+
{ id: 'cmd', name: 'CMD', kind: 'cmd', path: NODE_EXE },
|
|
100
|
+
],
|
|
101
|
+
default: 'pwsh',
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
function build() {
|
|
92
106
|
const { ctx, registered } = stubCtx()
|
|
93
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
107
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
94
108
|
return { executor, registered }
|
|
95
109
|
}
|
|
96
110
|
|
|
@@ -111,7 +125,7 @@ test('构造:注册 shell 工具、systemPrompt 段、shell-select 设置节、
|
|
|
111
125
|
|
|
112
126
|
test('settings 缺席:降级不 installSection,工具照常注册', () => {
|
|
113
127
|
const { ctx, registered } = stubCtx({ withSettings: false })
|
|
114
|
-
new ShellSelectExecutor(ctx,
|
|
128
|
+
new ShellSelectExecutor(ctx, baseConfig())
|
|
115
129
|
assert.equal(registered.sections.length, 0)
|
|
116
130
|
assert.equal(registered.tools.length, 1)
|
|
117
131
|
})
|
|
@@ -122,7 +136,7 @@ test('listShells:出厂条目自动解析为真实路径(pwsh/cmd 必在本机)'
|
|
|
122
136
|
assert.equal(listing.default, 'pwsh')
|
|
123
137
|
const pwsh = listing.shells.find((entry) => entry.id === 'pwsh')
|
|
124
138
|
assert.equal(pwsh.available, true)
|
|
125
|
-
assert.
|
|
139
|
+
assert.equal(pwsh.path, NODE_EXE)
|
|
126
140
|
})
|
|
127
141
|
|
|
128
142
|
test('entryFor:缺省落 default;未知 id 报可用清单;坏路径条目报配置指引', () => {
|
|
@@ -148,7 +162,7 @@ test('resolve:预算字段生效,策略缺省落部署策略', () => {
|
|
|
148
162
|
test('runFor danger 直跑:argv 按条目组装,不经 confine,stdout/stderr 回传', async () => {
|
|
149
163
|
const spawn = stubSpawn('out-text', 'err-text')
|
|
150
164
|
const { ctx } = stubCtx({ spawn })
|
|
151
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
165
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
152
166
|
const entry = executor.entryFor('git-bash')
|
|
153
167
|
const result = await executor.runFor(entry, executor.resolve({ command: 'ls -la', workdir: process.cwd() }))
|
|
154
168
|
assert.equal(spawn.calls.length, 1)
|
|
@@ -161,7 +175,7 @@ test('runFor danger 直跑:argv 按条目组装,不经 confine,stdout/stderr 回
|
|
|
161
175
|
test('deny 拦截:runFor 命中 deny 抛 DenyError 不 spawn', async () => {
|
|
162
176
|
const spawn = stubSpawn('ok', '')
|
|
163
177
|
const { ctx } = stubCtx({ spawn })
|
|
164
|
-
const executor = new ShellSelectExecutor(ctx, { deny: ['format '] })
|
|
178
|
+
const executor = new ShellSelectExecutor(ctx, { ...baseConfig(), deny: ['format '] })
|
|
165
179
|
const entry = executor.entryFor('git-bash')
|
|
166
180
|
await assert.rejects(
|
|
167
181
|
() => executor.runFor(entry, executor.resolve({ command: 'format c: /q', workdir: process.cwd() })),
|
|
@@ -174,7 +188,7 @@ test('deny 拦截:runFor 命中 deny 抛 DenyError 不 spawn', async () => {
|
|
|
174
188
|
test('deny 拦截:startFor 同样拒绝(后台入口)', () => {
|
|
175
189
|
const spawn = stubSpawn('', '', 0)
|
|
176
190
|
const { ctx } = stubCtx({ spawn })
|
|
177
|
-
const executor = new ShellSelectExecutor(ctx, { deny: ['shutdown'] })
|
|
191
|
+
const executor = new ShellSelectExecutor(ctx, { ...baseConfig(), deny: ['shutdown'] })
|
|
178
192
|
const entry = executor.entryFor('cmd')
|
|
179
193
|
assert.throws(
|
|
180
194
|
() => executor.startFor(entry, executor.resolve({ command: 'shutdown /r', workdir: process.cwd() })),
|
|
@@ -186,7 +200,7 @@ test('deny 拦截:startFor 同样拒绝(后台入口)', () => {
|
|
|
186
200
|
test('runFor 受限模式:经 confine 包装 argv,结果分类', async () => {
|
|
187
201
|
const spawn = stubSpawn('', 'some file access denied happened', 1)
|
|
188
202
|
const { ctx } = stubCtx({ spawn, sandboxMode: 'read-only' })
|
|
189
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
203
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
190
204
|
const entry = executor.entryFor('cmd')
|
|
191
205
|
const result = await executor.runFor(entry, executor.resolve({ command: 'dir', workdir: process.cwd() }))
|
|
192
206
|
assert.equal(spawn.calls[0].argv[0], 'WRAPPED')
|
|
@@ -199,7 +213,7 @@ test('runFor 受限模式:经 confine 包装 argv,结果分类', async () => {
|
|
|
199
213
|
test('runFor pwsh 条目:非交互形 + 编码前缀进 argv', async () => {
|
|
200
214
|
const spawn = stubSpawn()
|
|
201
215
|
const { ctx } = stubCtx({ spawn })
|
|
202
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
216
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
203
217
|
const entry = executor.entryFor('pwsh')
|
|
204
218
|
await executor.runFor(entry, executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
|
|
205
219
|
const argv = spawn.calls[0].argv
|
|
@@ -232,7 +246,7 @@ test('updateConfig:await settings.update 后读数即新值(setSource 接线)',
|
|
|
232
246
|
scope.value = Config(section)
|
|
233
247
|
registered.sectionsHook.onChange()
|
|
234
248
|
}
|
|
235
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
249
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
236
250
|
assert.equal(executor.config.default, 'pwsh')
|
|
237
251
|
await executor.updateConfig({ default: 'git-bash' })
|
|
238
252
|
assert.equal(executor.config.default, 'git-bash')
|
|
@@ -245,7 +259,7 @@ test('updateConfig:部分补丁归并当前节后 replace(单改 default 不丢
|
|
|
245
259
|
ctx.settings.replace = async (ns, section) => {
|
|
246
260
|
written.push(section)
|
|
247
261
|
}
|
|
248
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
262
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
249
263
|
await executor.updateConfig({ default: 'git-bash' })
|
|
250
264
|
assert.equal(written[0].shells.length, 3)
|
|
251
265
|
assert.equal(written[0].default, 'git-bash')
|
|
@@ -265,7 +279,7 @@ test('api POST config:await updateConfig,resolved 即新清单(async 修复)', a
|
|
|
265
279
|
registered.sections[0].hooks.setSource(() => section)
|
|
266
280
|
registered.sections[0].hooks.onChange()
|
|
267
281
|
}
|
|
268
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
282
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
269
283
|
const handler = ctx.__routes.find((r) => r.path === '/api/shell-select/config').handler
|
|
270
284
|
let captured = null
|
|
271
285
|
const fakeRes = {
|
|
@@ -291,12 +305,14 @@ test('api POST config:await updateConfig,resolved 即新清单(async 修复)', a
|
|
|
291
305
|
})
|
|
292
306
|
|
|
293
307
|
test('updateConfig:入参 path 反斜杠归一(防 yaml 无引号落盘字面翻倍)', async () => {
|
|
308
|
+
// 归一是 Windows 宿主行为(linux 无反斜杠翻倍怪癖,源码层按平台守卫)
|
|
309
|
+
if (process.platform !== 'win32') return
|
|
294
310
|
const { ctx, registered } = stubCtx()
|
|
295
311
|
const written = []
|
|
296
312
|
ctx.settings.replace = async (ns, section) => {
|
|
297
313
|
written.push(section)
|
|
298
314
|
}
|
|
299
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
315
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
300
316
|
await executor.updateConfig({
|
|
301
317
|
shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: 'C:\\\\WINDOWS\\\\System32\\\\powershell.exe' }],
|
|
302
318
|
})
|
|
@@ -310,6 +326,7 @@ test('entryFor:双反斜杠污染路径被归一后仍可用', () => {
|
|
|
310
326
|
shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: 'C:\\\\WINDOWS\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe' }],
|
|
311
327
|
default: 'pwsh',
|
|
312
328
|
})
|
|
329
|
+
if (process.platform !== 'win32') return
|
|
313
330
|
const entry = polluted.entryFor(undefined)
|
|
314
331
|
assert.equal(entry.path, 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')
|
|
315
332
|
})
|
|
@@ -318,29 +335,29 @@ test('entryFor:login 布尔透传,argv 产出 -lc(配置→执行全链)', async
|
|
|
318
335
|
const ctx2 = stubCtx()
|
|
319
336
|
const executor = new ShellSelectExecutor(ctx2.ctx, {
|
|
320
337
|
shells: [
|
|
321
|
-
{ id: 'm', name: 'MSYS2', kind: 'bash', path:
|
|
322
|
-
{ id: 'g', name: 'Git Bash', kind: 'bash', path:
|
|
338
|
+
{ id: 'm', name: 'MSYS2', kind: 'bash', path: NODE_EXE, login: true },
|
|
339
|
+
{ id: 'g', name: 'Git Bash', kind: 'bash', path: NODE_EXE },
|
|
323
340
|
],
|
|
324
341
|
default: 'm',
|
|
325
342
|
})
|
|
326
343
|
const loginEntry = executor.entryFor('m')
|
|
327
344
|
assert.equal(loginEntry.login, true)
|
|
328
|
-
assert.deepEqual(executor.argvFor(loginEntry, { command: 'uname -a' }).slice(0, 2), [
|
|
345
|
+
assert.deepEqual(executor.argvFor(loginEntry, { command: 'uname -a' }).slice(0, 2), [NODE_EXE, '-lc'])
|
|
329
346
|
const plainEntry = executor.entryFor('g')
|
|
330
347
|
assert.equal(plainEntry.login, false)
|
|
331
|
-
assert.deepEqual(executor.argvFor(plainEntry, { command: 'uname -a' }).slice(0, 2), [
|
|
348
|
+
assert.deepEqual(executor.argvFor(plainEntry, { command: 'uname -a' }).slice(0, 2), [NODE_EXE, '-c'])
|
|
332
349
|
})
|
|
333
350
|
|
|
334
351
|
function stubCtxAndBuild() {
|
|
335
352
|
const { ctx, registered } = stubCtx()
|
|
336
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
353
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
337
354
|
return { executor, registered }
|
|
338
355
|
}
|
|
339
356
|
|
|
340
357
|
test('startFor danger:返回进程句柄,readOutput 增量与 done 定局', async () => {
|
|
341
358
|
const spawn = stubSpawn('bg-out', '')
|
|
342
359
|
const { ctx } = stubCtx({ spawn })
|
|
343
|
-
const executor = new ShellSelectExecutor(ctx,
|
|
360
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
344
361
|
const proc = executor.startFor(executor.entryFor('pwsh'), executor.resolve({ command: 'sleep 1', workdir: process.cwd() }))
|
|
345
362
|
assert.equal(proc.status, 'running')
|
|
346
363
|
const first = proc.readOutput()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// preset 接管守卫:本包自带预设(standard 副本 − tool-pwsh 行)与 bundle patch
|
|
2
|
+
// 的 agent-presets 覆写形态。漂移守卫对照已安装 dsh 的内置 standard 预设——
|
|
3
|
+
// 上游增删行/改表达式即红,升级后按官方实文对表镜像;宿主缺失(无 dsh 本体的
|
|
4
|
+
// 最小环境)时 skip,不制造假红。
|
|
5
|
+
|
|
6
|
+
import { test } from 'node:test'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import { readFileSync, existsSync, mkdirSync, symlinkSync, rmSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { execPath } from 'node:process'
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const pkgRoot = join(here, '..')
|
|
17
|
+
const presetText = readFileSync(join(pkgRoot, 'presets', 'shell-select', 'agent.cordis.yml'), 'utf8')
|
|
18
|
+
const metaText = readFileSync(join(pkgRoot, 'presets', 'shell-select', 'preset.yml'), 'utf8')
|
|
19
|
+
const patchText = readFileSync(join(pkgRoot, 'cordis.patch.yml'), 'utf8')
|
|
20
|
+
|
|
21
|
+
// ── 宿主侧 yaml 能力探测:js-yaml 与官方 entryListSchema 经 dsh 安装位解析 ──
|
|
22
|
+
|
|
23
|
+
function locateHarnessDir() {
|
|
24
|
+
// 候选锚:运行中 node 的全局安装位(nvm/全局布局:execPath 目录/node_modules)→
|
|
25
|
+
// 仓库 compat 隔离宿主(.dsh-versions/<v>/package)。缺失即返回 null(skip)。
|
|
26
|
+
const candidates = [
|
|
27
|
+
resolve(dirname(execPath), 'node_modules', '@deepseek-ai', 'dsh'),
|
|
28
|
+
]
|
|
29
|
+
const versionsDir = resolve(pkgRoot, '..', '..', '.dsh-versions')
|
|
30
|
+
try {
|
|
31
|
+
for (const version of readdirSync(versionsDir)) {
|
|
32
|
+
candidates.push(join(versionsDir, version, 'package'))
|
|
33
|
+
}
|
|
34
|
+
} catch { /* 无 .dsh-versions,忽略 */ }
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// yaml 能力与宿主解耦:js-yaml 为 devDep(CI 无宿主也可解析本包文件)。
|
|
42
|
+
// !!js 标签本地同形 Type:tag 必须写完整 URI(tag:yaml.org,2002:js)——文档简写
|
|
43
|
+
// !!js 展开后按完整 URI 匹配,字面 '!!js' 永不命中(unknown tag,CI 假绿教训)。
|
|
44
|
+
// 形态场景恒走 LOCAL_SCHEMA(本机/CI 同路径);漂移守卫在场时改用官方
|
|
45
|
+
// entryListSchema 求同(对照面只在有宿主时跑)
|
|
46
|
+
import yamlStatic from 'js-yaml'
|
|
47
|
+
|
|
48
|
+
const LOCAL_SCHEMA = yamlStatic.JSON_SCHEMA.extend(new yamlStatic.Type('tag:yaml.org,2002:js', {
|
|
49
|
+
kind: 'scalar',
|
|
50
|
+
resolve: (data) => typeof data === 'string',
|
|
51
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
52
|
+
represent: (data) => data['__jsExpr'],
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
let harness = null
|
|
56
|
+
let entryListSchema = LOCAL_SCHEMA
|
|
57
|
+
try {
|
|
58
|
+
harness = locateHarnessDir()
|
|
59
|
+
if (harness !== null) {
|
|
60
|
+
const req = createRequire(join(harness, 'package.json'))
|
|
61
|
+
const toUrl = (specifier) => pathToFileURL(req.resolve(specifier)).href
|
|
62
|
+
entryListSchema = (await import(toUrl('@deepseek-ai/cordis-plugin-include'))).entryListSchema
|
|
63
|
+
}
|
|
64
|
+
} catch { /* 官方 schema 缺失按本地同形标签处理 */ }
|
|
65
|
+
|
|
66
|
+
const loadYaml = (text) => yamlStatic.load(text, { schema: entryListSchema })
|
|
67
|
+
|
|
68
|
+
// 镜像 loader 的表达式求值形态(new Function + with(ctx),全局可用)
|
|
69
|
+
const evalExpr = (expr, ctx) => new Function('ctx', `with (ctx) { return (${expr}) }`)(ctx)
|
|
70
|
+
|
|
71
|
+
// ── 结构对比:展平行树为 id → {name, disabled, config 键集} ──
|
|
72
|
+
|
|
73
|
+
function flattenRows(rows, out = new Map()) {
|
|
74
|
+
for (const row of rows ?? []) {
|
|
75
|
+
if (row.id !== undefined) {
|
|
76
|
+
out.set(row.id, {
|
|
77
|
+
name: row.name,
|
|
78
|
+
disabled: row.disabled === undefined ? undefined : row.disabled.__jsExpr ?? row.disabled,
|
|
79
|
+
configKeys: row.config && !Array.isArray(row.config) ? Object.keys(row.config).sort() : undefined,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
if (row.group && Array.isArray(row.config)) flattenRows(row.config, out)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── 场景 ──
|
|
88
|
+
|
|
89
|
+
test('预设形态:preset.yml 元数据在场', () => {
|
|
90
|
+
const meta = metaText.trim().split('\n').map((line) => line.split(/:(.*)/s)[0])
|
|
91
|
+
assert.ok(meta.includes('name'), 'preset.yml 缺 name')
|
|
92
|
+
assert.ok(metaText.includes('description:'), 'preset.yml 缺 description')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('预设形态:agent.cordis.yml 可解析且无 tool-pwsh 行', () => {
|
|
96
|
+
// 形态断言恒走本地同形 schema:本机与 CI 同一路径,官方 schema 在场与否不影响本场景
|
|
97
|
+
const rows = yamlStatic.load(presetText, { schema: LOCAL_SCHEMA })
|
|
98
|
+
assert.ok(Array.isArray(rows), '预设组合必须是行数组')
|
|
99
|
+
const ids = new Set()
|
|
100
|
+
const walk = (list) => {
|
|
101
|
+
for (const row of list) {
|
|
102
|
+
if (row.id !== undefined) ids.add(row.id)
|
|
103
|
+
if (row.group && Array.isArray(row.config)) walk(row.config)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
walk(rows)
|
|
107
|
+
assert.ok(ids.has('tool-bash'), 'POSIX bash 工具行必须保留')
|
|
108
|
+
assert.ok(!ids.has('tool-pwsh'), 'tool-pwsh 行必须移除(win32 shell 面由本包接管)')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('漂移守卫:与已安装 standard 逐行对表(缺失宿主则 skip)', (t) => {
|
|
112
|
+
if (harness === null) return t.skip('未找到 dsh 本体安装,无对照源')
|
|
113
|
+
const standardPath = join(harness, 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard', 'agent.cordis.yml')
|
|
114
|
+
if (!existsSync(standardPath)) return t.skip('dsh 安装位无 standard 预设,无对照源')
|
|
115
|
+
const standard = flattenRows(loadYaml(readFileSync(standardPath, 'utf8')))
|
|
116
|
+
const ours = flattenRows(loadYaml(presetText))
|
|
117
|
+
for (const [id, row] of standard) {
|
|
118
|
+
if (id === 'tool-pwsh') {
|
|
119
|
+
assert.ok(!ours.has(id), 'standard 有 tool-pwsh 而本包预设也出现了:镜像被污染')
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
assert.ok(ours.has(id), `standard 行 ${id} 在本包预设缺失,须对表镜像`)
|
|
123
|
+
assert.deepEqual(ours.get(id), row, `行 ${id} 与 standard 漂移,升级后按官方实文对表`)
|
|
124
|
+
}
|
|
125
|
+
for (const id of ours.keys()) {
|
|
126
|
+
assert.ok(standard.has(id), `本包预设多出 standard 没有的行 ${id}:镜像越界`)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('schema 同构:本地 !!js Type 与官方 entryListSchema 产物 deepEqual(缺失宿主则 skip)', (t) => {
|
|
131
|
+
if (harness === null) return t.skip('未找到 dsh 本体安装,无官方 schema 对照')
|
|
132
|
+
// harness 命中时 entryListSchema 已是官方版:loadYaml=官方路径,与 LOCAL_SCHEMA 逐文本对照。
|
|
133
|
+
// 本地同形标签的语义漂移曾以 unknown tag 形态炸穿 CI,此处固化为机器断言
|
|
134
|
+
for (const [label, text] of [['预设组合', presetText], ['bundle patch', patchText]]) {
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
yamlStatic.load(text, { schema: LOCAL_SCHEMA }),
|
|
137
|
+
loadYaml(text),
|
|
138
|
+
`${label} 双 schema 产物漂移,本地 !!js Type 须与官方 JsExpr 同构`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('patch 形态:agent-presets 覆写带 name 防御且含 default 与 roots', () => {
|
|
144
|
+
const patch = yamlStatic.load(patchText, { schema: LOCAL_SCHEMA })
|
|
145
|
+
const row = patch.find((entry) => entry.id === 'agent-presets')
|
|
146
|
+
assert.ok(row, 'patch 缺 agent-presets 覆写行')
|
|
147
|
+
assert.equal(row.name, '@deepseek-ai/dsh-agent-presets', 'name 字段是官方行改名防御,必须钉住包名')
|
|
148
|
+
assert.match(row.config.default.__jsExpr, /'shell-select'/)
|
|
149
|
+
assert.match(row.config.default.__jsExpr, /'standard'/)
|
|
150
|
+
assert.equal(row.config.roots.length, 1)
|
|
151
|
+
assert.match(row.config.roots[0].path.__jsExpr, /ctx\.baseUrl/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('patch 表达式求值:default 按平台切换,roots 指向本包 presets 目录', () => {
|
|
155
|
+
const patch = yamlStatic.load(patchText, { schema: LOCAL_SCHEMA })
|
|
156
|
+
const config = patch.find((entry) => entry.id === 'agent-presets').config
|
|
157
|
+
const expectedDefault = process.platform === 'win32' ? 'shell-select' : 'standard'
|
|
158
|
+
assert.equal(evalExpr(config.default.__jsExpr, { baseUrl: 'file:///any/profile/dir/' }), expectedDefault)
|
|
159
|
+
// 合成 profile 布局:baseUrl 锚 <profile>/profiles/web,其下 node_modules/@mzzsfy/dsh-shell-select
|
|
160
|
+
// 为符号链接桥(自依赖桥同构;junction 穿透到真实包目录)
|
|
161
|
+
const fakeProfile = join(tmpdir(), 'shell-select-preset-probe-' + process.pid)
|
|
162
|
+
const linkDir = join(fakeProfile, 'profiles', 'web', 'node_modules', '@mzzsfy')
|
|
163
|
+
mkdirSync(linkDir, { recursive: true })
|
|
164
|
+
symlinkSync(pkgRoot, join(linkDir, 'dsh-shell-select'), 'junction')
|
|
165
|
+
try {
|
|
166
|
+
const evaluated = evalExpr(config.roots[0].path.__jsExpr, {
|
|
167
|
+
baseUrl: pathToFileURL(join(fakeProfile, 'profiles', 'web') + '/').href,
|
|
168
|
+
})
|
|
169
|
+
assert.ok(/presets\/$/.test(evaluated), '求值路径必须锚在包内 presets 目录: ' + evaluated)
|
|
170
|
+
assert.ok(existsSync(join(evaluated, 'shell-select', 'agent.cordis.yml')), '求值路径必须落在本包 presets 目录(符号链接桥穿透)')
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(fakeProfile, { recursive: true, force: true })
|
|
173
|
+
}
|
|
174
|
+
})
|