@mzzsfy/dsh-shell-select 0.1.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 +88 -0
- package/cordis.patch.yml +30 -0
- package/package.json +70 -0
- package/src/api.mjs +109 -0
- package/src/apply-state.mjs +21 -0
- package/src/client.js +757 -0
- package/src/config.mjs +118 -0
- package/src/denylist.mjs +36 -0
- package/src/executor.mjs +530 -0
- package/src/guard-config.mjs +64 -0
- package/src/guard-state.mjs +62 -0
- package/src/guard.js +256 -0
- package/src/render.mjs +59 -0
- package/src/resolve.mjs +128 -0
- package/src/sandbox-classify.mjs +82 -0
- package/src/tool.mjs +350 -0
- package/test/client-card.test.mjs +106 -0
- package/test/client-drift.test.mjs +27 -0
- package/test/client-id.test.mjs +68 -0
- package/test/config-entry.test.mjs +93 -0
- package/test/config.test.mjs +106 -0
- package/test/deny-config.test.mjs +33 -0
- package/test/deny-tool.test.mjs +26 -0
- package/test/denylist.test.mjs +39 -0
- package/test/env.test.mjs +81 -0
- package/test/executor-seam.test.mjs +136 -0
- package/test/executor.test.mjs +363 -0
- package/test/guard-entry.test.mjs +157 -0
- package/test/guard-retry.test.mjs +79 -0
- package/test/guard-state.test.mjs +111 -0
- package/test/render.test.mjs +73 -0
- package/test/resolve.test.mjs +131 -0
- package/test/sandbox-classify.test.mjs +67 -0
- package/test/switch-guard.test.mjs +37 -0
package/src/tool.mjs
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// shell 工具:官方 dsh-tool-pwsh 逐调用镜像,三处参数化——
|
|
2
|
+
// 1. 工具名 shell(含 shell 枚举参数,模型按名选择客户端);
|
|
3
|
+
// 2. 描述动态含可用客户端清单与方言提示(settings onChange 重注册);
|
|
4
|
+
// 3. 执行经 executor.entryFor/runFor/startFor 按条目 argv 运行。
|
|
5
|
+
// 输出 schema、后台任务语义、升权流程、terminal 卡呈现与官方逐字同构。
|
|
6
|
+
|
|
7
|
+
import { TOOL_ABORTED, defineTool } from '@deepseek-ai/dsh-tools'
|
|
8
|
+
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
|
9
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
10
|
+
import { renderResult, renderProcessRead } from './render.mjs'
|
|
11
|
+
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
|
|
12
|
+
|
|
13
|
+
/** 拒升权/Abort 错误构造:dsh-llm HarnessError 缺失时以同码 Error 降级(错误码通道不变)。 */
|
|
14
|
+
async function loadHarnessError(ctx) {
|
|
15
|
+
try {
|
|
16
|
+
const dshLlm = await import('@deepseek-ai/dsh-llm')
|
|
17
|
+
if (typeof dshLlm.HarnessError === 'function') return dshLlm.HarnessError
|
|
18
|
+
} catch (error) {
|
|
19
|
+
ctx.logger?.warn?.(`shell-select: dsh-llm HarnessError 不可用,abort 错误降级为普通 Error: ${error?.message ?? error}`)
|
|
20
|
+
}
|
|
21
|
+
return class FallbackHarnessError extends Error {
|
|
22
|
+
constructor(message, code) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.code = code
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** DenyError 判定:跨层类型检查靠 code(动态 import 环境下 instanceof 不可靠)。 */
|
|
30
|
+
function isDenyError(error) {
|
|
31
|
+
return error?.code === 'SHELL_COMMAND_BLOCKED'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 拒绝 → 模型可见标记文本(与沙箱拒绝标记同风格)。 */
|
|
35
|
+
function blockedMarker(error) {
|
|
36
|
+
return `[blocked by shell-select: matches deny pattern ${error.pattern}]`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 显式 workdir 先行,相对者落会话工作区;否则用会话 cwd,执行器默认兜底(官方同构)。 */
|
|
40
|
+
function resolveWorkdir(modelWorkdir, exec) {
|
|
41
|
+
const headerCwd = exec.agent?.session.header.cwd
|
|
42
|
+
if (modelWorkdir === undefined) return headerCwd
|
|
43
|
+
if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) return resolve(headerCwd, modelWorkdir)
|
|
44
|
+
return modelWorkdir
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 后台进程定局 → 通用任务结果词汇(官方同构)。 */
|
|
48
|
+
function processOutcome(proc) {
|
|
49
|
+
if (proc.status === 'killed') {
|
|
50
|
+
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
|
|
51
|
+
}
|
|
52
|
+
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 前台结果 DTO 去只读化(官方 canonical 同构)。 */
|
|
56
|
+
function canonicalResult(result) {
|
|
57
|
+
const output = (stream) => ({
|
|
58
|
+
text: stream.text,
|
|
59
|
+
truncated: stream.truncated,
|
|
60
|
+
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
|
|
61
|
+
})
|
|
62
|
+
return {
|
|
63
|
+
kind: 'foreground',
|
|
64
|
+
exitCode: result.exitCode,
|
|
65
|
+
signal: result.signal,
|
|
66
|
+
timedOut: result.timedOut,
|
|
67
|
+
aborted: result.aborted,
|
|
68
|
+
timeoutMs: result.timeoutMs,
|
|
69
|
+
stdout: output(result.stdout),
|
|
70
|
+
stderr: output(result.stderr),
|
|
71
|
+
...result.sandbox !== undefined ? { sandbox: {
|
|
72
|
+
mode: result.sandbox.mode,
|
|
73
|
+
denied: result.sandbox.denied,
|
|
74
|
+
...result.sandbox.enforcement !== undefined ? { enforcement: result.sandbox.enforcement } : {},
|
|
75
|
+
...result.sandbox.runnerFailed !== undefined ? { runnerFailed: result.sandbox.runnerFailed } : {},
|
|
76
|
+
} } : {},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 后台输出并集公共键(官方同构)。 */
|
|
81
|
+
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
82
|
+
kind: { type: 'string', required: true, const: 'background' },
|
|
83
|
+
jobId: { type: 'string', required: true },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 前台输出分支(官方同构,逐字段)。 */
|
|
87
|
+
function foregroundOutputProperties() {
|
|
88
|
+
const streamSchema = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
required: true,
|
|
92
|
+
properties: {
|
|
93
|
+
text: { type: 'string', required: true },
|
|
94
|
+
truncated: { type: 'boolean', required: true },
|
|
95
|
+
spillPath: { type: 'string' },
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
kind: { type: 'string', required: true, const: 'foreground' },
|
|
100
|
+
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
101
|
+
signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
102
|
+
timedOut: { type: 'boolean', required: true },
|
|
103
|
+
aborted: { type: 'boolean', required: true },
|
|
104
|
+
timeoutMs: { type: 'number', required: true },
|
|
105
|
+
stdout: streamSchema,
|
|
106
|
+
stderr: streamSchema,
|
|
107
|
+
sandbox: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
properties: {
|
|
111
|
+
mode: { type: 'string', required: true },
|
|
112
|
+
denied: { type: 'boolean', required: true },
|
|
113
|
+
enforcement: { type: 'string' },
|
|
114
|
+
runnerFailed: { type: 'boolean' },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 工具描述:客户端清单与方言随配置动态变化。 */
|
|
121
|
+
function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
122
|
+
const listing = executor.listShells()
|
|
123
|
+
const lines = listing.shells
|
|
124
|
+
.map((entry) => `${entry.id} (${entry.kind}${entry.available ? '' : ', executable not found'})`)
|
|
125
|
+
.join(', ')
|
|
126
|
+
const base = 'Execute a command in one of the configured shell clients and return its stdout/stderr. '
|
|
127
|
+
+ `Available clients: ${lines}. The default client is "${listing.default}"; pass \`shell\` only when this command needs a different client (dialects differ: pwsh = PowerShell, bash/wsl = POSIX, cmd = cmd.exe). `
|
|
128
|
+
+ 'Each call runs in a fresh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
|
|
129
|
+
+ 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
|
|
130
|
+
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
|
131
|
+
+ (backgroundEnabled
|
|
132
|
+
? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
|
|
133
|
+
: 'Background execution is not available; long-running commands must finish within the timeout.')
|
|
134
|
+
if (escalationModes.length === 0) return base
|
|
135
|
+
return base + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 注册 shell 工具,返回卸载 disposer(重注册 = 先卸后挂)。
|
|
140
|
+
* @param {object} ctx cordis context(需 tools/shellEnv 服务在场)
|
|
141
|
+
* @param {{executor: object}} faces 执行器实例
|
|
142
|
+
*/
|
|
143
|
+
export function registerShellTool(ctx, { executor }) {
|
|
144
|
+
const backgroundEnabled = true
|
|
145
|
+
const escalationModes = ESCALATION_TARGETS
|
|
146
|
+
let HarnessErrorClass = undefined
|
|
147
|
+
let disposed = false
|
|
148
|
+
let HarnessErrorReady = loadHarnessError(ctx).then((resolved) => {
|
|
149
|
+
HarnessErrorClass = resolved
|
|
150
|
+
return resolved
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
async function abortError(message) {
|
|
154
|
+
await HarnessErrorReady
|
|
155
|
+
const error = new HarnessErrorClass(message, TOOL_ABORTED)
|
|
156
|
+
error.name = 'AbortError'
|
|
157
|
+
return error
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateArgs(args) {
|
|
161
|
+
if (args.command.trim().length === 0) throw new Error('invalid command: expected a non-empty string')
|
|
162
|
+
if (args.description.trim().length === 0) throw new Error('invalid description: expected a non-empty string')
|
|
163
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
164
|
+
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
|
165
|
+
}
|
|
166
|
+
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const definition = (faces) => defineTool({
|
|
170
|
+
name: 'shell',
|
|
171
|
+
description: shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes }),
|
|
172
|
+
parameters: {
|
|
173
|
+
command: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
required: true,
|
|
176
|
+
description: 'The command to execute, in the dialect of the selected shell client (default client when `shell` is omitted).',
|
|
177
|
+
},
|
|
178
|
+
description: {
|
|
179
|
+
type: 'string',
|
|
180
|
+
required: true,
|
|
181
|
+
description: 'Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI).',
|
|
182
|
+
},
|
|
183
|
+
shell: {
|
|
184
|
+
type: 'string',
|
|
185
|
+
description: 'Shell client id from the tool description list (e.g. pwsh, git-bash, cmd). Omit to use the configured default client.',
|
|
186
|
+
},
|
|
187
|
+
timeoutMs: {
|
|
188
|
+
type: 'number',
|
|
189
|
+
description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.',
|
|
190
|
+
},
|
|
191
|
+
workdir: {
|
|
192
|
+
type: 'string',
|
|
193
|
+
description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.',
|
|
194
|
+
},
|
|
195
|
+
...backgroundEnabled ? { run_in_background: {
|
|
196
|
+
type: 'boolean',
|
|
197
|
+
description: 'Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies.',
|
|
198
|
+
} } : {},
|
|
199
|
+
...escalationModes.length > 0 ? {
|
|
200
|
+
sandbox_permissions: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
enum: [...escalationModes],
|
|
203
|
+
description: 'The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.',
|
|
204
|
+
},
|
|
205
|
+
justification: {
|
|
206
|
+
type: 'string',
|
|
207
|
+
description: 'Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access.',
|
|
208
|
+
},
|
|
209
|
+
} : {},
|
|
210
|
+
},
|
|
211
|
+
output: {
|
|
212
|
+
schema: { oneOf: [{
|
|
213
|
+
type: 'object',
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
|
216
|
+
}, {
|
|
217
|
+
type: 'object',
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
properties: foregroundOutputProperties(),
|
|
220
|
+
}] },
|
|
221
|
+
render: (_args, value) => [{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text: value.kind === 'background' ? `started background job ${value.jobId}` : renderResult(value, escalationModes),
|
|
224
|
+
}],
|
|
225
|
+
},
|
|
226
|
+
async execute(args, exec) {
|
|
227
|
+
validateArgs(args)
|
|
228
|
+
const standingPolicy = resolveStandingPolicy(exec)
|
|
229
|
+
const approvedMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
|
230
|
+
? await approveShellEscalation(args.sandbox_permissions, args.justification, exec, standingPolicy)
|
|
231
|
+
: undefined
|
|
232
|
+
const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
|
|
233
|
+
const workdir = resolveWorkdir(args.workdir, exec)
|
|
234
|
+
const entry = faces.executor.entryFor(args.shell)
|
|
235
|
+
const request = {
|
|
236
|
+
command: args.command,
|
|
237
|
+
...workdir !== undefined ? { workdir } : {},
|
|
238
|
+
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
|
239
|
+
dshEnv: ctx.shellEnv.collect(exec),
|
|
240
|
+
...policy !== undefined ? { sandboxPolicy: policy } : {},
|
|
241
|
+
}
|
|
242
|
+
if (args.run_in_background === true) {
|
|
243
|
+
const jobs = ctx.get('jobs')
|
|
244
|
+
if (jobs === undefined) throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
245
|
+
if (exec.signal.aborted) throw await abortError('tool call aborted')
|
|
246
|
+
try {
|
|
247
|
+
faces.executor.startFor(entry, faces.executor.resolve(request))
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (isDenyError(error)) return { kind: 'foreground', blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
|
|
250
|
+
throw error
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
kind: 'background',
|
|
254
|
+
jobId: jobs.start({
|
|
255
|
+
kind: 'shell',
|
|
256
|
+
label: args.command,
|
|
257
|
+
...exec.agent ? { owner: exec.agent } : {},
|
|
258
|
+
run: () => {
|
|
259
|
+
const proc = faces.executor.startFor(entry, faces.executor.resolve(request))
|
|
260
|
+
return {
|
|
261
|
+
cancel: () => void proc.kill(),
|
|
262
|
+
done: proc.done.then(() => processOutcome(proc)),
|
|
263
|
+
readOutput: () => renderProcessRead(proc.readOutput(), proc.sandbox, escalationModes),
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let result
|
|
270
|
+
try {
|
|
271
|
+
result = await faces.executor.runFor(entry, faces.executor.resolve({
|
|
272
|
+
...request,
|
|
273
|
+
signal: exec.signal,
|
|
274
|
+
}))
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (isDenyError(error)) return { kind: 'foreground', blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
|
|
277
|
+
throw error
|
|
278
|
+
}
|
|
279
|
+
if (result.aborted) throw await abortError('tool call aborted')
|
|
280
|
+
return canonicalResult(result)
|
|
281
|
+
},
|
|
282
|
+
presentCall: (args) => {
|
|
283
|
+
if (args.run_in_background === true) {
|
|
284
|
+
return {
|
|
285
|
+
card: 'generic',
|
|
286
|
+
title: args.command,
|
|
287
|
+
kind: 'execute',
|
|
288
|
+
rawInput: args.command,
|
|
289
|
+
content: [{ type: 'text', text: args.description }],
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
card: 'terminal',
|
|
294
|
+
title: args.command,
|
|
295
|
+
description: args.description,
|
|
296
|
+
...args.workdir !== undefined ? { cwd: args.workdir } : {},
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
presentResult: (args, result) => {
|
|
300
|
+
const block = result.content.length === 1 ? result.content[0] : undefined
|
|
301
|
+
if (block === undefined || block.type !== 'text') return undefined
|
|
302
|
+
const raw = block.text
|
|
303
|
+
if (typeof args === 'object' && args !== null && args.run_in_background === true || result.isError) {
|
|
304
|
+
return {
|
|
305
|
+
card: 'generic',
|
|
306
|
+
content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }],
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const { body, ...exit } = parseExitStatus(raw)
|
|
310
|
+
return {
|
|
311
|
+
card: 'terminal',
|
|
312
|
+
output: body,
|
|
313
|
+
...exit,
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
/** 会话在场时解析其完整标准策略,直调落部署策略(官方同构)。 */
|
|
319
|
+
function resolveStandingPolicy(exec) {
|
|
320
|
+
const sandboxPolicy = ctx.get('sandboxPolicy')
|
|
321
|
+
if (sandboxPolicy === undefined) return undefined
|
|
322
|
+
return sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 升权审批:先于执行,共享 fail-closed 序列交 approveEscalation(官方同构)。 */
|
|
326
|
+
function approveShellEscalation(mode, justification, exec, standingPolicy) {
|
|
327
|
+
if (escalationModes.length === 0) throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
|
328
|
+
const approver = ctx.get('approval')
|
|
329
|
+
return approveEscalation({
|
|
330
|
+
requestedMode: mode,
|
|
331
|
+
justification,
|
|
332
|
+
effectiveMode: standingPolicy.mode,
|
|
333
|
+
subject: 'command',
|
|
334
|
+
}, {
|
|
335
|
+
approver,
|
|
336
|
+
agent: exec.agent,
|
|
337
|
+
callId: exec.callId,
|
|
338
|
+
toolName: 'shell',
|
|
339
|
+
signal: exec.signal,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const faces = { executor }
|
|
344
|
+
const disposer = ctx.tools.register(definition(faces))
|
|
345
|
+
return () => {
|
|
346
|
+
if (disposed) return
|
|
347
|
+
disposed = true
|
|
348
|
+
disposer()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// shellCardModel 行为测试:LOGIC 段提取(client.js 工厂内纯函数,形态照
|
|
2
|
+
// dsh-maintain/test/logic-extract.mjs)。BDD 场景见 docs/feat-shell-select-optim/plan.md S11-S13。
|
|
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
|
+
function cardModel() {
|
|
22
|
+
const lastSegment = extractLogic('lastSegment')
|
|
23
|
+
const displayCwd = extractLogic('displayCwd', { lastSegment })
|
|
24
|
+
return extractLogic('shellCardModel', { displayCwd, lastSegment, parseExitTail: extractLogic('parseExitTail'), hasSpillNotice: extractLogic('hasSpillNotice') })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseEnvText() {
|
|
28
|
+
return extractLogic('parseEnvText')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function invalidEnvLines() {
|
|
32
|
+
return extractLogic('invalidEnvLines')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 运行中调用块(官方形态:无 kind 字段)
|
|
36
|
+
function runningBlock(argsRaw) {
|
|
37
|
+
return { callId: 'c1', name: 'shell', argsRaw }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 已定调用块(官方形态:kind 字段在)
|
|
41
|
+
function settledBlock(argsRaw, text, options = {}) {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'tool',
|
|
44
|
+
callId: 'c1',
|
|
45
|
+
call: { callId: 'c1', name: 'shell', argsRaw },
|
|
46
|
+
content: [{ type: 'text', text }],
|
|
47
|
+
isError: options.isError,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ARGS = JSON.stringify({ command: 'git status', description: 'Show working tree status', workdir: 'sub' })
|
|
52
|
+
const SESSION_CWD = 'C:\\repo'
|
|
53
|
+
|
|
54
|
+
test('S11 running 且无 description(persistent 形)回退 generic', () => {
|
|
55
|
+
const model = cardModel()(runningBlock(JSON.stringify({ command: 'interactive session' })), SESSION_CWD)
|
|
56
|
+
assert.equal(model.kind, 'generic')
|
|
57
|
+
assert.equal(model.running, true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('S11b settled 无 description(persistent 结束)回退 generic', () => {
|
|
61
|
+
const model = cardModel()(settledBlock(JSON.stringify({ command: 'interactive session' }), 'out\n[exit code: 0]'), SESSION_CWD)
|
|
62
|
+
assert.equal(model.kind, 'generic')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('S12 官方 pwsh 形 settled:terminal 卡派生退出码与输出', () => {
|
|
66
|
+
const model = cardModel()(settledBlock(ARGS, 'On branch main\n[exit code: 2]', { isError: false }), SESSION_CWD)
|
|
67
|
+
assert.equal(model.kind, 'terminal')
|
|
68
|
+
assert.equal(model.status, 'failed')
|
|
69
|
+
assert.equal(model.exitCode, 2)
|
|
70
|
+
assert.equal(model.output, 'On branch main')
|
|
71
|
+
assert.equal(model.cwdDir, 'sub')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('S12b running 完整参数:terminal running 卡', () => {
|
|
75
|
+
const model = cardModel()(runningBlock(ARGS), SESSION_CWD)
|
|
76
|
+
assert.equal(model.kind, 'terminal')
|
|
77
|
+
assert.equal(model.status, 'running')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('S12c 后台 ack 与 isError 仍走 generic(回归)', () => {
|
|
81
|
+
const background = cardModel()(
|
|
82
|
+
settledBlock(JSON.stringify({ ...JSON.parse(ARGS), run_in_background: true }), 'started', { isError: false }),
|
|
83
|
+
SESSION_CWD,
|
|
84
|
+
)
|
|
85
|
+
assert.equal(background.kind, 'generic')
|
|
86
|
+
const errored = cardModel()(settledBlock(ARGS, 'boom', { isError: true }), SESSION_CWD)
|
|
87
|
+
assert.equal(errored.kind, 'generic')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('S13 注册面:shell/pwsh/bash 三 key 且 priority -1(文本守卫)', () => {
|
|
91
|
+
assert.match(source, /const TOOLVIEW_KEYS = \['shell', 'pwsh', 'bash'\]/)
|
|
92
|
+
assert.match(source, /key: toolKey, priority: -1/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('K=V 往返:值含 = 与空格无损,空行忽略,重复键后行胜', () => {
|
|
96
|
+
const parse = parseEnvText()
|
|
97
|
+
assert.deepEqual(parse('A=1\nB=x=y z\n\nA=2'), { A: '2', B: 'x=y z' })
|
|
98
|
+
assert.deepEqual(parse(''), {})
|
|
99
|
+
assert.deepEqual(parse(undefined), {})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('invalidEnvLines:缺 = 行报出,空行不报', () => {
|
|
103
|
+
const invalid = invalidEnvLines()
|
|
104
|
+
assert.deepEqual(invalid('A=1\nbroken\n\n noSep '), ['broken', 'noSep'])
|
|
105
|
+
assert.deepEqual(invalid('A=1'), [])
|
|
106
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// KINDS 漂移守卫:client.js 工具形态清单与 host config.mjs 同源(竞品
|
|
2
|
+
// bash-terminal-ts 的 drift 守卫同构:加形态忘改文案即测试失败)。
|
|
3
|
+
// BDD 场景见 docs/feat-shell-select-optim/plan.md S14。
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { KINDS } from '../src/config.mjs'
|
|
11
|
+
|
|
12
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const clientSource = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
14
|
+
|
|
15
|
+
test('S14 client KINDS 字面与 host KINDS 同源', () => {
|
|
16
|
+
const match = clientSource.match(/const KINDS = \[([^\]]*)\]/)
|
|
17
|
+
assert.ok(match, 'client.js 缺少 KINDS 声明')
|
|
18
|
+
const declared = match[1].split(',').map((item) => item.trim().replace(/^'|'$/g, '')).filter((item) => item.length > 0)
|
|
19
|
+
assert.deepEqual(declared, [...KINDS])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('S14b client KIND_LABELS 覆盖全部形态', () => {
|
|
23
|
+
for (const kind of KINDS) {
|
|
24
|
+
const pattern = new RegExp(kind + ":")
|
|
25
|
+
assert.match(clientSource, pattern, 'KIND_LABELS 缺少形态 ' + kind)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// client-id 守卫:样式注入点必须伴随 data-plugin=@mzzsfy/dsh-shell-select 标记
|
|
2
|
+
// (仓库契约,守卫形态照 dsh-cron-board/test/client-id.test.mjs:createElement('style')
|
|
3
|
+
// 与 setAttribute('data-plugin') 计数相等)。
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
13
|
+
|
|
14
|
+
test('head 注入样式全部带 data-plugin 标记', () => {
|
|
15
|
+
const createElementStyle = (source.match(/createElement\('style'/g) ?? []).length
|
|
16
|
+
+ (source.match(/createElement\("style"/g) ?? []).length
|
|
17
|
+
const marked = (source.match(/'data-plugin'/g) ?? []).length + (source.match(/"data-plugin"/g) ?? []).length
|
|
18
|
+
assert.ok(createElementStyle > 0, 'client.js 应至少有一个样式注入点')
|
|
19
|
+
assert.equal(marked, createElementStyle, '每个 createElement(\'style\') 必须伴随一个 data-plugin 标记')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('标记值为完整 npm 包名', () => {
|
|
23
|
+
assert.match(source, /setAttribute\('data-plugin', '@mzzsfy\/dsh-shell-select'\)/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('settings.section 注册与 id', () => {
|
|
27
|
+
assert.match(source, /settings\.section/)
|
|
28
|
+
assert.match(source, /id: 'shell-select'/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('tool.call.toolview 注册:shell 族三 key + priority -1', () => {
|
|
32
|
+
assert.match(source, /'tool\.call\.toolview'/)
|
|
33
|
+
assert.match(source, /const TOOLVIEW_KEYS = \['shell', 'pwsh', 'bash'\]/)
|
|
34
|
+
assert.match(source, /key: toolKey, priority: -1/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('卡片数据链:官方同构派生(argsRaw + content 尾部退出标记)+ generic 回退', () => {
|
|
38
|
+
assert.match(source, /parseExitTail/)
|
|
39
|
+
assert.ok(source.includes("[exit code: ("), '缺少 exit 尾标解析')
|
|
40
|
+
assert.match(source, /kind: 'generic'/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('primitives 缺席时图标降级自绘(require 有 try/catch 兜底)', () => {
|
|
44
|
+
assert.match(source, /require\('@deepseek-ai\/dsh-client-ui-primitives'\)/)
|
|
45
|
+
assert.match(source, /catch \{\s*return null\s*\}/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('扩展字段守卫:login/distro/env 进设置页数据链(wholesale replace 防静默重置)', () => {
|
|
49
|
+
// toSection(保存)与 toEntries(加载)必须同时携带 login/distro;
|
|
50
|
+
// env 走 envText(K=V 每行)编辑态往返:保存侧 parseEnvText,加载侧 envText
|
|
51
|
+
for (const field of ['login', 'distro']) {
|
|
52
|
+
const writes = (source.match(new RegExp(`^\\s*${field}: .*$`, 'gm')) ?? []).length
|
|
53
|
+
assert.ok(writes >= 2, `toSection/toEntries 应各携带 ${field}(发现 ${writes} 处)`)
|
|
54
|
+
}
|
|
55
|
+
assert.match(source, /env: parseEnvText\(entry\.envText\)/)
|
|
56
|
+
assert.match(source, /envText: Object\.entries\(entry\.env \?\? \{\}\)/)
|
|
57
|
+
assert.match(source, /登录壳/)
|
|
58
|
+
assert.match(source, /发行版/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('deny 名单进设置页数据链:state/toSection/校验/往返;allow 已移除(deny 绝对)', () => {
|
|
62
|
+
assert.match(source, /denyText/)
|
|
63
|
+
assert.match(source, /deny: splitPatternLines\(denyText\)/)
|
|
64
|
+
assert.match(source, /名单正则非法/)
|
|
65
|
+
assert.match(source, /拒绝名单/)
|
|
66
|
+
assert.doesNotMatch(source, /allowText/)
|
|
67
|
+
assert.doesNotMatch(source, /豁免名单/)
|
|
68
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
|
|
2
|
+
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import ShellSelectExecutor from '../src/executor.mjs'
|
|
6
|
+
import { Config, buildArgv, defaultConfig } from '../src/config.mjs'
|
|
7
|
+
|
|
8
|
+
test('S5 schema 默认:条目 env 空对象、distro 空串,旧配置反序列化补默认', () => {
|
|
9
|
+
const config = Config({ shells: [{ id: 'w', name: 'WSL', kind: 'wsl' }], default: 'w' })
|
|
10
|
+
const entry = config.shells[0]
|
|
11
|
+
assert.deepEqual(entry.env, {})
|
|
12
|
+
assert.equal(entry.distro, '')
|
|
13
|
+
const factory = defaultConfig()
|
|
14
|
+
assert.deepEqual(factory.shells[0].env, {})
|
|
15
|
+
assert.equal(factory.shells[0].distro, '')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('S8 wsl distro 非空:argv 带 -d 发行版', () => {
|
|
19
|
+
const argv = buildArgv({ kind: 'wsl', path: 'C:\\wsl.exe', distro: 'Ubuntu' }, 'ls')
|
|
20
|
+
assert.deepEqual(argv, ['C:\\wsl.exe', '-d', 'Ubuntu', '--exec', 'bash', '-c', 'ls'])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('S9 wsl distro 空:argv 现状形态', () => {
|
|
24
|
+
const argv = buildArgv({ kind: 'wsl', path: 'C:\\wsl.exe', distro: '' }, 'ls')
|
|
25
|
+
assert.deepEqual(argv, ['C:\\wsl.exe', '--exec', 'bash', '-c', 'ls'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('S10 args 模板条目接管 argv,distro 不出现', () => {
|
|
29
|
+
const argv = buildArgv({ kind: 'wsl', path: 'C:\\wsl.exe', args: ['--data', '{command}'], distro: 'Ubuntu' }, 'ls')
|
|
30
|
+
assert.deepEqual(argv, ['C:\\wsl.exe', '--data', 'ls'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('S6 entryFor 透传 env 与 distro;spawnSpec env 含条目键', () => {
|
|
34
|
+
const cmdPath = `${process.env.SystemRoot ?? 'C:\\WINDOWS'}\\System32\\cmd.exe`
|
|
35
|
+
const config = Config({
|
|
36
|
+
shells: [{ id: 'c', name: 'CMD', kind: 'cmd', path: cmdPath, env: { MSYSTEM: 'MINGW64' } }, { id: 'p', name: 'pwsh', kind: 'pwsh' }],
|
|
37
|
+
default: 'c',
|
|
38
|
+
})
|
|
39
|
+
const executor = new ShellSelectExecutor(stubCtxFor(config), config)
|
|
40
|
+
const entry = executor.entryFor('c')
|
|
41
|
+
assert.deepEqual(entry.env, { MSYSTEM: 'MINGW64' })
|
|
42
|
+
const spec = executor.resolve({ command: 'ver', workdir: process.cwd() })
|
|
43
|
+
const spawned = executor.spawnSpec(entry, spec, executor.argvFor(entry, spec), 1024, undefined)
|
|
44
|
+
assert.equal(spawned.env.MSYSTEM, 'MINGW64')
|
|
45
|
+
assert.equal(spawned.env.NO_COLOR, '1')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('S7 updateConfig 接受 env/distro 字段并落盘回读', async () => {
|
|
49
|
+
const section = Config({
|
|
50
|
+
shells: [
|
|
51
|
+
{ id: 'p', name: 'pwsh', kind: 'pwsh' },
|
|
52
|
+
{ id: 'w', name: 'WSL', kind: 'wsl', distro: 'Debian', env: { LANG: 'C.UTF-8' } },
|
|
53
|
+
],
|
|
54
|
+
default: 'p',
|
|
55
|
+
})
|
|
56
|
+
const executor = new ShellSelectExecutor(stubCtxFor(section), section)
|
|
57
|
+
const next = await executor.updateConfig({})
|
|
58
|
+
const wsl = next.shells.find((entry) => entry.id === 'w')
|
|
59
|
+
assert.equal(wsl.distro, 'Debian')
|
|
60
|
+
assert.deepEqual(wsl.env, { LANG: 'C.UTF-8' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function stubCtxFor(config) {
|
|
64
|
+
const registered = { sections: [], tools: [], promptSections: [], routes: [] }
|
|
65
|
+
return {
|
|
66
|
+
reflect: { provide: () => {} },
|
|
67
|
+
logger: { warn: () => {} },
|
|
68
|
+
effect: (fn) => { registered.routes.push(fn) },
|
|
69
|
+
get() { return { register: () => {} } },
|
|
70
|
+
subprocess: { spawn: () => { throw new Error('not expected') } },
|
|
71
|
+
sandbox: { confine: (argv) => ({ argv, enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }) },
|
|
72
|
+
sandboxPolicy: { resolve: () => ({ mode: 'danger-full-access', roots: [] }) },
|
|
73
|
+
tools: { register: (definition) => registered.tools.push(definition) },
|
|
74
|
+
systemPrompt: {
|
|
75
|
+
section: (item) => registered.promptSections.push(item),
|
|
76
|
+
getSectionOrder: () => 10,
|
|
77
|
+
},
|
|
78
|
+
settings: {
|
|
79
|
+
installSection: (ctx, ns, schema, base) => {
|
|
80
|
+
registered.sections.push({ ns, base })
|
|
81
|
+
},
|
|
82
|
+
replace: async (ns, next) => { registered.replaced = { ns, next } },
|
|
83
|
+
},
|
|
84
|
+
webServer: undefined,
|
|
85
|
+
shellEnv: { collect: () => ({}) },
|
|
86
|
+
_config: config,
|
|
87
|
+
_registered: registered,
|
|
88
|
+
...configAccessor(),
|
|
89
|
+
}
|
|
90
|
+
function configAccessor() {
|
|
91
|
+
return { get config() { return config } }
|
|
92
|
+
}
|
|
93
|
+
}
|