@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/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.',
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:已解析 spec 按默认客户端前台执行(dsh-pwsh-local run 同构)。 */
286
+ /** 官方 execute seam:pwsh 客户端前台执行(dsh-pwsh-local run 同构,契约钉死 pwsh)。 */
268
287
  async run(spec) {
269
- return this.runFor(this.entryFor(), spec)
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 后按默认客户端 spawn,返回 ShellExecution
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.entryFor()
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:已解析 spec 按默认客户端后台启动(dsh-pwsh-local start 同构)。 */
446
+ /** 官方 start seam:pwsh 客户端后台启动(dsh-pwsh-local start 同构,契约钉死 pwsh)。 */
424
447
  start(spec) {
425
- return this.startFor(this.entryFor(), spec)
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
- // PATH 分隔符:仅 Windows 分号(本包 v1 仅 win32 激活)
8
- const PATH_SEP = ';'
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(PATH_SEP)
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
- for (const relative of PF_RELATIVE[kind] ?? []) candidates.push([programFiles, ...relative].join('\\'))
69
- for (const relative of PF_X86_RELATIVE[kind] ?? []) candidates.push([programFilesX86, ...relative].join('\\'))
70
- for (const relative of LAD_RELATIVE[kind] ?? []) {
71
- if (localAppData.length > 0) candidates.push([localAppData, ...relative].join('\\'))
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 = PATH_EXECUTABLES[kind]
76
- if (executable !== undefined) candidates.push(`${entry}\\${executable}`)
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
- function shellDescription({ executor, backgroundEnabled, escalationModes }) {
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 lines = listing.shells
131
- .map((entry) => `${entry.id} (${entry.kind}${entry.available ? '' : ', executable not found'})`)
132
- .join(', ')
133
- const base = 'Execute a command in one of the configured shell clients and return its stdout/stderr. '
134
- + `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). `
135
- + 'Each call runs in a fresh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
136
- + 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
137
- + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
138
- + (backgroundEnabled
139
- ? '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
- : 'Background execution is not available; long-running commands must finish within the timeout.')
141
- if (escalationModes.length === 0) return base
142
- 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`.'
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, escalationModes })
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: 'Shell client id from the tool description list (e.g. pwsh, git-bash, cmd). Omit to use the configured default client.',
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: '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.',
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
- const entry = faces.executor.entryFor(pinned ?? args.shell)
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.startFor(entry, faces.executor.resolve(request))
299
+ faces.executor.assertNotDenied(request.command)
267
300
  } catch (error) {
268
- if (isDenyError(error)) return { kind: 'foreground', shell: entry.id, 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 } }
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 { kind: 'foreground', shell: entry.id, 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 } }
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[0] : undefined
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) {
@@ -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
- })
@@ -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
  })