@mzzsfy/dsh-shell-select 0.3.1 → 0.4.1
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 +25 -6
- package/cordis.patch.yml +181 -29
- package/package.json +19 -13
- package/src/apply-state.mjs +13 -0
- package/src/client.js +135 -67
- package/src/config.mjs +174 -14
- package/src/executor.mjs +211 -101
- package/src/guard-state.mjs +26 -0
- package/src/guard.js +14 -6
- package/src/index.js +163 -0
- package/src/render.mjs +5 -1
- package/src/takeover.mjs +167 -0
- package/src/tool.mjs +43 -20
- package/src/web-routes.mjs +93 -0
- package/test/client-card.test.mjs +171 -3
- package/test/client-render.test.mjs +169 -0
- package/test/config-entry.test.mjs +13 -8
- package/test/config.test.mjs +95 -4
- package/test/deny-config.test.mjs +8 -8
- package/test/executor-seam.test.mjs +3 -3
- package/test/executor.test.mjs +232 -121
- package/test/guard-state.test.mjs +46 -1
- package/test/preset-drift.test.mjs +49 -69
- package/test/render.test.mjs +39 -0
- package/test/takeover-shell.test.mjs +397 -0
- package/test/web-routes.test.mjs +267 -0
package/src/executor.mjs
CHANGED
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
// shell-select 执行器:ctx.shell 提供者(官方 SandboxPwshExecutor 同构,进程机制
|
|
2
|
-
// 继承 dsh-pwsh-local 形态),并把模型可见 shell
|
|
3
|
-
//
|
|
2
|
+
// 继承 dsh-pwsh-local 形态),并把模型可见 shell 工具与 systemPrompt 段挂在本行
|
|
3
|
+
// fiber 上;设置页数据通道在 web-routes 行(声明式 webServer 门控),配置事实源
|
|
4
|
+
// = 本行 Config(settings 面以行条目为存储,变更经 cordis 行重载活生效)。
|
|
4
5
|
//
|
|
5
6
|
// 与官方的两处结构差异(其余逐项同构):
|
|
6
7
|
// 1. argv 由「本次调用选中的 shell 条目」决定(pwsh/bash/cmd/wsl 四形 + args 模板),
|
|
7
8
|
// confine 包的正是该条目 argv;
|
|
8
|
-
// 2.
|
|
9
|
+
// 2. 执行器预算与客户端清单同行 Config,官方拆 'shell' 节 + 行内 Config。
|
|
9
10
|
//
|
|
10
11
|
// 兼容性:ShellExecutor 基类为官方文档级扩展缝(dsh-shell README 明示子类化);
|
|
11
12
|
// dsh-llm 经 tool.mjs 动态 import + 特性检测(HarnessError 缺失即降级),
|
|
12
13
|
// dsh-tools/dsh-sandbox 静态 import(官方组合必装,无降级面)。
|
|
13
14
|
|
|
14
15
|
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
|
|
15
|
-
import {
|
|
16
|
-
import { Config, KINDS, buildArgv, requireEntry } from './config.mjs'
|
|
16
|
+
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
|
17
|
+
import { Config, KINDS, buildArgv, requireEntry, resolveConfig, resolveConfigStrict, unwrapConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
|
|
17
18
|
import { matchDeny } from './denylist.mjs'
|
|
18
19
|
import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
|
|
19
20
|
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from './sandbox-classify.mjs'
|
|
20
21
|
import { registerShellTool } from './tool.mjs'
|
|
21
|
-
import { mountRoutes } from './api.mjs'
|
|
22
|
-
import { beginShellSelectApply, endShellSelectApplyActive } from './apply-state.mjs'
|
|
23
22
|
|
|
24
23
|
export const name = 'shell-select'
|
|
25
24
|
|
|
26
|
-
// 单一事实源:loader 消费模块级 inject 导出,Service
|
|
27
|
-
|
|
25
|
+
// 单一事实源:loader 消费模块级 inject 导出,Service 类静态与它共用同一常量。
|
|
26
|
+
// settings 不在列:配置事实源 = 行 Config(0.1.7 settings 面语义),页面策略经
|
|
27
|
+
// apply 内可选子级注入注册,业务插件无 settings 服务也可运行(官方 README 同构)。
|
|
28
|
+
export const SHELL_SELECT_INJECT = ['subprocess', 'sandbox', 'sandboxPolicy', 'tools', 'systemPrompt', 'shellEnv']
|
|
28
29
|
|
|
29
30
|
export const inject = SHELL_SELECT_INJECT
|
|
30
31
|
|
|
31
|
-
export { Config }
|
|
32
|
+
export { Config } from './config.mjs'
|
|
32
33
|
|
|
33
34
|
// 面向模型的终端环境覆盖(官方 dsh-pwsh-local 同构):禁色禁 pager
|
|
34
35
|
export const ENV_OVERRIDES = {
|
|
@@ -82,41 +83,6 @@ function assertPositiveFinite(name, value) {
|
|
|
82
83
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`shell-select: ${name} must be a positive finite number`)
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
// yaml 无引号标量会把 `\` 字面落盘,任何一层再转义都让路径翻倍(C:\\ 实测):
|
|
86
|
-
// 入口统一归一,保证进 schema 的 path 就是干净值。
|
|
87
|
-
// 仅 Windows 宿主生效:posix 路径以 / 分隔,归一会把合法路径毁成反斜杠字面(CI linux 实测)
|
|
88
|
-
function normalizeWin32Path(path) {
|
|
89
|
-
if (process.platform !== 'win32') return path
|
|
90
|
-
if (typeof path !== 'string' || path.length === 0) return path
|
|
91
|
-
return path.replace(/\\{2,}/g, '\\').replace(/\//g, '\\')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 更新负载深归一:shells[].path 与任意层字符串值只处理 path 键,避免误伤 args 模板
|
|
95
|
-
function normalizeConfigPaths(patch) {
|
|
96
|
-
if (typeof patch !== 'object' || patch === null || !Array.isArray(patch.shells)) return patch
|
|
97
|
-
return {
|
|
98
|
-
...patch,
|
|
99
|
-
shells: patch.shells.map((entry) => (typeof entry?.path === 'string' ? { ...entry, path: normalizeWin32Path(entry.path) } : entry)),
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** 拒绝无法运行的已解析配置节(schema 之外的正数/时限/清单约束,官方 assertServiceable 同构)。 */
|
|
104
|
-
export function assertServiceableConfig(config) {
|
|
105
|
-
assertPositiveFinite('timeoutMs', config.timeoutMs)
|
|
106
|
-
assertPositiveFinite('maxTimeoutMs', config.maxTimeoutMs)
|
|
107
|
-
assertPositiveFinite('maxOutputBytes', config.maxOutputBytes)
|
|
108
|
-
assertPositiveFinite('maxSpillBytes', config.maxSpillBytes)
|
|
109
|
-
assertPositiveFinite('graceMs', config.graceMs)
|
|
110
|
-
if (config.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`shell-select: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
|
|
111
|
-
if (!Array.isArray(config.shells) || config.shells.length === 0) throw new Error('shell-select: shells must not be empty')
|
|
112
|
-
const ids = new Set()
|
|
113
|
-
for (const entry of config.shells) {
|
|
114
|
-
if (ids.has(entry.id)) throw new Error(`shell-select: duplicate shell id "${entry.id}"`)
|
|
115
|
-
ids.add(entry.id)
|
|
116
|
-
}
|
|
117
|
-
if (!ids.has(config.default)) throw new Error(`shell-select: default "${config.default}" is not a configured shell id`)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
86
|
/** 收集模式的 reader 投影成 CollectedOutput(官方同构)。 */
|
|
121
87
|
function finalOutput(reader) {
|
|
122
88
|
const read = reader.readFrom(0)
|
|
@@ -155,10 +121,12 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
155
121
|
// (getTraceable/createShadowMethod)会把经 ctx.shell 访问的方法 this 重定向到
|
|
156
122
|
// 阴影对象,#私有字段在阴影 receiver 下触发 V8 品牌检查错误(官方工具
|
|
157
123
|
// tool-pwsh 正是经 ctx.shell 调用,实测复现)。
|
|
158
|
-
/**
|
|
124
|
+
/** 当前权威配置:行 Config(0.1.7 settings 面以行为存储,变更即行重载重建本实例)。 */
|
|
159
125
|
source
|
|
160
|
-
/** 工具重注册句柄(
|
|
126
|
+
/** 工具重注册句柄(仅构造期闭包触达,this 恒为裸实例)。 */
|
|
161
127
|
#toolRegistration = null
|
|
128
|
+
/** 配置快照(挂载时解包产物;volatile 变更经 refresh 换新)。 */
|
|
129
|
+
#entry = {}
|
|
162
130
|
/** 托管进程的每进程 confinement 事实(官方同构)。 */
|
|
163
131
|
processFacts = new Map()
|
|
164
132
|
/** SANDBOX_UNAVAILABLE 构造器(动态导入就绪后由官方类顶替降级类)。 */
|
|
@@ -166,36 +134,37 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
166
134
|
|
|
167
135
|
constructor(ctx, config) {
|
|
168
136
|
super(ctx)
|
|
169
|
-
beginShellSelectApply()
|
|
170
137
|
void loadSandboxUnavailable(ctx).then((resolved) => {
|
|
171
138
|
this.unavailableError = resolved
|
|
172
139
|
})
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
setSource: (current) => {
|
|
184
|
-
this.source = current
|
|
185
|
-
},
|
|
186
|
-
onChange: () => this.#onConfigChange(),
|
|
187
|
-
})
|
|
188
|
-
}
|
|
140
|
+
this.#setEntry(config)
|
|
141
|
+
this.source = () => resolveConfig(this.#entry)
|
|
142
|
+
// refresh 桥绑定随本 fiber 卸载自动解绑(实例退场后写路径请求静默丢弃)
|
|
143
|
+
ctx.effect(() => bindShellRefreshBridge((next) => this.refresh(next)))
|
|
144
|
+
// 自带设置页(客户端半区自定义卡):抑制宿主按 schema 自动生成的原生页
|
|
145
|
+
// (官方 README 同构:可选子级注入声明策略归属 fiber,服务迟加载也采纳)
|
|
146
|
+
ctx.inject?.(['settings'], (child) => {
|
|
147
|
+
if (typeof child.settings?.configure !== 'function') return
|
|
148
|
+
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
|
|
149
|
+
})
|
|
189
150
|
this.#registerTool()
|
|
190
151
|
this.#mountPromptSection()
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 挂载/换源共用落定:入参经宿主按 static Config resolve,volatile 字段是
|
|
155
|
+
* boxed ref,存储前解箱成普通对象;落定校验走 strict——解箱后仍存在非法
|
|
156
|
+
* 形态字段即抛(宿主契约变化炸在挂载点,接管序列回滚告警),禁止静默回落
|
|
157
|
+
* 出厂默认(行 config 永不生效缺陷的机械化防线)。 */
|
|
158
|
+
#setEntry(config) {
|
|
159
|
+
this.#entry = unwrapConfig(config ?? {})
|
|
160
|
+
assertServiceableConfig(resolveConfigStrict(this.#entry))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** volatile-only 配置变更的活刷新:换配置源并重建工具注册(写路径经
|
|
164
|
+
* requestShellRefresh 触达;行不重挂,本实例与进行中执行不受扰)。 */
|
|
165
|
+
refresh(config) {
|
|
166
|
+
this.#setEntry(config)
|
|
167
|
+
this.#registerTool()
|
|
199
168
|
}
|
|
200
169
|
|
|
201
170
|
get config() {
|
|
@@ -224,7 +193,6 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
224
193
|
const resolved = normalizeWin32Path(resolveEntryPath(entry, candidateExists))
|
|
225
194
|
return {
|
|
226
195
|
id: entry.id,
|
|
227
|
-
name: entry.name,
|
|
228
196
|
kind: entry.kind,
|
|
229
197
|
args: entry.args,
|
|
230
198
|
path: resolved,
|
|
@@ -258,33 +226,6 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
258
226
|
return { id: entry.id, kind: entry.kind, path: resolved, args: entry.args, login: entry.login === true, distro: entry.distro ?? '', env: entry.env ?? {} }
|
|
259
227
|
}
|
|
260
228
|
|
|
261
|
-
/**
|
|
262
|
-
* 客户端半区配置更新入口:wholesale replace(settings merge 对数组是整值
|
|
263
|
-
* 覆盖,部分清单会静默丢条目;设置页语义是保存完整清单);等写队列落定再回读。
|
|
264
|
-
*/
|
|
265
|
-
async updateConfig(patch) {
|
|
266
|
-
const current = this.config
|
|
267
|
-
const section = normalizeConfigPaths({
|
|
268
|
-
shells: patch.shells ?? current.shells,
|
|
269
|
-
default: patch.default ?? current.default,
|
|
270
|
-
deny: Array.isArray(patch.deny) ? patch.deny : current.deny,
|
|
271
|
-
})
|
|
272
|
-
assertServiceableConfig(Config(section))
|
|
273
|
-
await this.ctx.settings.replace('shell-select', section)
|
|
274
|
-
return this.listShells()
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
#onConfigChange() {
|
|
278
|
-
try {
|
|
279
|
-
assertServiceableConfig(this.config)
|
|
280
|
-
} catch (error) {
|
|
281
|
-
// validate hook 已在写入路径拦截;此处兜底防御,保旧配置
|
|
282
|
-
this.ctx.logger?.warn?.(`shell-select: 新配置不可用,保留先前配置: ${error?.message ?? error}`)
|
|
283
|
-
return
|
|
284
|
-
}
|
|
285
|
-
this.#registerTool()
|
|
286
|
-
}
|
|
287
|
-
|
|
288
229
|
/** 注册/重注册 shell 工具(描述随客户端清单变化)。 */
|
|
289
230
|
#registerTool() {
|
|
290
231
|
this.#toolRegistration?.()
|
|
@@ -323,11 +264,162 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
323
264
|
return buildArgv(entry, spec.command)
|
|
324
265
|
}
|
|
325
266
|
|
|
326
|
-
/** 官方
|
|
267
|
+
/** 官方 execute seam:已解析 spec 按默认客户端前台执行(dsh-pwsh-local run 同构)。 */
|
|
327
268
|
async run(spec) {
|
|
328
269
|
return this.runFor(this.entryFor(), spec)
|
|
329
270
|
}
|
|
330
271
|
|
|
272
|
+
/**
|
|
273
|
+
* 官方 ctx.shell 唯一执行契约(ShellExecutor 抽象方法,官方 tool-pwsh 与
|
|
274
|
+
* 进程内消费方共同依赖):解析 spec 后按默认客户端 spawn,返回 ShellExecution
|
|
275
|
+
* 句柄(done/readOutput/kill/observed/result)。前台与否是调用方 await 什么的
|
|
276
|
+
* 属性,不是 spawn 的属性。沙箱语义与 runFor 同源:danger 直跑,受限模式
|
|
277
|
+
* confine 包装,定案按 denial/runner-failure 规则附 sandbox 事实。
|
|
278
|
+
* @param {object} spec resolve() 产物(never 原始 request)
|
|
279
|
+
* @returns {Promise<object>} ShellExecution 句柄
|
|
280
|
+
*/
|
|
281
|
+
async execute(spec) {
|
|
282
|
+
this.assertNotDenied(spec.command)
|
|
283
|
+
const entry = this.entryFor()
|
|
284
|
+
const policy = spec.sandboxPolicy
|
|
285
|
+
const { mode } = policy
|
|
286
|
+
if (mode === 'danger-full-access') {
|
|
287
|
+
return this.executionArgv(entry, spec)
|
|
288
|
+
}
|
|
289
|
+
const confined = this.ctx.sandbox.confine(this.argvFor(entry, spec), { ...policy, mode })
|
|
290
|
+
return this.executionArgv(entry, spec, confined.argv, {
|
|
291
|
+
mode,
|
|
292
|
+
enforcement: confined.enforcement,
|
|
293
|
+
denialSignatures: confined.denialSignatures,
|
|
294
|
+
runnerFailureRules: confined.runnerFailureRules,
|
|
295
|
+
runnerProgram: confined.argv[0],
|
|
296
|
+
workdir: spec.workdir,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 官方 executeArgv 同构(条目参数化):deadline 按 spec.onExpiry 武装
|
|
302
|
+
* ('kill' 到期杀,'none'/缺省只跟随调用方信号);done 永不 reject,provider
|
|
303
|
+
* spawn 失败定局 killed + stderr 注记,result() 携带同一 rejection;受限模式
|
|
304
|
+
* 定案事实经 processFacts/onProcessDone 单通道写 proc.sandbox(官方
|
|
305
|
+
* pwsh-sandbox 子类同构),unsandboxed 的 result() 无 sandbox 键。
|
|
306
|
+
*/
|
|
307
|
+
executionArgv(entry, spec, forcedArgv, sandboxFacts) {
|
|
308
|
+
const argv = forcedArgv ?? this.argvFor(entry, spec)
|
|
309
|
+
const armDeadline = spec.onExpiry === 'kill'
|
|
310
|
+
const d = armDeadline ? deadline(spec.signal, spec.timeoutMs, 'SHELL_TIMEOUT') : undefined
|
|
311
|
+
const spawnSignal = d ? d.signal : spec.signal
|
|
312
|
+
const classifyState = () => {
|
|
313
|
+
if (d === undefined) return { timedOut: false, aborted: spec.signal?.aborted === true }
|
|
314
|
+
const timedOut = timeoutOf(d.signal, 'SHELL_TIMEOUT') !== undefined
|
|
315
|
+
return { timedOut, aborted: d.signal.aborted === true && !timedOut }
|
|
316
|
+
}
|
|
317
|
+
let running
|
|
318
|
+
let syncSpawnError
|
|
319
|
+
const prepareAborted = spawnSignal?.aborted === true
|
|
320
|
+
if (!prepareAborted) {
|
|
321
|
+
try {
|
|
322
|
+
running = this.ctx.subprocess.spawn(this.spawnSpec(entry, spec, argv, spec.stdoutMaxBytes, spawnSignal))
|
|
323
|
+
} catch (error) {
|
|
324
|
+
syncSpawnError = { error }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const emptyReader = { readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }) }
|
|
328
|
+
const collected = running !== undefined ? ShellSelectExecutor.collected(running) : { stdout: emptyReader, stderr: emptyReader }
|
|
329
|
+
const spawnThrow = () => syncSpawnError.error
|
|
330
|
+
const spawned = prepareAborted
|
|
331
|
+
? Promise.resolve({ exitCode: null, signal: null })
|
|
332
|
+
: running !== undefined ? running.done : Promise.reject(spawnThrow())
|
|
333
|
+
let providerFailure
|
|
334
|
+
const consumeProviderFailure = () => {
|
|
335
|
+
if (providerFailure === undefined || providerFailure.reported) return ''
|
|
336
|
+
providerFailure.reported = true
|
|
337
|
+
return providerFailure.note
|
|
338
|
+
}
|
|
339
|
+
let stdoutOffset = 0
|
|
340
|
+
let stderrOffset = 0
|
|
341
|
+
const readCollected = (offsets) => ({
|
|
342
|
+
out: collected.stdout.readFrom(offsets.stdout),
|
|
343
|
+
err: collected.stderr.readFrom(offsets.stderr),
|
|
344
|
+
})
|
|
345
|
+
const executor = this
|
|
346
|
+
let resultPromise
|
|
347
|
+
const proc = {
|
|
348
|
+
status: 'running',
|
|
349
|
+
exitCode: null,
|
|
350
|
+
signal: null,
|
|
351
|
+
observed: {
|
|
352
|
+
stdout: collected.stdout,
|
|
353
|
+
stderr: {
|
|
354
|
+
readFrom: (fromByte) => {
|
|
355
|
+
if (providerFailure === undefined) return collected.stderr.readFrom(fromByte)
|
|
356
|
+
const note = Buffer.from(providerFailure.note, 'utf8')
|
|
357
|
+
return { text: note.subarray(Math.min(fromByte, note.length)).toString('utf8'), nextOffset: note.length, lossy: false }
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
done: spawned.then((outcome) => {
|
|
362
|
+
if (proc.status === 'running') proc.status = spawnSignal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
|
|
363
|
+
proc.exitCode = outcome.exitCode
|
|
364
|
+
proc.signal = outcome.signal
|
|
365
|
+
if (sandboxFacts !== undefined) executor.processFacts.set(proc, sandboxFacts)
|
|
366
|
+
executor.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
|
|
367
|
+
d?.[Symbol.dispose]?.()
|
|
368
|
+
}, (error) => {
|
|
369
|
+
proc.status = 'killed'
|
|
370
|
+
let detail = 'unprintable provider failure'
|
|
371
|
+
try {
|
|
372
|
+
detail = String(error)
|
|
373
|
+
} catch {}
|
|
374
|
+
const isRunnerFailure = sandboxFacts !== undefined && isRunnerSpawnFailure(error, sandboxFacts.runnerProgram, sandboxFacts.workdir)
|
|
375
|
+
if (isRunnerFailure) {
|
|
376
|
+
providerFailure = { error: new executor.unavailableError(sandboxFacts.mode, String(error)), note: String(error), reported: false }
|
|
377
|
+
} else {
|
|
378
|
+
providerFailure = { error, note: `subprocess failed before reporting an outcome: ${detail}`, reported: false }
|
|
379
|
+
}
|
|
380
|
+
executor.onProcessDone(proc, providerFailure.note, true, error)
|
|
381
|
+
d?.[Symbol.dispose]?.()
|
|
382
|
+
}),
|
|
383
|
+
readOutput: () => {
|
|
384
|
+
const { out, err } = readCollected({ stdout: stdoutOffset, stderr: stderrOffset })
|
|
385
|
+
stdoutOffset = out.nextOffset
|
|
386
|
+
stderrOffset = err.nextOffset
|
|
387
|
+
const providerNote = consumeProviderFailure()
|
|
388
|
+
const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : ''
|
|
389
|
+
const errText = err.text + (providerNote.length > 0 ? `${failureSeparator}${providerNote}` : '')
|
|
390
|
+
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
|
391
|
+
return {
|
|
392
|
+
delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ''),
|
|
393
|
+
lossy: out.lossy || err.lossy,
|
|
394
|
+
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
|
395
|
+
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
kill: () => {
|
|
399
|
+
if (proc.status !== 'running') return false
|
|
400
|
+
proc.status = 'killed'
|
|
401
|
+
running?.terminate()
|
|
402
|
+
return true
|
|
403
|
+
},
|
|
404
|
+
result: () => {
|
|
405
|
+
resultPromise ??= proc.done.then(() => {
|
|
406
|
+
if (providerFailure !== undefined) throw providerFailure.error
|
|
407
|
+
return {
|
|
408
|
+
exitCode: proc.exitCode,
|
|
409
|
+
signal: proc.signal,
|
|
410
|
+
...classifyState(),
|
|
411
|
+
timeoutMs: spec.timeoutMs,
|
|
412
|
+
stdout: finalOutput(collected.stdout),
|
|
413
|
+
stderr: finalOutput(collected.stderr),
|
|
414
|
+
...proc.sandbox !== undefined ? { sandbox: proc.sandbox } : {},
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
return resultPromise
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
return Promise.resolve(proc)
|
|
421
|
+
}
|
|
422
|
+
|
|
331
423
|
/** 官方 start seam:已解析 spec 按默认客户端后台启动(dsh-pwsh-local start 同构)。 */
|
|
332
424
|
start(spec) {
|
|
333
425
|
return this.startFor(this.entryFor(), spec)
|
|
@@ -529,4 +621,22 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
529
621
|
}
|
|
530
622
|
}
|
|
531
623
|
|
|
624
|
+
// volatile 变更的 refresh 桥(模块级单例,apply-state 同款先例):执行器
|
|
625
|
+
// provide 的 ctx.shell 作用域在主行子树,web-routes 行是兄弟行,cordis 服务
|
|
626
|
+
// 沿 ctx 树向上查找不横向,ctx.shell getter 直接 throw——写路径经此桥触达
|
|
627
|
+
// 活跃实例。挂载绑定、实例卸载/顶替时解绑,未挂载时请求静默丢弃(执行面
|
|
628
|
+
// 由下次挂载的行配置重建)。
|
|
629
|
+
let refreshBridge = null
|
|
630
|
+
|
|
631
|
+
export function bindShellRefreshBridge(handler) {
|
|
632
|
+
refreshBridge = handler
|
|
633
|
+
return () => {
|
|
634
|
+
if (refreshBridge === handler) refreshBridge = null
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export function requestShellRefresh(snapshot) {
|
|
639
|
+
refreshBridge?.(snapshot)
|
|
640
|
+
}
|
|
641
|
+
|
|
532
642
|
export default ShellSelectExecutor
|
package/src/guard-state.mjs
CHANGED
|
@@ -60,3 +60,29 @@ export function detectDeadState(loader, { applyState }) {
|
|
|
60
60
|
return !state.present || (state.disabled && !state.running)
|
|
61
61
|
})
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 全树扫描:枚举 loader 树内全部 entry 中 id/name 命中 pwsh 关键字的行,
|
|
66
|
+
* 报告前缀 id/声明禁用/求值禁用/fiber 存活(多实例二相性诊断用)。
|
|
67
|
+
* @param {object} loader 宿主 loader 服务
|
|
68
|
+
*/
|
|
69
|
+
export function rowScan(loader) {
|
|
70
|
+
if (loader?.root === undefined) return []
|
|
71
|
+
const hits = []
|
|
72
|
+
const walk = (tree, prefix) => {
|
|
73
|
+
for (const [id, entry] of Object.entries(tree.store ?? {})) {
|
|
74
|
+
const fullId = prefix + id
|
|
75
|
+
if (/pwsh/i.test(fullId) || /pwsh/i.test(String(entry.options?.name ?? ''))) {
|
|
76
|
+
hits.push({
|
|
77
|
+
id: fullId,
|
|
78
|
+
rawType: typeof entry.options?.disabled,
|
|
79
|
+
evalDisabled: (() => { try { return entry.disabled === true } catch { return 'throw' } })(),
|
|
80
|
+
running: entry.fiber?.uid != null,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
if (entry.subtree) walk(entry.subtree, `${fullId}:`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
walk(loader.root.tree ?? loader.root, '')
|
|
87
|
+
return hits
|
|
88
|
+
}
|
package/src/guard.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// shell-select guard 哨兵行:死态自愈,守服务不抢权(逐项同构 dsh-llm-pi-gateway
|
|
2
2
|
// guard,差异仅代挂对象为官方 tool-pwsh + pwsh-sandbox 两行,且无 compat 键处理)。
|
|
3
3
|
// 死态 = 主行功能性停摆(市场开关即时禁用 / apply 旗标 inactive)且官方两行仍被
|
|
4
|
-
//
|
|
5
|
-
// fiber 卸载;主行或官方行任一复活先卸代挂。guard 行 id 含
|
|
6
|
-
//
|
|
4
|
+
// 主行 runtime-disable 禁用停稳(内存残留)——无任何 shell 执行器与工具。代挂走
|
|
5
|
+
// ctx.plugin,随 guard fiber 卸载;主行或官方行任一复活先卸代挂。guard 行 id 含
|
|
6
|
+
// "/",市场行写入对其拒绝,窗口内始终存活。运行时接管后官方行默认启用,主行
|
|
7
|
+
// 包级损伤形态下官方自服务,guard 空闲;死窗仅剩「接管中主行被禁」。
|
|
7
8
|
|
|
8
|
-
import { MAIN_ROW_ID, OFFICIAL_ROW_IDS, detectDeadState, rowState } from './guard-state.mjs'
|
|
9
|
+
import { MAIN_ROW_ID, OFFICIAL_ROW_IDS, detectDeadState, rowState, rowScan } from './guard-state.mjs'
|
|
9
10
|
import { officialRowConfig, readSettingsDocument, resolveFromHostTree } from './guard-config.mjs'
|
|
10
|
-
import {
|
|
11
|
+
import { officialRowState } from './takeover.mjs'
|
|
12
|
+
import { shellSelectApplyState, shellSelectTakeoverTrace } from './apply-state.mjs'
|
|
11
13
|
|
|
12
14
|
// 官方包 npm 名(与行 id 独立:行 id 是 dsh-base 组合行,包名才是模块解析键)
|
|
13
15
|
const OFFICIAL_PACKAGES = {
|
|
@@ -211,15 +213,21 @@ export async function installGuard(ctx, {
|
|
|
211
213
|
}
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
//
|
|
216
|
+
// 可观测面:死态判定中间量、代挂状态与主行接管序列只读暴露(排障与巡检共用)
|
|
215
217
|
const webServer = ctx.get?.('webServer')
|
|
216
218
|
if (webServer !== undefined && typeof webServer.register === 'function') {
|
|
217
219
|
const status = () => ({
|
|
218
220
|
dead: detectDeadState(ctx.loader, { applyState: shellSelectApplyState }),
|
|
219
221
|
applyState: shellSelectApplyState(),
|
|
222
|
+
takeover: shellSelectTakeoverTrace(),
|
|
220
223
|
mounted: [...mounted.keys()],
|
|
221
224
|
mounting: mounting !== null,
|
|
222
225
|
rows: [MAIN_ROW_ID, ...OFFICIAL_ROW_IDS].map((id) => ({ id, ...rowState(ctx.loader, id) })),
|
|
226
|
+
officialRaw: Object.fromEntries(OFFICIAL_ROW_IDS.map((id) => {
|
|
227
|
+
const s = officialRowState(ctx.loader, id)
|
|
228
|
+
return [id, { rawType: typeof s.rawDisabled, disabled: s.disabled, running: s.running }]
|
|
229
|
+
})),
|
|
230
|
+
rowScan: rowScan(ctx.loader),
|
|
223
231
|
})
|
|
224
232
|
ctx.effect(() => webServer.register({
|
|
225
233
|
kind: 'exact',
|
package/src/index.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// shell-select 主行入口(apply 型):官方 pwsh 链的运行时接管编排。
|
|
2
|
+
//
|
|
3
|
+
// 为什么是 apply 型而不是 Service 类:本包执行器与官方 pwsh-sandbox 抢同一个
|
|
4
|
+
// cordis 服务(ctx.shell,cordis 重复 provide 抛错)。Service 类在构造期同步
|
|
5
|
+
// provide,无法等待官方退场;apply 型行把「占服务」的动作变成可编排的挂载
|
|
6
|
+
// 时序——apply 落定后禁官方行 → 等退场 → ctx.plugin 挂执行器,与 guard 的
|
|
7
|
+
// mountOfficial(主行死时挂官方)互为镜像。
|
|
8
|
+
//
|
|
9
|
+
// 流程:POSIX 早退(官方 bash 链不碰,Linux 行为与无本包一致)→ 决策 →
|
|
10
|
+
// yield(宿主状态异常,零动作)/ takeover(官方已禁,直挂)/
|
|
11
|
+
// await-exit(等官方退场再挂)/ runtime-disable(官方启用中,apply 落定后
|
|
12
|
+
// 运行时禁用再接管)。接管序列任何失败都回滚或降级:官方行恢复启用或保持
|
|
13
|
+
// 在场服务,系统不出现 shell 真空。序列全程经 recordTakeover 留可观测面
|
|
14
|
+
// (guard-status 暴露,0.1.7 运行期日志不落盘,排障以该探口为准)。
|
|
15
|
+
//
|
|
16
|
+
// 配置事实源 = 行 Config(Config 声明随主行模块导出供宿主 settings 面注册节,
|
|
17
|
+
// apply 经 ctx.plugin 第二参传给执行器;设置页写经 settings 面行条目重载本行,
|
|
18
|
+
// apply 重跑换挂执行器)。
|
|
19
|
+
|
|
20
|
+
import { ShellSelectExecutor } from './executor.mjs'
|
|
21
|
+
import {
|
|
22
|
+
beginShellSelectApply,
|
|
23
|
+
endShellSelectApplyActive,
|
|
24
|
+
endShellSelectApplyInactive,
|
|
25
|
+
recordTakeover,
|
|
26
|
+
} from './apply-state.mjs'
|
|
27
|
+
import {
|
|
28
|
+
awaitOfficialExit,
|
|
29
|
+
officialChainState,
|
|
30
|
+
rollbackDisabledRows,
|
|
31
|
+
runtimeDisableRows,
|
|
32
|
+
takeoverDecision,
|
|
33
|
+
} from './takeover.mjs'
|
|
34
|
+
import { coerceServiceable } from './config.mjs'
|
|
35
|
+
|
|
36
|
+
export const name = 'shell-select'
|
|
37
|
+
|
|
38
|
+
// settings 节注册面:宿主按行模块的 Config 导出注册 shell-select 节(与执行器
|
|
39
|
+
// 同一 schema 单一事实源)
|
|
40
|
+
export { Config } from './config.mjs'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 主行 apply。
|
|
44
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
45
|
+
* @param {object|undefined} config 行 Config(shell 客户端清单等)
|
|
46
|
+
*/
|
|
47
|
+
export async function apply(ctx, config) {
|
|
48
|
+
beginShellSelectApply()
|
|
49
|
+
// 平台早退:行级平台门控在本前已停用本行,此为纵深防御——行被显式启用到
|
|
50
|
+
// POSIX 时本行自身不得碰官方 bash/pwsh 链
|
|
51
|
+
if (process.platform !== 'win32') {
|
|
52
|
+
endShellSelectApplyInactive()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const states = officialChainState(ctx.loader)
|
|
56
|
+
const { decision, targets } = takeoverDecision(states)
|
|
57
|
+
recordTakeover({
|
|
58
|
+
event: `decision=${decision} targets=${targets.length}`,
|
|
59
|
+
rows: Object.fromEntries(Object.entries(states).map(([id, row]) => [id, {
|
|
60
|
+
present: row.present,
|
|
61
|
+
rawType: typeof row.rawDisabled,
|
|
62
|
+
unreliable: row.disabledUnreliable,
|
|
63
|
+
running: row.running,
|
|
64
|
+
}])),
|
|
65
|
+
})
|
|
66
|
+
ctx.logger.info?.(`shell-select: 官方 pwsh 链接管决策 ${decision}(targets=${targets.length})`)
|
|
67
|
+
if (decision === 'yield') {
|
|
68
|
+
endShellSelectApplyActive()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (decision === 'runtime-disable') {
|
|
72
|
+
endShellSelectApplyActive()
|
|
73
|
+
void runtimeTakeover(ctx, config, targets).catch((error) => {
|
|
74
|
+
recordTakeover({ error: String(error?.message ?? error) })
|
|
75
|
+
})
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
// takeover / await-exit:官方行已禁(或缺席),直接接管
|
|
79
|
+
if (decision === 'await-exit') {
|
|
80
|
+
const entries = Object.values(states).filter((row) => row.present).map((row) => row.entry)
|
|
81
|
+
const exited = await awaitOfficialExit(entries)
|
|
82
|
+
if (!exited) {
|
|
83
|
+
ctx.logger.warn('shell-select: 官方行退场超时,放弃接管(官方仍在服务);重启后由行树声明态收敛')
|
|
84
|
+
recordTakeover({ event: 'await-exit 退场超时,放弃挂载' })
|
|
85
|
+
endShellSelectApplyActive()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
await mountExecutor(ctx, config)
|
|
90
|
+
recordTakeover({ event: 'executed=mounted' })
|
|
91
|
+
endShellSelectApplyActive()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** volatile ref 解包成挂载/刷新快照(深拷切断冻结产物,见 mountExecutor 注释)。 */
|
|
95
|
+
function unwrapConfigSnapshot(config) {
|
|
96
|
+
const unwrap = (value) => {
|
|
97
|
+
const raw = typeof value?.get === 'function' ? value.get() : value
|
|
98
|
+
return raw !== null && typeof raw === 'object' ? structuredClone(raw) : raw
|
|
99
|
+
}
|
|
100
|
+
const section = { ...config }
|
|
101
|
+
for (const key of Object.keys(section)) section[key] = unwrap(section[key])
|
|
102
|
+
return section
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 挂载执行器(注册 ctx.shell 服务、shell 工具与 systemPrompt 段,均随本行
|
|
107
|
+
* fiber 卸载自动清理)。行 config 的 volatile 字段是响应式 ref(get() 协议):
|
|
108
|
+
* 挂载前解包成快照——子行管线(ShellSelectExecutor static Config)是纯
|
|
109
|
+
* schemastery,不认 ref,透传 ref 容器直接撞 "expected array but got
|
|
110
|
+
* [object Object]" 拒挂(真机实证);解包产物必须 structuredClone:ref
|
|
111
|
+
* .get() 返回 cosmokit snapshot 深冻结对象,cordis 挂载管线对 section 原地
|
|
112
|
+
* resolve,冻结 env 直接炸 "Cannot assign to read only property" 并触发
|
|
113
|
+
* 接管回滚。快照的陈旧由 apply 里的 loader/volatile-update 监听补偿:
|
|
114
|
+
* volatile-only 变更宿主换值后发事件不重挂行,监听器现取 ref 解包成新
|
|
115
|
+
* 快照经 ctx.shell.refresh 换源并重建工具。
|
|
116
|
+
*/
|
|
117
|
+
async function mountExecutor(ctx, 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)
|
|
124
|
+
// await 返回 ≠ 激活:executor inject 的宿主服务缺失时 fiber 静默 pending
|
|
125
|
+
// (不 provide 不报错),ctx.shell 空缺会被官方复活行补位——挂载必须核实
|
|
126
|
+
// 激活态,未激活按失败处理(触发回滚,官方接管)
|
|
127
|
+
if (fiber?.state !== 2) {
|
|
128
|
+
const waiting = fiber?.inject ? Object.keys(fiber.inject).join(',') : '?'
|
|
129
|
+
throw new Error(`执行器 fiber 未激活(state=${fiber?.state},inject=${waiting})`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* runtime-disable 接管序列(apply 落定后的异步链,不阻塞 apply 生命周期):
|
|
135
|
+
* 禁用启用中的官方行 → 等双行退场 → 挂执行器。任何失败都把系统带回官方
|
|
136
|
+
* 自服务形态:禁一半(update 失败)回滚已禁行;挂载失败(官方已退场,
|
|
137
|
+
* ctx.shell 真空)回滚启用官方两行令其复活。
|
|
138
|
+
*/
|
|
139
|
+
async function runtimeTakeover(ctx, config, targets) {
|
|
140
|
+
const { disabled, failed } = await runtimeDisableRows(targets)
|
|
141
|
+
recordTakeover({ event: `disabled=${disabled.length} updateFailed=${failed.length}` })
|
|
142
|
+
if (failed.length > 0) {
|
|
143
|
+
ctx.logger.warn(`shell-select: 官方行运行时禁用失败(${failed.length} 行),回滚并保持官方服务`)
|
|
144
|
+
await rollbackDisabledRows(disabled)
|
|
145
|
+
recordTakeover({ event: `rolledBack=${disabled.length}, 不挂载` })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const exited = await awaitOfficialExit(targets)
|
|
149
|
+
if (!exited) {
|
|
150
|
+
ctx.logger.warn('shell-select: 官方行退场超时,放弃接管(官方 fiber 仍在服务);重启后由行树声明态收敛')
|
|
151
|
+
recordTakeover({ event: 'runtime 退场超时,放弃挂载' })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await mountExecutor(ctx, config)
|
|
156
|
+
recordTakeover({ event: 'executed=mounted' })
|
|
157
|
+
ctx.logger.warn('shell-select: 官方 pwsh 链已运行时禁用,本包执行器接管 ctx.shell 与 shell 工具')
|
|
158
|
+
} catch (error) {
|
|
159
|
+
recordTakeover({ event: `挂载失败,回滚: ${error?.message ?? error}` })
|
|
160
|
+
ctx.logger.error(`shell-select: 执行器挂载失败,回滚启用官方 pwsh 链: ${error?.message ?? error}`)
|
|
161
|
+
await rollbackDisabledRows(targets)
|
|
162
|
+
}
|
|
163
|
+
}
|
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'))
|