@mzzsfy/dsh-shell-select 0.1.0 → 0.2.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
@@ -60,7 +60,7 @@ env 优先级(同键高右):内置覆盖集(NO_COLOR/PAGER/GIT_PAGER)< 条目 `e
60
60
  - 配置节顶层 `deny`:正则字符串数组,对模型提交的整条命令文本匹配(大小写不敏感),命中即拒绝——**deny 绝对,无豁免语义**;空数组 = 不拦截(出厂默认)。
61
61
  - 精细放行在模式内用正则前瞻表达:`deny: ['rm -rf\\s+(?!\\S*node_modules)']` 禁 rm -rf 但放行清依赖目录;默认放行系统下独立 allow 列表的唯一语义就是覆盖 deny,与"deny 绝对"矛盾,故不设。
62
62
  - 拦截点在执行器 runFor/startFor 入口:经 `ctx.shell` 代理的官方 pwsh 工具同样受管。拒绝以模型可见标记返回:`[blocked by shell-select: matches deny pattern …]`,后台任务在启动前同步拒绝,不产生僵尸任务。
63
- - 定位是防误触护栏而非安全边界:整文本正则挡不住间接包装(编码/嵌套壳),真正的边界始终是访问模式与沙箱。坏正则条目容错跳过,不瘫执行链;设置页多行文本域编辑(每行一条正则),保存时校验正则合法性。
63
+ - 定位是防误触护栏而非安全边界:整文本正则挡不住间接包装(编码/嵌套壳),真正的边界始终是访问模式与沙箱。坏正则条目容错跳过,不瘫执行链;设置页为逐条规则编辑器(每条规则独立行,行级即时校验并标错,保存时按行定位拦截非法正则)。
64
64
 
65
65
  ### bash 形探测与 msys2
66
66
 
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.2.0",
5
5
  "type": "module",
6
6
  "main": "src/executor.mjs",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -84,6 +84,18 @@ window.__ModuleLoader__.load({
84
84
  '.sls-hint { font-size:12px; opacity:.7; }',
85
85
  '.sls-error { font-size:12px; color:#c44; white-space:pre-wrap; }',
86
86
  '.sls-args { font-size:12px; font-family:var(--sls-mono, monospace); }',
87
+ // 命令黑名单:逐条规则编辑器(每条规则独立行,行级即时校验)
88
+ '.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; }',
89
+ '.sls-rules__head { display:flex; align-items:center; gap:8px; }',
90
+ '.sls-rules__title { font-weight:500; }',
91
+ '.sls-rules__note { font-size:12px; opacity:.7; }',
92
+ '.sls-rule { display:flex; align-items:center; gap:6px; }',
93
+ '.sls-rule__no { flex:none; width:18px; text-align:right; font-size:12px; opacity:.45; user-select:none; }',
94
+ '.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); }',
95
+ '.sls-rule__input:focus { outline:none; border-color:var(--sls-accent, #4b7bcc); }',
96
+ '.sls-rule--bad .sls-rule__input { border-color:#c44; }',
97
+ '.sls-rule__err { flex:none; max-width:40%; font-size:11px; color:#c44; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
98
+ '.sls-rule__del { flex:none; padding:2px 8px; }',
87
99
  // tool.call.toolview 卡片(key 'shell'):官方 terminal 行同构 + 复制/折行增强
88
100
  '.sls-tv { font-size:13px; line-height:1.45; }',
89
101
  '.sls-tv__row { display:flex; align-items:center; gap:7px; padding:2px 0; cursor:default; }',
@@ -142,7 +154,7 @@ window.__ModuleLoader__.load({
142
154
  }
143
155
 
144
156
  // 保存负载:仅取设置 schema 字段,剥离探测态
145
- function toSection(entries, defaultId, denyText) {
157
+ function toSection(entries, defaultId, denyRules) {
146
158
  return {
147
159
  shells: entries.map((entry) => ({
148
160
  id: entry.id.trim(),
@@ -155,16 +167,25 @@ window.__ModuleLoader__.load({
155
167
  env: parseEnvText(entry.envText),
156
168
  })),
157
169
  default: defaultId,
158
- deny: splitPatternLines(denyText),
170
+ deny: denyRules.filter((rule) => rule.length > 0),
159
171
  }
160
172
  }
161
173
 
162
- // 名单文本(每行一正则)→ 数组;空行与纯空白行忽略
163
- // LOGIC-BEGIN splitPatternLines
164
- function splitPatternLines(text) {
165
- return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
174
+ // 逐条规则的正则校验:返回非法条目 [{index, error}](index 为规则序,供行级标错与保存拦截复用)
175
+ // LOGIC-BEGIN denyPatternIssues
176
+ function denyPatternIssues(rules) {
177
+ const issues = []
178
+ for (let index = 0; index < rules.length; index += 1) {
179
+ if (rules[index].length === 0) continue
180
+ try {
181
+ new RegExp(rules[index])
182
+ } catch (error) {
183
+ issues.push({ index, error: String(error?.message ?? error) })
184
+ }
185
+ }
186
+ return issues
166
187
  }
167
- // LOGIC-END splitPatternLines
188
+ // LOGIC-END denyPatternIssues
168
189
 
169
190
  // 环境文本(每行 K=V)→ 记录;空行与缺 = 的行忽略,值保留原样含空格与 =。
170
191
  // LOGIC-BEGIN parseEnvText
@@ -190,7 +211,7 @@ window.__ModuleLoader__.load({
190
211
  }
191
212
  // LOGIC-END invalidEnvLines
192
213
 
193
- // 服务器清单 → 编辑态(argsText 汇成一串便于编辑;envText 每行 K=V;denyText 每行一正则)
214
+ // 服务器清单 → 编辑态(argsText 汇成一串便于编辑;envText 每行 K=V;deny 为逐条规则数组)
194
215
  function toEntries(section) {
195
216
  return section.shells.map((entry) => ({
196
217
  id: entry.id,
@@ -206,9 +227,9 @@ window.__ModuleLoader__.load({
206
227
  }))
207
228
  }
208
229
 
209
- // 名单数组 → 每行一正则的编辑态文本
230
+ // 名单数组 → 编辑态数组(去空白行;加载侧解析)
210
231
  function patternText(list) {
211
- return (Array.isArray(list) ? list : []).join('\n')
232
+ return (Array.isArray(list) ? list : []).map((rule) => String(rule).trim()).filter((rule) => rule.length > 0)
212
233
  }
213
234
 
214
235
  function ShellSelectApp() {
@@ -217,7 +238,7 @@ window.__ModuleLoader__.load({
217
238
  const [dirty, setDirty] = useState(false)
218
239
  const [notice, setNotice] = useState(null)
219
240
  const [busy, setBusy] = useState(false)
220
- const [denyText, setDenyText] = useState('')
241
+ const [denyRules, setDenyRules] = useState([])
221
242
 
222
243
  useEffect(() => {
223
244
  let disposed = false
@@ -225,7 +246,7 @@ window.__ModuleLoader__.load({
225
246
  if (disposed) return
226
247
  setEntries(toEntries(section))
227
248
  setDefaultId(section.default)
228
- setDenyText(patternText(section.deny))
249
+ setDenyRules(patternText(section.deny))
229
250
  }).catch((error) => {
230
251
  if (!disposed) setNotice('加载失败:' + error.message)
231
252
  })
@@ -251,11 +272,26 @@ window.__ModuleLoader__.load({
251
272
  if (defaultId === target.id) setDefaultId(entries.length > 1 ? entries[0].id : '')
252
273
  }
253
274
 
275
+ const patchRule = (index, value) => {
276
+ setDirty(true)
277
+ setDenyRules(denyRules.map((rule, at) => (at === index ? value : rule)))
278
+ }
279
+
280
+ const addRule = () => {
281
+ setDirty(true)
282
+ setDenyRules([...denyRules, ''])
283
+ }
284
+
285
+ const removeRule = (index) => {
286
+ setDirty(true)
287
+ setDenyRules(denyRules.filter((_, at) => at !== index))
288
+ }
289
+
254
290
  const save = async () => {
255
291
  setBusy(true)
256
292
  setNotice(null)
257
293
  try {
258
- const section = toSection(entries, defaultId, denyText)
294
+ const section = toSection(entries, defaultId, denyRules)
259
295
  const ids = section.shells.map((entry) => entry.id)
260
296
  if (ids.some((id) => id.length === 0)) throw new Error('存在空 id 条目')
261
297
  if (new Set(ids).size !== ids.length) throw new Error('id 重复:' + ids.join(', '))
@@ -263,16 +299,14 @@ window.__ModuleLoader__.load({
263
299
  const invalid = entries.map((entry) => ({ entry, lines: invalidEnvLines(entry.envText) }))
264
300
  .find(({ lines }) => lines.length > 0)
265
301
  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]}`)
302
+ const issue = denyPatternIssues(section.deny)[0]
303
+ if (issue !== undefined) {
304
+ throw new Error(`命令黑名单第 ${issue.index + 1} 条正则非法:${issue.error}`)
271
305
  }
272
306
  const payload = await api(API.config, { method: 'POST', body: JSON.stringify(section) })
273
307
  setEntries(toEntries({ shells: payload.resolved.shells }))
274
308
  setDefaultId(payload.resolved.default)
275
- setDenyText(patternText(payload.resolved.deny))
309
+ setDenyRules(patternText(payload.resolved.deny))
276
310
  setDirty(false)
277
311
  setNotice('已保存,工具描述与默认客户端即时生效')
278
312
  } catch (error) {
@@ -427,15 +461,37 @@ window.__ModuleLoader__.load({
427
461
  }),
428
462
  ),
429
463
  )),
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
- }),
464
+ h('div', { className: 'sls-rules' },
465
+ h('div', { className: 'sls-rules__head' },
466
+ h('span', { className: 'sls-rules__title' }, '命令黑名单'),
467
+ h('span', { className: 'sls-badge' + (denyRules.length > 0 ? ' sls-badge--ok' : ' sls-badge--bad') },
468
+ denyRules.length > 0 ? denyRules.length + ' 条规则' : '未生效'),
469
+ ),
470
+ h('span', { className: 'sls-rules__note' },
471
+ '每条规则为一条正则,对整条命令全文匹配(大小写不敏感),命中即拒,无豁免;精细放行写在模式内用前瞻,如 rm -rf\\s+(?!\\S*node_modules)。'),
472
+ denyRules.length === 0
473
+ ? h('span', { className: 'sls-rules__note' }, '未配置,命中拦截不生效,模型可执行任意命令。')
474
+ : denyRules.map((rule, index) => {
475
+ const issue = denyPatternIssues([rule])[0]
476
+ return h('div', { className: 'sls-rule' + (issue !== undefined ? ' sls-rule--bad' : ''), key: index },
477
+ h('span', { className: 'sls-rule__no' }, index + 1),
478
+ h('input', {
479
+ className: 'sls-rule__input',
480
+ value: rule,
481
+ placeholder: '正则,如 ^format\\s',
482
+ onChange: (event) => patchRule(index, event.target.value),
483
+ }),
484
+ issue !== undefined ? h('span', { className: 'sls-rule__err', title: issue.error }, issue.error) : null,
485
+ h('button', {
486
+ className: 'sls-btn sls-rule__del',
487
+ title: '删除该条规则',
488
+ onClick: () => removeRule(index),
489
+ }, '删除'),
490
+ )
491
+ }),
492
+ h('div', { className: 'sls-row' },
493
+ h('button', { className: 'sls-btn', onClick: addRule }, '添加规则'),
494
+ ),
439
495
  ),
440
496
  h('div', { className: 'sls-row' },
441
497
  h('button', { className: 'sls-btn', disabled: busy, onClick: addEntry }, '添加客户端'),
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
  }
@@ -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()