@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.
@@ -0,0 +1,64 @@
1
+ // guard 官方行配置:从宿主 settings.yaml 读官方 `shell` 节,代挂 pwsh-sandbox
2
+ // 时透传(Config 消费 pwshPath 等),保持官方执行器配置零感知延续。
3
+ // settings.yaml 定位经宿主 dsh-home-paths(官方同构 gateway officialRowConfig,
4
+ // 剥离 pi-ai 专属的 compat 键处理——pwsh 配置无此形态)。
5
+
6
+ /**
7
+ * 读官方 shell 节。
8
+ * @param {() => Promise<object>} readDocument settings.yaml 全文档读取(测试注入)
9
+ * @returns {Promise<object>} 节缺失/读取失败一律空对象(schema 默认顶上)
10
+ */
11
+ export async function officialRowConfig(readDocument) {
12
+ try {
13
+ const document = await readDocument()
14
+ const section = document?.shell
15
+ return typeof section === 'object' && section !== null && !Array.isArray(section) ? section : {}
16
+ } catch {
17
+ return {}
18
+ }
19
+ }
20
+
21
+ /**
22
+ * settings.yaml 全文档读取的生产实现:宿主入口依赖树内解析 dsh-home-paths
23
+ * 与 yaml 包,经 realpath 对齐宿主本体树(官方同构;解析链裸 import 会命中
24
+ * 与运行宿主脱钩的副本)。
25
+ */
26
+ export async function readSettingsDocument() {
27
+ const { createRequire } = await import('node:module')
28
+ const { pathToFileURL } = await import('node:url')
29
+ const { realpath } = await import('node:fs/promises')
30
+ const { dirname, join } = await import('node:path')
31
+ const entry = process.argv[1]
32
+ if (!entry) return {}
33
+ const realEntry = await realpath(entry)
34
+ const require = createRequire(pathToFileURL(realEntry))
35
+ const yamlPath = await realpath(require.resolve('yaml'))
36
+ const { parse } = await import(pathToFileURL(yamlPath).href)
37
+ let home = null
38
+ try {
39
+ const homePaths = await realpath(require.resolve('@deepseek-ai/dsh-home-paths'))
40
+ const resolved = await import(pathToFileURL(homePaths).href)
41
+ home = resolved.resolveDshHome
42
+ } catch {
43
+ home = null
44
+ }
45
+ const filename = typeof home === 'function' ? join(home(), 'settings.yaml') : join(dirname(process.execPath), 'settings.yaml')
46
+ const { readFile } = await import('node:fs/promises')
47
+ return parse(await readFile(filename, 'utf8'))
48
+ }
49
+
50
+ /** 官方包物理路径解析:宿主入口依赖树内定位(官方同构),失败返回 null。 */
51
+ export async function resolveFromHostTree(packageName) {
52
+ try {
53
+ const { createRequire } = await import('node:module')
54
+ const { pathToFileURL } = await import('node:url')
55
+ const { realpath } = await import('node:fs/promises')
56
+ const entry = process.argv[1]
57
+ if (!entry) return null
58
+ const realEntry = await realpath(entry)
59
+ const require = createRequire(pathToFileURL(realEntry))
60
+ return await realpath(require.resolve(packageName))
61
+ } catch {
62
+ return null
63
+ }
64
+ }
@@ -0,0 +1,62 @@
1
+ // guard 死态判定(纯函数):主行功能性停摆(禁用停稳 / 旗标 inactive;崩溃
2
+ // pending 保守不判)且官方两行被禁停稳。行 id 解析与 Entry.disabled getter
3
+ // 语义同构 dsh-llm-pi-gateway takeover.mjs(!!js 求值 + 布尔宽化,抛错按未禁)。
4
+
5
+ // 本包主行 id(cordis.patch.yml insert 声明)
6
+ export const MAIN_ROW_ID = 'shell-select'
7
+ // 官方受控行 id(dsh-base cordis.patch.yml 声明)
8
+ export const OFFICIAL_ROW_IDS = ['tool-pwsh', 'pwsh-sandbox']
9
+
10
+ // 行 id 解析候选前缀:宿主把 profile 行树经 cordis:include 挂载,受控行实际
11
+ // 解析 id 带前缀;空串覆盖裸树形态(官方同构)
12
+ export const ROW_ID_PREFIXES = ['include:', '']
13
+
14
+ const ABSENT = Object.freeze({ present: false, disabled: false, running: false })
15
+
16
+ /** Entry.disabled getter 语义:求值抛错按未禁处理(让位安全向,不占官方资源)。 */
17
+ export function effectiveDisabled(entry) {
18
+ try {
19
+ return entry.disabled === true
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ /**
26
+ * 行状态探测;loader 缺失或行不存在按缺席。
27
+ * @returns {{present: boolean, disabled: boolean, running: boolean}}
28
+ */
29
+ export function rowState(loader, id) {
30
+ if (loader?.resolve === undefined) return ABSENT
31
+ for (const prefix of ROW_ID_PREFIXES) {
32
+ try {
33
+ const entry = loader.resolve(prefix + id)
34
+ if (entry) {
35
+ return {
36
+ present: true,
37
+ disabled: effectiveDisabled(entry),
38
+ running: entry.fiber?.uid != null,
39
+ }
40
+ }
41
+ } catch {
42
+ // 该命名空间无此行,试下一候选
43
+ }
44
+ }
45
+ return ABSENT
46
+ }
47
+
48
+ /**
49
+ * 死态判定:主行功能性停摆且官方两行全部禁用停稳。
50
+ * @param {object} loader 宿主 loader 服务
51
+ * @param {{applyState: () => string}} faces apply 旗标读数
52
+ * @returns {boolean} PENDING 形态(行不可解析)按 false 处理,下轮再扫
53
+ */
54
+ export function detectDeadState(loader, { applyState }) {
55
+ const main = rowState(loader, MAIN_ROW_ID)
56
+ const mainStalled = (main.present && main.disabled && !main.running) || applyState() === 'inactive'
57
+ if (!mainStalled) return false
58
+ return OFFICIAL_ROW_IDS.every((id) => {
59
+ const state = rowState(loader, id)
60
+ return !state.present || (state.disabled && !state.running)
61
+ })
62
+ }
package/src/guard.js ADDED
@@ -0,0 +1,256 @@
1
+ // shell-select guard 哨兵行:死态自愈,守服务不抢权(逐项同构 dsh-llm-pi-gateway
2
+ // guard,差异仅代挂对象为官方 tool-pwsh + pwsh-sandbox 两行,且无 compat 键处理)。
3
+ // 死态 = 主行功能性停摆(市场开关即时禁用 / apply 旗标 inactive)且官方两行仍被
4
+ // bundle patch 禁用停稳——无任何 shell 执行器与工具。代挂走 ctx.plugin,随 guard
5
+ // fiber 卸载;主行或官方行任一复活先卸代挂。guard 行 id 含 "/",市场行写入对其
6
+ // 拒绝,窗口内始终存活。
7
+
8
+ import { MAIN_ROW_ID, OFFICIAL_ROW_IDS, detectDeadState, rowState } from './guard-state.mjs'
9
+ import { officialRowConfig, readSettingsDocument, resolveFromHostTree } from './guard-config.mjs'
10
+ import { shellSelectApplyState } from './apply-state.mjs'
11
+
12
+ // 官方包 npm 名(与行 id 独立:行 id 是 dsh-base 组合行,包名才是模块解析键)
13
+ const OFFICIAL_PACKAGES = {
14
+ 'tool-pwsh': '@deepseek-ai/dsh-tool-pwsh',
15
+ 'pwsh-sandbox': '@deepseek-ai/dsh-pwsh-sandbox',
16
+ }
17
+ // pwsh-sandbox 行消费官方 settings 文档的 `shell` 节;tool-pwsh 行零配置(schema 默认)
18
+ const OFFICIAL_ROW_CONFIG = {
19
+ 'tool-pwsh': async () => ({}),
20
+ 'pwsh-sandbox': async () => officialRowConfig(readSettingsDocument),
21
+ }
22
+
23
+ // 事件缺失时的轮询兜底周期与退场等待上界(官方同构)
24
+ const SWEEP_INTERVAL_MS = 3000
25
+ const EXIT_POLL_INTERVAL_MS = 20
26
+ const EXIT_POLL_ROUNDS = 50
27
+ // 护栏:官方包加载/代挂上界,超时按本周期放弃(防 boot 树卡死)
28
+ const MOUNT_TIMEOUT_MS = 10 * 1000
29
+ const LOAD_TIMEOUT_MS = 10 * 1000
30
+
31
+ const delay = (ms) => new Promise((resolve) => {
32
+ const timer = setTimeout(resolve, ms)
33
+ timer.unref?.()
34
+ })
35
+
36
+ async function withTimeout(promise, timeoutMs, label) {
37
+ let timer = null
38
+ try {
39
+ return await Promise.race([
40
+ Promise.resolve(promise),
41
+ new Promise((_, reject) => {
42
+ timer = setTimeout(() => reject(new Error(`${label} 超时(${timeoutMs}ms)`)), timeoutMs)
43
+ }),
44
+ ])
45
+ } finally {
46
+ if (timer !== null) clearTimeout(timer)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 官方包模块归一成 ctx.plugin 可挂载形态:apply 型模块(如 tool-pwsh)直挂,
52
+ * Service 类包(pwsh-sandbox 仅 default 导出类)挂类本身;两形态皆无返回
53
+ * undefined(按预载失败处理)。
54
+ */
55
+ function mountableOf(module) {
56
+ if (typeof module?.apply === 'function') return module
57
+ if (typeof module?.default === 'function') return module.default
58
+ return undefined
59
+ }
60
+
61
+ /** 官方包模块加载:宿主本体树优先,失败回退裸 import;均败抛错(调用方放弃自愈)。 */
62
+ async function importOfficial(packageName) {
63
+ const hostPath = await resolveFromHostTree(packageName)
64
+ if (hostPath !== null) {
65
+ const { pathToFileURL } = await import('node:url')
66
+ return import(pathToFileURL(hostPath).href)
67
+ }
68
+ return import(packageName)
69
+ }
70
+
71
+ /**
72
+ * 安装 guard:初始判定后事件跟随 + 轮询兜底双通道跟随死态/复活边沿。
73
+ * 全部状态挂 guard 自身 fiber,随其卸载自动清理。
74
+ */
75
+ export async function installGuard(ctx, {
76
+ importOfficial: importOfficialOverride,
77
+ delay: delayOverride = delay,
78
+ sweepIntervalMs = SWEEP_INTERVAL_MS,
79
+ timeouts = {},
80
+ } = {}) {
81
+ const loadTimeout = timeouts.load ?? LOAD_TIMEOUT_MS
82
+ const mountTimeout = timeouts.mount ?? MOUNT_TIMEOUT_MS
83
+ const importOne = importOfficialOverride ?? importOfficial
84
+
85
+ // 官方模块预载并归一挂载形态:瞬时失败退避重试,耗尽才停用自愈
86
+ // (HMR 重建风暴期 import 竞争易超时,单次放弃会让死态窗口无自愈)
87
+ const modules = new Map()
88
+ const LOAD_RETRIES = 2
89
+ const LOAD_RETRY_DELAY_MS = 3 * 1000
90
+ for (const id of OFFICIAL_ROW_IDS) {
91
+ let lastError = null
92
+ for (let attempt = 0; attempt <= LOAD_RETRIES; attempt++) {
93
+ try {
94
+ const loaded = mountableOf(await withTimeout(importOne(OFFICIAL_PACKAGES[id]), loadTimeout, `guard 官方包 ${OFFICIAL_PACKAGES[id]} 加载`))
95
+ if (loaded === undefined) throw new Error('模块既无 apply 也无 default 导出,无法挂载')
96
+ modules.set(id, loaded)
97
+ lastError = null
98
+ break
99
+ } catch (error) {
100
+ lastError = error
101
+ if (attempt < LOAD_RETRIES) {
102
+ await delayOverride(LOAD_RETRY_DELAY_MS)
103
+ }
104
+ }
105
+ }
106
+ if (lastError !== null) {
107
+ ctx.logger.warn(`shell-select/guard: 官方 ${OFFICIAL_PACKAGES[id]} 不可用,死态自愈停用: ${lastError?.message ?? lastError}`)
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ // 代挂状态:id → fiber;mounting 去并发,卸载幂等
113
+ let mounted = new Map()
114
+ let mounting = null
115
+
116
+ const readRowConfig = async (id) => {
117
+ try {
118
+ return await withTimeout(OFFICIAL_ROW_CONFIG[id](), mountTimeout, `guard ${id} 行配置读取`)
119
+ } catch {
120
+ return {}
121
+ }
122
+ }
123
+
124
+ async function mountOfficial() {
125
+ if (mounted.size > 0 || mounting !== null) return
126
+ mounting = (async () => {
127
+ const fibers = new Map()
128
+ try {
129
+ // 服务依赖方(tool-pwsh inject shell)与提供者(pwsh-sandbox provide shell)
130
+ // 都不能串行 await 激活:依赖方挂载会在等待提供者时自锁。全部先发起,
131
+ // 再统一等待落定;纤维 await 等的是激活完成,窗口由 mountTimeout 兜底。
132
+ const created = OFFICIAL_ROW_IDS.map(async (id) => {
133
+ const config = await readRowConfig(id)
134
+ return [id, Promise.resolve(ctx.plugin(modules.get(id), config))]
135
+ })
136
+ for (const pair of await Promise.all(created)) {
137
+ const [id, fiberPromise] = pair
138
+ fibers.set(id, await withTimeout(fiberPromise, mountTimeout, `guard 代挂 ${id}`))
139
+ }
140
+ mounted = fibers
141
+ ctx.logger.warn('shell-select/guard: 主行与官方 shell 链同时停用,已代挂官方工具恢复服务;重启后由宿主组合自然归位')
142
+ } catch (error) {
143
+ ctx.logger.error(`shell-select/guard: 代挂官方插件失败: ${error?.message ?? error}`)
144
+ // 半挂态回收:已挂成的部分先卸,不留半套官方链
145
+ for (const [id, fiber] of fibers) {
146
+ try {
147
+ await withTimeout(Promise.resolve(fiber).then((f) => f.dispose()), mountTimeout, `guard 回收半挂 ${id}`)
148
+ } catch (disposeError) {
149
+ ctx.logger.warn(`shell-select/guard: 半挂回收失败 ${id}: ${disposeError?.message ?? disposeError}`)
150
+ }
151
+ }
152
+ } finally {
153
+ mounting = null
154
+ }
155
+ })()
156
+ await mounting
157
+ }
158
+
159
+ async function unmountOfficial() {
160
+ if (mounting !== null) await mounting
161
+ const fibers = mounted
162
+ mounted = new Map()
163
+ for (const [id, fiber] of fibers) {
164
+ try {
165
+ await withTimeout(Promise.resolve(fiber).then((f) => f.dispose()), mountTimeout, `guard 卸代挂 ${id}`)
166
+ } catch (error) {
167
+ ctx.logger.warn(`shell-select/guard: 卸代挂失败 ${id}(${error?.message ?? error});放行继续,复活方可能撞残余注册,属有意取舍`)
168
+ }
169
+ }
170
+ }
171
+
172
+ // 复活让位:受控行任一即将 init 且本方在场,先卸代挂(waterfall 保证先于其 apply)
173
+ const patchContextGuard = async (entry, next) => {
174
+ const id = entry?.options?.id
175
+ if (id !== MAIN_ROW_ID && !OFFICIAL_ROW_IDS.includes(id)) return next()
176
+ if (entry.fiber?.uid != null) return next()
177
+ if (mounted.size === 0 && mounting === null) return next()
178
+ ctx.logger.warn(`shell-select/guard: ${id} 行复活,卸代挂让位`)
179
+ try {
180
+ await unmountOfficial()
181
+ } catch (error) {
182
+ ctx.logger.warn(`shell-select/guard: 卸代挂交接异常(${error?.message ?? error}),仍放行复活方`)
183
+ } finally {
184
+ return next()
185
+ }
186
+ }
187
+
188
+ // 禁用边沿:行退场后等注册撤清再判死态,防与退场序列并发
189
+ const partialDisposeGuard = async (entry) => {
190
+ const id = entry?.options?.id
191
+ if (id !== MAIN_ROW_ID && !OFFICIAL_ROW_IDS.includes(id)) return
192
+ if (entry.fiber?.uid != null) return
193
+ for (let round = 0; round < EXIT_POLL_ROUNDS; round += 1) {
194
+ if (entry.fiber?.uid == null) break
195
+ await delayOverride(EXIT_POLL_INTERVAL_MS)
196
+ }
197
+ if (detectDeadState(ctx.loader, { applyState: shellSelectApplyState }) === true) await mountOfficial()
198
+ }
199
+
200
+ // 轮询兜底:边沿时序因宿主版本而异,周期全量扫描保证最终一致;mount/unmount 各自幂等
201
+ let sweeping = false
202
+ const sweep = async () => {
203
+ if (sweeping) return
204
+ sweeping = true
205
+ try {
206
+ const dead = detectDeadState(ctx.loader, { applyState: shellSelectApplyState })
207
+ if (dead === true && mounted.size === 0 && mounting === null) await mountOfficial()
208
+ else if (dead === false && (mounted.size > 0 || mounting !== null)) await unmountOfficial()
209
+ } finally {
210
+ sweeping = false
211
+ }
212
+ }
213
+
214
+ // 可观测面:死态判定中间量与代挂状态只读暴露(排障与巡检共用)
215
+ const webServer = ctx.get?.('webServer')
216
+ if (webServer !== undefined && typeof webServer.register === 'function') {
217
+ const status = () => ({
218
+ dead: detectDeadState(ctx.loader, { applyState: shellSelectApplyState }),
219
+ applyState: shellSelectApplyState(),
220
+ mounted: [...mounted.keys()],
221
+ mounting: mounting !== null,
222
+ rows: [MAIN_ROW_ID, ...OFFICIAL_ROW_IDS].map((id) => ({ id, ...rowState(ctx.loader, id) })),
223
+ })
224
+ ctx.effect(() => webServer.register({
225
+ kind: 'exact',
226
+ path: '/api/shell-select/guard-status',
227
+ handler: (req, res) => {
228
+ res.writeHead(200, { 'content-type': 'application/json' })
229
+ res.end(JSON.stringify(status()))
230
+ },
231
+ }), 'shell-select/guard status route')
232
+ }
233
+
234
+ ctx.on('loader/patch-context', patchContextGuard, { global: true })
235
+ ctx.on('loader/partial-dispose', partialDisposeGuard, { global: true })
236
+
237
+ const initial = detectDeadState(ctx.loader, { applyState: shellSelectApplyState })
238
+ ctx.logger.info?.(`shell-select/guard: 初始判定 dead=${initial}`)
239
+ if (initial === true) await mountOfficial()
240
+
241
+ const timer = setInterval(() => {
242
+ void sweep()
243
+ }, sweepIntervalMs)
244
+ timer.unref?.()
245
+ return undefined
246
+ }
247
+
248
+ export const name = 'shell-select/guard'
249
+
250
+ /**
251
+ * guard 行入口:零配置,无服务依赖(loader 经 ctx 原型链可达)。
252
+ * @param {import('@deepseek-ai/cordis').Context} ctx
253
+ */
254
+ export async function apply(ctx) {
255
+ await installGuard(ctx)
256
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // 模型可见渲染:官方 dsh-tool-pwsh 渲染逐字镜像(stdout、[stderr] 段、
2
+ // 截断/超时/信号/退出/沙箱标记),文本形态变更必须与官方同步,否则
3
+ // terminal 卡的 parseExitStatus 退出 pill 复原失效。
4
+
5
+ import { sandboxDenialMarker, escalationHintMarker } from '@deepseek-ai/dsh-sandbox'
6
+
7
+ /** 追加单流截断提示(带全量输出 spill 路径)。 */
8
+ function streamText(output) {
9
+ if (!output.truncated) return output.text
10
+ return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
11
+ }
12
+
13
+ /**
14
+ * 前台运行结果 → 模型可见文本。
15
+ * @param {object} result ShellRunResult(含 sandbox 事实)
16
+ * @param {string[]} escalationModes 本组合公示的升权目标;非空时拒绝标记后附升权提示
17
+ */
18
+ export function renderResult(result, escalationModes = []) {
19
+ const out = streamText(result.stdout)
20
+ const err = streamText(result.stderr)
21
+ let body = out
22
+ if (err.length > 0) {
23
+ if (body.length > 0 && !body.endsWith('\n')) body += '\n'
24
+ body += `[stderr]\n${err}`
25
+ }
26
+ if (body.length === 0) body = '(no output)'
27
+ const markers = []
28
+ if (result.sandbox?.denied) {
29
+ markers.push(sandboxDenialMarker(result.sandbox.mode))
30
+ if (escalationModes.length > 0) markers.push(escalationHintMarker('command'))
31
+ }
32
+ if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
33
+ if (result.signal !== null) markers.push(`[killed by signal: ${result.signal}]`)
34
+ else if (result.exitCode !== 0) markers.push(`[exit code: ${result.exitCode}]`)
35
+ if (markers.length === 0) return body
36
+ if (!body.endsWith('\n')) body += '\n'
37
+ return body + markers.join('\n')
38
+ }
39
+
40
+ /**
41
+ * 后台进程一次增量读 → job_output 增量文本。
42
+ * @param {object} read ShellProcessRead
43
+ * @param {object|undefined} sandbox 已定进程的沙箱事实
44
+ * @param {string[]} escalationModes
45
+ */
46
+ export function renderProcessRead(read, sandbox, escalationModes = []) {
47
+ const notices = []
48
+ if (read.lossy) {
49
+ const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path) => path !== undefined)
50
+ notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`)
51
+ }
52
+ if (sandbox?.runnerFailed) notices.push('[sandbox: the sandbox runner itself failed under ' + sandbox.mode + ' mode — the command did not run; this is a sandbox problem, not a command failure]')
53
+ else if (sandbox?.denied) {
54
+ notices.push(sandboxDenialMarker(sandbox.mode))
55
+ if (escalationModes.length > 0) notices.push(escalationHintMarker('command'))
56
+ }
57
+ if (notices.length === 0) return read.delta
58
+ return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}`
59
+ }
@@ -0,0 +1,128 @@
1
+ // shell 可执行文件候选探测(纯函数):候选顺序、自动解析与批量探测。
2
+ // 官方 dsh-pwsh-local candidatePwshPaths 语义同构,扩展 bash/cmd/wsl 三类。
3
+ // 存在性判定与官方 lstat 语义一致(isFile || isSymbolicLink),由构造方注入。
4
+
5
+ import { lstatSync } from 'node:fs'
6
+
7
+ // PATH 分隔符:仅 Windows 分号(本包 v1 仅 win32 激活)
8
+ const PATH_SEP = ';'
9
+
10
+ // 各 kind 的探测锚点:主 ProgramFiles;x86 与 LOCALAPPDATA 仅 bash 有常见安装
11
+ const PF_RELATIVE = {
12
+ pwsh: [['PowerShell', '7', 'pwsh.exe']],
13
+ bash: [['Git', 'bin', 'bash.exe']],
14
+ }
15
+
16
+ const PF_X86_RELATIVE = {
17
+ bash: [['Git', 'bin', 'bash.exe']],
18
+ }
19
+
20
+ // LOCALAPPDATA 下的附加锚点(仅 bash 的 per-user Git 安装)
21
+ const LAD_RELATIVE = {
22
+ bash: [['Programs', 'Git', 'bin', 'bash.exe']],
23
+ }
24
+
25
+ // System32 单文件锚点
26
+ const SYSTEM32_FILES = {
27
+ pwsh: 'WindowsPowerShell\\v1.0\\powershell.exe',
28
+ cmd: 'cmd.exe',
29
+ wsl: 'wsl.exe',
30
+ }
31
+
32
+ // PATH 探测的可执行名
33
+ const PATH_EXECUTABLES = {
34
+ pwsh: 'pwsh.exe',
35
+ bash: 'bash.exe',
36
+ }
37
+
38
+ // MSYS2 默认安装锚点:真实 bash.exe 优先。msys2.exe(Cygwin 控制台启动器)
39
+ // 在管道 stdio 下 exit 0 且零输出——命令静默失败,任何情形不得进入候选
40
+ const MSYS2_ANCHORS = [
41
+ 'C:\\msys64\\usr\\bin\\bash.exe',
42
+ 'C:\\msys64\\bin\\bash.exe',
43
+ ]
44
+
45
+ /**
46
+ * 一个 kind 的候选路径,按解析顺序。显式参数化(env)保证纯函数性。
47
+ * @param {string} kind pwsh|bash|cmd|wsl
48
+ * @param {object} env 模拟进程环境
49
+ * @returns {string[]}
50
+ */
51
+ export function candidatePaths(kind, env) {
52
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
53
+ const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
54
+ const localAppData = env.LocalAppData ?? ''
55
+ const system32 = `${env.SystemRoot ?? 'C:\\WINDOWS'}\\System32`
56
+ const pathEntries = (env.PATH ?? '')
57
+ .split(PATH_SEP)
58
+ .map((entry) => entry.trim().replace(/^"|"$/g, ''))
59
+ .filter((entry) => entry.length > 0)
60
+ // bash 的 PATH 探测排除 SystemRoot 下条目:System32\bash.exe 是 WSL forwarder,
61
+ // 误命中会让 git-bash 客户端实际跑 WSL bash(方言/路径全变)。
62
+ // 斜杠形态归一后比较,正斜杠 PATH 条目(手动配置)同样命中;剥尾分隔符防前缀永不匹配
63
+ const systemRoot = String(env.SystemRoot ?? 'C:\\WINDOWS').toLowerCase().replace(/\//g, '\\').replace(/[\\/]+$/, '')
64
+ const pathEntriesForKind = (kind) => (kind === 'bash'
65
+ ? pathEntries.filter((entry) => !entry.toLowerCase().replace(/\//g, '\\').startsWith(`${systemRoot}\\`))
66
+ : pathEntries)
67
+ const candidates = []
68
+ for (const relative of PF_RELATIVE[kind] ?? []) candidates.push([programFiles, ...relative].join('\\'))
69
+ for (const relative of PF_X86_RELATIVE[kind] ?? []) candidates.push([programFilesX86, ...relative].join('\\'))
70
+ for (const relative of LAD_RELATIVE[kind] ?? []) {
71
+ if (localAppData.length > 0) candidates.push([localAppData, ...relative].join('\\'))
72
+ }
73
+ if (kind === 'bash') candidates.push(...MSYS2_ANCHORS)
74
+ for (const entry of pathEntriesForKind(kind)) {
75
+ const executable = PATH_EXECUTABLES[kind]
76
+ if (executable !== undefined) candidates.push(`${entry}\\${executable}`)
77
+ }
78
+ const system32File = SYSTEM32_FILES[kind]
79
+ if (system32File !== undefined) candidates.push(`${system32}\\${system32File}`)
80
+ return candidates
81
+ }
82
+
83
+ /**
84
+ * 解析一个 shell 条目的可执行路径:显式 path 原样信任;空 path 走候选探测。
85
+ * @param {{kind: string, path: string}} entry
86
+ * @param {(candidate: string) => boolean} exists 存在性判定(lstat 语义)
87
+ * @param {object} [env] 模拟进程环境,缺省用 process.env
88
+ * @returns {string|undefined} 无候选命中时 undefined
89
+ */
90
+ export function resolveEntryPath(entry, exists, env = process.env) {
91
+ if (entry.path.length > 0) return entry.path
92
+ for (const candidate of candidatePaths(entry.kind, env)) {
93
+ if (exists(candidate)) return candidate
94
+ }
95
+ return undefined
96
+ }
97
+
98
+ /**
99
+ * 批量探测:给定 kind 集合,返回全部命中候选(保持 kind 顺序与候选顺序,
100
+ * 同路径去重)。
101
+ * @param {string[]} kinds
102
+ * @param {object} env
103
+ * @param {(candidate: string) => boolean} exists
104
+ * @returns {{kind: string, path: string}[]}
105
+ */
106
+ export function detectCandidates(kinds, env, exists) {
107
+ const seen = new Set()
108
+ const found = []
109
+ for (const kind of kinds) {
110
+ for (const candidate of candidatePaths(kind, env)) {
111
+ if (seen.has(candidate)) continue
112
+ if (!exists(candidate)) continue
113
+ seen.add(candidate)
114
+ found.push({ kind, path: candidate })
115
+ }
116
+ }
117
+ return found
118
+ }
119
+
120
+ /** 生产存在性判定:官方 lstat 语义(isFile || isSymbolicLink,目录不匹配)。 */
121
+ export function candidateExists(candidate) {
122
+ try {
123
+ const stat = lstatSync(candidate)
124
+ return stat.isFile() || stat.isSymbolicLink()
125
+ } catch {
126
+ return false
127
+ }
128
+ }
@@ -0,0 +1,82 @@
1
+ // 沙箱结果分类助手:官方 dsh-pwsh-sandbox helpers 逐项镜像(call-for-call),
2
+ // 拒绝/runner 失败判据必须与官方执行器族完全一致,工具层的沙箱渲染才成立。
3
+
4
+ import { accessSync, constants, statSync } from 'node:fs'
5
+
6
+ // Node 本地 spawn 码中可证实 runner 可执行文件解析/权限失败的两类
7
+ const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT'])
8
+
9
+ /** 调用方 spawn cwd 可进入。 */
10
+ export function isUsableWorkdir(path) {
11
+ try {
12
+ if (!statSync(path).isDirectory()) return false
13
+ accessSync(path, constants.X_OK)
14
+ return true
15
+ } catch {
16
+ return false
17
+ }
18
+ }
19
+
20
+ /**
21
+ * 仅在排除 cwd 因素后,凭 argv[0] 溯源把 ENOENT/EACCES 归为 runner 自身失败。
22
+ * @param {unknown} error spawn 拒绝原因
23
+ * @param {string|undefined} runnerProgram provider argv[0]
24
+ * @param {string} workdir 调用方 cwd
25
+ */
26
+ export function isRunnerSpawnFailure(error, runnerProgram, workdir) {
27
+ if (runnerProgram === undefined || !isUsableWorkdir(workdir)) return false
28
+ if (typeof error !== 'object' || error === null) return false
29
+ const { code, path, syscall } = error
30
+ if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code)) return false
31
+ if (typeof syscall !== 'string') return false
32
+ const exactSyscall = `spawn ${runnerProgram}`
33
+ if (path === undefined) return syscall === exactSyscall
34
+ if (typeof path !== 'string' || path.length === 0 || path !== runnerProgram) return false
35
+ return syscall === 'spawn' || syscall === exactSyscall
36
+ }
37
+
38
+ /**
39
+ * 按所选 runner 的拒绝方言分类失败运行。
40
+ * @param {{exitCode: number|null, stderr: {text: string}}} result
41
+ * @param {string[]} signatures 拒绝签名(大小写不敏感子串)
42
+ */
43
+ export function classifyDenial(result, signatures) {
44
+ return matchesSignature(result.exitCode, result.stderr.text, signatures)
45
+ }
46
+
47
+ /**
48
+ * 结构化 runner 失败规则匹配:非零退出 + 可选允许码 + 例外信息行后的致命行。
49
+ * @param {number|null} exitCode
50
+ * @param {string} stderr
51
+ * @param {Array<{allowedExitCodes?: number[], informationalLines?: string[], fatalSignatures: string[]}>} rules
52
+ * @returns {{detail: string}|undefined}
53
+ */
54
+ export function classifyRunnerFailure(exitCode, stderr, rules) {
55
+ if (exitCode === null || exitCode === 0) return undefined
56
+ const lines = stderr.split(/\r?\n/)
57
+ for (const rule of rules) {
58
+ if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
59
+ const informationalLines = new Set((rule.informationalLines ?? []).map((line) => line.toLowerCase()))
60
+ const fatalSignatures = rule.fatalSignatures
61
+ .filter((signature) => signature.trim().length > 0)
62
+ .map((signature) => signature.toLowerCase())
63
+ for (const line of lines) {
64
+ const lowered = line.toLowerCase()
65
+ if (informationalLines.has(lowered)) continue
66
+ if (fatalSignatures.some((signature) => lowered.includes(signature))) return { detail: line }
67
+ }
68
+ }
69
+ return undefined
70
+ }
71
+
72
+ /**
73
+ * 非零退出 + stderr 命中任一签名(大小写不敏感)。
74
+ * @param {number|null} exitCode
75
+ * @param {string} stderr
76
+ * @param {string[]} signatures
77
+ */
78
+ export function matchesSignature(exitCode, stderr, signatures) {
79
+ if (exitCode === null || exitCode === 0) return false
80
+ const lowered = stderr.toLowerCase()
81
+ return signatures.some((signature) => lowered.includes(signature.toLowerCase()))
82
+ }