@mzzsfy/dsh-shell-select 0.3.1 → 0.4.1
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 +25 -6
- package/cordis.patch.yml +181 -29
- package/package.json +19 -13
- package/src/apply-state.mjs +13 -0
- package/src/client.js +135 -67
- package/src/config.mjs +174 -14
- package/src/executor.mjs +211 -101
- package/src/guard-state.mjs +26 -0
- package/src/guard.js +14 -6
- package/src/index.js +163 -0
- package/src/render.mjs +5 -1
- package/src/takeover.mjs +167 -0
- package/src/tool.mjs +43 -20
- package/src/web-routes.mjs +93 -0
- package/test/client-card.test.mjs +171 -3
- package/test/client-render.test.mjs +169 -0
- package/test/config-entry.test.mjs +13 -8
- package/test/config.test.mjs +95 -4
- package/test/deny-config.test.mjs +8 -8
- package/test/executor-seam.test.mjs +3 -3
- package/test/executor.test.mjs +232 -121
- package/test/guard-state.test.mjs +46 -1
- package/test/preset-drift.test.mjs +49 -69
- package/test/render.test.mjs +39 -0
- package/test/takeover-shell.test.mjs +397 -0
- package/test/web-routes.test.mjs +267 -0
package/test/executor.test.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { test } from 'node:test'
|
|
5
5
|
import assert from 'node:assert/strict'
|
|
6
|
-
import ShellSelectExecutor
|
|
7
|
-
import {
|
|
6
|
+
import ShellSelectExecutor from '../src/executor.mjs'
|
|
7
|
+
import { resolveConfig, assertServiceableConfig, defaultConfig } from '../src/config.mjs'
|
|
8
8
|
|
|
9
9
|
// --- 桩具 ---
|
|
10
10
|
|
|
@@ -29,18 +29,14 @@ function stubSpawn(stdoutText = '', stderrText = '', exitCode = 0) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function stubCtx({ withSettings = true, sandboxMode = 'danger-full-access', spawn = stubSpawn() } = {}) {
|
|
32
|
-
const registered = { tools: [], sections: [], promptSections: [], routes: [] }
|
|
32
|
+
const registered = { tools: [], sections: [], promptSections: [], routes: [], policies: [] }
|
|
33
33
|
const settingsImpl = {
|
|
34
|
-
|
|
35
|
-
registered.
|
|
36
|
-
settingsImpl._source = () => Config(base ?? {})
|
|
37
|
-
},
|
|
38
|
-
update: (ns, patch) => {
|
|
39
|
-
settingsImpl._patch = { ns, patch }
|
|
40
|
-
registered.sections[0].hooks.onChange()
|
|
34
|
+
configure: (presentation, fiber) => {
|
|
35
|
+
registered.policies.push({ presentation, fiber })
|
|
41
36
|
},
|
|
42
37
|
}
|
|
43
38
|
const disposers = []
|
|
39
|
+
const childEffects = []
|
|
44
40
|
const ctx = {
|
|
45
41
|
// cordis Service 基类构造期要求 reflect.provide(登记服务实例),桩空转
|
|
46
42
|
reflect: { provide: () => {} },
|
|
@@ -51,6 +47,18 @@ function stubCtx({ withSettings = true, sandboxMode = 'danger-full-access', spaw
|
|
|
51
47
|
}
|
|
52
48
|
return undefined
|
|
53
49
|
},
|
|
50
|
+
// 可选子级注入(设置页策略形态):子级立即执行,effect 收集待验
|
|
51
|
+
inject(services, fn) {
|
|
52
|
+
const child = {
|
|
53
|
+
settings: withSettings ? settingsImpl : undefined,
|
|
54
|
+
effect: (effectFn) => {
|
|
55
|
+
const disposer = effectFn()
|
|
56
|
+
childEffects.push(disposer)
|
|
57
|
+
return disposer
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
fn(child)
|
|
61
|
+
},
|
|
54
62
|
subprocess: spawn,
|
|
55
63
|
sandbox: {
|
|
56
64
|
confine: (argv, policy) => ({
|
|
@@ -84,6 +92,7 @@ function stubCtx({ withSettings = true, sandboxMode = 'danger-full-access', spaw
|
|
|
84
92
|
disposers.push(disposer)
|
|
85
93
|
return disposer
|
|
86
94
|
},
|
|
95
|
+
_childEffects: childEffects,
|
|
87
96
|
}
|
|
88
97
|
return { ctx, registered, disposers }
|
|
89
98
|
}
|
|
@@ -110,24 +119,50 @@ function build() {
|
|
|
110
119
|
|
|
111
120
|
// --- 断言 ---
|
|
112
121
|
|
|
113
|
-
test('构造:注册 shell
|
|
122
|
+
test('构造:注册 shell+pwsh 双工具与 systemPrompt 段,路由不再由主行挂载', () => {
|
|
114
123
|
const { registered } = build()
|
|
115
|
-
assert.
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
assert.deepEqual(registered.tools.map((tool) => tool.name).sort(), ['pwsh', 'shell'])
|
|
125
|
+
const shell = registered.tools.find((tool) => tool.name === 'shell')
|
|
126
|
+
const pwsh = registered.tools.find((tool) => tool.name === 'pwsh')
|
|
127
|
+
assert.match(shell.description, /git-bash/)
|
|
128
|
+
// 兼容工具描述一句话:不要调用,使用 shell
|
|
129
|
+
assert.equal(pwsh.description, 'Do not call; use `shell`.')
|
|
130
|
+
assert.ok(!('shell' in pwsh.parameters.properties))
|
|
131
|
+
assert.ok('shell' in shell.parameters.properties)
|
|
118
132
|
assert.equal(registered.promptSections.length, 1)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
// 数据通道迁至 web-routes 行:主行零路由
|
|
134
|
+
const routePaths = registered.routes.filter((path) => String(path).startsWith('/api/shell-select/'))
|
|
135
|
+
assert.equal(routePaths.length, 0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('settings 在场:注册 auto:false 页面策略挂本行 fiber(自带设置页,官方同构)', () => {
|
|
139
|
+
const { ctx, registered } = stubCtx()
|
|
140
|
+
const fiber = { id: 'fiber-1' }
|
|
141
|
+
ctx.fiber = fiber
|
|
142
|
+
new ShellSelectExecutor(ctx, baseConfig())
|
|
143
|
+
assert.equal(registered.policies.length, 1)
|
|
144
|
+
assert.deepEqual(registered.policies[0].presentation, { auto: false })
|
|
145
|
+
assert.equal(registered.policies[0].fiber, fiber)
|
|
124
146
|
})
|
|
125
147
|
|
|
126
|
-
test('settings
|
|
148
|
+
test('settings 缺席:策略静默跳过,双工具照常注册(业务插件无 settings 也可运行)', () => {
|
|
127
149
|
const { ctx, registered } = stubCtx({ withSettings: false })
|
|
128
150
|
new ShellSelectExecutor(ctx, baseConfig())
|
|
129
|
-
assert.equal(registered.
|
|
130
|
-
assert.equal(registered.tools.length,
|
|
151
|
+
assert.equal(registered.policies.length, 0)
|
|
152
|
+
assert.equal(registered.tools.length, 2)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('pwsh 工具钉死 pwsh 客户端:default 漂移不改变落点(旧会话语义保真)', async () => {
|
|
156
|
+
const spawn = stubSpawn('ok', '', 0)
|
|
157
|
+
const { ctx, registered } = stubCtx({ spawn })
|
|
158
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
159
|
+
const pwshTool = registered.tools.find((tool) => tool.name === 'pwsh')
|
|
160
|
+
// 模型无 shell 参数可传(pinned 工具),default 漂到 git-bash 也不影响
|
|
161
|
+
executor.refresh({ shells: baseConfig().shells, default: 'git-bash' })
|
|
162
|
+
await pwshTool.execute({ command: 'Get-Date', description: 'probe', workdir: process.cwd() }, { signal: new AbortController().signal })
|
|
163
|
+
assert.equal(spawn.calls[0].argv[0], NODE_EXE)
|
|
164
|
+
assert.match(spawn.calls[0].argv.at(-1), /Get-Date$/)
|
|
165
|
+
assert.deepEqual(spawn.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
131
166
|
})
|
|
132
167
|
|
|
133
168
|
test('listShells:出厂条目自动解析为真实路径(pwsh/cmd 必在本机)', () => {
|
|
@@ -223,102 +258,6 @@ test('runFor pwsh 条目:非交互形 + 编码前缀进 argv', async () => {
|
|
|
223
258
|
assert.match(argv[5], /OutputEncoding/)
|
|
224
259
|
})
|
|
225
260
|
|
|
226
|
-
test('settings.update 触发 onChange:工具重注册且描述更新', () => {
|
|
227
|
-
const { executor, registered } = stubCtxAndBuild()
|
|
228
|
-
const before = registered.tools.length
|
|
229
|
-
assert.ok(before >= 1)
|
|
230
|
-
registered.sections[0].hooks.onChange()
|
|
231
|
-
assert.ok(registered.tools.length >= 1)
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
test('updateConfig:await settings.update 后读数即新值(setSource 接线)', async () => {
|
|
235
|
-
const { ctx, registered } = stubCtx()
|
|
236
|
-
// 桩 settings:installSection 后 setSource 接 scope;replace wholesale 后 setSource 到新值
|
|
237
|
-
const scope = { value: Config({}) }
|
|
238
|
-
registered.sectionsHook = null
|
|
239
|
-
ctx.settings.installSection = (ctx2, ns, schema, base, hooks) => {
|
|
240
|
-
registered.sections.push({ ns, schema, base, hooks })
|
|
241
|
-
scope.value = Config(base ?? {})
|
|
242
|
-
hooks.setSource(() => scope.value)
|
|
243
|
-
registered.sectionsHook = hooks
|
|
244
|
-
}
|
|
245
|
-
ctx.settings.replace = async (ns, section) => {
|
|
246
|
-
scope.value = Config(section)
|
|
247
|
-
registered.sectionsHook.onChange()
|
|
248
|
-
}
|
|
249
|
-
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
250
|
-
assert.equal(executor.config.default, 'pwsh')
|
|
251
|
-
await executor.updateConfig({ default: 'git-bash' })
|
|
252
|
-
assert.equal(executor.config.default, 'git-bash')
|
|
253
|
-
assert.equal(executor.listShells().default, 'git-bash')
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
test('updateConfig:部分补丁归并当前节后 replace(单改 default 不丢 shells)', async () => {
|
|
257
|
-
const { ctx, registered } = stubCtx()
|
|
258
|
-
const written = []
|
|
259
|
-
ctx.settings.replace = async (ns, section) => {
|
|
260
|
-
written.push(section)
|
|
261
|
-
}
|
|
262
|
-
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
263
|
-
await executor.updateConfig({ default: 'git-bash' })
|
|
264
|
-
assert.equal(written[0].shells.length, 3)
|
|
265
|
-
assert.equal(written[0].default, 'git-bash')
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
test('api POST config:await updateConfig,resolved 即新清单(async 修复)', async () => {
|
|
269
|
-
const { ctx, registered } = stubCtx()
|
|
270
|
-
ctx.get = (service) => {
|
|
271
|
-
if (service === 'webServer') {
|
|
272
|
-
const routes = []
|
|
273
|
-
ctx.__routes = routes
|
|
274
|
-
return { register: (item) => routes.push(item) }
|
|
275
|
-
}
|
|
276
|
-
return undefined
|
|
277
|
-
}
|
|
278
|
-
ctx.settings.replace = async (ns, section) => {
|
|
279
|
-
registered.sections[0].hooks.setSource(() => section)
|
|
280
|
-
registered.sections[0].hooks.onChange()
|
|
281
|
-
}
|
|
282
|
-
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
283
|
-
const handler = ctx.__routes.find((r) => r.path === '/api/shell-select/config').handler
|
|
284
|
-
let captured = null
|
|
285
|
-
const fakeRes = {
|
|
286
|
-
writeHead: () => {},
|
|
287
|
-
end: (body) => {
|
|
288
|
-
captured = JSON.parse(body)
|
|
289
|
-
},
|
|
290
|
-
}
|
|
291
|
-
const body = JSON.stringify({ default: 'git-bash', shells: executor.config.shells })
|
|
292
|
-
const fakeReq = {
|
|
293
|
-
method: 'POST',
|
|
294
|
-
headers: { origin: 'http://127.0.0.1:9191', host: '127.0.0.1:9191' },
|
|
295
|
-
on(event, cb) {
|
|
296
|
-
if (event === 'data') setImmediate(() => cb(Buffer.from(body)))
|
|
297
|
-
if (event === 'end') setImmediate(cb)
|
|
298
|
-
},
|
|
299
|
-
destroy: () => {},
|
|
300
|
-
}
|
|
301
|
-
await handler(fakeReq, fakeRes)
|
|
302
|
-
assert.equal(captured.ok, true)
|
|
303
|
-
assert.equal(captured.resolved.default, 'git-bash')
|
|
304
|
-
assert.equal(captured.resolved.shells.length, 3)
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
test('updateConfig:入参 path 反斜杠归一(防 yaml 无引号落盘字面翻倍)', async () => {
|
|
308
|
-
// 归一是 Windows 宿主行为(linux 无反斜杠翻倍怪癖,源码层按平台守卫)
|
|
309
|
-
if (process.platform !== 'win32') return
|
|
310
|
-
const { ctx, registered } = stubCtx()
|
|
311
|
-
const written = []
|
|
312
|
-
ctx.settings.replace = async (ns, section) => {
|
|
313
|
-
written.push(section)
|
|
314
|
-
}
|
|
315
|
-
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
316
|
-
await executor.updateConfig({
|
|
317
|
-
shells: [{ id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: 'C:\\\\WINDOWS\\\\System32\\\\powershell.exe' }],
|
|
318
|
-
})
|
|
319
|
-
assert.equal(written[0].shells[0].path, 'C:\\WINDOWS\\System32\\powershell.exe')
|
|
320
|
-
})
|
|
321
|
-
|
|
322
261
|
test('entryFor:双反斜杠污染路径被归一后仍可用', () => {
|
|
323
262
|
const { executor } = stubCtxAndBuild()
|
|
324
263
|
const ctx2 = stubCtx()
|
|
@@ -368,9 +307,9 @@ test('startFor danger:返回进程句柄,readOutput 增量与 done 定局', asyn
|
|
|
368
307
|
})
|
|
369
308
|
|
|
370
309
|
test('assertServiceableConfig:空 shells/default 不在列/重复 id 全拒', () => {
|
|
371
|
-
assert.throws(() => assertServiceableConfig(
|
|
372
|
-
assert.throws(() => assertServiceableConfig(
|
|
373
|
-
assert.throws(() => assertServiceableConfig(
|
|
310
|
+
assert.throws(() => assertServiceableConfig(resolveConfig({ shells: [], default: 'x' })), /shells/)
|
|
311
|
+
assert.throws(() => assertServiceableConfig(resolveConfig({ shells: [{ id: 'a', name: 'A', kind: 'bash', path: '' }], default: 'b' })), /default/)
|
|
312
|
+
assert.throws(() => assertServiceableConfig(resolveConfig({
|
|
374
313
|
shells: [
|
|
375
314
|
{ id: 'a', name: 'A', kind: 'bash', path: '' },
|
|
376
315
|
{ id: 'a', name: 'A2', kind: 'cmd', path: '' },
|
|
@@ -378,3 +317,175 @@ test('assertServiceableConfig:空 shells/default 不在列/重复 id 全拒', ()
|
|
|
378
317
|
default: 'a',
|
|
379
318
|
})), /duplicate/)
|
|
380
319
|
})
|
|
320
|
+
|
|
321
|
+
// --- execute 官方契约(ShellExecutor 抽象方法,官方 tool-pwsh 唯一执行入口)---
|
|
322
|
+
|
|
323
|
+
test('execute 契约:danger 直跑,返回句柄形态,result() 前台投影无 sandbox 键', async () => {
|
|
324
|
+
const spawn = stubSpawn('exec-out', '', 0)
|
|
325
|
+
const { ctx } = stubCtx({ spawn })
|
|
326
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
327
|
+
const handle = await executor.execute(executor.resolve({ command: 'hi', workdir: process.cwd() }))
|
|
328
|
+
assert.ok(['running', 'completed'].includes(handle.status))
|
|
329
|
+
assert.equal(typeof handle.readOutput, 'function')
|
|
330
|
+
assert.equal(typeof handle.kill, 'function')
|
|
331
|
+
assert.equal(typeof handle.observed.stdout.readFrom, 'function')
|
|
332
|
+
const result = await handle.result()
|
|
333
|
+
assert.equal(result.exitCode, 0)
|
|
334
|
+
assert.equal(result.timedOut, false)
|
|
335
|
+
assert.equal(result.aborted, false)
|
|
336
|
+
assert.equal(result.stdout.text, 'exec-out')
|
|
337
|
+
assert.equal(result.sandbox, undefined)
|
|
338
|
+
assert.equal(spawn.calls[0].argv[0], NODE_EXE)
|
|
339
|
+
assert.match(spawn.calls[0].argv.at(-1), /hi$/)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('execute 契约:onExpiry none 不武装 deadline(后台注册形态)', async () => {
|
|
343
|
+
const spawn = stubSpawn('bg', '', 0)
|
|
344
|
+
const { ctx } = stubCtx({ spawn })
|
|
345
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
346
|
+
const handle = await executor.execute(executor.resolve({ command: 'x', workdir: process.cwd(), onExpiry: 'none' }))
|
|
347
|
+
const result = await handle.result()
|
|
348
|
+
assert.equal(result.exitCode, 0)
|
|
349
|
+
assert.equal(result.timedOut, false)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
test('execute 契约:provider spawn 失败,done 定局 killed 不 reject,result() 携带同一失败', async () => {
|
|
353
|
+
const spawn = { spawn: () => { throw new Error('spawn exploded') } }
|
|
354
|
+
const { ctx } = stubCtx({ spawn })
|
|
355
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
356
|
+
const handle = await executor.execute(executor.resolve({ command: 'x', workdir: process.cwd() }))
|
|
357
|
+
await handle.done
|
|
358
|
+
assert.equal(handle.status, 'killed')
|
|
359
|
+
const read = handle.readOutput()
|
|
360
|
+
assert.match(read.delta, /subprocess failed before reporting an outcome/)
|
|
361
|
+
await assert.rejects(() => handle.result(), /spawn exploded/)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('execute 契约:受限模式经 confine,定案写 proc.sandbox 并入 result', async () => {
|
|
365
|
+
const spawn = stubSpawn('', 'file access denied under read-only', 1)
|
|
366
|
+
const { ctx } = stubCtx({ spawn, sandboxMode: 'read-only' })
|
|
367
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
368
|
+
const handle = await executor.execute(executor.resolve({ command: 'dir', workdir: process.cwd() }))
|
|
369
|
+
assert.equal(spawn.calls[0].argv[0], 'WRAPPED')
|
|
370
|
+
const result = await handle.result()
|
|
371
|
+
assert.equal(result.sandbox.mode, 'read-only')
|
|
372
|
+
assert.equal(result.sandbox.denied, true)
|
|
373
|
+
assert.equal(result.sandbox.enforcement, 'full')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test('execute 契约:deny 命中拒绝执行,不产生句柄', async () => {
|
|
377
|
+
const spawn = stubSpawn('', '', 0)
|
|
378
|
+
const { ctx } = stubCtx({ spawn })
|
|
379
|
+
const executor = new ShellSelectExecutor(ctx, { ...baseConfig(), deny: ['rm -rf'] })
|
|
380
|
+
await assert.rejects(
|
|
381
|
+
() => executor.execute(executor.resolve({ command: 'rm -rf /', workdir: process.cwd() })),
|
|
382
|
+
(error) => error.code === 'SHELL_COMMAND_BLOCKED',
|
|
383
|
+
)
|
|
384
|
+
assert.equal(spawn.calls.length, 0)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('execute 契约:默认客户端解析,显式 onExpiry kill 正常完成', async () => {
|
|
388
|
+
const spawn = stubSpawn('ok', '', 0)
|
|
389
|
+
const { ctx } = stubCtx({ spawn })
|
|
390
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
391
|
+
const handle = await executor.execute(executor.resolve({ command: 'x', workdir: process.cwd(), onExpiry: 'kill', timeoutMs: 5000 }))
|
|
392
|
+
const result = await handle.result()
|
|
393
|
+
assert.equal(result.exitCode, 0)
|
|
394
|
+
assert.equal(result.timeoutMs, 5000)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('refresh:换源后执行面即时收紧(已删 id 拒绝,新清单生效)', async () => {
|
|
398
|
+
const { ctx, registered } = stubCtx()
|
|
399
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
400
|
+
assert.equal(executor.entryFor('cmd').id, 'cmd')
|
|
401
|
+
const toolsBefore = registered.tools.length
|
|
402
|
+
executor.refresh({
|
|
403
|
+
shells: baseConfig().shells.filter((entry) => entry.id !== 'cmd'),
|
|
404
|
+
default: 'pwsh',
|
|
405
|
+
})
|
|
406
|
+
assert.throws(() => executor.entryFor('cmd'), /cmd/)
|
|
407
|
+
assert.equal(executor.entryFor('pwsh').id, 'pwsh')
|
|
408
|
+
assert.equal(executor.entryFor(undefined).id, 'pwsh')
|
|
409
|
+
assert.equal(registered.tools.length, toolsBefore)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// 真机宿主形态:行 config 经宿主按 static Config resolve,volatile 字段是
|
|
413
|
+
// boxed ref({get}),非普通对象。构造/refresh 直存该形态时 resolveConfig 的
|
|
414
|
+
// 类型对账会把 ref 全部当非法输入丢弃,执行面静默回落出厂默认(行 config
|
|
415
|
+
// 永不生效,真机 0.2.0-rc.2 实证)。回归锁定:boxed 形态必须解箱生效。
|
|
416
|
+
|
|
417
|
+
// cordis resolve 产物的同构 boxed 形态:每字段值包 {get}
|
|
418
|
+
function boxFields(config) {
|
|
419
|
+
return Object.fromEntries(Object.entries(config).map(([key, value]) => [key, { get: () => value }]))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
test('构造:boxed volatile 字段(宿主 resolve 产物)解箱生效,不回落出厂默认', () => {
|
|
423
|
+
const { ctx, registered } = stubCtx()
|
|
424
|
+
const userConfig = {
|
|
425
|
+
shells: baseConfig().shells.filter((entry) => entry.id !== 'cmd'),
|
|
426
|
+
default: 'git-bash',
|
|
427
|
+
}
|
|
428
|
+
const executor = new ShellSelectExecutor(ctx, boxFields(userConfig))
|
|
429
|
+
const listing = executor.listShells()
|
|
430
|
+
assert.equal(listing.default, 'git-bash')
|
|
431
|
+
assert.deepEqual(listing.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
|
|
432
|
+
assert.equal(executor.entryFor(undefined).id, 'git-bash')
|
|
433
|
+
const shell = registered.tools.find((tool) => tool.name === 'shell')
|
|
434
|
+
assert.match(shell.description, /git-bash \(bash\)$|default client is "git-bash"/)
|
|
435
|
+
assert.doesNotMatch(shell.description, /cmd \(cmd\)/)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
test('refresh:boxed volatile 字段换源同样解箱生效', () => {
|
|
439
|
+
const { ctx } = stubCtx()
|
|
440
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
441
|
+
executor.refresh(boxFields({
|
|
442
|
+
shells: baseConfig().shells,
|
|
443
|
+
default: 'git-bash',
|
|
444
|
+
}))
|
|
445
|
+
assert.equal(executor.entryFor(undefined).id, 'git-bash')
|
|
446
|
+
const listing = executor.listShells()
|
|
447
|
+
assert.equal(listing.default, 'git-bash')
|
|
448
|
+
assert.equal(listing.shells.length, 3)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('构造:混合形态(部分字段 boxed 部分普通)逐字段解箱生效', () => {
|
|
452
|
+
const { ctx } = stubCtx()
|
|
453
|
+
const executor = new ShellSelectExecutor(ctx, {
|
|
454
|
+
shells: boxFields({ shells: baseConfig().shells }).shells,
|
|
455
|
+
default: 'git-bash',
|
|
456
|
+
timeoutMs: 30000,
|
|
457
|
+
})
|
|
458
|
+
assert.equal(executor.entryFor(undefined).id, 'git-bash')
|
|
459
|
+
assert.equal(executor.config.timeoutMs, 30000)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('入参缺省:null/undefined 落出厂默认,不抛', () => {
|
|
463
|
+
const { ctx } = stubCtx()
|
|
464
|
+
// 出厂默认 path=RESOLVED_AUTO('') → entryFor 走真实磁盘探测,linux CI 无
|
|
465
|
+
// pwsh.exe 必抛 no executable(出厂清单其余条目同);node 自身全平台存在,
|
|
466
|
+
// 以显式 path 覆写探测面,断言目标(id 落定与缺省容错)不受影响。
|
|
467
|
+
// refresh(null) 会把 #entry 归零回出厂(auto 路径),其后 entryFor 同样
|
|
468
|
+
// 需要探测面覆写:refresh 收 bypass 配置,null/undefined 容错语义(归零
|
|
469
|
+
// 不抛)由 refresh 调用本身不抛保证
|
|
470
|
+
const autoBypass = (entryConfig) => ({
|
|
471
|
+
...entryConfig,
|
|
472
|
+
shells: entryConfig.shells.map((item) => ({ ...item, path: NODE_EXE })),
|
|
473
|
+
})
|
|
474
|
+
const fromNull = new ShellSelectExecutor(ctx, autoBypass(defaultConfig()))
|
|
475
|
+
assert.equal(fromNull.entryFor(undefined).id, 'pwsh')
|
|
476
|
+
fromNull.refresh(autoBypass(defaultConfig()))
|
|
477
|
+
assert.equal(fromNull.entryFor(undefined).id, 'pwsh')
|
|
478
|
+
const fromUndefined = new ShellSelectExecutor(ctx, autoBypass(defaultConfig()))
|
|
479
|
+
assert.equal(fromUndefined.entryFor(undefined).id, 'pwsh')
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('fail-loud:解箱后仍非法的形态炸在挂载点,不静默回落出厂默认', () => {
|
|
483
|
+
const { ctx } = stubCtx()
|
|
484
|
+
// ref 容器但解箱值非法(模拟宿主 resolve 形态契约变化):strict 必须抛
|
|
485
|
+
assert.throws(
|
|
486
|
+
() => new ShellSelectExecutor(ctx, { shells: { get: () => 'not-array' }, default: 'pwsh' }),
|
|
487
|
+
/形态非法.*shells/,
|
|
488
|
+
)
|
|
489
|
+
// 同形态宽容路径(resolveConfig)不抛——降级语义仅保留给持久化读回
|
|
490
|
+
assert.doesNotThrow(() => new ShellSelectExecutor(ctx, baseConfig()))
|
|
491
|
+
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { test } from 'node:test'
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
|
-
import { detectDeadState, rowState, effectiveDisabled, MAIN_ROW_ID, OFFICIAL_ROW_IDS } from '../src/guard-state.mjs'
|
|
5
|
+
import { detectDeadState, rowState, rowScan, effectiveDisabled, MAIN_ROW_ID, OFFICIAL_ROW_IDS } from '../src/guard-state.mjs'
|
|
6
6
|
import { officialRowConfig } from '../src/guard-config.mjs'
|
|
7
7
|
|
|
8
8
|
// --- 桩 loader / 行 ---
|
|
@@ -101,6 +101,51 @@ test('主行禁用停稳时旗标不阻断判定(行确已停,残留旗标无意
|
|
|
101
101
|
assert.equal(detectDeadState(rows, { applyState: () => 'pending' }), true)
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
+
test('运行时接管常态:主行崩(pending)且官方两行启用 → 非死态(官方自服务,guard 空闲)', () => {
|
|
105
|
+
const crashed = loader([
|
|
106
|
+
row({ id: MAIN }),
|
|
107
|
+
row({ id: OFF_TOOL }),
|
|
108
|
+
row({ id: OFF_EXEC }),
|
|
109
|
+
])
|
|
110
|
+
assert.equal(detectDeadState(crashed, { applyState: () => 'pending' }), false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('rowScan:全树枚举 pwsh 行(声明态类型/求值禁用/fiber 存活),无 root 按空', () => {
|
|
114
|
+
assert.deepEqual(rowScan({}), [])
|
|
115
|
+
assert.deepEqual(rowScan(undefined), [])
|
|
116
|
+
const entryOf = (r) => ({
|
|
117
|
+
options: { id: r.id, name: r.name ?? `@x/${r.id}`, disabled: r.rawDisabled ?? r.disabled },
|
|
118
|
+
get disabled() {
|
|
119
|
+
if (r.disabled === 'throw') throw new Error('expr eval failed')
|
|
120
|
+
return r.disabled
|
|
121
|
+
},
|
|
122
|
+
fiber: r.running ? { uid: 'f1' } : undefined,
|
|
123
|
+
})
|
|
124
|
+
const treeLoader = (rows) => ({ root: { tree: { store: Object.fromEntries(rows.map((r) => [r.id, entryOf(r)])) } } })
|
|
125
|
+
const hits = rowScan(treeLoader([
|
|
126
|
+
{ id: 'tool-pwsh', disabled: true },
|
|
127
|
+
{ id: 'pwsh-sandbox', rawDisabled: { expr: true }, disabled: false, running: true },
|
|
128
|
+
{ id: 'shell-select', disabled: false },
|
|
129
|
+
]))
|
|
130
|
+
assert.deepEqual(hits, [
|
|
131
|
+
{ id: 'tool-pwsh', rawType: 'boolean', evalDisabled: true, running: false },
|
|
132
|
+
{ id: 'pwsh-sandbox', rawType: 'object', evalDisabled: false, running: true },
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('rowScan:disabled getter 抛错按 throw 标记,不中断扫描', () => {
|
|
137
|
+
const entry = (r) => ({
|
|
138
|
+
options: { id: r.id, disabled: false },
|
|
139
|
+
get disabled() {
|
|
140
|
+
throw new Error('expr eval failed')
|
|
141
|
+
},
|
|
142
|
+
fiber: undefined,
|
|
143
|
+
})
|
|
144
|
+
const treeLoader = (rows) => ({ root: { tree: { store: Object.fromEntries(rows.map((r) => [r.id, entry(r)])) } } })
|
|
145
|
+
const hits = rowScan(treeLoader([{ id: 'pwsh-sandbox' }]))
|
|
146
|
+
assert.deepEqual(hits, [{ id: 'pwsh-sandbox', rawType: 'boolean', evalDisabled: 'throw', running: false }])
|
|
147
|
+
})
|
|
148
|
+
|
|
104
149
|
test('officialRowConfig:读 settings.yaml shell 节,缺失返回空对象', async () => {
|
|
105
150
|
const section = await officialRowConfig(async () => ({ shell: { pwshPath: 'C:\\x\\pwsh.exe' } }))
|
|
106
151
|
assert.deepEqual(section, { pwshPath: 'C:\\x\\pwsh.exe' })
|
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
// preset
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// preset 接管守卫(0.1.7 预设架构):本包 patch insert 自带预设声明
|
|
2
|
+
// (preset-shell-select,standard 内联 roster − tool-pwsh 行)并覆写
|
|
3
|
+
// agent-preset-registry 的 default。漂移守卫对照已安装 dsh 的
|
|
4
|
+
// dsh-web-app/presets/standard.patch.yml——上游增删行/改表达式即红,升级后
|
|
5
|
+
// 按官方实文对表镜像;宿主缺失(无 dsh 本体的最小环境)时 skip,不制造假红。
|
|
5
6
|
|
|
6
7
|
import { test } from 'node:test'
|
|
7
8
|
import assert from 'node:assert/strict'
|
|
8
|
-
import { readFileSync, existsSync,
|
|
9
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
9
10
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
10
11
|
import { dirname, join, resolve } from 'node:path'
|
|
11
12
|
import { createRequire } from 'node:module'
|
|
12
|
-
import { tmpdir } from 'node:os'
|
|
13
13
|
import { execPath } from 'node:process'
|
|
14
14
|
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
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
17
|
const patchText = readFileSync(join(pkgRoot, 'cordis.patch.yml'), 'utf8')
|
|
20
18
|
|
|
21
19
|
// ── 宿主侧 yaml 能力探测:js-yaml 与官方 entryListSchema 经 dsh 安装位解析 ──
|
|
@@ -41,8 +39,6 @@ function locateHarnessDir() {
|
|
|
41
39
|
// yaml 能力与宿主解耦:js-yaml 为 devDep(CI 无宿主也可解析本包文件)。
|
|
42
40
|
// !!js 标签本地同形 Type:tag 必须写完整 URI(tag:yaml.org,2002:js)——文档简写
|
|
43
41
|
// !!js 展开后按完整 URI 匹配,字面 '!!js' 永不命中(unknown tag,CI 假绿教训)。
|
|
44
|
-
// 形态场景恒走 LOCAL_SCHEMA(本机/CI 同路径);漂移守卫在场时改用官方
|
|
45
|
-
// entryListSchema 求同(对照面只在有宿主时跑)
|
|
46
42
|
import yamlStatic from 'js-yaml'
|
|
47
43
|
|
|
48
44
|
const LOCAL_SCHEMA = yamlStatic.JSON_SCHEMA.extend(new yamlStatic.Type('tag:yaml.org,2002:js', {
|
|
@@ -68,7 +64,9 @@ const loadYaml = (text) => yamlStatic.load(text, { schema: entryListSchema })
|
|
|
68
64
|
// 镜像 loader 的表达式求值形态(new Function + with(ctx),全局可用)
|
|
69
65
|
const evalExpr = (expr, ctx) => new Function('ctx', `with (ctx) { return (${expr}) }`)(ctx)
|
|
70
66
|
|
|
71
|
-
// ── 结构对比:展平行树为 id → {name, disabled, config
|
|
67
|
+
// ── 结构对比:展平行树为 id → {name, disabled, isolate, config 全值} ──
|
|
68
|
+
// config 比全值而非键集:键集守不住文本漂移(plan-mode section 曾落后官方一句
|
|
69
|
+
// 而守卫全绿),镜像声明即「standard 全量 − tool-pwsh 逐项同构」,值必须对表
|
|
72
70
|
|
|
73
71
|
function flattenRows(rows, out = new Map()) {
|
|
74
72
|
for (const row of rows ?? []) {
|
|
@@ -76,7 +74,8 @@ function flattenRows(rows, out = new Map()) {
|
|
|
76
74
|
out.set(row.id, {
|
|
77
75
|
name: row.name,
|
|
78
76
|
disabled: row.disabled === undefined ? undefined : row.disabled.__jsExpr ?? row.disabled,
|
|
79
|
-
|
|
77
|
+
isolate: row.isolate === undefined ? undefined : JSON.parse(JSON.stringify(row.isolate)),
|
|
78
|
+
config: row.config === undefined ? undefined : JSON.parse(JSON.stringify(row.config)),
|
|
80
79
|
})
|
|
81
80
|
}
|
|
82
81
|
if (row.group && Array.isArray(row.config)) flattenRows(row.config, out)
|
|
@@ -84,18 +83,16 @@ function flattenRows(rows, out = new Map()) {
|
|
|
84
83
|
return out
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
//
|
|
86
|
+
// 本包 patch 解析:insert 的预设声明行 + 各覆写行
|
|
87
|
+
const patchRows = yamlStatic.load(patchText, { schema: LOCAL_SCHEMA })
|
|
88
|
+
const insertRows = patchRows.filter((entry) => entry.insert !== undefined).flatMap((entry) => entry.insert)
|
|
89
|
+
const ourPresetRow = insertRows.find((row) => row.id === 'preset-shell-select')
|
|
90
|
+
const ourPlugins = ourPresetRow?.config?.plugins ?? []
|
|
88
91
|
|
|
89
|
-
test('
|
|
90
|
-
|
|
91
|
-
assert.
|
|
92
|
-
assert.
|
|
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), '预设组合必须是行数组')
|
|
92
|
+
test('patch 形态:自带预设声明行(preset-shell-select,0.1.7 内联架构)', () => {
|
|
93
|
+
assert.ok(ourPresetRow, 'patch 缺 preset-shell-select 声明(insert 段)')
|
|
94
|
+
assert.equal(ourPresetRow.name, '@deepseek-ai/dsh-agent-preset')
|
|
95
|
+
assert.equal(ourPresetRow.config.id, 'shell-select')
|
|
99
96
|
const ids = new Set()
|
|
100
97
|
const walk = (list) => {
|
|
101
98
|
for (const row of list) {
|
|
@@ -103,17 +100,35 @@ test('预设形态:agent.cordis.yml 可解析且无 tool-pwsh 行', () => {
|
|
|
103
100
|
if (row.group && Array.isArray(row.config)) walk(row.config)
|
|
104
101
|
}
|
|
105
102
|
}
|
|
106
|
-
walk(
|
|
103
|
+
walk(ourPlugins)
|
|
107
104
|
assert.ok(ids.has('tool-bash'), 'POSIX bash 工具行必须保留')
|
|
108
105
|
assert.ok(!ids.has('tool-pwsh'), 'tool-pwsh 行必须移除(win32 shell 面由本包接管)')
|
|
106
|
+
assert.ok(!ids.has('persistent-pwsh'), 'persistent-pwsh 行不得进入本包预设')
|
|
109
107
|
})
|
|
110
108
|
|
|
111
|
-
test('
|
|
109
|
+
test('patch 形态:agent-preset-registry 覆写含平台 default(patch 语义为整体替换 config)', () => {
|
|
110
|
+
const row = patchRows.find((entry) => entry.id === 'agent-preset-registry')
|
|
111
|
+
assert.ok(row, 'patch 缺 agent-preset-registry 覆写行')
|
|
112
|
+
assert.equal(row.name, undefined, 'registry 行是本包上层已 insert 的行,覆写按 id 匹配即无须 name')
|
|
113
|
+
assert.match(row.config.default.__jsExpr, /'shell-select'/)
|
|
114
|
+
assert.match(row.config.default.__jsExpr, /'standard'/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('patch 表达式求值:default 按平台切换', () => {
|
|
118
|
+
const row = patchRows.find((entry) => entry.id === 'agent-preset-registry')
|
|
119
|
+
const expected = process.platform === 'win32' ? 'shell-select' : 'standard'
|
|
120
|
+
assert.equal(evalExpr(row.config.default.__jsExpr, {}), expected)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('漂移守卫:与已安装 standard 预设逐行对表(缺失宿主则 skip)', (t) => {
|
|
112
124
|
if (harness === null) return t.skip('未找到 dsh 本体安装,无对照源')
|
|
113
|
-
const standardPath = join(harness, 'node_modules', '@deepseek-ai', 'dsh-
|
|
114
|
-
if (!existsSync(standardPath)) return t.skip('dsh 安装位无 standard
|
|
115
|
-
const
|
|
116
|
-
const
|
|
125
|
+
const standardPath = join(harness, 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml')
|
|
126
|
+
if (!existsSync(standardPath)) return t.skip('dsh 安装位无 standard 预设声明,无对照源')
|
|
127
|
+
const standardPatch = yamlStatic.load(readFileSync(standardPath, 'utf8'), { schema: LOCAL_SCHEMA })
|
|
128
|
+
const declared = standardPatch.filter((entry) => entry.insert !== undefined).flatMap((entry) => entry.insert).find((row) => row.id === 'preset-standard')
|
|
129
|
+
assert.ok(declared, 'standard.patch.yml 缺 preset-standard 声明')
|
|
130
|
+
const standard = flattenRows(declared.config.plugins)
|
|
131
|
+
const ours = flattenRows(ourPlugins)
|
|
117
132
|
for (const [id, row] of standard) {
|
|
118
133
|
if (id === 'tool-pwsh') {
|
|
119
134
|
assert.ok(!ours.has(id), 'standard 有 tool-pwsh 而本包预设也出现了:镜像被污染')
|
|
@@ -131,44 +146,9 @@ test('schema 同构:本地 !!js Type 与官方 entryListSchema 产物 deepEqual(
|
|
|
131
146
|
if (harness === null) return t.skip('未找到 dsh 本体安装,无官方 schema 对照')
|
|
132
147
|
// harness 命中时 entryListSchema 已是官方版:loadYaml=官方路径,与 LOCAL_SCHEMA 逐文本对照。
|
|
133
148
|
// 本地同形标签的语义漂移曾以 unknown tag 形态炸穿 CI,此处固化为机器断言
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
}
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
yamlStatic.load(patchText, { schema: LOCAL_SCHEMA }),
|
|
151
|
+
loadYaml(patchText),
|
|
152
|
+
'bundle patch 双 schema 产物漂移,本地 !!js Type 须与官方 JsExpr 同构',
|
|
153
|
+
)
|
|
174
154
|
})
|