@mzzsfy/dsh-shell-select 0.3.0 → 0.4.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 +10 -4
- package/cordis.patch.yml +181 -29
- package/package.json +19 -13
- package/src/apply-state.mjs +13 -0
- package/src/client.js +108 -49
- package/src/config.mjs +108 -14
- package/src/executor.mjs +204 -101
- package/src/guard-state.mjs +26 -0
- package/src/guard.js +14 -6
- package/src/index.js +157 -0
- package/src/takeover.mjs +167 -0
- package/src/tool.mjs +39 -13
- package/src/web-routes.mjs +91 -0
- package/test/client-card.test.mjs +24 -4
- package/test/client-render.test.mjs +170 -0
- package/test/config-entry.test.mjs +13 -8
- package/test/config.test.mjs +57 -4
- package/test/deny-config.test.mjs +8 -8
- package/test/executor-seam.test.mjs +3 -3
- package/test/executor.test.mjs +150 -121
- package/test/guard-state.test.mjs +46 -1
- package/test/preset-drift.test.mjs +49 -69
- package/test/takeover-shell.test.mjs +397 -0
- package/test/web-routes.test.mjs +267 -0
package/test/config.test.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
import { test } from 'node:test'
|
|
5
5
|
import assert from 'node:assert/strict'
|
|
6
|
-
import {
|
|
6
|
+
import { resolveConfig, defaultConfig, entryById, requireEntry, buildArgv, KINDS, RESOLVED_AUTO } from '../src/config.mjs'
|
|
7
7
|
|
|
8
8
|
test('schema 应用出厂默认:三客户端 + 默认 pwsh', () => {
|
|
9
|
-
const applied =
|
|
9
|
+
const applied = resolveConfig({})
|
|
10
10
|
assert.deepEqual(applied, defaultConfig())
|
|
11
11
|
assert.equal(applied.default, 'pwsh')
|
|
12
12
|
assert.deepEqual(applied.shells.map((entry) => entry.id), ['pwsh', 'git-bash', 'cmd'])
|
|
@@ -17,7 +17,60 @@ test('schema 应用出厂默认:三客户端 + 默认 pwsh', () => {
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
test('schema 拒绝未知 kind;default 跨字段一致性由 validate hook 与 requireEntry 兜底', () => {
|
|
20
|
-
assert.throws(() =>
|
|
20
|
+
assert.throws(() => resolveConfig({ shells: [{ id: 'x', name: 'X', kind: 'fish', path: '' }], default: 'x' }))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('落盘坏形态防御:volatile 字段被序列化成对象时降级默认,行构造不炸', () => {
|
|
24
|
+
// 事故:原生设置页/ref 序列化把 shells 落盘为 {},冷启动 Config 抛
|
|
25
|
+
// "expected array but got [object Object]" → 行死 → settings 无节 →
|
|
26
|
+
// 设置页 "no longer configurable" 死锁。类型不符的键必须降级默认。
|
|
27
|
+
const poisoned = {
|
|
28
|
+
shells: {},
|
|
29
|
+
deny: { 0: 'rm' },
|
|
30
|
+
default: 42,
|
|
31
|
+
timeoutMs: 'forever',
|
|
32
|
+
cwd: ['C:\\'],
|
|
33
|
+
good: 'ignored-by-schema',
|
|
34
|
+
}
|
|
35
|
+
const applied = resolveConfig(poisoned)
|
|
36
|
+
assert.deepEqual(applied, defaultConfig())
|
|
37
|
+
// 合法值原样保留,清洗不做过度剥离
|
|
38
|
+
const valid = resolveConfig({ shells: [{ id: 'pwsh', kind: 'pwsh' }], default: 'pwsh', deny: ['rm -rf'], cwd: 'C:\\tmp', timeoutMs: 5000 })
|
|
39
|
+
assert.equal(valid.shells.length, 1)
|
|
40
|
+
assert.equal(valid.default, 'pwsh')
|
|
41
|
+
assert.deepEqual(valid.deny, ['rm -rf'])
|
|
42
|
+
assert.equal(valid.cwd, 'C:\\tmp')
|
|
43
|
+
assert.equal(valid.timeoutMs, 5000)
|
|
44
|
+
// 非对象整体(null/数组/标量)= 无配置,出厂默认
|
|
45
|
+
for (const junk of [null, 'x', 42, ['shells']]) {
|
|
46
|
+
assert.deepEqual(resolveConfig(junk), defaultConfig())
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('冻结输入防御:volatile ref 深冻结快照喂入不改写不抛(挂载路径)', () => {
|
|
51
|
+
// 事故:cosmokit createVolatile 的 snapshot 对 volatile 值逐层 Object.freeze,
|
|
52
|
+
// 行 Config ref .get() 产物是深冻结对象;sanitizeConfigEntry 引用透传,
|
|
53
|
+
// schemastery resolve 原地改写入参(填默认)撞冻结 env 抛
|
|
54
|
+
// "Cannot assign to read only property 'MSYSTEM'" → 执行器挂载失败,
|
|
55
|
+
// 每次启动回滚官方 pwsh 链,shell 工具永不注册。
|
|
56
|
+
// 契约:resolveConfig 对输入不可变(与 unwrapConfig 输出 clone 对称)。
|
|
57
|
+
const frozenSection = Object.freeze({
|
|
58
|
+
shells: Object.freeze([
|
|
59
|
+
Object.freeze({ id: 'pwsh', kind: 'pwsh', path: '', args: Object.freeze([]), env: Object.freeze({}) }),
|
|
60
|
+
Object.freeze({ id: 'git-bash', kind: 'bash', path: '', args: Object.freeze([]), login: true, distro: '', env: Object.freeze({ MSYSTEM: 'MINGW64' }) }),
|
|
61
|
+
]),
|
|
62
|
+
default: 'git-bash',
|
|
63
|
+
})
|
|
64
|
+
const applied = resolveConfig(frozenSection)
|
|
65
|
+
assert.equal(applied.default, 'git-bash')
|
|
66
|
+
assert.deepEqual(applied.shells.map((entry) => entry.id), ['pwsh', 'git-bash'])
|
|
67
|
+
assert.deepEqual(applied.shells[1].env, { MSYSTEM: 'MINGW64' })
|
|
68
|
+
// 二轮 resolve(行重载/快照回灌常态)同样幂等
|
|
69
|
+
const again = resolveConfig(frozenSection)
|
|
70
|
+
assert.deepEqual(again, applied)
|
|
71
|
+
// 输出与冻结输入脱耦:改输出不 affects 输入,反复解析结果稳定
|
|
72
|
+
applied.shells[1].env.MSYSTEM = 'TERM'
|
|
73
|
+
assert.deepEqual(resolveConfig(frozenSection).shells[1].env, { MSYSTEM: 'MINGW64' })
|
|
21
74
|
})
|
|
22
75
|
|
|
23
76
|
test('pwsh argv:非交互形 + 编码前缀', () => {
|
|
@@ -50,7 +103,7 @@ test('login 显式配置与自定义 args 模板互斥时模板优先', () => {
|
|
|
50
103
|
})
|
|
51
104
|
|
|
52
105
|
test('schema 往返:login 布尔保留,旧配置缺省落 false', () => {
|
|
53
|
-
const applied =
|
|
106
|
+
const applied = resolveConfig({ shells: [
|
|
54
107
|
{ id: 'm', name: 'MSYS2', kind: 'bash', path: 'C:\\msys64\\usr\\bin\\bash.exe', login: true },
|
|
55
108
|
{ id: 'g', name: 'Git Bash', kind: 'bash', path: '' },
|
|
56
109
|
], default: 'm' })
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// deny 顶层字段:schema 往返与 updateConfig 落盘回读。
|
|
1
|
+
// deny 顶层字段:schema 往返与 updateConfig 落盘回读。
|
|
2
2
|
// BDD 场景见 docs/progress/feat-shell-select-deny.md 场景 7。
|
|
3
3
|
|
|
4
4
|
import { test } from 'node:test'
|
|
@@ -6,10 +6,10 @@ import assert from 'node:assert/strict'
|
|
|
6
6
|
import { readFileSync } from 'node:fs'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import { dirname, join } from 'node:path'
|
|
9
|
-
import {
|
|
9
|
+
import { resolveConfig, defaultConfig } from '../src/config.mjs'
|
|
10
10
|
|
|
11
11
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
12
|
-
const
|
|
12
|
+
const webRoutesSource = readFileSync(join(here, '..', 'src', 'web-routes.mjs'), 'utf8')
|
|
13
13
|
|
|
14
14
|
test('schema 默认:deny 空数组,旧配置反序列化补默认', () => {
|
|
15
15
|
const config = defaultConfig()
|
|
@@ -17,17 +17,17 @@ test('schema 默认:deny 空数组,旧配置反序列化补默认', () => {
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
test('schema 往返:deny 保留;allow 无消费者(deny 绝对,无豁免语义)', () => {
|
|
20
|
-
const config =
|
|
20
|
+
const config = resolveConfig({ deny: ['format '], allow: ['git .*'] })
|
|
21
21
|
assert.deepEqual(config.deny, ['format '])
|
|
22
22
|
// schemastery 透传未知键,僵尸键靠"无读取方"守卫:matchDeny/toSection 均只触 deny
|
|
23
23
|
const denylistSource = readFileSync(join(here, '..', 'src', 'denylist.mjs'), 'utf8')
|
|
24
24
|
const clientSource = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
25
25
|
assert.doesNotMatch(denylistSource, /allow/)
|
|
26
26
|
assert.doesNotMatch(clientSource, /allowText|section\.allow/)
|
|
27
|
-
assert.doesNotMatch(
|
|
27
|
+
assert.doesNotMatch(webRoutesSource, /current\.allow|patch\.allow/)
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
test('updateConfig 携带 deny(wholesale replace 防静默重置,同 login
|
|
31
|
-
assert.match(
|
|
32
|
-
assert.doesNotMatch(
|
|
30
|
+
test('updateConfig 携带 deny(wholesale replace 防静默重置,同 login 教训;写路径在 web 行)', () => {
|
|
31
|
+
assert.match(webRoutesSource, /deny: Array\.isArray\(patch\.deny\) \? patch\.deny : current\.deny/)
|
|
32
|
+
assert.doesNotMatch(webRoutesSource, /patch\.allow/)
|
|
33
33
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// executor 官方 seam 集成(桩 ctx):BDD 场景见本文件与 README「官方工具兼容」。
|
|
1
|
+
// executor 官方 seam 集成(桩 ctx):BDD 场景见本文件与 README「官方工具兼容」。
|
|
2
2
|
// 背景:preset 注入的官方 tool-pwsh 经 cordis 服务代理(ctx.shell)调用执行器,
|
|
3
3
|
// 代理把方法 this 重定向到阴影对象;执行器公开面必须与官方 PwshLocalExecutor
|
|
4
4
|
// 同构地使用公有字段/方法(#私有在阴影 receiver 下触发品牌检查错误)。
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { test } from 'node:test'
|
|
7
7
|
import assert from 'node:assert/strict'
|
|
8
8
|
import ShellSelectExecutor from '../src/executor.mjs'
|
|
9
|
-
import {
|
|
9
|
+
import { resolveConfig } from '../src/config.mjs'
|
|
10
10
|
|
|
11
11
|
function stubReader(text) {
|
|
12
12
|
return { readFrom: () => ({ text, lossy: false, nextOffset: text.length }) }
|
|
@@ -33,7 +33,7 @@ function stubCtx({ sandboxMode = 'danger-full-access', spawn = stubSpawn() } = {
|
|
|
33
33
|
const settingsImpl = {
|
|
34
34
|
installSection: (ctx, ns, schema, base, hooks) => {
|
|
35
35
|
registered.sections.push({ ns, schema, base, hooks })
|
|
36
|
-
settingsImpl._source = () =>
|
|
36
|
+
settingsImpl._source = () => resolveConfig(base ?? {})
|
|
37
37
|
},
|
|
38
38
|
}
|
|
39
39
|
const ctx = {
|
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 } 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,49 @@ 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
|
+
assert.match(pwsh.description, /PowerShell/)
|
|
129
|
+
assert.ok(!('shell' in pwsh.parameters.properties))
|
|
130
|
+
assert.ok('shell' in shell.parameters.properties)
|
|
118
131
|
assert.equal(registered.promptSections.length, 1)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
assert.equal(routePaths.length, 3)
|
|
123
|
-
assert.equal(new Set(routePaths).size, 3)
|
|
132
|
+
// 数据通道迁至 web-routes 行:主行零路由
|
|
133
|
+
const routePaths = registered.routes.filter((path) => String(path).startsWith('/api/shell-select/'))
|
|
134
|
+
assert.equal(routePaths.length, 0)
|
|
124
135
|
})
|
|
125
136
|
|
|
126
|
-
test('settings
|
|
137
|
+
test('settings 在场:注册 auto:false 页面策略挂本行 fiber(自带设置页,官方同构)', () => {
|
|
138
|
+
const { ctx, registered } = stubCtx()
|
|
139
|
+
const fiber = { id: 'fiber-1' }
|
|
140
|
+
ctx.fiber = fiber
|
|
141
|
+
new ShellSelectExecutor(ctx, baseConfig())
|
|
142
|
+
assert.equal(registered.policies.length, 1)
|
|
143
|
+
assert.deepEqual(registered.policies[0].presentation, { auto: false })
|
|
144
|
+
assert.equal(registered.policies[0].fiber, fiber)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('settings 缺席:策略静默跳过,双工具照常注册(业务插件无 settings 也可运行)', () => {
|
|
127
148
|
const { ctx, registered } = stubCtx({ withSettings: false })
|
|
128
149
|
new ShellSelectExecutor(ctx, baseConfig())
|
|
129
|
-
assert.equal(registered.
|
|
130
|
-
assert.equal(registered.tools.length,
|
|
150
|
+
assert.equal(registered.policies.length, 0)
|
|
151
|
+
assert.equal(registered.tools.length, 2)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('pwsh 工具钉死 pwsh 客户端:default 漂移不改变落点(旧会话语义保真)', async () => {
|
|
155
|
+
const spawn = stubSpawn('ok', '', 0)
|
|
156
|
+
const { ctx, registered } = stubCtx({ spawn })
|
|
157
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
158
|
+
const pwshTool = registered.tools.find((tool) => tool.name === 'pwsh')
|
|
159
|
+
// 模型无 shell 参数可传(pinned 工具),default 漂到 git-bash 也不影响
|
|
160
|
+
executor.refresh({ shells: baseConfig().shells, default: 'git-bash' })
|
|
161
|
+
await pwshTool.execute({ command: 'Get-Date', description: 'probe', workdir: process.cwd() }, { signal: new AbortController().signal })
|
|
162
|
+
assert.equal(spawn.calls[0].argv[0], NODE_EXE)
|
|
163
|
+
assert.match(spawn.calls[0].argv.at(-1), /Get-Date$/)
|
|
164
|
+
assert.deepEqual(spawn.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
|
|
131
165
|
})
|
|
132
166
|
|
|
133
167
|
test('listShells:出厂条目自动解析为真实路径(pwsh/cmd 必在本机)', () => {
|
|
@@ -223,102 +257,6 @@ test('runFor pwsh 条目:非交互形 + 编码前缀进 argv', async () => {
|
|
|
223
257
|
assert.match(argv[5], /OutputEncoding/)
|
|
224
258
|
})
|
|
225
259
|
|
|
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
260
|
test('entryFor:双反斜杠污染路径被归一后仍可用', () => {
|
|
323
261
|
const { executor } = stubCtxAndBuild()
|
|
324
262
|
const ctx2 = stubCtx()
|
|
@@ -368,9 +306,9 @@ test('startFor danger:返回进程句柄,readOutput 增量与 done 定局', asyn
|
|
|
368
306
|
})
|
|
369
307
|
|
|
370
308
|
test('assertServiceableConfig:空 shells/default 不在列/重复 id 全拒', () => {
|
|
371
|
-
assert.throws(() => assertServiceableConfig(
|
|
372
|
-
assert.throws(() => assertServiceableConfig(
|
|
373
|
-
assert.throws(() => assertServiceableConfig(
|
|
309
|
+
assert.throws(() => assertServiceableConfig(resolveConfig({ shells: [], default: 'x' })), /shells/)
|
|
310
|
+
assert.throws(() => assertServiceableConfig(resolveConfig({ shells: [{ id: 'a', name: 'A', kind: 'bash', path: '' }], default: 'b' })), /default/)
|
|
311
|
+
assert.throws(() => assertServiceableConfig(resolveConfig({
|
|
374
312
|
shells: [
|
|
375
313
|
{ id: 'a', name: 'A', kind: 'bash', path: '' },
|
|
376
314
|
{ id: 'a', name: 'A2', kind: 'cmd', path: '' },
|
|
@@ -378,3 +316,94 @@ test('assertServiceableConfig:空 shells/default 不在列/重复 id 全拒', ()
|
|
|
378
316
|
default: 'a',
|
|
379
317
|
})), /duplicate/)
|
|
380
318
|
})
|
|
319
|
+
|
|
320
|
+
// --- execute 官方契约(ShellExecutor 抽象方法,官方 tool-pwsh 唯一执行入口)---
|
|
321
|
+
|
|
322
|
+
test('execute 契约:danger 直跑,返回句柄形态,result() 前台投影无 sandbox 键', async () => {
|
|
323
|
+
const spawn = stubSpawn('exec-out', '', 0)
|
|
324
|
+
const { ctx } = stubCtx({ spawn })
|
|
325
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
326
|
+
const handle = await executor.execute(executor.resolve({ command: 'hi', workdir: process.cwd() }))
|
|
327
|
+
assert.ok(['running', 'completed'].includes(handle.status))
|
|
328
|
+
assert.equal(typeof handle.readOutput, 'function')
|
|
329
|
+
assert.equal(typeof handle.kill, 'function')
|
|
330
|
+
assert.equal(typeof handle.observed.stdout.readFrom, 'function')
|
|
331
|
+
const result = await handle.result()
|
|
332
|
+
assert.equal(result.exitCode, 0)
|
|
333
|
+
assert.equal(result.timedOut, false)
|
|
334
|
+
assert.equal(result.aborted, false)
|
|
335
|
+
assert.equal(result.stdout.text, 'exec-out')
|
|
336
|
+
assert.equal(result.sandbox, undefined)
|
|
337
|
+
assert.equal(spawn.calls[0].argv[0], NODE_EXE)
|
|
338
|
+
assert.match(spawn.calls[0].argv.at(-1), /hi$/)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('execute 契约:onExpiry none 不武装 deadline(后台注册形态)', async () => {
|
|
342
|
+
const spawn = stubSpawn('bg', '', 0)
|
|
343
|
+
const { ctx } = stubCtx({ spawn })
|
|
344
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
345
|
+
const handle = await executor.execute(executor.resolve({ command: 'x', workdir: process.cwd(), onExpiry: 'none' }))
|
|
346
|
+
const result = await handle.result()
|
|
347
|
+
assert.equal(result.exitCode, 0)
|
|
348
|
+
assert.equal(result.timedOut, false)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('execute 契约:provider spawn 失败,done 定局 killed 不 reject,result() 携带同一失败', async () => {
|
|
352
|
+
const spawn = { spawn: () => { throw new Error('spawn exploded') } }
|
|
353
|
+
const { ctx } = stubCtx({ spawn })
|
|
354
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
355
|
+
const handle = await executor.execute(executor.resolve({ command: 'x', workdir: process.cwd() }))
|
|
356
|
+
await handle.done
|
|
357
|
+
assert.equal(handle.status, 'killed')
|
|
358
|
+
const read = handle.readOutput()
|
|
359
|
+
assert.match(read.delta, /subprocess failed before reporting an outcome/)
|
|
360
|
+
await assert.rejects(() => handle.result(), /spawn exploded/)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('execute 契约:受限模式经 confine,定案写 proc.sandbox 并入 result', async () => {
|
|
364
|
+
const spawn = stubSpawn('', 'file access denied under read-only', 1)
|
|
365
|
+
const { ctx } = stubCtx({ spawn, sandboxMode: 'read-only' })
|
|
366
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
367
|
+
const handle = await executor.execute(executor.resolve({ command: 'dir', workdir: process.cwd() }))
|
|
368
|
+
assert.equal(spawn.calls[0].argv[0], 'WRAPPED')
|
|
369
|
+
const result = await handle.result()
|
|
370
|
+
assert.equal(result.sandbox.mode, 'read-only')
|
|
371
|
+
assert.equal(result.sandbox.denied, true)
|
|
372
|
+
assert.equal(result.sandbox.enforcement, 'full')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('execute 契约:deny 命中拒绝执行,不产生句柄', async () => {
|
|
376
|
+
const spawn = stubSpawn('', '', 0)
|
|
377
|
+
const { ctx } = stubCtx({ spawn })
|
|
378
|
+
const executor = new ShellSelectExecutor(ctx, { ...baseConfig(), deny: ['rm -rf'] })
|
|
379
|
+
await assert.rejects(
|
|
380
|
+
() => executor.execute(executor.resolve({ command: 'rm -rf /', workdir: process.cwd() })),
|
|
381
|
+
(error) => error.code === 'SHELL_COMMAND_BLOCKED',
|
|
382
|
+
)
|
|
383
|
+
assert.equal(spawn.calls.length, 0)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test('execute 契约:默认客户端解析,显式 onExpiry kill 正常完成', async () => {
|
|
387
|
+
const spawn = stubSpawn('ok', '', 0)
|
|
388
|
+
const { ctx } = stubCtx({ spawn })
|
|
389
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
390
|
+
const handle = await executor.execute(executor.resolve({ command: 'x', workdir: process.cwd(), onExpiry: 'kill', timeoutMs: 5000 }))
|
|
391
|
+
const result = await handle.result()
|
|
392
|
+
assert.equal(result.exitCode, 0)
|
|
393
|
+
assert.equal(result.timeoutMs, 5000)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('refresh:换源后执行面即时收紧(已删 id 拒绝,新清单生效)', async () => {
|
|
397
|
+
const { ctx, registered } = stubCtx()
|
|
398
|
+
const executor = new ShellSelectExecutor(ctx, baseConfig())
|
|
399
|
+
assert.equal(executor.entryFor('cmd').id, 'cmd')
|
|
400
|
+
const toolsBefore = registered.tools.length
|
|
401
|
+
executor.refresh({
|
|
402
|
+
shells: baseConfig().shells.filter((entry) => entry.id !== 'cmd'),
|
|
403
|
+
default: 'pwsh',
|
|
404
|
+
})
|
|
405
|
+
assert.throws(() => executor.entryFor('cmd'), /cmd/)
|
|
406
|
+
assert.equal(executor.entryFor('pwsh').id, 'pwsh')
|
|
407
|
+
assert.equal(executor.entryFor(undefined).id, 'pwsh')
|
|
408
|
+
assert.equal(registered.tools.length, toolsBefore)
|
|
409
|
+
})
|
|
@@ -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' })
|