@mzzsfy/dsh-shell-select 0.4.0 → 0.4.3

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.
@@ -0,0 +1,208 @@
1
+ // 官方 ctx.shell 契约钉死 pwsh + 工具层六场景矩阵(前台/后台 × pwsh/git-bash):
2
+ // BDD:官方 dsh-pwsh-local 契约即 PowerShell 专用,execute/run/start 落默认
3
+ // 客户端会随配置漂移炸方言(官方 pwsh 工具调用在 git-bash 里执行 PowerShell)。
4
+ // 后台分支 owner 必须是 agent id(resolveOwner 以 id 查 registry,传对象报
5
+ // "[object Object] has no live agent");deny 探测不得真实 spawn(命令双跑)。
6
+
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+ import ShellSelectExecutor from '../src/executor.mjs'
10
+ import { registerShellTool } from '../src/tool.mjs'
11
+
12
+ function stubReader(text) {
13
+ return { readFrom: () => ({ text, lossy: false, nextOffset: text.length }) }
14
+ }
15
+
16
+ function stubSpawn(stdoutText = '', stderrText = '', exitCode = 0) {
17
+ const calls = []
18
+ const spawn = (spec) => {
19
+ calls.push(spec)
20
+ return {
21
+ collected: { stdout: stubReader(stdoutText), stderr: stubReader(stderrText) },
22
+ done: Promise.resolve({ exitCode, signal: null }),
23
+ terminate: () => true,
24
+ }
25
+ }
26
+ return { spawn, calls }
27
+ }
28
+
29
+ const SHELLS = [
30
+ { id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: process.execPath },
31
+ { id: 'git-bash', name: 'Git Bash', kind: 'bash', path: process.execPath },
32
+ ]
33
+
34
+ function buildExecutor({ defaultId = 'git-bash', deny = [] } = {}) {
35
+ const stub = stubSpawn('out', '', 0)
36
+ const registered = { tools: [] }
37
+ const jobs = { starts: [] }
38
+ const ctx = {
39
+ reflect: { provide: () => {} },
40
+ on: () => () => {},
41
+ logger: { warn: () => {} },
42
+ get(service) {
43
+ if (service === 'webServer') return { register: () => {} }
44
+ if (service === 'jobs') return jobsApi()
45
+ return undefined
46
+ },
47
+ inject(services, fn) {
48
+ fn({ settings: undefined, effect: (effectFn) => effectFn() })
49
+ },
50
+ subprocess: stub,
51
+ sandbox: { confine: (argv) => ({ argv: ['WRAPPED', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }) },
52
+ sandboxPolicy: { defaultMode: 'danger-full-access', resolve: () => ({ mode: 'danger-full-access', roots: [] }) },
53
+ tools: { register: (definition) => { registered.tools.push(definition); return () => registered.tools.pop() } },
54
+ systemPrompt: { section: () => {}, getSectionOrder: (key) => key },
55
+ shellEnv: { collect: () => ({}) },
56
+ effect: (fn) => fn(),
57
+ }
58
+ function jobsApi() {
59
+ return {
60
+ start(spec) {
61
+ jobs.starts.push(spec)
62
+ const outcome = spec.run()
63
+ return { id: `job-${jobs.starts.length}`, done: outcome.done, read: () => outcome.readOutput() }
64
+ },
65
+ }
66
+ }
67
+ const executor = new ShellSelectExecutor(ctx, { shells: SHELLS, default: defaultId, deny })
68
+ return { executor, ctx, stub, registered, jobs }
69
+ }
70
+
71
+ const execFor = (id) => ({ signal: new AbortController().signal, agent: { id, session: { header: { cwd: process.cwd() } } } })
72
+ const EXEC = execFor('agent-0')
73
+
74
+ test('execute 契约钉死 pwsh:default=git-bash 时仍走 pwsh argv', async () => {
75
+ const { executor, stub } = buildExecutor({ defaultId: 'git-bash' })
76
+ const handle = await executor.execute(executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
77
+ await handle.result()
78
+ assert.match(stub.calls[0].argv.at(-1), /Get-Date$/)
79
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
80
+ })
81
+
82
+ test('start 契约钉死 pwsh:default=git-bash 时仍走 pwsh argv', () => {
83
+ const { executor, stub } = buildExecutor({ defaultId: 'git-bash' })
84
+ const proc = executor.start(executor.resolve({ command: 'Get-Date', workdir: process.cwd(), onExpiry: 'none' }))
85
+ return proc.done.then(() => {
86
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
87
+ })
88
+ })
89
+
90
+ test('配置删除 pwsh 条目:契约现场探测仍走 pwsh,不落默认客户端', async () => {
91
+ const { executor, stub } = buildExecutor({ defaultId: 'git-bash' })
92
+ executor.refresh({ shells: SHELLS.filter((entry) => entry.id !== 'pwsh'), default: 'git-bash' })
93
+ assert.throws(() => executor.entryFor('pwsh'), /pwsh/)
94
+ const entry = executor.contractEntry()
95
+ assert.equal(entry.kind, 'pwsh')
96
+ const handle = await executor.execute(executor.resolve({ command: 'Get-Date', workdir: process.cwd() }))
97
+ await handle.result()
98
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
99
+ })
100
+
101
+ // --- 工具层六场景矩阵 ---
102
+
103
+ test('矩阵:shell 前台 pwsh(git-bash 默认)', async () => {
104
+ const { executor, ctx, stub, registered } = buildExecutor({ defaultId: 'git-bash' })
105
+ registerShellTool(ctx, { executor })
106
+ const shellTool = registered.tools.find((tool) => tool.name === 'shell')
107
+ const value = await shellTool.execute({ command: 'Write-Output hi', description: 'probe', shell: 'pwsh', workdir: process.cwd() }, EXEC)
108
+ assert.equal(value.kind, 'foreground')
109
+ assert.equal(value.shell, 'pwsh')
110
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
111
+ })
112
+
113
+ test('矩阵:shell 前台 git-bash(默认)', async () => {
114
+ const { executor, ctx, stub, registered } = buildExecutor({ defaultId: 'git-bash' })
115
+ registerShellTool(ctx, { executor })
116
+ const shellTool = registered.tools.find((tool) => tool.name === 'shell')
117
+ const value = await shellTool.execute({ command: 'echo hi', description: 'probe', workdir: process.cwd() }, EXEC)
118
+ assert.equal(value.kind, 'foreground')
119
+ assert.equal(value.shell, 'git-bash')
120
+ assert.deepEqual(stub.calls[0].argv.slice(1, 3), ['-c', 'echo hi'])
121
+ })
122
+
123
+ test('矩阵:pwsh 工具前台(钉死 pwsh)', async () => {
124
+ const { executor, ctx, stub, registered } = buildExecutor({ defaultId: 'git-bash' })
125
+ registerShellTool(ctx, { executor })
126
+ const pwshTool = registered.tools.find((tool) => tool.name === 'pwsh')
127
+ const value = await pwshTool.execute({ command: 'Get-Date', description: 'probe', workdir: process.cwd() }, EXEC)
128
+ assert.equal(value.shell, 'pwsh')
129
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
130
+ })
131
+
132
+ async function backgroundCase({ shellArg, defaultId }) {
133
+ const { executor, ctx, stub, registered, jobs } = buildExecutor({ defaultId })
134
+ registerShellTool(ctx, { executor })
135
+ const shellTool = registered.tools.find((tool) => tool.name === 'shell')
136
+ const args = { command: 'echo hi', description: 'probe', run_in_background: true, workdir: process.cwd() }
137
+ if (shellArg !== undefined) args.shell = shellArg
138
+ const value = await shellTool.execute(args, { signal: new AbortController().signal, agent: execFor('agent-1').agent })
139
+ assert.equal(value.kind, 'background')
140
+ assert.equal(jobs.starts.length, 1)
141
+ assert.equal(jobs.starts[0].owner, 'agent-1', 'owner 必须是 agent id 而非对象')
142
+ const expectedKind = shellArg === 'pwsh' || (shellArg === undefined && defaultId === 'pwsh') ? 'pwsh' : 'bash'
143
+ assert.equal(executor.entryFor(shellArg).kind, expectedKind)
144
+ return { stub, jobs }
145
+ }
146
+
147
+ test('矩阵:后台 default(git-bash)', async () => {
148
+ const { stub, jobs } = await backgroundCase({ defaultId: 'git-bash' })
149
+ await jobs.starts[0].run // run() 已在 start 内同步执行
150
+ await Promise.resolve()
151
+ assert.equal(stub.calls.length, 1, 'deny 探测不得真实 spawn,整个后台任务只 spawn 一次')
152
+ assert.deepEqual(stub.calls[0].argv.slice(1, 3), ['-c', 'echo hi'])
153
+ })
154
+
155
+ test('矩阵:后台 shell=pwsh', async () => {
156
+ const { stub } = await backgroundCase({ shellArg: 'pwsh', defaultId: 'git-bash' })
157
+ await Promise.resolve()
158
+ assert.equal(stub.calls.length, 1)
159
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
160
+ })
161
+
162
+ test('矩阵:后台 default=pwsh(默认即 pwsh)', async () => {
163
+ const { stub } = await backgroundCase({ defaultId: 'pwsh' })
164
+ await Promise.resolve()
165
+ assert.equal(stub.calls.length, 1)
166
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
167
+ })
168
+
169
+ test('矩阵:后台 pwsh 工具(钉死 pwsh)', async () => {
170
+ const { executor, ctx, stub, registered, jobs } = buildExecutor({ defaultId: 'git-bash' })
171
+ registerShellTool(ctx, { executor })
172
+ const pwshTool = registered.tools.find((tool) => tool.name === 'pwsh')
173
+ const value = await pwshTool.execute(
174
+ { command: 'Get-Date', description: 'probe', run_in_background: true, workdir: process.cwd() },
175
+ { signal: new AbortController().signal, agent: execFor('agent-2').agent },
176
+ )
177
+ assert.equal(value.kind, 'background')
178
+ assert.equal(jobs.starts[0].owner, 'agent-2')
179
+ await Promise.resolve()
180
+ assert.equal(stub.calls.length, 1)
181
+ assert.deepEqual(stub.calls[0].argv.slice(1, 5), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command'])
182
+ })
183
+
184
+ test('本包同名 pwsh 工具结果携带接管事实,而 shell=pwsh 只携带客户端事实', async () => {
185
+ // Given本包两种PowerShell入口;When执行并渲染;Then仅直接pwsh产生独立接管事实。
186
+ const { ctx, registered } = buildExecutor()
187
+ for (const name of ['pwsh', 'shell']) {
188
+ const tool = registered.tools.find(tool => tool.name === name)
189
+ const args = { command: 'Get-Date', description: 'probe', ...(name === 'shell' ? { shell: 'pwsh' } : {}) }
190
+ const value = await tool.execute(args, EXEC)
191
+ const content = tool.output.render(args, value)
192
+ if (name === 'pwsh') assert.deepEqual(content.at(-1), { type: 'shell-select-takeover', tool: 'pwsh' })
193
+ else assert.equal(content.some(part => part.type === 'shell-select-takeover'), false)
194
+ }
195
+ })
196
+
197
+ test('矩阵:后台 deny 命中,启动前拒绝且零 spawn', async () => {
198
+ const { executor, ctx, stub, registered, jobs } = buildExecutor({ defaultId: 'git-bash', deny: ['forbidden'] })
199
+ registerShellTool(ctx, { executor })
200
+ const shellTool = registered.tools.find((tool) => tool.name === 'shell')
201
+ const value = await shellTool.execute(
202
+ { command: 'echo forbidden thing', description: 'probe', run_in_background: true, workdir: process.cwd() },
203
+ { signal: new AbortController().signal, agent: execFor('agent-3').agent },
204
+ )
205
+ assert.equal(value.kind, 'foreground', 'deny 命中回退前台拒绝形态')
206
+ assert.equal(jobs.starts.length, 0)
207
+ assert.equal(stub.calls.length, 0)
208
+ })
@@ -0,0 +1,28 @@
1
+ // bash 路径措辞测试:shellDescription 在 bash 形可用客户端在场时追加路径引导。
2
+ // BDD 场景见 docs/feat-shell-select-pathrescue/plan.md A 节。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { shellDescription } from '../src/tool.mjs'
7
+
8
+ const FACES = { backgroundEnabled: true, escalationModes: [] }
9
+ const entry = (id, kind, available) => ({ id, kind, available })
10
+
11
+ test('A1 含可用 bash 条目:描述含 Windows-style 路径措辞', () => {
12
+ const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('git-bash', 'bash', true)] }) }
13
+ const text = shellDescription({ executor, ...FACES })
14
+ assert.match(text, /Windows-style paths/)
15
+ assert.match(text, /`workdir`/)
16
+ })
17
+
18
+ test('A2 仅 pwsh/cmd:不含措辞', () => {
19
+ const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('cmd', 'cmd', true)] }) }
20
+ const text = shellDescription({ executor, ...FACES })
21
+ assert.doesNotMatch(text, /Windows-style paths/)
22
+ })
23
+
24
+ test('A3 bash 条目不可执行:不含措辞(可执行性事实优先)', () => {
25
+ const executor = { listShells: () => ({ default: 'pwsh', shells: [entry('pwsh', 'pwsh', true), entry('git-bash', 'bash', false)] }) }
26
+ const text = shellDescription({ executor, ...FACES })
27
+ assert.doesNotMatch(text, /Windows-style paths/)
28
+ })
@@ -0,0 +1,173 @@
1
+ // 侧栏路径救援测试:函数映射、地址语法与实际注入生命周期。
2
+ // BDD 场景见 docs/feat-shell-select-pathrescue/plan.md B 节。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { dirname, join } from 'node:path'
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url))
11
+ const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
12
+
13
+ function extractLogic(name, deps = {}) {
14
+ const pattern = new RegExp('// LOGIC-BEGIN ' + name + '\\n([\\s\\S]*?)\\n\\s*// LOGIC-END ' + name)
15
+ const match = source.match(pattern)
16
+ if (!match) throw new Error('client.js 缺少 LOGIC 段: ' + name)
17
+ const keys = Object.keys(deps)
18
+ return new Function(...keys, 'return (' + match[1].trim() + ')')(...keys.map((key) => deps[key]))
19
+ }
20
+
21
+ const FACTS = { platform: 'win32', home: 'C:/Users/me', tmpDir: 'C:/Users/me/AppData/Local/Temp' }
22
+
23
+ function mountClient({ facts, hasEffect = true, laterWrapper = false }) {
24
+ let definition
25
+ let activeFacts = facts
26
+ const registrations = []
27
+ const disposers = []
28
+ const controller = {
29
+ calls: [],
30
+ openResource(address, options) { this.calls.push({ method: 'openResource', address, options }); return 'opened' },
31
+ openResourceIn(sessionId, address, options) { this.calls.push({ method: 'openResourceIn', sessionId, address, options }); return 'opened' },
32
+ }
33
+ const original = { openResource: controller.openResource, openResourceIn: controller.openResourceIn }
34
+ const fetcher = () => activeFacts === null
35
+ ? Promise.reject(new Error('route not ready'))
36
+ : Promise.resolve({ ok: true, json: () => Promise.resolve(activeFacts) })
37
+ new Function('window', 'document', 'navigator', 'fetch', source)(
38
+ { __ModuleLoader__: { load: (value) => { definition = value } } },
39
+ { createElement: () => ({ setAttribute() {}, remove() {} }), head: { appendChild() {} } },
40
+ {}, fetcher,
41
+ )
42
+ const client = definition.factory((name) => name === 'react'
43
+ ? { createElement: () => null, useState: () => [false, () => {}], useEffect() {} }
44
+ : {})
45
+ const ctx = {
46
+ effect: (body) => body(),
47
+ slots: { inject: (_hole, callback) => callback(), register: (meta) => registrations.push(meta) },
48
+ inject: (names, callback) => {
49
+ if (!names.includes('sidebarRight')) return
50
+ callback({
51
+ sidebarRight: controller,
52
+ ...(hasEffect ? { effect: (body) => disposers.push(body()) } : {}),
53
+ })
54
+ },
55
+ }
56
+ client.apply(ctx)
57
+ if (laterWrapper) {
58
+ const wrapped = controller.openResource
59
+ controller.openResource = function later(address, options) { return wrapped.call(this, address, options) }
60
+ }
61
+ return { controller, original, disposers, setFacts: (value) => { activeFacts = value } }
62
+ }
63
+
64
+ test('B5 盘根翻译:/c/x → C:/x,/c → C:/', () => {
65
+ const translate = extractLogic('msysToHostPath')
66
+ assert.equal(translate('/c/Users/x', FACTS), 'C:/Users/x')
67
+ assert.equal(translate('/c', FACTS), 'C:/')
68
+ assert.equal(translate('/C/Users/x', FACTS), 'C:/Users/x')
69
+ })
70
+
71
+ test('B5 home 翻译:~ 与 ~/x', () => {
72
+ const translate = extractLogic('msysToHostPath')
73
+ assert.equal(translate('~', FACTS), 'C:/Users/me')
74
+ assert.equal(translate('~/a/b.txt', FACTS), 'C:/Users/me/a/b.txt')
75
+ })
76
+
77
+ test('B5 tmp 翻译:/tmp 与 /tmp/x', () => {
78
+ const translate = extractLogic('msysToHostPath')
79
+ assert.equal(translate('/tmp', FACTS), 'C:/Users/me/AppData/Local/Temp')
80
+ assert.equal(translate('/tmp/x.log', FACTS), 'C:/Users/me/AppData/Local/Temp/x.log')
81
+ })
82
+
83
+ test('B5 非翻译形态原样:Windows 路径/相对路径/未知 POSIX 段', () => {
84
+ const translate = extractLogic('msysToHostPath')
85
+ assert.equal(translate('C:/repo/a.txt', FACTS), 'C:/repo/a.txt')
86
+ assert.equal(translate('src/index.js', FACTS), 'src/index.js')
87
+ assert.equal(translate('/usr/bin/bash', FACTS), '/usr/bin/bash')
88
+ assert.equal(translate(42, FACTS), 42)
89
+ })
90
+
91
+ test('B6 file 地址翻译:session/absolute、空格与后缀', () => {
92
+ const translate = extractLogic('msysToHostPath')
93
+ const rescue = extractLogic('rescueFileAddress')
94
+ assert.equal(rescue('dsh-resource://file/session/s1//c/Users/x.txt', translate, FACTS), 'dsh-resource://file/session/s1/C:/Users/x.txt')
95
+ assert.equal(rescue('dsh-resource://file/absolute/c/Users/y%20x.md', translate, FACTS), 'dsh-resource://file/absolute/C:/Users/y%20x.md')
96
+ assert.ok(rescue('dsh-resource://file/absolute/c/Users/y.md?line=3', translate, FACTS).endsWith('?line=3'))
97
+ const unchanged = 'dsh-resource://file/absolute/C%3A/Users/z.txt'
98
+ assert.equal(rescue(unchanged, translate, FACTS), unchanged)
99
+ })
100
+
101
+ test('B6 非 file、UNC、坏转义地址原样', () => {
102
+ const translate = extractLogic('msysToHostPath')
103
+ const rescue = extractLogic('rescueFileAddress')
104
+ for (const address of [
105
+ 'dsh-resource://other/abc', 'not an address',
106
+ 'dsh-resource://file/absolute//server/share/x',
107
+ 'dsh-resource://file/absolute/%zz/x',
108
+ ]) assert.equal(rescue(address, translate, FACTS), address)
109
+ })
110
+
111
+ test('B6 编码 sessionId 翻译后只编码一次', () => {
112
+ const translate = extractLogic('msysToHostPath')
113
+ const rescue = extractLogic('rescueFileAddress')
114
+ const address = 'dsh-resource://file/session/s%201//c/Users/x.txt'
115
+ assert.equal(rescue(address, translate, FACTS), 'dsh-resource://file/session/s%201/C:/Users/x.txt')
116
+ })
117
+
118
+ test('B6 地址翻译函数是纯函数,平台门由运行时安装包装负责', () => {
119
+ const translate = extractLogic('msysToHostPath')
120
+ assert.equal(translate('/c/x', null), 'C:/x')
121
+ const rescue = extractLogic('rescueFileAddress')
122
+ assert.equal(rescue('dsh-resource://file/absolute/c/x', translate, FACTS), 'dsh-resource://file/absolute/C:/x')
123
+ })
124
+
125
+ test('B7 可观察的 sidebar 包装透传两个入口并在 fiber dispose 后恢复', async () => {
126
+ const mounted = mountClient({ facts: FACTS })
127
+ await new Promise((resolve) => setImmediate(resolve))
128
+ const { controller, original, disposers } = mounted
129
+ assert.equal(controller.openResource('dsh-resource://file/absolute/c/x', { tab: true }), 'opened')
130
+ assert.deepEqual(controller.calls.at(-1), { method: 'openResource', address: 'dsh-resource://file/absolute/C:/x', options: { tab: true } })
131
+ assert.equal(controller.openResourceIn('s2', 'dsh-resource://file/session/s1//c/x', { line: 2 }), 'opened')
132
+ assert.equal(controller.calls.at(-1).address, 'dsh-resource://file/session/s1/C:/x')
133
+ assert.equal(controller.calls.at(-1).sessionId, 's2')
134
+ disposers[0]()
135
+ assert.equal(controller.openResource, original.openResource)
136
+ assert.equal(controller.openResourceIn, original.openResourceIn)
137
+ controller.openResource('dsh-resource://file/absolute/c/x')
138
+ assert.equal(controller.calls.at(-1).address, 'dsh-resource://file/absolute/c/x')
139
+ })
140
+
141
+ test('B7 缺少 effect 清理能力时不改写侧栏方法', () => {
142
+ const mounted = mountClient({ facts: FACTS, hasEffect: false })
143
+ assert.equal(mounted.controller.openResource, mounted.original.openResource)
144
+ assert.equal(mounted.controller.openResourceIn, mounted.original.openResourceIn)
145
+ })
146
+
147
+ test('B7 后续插件包装仍保留,卸载只移除本插件包装', async () => {
148
+ const mounted = mountClient({ facts: FACTS, laterWrapper: true })
149
+ await new Promise((resolve) => setImmediate(resolve))
150
+ const laterWrapper = mounted.controller.openResource
151
+ mounted.disposers[0]()
152
+ assert.equal(mounted.controller.openResource, laterWrapper)
153
+ mounted.controller.openResource('dsh-resource://file/absolute/c/x')
154
+ assert.equal(mounted.controller.calls.at(-1).address, 'dsh-resource://file/absolute/c/x')
155
+ })
156
+
157
+ test('B7 pathmap 请求失败可在后续调用重试', async () => {
158
+ const mounted = mountClient({ facts: null })
159
+ await new Promise((resolve) => setImmediate(resolve))
160
+ mounted.setFacts(FACTS)
161
+ mounted.controller.openResource('dsh-resource://file/absolute/c/x')
162
+ await new Promise((resolve) => setImmediate(resolve))
163
+ mounted.controller.calls.length = 0
164
+ mounted.controller.openResource('dsh-resource://file/absolute/c/x')
165
+ assert.equal(mounted.controller.calls[0].address, 'dsh-resource://file/absolute/C:/x')
166
+ })
167
+
168
+ test('B7 非 Windows pathmap 不改写原地址', async () => {
169
+ const mounted = mountClient({ facts: { ...FACTS, platform: 'linux' } })
170
+ await new Promise((resolve) => setImmediate(resolve))
171
+ mounted.controller.openResource('/c/x')
172
+ assert.equal(mounted.controller.calls[0].address, '/c/x')
173
+ })
@@ -1,154 +1,45 @@
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,不制造假红。
6
-
7
1
  import { test } from 'node:test'
8
2
  import assert from 'node:assert/strict'
9
- import { readFileSync, existsSync, readdirSync } from 'node:fs'
10
- import { fileURLToPath, pathToFileURL } from 'node:url'
11
- import { dirname, join, resolve } from 'node:path'
12
- import { createRequire } from 'node:module'
13
- import { execPath } from 'node:process'
14
-
15
- const here = dirname(fileURLToPath(import.meta.url))
16
- const pkgRoot = join(here, '..')
17
- const patchText = readFileSync(join(pkgRoot, 'cordis.patch.yml'), 'utf8')
18
-
19
- // ── 宿主侧 yaml 能力探测:js-yaml 与官方 entryListSchema 经 dsh 安装位解析 ──
20
-
21
- function locateHarnessDir() {
22
- // 候选锚:运行中 node 的全局安装位(nvm/全局布局:execPath 目录/node_modules)→
23
- // 仓库 compat 隔离宿主(.dsh-versions/<v>/package)。缺失即返回 null(skip)。
24
- const candidates = [
25
- resolve(dirname(execPath), 'node_modules', '@deepseek-ai', 'dsh'),
26
- ]
27
- const versionsDir = resolve(pkgRoot, '..', '..', '.dsh-versions')
28
- try {
29
- for (const version of readdirSync(versionsDir)) {
30
- candidates.push(join(versionsDir, version, 'package'))
31
- }
32
- } catch { /* 无 .dsh-versions,忽略 */ }
33
- for (const candidate of candidates) {
34
- if (existsSync(join(candidate, 'package.json'))) return candidate
35
- }
36
- return null
37
- }
3
+ import { readFileSync } from 'node:fs'
4
+ import yaml from 'js-yaml'
38
5
 
39
- // yaml 能力与宿主解耦:js-yaml 为 devDep(CI 无宿主也可解析本包文件)。
40
- // !!js 标签本地同形 Type:tag 必须写完整 URI(tag:yaml.org,2002:js)——文档简写
41
- // !!js 展开后按完整 URI 匹配,字面 '!!js' 永不命中(unknown tag,CI 假绿教训)。
42
- import yamlStatic from 'js-yaml'
43
-
44
- const LOCAL_SCHEMA = yamlStatic.JSON_SCHEMA.extend(new yamlStatic.Type('tag:yaml.org,2002:js', {
6
+ const schema = yaml.JSON_SCHEMA.extend(new yaml.Type('tag:yaml.org,2002:js', {
45
7
  kind: 'scalar',
46
8
  resolve: (data) => typeof data === 'string',
47
9
  construct: (data) => ({ __jsExpr: data }),
48
- represent: (data) => data['__jsExpr'],
49
10
  }))
50
-
51
- let harness = null
52
- let entryListSchema = LOCAL_SCHEMA
53
- try {
54
- harness = locateHarnessDir()
55
- if (harness !== null) {
56
- const req = createRequire(join(harness, 'package.json'))
57
- const toUrl = (specifier) => pathToFileURL(req.resolve(specifier)).href
58
- entryListSchema = (await import(toUrl('@deepseek-ai/cordis-plugin-include'))).entryListSchema
59
- }
60
- } catch { /* 官方 schema 缺失按本地同形标签处理 */ }
61
-
62
- const loadYaml = (text) => yamlStatic.load(text, { schema: entryListSchema })
63
-
64
- // 镜像 loader 的表达式求值形态(new Function + with(ctx),全局可用)
65
- const evalExpr = (expr, ctx) => new Function('ctx', `with (ctx) { return (${expr}) }`)(ctx)
66
-
67
- // ── 结构对比:展平行树为 id → {name, disabled, isolate, config 全值} ──
68
- // config 比全值而非键集:键集守不住文本漂移(plan-mode section 曾落后官方一句
69
- // 而守卫全绿),镜像声明即「standard 全量 − tool-pwsh 逐项同构」,值必须对表
70
-
71
- function flattenRows(rows, out = new Map()) {
72
- for (const row of rows ?? []) {
73
- if (row.id !== undefined) {
74
- out.set(row.id, {
75
- name: row.name,
76
- disabled: row.disabled === undefined ? undefined : row.disabled.__jsExpr ?? row.disabled,
77
- isolate: row.isolate === undefined ? undefined : JSON.parse(JSON.stringify(row.isolate)),
78
- config: row.config === undefined ? undefined : JSON.parse(JSON.stringify(row.config)),
79
- })
80
- }
81
- if (row.group && Array.isArray(row.config)) flattenRows(row.config, out)
82
- }
83
- return out
84
- }
85
-
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 ?? []
91
-
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')
96
- const ids = new Set()
97
- const walk = (list) => {
98
- for (const row of list) {
99
- if (row.id !== undefined) ids.add(row.id)
100
- if (row.group && Array.isArray(row.config)) walk(row.config)
101
- }
102
- }
103
- walk(ourPlugins)
104
- assert.ok(ids.has('tool-bash'), 'POSIX bash 工具行必须保留')
105
- assert.ok(!ids.has('tool-pwsh'), 'tool-pwsh 行必须移除(win32 shell 面由本包接管)')
106
- assert.ok(!ids.has('persistent-pwsh'), 'persistent-pwsh 行不得进入本包预设')
107
- })
108
-
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'/)
11
+ const patch = yaml.load(readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8'), { schema })
12
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
13
+ const inserted = patch.flatMap((entry) => entry.insert ?? [])
14
+
15
+ // Given 插件补丁,When 应用插入行,Then 只增加宿主执行面,不声明会话预设。
16
+ test('补丁仅插入主行、guard 与 web,不增加会话预设', () => {
17
+ assert.deepEqual(inserted.map(({ id, name }) => ({ id, name })), [
18
+ { id: 'shell-select', name: '@mzzsfy/dsh-shell-select' },
19
+ { id: 'shell-select/guard', name: '@mzzsfy/dsh-shell-select/guard' },
20
+ { id: 'shell-select/web', name: '@mzzsfy/dsh-shell-select/web' },
21
+ ])
115
22
  })
116
23
 
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)
24
+ // Given 任意宿主默认预设,When 应用插件补丁,Then 不覆写既有组合行。
25
+ test('补丁不覆写宿主行或默认预设', () => {
26
+ assert.equal(patch.length, 1)
27
+ assert.deepEqual(Object.keys(patch[0]), ['insert'])
121
28
  })
122
29
 
123
- test('漂移守卫:与已安装 standard 预设逐行对表(缺失宿主则 skip)', (t) => {
124
- if (harness === null) return t.skip('未找到 dsh 本体安装,无对照源')
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)
132
- for (const [id, row] of standard) {
133
- if (id === 'tool-pwsh') {
134
- assert.ok(!ours.has(id), 'standard 有 tool-pwsh 而本包预设也出现了:镜像被污染')
135
- continue
136
- }
137
- assert.ok(ours.has(id), `standard 行 ${id} 在本包预设缺失,须对表镜像`)
138
- assert.deepEqual(ours.get(id), row, `行 ${id} 与 standard 漂移,升级后按官方实文对表`)
139
- }
140
- for (const id of ours.keys()) {
141
- assert.ok(standard.has(id), `本包预设多出 standard 没有的行 ${id}:镜像越界`)
30
+ // Given 不同宿主平台,When 求值禁用表达式,Then Windows 启用、POSIX 停用。
31
+ test('宿主行保持 Windows 平台门控', () => {
32
+ assert.equal(inserted.length, 3)
33
+ for (const row of inserted) {
34
+ const evaluate = new Function('process', `return (${row.disabled.__jsExpr})`)
35
+ assert.equal(evaluate({ platform: 'win32' }), false)
36
+ assert.equal(evaluate({ platform: 'linux' }), true)
37
+ assert.equal(evaluate({ platform: 'darwin' }), true)
142
38
  }
143
39
  })
144
40
 
145
- test('schema 同构:本地 !!js Type 与官方 entryListSchema 产物 deepEqual(缺失宿主则 skip)', (t) => {
146
- if (harness === null) return t.skip('未找到 dsh 本体安装,无官方 schema 对照')
147
- // harness 命中时 entryListSchema 已是官方版:loadYaml=官方路径,与 LOCAL_SCHEMA 逐文本对照。
148
- // 本地同形标签的语义漂移曾以 unknown tag 形态炸穿 CI,此处固化为机器断言
149
- assert.deepEqual(
150
- yamlStatic.load(patchText, { schema: LOCAL_SCHEMA }),
151
- loadYaml(patchText),
152
- 'bundle patch 双 schema 产物漂移,本地 !!js Type 须与官方 JsExpr 同构',
153
- )
41
+ // Given 插件清单,When 对账宿主依赖,Then 删除预设依赖但保留最低宿主版本。
42
+ test('清单不再依赖预设模块,宿主门槛不变化', () => {
43
+ assert.equal(manifest.peerDependencies['@deepseek-ai/dsh-agent-preset'], undefined)
44
+ assert.equal(manifest.dsh.hostMin, '0.1.7-rc.1')
154
45
  })