@mzzsfy/dsh-shell-select 0.1.0

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 ADDED
@@ -0,0 +1,118 @@
1
+ // 设置 schema 与 argv 组装(纯函数):条目结构、出厂默认、按 kind 的 argv 形。
2
+ // pwsh argv 与编码前缀官方 dsh-pwsh-local 同构;bash argv 官方 dsh-bash-local 同构;
3
+ // cmd /d /s /c 与 wsl --exec 为本包定义的保守形态。
4
+
5
+ import z from '@deepseek-ai/schemastery'
6
+
7
+ // 支持的 shell 形态
8
+ export const KINDS = ['pwsh', 'bash', 'cmd', 'wsl']
9
+
10
+ // path 字段的自动解析标记值:空串走候选探测
11
+ export const RESOLVED_AUTO = ''
12
+
13
+ // pwsh 每命令前置的 UTF-8 输出钉扎(官方同构):子进程按 UTF-8 解码,
14
+ // Windows PowerShell 5.1 兜底默认写 OEM 代码页,不加会乱码
15
+ export const PWSH_ENCODING_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
16
+
17
+ // 模板占位符:args 数组中该标记项替换为 command;无标记则 command 追加末项
18
+ const COMMAND_PLACEHOLDER = '{command}'
19
+
20
+ // 执行器预算字段(官方 dsh-pwsh-local Config 同构,默认值逐项对齐;
21
+ // cwd 同官方无 default,缺省态由 resolve 落 process.cwd())
22
+ const EXECUTOR_DEFAULTS = {
23
+ timeoutMs: 12e4,
24
+ maxTimeoutMs: 6e5,
25
+ maxOutputBytes: 64e3,
26
+ maxSpillBytes: 64 * 1024 * 1024,
27
+ graceMs: 3e3,
28
+ }
29
+
30
+ const shellEntry = z.object({
31
+ id: z.string().required(),
32
+ name: z.string().required(),
33
+ kind: z.union(KINDS).required(),
34
+ path: z.string().default(RESOLVED_AUTO),
35
+ args: z.array(z.string()).default([]),
36
+ login: z.boolean().default(false),
37
+ distro: z.string().default(''),
38
+ env: z.dict(z.string()).default({}),
39
+ })
40
+
41
+ export const Config = z.object({
42
+ shells: z.array(shellEntry).default([
43
+ { id: 'pwsh', name: 'PowerShell', kind: 'pwsh', path: RESOLVED_AUTO, args: [] },
44
+ { id: 'git-bash', name: 'Git Bash', kind: 'bash', path: RESOLVED_AUTO, args: [] },
45
+ { id: 'cmd', name: 'CMD', kind: 'cmd', path: RESOLVED_AUTO, args: [] },
46
+ ]),
47
+ default: z.string().default('pwsh'),
48
+ // 命令黑名单(整文本正则,大小写不敏感):命中即拒,deny 绝对无豁免;
49
+ // 精细放行用正则前瞻在模式内表达。空数组 = 不拦截。
50
+ // 护栏防误触,非安全边界(真边界是沙箱与访问模式)
51
+ deny: z.array(z.string()).default([]),
52
+ cwd: z.string(),
53
+ timeoutMs: z.number().default(EXECUTOR_DEFAULTS.timeoutMs),
54
+ maxTimeoutMs: z.number().default(EXECUTOR_DEFAULTS.maxTimeoutMs),
55
+ maxOutputBytes: z.number().default(EXECUTOR_DEFAULTS.maxOutputBytes),
56
+ maxSpillBytes: z.number().default(EXECUTOR_DEFAULTS.maxSpillBytes),
57
+ graceMs: z.number().default(EXECUTOR_DEFAULTS.graceMs),
58
+ })
59
+
60
+ /** 出厂默认配置(供文档与测试比对;schema 内 default 为同构静态值)。 */
61
+ export function defaultConfig() {
62
+ return Config({})
63
+ }
64
+
65
+ /**
66
+ * 按 id 取条目。
67
+ * @param {Array<{id: string}>} shells
68
+ * @param {string} id
69
+ */
70
+ export function entryById(shells, id) {
71
+ return shells.find((entry) => entry.id === id)
72
+ }
73
+
74
+ /**
75
+ * 取本次调用生效的条目:显式 id 优先,缺省落 default。
76
+ * @param {Array<{id: string}>} shells
77
+ * @param {string|undefined} requested 模型传的 shell 参数
78
+ * @param {string} fallbackId 配置的 default
79
+ * @returns {{id: string, name: string, kind: string, path: string, args: string[]}}
80
+ * @throws 未指定且 default 缺失/无效,或指定 id 不存在——文案带配置指引
81
+ */
82
+ export function requireEntry(shells, requested, fallbackId) {
83
+ const wanted = requested ?? fallbackId
84
+ const entry = entryById(shells, wanted)
85
+ if (entry !== undefined) return entry
86
+ if (requested === undefined) {
87
+ throw new Error(`no shell client to run: default "${fallbackId}" is not in the configured shells list; configure shells/default in the shell-select settings section or pass an explicit shell argument`)
88
+ }
89
+ throw new Error(`unknown shell client "${requested}"; available: ${shells.map((entry) => entry.id).join(', ') || '(none)'}; configure shells in the shell-select settings section`)
90
+ }
91
+
92
+ /**
93
+ * 按条目组装精确 argv。
94
+ * @param {{kind: string, path: string, args?: string[]}} entry 已解析 path 的条目
95
+ * @param {string} command
96
+ * @returns {string[]}
97
+ */
98
+ export function buildArgv(entry, command) {
99
+ if (entry.args !== undefined && entry.args.length > 0) {
100
+ const hasPlaceholder = entry.args.includes(COMMAND_PLACEHOLDER)
101
+ return [entry.path, ...entry.args.map((arg) => arg === COMMAND_PLACEHOLDER ? command : arg),
102
+ ...(hasPlaceholder ? [] : [command])]
103
+ }
104
+ switch (entry.kind) {
105
+ case 'pwsh': return [entry.path, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', PWSH_ENCODING_PREAMBLE + command]
106
+ // login:登录壳 source /etc/profile,只把 /usr/bin 注入 PATH;/mingw64/bin
107
+ // 需条目 env 配 MSYSTEM=MINGW64。默认保持 -c:官方 dsh-bash-local 同构,
108
+ // 且不读 profile,输出无用户脚本副作用
109
+ case 'bash': return [entry.path, ...(entry.login === true ? ['-lc'] : ['-c']), command]
110
+ case 'cmd': return [entry.path, '/d', '/s', '/c', command]
111
+ // distro:发行版选择仅默认形生效;args 模板条目全权接管 argv,模板分支优先
112
+ case 'wsl': {
113
+ const distroPrefix = entry.distro ? ['-d', entry.distro] : []
114
+ return [entry.path, ...distroPrefix, '--exec', 'bash', '-c', command]
115
+ }
116
+ default: throw new Error(`shell-select: unknown kind ${JSON.stringify(entry.kind)}`)
117
+ }
118
+ }
@@ -0,0 +1,36 @@
1
+ // 命令黑名单:deny 绝对——命中任一模式即拒,无豁免语义(精细放行在模式内
2
+ // 用正则前瞻表达,如 rm -rf\s+(?!\S*node_modules))。
3
+ // 护栏定位:启发式防误触,非安全边界(真边界是沙箱模式与访问模式)。
4
+
5
+ /** 拒绝错误:工具层 catch 转模型可见标记。 */
6
+ export class DenyError extends Error {
7
+ constructor(pattern, command) {
8
+ super(`command blocked by shell-select deny pattern ${JSON.stringify(pattern)}: ${command.slice(0, 120)}`)
9
+ this.name = 'DenyError'
10
+ this.code = 'SHELL_COMMAND_BLOCKED'
11
+ this.pattern = pattern
12
+ }
13
+ }
14
+
15
+ /** 编译单条正则:大小写不敏感;坏条目返回 undefined(容错,不瘫执行链)。 */
16
+ function compile(pattern) {
17
+ try {
18
+ return new RegExp(pattern, 'i')
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 黑名单匹配:命令命中任一 deny 条目即拒。
26
+ * @param {string} command 模型提交的整条命令文本
27
+ * @param {string[]|undefined} deny 拒绝正则列表
28
+ * @throws {DenyError} 命中 deny
29
+ */
30
+ export function matchDeny(command, deny) {
31
+ const text = String(command ?? '')
32
+ for (const pattern of deny ?? []) {
33
+ const re = compile(pattern)
34
+ if (re !== undefined && re.test(text)) throw new DenyError(pattern, text)
35
+ }
36
+ }
@@ -0,0 +1,530 @@
1
+ // shell-select 执行器:ctx.shell 提供者(官方 SandboxPwshExecutor 同构,进程机制
2
+ // 继承 dsh-pwsh-local 形态),并把模型可见 shell 工具、systemPrompt 段、
3
+ // shell-select 设置节与浏览器半区路由一并挂在本行 fiber 上。
4
+ //
5
+ // 与官方的两处结构差异(其余逐项同构):
6
+ // 1. argv 由「本次调用选中的 shell 条目」决定(pwsh/bash/cmd/wsl 四形 + args 模板),
7
+ // confine 包的正是该条目 argv;
8
+ // 2. 执行器预算与客户端清单同节('shell-select'),官方拆 'shell' 节 + 行内 Config。
9
+ //
10
+ // 兼容性:ShellExecutor 基类为官方文档级扩展缝(dsh-shell README 明示子类化);
11
+ // dsh-llm 经 tool.mjs 动态 import + 特性检测(HarnessError 缺失即降级),
12
+ // dsh-tools/dsh-sandbox 静态 import(官方组合必装,无降级面)。
13
+
14
+ import { ShellExecutor } from '@deepseek-ai/dsh-shell'
15
+ import { MAX_TIMER_DELAY_MS, clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
16
+ import { Config, KINDS, buildArgv, requireEntry } from './config.mjs'
17
+ import { matchDeny } from './denylist.mjs'
18
+ import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
19
+ import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from './sandbox-classify.mjs'
20
+ import { registerShellTool } from './tool.mjs'
21
+ import { mountRoutes } from './api.mjs'
22
+ import { beginShellSelectApply, endShellSelectApplyActive } from './apply-state.mjs'
23
+
24
+ export const name = 'shell-select'
25
+
26
+ // 单一事实源:loader 消费模块级 inject 导出,Service 类静态与它共用同一常量
27
+ export const SHELL_SELECT_INJECT = ['subprocess', 'sandbox', 'sandboxPolicy', 'settings', 'tools', 'systemPrompt', 'shellEnv']
28
+
29
+ export const inject = SHELL_SELECT_INJECT
30
+
31
+ export { Config }
32
+
33
+ // 面向模型的终端环境覆盖(官方 dsh-pwsh-local 同构):禁色禁 pager
34
+ export const ENV_OVERRIDES = {
35
+ NO_COLOR: '1',
36
+ PAGER: 'cat',
37
+ GIT_PAGER: 'cat',
38
+ }
39
+
40
+ // WSLENV 分隔符:WSL 白名单变量表,VAR[:VAR...]
41
+ const WSLENV_SEPARATOR = ':'
42
+
43
+ /**
44
+ * spawn env 构造纯函数:内置覆盖集 + 条目 env + 调用方 env 三层并集
45
+ * (ENV_OVERRIDES < entryEnv < callerEnv,同键高右优先);
46
+ * wsl 形把条目与调用方全部键(WSLENV 本身除外)追加进 WSLENV——WSL 只放行
47
+ * 白名单变量,不追加则配置静默失效。base 三级:调用方显式 > 条目显式 > 继承值;
48
+ * 追加在 base 之上(Windows Terminal 等已写入条目,重建=静默丢弃),
49
+ * 规范化去空段并去重;追加段无方向 flag,默认双向(Win32 回流可见同键)。
50
+ * @param {string} kind 条目形态
51
+ * @param {Record<string,string>|undefined} callerEnv 调用方环境(spec.env+dshEnv)
52
+ * @param {Record<string,string>|undefined} entryEnv 条目配置环境(shells[].env)
53
+ * @param {{inheritedWslenv?: string}} [io] 继承 WSLENV(注入以便测试)
54
+ */
55
+ export function buildClientEnv(kind, callerEnv, entryEnv, io = {}) {
56
+ const env = { ...ENV_OVERRIDES, ...entryEnv, ...callerEnv }
57
+ if (kind !== 'wsl') return env
58
+ const keys = [...Object.keys(entryEnv ?? {}), ...Object.keys(callerEnv ?? {})]
59
+ .filter((key) => key !== 'WSLENV')
60
+ .filter((key, index, all) => all.indexOf(key) === index)
61
+ const callerDeclared = Object.prototype.hasOwnProperty.call(callerEnv ?? {}, 'WSLENV')
62
+ const entryDeclared = Object.prototype.hasOwnProperty.call(entryEnv ?? {}, 'WSLENV')
63
+ const base = callerDeclared
64
+ ? env.WSLENV
65
+ : entryDeclared
66
+ ? entryEnv.WSLENV
67
+ : (typeof io.inheritedWslenv === 'string' && io.inheritedWslenv.length > 0 ? io.inheritedWslenv : env.WSLENV)
68
+ if (keys.length === 0) {
69
+ // 无追加键也透传 base:spawn 可能整包替换子环境,缺键=继承条目丢失
70
+ if (typeof base === 'string' && base.length > 0) env.WSLENV = base
71
+ return env
72
+ }
73
+ const parts = typeof base === 'string' ? base.split(WSLENV_SEPARATOR) : []
74
+ env.WSLENV = [...new Set([...parts, ...keys])]
75
+ .map((part) => part.trim())
76
+ .filter((part) => part.length > 0)
77
+ .join(WSLENV_SEPARATOR)
78
+ return env
79
+ }
80
+
81
+ function assertPositiveFinite(name, value) {
82
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`shell-select: ${name} must be a positive finite number`)
83
+ }
84
+
85
+ // yaml 无引号标量会把 `\` 字面落盘,任何一层再转义都让路径翻倍(C:\\ 实测):
86
+ // 入口统一归一,保证进 schema 的 path 就是干净值
87
+ function normalizeWin32Path(path) {
88
+ if (typeof path !== 'string' || path.length === 0) return path
89
+ return path.replace(/\\{2,}/g, '\\').replace(/\//g, '\\')
90
+ }
91
+
92
+ // 更新负载深归一:shells[].path 与任意层字符串值只处理 path 键,避免误伤 args 模板
93
+ function normalizeConfigPaths(patch) {
94
+ if (typeof patch !== 'object' || patch === null || !Array.isArray(patch.shells)) return patch
95
+ return {
96
+ ...patch,
97
+ shells: patch.shells.map((entry) => (typeof entry?.path === 'string' ? { ...entry, path: normalizeWin32Path(entry.path) } : entry)),
98
+ }
99
+ }
100
+
101
+ /** 拒绝无法运行的已解析配置节(schema 之外的正数/时限/清单约束,官方 assertServiceable 同构)。 */
102
+ export function assertServiceableConfig(config) {
103
+ assertPositiveFinite('timeoutMs', config.timeoutMs)
104
+ assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs)
105
+ assertPositiveFinite('maxOutputBytes', config.maxOutputBytes)
106
+ assertPositiveFinite('maxSpillBytes', config.maxSpillBytes)
107
+ assertPositiveFinite('graceMs', config.graceMs)
108
+ if (config.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`shell-select: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
109
+ if (!Array.isArray(config.shells) || config.shells.length === 0) throw new Error('shell-select: shells must not be empty')
110
+ const ids = new Set()
111
+ for (const entry of config.shells) {
112
+ if (ids.has(entry.id)) throw new Error(`shell-select: duplicate shell id "${entry.id}"`)
113
+ ids.add(entry.id)
114
+ }
115
+ if (!ids.has(config.default)) throw new Error(`shell-select: default "${config.default}" is not a configured shell id`)
116
+ }
117
+
118
+ /** 收集模式的 reader 投影成 CollectedOutput(官方同构)。 */
119
+ function finalOutput(reader) {
120
+ const read = reader.readFrom(0)
121
+ return {
122
+ text: read.text,
123
+ truncated: read.lossy,
124
+ ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
125
+ }
126
+ }
127
+
128
+ /** SANDBOX_UNAVAILABLE 降级构造器:官方类动态导入落地前的同码同名顶替(错误码通道不变)。 */
129
+ class FallbackSandboxUnavailableError extends Error {
130
+ constructor(mode, detail) {
131
+ super(`sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined.${detail === undefined ? '' : ` Runner failure: ${detail}`}`)
132
+ this.name = 'SandboxUnavailableError'
133
+ this.code = 'SANDBOX_UNAVAILABLE'
134
+ }
135
+ }
136
+
137
+ async function loadSandboxUnavailable(ctx) {
138
+ try {
139
+ const dshSandbox = await import('@deepseek-ai/dsh-sandbox')
140
+ if (typeof dshSandbox.SandboxUnavailableError === 'function') return dshSandbox.SandboxUnavailableError
141
+ } catch (error) {
142
+ ctx.logger?.warn?.(`shell-select: dsh-sandbox SandboxUnavailableError 不可用,降级为同码 Error: ${error?.message ?? error}`)
143
+ }
144
+ return FallbackSandboxUnavailableError
145
+ }
146
+
147
+ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecutor {
148
+ static inject = SHELL_SELECT_INJECT
149
+
150
+ static Config = Config
151
+
152
+ // 官方 PwshLocalExecutor 同构:实例状态一律公有字段。cordis 服务代理
153
+ // (getTraceable/createShadowMethod)会把经 ctx.shell 访问的方法 this 重定向到
154
+ // 阴影对象,#私有字段在阴影 receiver 下触发 V8 品牌检查错误(官方工具
155
+ // tool-pwsh 正是经 ctx.shell 调用,实测复现)。
156
+ /** 当前权威配置来源:设置节(接线后)或行内配置。 */
157
+ source
158
+ /** 工具重注册句柄(onChange 先卸后挂;仅构造期闭包触达,this 恒为裸实例)。 */
159
+ #toolRegistration = null
160
+ /** 托管进程的每进程 confinement 事实(官方同构)。 */
161
+ processFacts = new Map()
162
+ /** SANDBOX_UNAVAILABLE 构造器(动态导入就绪后由官方类顶替降级类)。 */
163
+ unavailableError = FallbackSandboxUnavailableError
164
+
165
+ constructor(ctx, config) {
166
+ super(ctx)
167
+ beginShellSelectApply()
168
+ void loadSandboxUnavailable(ctx).then((resolved) => {
169
+ this.unavailableError = resolved
170
+ })
171
+ // settings 为硬依赖(static inject 门控);面异常按降级处理,能力不损
172
+ const settingsOk = typeof ctx.settings?.installSection === 'function'
173
+ if (!settingsOk) ctx.logger?.warn?.('shell-select: 宿主 settings 服务缺 installSection,配置退化为行内 Config,无热更新')
174
+
175
+ const entry = config ?? {}
176
+ assertServiceableConfig(Config(entry))
177
+ this.source = () => Config(entry)
178
+ if (settingsOk) {
179
+ ctx.settings.installSection(ctx, 'shell-select', Config, entry, {
180
+ validate: assertServiceableConfig,
181
+ setSource: (current) => {
182
+ this.source = current
183
+ },
184
+ onChange: () => this.#onConfigChange(),
185
+ })
186
+ }
187
+ this.#registerTool()
188
+ this.#mountPromptSection()
189
+ mountRoutes(ctx, {
190
+ listShells: () => this.listShells(),
191
+ readConfig: () => this.source(),
192
+ updateConfig: (patch) => this.updateConfig(patch),
193
+ detect: (kinds) => this.detect(kinds),
194
+ probe: (candidatePath) => candidateExists(candidatePath),
195
+ })
196
+ endShellSelectApplyActive()
197
+ }
198
+
199
+ get config() {
200
+ return this.source()
201
+ }
202
+
203
+ // 命令黑名单检查(deny 绝对:命中即拒;沙箱拦截前的内容级护栏)。
204
+ // 公有方法:经 ctx.shell 代理调用的 runFor/startFor 内触达,this 可能是
205
+ // cordis 阴影对象,# 私有会触发 V8 品牌检查错误(与字段同坑)
206
+ assertNotDenied(command) {
207
+ const current = this.config
208
+ matchDeny(command, current.deny)
209
+ }
210
+
211
+ /** 能力事实:挂载沙箱执行器语义,工具层据它公示升权面(官方同构)。 */
212
+ get sandboxMode() {
213
+ return this.ctx.sandboxPolicy?.resolve().mode
214
+ }
215
+
216
+ /** 会话可见清单:条目 + 解析后的真实路径与可用性(设置页/工具描述共用)。 */
217
+ listShells() {
218
+ const current = this.config
219
+ return {
220
+ default: current.default,
221
+ shells: current.shells.map((entry) => {
222
+ const resolved = normalizeWin32Path(resolveEntryPath(entry, candidateExists))
223
+ return {
224
+ id: entry.id,
225
+ name: entry.name,
226
+ kind: entry.kind,
227
+ args: entry.args,
228
+ path: resolved,
229
+ available: resolved !== undefined,
230
+ login: entry.login === true,
231
+ distro: entry.distro ?? '',
232
+ env: entry.env ?? {},
233
+ }
234
+ }),
235
+ }
236
+ }
237
+
238
+ /** 批量探测 kind 候选(设置页「自动探测」)。 */
239
+ detect(kinds) {
240
+ return detectCandidates(kinds ?? [...KINDS], process.env, candidateExists)
241
+ }
242
+
243
+ /**
244
+ * 取本次调用生效的可用条目:按 id 解析真实可执行路径;显式路径同样验证存在,
245
+ * 坏路径在调用前报配置指引而非 spawn ENOENT。
246
+ * @param {string|undefined} requested 模型传的 shell 参数
247
+ * @throws 不可用(未知 id/默认缺失/可执行解析失败)——文案带配置指引
248
+ */
249
+ entryFor(requested) {
250
+ const current = this.config
251
+ const entry = requireEntry(current.shells, requested, current.default)
252
+ const resolved = normalizeWin32Path(resolveEntryPath(entry, candidateExists))
253
+ if (resolved === undefined || !candidateExists(resolved)) {
254
+ throw new Error(`shell client "${entry.id}" (${entry.kind}) has no executable on this machine: set an explicit path in the shell-select settings section or reinstall the client`)
255
+ }
256
+ return { id: entry.id, kind: entry.kind, path: resolved, args: entry.args, login: entry.login === true, distro: entry.distro ?? '', env: entry.env ?? {} }
257
+ }
258
+
259
+ /**
260
+ * 客户端半区配置更新入口:wholesale replace(settings merge 对数组是整值
261
+ * 覆盖,部分清单会静默丢条目;设置页语义是保存完整清单);等写队列落定再回读。
262
+ */
263
+ async updateConfig(patch) {
264
+ const current = this.config
265
+ const section = normalizeConfigPaths({
266
+ shells: patch.shells ?? current.shells,
267
+ default: patch.default ?? current.default,
268
+ deny: Array.isArray(patch.deny) ? patch.deny : current.deny,
269
+ })
270
+ assertServiceableConfig(Config(section))
271
+ await this.ctx.settings.replace('shell-select', section)
272
+ return this.listShells()
273
+ }
274
+
275
+ #onConfigChange() {
276
+ try {
277
+ assertServiceableConfig(this.config)
278
+ } catch (error) {
279
+ // validate hook 已在写入路径拦截;此处兜底防御,保旧配置
280
+ this.ctx.logger?.warn?.(`shell-select: 新配置不可用,保留先前配置: ${error?.message ?? error}`)
281
+ return
282
+ }
283
+ this.#registerTool()
284
+ }
285
+
286
+ /** 注册/重注册 shell 工具(描述随客户端清单变化)。 */
287
+ #registerTool() {
288
+ this.#toolRegistration?.()
289
+ this.#toolRegistration = registerShellTool(this.ctx, { executor: this })
290
+ }
291
+
292
+ #mountPromptSection() {
293
+ this.ctx.systemPrompt.section({
294
+ name: 'tool:shell',
295
+ order: this.ctx.systemPrompt.getSectionOrder('TOOL_PWSH'),
296
+ 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.',
297
+ })
298
+ }
299
+
300
+ /** 为本次调用补全策略:工具供给会话策略,直调落部署策略(官方同构)。 */
301
+ resolve(request) {
302
+ const current = this.config
303
+ const timeoutMs = clampTimeout(request.timeoutMs, current.timeoutMs, current.maxTimeoutMs, 'shell-select: request.timeoutMs')
304
+ const stdoutMaxBytes = request.stdoutMaxBytes ?? current.maxOutputBytes
305
+ assertPositiveFinite('request.stdoutMaxBytes', stdoutMaxBytes)
306
+ return {
307
+ command: request.command,
308
+ workdir: request.workdir ?? current.cwd ?? process.cwd(),
309
+ timeoutMs,
310
+ stdoutMaxBytes,
311
+ ...request.signal ? { signal: request.signal } : {},
312
+ ...request.stdin !== undefined ? { stdin: request.stdin } : {},
313
+ ...request.env !== undefined ? { env: request.env } : {},
314
+ ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
315
+ sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve(),
316
+ }
317
+ }
318
+
319
+ /** 本条目 + 已解析 spec 的精确 argv(confine 的输入)。 */
320
+ argvFor(entry, spec) {
321
+ return buildArgv(entry, spec.command)
322
+ }
323
+
324
+ /** 官方 run seam:已解析 spec 按默认客户端前台执行(dsh-pwsh-local run 同构)。 */
325
+ async run(spec) {
326
+ return this.runFor(this.entryFor(), spec)
327
+ }
328
+
329
+ /** 官方 start seam:已解析 spec 按默认客户端后台启动(dsh-pwsh-local start 同构)。 */
330
+ start(spec) {
331
+ return this.startFor(this.entryFor(), spec)
332
+ }
333
+
334
+ /** 组装一次 spawn 的完整规格(官方 spawnSpec 同构,argv/条目参数化)。 */
335
+ spawnSpec(entry, spec, argv, stdoutMaxBytes, signal) {
336
+ const current = this.config
337
+ const collect = (maxBytes) => ({
338
+ maxBytes,
339
+ spill: { maxBytes: current.maxSpillBytes },
340
+ })
341
+ return {
342
+ argv: [...argv],
343
+ cwd: spec.workdir,
344
+ stdio: {
345
+ stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore',
346
+ stdout: collect(stdoutMaxBytes),
347
+ stderr: collect(current.maxOutputBytes),
348
+ },
349
+ graceMs: current.graceMs,
350
+ signal,
351
+ env: buildClientEnv(entry.kind, {
352
+ ...spec.env,
353
+ ...spec.dshEnv,
354
+ }, entry.env, { inheritedWslenv: process.env.WSLENV }),
355
+ }
356
+ }
357
+
358
+ /** 收集模式的 reader 投影(官方静态 collected 同构)。 */
359
+ static collected(handle) {
360
+ const { stdout, stderr } = handle.collected
361
+ if (stdout === undefined || stderr === undefined) throw new Error('shell-select: subprocess implementation dropped a requested collect stream')
362
+ return { stdout, stderr }
363
+ }
364
+
365
+ /**
366
+ * 前台运行指定条目(工具直调入口)。
367
+ * @param {{id: string, kind: string, path: string, args: string[]}} entry entryFor 产物
368
+ * @param {object} spec resolve() 产物
369
+ */
370
+ async runFor(entry, spec) {
371
+ this.assertNotDenied(spec.command)
372
+ const policy = spec.sandboxPolicy
373
+ const { mode } = policy
374
+ if (mode === 'danger-full-access') {
375
+ const result = await this.runArgv(entry, spec)
376
+ return { ...result, sandbox: { mode, denied: false } }
377
+ }
378
+ const confined = this.ctx.sandbox.confine(this.argvFor(entry, spec), { ...policy, mode })
379
+ let result
380
+ try {
381
+ result = await this.runArgv(entry, spec, confined.argv)
382
+ } catch (error) {
383
+ if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
384
+ if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) throw new this.unavailableError(mode, String(error))
385
+ throw error
386
+ }
387
+ const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
388
+ if (runnerFailure !== undefined) throw new this.unavailableError(mode, runnerFailure.detail)
389
+ return {
390
+ ...result,
391
+ sandbox: {
392
+ mode,
393
+ denied: classifyDenial(result, confined.denialSignatures),
394
+ enforcement: confined.enforcement,
395
+ },
396
+ }
397
+ }
398
+
399
+ // 动态导入尚未落地时的同步兜底见 unavailableError 初始化(Fallback 类)
400
+
401
+ /** 前台运行精确 argv(官方 runArgv 同构,条目参数化)。 */
402
+ async runArgv(entry, spec, forcedArgv) {
403
+ const argv = forcedArgv ?? this.argvFor(entry, spec)
404
+ const d = deadline(spec.signal, spec.timeoutMs, 'SHELL_TIMEOUT')
405
+ try {
406
+ const handle = this.ctx.subprocess.spawn(this.spawnSpec(entry, spec, argv, spec.stdoutMaxBytes, d.signal))
407
+ const outcome = await handle.done
408
+ const collected = ShellSelectExecutor.collected(handle)
409
+ const timedOut = timeoutOf(d.signal, 'SHELL_TIMEOUT') !== undefined
410
+ const aborted = d.signal.aborted && !timedOut
411
+ return {
412
+ ...outcome,
413
+ timedOut,
414
+ aborted,
415
+ timeoutMs: spec.timeoutMs,
416
+ stdout: finalOutput(collected.stdout),
417
+ stderr: finalOutput(collected.stderr),
418
+ }
419
+ } finally {
420
+ d[Symbol.dispose]?.()
421
+ }
422
+ }
423
+
424
+ /**
425
+ * 后台启动指定条目(工具直调入口)。
426
+ * @returns 官方 ShellProcess 形态句柄
427
+ */
428
+ startFor(entry, spec) {
429
+ this.assertNotDenied(spec.command)
430
+ const policy = spec.sandboxPolicy
431
+ const { mode } = policy
432
+ if (mode === 'danger-full-access') return this.startArgv(entry, spec)
433
+ const confined = this.ctx.sandbox.confine(this.argvFor(entry, spec), { ...policy, mode })
434
+ let proc
435
+ try {
436
+ proc = this.startArgv(entry, spec, confined.argv)
437
+ } catch (error) {
438
+ if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) throw new this.unavailableError(mode, String(error))
439
+ throw error
440
+ }
441
+ const { enforcement, denialSignatures, runnerFailureRules } = confined
442
+ this.processFacts.set(proc, {
443
+ mode,
444
+ enforcement,
445
+ denialSignatures,
446
+ runnerFailureRules,
447
+ runnerProgram: confined.argv[0],
448
+ workdir: spec.workdir,
449
+ })
450
+ return proc
451
+ }
452
+
453
+ /** 后台启动精确 argv(官方 startArgv 同构)。 */
454
+ startArgv(entry, spec, forcedArgv) {
455
+ const current = this.config
456
+ const argv = forcedArgv ?? this.argvFor(entry, spec)
457
+ const running = this.ctx.subprocess.spawn(this.spawnSpec(entry, spec, argv, current.maxOutputBytes, spec.signal))
458
+ const collected = ShellSelectExecutor.collected(running)
459
+ let providerFailureNote
460
+ const consumeProviderFailure = () => {
461
+ const note = providerFailureNote ?? ''
462
+ providerFailureNote = undefined
463
+ return note
464
+ }
465
+ let stdoutOffset = 0
466
+ let stderrOffset = 0
467
+ const executor = this
468
+ const proc = {
469
+ status: 'running',
470
+ exitCode: null,
471
+ signal: null,
472
+ done: running.done.then((outcome) => {
473
+ if (proc.status === 'running') proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
474
+ proc.exitCode = outcome.exitCode
475
+ proc.signal = outcome.signal
476
+ executor.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
477
+ }, (error) => {
478
+ proc.status = 'killed'
479
+ let detail = 'unprintable provider failure'
480
+ try {
481
+ detail = String(error)
482
+ } catch {}
483
+ providerFailureNote = `subprocess failed before reporting an outcome: ${detail}`
484
+ executor.onProcessDone(proc, providerFailureNote, true, error)
485
+ }),
486
+ readOutput: () => {
487
+ const out = collected.stdout.readFrom(stdoutOffset)
488
+ const err = collected.stderr.readFrom(stderrOffset)
489
+ stdoutOffset = out.nextOffset
490
+ stderrOffset = err.nextOffset
491
+ const providerFailure = consumeProviderFailure()
492
+ const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : ''
493
+ const errText = err.text + (providerFailure.length > 0 ? `${failureSeparator}${providerFailure}` : '')
494
+ const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
495
+ return {
496
+ delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ''),
497
+ lossy: out.lossy || err.lossy,
498
+ ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
499
+ ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
500
+ }
501
+ },
502
+ kill: () => {
503
+ if (proc.status !== 'running') return false
504
+ proc.status = 'killed'
505
+ running.terminate()
506
+ return true
507
+ },
508
+ }
509
+ return proc
510
+ }
511
+
512
+ /** 定案时附加每进程沙箱事实(官方同构;信号死亡非拒绝)。 */
513
+ onProcessDone(proc, stderr, providerRejected, providerError) {
514
+ const facts = this.processFacts.get(proc)
515
+ if (facts !== undefined) {
516
+ this.processFacts.delete(proc)
517
+ const runnerFailed = providerRejected
518
+ ? isRunnerSpawnFailure(providerError, facts.runnerProgram, facts.workdir)
519
+ : classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
520
+ proc.sandbox = {
521
+ mode: facts.mode,
522
+ denied: !runnerFailed && classifyDenial({ exitCode: proc.exitCode, stderr: { text: stderr } }, facts.denialSignatures),
523
+ enforcement: facts.enforcement,
524
+ ...runnerFailed ? { runnerFailed } : {},
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ export default ShellSelectExecutor