@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 +1 -1
- package/package.json +1 -1
- package/src/client.js +84 -28
- package/src/executor.mjs +3 -1
- 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/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
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,
|
|
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:
|
|
170
|
+
deny: denyRules.filter((rule) => rule.length > 0),
|
|
159
171
|
}
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
//
|
|
163
|
-
// LOGIC-BEGIN
|
|
164
|
-
function
|
|
165
|
-
|
|
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
|
|
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;
|
|
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 : []).
|
|
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 [
|
|
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
|
-
|
|
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,
|
|
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
|
|
267
|
-
|
|
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
|
-
|
|
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-
|
|
431
|
-
h('
|
|
432
|
-
|
|
433
|
-
className: 'sls-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
}
|
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()
|