@mzzsfy/dsh-shell-select 0.4.0 → 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/config.mjs CHANGED
@@ -83,6 +83,72 @@ export function resolveConfig(entry) {
83
83
  return unwrapConfig(Config(structuredClone(sanitized)))
84
84
  }
85
85
 
86
+ /** 执行面严格解析:输入键被类型对账丢弃时抛。执行面(挂载/refresh)输入是
87
+ * 宿主刚 resolve 的产物,形态非法 = 宿主契约变化或调用方未解箱,必须炸在
88
+ * 挂载点(接管序列有回滚告警)而非静默回落出厂默认——静默曾让行 config
89
+ * 长期失效(0.2.0-rc.2 真机实证)。持久化读回路径(落盘写坏降级、行不死
90
+ * 可保存修复)仍用 resolveConfig 的宽容语义。 */
91
+ export function resolveConfigStrict(entry) {
92
+ const input = entry ?? {}
93
+ const sanitized = sanitizeConfigEntry(input)
94
+ const dropped = Object.keys(FIELD_TYPES)
95
+ .filter((key) => input[key] !== undefined && !(key in sanitized))
96
+ if (dropped.length > 0) {
97
+ throw new Error(`shell-select: config 字段形态非法(执行面输入须先解箱为普通对象): ${dropped.join(', ')}`)
98
+ }
99
+ return unwrapConfig(Config(structuredClone(sanitized)))
100
+ }
101
+
102
+ /** 用户声明值挂载前 coercing:类型清洗(sanitize)+ 语义兜底,任意用户输入
103
+ * 产出可服务配置——插件本体不因配置死(行活、工具活,坏字段告警后降默认)。
104
+ * 语义兜底:shells 清洗后为空(含条目缺 id/kind 或 kind 非法被剔除)补出厂
105
+ * 默认;default 不在 shells id 集归 shells[0];预算数字非正数或超 graceMs
106
+ * 上限降 schema 默认;deny 剔除非字符串与不可编译条目。 */
107
+ export function coerceServiceable(entry) {
108
+ const warnings = []
109
+ const cleaned = sanitizeConfigEntry(entry ?? {})
110
+ const rawShells = Array.isArray(cleaned.shells) ? cleaned.shells : []
111
+ const usable = rawShells.filter((item) => item !== null && typeof item === 'object'
112
+ && typeof item.id === 'string' && item.id !== ''
113
+ && typeof item.kind === 'string' && KINDS.includes(item.kind))
114
+ if (usable.length !== rawShells.length) warnings.push(`shells 剔除 ${rawShells.length - usable.length} 个缺 id/kind 或 kind 非法的条目`)
115
+ const shells = usable.length > 0 ? usable : defaultConfig().shells
116
+ if (usable.length === 0) warnings.push('shells 为空或全部非法,回落出厂默认客户端清单')
117
+ let defaultId = cleaned.default
118
+ if (typeof defaultId !== 'string' || !shells.some((item) => item.id === defaultId)) {
119
+ warnings.push(`default ${JSON.stringify(defaultId)} 不在 shells 清单,归 shells[0]`)
120
+ defaultId = shells[0].id
121
+ }
122
+ const rawDeny = Array.isArray(cleaned.deny) ? cleaned.deny : undefined
123
+ const deny = rawDeny?.filter((pattern) => {
124
+ if (typeof pattern !== 'string' || pattern === '') return false
125
+ try {
126
+ new RegExp(pattern, 'i')
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ })
132
+ if (rawDeny !== undefined && deny.length !== rawDeny.length) warnings.push(`deny 剔除 ${rawDeny.length - deny.length} 个非字符串或不可编译正则的条目`)
133
+ const budgets = {}
134
+ for (const key of ['timeoutMs', 'maxTimeoutMs', 'maxOutputBytes', 'maxSpillBytes', 'graceMs']) {
135
+ const value = cleaned[key]
136
+ if (value === undefined) continue
137
+ const overCeiling = key === 'graceMs' && value > MAX_TIMER_DELAY_MS
138
+ if (!Number.isFinite(value) || value <= 0 || overCeiling) {
139
+ warnings.push(`${key} 必须为正有限数${key === 'graceMs' ? `且不大于 ${MAX_TIMER_DELAY_MS}` : ''},降 schema 默认`)
140
+ // 显式置空覆盖 cleaned 原值,交 schema 默认兜底
141
+ budgets[key] = undefined
142
+ continue
143
+ }
144
+ budgets[key] = value
145
+ }
146
+ return {
147
+ config: { ...cleaned, shells, default: defaultId, deny, ...budgets },
148
+ warnings,
149
+ }
150
+ }
151
+
86
152
  // 落盘行 config 的类型防御:设置写路径(原生页/mutate)可能把 volatile 字段
87
153
  // 以非预期形态(ref 序列化产物 {})持久化,冷启动 Config 直接抛 → 行死 →
88
154
  // settings describe 无此节 → 设置页保存 "no longer configurable" 死锁,
package/src/executor.mjs CHANGED
@@ -14,11 +14,13 @@
14
14
 
15
15
  import { ShellExecutor } from '@deepseek-ai/dsh-shell'
16
16
  import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
17
- import { Config, KINDS, buildArgv, requireEntry, resolveConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
17
+ import { Config, KINDS, buildArgv, requireEntry, resolveConfig, resolveConfigStrict, unwrapConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
18
18
  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
 
@@ -137,8 +139,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
137
139
  void loadSandboxUnavailable(ctx).then((resolved) => {
138
140
  this.unavailableError = resolved
139
141
  })
140
- this.#entry = config ?? {}
141
- assertServiceableConfig(resolveConfig(this.#entry))
142
+ this.#setEntry(config)
142
143
  this.source = () => resolveConfig(this.#entry)
143
144
  // refresh 桥绑定随本 fiber 卸载自动解绑(实例退场后写路径请求静默丢弃)
144
145
  ctx.effect(() => bindShellRefreshBridge((next) => this.refresh(next)))
@@ -150,13 +151,23 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
150
151
  })
151
152
  this.#registerTool()
152
153
  this.#mountPromptSection()
154
+ mountShellPromptProjection(ctx)
155
+ this.markPwshTakeover = mountPwshTakeover(ctx)
156
+ }
157
+
158
+ /** 挂载/换源共用落定:入参经宿主按 static Config resolve,volatile 字段是
159
+ * boxed ref,存储前解箱成普通对象;落定校验走 strict——解箱后仍存在非法
160
+ * 形态字段即抛(宿主契约变化炸在挂载点,接管序列回滚告警),禁止静默回落
161
+ * 出厂默认(行 config 永不生效缺陷的机械化防线)。 */
162
+ #setEntry(config) {
163
+ this.#entry = unwrapConfig(config ?? {})
164
+ assertServiceableConfig(resolveConfigStrict(this.#entry))
153
165
  }
154
166
 
155
167
  /** volatile-only 配置变更的活刷新:换配置源并重建工具注册(写路径经
156
168
  * requestShellRefresh 触达;行不重挂,本实例与进行中执行不受扰)。 */
157
169
  refresh(config) {
158
- this.#entry = config ?? {}
159
- assertServiceableConfig(resolveConfig(this.#entry))
170
+ this.#setEntry(config)
160
171
  this.#registerTool()
161
172
  }
162
173
 
@@ -219,6 +230,21 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
219
230
  return { id: entry.id, kind: entry.kind, path: resolved, args: entry.args, login: entry.login === true, distro: entry.distro ?? '', env: entry.env ?? {} }
220
231
  }
221
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
+
222
248
  /** 注册/重注册 shell 工具(描述随客户端清单变化)。 */
223
249
  #registerTool() {
224
250
  this.#toolRegistration?.()
@@ -229,7 +255,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
229
255
  this.ctx.systemPrompt.section({
230
256
  name: 'tool:shell',
231
257
  order: this.ctx.systemPrompt.getSectionOrder('TOOL_PWSH'),
232
- 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.',
233
259
  })
234
260
  }
235
261
 
@@ -257,14 +283,17 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
257
283
  return buildArgv(entry, spec.command)
258
284
  }
259
285
 
260
- /** 官方 execute seam:已解析 spec 按默认客户端前台执行(dsh-pwsh-local run 同构)。 */
286
+ /** 官方 execute seam:pwsh 客户端前台执行(dsh-pwsh-local run 同构,契约钉死 pwsh)。 */
261
287
  async run(spec) {
262
- return this.runFor(this.entryFor(), spec)
288
+ this.markPwshTakeover()
289
+ return this.runFor(this.contractEntry(), spec)
263
290
  }
264
291
 
265
292
  /**
266
293
  * 官方 ctx.shell 唯一执行契约(ShellExecutor 抽象方法,官方 tool-pwsh 与
267
- * 进程内消费方共同依赖):解析 spec 后按默认客户端 spawn,返回 ShellExecution
294
+ * 进程内消费方共同依赖):解析 spec 后按 pwsh 客户端 spawn(官方 dsh-pwsh-local
295
+ * 契约即 PowerShell 专用,落默认客户端会随配置漂移炸方言),返回
296
+ * ShellExecution
268
297
  * 句柄(done/readOutput/kill/observed/result)。前台与否是调用方 await 什么的
269
298
  * 属性,不是 spawn 的属性。沙箱语义与 runFor 同源:danger 直跑,受限模式
270
299
  * confine 包装,定案按 denial/runner-failure 规则附 sandbox 事实。
@@ -272,8 +301,9 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
272
301
  * @returns {Promise<object>} ShellExecution 句柄
273
302
  */
274
303
  async execute(spec) {
304
+ this.markPwshTakeover()
275
305
  this.assertNotDenied(spec.command)
276
- const entry = this.entryFor()
306
+ const entry = this.contractEntry()
277
307
  const policy = spec.sandboxPolicy
278
308
  const { mode } = policy
279
309
  if (mode === 'danger-full-access') {
@@ -413,9 +443,10 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
413
443
  return Promise.resolve(proc)
414
444
  }
415
445
 
416
- /** 官方 start seam:已解析 spec 按默认客户端后台启动(dsh-pwsh-local start 同构)。 */
446
+ /** 官方 start seam:pwsh 客户端后台启动(dsh-pwsh-local start 同构,契约钉死 pwsh)。 */
417
447
  start(spec) {
418
- return this.startFor(this.entryFor(), spec)
448
+ this.markPwshTakeover()
449
+ return this.startFor(this.contractEntry(), spec)
419
450
  }
420
451
 
421
452
  /** 组装一次 spawn 的完整规格(官方 spawnSpec 同构,argv/条目参数化)。 */
package/src/index.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  runtimeDisableRows,
32
32
  takeoverDecision,
33
33
  } from './takeover.mjs'
34
+ import { coerceServiceable } from './config.mjs'
34
35
 
35
36
  export const name = 'shell-select'
36
37
 
@@ -114,7 +115,12 @@ function unwrapConfigSnapshot(config) {
114
115
  * 快照经 ctx.shell.refresh 换源并重建工具。
115
116
  */
116
117
  async function mountExecutor(ctx, config) {
117
- const fiber = await ctx.plugin(ShellSelectExecutor, unwrapConfigSnapshot(config))
118
+ // 用户声明值挂载前 coercing:任意配置(坏类型/空 shells/悬空 default/坏
119
+ // deny 正则)降级为可服务配置 + 逐条告警,行永不因配置死(官方链兜底之外
120
+ // 的第一道本体防线)
121
+ const { config: coerced, warnings } = coerceServiceable(unwrapConfigSnapshot(config))
122
+ for (const warning of warnings) ctx.logger?.warn?.(`shell-select: ${warning}`)
123
+ const fiber = await ctx.plugin(ShellSelectExecutor, coerced)
118
124
  // await 返回 ≠ 激活:executor inject 的宿主服务缺失时 fiber 静默 pending
119
125
  // (不 provide 不报错),ctx.shell 空缺会被官方复活行补位——挂载必须核实
120
126
  // 激活态,未激活按失败处理(触发回滚,官方接管)
@@ -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/render.mjs CHANGED
@@ -14,8 +14,11 @@ function streamText(output) {
14
14
  * 前台运行结果 → 模型可见文本。
15
15
  * @param {object} result ShellRunResult(含 sandbox 事实)
16
16
  * @param {string[]} escalationModes 本组合公示的升权目标;非空时拒绝标记后附升权提示
17
+ * @param {string|undefined} shellId 执行事实:实际生效的客户端条目 id;缺省不落标记
18
+ * (渲染面与官方逐字同构)。标记居 markers 首位,官方退出/信号锚保持末行,
19
+ * parseExitStatus 复原不受扰;客户端半区据此校正卡片徽章(配置事后变更不溯往)。
17
20
  */
18
- export function renderResult(result, escalationModes = []) {
21
+ export function renderResult(result, escalationModes = [], shellId) {
19
22
  const out = streamText(result.stdout)
20
23
  const err = streamText(result.stderr)
21
24
  let body = out
@@ -25,6 +28,7 @@ export function renderResult(result, escalationModes = []) {
25
28
  }
26
29
  if (body.length === 0) body = '(no output)'
27
30
  const markers = []
31
+ if (shellId !== undefined) markers.push(`[shell: ${shellId}]`)
28
32
  if (result.sandbox?.denied) {
29
33
  markers.push(sandboxDenialMarker(result.sandbox.mode))
30
34
  if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
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
@@ -56,8 +76,8 @@ function processOutcome(proc) {
56
76
  return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
57
77
  }
58
78
 
59
- /** 前台结果 DTO 去只读化(官方 canonical 同构)。 */
60
- function canonicalResult(result) {
79
+ /** 前台结果 DTO 去只读化(官方 canonical 同构);shellId 为执行事实,随块落存储供卡片徽章回放。 */
80
+ function canonicalResult(result, shellId) {
61
81
  const output = (stream) => ({
62
82
  text: stream.text,
63
83
  truncated: stream.truncated,
@@ -72,6 +92,7 @@ function canonicalResult(result) {
72
92
  timeoutMs: result.timeoutMs,
73
93
  stdout: output(result.stdout),
74
94
  stderr: output(result.stderr),
95
+ ...shellId !== undefined ? { shell: shellId } : {},
75
96
  ...result.sandbox !== undefined ? { sandbox: {
76
97
  mode: result.sandbox.mode,
77
98
  denied: result.sandbox.denied,
@@ -81,10 +102,11 @@ function canonicalResult(result) {
81
102
  }
82
103
  }
83
104
 
84
- /** 后台输出并集公共键(官方同构)。 */
105
+ /** 后台输出并集公共键(官方同构);shell 同前台,执行事实随 DTO 供渲染。 */
85
106
  const BACKGROUND_OUTPUT_PROPERTIES = {
86
107
  kind: { type: 'string', required: true, const: 'background' },
87
108
  jobId: { type: 'string', required: true },
109
+ shell: { type: 'string' },
88
110
  }
89
111
 
90
112
  /** 前台输出分支(官方同构,逐字段)。 */
@@ -108,6 +130,7 @@ function foregroundOutputProperties() {
108
130
  timeoutMs: { type: 'number', required: true },
109
131
  stdout: streamSchema,
110
132
  stderr: streamSchema,
133
+ shell: { type: 'string' },
111
134
  sandbox: {
112
135
  type: 'object',
113
136
  additionalProperties: false,
@@ -121,8 +144,11 @@ function foregroundOutputProperties() {
121
144
  }
122
145
  }
123
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
+
124
150
  /** 工具描述:客户端清单与方言随配置动态变化。 */
125
- function shellDescription({ executor, backgroundEnabled, escalationModes }) {
151
+ export function shellDescription({ executor, backgroundEnabled, escalationModes }) {
126
152
  const listing = executor.listShells()
127
153
  const lines = listing.shells
128
154
  .map((entry) => `${entry.id} (${entry.kind}${entry.available ? '' : ', executable not found'})`)
@@ -135,22 +161,15 @@ function shellDescription({ executor, backgroundEnabled, escalationModes }) {
135
161
  + (backgroundEnabled
136
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`.'
137
163
  : 'Background execution is not available; long-running commands must finish within the timeout.')
138
- if (escalationModes.length === 0) return base
139
- 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`.'
140
168
  }
141
169
 
142
- /** pwsh 工具描述:兼容保留工具,开头一句禁止调用。 */
143
- function pwshDescription({ backgroundEnabled, escalationModes }) {
144
- const base = 'Do not call this tool. It is kept only for backward compatibility with old sessions — call the `shell` tool instead. '
145
- + 'It executes a PowerShell command and returns its stdout/stderr. '
146
- + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
147
- + 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
148
- + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
149
- + (backgroundEnabled
150
- ? '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`.'
151
- : 'Background execution is not available; long-running commands must finish within the timeout.')
152
- if (escalationModes.length === 0) return base
153
- 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`.'
170
+ /** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
171
+ function pwshDescription() {
172
+ return 'Do not call; use `shell`.'
154
173
  }
155
174
 
156
175
  /**
@@ -189,7 +208,7 @@ export function registerShellTool(ctx, { executor }) {
189
208
  name: toolName,
190
209
  description: pinned === undefined
191
210
  ? shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes })
192
- : pwshDescription({ backgroundEnabled, escalationModes }),
211
+ : pwshDescription(),
193
212
  parameters: {
194
213
  command: {
195
214
  type: 'string',
@@ -243,8 +262,10 @@ export function registerShellTool(ctx, { executor }) {
243
262
  }] },
244
263
  render: (_args, value) => [{
245
264
  type: 'text',
246
- text: value.kind === 'background' ? `started background job ${value.jobId}` : renderResult(value, escalationModes),
247
- }],
265
+ text: value.kind === 'background'
266
+ ? `started background job ${value.jobId}${value.shell !== undefined ? `\n[shell: ${value.shell}]` : ''}`
267
+ : renderResult(value, escalationModes, value.shell),
268
+ }, ...(toolName === 'pwsh' ? [PWSH_TAKEOVER_BLOCK] : [])],
248
269
  },
249
270
  async execute(args, exec) {
250
271
  validateArgs(args)
@@ -254,7 +275,8 @@ export function registerShellTool(ctx, { executor }) {
254
275
  : undefined
255
276
  const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
256
277
  const workdir = resolveWorkdir(args.workdir, exec)
257
- const entry = faces.executor.entryFor(pinned ?? args.shell)
278
+ // pwsh 契约:钉死工具永远 PowerShell,配置删条目也现场探测,不落默认客户端
279
+ const entry = pinned !== undefined ? faces.executor.contractEntry() : faces.executor.entryFor(args.shell)
258
280
  const request = {
259
281
  command: args.command,
260
282
  ...workdir !== undefined ? { workdir } : {},
@@ -267,17 +289,18 @@ export function registerShellTool(ctx, { executor }) {
267
289
  if (jobs === undefined) throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
268
290
  if (exec.signal.aborted) throw await abortError('tool call aborted')
269
291
  try {
270
- faces.executor.startFor(entry, faces.executor.resolve(request))
292
+ faces.executor.assertNotDenied(request.command)
271
293
  } catch (error) {
272
- if (isDenyError(error)) return { kind: 'foreground', blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
294
+ if (isDenyError(error)) return deniedForeground(error, entry.id)
273
295
  throw error
274
296
  }
275
297
  return {
276
298
  kind: 'background',
299
+ shell: entry.id,
277
300
  jobId: jobs.start({
278
301
  kind: 'shell',
279
302
  label: args.command,
280
- ...exec.agent ? { owner: exec.agent } : {},
303
+ ...exec.agent ? { owner: exec.agent.id } : {},
281
304
  run: () => {
282
305
  const proc = faces.executor.startFor(entry, faces.executor.resolve(request))
283
306
  return {
@@ -296,11 +319,11 @@ export function registerShellTool(ctx, { executor }) {
296
319
  signal: exec.signal,
297
320
  }))
298
321
  } catch (error) {
299
- if (isDenyError(error)) return { kind: 'foreground', blocked: true, blockedBy: error.pattern, exitCode: null, signal: null, timedOut: false, aborted: false, timeoutMs: 0, stdout: { text: '', truncated: false }, stderr: { text: blockedMarker(error), truncated: false } }
322
+ if (isDenyError(error)) return deniedForeground(error, entry.id)
300
323
  throw error
301
324
  }
302
325
  if (result.aborted) throw await abortError('tool call aborted')
303
- return canonicalResult(result)
326
+ return canonicalResult(result, entry.id)
304
327
  },
305
328
  presentCall: (args) => {
306
329
  if (args.run_in_background === true) {
@@ -320,7 +343,7 @@ export function registerShellTool(ctx, { executor }) {
320
343
  }
321
344
  },
322
345
  presentResult: (args, result) => {
323
- 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
324
347
  if (block === undefined || block.type !== 'text') return undefined
325
348
  const raw = block.text
326
349
  if (typeof args === 'object' && args !== null && args.run_in_background === true || result.isError) {
@@ -5,7 +5,8 @@
5
5
  // settings.describe,写经 settings.replace;探测/扫描用 resolve 纯函数。
6
6
  // HTTP 形态(具名 exact 路由 + 跨源守卫 + 业务异常归一 400)照 dsh-maintain。
7
7
 
8
- import { KINDS, resolveConfig, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
8
+ import { homedir, tmpdir } from 'node:os'
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'
11
12
  import { mountRoutes } from './api.mjs'
@@ -48,7 +49,9 @@ function readSection(ctx, settings) {
48
49
  ctx.logger?.warn?.(`shell-select: settings 面无 "${SECTION_NS}" 节,设置页回落出厂默认`)
49
50
  return resolveConfig({})
50
51
  }
51
- return resolveConfig(found.value ?? {})
52
+ // 读出面同走 coercing:落盘坏值(悬空 default/空 shells)投影为可服务配置,
53
+ // 设置页显示与执行面一致,不再暴露不可服务的中间态
54
+ return resolveConfig(coerceServiceable(found.value ?? {}).config)
52
55
  }
53
56
 
54
57
  /**
@@ -82,6 +85,8 @@ export function buildFaces(ctx, settings) {
82
85
  },
83
86
  detect: (kinds) => detectCandidates(kinds ?? [...KINDS], process.env, candidateExists),
84
87
  probe: (candidatePath) => candidateExists(candidatePath),
88
+ // 宿主路径事实:客户端侧栏路径救援用(翻译 MSYS 拼写回宿主形式的事实来源)
89
+ pathMap: () => ({ platform: process.platform, home: homedir(), tmpDir: tmpdir() }),
85
90
  }
86
91
  }
87
92