@mzzsfy/dsh-shell-select 0.4.1 → 0.4.4
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 -20
- package/cordis.patch.yml +3 -189
- package/package.json +10 -4
- package/src/api.mjs +7 -1
- package/src/client.js +439 -389
- 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 +58 -25
- 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/client-settings.test.mjs +251 -0
- 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 +23 -3
- package/test/official-contract.test.mjs +208 -0
- package/test/path-guidance.test.mjs +77 -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.
|
|
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.',
|
|
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,22 +144,34 @@ function foregroundOutputProperties() {
|
|
|
124
144
|
}
|
|
125
145
|
}
|
|
126
146
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
147
|
+
/** Bash 命令内路径与宿主工具参数使用不同拼写。 */
|
|
148
|
+
const BASH_PATH_GUIDANCE = 'Use Windows-style paths (C:/...) in workdir and file-tool arguments; MSYS paths (/c/..., /tmp) are valid only inside Bash commands.'
|
|
149
|
+
const SHELL_ENVIRONMENTS = { pwsh: 'PowerShell', bash: 'Bash', cmd: 'Windows cmd.exe', wsl: 'WSL' }
|
|
150
|
+
|
|
151
|
+
/** 工具描述只展示当前配置与常规调用方式。 */
|
|
152
|
+
export function shellDescription({ executor, backgroundEnabled }) {
|
|
129
153
|
const listing = executor.listShells()
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
const clients = listing.shells.map((entry) => {
|
|
155
|
+
const distro = entry.kind === 'wsl' && entry.distro ? ` (${entry.distro})` : ''
|
|
156
|
+
const availability = entry.available ? '' : ' (unavailable)'
|
|
157
|
+
return `- ${entry.id} — ${SHELL_ENVIRONMENTS[entry.kind]}${distro}${availability}`
|
|
158
|
+
}).join('\n')
|
|
159
|
+
const bashPresent = listing.shells.some((entry) => entry.kind === 'bash' && entry.available)
|
|
160
|
+
return [
|
|
161
|
+
`Execute commands using shell=${JSON.stringify(listing.default)} by default and return stdout/stderr.`,
|
|
162
|
+
'Omit shell to use the default; set it to one of:',
|
|
163
|
+
clients,
|
|
164
|
+
'',
|
|
165
|
+
'Each call starts a fresh process; cwd, variables, and functions do not persist.',
|
|
166
|
+
'Use workdir to set the working directory.',
|
|
167
|
+
...bashPresent ? [BASH_PATH_GUIDANCE] : [],
|
|
168
|
+
'',
|
|
169
|
+
...backgroundEnabled ? [
|
|
170
|
+
'For long-running commands, set run_in_background=true.',
|
|
171
|
+
'Use job_output to read background output and job_kill to stop the job.',
|
|
172
|
+
] : ['Background execution is not available; long-running commands must finish within the timeout.'],
|
|
173
|
+
'Long output is truncated; the full-output file path is reported when available.',
|
|
174
|
+
].join('\n')
|
|
143
175
|
}
|
|
144
176
|
|
|
145
177
|
/** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
|
|
@@ -182,7 +214,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
182
214
|
const makeDefinition = (toolName, pinned) => (faces) => defineTool({
|
|
183
215
|
name: toolName,
|
|
184
216
|
description: pinned === undefined
|
|
185
|
-
? shellDescription({ executor: faces.executor, backgroundEnabled
|
|
217
|
+
? shellDescription({ executor: faces.executor, backgroundEnabled })
|
|
186
218
|
: pwshDescription(),
|
|
187
219
|
parameters: {
|
|
188
220
|
command: {
|
|
@@ -199,7 +231,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
199
231
|
},
|
|
200
232
|
...pinned === undefined ? { shell: {
|
|
201
233
|
type: 'string',
|
|
202
|
-
description: '
|
|
234
|
+
description: 'Override the default shell using an id listed above.',
|
|
203
235
|
} } : {},
|
|
204
236
|
timeoutMs: {
|
|
205
237
|
type: 'number',
|
|
@@ -217,7 +249,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
217
249
|
sandbox_permissions: {
|
|
218
250
|
type: 'string',
|
|
219
251
|
enum: [...escalationModes],
|
|
220
|
-
description: '
|
|
252
|
+
description: 'Wider sandbox access mode for this command; requires justification and user approval.',
|
|
221
253
|
},
|
|
222
254
|
justification: {
|
|
223
255
|
type: 'string',
|
|
@@ -240,7 +272,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
240
272
|
text: value.kind === 'background'
|
|
241
273
|
? `started background job ${value.jobId}${value.shell !== undefined ? `\n[shell: ${value.shell}]` : ''}`
|
|
242
274
|
: renderResult(value, escalationModes, value.shell),
|
|
243
|
-
}],
|
|
275
|
+
}, ...(toolName === 'pwsh' ? [PWSH_TAKEOVER_BLOCK] : [])],
|
|
244
276
|
},
|
|
245
277
|
async execute(args, exec) {
|
|
246
278
|
validateArgs(args)
|
|
@@ -250,7 +282,8 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
250
282
|
: undefined
|
|
251
283
|
const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
|
|
252
284
|
const workdir = resolveWorkdir(args.workdir, exec)
|
|
253
|
-
|
|
285
|
+
// pwsh 契约:钉死工具永远 PowerShell,配置删条目也现场探测,不落默认客户端
|
|
286
|
+
const entry = pinned !== undefined ? faces.executor.contractEntry() : faces.executor.entryFor(args.shell)
|
|
254
287
|
const request = {
|
|
255
288
|
command: args.command,
|
|
256
289
|
...workdir !== undefined ? { workdir } : {},
|
|
@@ -263,9 +296,9 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
263
296
|
if (jobs === undefined) throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
264
297
|
if (exec.signal.aborted) throw await abortError('tool call aborted')
|
|
265
298
|
try {
|
|
266
|
-
faces.executor.
|
|
299
|
+
faces.executor.assertNotDenied(request.command)
|
|
267
300
|
} catch (error) {
|
|
268
|
-
if (isDenyError(error)) return
|
|
301
|
+
if (isDenyError(error)) return deniedForeground(error, entry.id)
|
|
269
302
|
throw error
|
|
270
303
|
}
|
|
271
304
|
return {
|
|
@@ -274,7 +307,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
274
307
|
jobId: jobs.start({
|
|
275
308
|
kind: 'shell',
|
|
276
309
|
label: args.command,
|
|
277
|
-
...exec.agent ? { owner: exec.agent } : {},
|
|
310
|
+
...exec.agent ? { owner: exec.agent.id } : {},
|
|
278
311
|
run: () => {
|
|
279
312
|
const proc = faces.executor.startFor(entry, faces.executor.resolve(request))
|
|
280
313
|
return {
|
|
@@ -293,7 +326,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
293
326
|
signal: exec.signal,
|
|
294
327
|
}))
|
|
295
328
|
} catch (error) {
|
|
296
|
-
if (isDenyError(error)) return
|
|
329
|
+
if (isDenyError(error)) return deniedForeground(error, entry.id)
|
|
297
330
|
throw error
|
|
298
331
|
}
|
|
299
332
|
if (result.aborted) throw await abortError('tool call aborted')
|
|
@@ -317,7 +350,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
317
350
|
}
|
|
318
351
|
},
|
|
319
352
|
presentResult: (args, result) => {
|
|
320
|
-
const block = result.content.length === 1 ? result.content
|
|
353
|
+
const block = result.content.filter(part => part.type === 'text').length === 1 ? result.content.find(part => part.type === 'text') : undefined
|
|
321
354
|
if (block === undefined || block.type !== 'text') return undefined
|
|
322
355
|
const raw = block.text
|
|
323
356
|
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
|
})
|