@mzzsfy/dsh-shell-select 0.4.1 → 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.
- package/README.md +13 -11
- package/cordis.patch.yml +3 -189
- package/package.json +10 -4
- package/src/api.mjs +7 -1
- package/src/client.js +132 -416
- package/src/executor.mjs +31 -7
- package/src/prompt-projection.mjs +34 -0
- package/src/pwsh-takeover.mjs +23 -0
- package/src/resolve.mjs +35 -23
- package/src/tool.mjs +36 -10
- package/src/web-routes.mjs +3 -0
- package/test/background-owner.test.mjs +14 -0
- package/test/client-card.test.mjs +0 -21
- package/test/client-id.test.mjs +0 -56
- package/test/client-render.test.mjs +38 -12
- package/test/config-entry.test.mjs +2 -1
- package/test/deny-output-schema.test.mjs +53 -0
- package/test/executor-seam.test.mjs +2 -1
- package/test/executor.test.mjs +12 -0
- package/test/official-contract.test.mjs +208 -0
- package/test/path-guidance.test.mjs +28 -0
- package/test/path-rescue.test.mjs +173 -0
- package/test/preset-drift.test.mjs +30 -139
- package/test/prompt-projection.test.mjs +120 -0
- package/test/pwsh-takeover.test.mjs +41 -0
- package/test/resolve.test.mjs +43 -13
- package/test/web-routes.test.mjs +15 -2
- package/test/client-drift.test.mjs +0 -27
- package/test/switch-guard.test.mjs +0 -37
package/src/executor.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import { matchDeny } from './denylist.mjs'
|
|
|
19
19
|
import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
|
|
20
20
|
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from './sandbox-classify.mjs'
|
|
21
21
|
import { registerShellTool } from './tool.mjs'
|
|
22
|
+
import { mountShellPromptProjection } from './prompt-projection.mjs'
|
|
23
|
+
import { mountPwshTakeover } from './pwsh-takeover.mjs'
|
|
22
24
|
|
|
23
25
|
export const name = 'shell-select'
|
|
24
26
|
|
|
@@ -149,6 +151,8 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
149
151
|
})
|
|
150
152
|
this.#registerTool()
|
|
151
153
|
this.#mountPromptSection()
|
|
154
|
+
mountShellPromptProjection(ctx)
|
|
155
|
+
this.markPwshTakeover = mountPwshTakeover(ctx)
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
/** 挂载/换源共用落定:入参经宿主按 static Config resolve,volatile 字段是
|
|
@@ -226,6 +230,21 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
226
230
|
return { id: entry.id, kind: entry.kind, path: resolved, args: entry.args, login: entry.login === true, distro: entry.distro ?? '', env: entry.env ?? {} }
|
|
227
231
|
}
|
|
228
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 官方契约的 pwsh 落点:配置含 pwsh 条目时走 entryFor;条目被删除或不可解析时
|
|
235
|
+
* 仍按候选探测现场解析一台 pwsh(官方 dsh-pwsh-local 语义——pwsh 调用永远
|
|
236
|
+
* PowerShell,不落默认客户端,也不得以配置缺失为由改方言)。探测全失时才抛。
|
|
237
|
+
*/
|
|
238
|
+
contractEntry() {
|
|
239
|
+
try {
|
|
240
|
+
return this.entryFor('pwsh')
|
|
241
|
+
} catch {
|
|
242
|
+
const [detected] = detectCandidates(['pwsh'], process.env, candidateExists)
|
|
243
|
+
if (detected === undefined) throw new Error('no pwsh executable on this machine: the official pwsh contract requires PowerShell (pwsh 7 or Windows PowerShell); reinstall PowerShell or add a pwsh entry in the shell-select settings section')
|
|
244
|
+
return { id: 'pwsh', kind: 'pwsh', path: normalizeWin32Path(detected.path), args: [], login: false, distro: '', env: {} }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
229
248
|
/** 注册/重注册 shell 工具(描述随客户端清单变化)。 */
|
|
230
249
|
#registerTool() {
|
|
231
250
|
this.#toolRegistration?.()
|
|
@@ -236,7 +255,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
236
255
|
this.ctx.systemPrompt.section({
|
|
237
256
|
name: 'tool:shell',
|
|
238
257
|
order: this.ctx.systemPrompt.getSectionOrder('TOOL_PWSH'),
|
|
239
|
-
text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. Commands run in fresh processes: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Pass the `shell` argument only when the default client cannot run the command; the available clients are listed in the tool description.',
|
|
258
|
+
text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. Commands run in fresh processes: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Pass the `shell` argument only when the default client cannot run the command; the available clients are listed in the tool description.',
|
|
240
259
|
})
|
|
241
260
|
}
|
|
242
261
|
|
|
@@ -264,14 +283,17 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
264
283
|
return buildArgv(entry, spec.command)
|
|
265
284
|
}
|
|
266
285
|
|
|
267
|
-
/** 官方 execute seam
|
|
286
|
+
/** 官方 execute seam:pwsh 客户端前台执行(dsh-pwsh-local run 同构,契约钉死 pwsh)。 */
|
|
268
287
|
async run(spec) {
|
|
269
|
-
|
|
288
|
+
this.markPwshTakeover()
|
|
289
|
+
return this.runFor(this.contractEntry(), spec)
|
|
270
290
|
}
|
|
271
291
|
|
|
272
292
|
/**
|
|
273
293
|
* 官方 ctx.shell 唯一执行契约(ShellExecutor 抽象方法,官方 tool-pwsh 与
|
|
274
|
-
* 进程内消费方共同依赖):解析 spec
|
|
294
|
+
* 进程内消费方共同依赖):解析 spec 后按 pwsh 客户端 spawn(官方 dsh-pwsh-local
|
|
295
|
+
* 契约即 PowerShell 专用,落默认客户端会随配置漂移炸方言),返回
|
|
296
|
+
* ShellExecution
|
|
275
297
|
* 句柄(done/readOutput/kill/observed/result)。前台与否是调用方 await 什么的
|
|
276
298
|
* 属性,不是 spawn 的属性。沙箱语义与 runFor 同源:danger 直跑,受限模式
|
|
277
299
|
* confine 包装,定案按 denial/runner-failure 规则附 sandbox 事实。
|
|
@@ -279,8 +301,9 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
279
301
|
* @returns {Promise<object>} ShellExecution 句柄
|
|
280
302
|
*/
|
|
281
303
|
async execute(spec) {
|
|
304
|
+
this.markPwshTakeover()
|
|
282
305
|
this.assertNotDenied(spec.command)
|
|
283
|
-
const entry = this.
|
|
306
|
+
const entry = this.contractEntry()
|
|
284
307
|
const policy = spec.sandboxPolicy
|
|
285
308
|
const { mode } = policy
|
|
286
309
|
if (mode === 'danger-full-access') {
|
|
@@ -420,9 +443,10 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
420
443
|
return Promise.resolve(proc)
|
|
421
444
|
}
|
|
422
445
|
|
|
423
|
-
/** 官方 start seam
|
|
446
|
+
/** 官方 start seam:pwsh 客户端后台启动(dsh-pwsh-local start 同构,契约钉死 pwsh)。 */
|
|
424
447
|
start(spec) {
|
|
425
|
-
|
|
448
|
+
this.markPwshTakeover()
|
|
449
|
+
return this.startFor(this.contractEntry(), spec)
|
|
426
450
|
}
|
|
427
451
|
|
|
428
452
|
/** 组装一次 spawn 的完整规格(官方 spawnSpec 同构,argv/条目参数化)。 */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 只收敛模型投影,不改工具注册或执行策略;旧调用保持原 PowerShell 契约。
|
|
2
|
+
export function mountShellPromptProjection(ctx) {
|
|
3
|
+
if (typeof ctx.on !== 'function') return
|
|
4
|
+
let warned = false
|
|
5
|
+
return ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
6
|
+
const result = await next()
|
|
7
|
+
if (ctx.tools.get('shell', context.scope) === undefined) return result
|
|
8
|
+
let sections = result.sections
|
|
9
|
+
const sdk = sections.find((section) => section.name === 'tools:sdk' && section.text.trim() !== '')
|
|
10
|
+
if (sdk !== undefined) {
|
|
11
|
+
try {
|
|
12
|
+
const { renderToolsSdk, renderToolsSdkPy } = await import('@deepseek-ai/dsh-tools')
|
|
13
|
+
const render = { typescript: renderToolsSdk, python: renderToolsSdkPy }[ctx.get('ptcRuntime')?.language]
|
|
14
|
+
if (typeof render !== 'function') throw new Error('PTC SDK renderer is unavailable')
|
|
15
|
+
const schemas = ctx.tools.schemas(context.scope)
|
|
16
|
+
.filter((schema) => schema.name !== 'pwsh' && schema.name !== 'run_code')
|
|
17
|
+
.map((schema) => ({ ...schema, output: ctx.tools.get(schema.name, context.scope).output.schema }))
|
|
18
|
+
const text = render(schemas)
|
|
19
|
+
sections = sections.map((section) => section === sdk ? { ...section, text, interpolate: false } : section)
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!warned) {
|
|
22
|
+
warned = true
|
|
23
|
+
ctx.logger.warn(`shell-select: 模型工具投影未启用,保留原始工具与SDK:${error.message}`)
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...result,
|
|
30
|
+
tools: result.tools.filter((tool) => tool.name !== 'pwsh'),
|
|
31
|
+
sections: sections.filter((section) => section.name !== 'tool:pwsh'),
|
|
32
|
+
}
|
|
33
|
+
}, { prepend: true })
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
export const PWSH_TAKEOVER_BLOCK = Object.freeze({ type: 'shell-select-takeover', tool: 'pwsh' })
|
|
4
|
+
|
|
5
|
+
// 调用级事实独立于stdout,持久化扩展块不会进入模型文本。
|
|
6
|
+
export function mountPwshTakeover(ctx) {
|
|
7
|
+
const calls = new AsyncLocalStorage()
|
|
8
|
+
const taken = new Set()
|
|
9
|
+
ctx.effect(() => () => { calls.disable(); taken.clear() })
|
|
10
|
+
ctx.on('tools/execute', (exec, next) => exec.name === 'pwsh'
|
|
11
|
+
? calls.run(exec.token, next)
|
|
12
|
+
: next())
|
|
13
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
14
|
+
const intercepted = taken.delete(exec.token)
|
|
15
|
+
const decision = await next()
|
|
16
|
+
if (!intercepted || decision.kind !== 'accept' || Object.hasOwn(decision, 'content') || Object.hasOwn(decision, 'value')) return decision
|
|
17
|
+
return { ...decision, content: [...result.content, PWSH_TAKEOVER_BLOCK] }
|
|
18
|
+
}, { prepend: true })
|
|
19
|
+
return () => {
|
|
20
|
+
const token = calls.getStore()
|
|
21
|
+
if (token !== undefined) taken.add(token)
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/resolve.mjs
CHANGED
|
@@ -4,8 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { lstatSync } from 'node:fs'
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
const
|
|
7
|
+
// POSIX pwsh 探测锚点:发行版包管理器与官方 tarball/Microsoft 源的固定落点
|
|
8
|
+
const POSIX_PWSH_ANCHORS = [
|
|
9
|
+
'/usr/bin/pwsh',
|
|
10
|
+
'/usr/local/bin/pwsh',
|
|
11
|
+
'/opt/microsoft/powershell/7/pwsh',
|
|
12
|
+
'/opt/homebrew/bin/pwsh',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
// PATH 探测的可执行名:win32 带 .exe 后缀,POSIX 裸名
|
|
16
|
+
const executableName = (kind, platform) => {
|
|
17
|
+
const suffix = platform === 'win32' ? '.exe' : ''
|
|
18
|
+
return { pwsh: `pwsh${suffix}`, bash: `bash${suffix}` }[kind]
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
// 各 kind 的探测锚点:主 ProgramFiles;x86 与 LOCALAPPDATA 仅 bash 有常见安装
|
|
11
22
|
const PF_RELATIVE = {
|
|
@@ -29,12 +40,6 @@ const SYSTEM32_FILES = {
|
|
|
29
40
|
wsl: 'wsl.exe',
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
// PATH 探测的可执行名
|
|
33
|
-
const PATH_EXECUTABLES = {
|
|
34
|
-
pwsh: 'pwsh.exe',
|
|
35
|
-
bash: 'bash.exe',
|
|
36
|
-
}
|
|
37
|
-
|
|
38
43
|
// MSYS2 默认安装锚点:真实 bash.exe 优先。msys2.exe(Cygwin 控制台启动器)
|
|
39
44
|
// 在管道 stdio 下 exit 0 且零输出——命令静默失败,任何情形不得进入候选
|
|
40
45
|
const MSYS2_ANCHORS = [
|
|
@@ -43,18 +48,22 @@ const MSYS2_ANCHORS = [
|
|
|
43
48
|
]
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
* 一个 kind 的候选路径,按解析顺序。显式参数化(env)保证纯函数性。
|
|
51
|
+
* 一个 kind 的候选路径,按解析顺序。显式参数化(env/platform)保证纯函数性。
|
|
47
52
|
* @param {string} kind pwsh|bash|cmd|wsl
|
|
48
53
|
* @param {object} env 模拟进程环境
|
|
54
|
+
* @param {string} [platform] 目标平台,缺省 process.platform
|
|
49
55
|
* @returns {string[]}
|
|
50
56
|
*/
|
|
51
|
-
export function candidatePaths(kind, env) {
|
|
57
|
+
export function candidatePaths(kind, env, platform = process.platform) {
|
|
58
|
+
const isWin32 = platform === 'win32'
|
|
59
|
+
const pathSep = isWin32 ? ';' : ':'
|
|
60
|
+
const fileSep = isWin32 ? '\\' : '/'
|
|
52
61
|
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
|
53
62
|
const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
54
63
|
const localAppData = env.LocalAppData ?? ''
|
|
55
64
|
const system32 = `${env.SystemRoot ?? 'C:\\WINDOWS'}\\System32`
|
|
56
65
|
const pathEntries = (env.PATH ?? '')
|
|
57
|
-
.split(
|
|
66
|
+
.split(pathSep)
|
|
58
67
|
.map((entry) => entry.trim().replace(/^"|"$/g, ''))
|
|
59
68
|
.filter((entry) => entry.length > 0)
|
|
60
69
|
// bash 的 PATH 探测排除 SystemRoot 下条目:System32\bash.exe 是 WSL forwarder,
|
|
@@ -65,18 +74,21 @@ export function candidatePaths(kind, env) {
|
|
|
65
74
|
? pathEntries.filter((entry) => !entry.toLowerCase().replace(/\//g, '\\').startsWith(`${systemRoot}\\`))
|
|
66
75
|
: pathEntries)
|
|
67
76
|
const candidates = []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
if (isWin32) {
|
|
78
|
+
for (const relative of PF_RELATIVE[kind] ?? []) candidates.push([programFiles, ...relative].join('\\'))
|
|
79
|
+
for (const relative of PF_X86_RELATIVE[kind] ?? []) candidates.push([programFilesX86, ...relative].join('\\'))
|
|
80
|
+
for (const relative of LAD_RELATIVE[kind] ?? []) {
|
|
81
|
+
if (localAppData.length > 0) candidates.push([localAppData, ...relative].join('\\'))
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
|
-
if (kind === 'bash') candidates.push(...MSYS2_ANCHORS)
|
|
84
|
+
if (isWin32 && kind === 'bash') candidates.push(...MSYS2_ANCHORS)
|
|
85
|
+
if (!isWin32 && kind === 'pwsh') candidates.push(...POSIX_PWSH_ANCHORS)
|
|
74
86
|
for (const entry of pathEntriesForKind(kind)) {
|
|
75
|
-
const executable =
|
|
76
|
-
if (executable !== undefined) candidates.push(`${entry}
|
|
87
|
+
const executable = executableName(kind, platform)
|
|
88
|
+
if (executable !== undefined) candidates.push(`${entry}${fileSep}${executable}`)
|
|
77
89
|
}
|
|
78
90
|
const system32File = SYSTEM32_FILES[kind]
|
|
79
|
-
if (system32File !== undefined) candidates.push(`${system32}\\${system32File}`)
|
|
91
|
+
if (isWin32 && system32File !== undefined) candidates.push(`${system32}\\${system32File}`)
|
|
80
92
|
return candidates
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -87,9 +99,9 @@ export function candidatePaths(kind, env) {
|
|
|
87
99
|
* @param {object} [env] 模拟进程环境,缺省用 process.env
|
|
88
100
|
* @returns {string|undefined} 无候选命中时 undefined
|
|
89
101
|
*/
|
|
90
|
-
export function resolveEntryPath(entry, exists, env = process.env) {
|
|
102
|
+
export function resolveEntryPath(entry, exists, env = process.env, platform = process.platform) {
|
|
91
103
|
if (entry.path.length > 0) return entry.path
|
|
92
|
-
for (const candidate of candidatePaths(entry.kind, env)) {
|
|
104
|
+
for (const candidate of candidatePaths(entry.kind, env, platform)) {
|
|
93
105
|
if (exists(candidate)) return candidate
|
|
94
106
|
}
|
|
95
107
|
return undefined
|
|
@@ -103,11 +115,11 @@ export function resolveEntryPath(entry, exists, env = process.env) {
|
|
|
103
115
|
* @param {(candidate: string) => boolean} exists
|
|
104
116
|
* @returns {{kind: string, path: string}[]}
|
|
105
117
|
*/
|
|
106
|
-
export function detectCandidates(kinds, env, exists) {
|
|
118
|
+
export function detectCandidates(kinds, env, exists, platform = process.platform) {
|
|
107
119
|
const seen = new Set()
|
|
108
120
|
const found = []
|
|
109
121
|
for (const kind of kinds) {
|
|
110
|
-
for (const candidate of candidatePaths(kind, env)) {
|
|
122
|
+
for (const candidate of candidatePaths(kind, env, platform)) {
|
|
111
123
|
if (seen.has(candidate)) continue
|
|
112
124
|
if (!exists(candidate)) continue
|
|
113
125
|
seen.add(candidate)
|
package/src/tool.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { TOOL_ABORTED, defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
12
12
|
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
|
13
13
|
import { isAbsolute, resolve } from 'node:path'
|
|
14
14
|
import { renderResult, renderProcessRead } from './render.mjs'
|
|
15
|
+
import { PWSH_TAKEOVER_BLOCK } from './pwsh-takeover.mjs'
|
|
15
16
|
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
|
|
16
17
|
|
|
17
18
|
/** 拒升权/Abort 错误构造:dsh-llm HarnessError 缺失时以同码 Error 降级(错误码通道不变)。 */
|
|
@@ -40,6 +41,25 @@ function blockedMarker(error) {
|
|
|
40
41
|
return `[blocked by shell-select: matches deny pattern ${error.pattern}]`
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* deny 命中 → schema 合法的前台结果:拒绝事实全在 stderr 标记文本,
|
|
46
|
+
* 禁止携带 blocked/blockedBy 等声明外字段——输出 oneOf 各分支
|
|
47
|
+
* additionalProperties:false,违者被宿主判 INVALID_TOOL_OUTPUT,拒绝文案不可达。
|
|
48
|
+
*/
|
|
49
|
+
function deniedForeground(error, shellId) {
|
|
50
|
+
return {
|
|
51
|
+
kind: 'foreground',
|
|
52
|
+
exitCode: null,
|
|
53
|
+
signal: null,
|
|
54
|
+
timedOut: false,
|
|
55
|
+
aborted: false,
|
|
56
|
+
timeoutMs: 0,
|
|
57
|
+
stdout: { text: '', truncated: false },
|
|
58
|
+
stderr: { text: blockedMarker(error), truncated: false },
|
|
59
|
+
shell: shellId,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** 显式 workdir 先行,相对者落会话工作区;否则用会话 cwd,执行器默认兜底(官方同构)。 */
|
|
44
64
|
function resolveWorkdir(modelWorkdir, exec) {
|
|
45
65
|
const headerCwd = exec.agent?.session.header.cwd
|
|
@@ -124,8 +144,11 @@ function foregroundOutputProperties() {
|
|
|
124
144
|
}
|
|
125
145
|
}
|
|
126
146
|
|
|
147
|
+
/** bash 路径措辞:bash 输出的 MSYS 拼写不得回流进工具参数(宿主文件面只认 Windows 形式)。 */
|
|
148
|
+
const BASH_PATH_GUIDANCE = 'Bash clients accept Windows-style paths (C:/...): use that form for `workdir` and file-tool arguments — MSYS spellings like `/c/Users/...` or `/tmp` only work inside the bash command itself, never in tool arguments.'
|
|
149
|
+
|
|
127
150
|
/** 工具描述:客户端清单与方言随配置动态变化。 */
|
|
128
|
-
function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
151
|
+
export function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
129
152
|
const listing = executor.listShells()
|
|
130
153
|
const lines = listing.shells
|
|
131
154
|
.map((entry) => `${entry.id} (${entry.kind}${entry.available ? '' : ', executable not found'})`)
|
|
@@ -138,8 +161,10 @@ function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
|
138
161
|
+ (backgroundEnabled
|
|
139
162
|
? '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`.'
|
|
140
163
|
: 'Background execution is not available; long-running commands must finish within the timeout.')
|
|
141
|
-
|
|
142
|
-
|
|
164
|
+
const bashPresent = listing.shells.some((candidate) => candidate.kind === 'bash' && candidate.available)
|
|
165
|
+
const text = bashPresent ? `${base} ${BASH_PATH_GUIDANCE}` : base
|
|
166
|
+
if (escalationModes.length === 0) return text
|
|
167
|
+
return text + ' 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`.'
|
|
143
168
|
}
|
|
144
169
|
|
|
145
170
|
/** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
|
|
@@ -240,7 +265,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
240
265
|
text: value.kind === 'background'
|
|
241
266
|
? `started background job ${value.jobId}${value.shell !== undefined ? `\n[shell: ${value.shell}]` : ''}`
|
|
242
267
|
: renderResult(value, escalationModes, value.shell),
|
|
243
|
-
}],
|
|
268
|
+
}, ...(toolName === 'pwsh' ? [PWSH_TAKEOVER_BLOCK] : [])],
|
|
244
269
|
},
|
|
245
270
|
async execute(args, exec) {
|
|
246
271
|
validateArgs(args)
|
|
@@ -250,7 +275,8 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
250
275
|
: undefined
|
|
251
276
|
const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
|
|
252
277
|
const workdir = resolveWorkdir(args.workdir, exec)
|
|
253
|
-
|
|
278
|
+
// pwsh 契约:钉死工具永远 PowerShell,配置删条目也现场探测,不落默认客户端
|
|
279
|
+
const entry = pinned !== undefined ? faces.executor.contractEntry() : faces.executor.entryFor(args.shell)
|
|
254
280
|
const request = {
|
|
255
281
|
command: args.command,
|
|
256
282
|
...workdir !== undefined ? { workdir } : {},
|
|
@@ -263,9 +289,9 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
263
289
|
if (jobs === undefined) throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
264
290
|
if (exec.signal.aborted) throw await abortError('tool call aborted')
|
|
265
291
|
try {
|
|
266
|
-
faces.executor.
|
|
292
|
+
faces.executor.assertNotDenied(request.command)
|
|
267
293
|
} catch (error) {
|
|
268
|
-
if (isDenyError(error)) return
|
|
294
|
+
if (isDenyError(error)) return deniedForeground(error, entry.id)
|
|
269
295
|
throw error
|
|
270
296
|
}
|
|
271
297
|
return {
|
|
@@ -274,7 +300,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
274
300
|
jobId: jobs.start({
|
|
275
301
|
kind: 'shell',
|
|
276
302
|
label: args.command,
|
|
277
|
-
...exec.agent ? { owner: exec.agent } : {},
|
|
303
|
+
...exec.agent ? { owner: exec.agent.id } : {},
|
|
278
304
|
run: () => {
|
|
279
305
|
const proc = faces.executor.startFor(entry, faces.executor.resolve(request))
|
|
280
306
|
return {
|
|
@@ -293,7 +319,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
293
319
|
signal: exec.signal,
|
|
294
320
|
}))
|
|
295
321
|
} catch (error) {
|
|
296
|
-
if (isDenyError(error)) return
|
|
322
|
+
if (isDenyError(error)) return deniedForeground(error, entry.id)
|
|
297
323
|
throw error
|
|
298
324
|
}
|
|
299
325
|
if (result.aborted) throw await abortError('tool call aborted')
|
|
@@ -317,7 +343,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
317
343
|
}
|
|
318
344
|
},
|
|
319
345
|
presentResult: (args, result) => {
|
|
320
|
-
const block = result.content.length === 1 ? result.content
|
|
346
|
+
const block = result.content.filter(part => part.type === 'text').length === 1 ? result.content.find(part => part.type === 'text') : undefined
|
|
321
347
|
if (block === undefined || block.type !== 'text') return undefined
|
|
322
348
|
const raw = block.text
|
|
323
349
|
if (typeof args === 'object' && args !== null && args.run_in_background === true || result.isError) {
|
package/src/web-routes.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// settings.describe,写经 settings.replace;探测/扫描用 resolve 纯函数。
|
|
6
6
|
// HTTP 形态(具名 exact 路由 + 跨源守卫 + 业务异常归一 400)照 dsh-maintain。
|
|
7
7
|
|
|
8
|
+
import { homedir, tmpdir } from 'node:os'
|
|
8
9
|
import { KINDS, resolveConfig, coerceServiceable, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
|
|
9
10
|
import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
|
|
10
11
|
import { requestShellRefresh } from './executor.mjs'
|
|
@@ -84,6 +85,8 @@ export function buildFaces(ctx, settings) {
|
|
|
84
85
|
},
|
|
85
86
|
detect: (kinds) => detectCandidates(kinds ?? [...KINDS], process.env, candidateExists),
|
|
86
87
|
probe: (candidatePath) => candidateExists(candidatePath),
|
|
88
|
+
// 宿主路径事实:客户端侧栏路径救援用(翻译 MSYS 拼写回宿主形式的事实来源)
|
|
89
|
+
pathMap: () => ({ platform: process.platform, home: homedir(), tmpDir: tmpdir() }),
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// 后台 job owner 必须传 agent id(场景:run_in_background 报 "[object Object] has no live agent"):源级守卫
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { join, dirname } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const source = readFileSync(join(here, '..', 'src', 'tool.mjs'), 'utf8')
|
|
10
|
+
|
|
11
|
+
test('后台 job owner 传 agent id 而非 agent 对象', () => {
|
|
12
|
+
assert.match(source, /owner: exec\.agent\.id/)
|
|
13
|
+
assert.doesNotMatch(source, /owner: exec\.agent \}/)
|
|
14
|
+
})
|
|
@@ -32,10 +32,6 @@ function cardModel(catalog = null) {
|
|
|
32
32
|
})
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function parseEnvText() {
|
|
36
|
-
return extractLogic('parseEnvText')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
35
|
test('运行时长格式:秒→m:ss,一小时以上→h:mm:ss,负值钳 0', () => {
|
|
40
36
|
const formatDuration = extractLogic('formatDuration')
|
|
41
37
|
assert.equal(formatDuration(0), '0:00')
|
|
@@ -46,10 +42,6 @@ test('运行时长格式:秒→m:ss,一小时以上→h:mm:ss,负值钳 0', () =
|
|
|
46
42
|
assert.equal(formatDuration(-5), '0:00')
|
|
47
43
|
})
|
|
48
44
|
|
|
49
|
-
function invalidEnvLines() {
|
|
50
|
-
return extractLogic('invalidEnvLines')
|
|
51
|
-
}
|
|
52
|
-
|
|
53
45
|
// 运行中调用块(官方形态:无 kind 字段)
|
|
54
46
|
function runningBlock(argsRaw) {
|
|
55
47
|
return { callId: 'c1', name: 'shell', argsRaw }
|
|
@@ -323,16 +315,3 @@ test('S21j 钉死键集与注册键集同源:PINNED_CLIENT 恒等于 TOOLVIEW_KE
|
|
|
323
315
|
.split(',').map((pair) => pair.trim().split(':')[0].trim().replace(/^'|'$/g, '')).filter((key) => key !== '')
|
|
324
316
|
assert.deepEqual(pinnedKeys.sort(), toolviewKeys.sort())
|
|
325
317
|
})
|
|
326
|
-
|
|
327
|
-
test('K=V 往返:值含 = 与空格无损,空行忽略,重复键后行胜', () => {
|
|
328
|
-
const parse = parseEnvText()
|
|
329
|
-
assert.deepEqual(parse('A=1\nB=x=y z\n\nA=2'), { A: '2', B: 'x=y z' })
|
|
330
|
-
assert.deepEqual(parse(''), {})
|
|
331
|
-
assert.deepEqual(parse(undefined), {})
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
test('invalidEnvLines:缺 = 行报出,空行不报', () => {
|
|
335
|
-
const invalid = invalidEnvLines()
|
|
336
|
-
assert.deepEqual(invalid('A=1\nbroken\n\n noSep '), ['broken', 'noSep'])
|
|
337
|
-
assert.deepEqual(invalid('A=1'), [])
|
|
338
|
-
})
|
package/test/client-id.test.mjs
CHANGED
|
@@ -23,11 +23,6 @@ test('标记值为完整 npm 包名', () => {
|
|
|
23
23
|
assert.match(source, /setAttribute\('data-plugin', '@mzzsfy\/dsh-shell-select'\)/)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
test('settings.section 注册与 id', () => {
|
|
27
|
-
assert.match(source, /settings\.section/)
|
|
28
|
-
assert.match(source, /id: 'shell-select'/)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
26
|
test('tool.call.toolview 注册:shell 族三 key + priority -1', () => {
|
|
32
27
|
assert.match(source, /'tool\.call\.toolview'/)
|
|
33
28
|
assert.match(source, /const TOOLVIEW_KEYS = \['shell', 'pwsh', 'bash'\]/)
|
|
@@ -43,55 +38,4 @@ test('卡片数据链:官方同构派生(argsRaw + content 尾部退出标记)+
|
|
|
43
38
|
test('primitives 缺席时图标降级自绘(require 有 try/catch 兜底)', () => {
|
|
44
39
|
assert.match(source, /require\('@deepseek-ai\/dsh-client-ui-primitives'\)/)
|
|
45
40
|
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 黑名单进设置页数据链:逐条规则编辑器(deny 绝对,allow 已移除)', () => {
|
|
62
|
-
// 数据链:state 数组 → 保存直落 deny;加载侧 patternText 按条解析(Trim 去空白)
|
|
63
|
-
assert.match(source, /denyRules/)
|
|
64
|
-
assert.match(source, /deny: denyRules/)
|
|
65
|
-
assert.match(source, /patternText\(section\.deny\)/)
|
|
66
|
-
// 行级即时校验 LOGIC 段 + 保存侧复用
|
|
67
|
-
assert.match(source, /denyPatternIssues/)
|
|
68
|
-
assert.match(source, /LOGIC-BEGIN denyPatternIssues/)
|
|
69
|
-
// 交互结构:规则行 + 删除 + 添加 + 空态提示
|
|
70
|
-
assert.match(source, /sls-rule/)
|
|
71
|
-
assert.match(source, /添加规则/)
|
|
72
|
-
assert.match(source, /未配置.*命中拦截不生效/)
|
|
73
|
-
// 术语与僵尸防线
|
|
74
|
-
assert.match(source, /命令黑名单/)
|
|
75
|
-
assert.doesNotMatch(source, /denyText/)
|
|
76
|
-
assert.doesNotMatch(source, /allowText|豁免名单|拒绝名单/)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
// LOGIC 段行为测试:行级正则校验(提取形态照 client-card.test.mjs)
|
|
80
|
-
function extractLogic(name) {
|
|
81
|
-
const pattern = new RegExp('// LOGIC-BEGIN ' + name + '\\n([\\s\\S]*?)\\n\\s*// LOGIC-END ' + name)
|
|
82
|
-
const match = source.match(pattern)
|
|
83
|
-
if (!match) throw new Error('client.js 缺少 LOGIC 段: ' + name)
|
|
84
|
-
return new Function('return (' + match[1].trim() + ')')()
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
test('denyPatternIssues:合法与空规则无问题', () => {
|
|
88
|
-
const issues = extractLogic('denyPatternIssues')(['^format\\s', 'rm -rf\\s+(?!\\S*node_modules)', ''])
|
|
89
|
-
assert.deepEqual(issues, [])
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('denyPatternIssues:非法规则定位到行(行号 = 规则序)', () => {
|
|
93
|
-
const issues = extractLogic('denyPatternIssues')(['^format\\s', 'rm -rf\\s+(?!', 'shutdown'])
|
|
94
|
-
assert.equal(issues.length, 1)
|
|
95
|
-
assert.equal(issues[0].index, 1)
|
|
96
|
-
assert.match(issues[0].error, /Invalid|regular|正则/)
|
|
97
41
|
})
|
|
@@ -10,7 +10,7 @@ import { dirname, join } from 'node:path'
|
|
|
10
10
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
11
11
|
const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
|
|
12
12
|
|
|
13
|
-
function reactStub() {
|
|
13
|
+
function reactStub(expanded = false) {
|
|
14
14
|
// 函数组件即时执行(模拟渲染语义):组件体内的未定义引用/坏访问当场抛出
|
|
15
15
|
const createElement = (type, props, ...children) => {
|
|
16
16
|
if (typeof type === 'function') return type(props ?? {})
|
|
@@ -18,14 +18,14 @@ function reactStub() {
|
|
|
18
18
|
}
|
|
19
19
|
return {
|
|
20
20
|
createElement,
|
|
21
|
-
useState: (init) => [typeof init === 'function' ? init() : init, () => {}],
|
|
21
|
+
useState: (init) => [expanded || (typeof init === 'function' ? init() : init), () => {}],
|
|
22
22
|
useEffect: () => {},
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// 加载 client.js 并执行 apply,返回 slots 注册面
|
|
27
|
-
function loadClient() {
|
|
28
|
-
const React = reactStub()
|
|
27
|
+
function loadClient(expanded = false) {
|
|
28
|
+
const React = reactStub(expanded)
|
|
29
29
|
const registrations = []
|
|
30
30
|
const slots = {
|
|
31
31
|
inject: (_name, factory) => factory(),
|
|
@@ -70,6 +70,39 @@ function settledBlock(argsRaw, text) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
test('直接 pwsh 接管:折叠与展开都有独立徽章,复制输出剥离事实标记', () => {
|
|
74
|
+
// Given 已接管 pwsh 结果;When折叠/展开渲染;Then徽章可见且输出不泄露内部标记。
|
|
75
|
+
const args = JSON.stringify({ command: 'Get-Date', description: 'probe' })
|
|
76
|
+
for (const expanded of [false, true]) {
|
|
77
|
+
const component = toolviewOf(loadClient(expanded), 'pwsh')
|
|
78
|
+
const block = settledBlock(args, 'out\n[exit code: 2]')
|
|
79
|
+
block.content.push({ type: 'shell-select-takeover', tool: 'pwsh' })
|
|
80
|
+
const element = component({ block, cwd: 'C:\\repo' })
|
|
81
|
+
const nodes = []
|
|
82
|
+
const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) return node.forEach(walk); nodes.push(node); node.children?.forEach(walk) }
|
|
83
|
+
walk(element)
|
|
84
|
+
assert.ok(nodes.some(node => node.props?.className === 'sls-tv__takeover' && node.children.includes('插件接管')))
|
|
85
|
+
assert.doesNotMatch(JSON.stringify(element), /\[shell-select: pwsh takeover\]/)
|
|
86
|
+
if (expanded) assert.ok(nodes.some(node => node.props?.className === 'sls-tv__badge' && node.children.includes('pwsh')))
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('历史 stdout 恰含标记也不得冒充接管事实或丢失输出', () => {
|
|
91
|
+
// Given历史pwsh stdout含旧标记;When展开;Then没有接管徽章且原文保留。
|
|
92
|
+
const element = toolviewOf(loadClient(true), 'pwsh')({ block: settledBlock(JSON.stringify({ command: 'Write-Output marker', description: 'history' }), '[shell-select: pwsh takeover]'), cwd: 'C:\\repo' })
|
|
93
|
+
assert.doesNotMatch(JSON.stringify(element), /插件接管/)
|
|
94
|
+
assert.match(JSON.stringify(element), /\[shell-select: pwsh takeover\]/)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('未标记历史 pwsh 与普通 shell 不显示接管徽章', () => {
|
|
98
|
+
// Given普通/历史结果;When渲染;Then不由工具名或当前配置臆测接管。
|
|
99
|
+
const args = JSON.stringify({ command: 'echo x', description: 'probe' })
|
|
100
|
+
for (const key of ['pwsh', 'shell']) {
|
|
101
|
+
const element = toolviewOf(loadClient(true), key)({ block: settledBlock(args, 'out'), cwd: 'C:\\repo' })
|
|
102
|
+
assert.doesNotMatch(JSON.stringify(element), /插件接管/)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
73
106
|
test('渲染守卫:settled terminal 调用组件执行不抛(引用完整)', () => {
|
|
74
107
|
const component = toolviewOf(loadClient(), 'shell')
|
|
75
108
|
const element = component({
|
|
@@ -108,13 +141,6 @@ test('渲染守卫:后台 ack 块(默认收起行 + 任务号徽标)组件执行
|
|
|
108
141
|
assert.notEqual(element, undefined)
|
|
109
142
|
})
|
|
110
143
|
|
|
111
|
-
test('设置卡注册:settings.section 单卡 id shell-select', () => {
|
|
112
|
-
const registrations = loadClient()
|
|
113
|
-
const card = registrations.find((item) => item.meta.name === 'settings.section')
|
|
114
|
-
assert.notEqual(card, undefined)
|
|
115
|
-
assert.equal(card.meta.id, 'shell-select')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
144
|
test('图标名守卫:0.1.7 primitives 字重后缀名在场,尺寸后缀旧名不在首选位', () => {
|
|
119
145
|
// 0.1.7 图标改名:尺寸从名字移除(IconApiOutline14 → IconApiOutlineRegular/Medium),
|
|
120
146
|
// 旧名 miss 使 IconApi 落淡灰 fallback 被当成"空白 icon"(实测事故)
|
|
@@ -156,7 +182,7 @@ test('hooks 恒序守卫:组件内 hook 调用必须全部位于第一个提前
|
|
|
156
182
|
const m = /return h\(/.exec(scope)
|
|
157
183
|
return m === null ? Number.POSITIVE_INFINITY : beginLine + scope.slice(0, m.index).split('\n').length - 1
|
|
158
184
|
}
|
|
159
|
-
for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration'
|
|
185
|
+
for (const name of ['ShellToolRow', 'GenericShellRow', 'CopyButton', 'RunningDuration']) {
|
|
160
186
|
const { hookLines } = extract(name)
|
|
161
187
|
const scopeBody = source.slice(source.indexOf(`function ${name}(`))
|
|
162
188
|
const end = scopeBody.indexOf('\n function ', 1)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
|
|
2
2
|
|
|
3
3
|
import { test } from 'node:test'
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
@@ -69,6 +69,7 @@ function stubCtxFor(config) {
|
|
|
69
69
|
const registered = { sections: [], tools: [], promptSections: [], routes: [] }
|
|
70
70
|
return {
|
|
71
71
|
reflect: { provide: () => {} },
|
|
72
|
+
on: () => () => {},
|
|
72
73
|
logger: { warn: () => {} },
|
|
73
74
|
effect: (fn) => { registered.routes.push(fn) },
|
|
74
75
|
get() { return { register: () => {} } },
|