@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/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:已解析 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,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
- 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`.'
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
- const entry = faces.executor.entryFor(pinned ?? args.shell)
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.startFor(entry, faces.executor.resolve(request))
292
+ faces.executor.assertNotDenied(request.command)
267
293
  } 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 } }
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 { 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 } }
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[0] : undefined
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) {
@@ -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
  })
@@ -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', 'ShellSelectApp']) {
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
- // 条目级 env 与 wsl distro:schema 默认、argv 形、entryFor 透传。BDD 场景见 docs/feat-shell-select-optim/plan.md S5-S10。
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: () => {} } },