@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/README.md +88 -0
- package/cordis.patch.yml +30 -0
- package/package.json +70 -0
- package/src/api.mjs +109 -0
- package/src/apply-state.mjs +21 -0
- package/src/client.js +757 -0
- package/src/config.mjs +118 -0
- package/src/denylist.mjs +36 -0
- package/src/executor.mjs +530 -0
- package/src/guard-config.mjs +64 -0
- package/src/guard-state.mjs +62 -0
- package/src/guard.js +256 -0
- package/src/render.mjs +59 -0
- package/src/resolve.mjs +128 -0
- package/src/sandbox-classify.mjs +82 -0
- package/src/tool.mjs +350 -0
- package/test/client-card.test.mjs +106 -0
- package/test/client-drift.test.mjs +27 -0
- package/test/client-id.test.mjs +68 -0
- package/test/config-entry.test.mjs +93 -0
- package/test/config.test.mjs +106 -0
- package/test/deny-config.test.mjs +33 -0
- package/test/deny-tool.test.mjs +26 -0
- package/test/denylist.test.mjs +39 -0
- package/test/env.test.mjs +81 -0
- package/test/executor-seam.test.mjs +136 -0
- package/test/executor.test.mjs +363 -0
- package/test/guard-entry.test.mjs +157 -0
- package/test/guard-retry.test.mjs +79 -0
- package/test/guard-state.test.mjs +111 -0
- package/test/render.test.mjs +73 -0
- package/test/resolve.test.mjs +131 -0
- package/test/sandbox-classify.test.mjs +67 -0
- package/test/switch-guard.test.mjs +37 -0
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
|
+
}
|
package/src/denylist.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/executor.mjs
ADDED
|
@@ -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
|