@mzzsfy/dsh-shell-select 0.3.0 → 0.4.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/executor.mjs CHANGED
@@ -1,34 +1,35 @@
1
1
  // shell-select 执行器:ctx.shell 提供者(官方 SandboxPwshExecutor 同构,进程机制
2
- // 继承 dsh-pwsh-local 形态),并把模型可见 shell 工具、systemPrompt 段、
3
- // shell-select 设置节与浏览器半区路由一并挂在本行 fiber 上。
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. 执行器预算与客户端清单同节('shell-select'),官方拆 'shell' 节 + 行内 Config。
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 { MAX_TIMER_DELAY_MS, clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
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, 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
- export const SHELL_SELECT_INJECT = ['subprocess', 'sandbox', 'sandboxPolicy', 'settings', 'tools', 'systemPrompt', 'shellEnv']
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
- /** 工具重注册句柄(onChange 先卸后挂;仅构造期闭包触达,this 恒为裸实例)。 */
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,30 @@ 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
- // settings 为硬依赖(static inject 门控);面异常按降级处理,能力不损
174
- const settingsOk = typeof ctx.settings?.installSection === 'function'
175
- if (!settingsOk) ctx.logger?.warn?.('shell-select: 宿主 settings 服务缺 installSection,配置退化为行内 Config,无热更新')
176
-
177
- const entry = config ?? {}
178
- assertServiceableConfig(Config(entry))
179
- this.source = () => Config(entry)
180
- if (settingsOk) {
181
- ctx.settings.installSection(ctx, 'shell-select', Config, entry, {
182
- validate: assertServiceableConfig,
183
- setSource: (current) => {
184
- this.source = current
185
- },
186
- onChange: () => this.#onConfigChange(),
187
- })
188
- }
140
+ this.#entry = config ?? {}
141
+ assertServiceableConfig(resolveConfig(this.#entry))
142
+ this.source = () => resolveConfig(this.#entry)
143
+ // refresh 桥绑定随本 fiber 卸载自动解绑(实例退场后写路径请求静默丢弃)
144
+ ctx.effect(() => bindShellRefreshBridge((next) => this.refresh(next)))
145
+ // 自带设置页(客户端半区自定义卡):抑制宿主按 schema 自动生成的原生页
146
+ // (官方 README 同构:可选子级注入声明策略归属 fiber,服务迟加载也采纳)
147
+ ctx.inject?.(['settings'], (child) => {
148
+ if (typeof child.settings?.configure !== 'function') return
149
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber))
150
+ })
189
151
  this.#registerTool()
190
152
  this.#mountPromptSection()
191
- mountRoutes(ctx, {
192
- listShells: () => this.listShells(),
193
- readConfig: () => this.source(),
194
- updateConfig: (patch) => this.updateConfig(patch),
195
- detect: (kinds) => this.detect(kinds),
196
- probe: (candidatePath) => candidateExists(candidatePath),
197
- })
198
- endShellSelectApplyActive()
153
+ }
154
+
155
+ /** volatile-only 配置变更的活刷新:换配置源并重建工具注册(写路径经
156
+ * requestShellRefresh 触达;行不重挂,本实例与进行中执行不受扰)。 */
157
+ refresh(config) {
158
+ this.#entry = config ?? {}
159
+ assertServiceableConfig(resolveConfig(this.#entry))
160
+ this.#registerTool()
199
161
  }
200
162
 
201
163
  get config() {
@@ -224,7 +186,6 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
224
186
  const resolved = normalizeWin32Path(resolveEntryPath(entry, candidateExists))
225
187
  return {
226
188
  id: entry.id,
227
- name: entry.name,
228
189
  kind: entry.kind,
229
190
  args: entry.args,
230
191
  path: resolved,
@@ -258,33 +219,6 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
258
219
  return { id: entry.id, kind: entry.kind, path: resolved, args: entry.args, login: entry.login === true, distro: entry.distro ?? '', env: entry.env ?? {} }
259
220
  }
260
221
 
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
222
  /** 注册/重注册 shell 工具(描述随客户端清单变化)。 */
289
223
  #registerTool() {
290
224
  this.#toolRegistration?.()
@@ -323,11 +257,162 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
323
257
  return buildArgv(entry, spec.command)
324
258
  }
325
259
 
326
- /** 官方 run seam:已解析 spec 按默认客户端前台执行(dsh-pwsh-local run 同构)。 */
260
+ /** 官方 execute seam:已解析 spec 按默认客户端前台执行(dsh-pwsh-local run 同构)。 */
327
261
  async run(spec) {
328
262
  return this.runFor(this.entryFor(), spec)
329
263
  }
330
264
 
265
+ /**
266
+ * 官方 ctx.shell 唯一执行契约(ShellExecutor 抽象方法,官方 tool-pwsh 与
267
+ * 进程内消费方共同依赖):解析 spec 后按默认客户端 spawn,返回 ShellExecution
268
+ * 句柄(done/readOutput/kill/observed/result)。前台与否是调用方 await 什么的
269
+ * 属性,不是 spawn 的属性。沙箱语义与 runFor 同源:danger 直跑,受限模式
270
+ * confine 包装,定案按 denial/runner-failure 规则附 sandbox 事实。
271
+ * @param {object} spec resolve() 产物(never 原始 request)
272
+ * @returns {Promise<object>} ShellExecution 句柄
273
+ */
274
+ async execute(spec) {
275
+ this.assertNotDenied(spec.command)
276
+ const entry = this.entryFor()
277
+ const policy = spec.sandboxPolicy
278
+ const { mode } = policy
279
+ if (mode === 'danger-full-access') {
280
+ return this.executionArgv(entry, spec)
281
+ }
282
+ const confined = this.ctx.sandbox.confine(this.argvFor(entry, spec), { ...policy, mode })
283
+ return this.executionArgv(entry, spec, confined.argv, {
284
+ mode,
285
+ enforcement: confined.enforcement,
286
+ denialSignatures: confined.denialSignatures,
287
+ runnerFailureRules: confined.runnerFailureRules,
288
+ runnerProgram: confined.argv[0],
289
+ workdir: spec.workdir,
290
+ })
291
+ }
292
+
293
+ /**
294
+ * 官方 executeArgv 同构(条目参数化):deadline 按 spec.onExpiry 武装
295
+ * ('kill' 到期杀,'none'/缺省只跟随调用方信号);done 永不 reject,provider
296
+ * spawn 失败定局 killed + stderr 注记,result() 携带同一 rejection;受限模式
297
+ * 定案事实经 processFacts/onProcessDone 单通道写 proc.sandbox(官方
298
+ * pwsh-sandbox 子类同构),unsandboxed 的 result() 无 sandbox 键。
299
+ */
300
+ executionArgv(entry, spec, forcedArgv, sandboxFacts) {
301
+ const argv = forcedArgv ?? this.argvFor(entry, spec)
302
+ const armDeadline = spec.onExpiry === 'kill'
303
+ const d = armDeadline ? deadline(spec.signal, spec.timeoutMs, 'SHELL_TIMEOUT') : undefined
304
+ const spawnSignal = d ? d.signal : spec.signal
305
+ const classifyState = () => {
306
+ if (d === undefined) return { timedOut: false, aborted: spec.signal?.aborted === true }
307
+ const timedOut = timeoutOf(d.signal, 'SHELL_TIMEOUT') !== undefined
308
+ return { timedOut, aborted: d.signal.aborted === true && !timedOut }
309
+ }
310
+ let running
311
+ let syncSpawnError
312
+ const prepareAborted = spawnSignal?.aborted === true
313
+ if (!prepareAborted) {
314
+ try {
315
+ running = this.ctx.subprocess.spawn(this.spawnSpec(entry, spec, argv, spec.stdoutMaxBytes, spawnSignal))
316
+ } catch (error) {
317
+ syncSpawnError = { error }
318
+ }
319
+ }
320
+ const emptyReader = { readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }) }
321
+ const collected = running !== undefined ? ShellSelectExecutor.collected(running) : { stdout: emptyReader, stderr: emptyReader }
322
+ const spawnThrow = () => syncSpawnError.error
323
+ const spawned = prepareAborted
324
+ ? Promise.resolve({ exitCode: null, signal: null })
325
+ : running !== undefined ? running.done : Promise.reject(spawnThrow())
326
+ let providerFailure
327
+ const consumeProviderFailure = () => {
328
+ if (providerFailure === undefined || providerFailure.reported) return ''
329
+ providerFailure.reported = true
330
+ return providerFailure.note
331
+ }
332
+ let stdoutOffset = 0
333
+ let stderrOffset = 0
334
+ const readCollected = (offsets) => ({
335
+ out: collected.stdout.readFrom(offsets.stdout),
336
+ err: collected.stderr.readFrom(offsets.stderr),
337
+ })
338
+ const executor = this
339
+ let resultPromise
340
+ const proc = {
341
+ status: 'running',
342
+ exitCode: null,
343
+ signal: null,
344
+ observed: {
345
+ stdout: collected.stdout,
346
+ stderr: {
347
+ readFrom: (fromByte) => {
348
+ if (providerFailure === undefined) return collected.stderr.readFrom(fromByte)
349
+ const note = Buffer.from(providerFailure.note, 'utf8')
350
+ return { text: note.subarray(Math.min(fromByte, note.length)).toString('utf8'), nextOffset: note.length, lossy: false }
351
+ },
352
+ },
353
+ },
354
+ done: spawned.then((outcome) => {
355
+ if (proc.status === 'running') proc.status = spawnSignal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
356
+ proc.exitCode = outcome.exitCode
357
+ proc.signal = outcome.signal
358
+ if (sandboxFacts !== undefined) executor.processFacts.set(proc, sandboxFacts)
359
+ executor.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
360
+ d?.[Symbol.dispose]?.()
361
+ }, (error) => {
362
+ proc.status = 'killed'
363
+ let detail = 'unprintable provider failure'
364
+ try {
365
+ detail = String(error)
366
+ } catch {}
367
+ const isRunnerFailure = sandboxFacts !== undefined && isRunnerSpawnFailure(error, sandboxFacts.runnerProgram, sandboxFacts.workdir)
368
+ if (isRunnerFailure) {
369
+ providerFailure = { error: new executor.unavailableError(sandboxFacts.mode, String(error)), note: String(error), reported: false }
370
+ } else {
371
+ providerFailure = { error, note: `subprocess failed before reporting an outcome: ${detail}`, reported: false }
372
+ }
373
+ executor.onProcessDone(proc, providerFailure.note, true, error)
374
+ d?.[Symbol.dispose]?.()
375
+ }),
376
+ readOutput: () => {
377
+ const { out, err } = readCollected({ stdout: stdoutOffset, stderr: stderrOffset })
378
+ stdoutOffset = out.nextOffset
379
+ stderrOffset = err.nextOffset
380
+ const providerNote = consumeProviderFailure()
381
+ const failureSeparator = err.text.length > 0 && !err.text.endsWith('\n') ? '\n' : ''
382
+ const errText = err.text + (providerNote.length > 0 ? `${failureSeparator}${providerNote}` : '')
383
+ const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
384
+ return {
385
+ delta: out.text + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : ''),
386
+ lossy: out.lossy || err.lossy,
387
+ ...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
388
+ ...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
389
+ }
390
+ },
391
+ kill: () => {
392
+ if (proc.status !== 'running') return false
393
+ proc.status = 'killed'
394
+ running?.terminate()
395
+ return true
396
+ },
397
+ result: () => {
398
+ resultPromise ??= proc.done.then(() => {
399
+ if (providerFailure !== undefined) throw providerFailure.error
400
+ return {
401
+ exitCode: proc.exitCode,
402
+ signal: proc.signal,
403
+ ...classifyState(),
404
+ timeoutMs: spec.timeoutMs,
405
+ stdout: finalOutput(collected.stdout),
406
+ stderr: finalOutput(collected.stderr),
407
+ ...proc.sandbox !== undefined ? { sandbox: proc.sandbox } : {},
408
+ }
409
+ })
410
+ return resultPromise
411
+ },
412
+ }
413
+ return Promise.resolve(proc)
414
+ }
415
+
331
416
  /** 官方 start seam:已解析 spec 按默认客户端后台启动(dsh-pwsh-local start 同构)。 */
332
417
  start(spec) {
333
418
  return this.startFor(this.entryFor(), spec)
@@ -529,4 +614,22 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
529
614
  }
530
615
  }
531
616
 
617
+ // volatile 变更的 refresh 桥(模块级单例,apply-state 同款先例):执行器
618
+ // provide 的 ctx.shell 作用域在主行子树,web-routes 行是兄弟行,cordis 服务
619
+ // 沿 ctx 树向上查找不横向,ctx.shell getter 直接 throw——写路径经此桥触达
620
+ // 活跃实例。挂载绑定、实例卸载/顶替时解绑,未挂载时请求静默丢弃(执行面
621
+ // 由下次挂载的行配置重建)。
622
+ let refreshBridge = null
623
+
624
+ export function bindShellRefreshBridge(handler) {
625
+ refreshBridge = handler
626
+ return () => {
627
+ if (refreshBridge === handler) refreshBridge = null
628
+ }
629
+ }
630
+
631
+ export function requestShellRefresh(snapshot) {
632
+ refreshBridge?.(snapshot)
633
+ }
634
+
532
635
  export default ShellSelectExecutor
@@ -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
- // bundle patch 禁用停稳——无任何 shell 执行器与工具。代挂走 ctx.plugin,随 guard
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 { shellSelectApplyState } from './apply-state.mjs'
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,157 @@
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
+
35
+ export const name = 'shell-select'
36
+
37
+ // settings 节注册面:宿主按行模块的 Config 导出注册 shell-select 节(与执行器
38
+ // 同一 schema 单一事实源)
39
+ export { Config } from './config.mjs'
40
+
41
+ /**
42
+ * 主行 apply。
43
+ * @param {import('@deepseek-ai/cordis').Context} ctx
44
+ * @param {object|undefined} config 行 Config(shell 客户端清单等)
45
+ */
46
+ export async function apply(ctx, config) {
47
+ beginShellSelectApply()
48
+ // 平台早退:行级平台门控在本前已停用本行,此为纵深防御——行被显式启用到
49
+ // POSIX 时本行自身不得碰官方 bash/pwsh 链
50
+ if (process.platform !== 'win32') {
51
+ endShellSelectApplyInactive()
52
+ return
53
+ }
54
+ const states = officialChainState(ctx.loader)
55
+ const { decision, targets } = takeoverDecision(states)
56
+ recordTakeover({
57
+ event: `decision=${decision} targets=${targets.length}`,
58
+ rows: Object.fromEntries(Object.entries(states).map(([id, row]) => [id, {
59
+ present: row.present,
60
+ rawType: typeof row.rawDisabled,
61
+ unreliable: row.disabledUnreliable,
62
+ running: row.running,
63
+ }])),
64
+ })
65
+ ctx.logger.info?.(`shell-select: 官方 pwsh 链接管决策 ${decision}(targets=${targets.length})`)
66
+ if (decision === 'yield') {
67
+ endShellSelectApplyActive()
68
+ return
69
+ }
70
+ if (decision === 'runtime-disable') {
71
+ endShellSelectApplyActive()
72
+ void runtimeTakeover(ctx, config, targets).catch((error) => {
73
+ recordTakeover({ error: String(error?.message ?? error) })
74
+ })
75
+ return
76
+ }
77
+ // takeover / await-exit:官方行已禁(或缺席),直接接管
78
+ if (decision === 'await-exit') {
79
+ const entries = Object.values(states).filter((row) => row.present).map((row) => row.entry)
80
+ const exited = await awaitOfficialExit(entries)
81
+ if (!exited) {
82
+ ctx.logger.warn('shell-select: 官方行退场超时,放弃接管(官方仍在服务);重启后由行树声明态收敛')
83
+ recordTakeover({ event: 'await-exit 退场超时,放弃挂载' })
84
+ endShellSelectApplyActive()
85
+ return
86
+ }
87
+ }
88
+ await mountExecutor(ctx, config)
89
+ recordTakeover({ event: 'executed=mounted' })
90
+ endShellSelectApplyActive()
91
+ }
92
+
93
+ /** volatile ref 解包成挂载/刷新快照(深拷切断冻结产物,见 mountExecutor 注释)。 */
94
+ function unwrapConfigSnapshot(config) {
95
+ const unwrap = (value) => {
96
+ const raw = typeof value?.get === 'function' ? value.get() : value
97
+ return raw !== null && typeof raw === 'object' ? structuredClone(raw) : raw
98
+ }
99
+ const section = { ...config }
100
+ for (const key of Object.keys(section)) section[key] = unwrap(section[key])
101
+ return section
102
+ }
103
+
104
+ /**
105
+ * 挂载执行器(注册 ctx.shell 服务、shell 工具与 systemPrompt 段,均随本行
106
+ * fiber 卸载自动清理)。行 config 的 volatile 字段是响应式 ref(get() 协议):
107
+ * 挂载前解包成快照——子行管线(ShellSelectExecutor static Config)是纯
108
+ * schemastery,不认 ref,透传 ref 容器直接撞 "expected array but got
109
+ * [object Object]" 拒挂(真机实证);解包产物必须 structuredClone:ref
110
+ * .get() 返回 cosmokit snapshot 深冻结对象,cordis 挂载管线对 section 原地
111
+ * resolve,冻结 env 直接炸 "Cannot assign to read only property" 并触发
112
+ * 接管回滚。快照的陈旧由 apply 里的 loader/volatile-update 监听补偿:
113
+ * volatile-only 变更宿主换值后发事件不重挂行,监听器现取 ref 解包成新
114
+ * 快照经 ctx.shell.refresh 换源并重建工具。
115
+ */
116
+ async function mountExecutor(ctx, config) {
117
+ const fiber = await ctx.plugin(ShellSelectExecutor, unwrapConfigSnapshot(config))
118
+ // await 返回 ≠ 激活:executor inject 的宿主服务缺失时 fiber 静默 pending
119
+ // (不 provide 不报错),ctx.shell 空缺会被官方复活行补位——挂载必须核实
120
+ // 激活态,未激活按失败处理(触发回滚,官方接管)
121
+ if (fiber?.state !== 2) {
122
+ const waiting = fiber?.inject ? Object.keys(fiber.inject).join(',') : '?'
123
+ throw new Error(`执行器 fiber 未激活(state=${fiber?.state},inject=${waiting})`)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * runtime-disable 接管序列(apply 落定后的异步链,不阻塞 apply 生命周期):
129
+ * 禁用启用中的官方行 → 等双行退场 → 挂执行器。任何失败都把系统带回官方
130
+ * 自服务形态:禁一半(update 失败)回滚已禁行;挂载失败(官方已退场,
131
+ * ctx.shell 真空)回滚启用官方两行令其复活。
132
+ */
133
+ async function runtimeTakeover(ctx, config, targets) {
134
+ const { disabled, failed } = await runtimeDisableRows(targets)
135
+ recordTakeover({ event: `disabled=${disabled.length} updateFailed=${failed.length}` })
136
+ if (failed.length > 0) {
137
+ ctx.logger.warn(`shell-select: 官方行运行时禁用失败(${failed.length} 行),回滚并保持官方服务`)
138
+ await rollbackDisabledRows(disabled)
139
+ recordTakeover({ event: `rolledBack=${disabled.length}, 不挂载` })
140
+ return
141
+ }
142
+ const exited = await awaitOfficialExit(targets)
143
+ if (!exited) {
144
+ ctx.logger.warn('shell-select: 官方行退场超时,放弃接管(官方 fiber 仍在服务);重启后由行树声明态收敛')
145
+ recordTakeover({ event: 'runtime 退场超时,放弃挂载' })
146
+ return
147
+ }
148
+ try {
149
+ await mountExecutor(ctx, config)
150
+ recordTakeover({ event: 'executed=mounted' })
151
+ ctx.logger.warn('shell-select: 官方 pwsh 链已运行时禁用,本包执行器接管 ctx.shell 与 shell 工具')
152
+ } catch (error) {
153
+ recordTakeover({ event: `挂载失败,回滚: ${error?.message ?? error}` })
154
+ ctx.logger.error(`shell-select: 执行器挂载失败,回滚启用官方 pwsh 链: ${error?.message ?? error}`)
155
+ await rollbackDisabledRows(targets)
156
+ }
157
+ }