@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 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` 重定向到阴影对象,`#` 私有触发品牌检查错误)。agent preset 注入的官方 `pwsh` 工具因此照常可用:经 `ctx.shell` 代理走默认客户端;`shell` 工具提供多客户端选择面。
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` 代理的官方 pwsh 工具同样受管。拒绝以模型可见标记返回:`[blocked by shell-select: matches deny pattern …]`,后台任务在启动前同步拒绝,不产生僵尸任务。
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.1.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
- // tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强
88
- '.sls-tv { font-size:13px; line-height:1.45; }',
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:500; }',
96
- '.sls-tv__sep { width:3px; height:3px; border-radius:50%; background:currentColor; opacity:.35; flex:none; }',
97
- '.sls-tv__sum { opacity:.6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
98
- '.sls-tv__sum--err { color:#d4553f; opacity:.95; }',
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, denyText) {
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: splitPatternLines(denyText),
198
+ deny: denyRules.filter((rule) => rule.length > 0),
159
199
  }
160
200
  }
161
201
 
162
- // 名单文本(每行一正则)→ 数组;空行与纯空白行忽略
163
- // LOGIC-BEGIN splitPatternLines
164
- function splitPatternLines(text) {
165
- return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
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 splitPatternLines
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;denyText 每行一正则)
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 : []).join('\n')
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 [denyText, setDenyText] = useState('')
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
- setDenyText(patternText(section.deny))
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, denyText)
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 badPatterns = section.deny.filter((line) => {
267
- try { new RegExp(line); return false } catch { return true }
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
- setDenyText(patternText(payload.resolved.deny))
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-grid' },
431
- h('span', { className: 'sls-grid__label' }, '拒绝名单'),
432
- h('textarea', {
433
- className: 'sls-input sls-args',
434
- rows: 3,
435
- value: denyText,
436
- placeholder: '每行一条正则,命中的命令拒绝执行(绝对,无豁免);精细放行用前瞻,如 rm -rf\\s+(?!\\S*node_modules)',
437
- onChange: (event) => { setDirty(true); setDenyText(event.target.value) },
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 cwdDir = (() => {
540
- const dir = displayCwd(typeof args?.workdir === 'string' ? args.workdir : undefined, sessionCwd)
541
- return dir !== undefined ? lastSegment(dir) : undefined
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 || args.run_in_background === true || description === undefined || hasSpillNotice(contentText)) {
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', { displayCwd, lastSegment, parseExitTail: extractLogic('parseExitTail'), hasSpillNotice: extractLogic('hasSpillNotice') })
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('S12c 后台 ack isError 仍走 generic(回归)', () => {
81
- const background = cardModel()(
82
- settledBlock(JSON.stringify({ ...JSON.parse(ARGS), run_in_background: true }), 'started', { isError: false }),
83
- SESSION_CWD,
84
- )
85
- assert.equal(background.kind, 'generic')
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(文本守卫)', () => {
@@ -58,11 +58,40 @@ test('扩展字段守卫:login/distro/env 进设置页数据链(wholesale replac
58
58
  assert.match(source, /发行版/)
59
59
  })
60
60
 
61
- test('deny 名单进设置页数据链:state/toSection/校验/往返;allow 已移除(deny 绝对)', () => {
62
- assert.match(source, /denyText/)
63
- assert.match(source, /deny: splitPatternLines\(denyText\)/)
64
- assert.match(source, /名单正则非法/)
65
- assert.match(source, /拒绝名单/)
66
- assert.doesNotMatch(source, /allowText/)
67
- assert.doesNotMatch(source, /豁免名单/)
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 = `${process.env.SystemRoot ?? 'C:\\WINDOWS'}\\System32\\cmd.exe`
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
  })
@@ -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.match(pwsh.path, /pwsh\.exe$|powershell\.exe$/)
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: 'C:\\Program Files\\Git\\bin\\bash.exe', login: true },
322
- { id: 'g', name: 'Git Bash', kind: 'bash', path: 'C:\\Program Files\\Git\\bin\\bash.exe' },
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), ['C:\\Program Files\\Git\\bin\\bash.exe', '-lc'])
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), ['C:\\Program Files\\Git\\bin\\bash.exe', '-c'])
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
+ })