@mzzsfy/dsh-shell-select 0.4.0 → 0.4.3
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 +26 -11
- package/cordis.patch.yml +3 -189
- package/package.json +10 -4
- package/src/api.mjs +7 -1
- package/src/client.js +177 -450
- package/src/config.mjs +66 -0
- package/src/executor.mjs +43 -12
- package/src/index.js +7 -1
- package/src/prompt-projection.mjs +34 -0
- package/src/pwsh-takeover.mjs +23 -0
- package/src/render.mjs +5 -1
- package/src/resolve.mjs +35 -23
- package/src/tool.mjs +51 -28
- package/src/web-routes.mjs +7 -2
- package/test/background-owner.test.mjs +14 -0
- package/test/client-card.test.mjs +153 -17
- package/test/client-id.test.mjs +0 -56
- package/test/client-render.test.mjs +38 -13
- package/test/config-entry.test.mjs +2 -1
- package/test/config.test.mjs +41 -3
- package/test/deny-output-schema.test.mjs +53 -0
- package/test/executor-seam.test.mjs +2 -1
- package/test/executor.test.mjs +96 -2
- package/test/official-contract.test.mjs +208 -0
- package/test/path-guidance.test.mjs +28 -0
- package/test/path-rescue.test.mjs +173 -0
- package/test/preset-drift.test.mjs +30 -139
- package/test/prompt-projection.test.mjs +120 -0
- package/test/pwsh-takeover.test.mjs +41 -0
- package/test/render.test.mjs +39 -0
- package/test/resolve.test.mjs +43 -13
- package/test/web-routes.test.mjs +15 -2
- package/test/client-drift.test.mjs +0 -27
- package/test/switch-guard.test.mjs +0 -37
package/src/config.mjs
CHANGED
|
@@ -83,6 +83,72 @@ export function resolveConfig(entry) {
|
|
|
83
83
|
return unwrapConfig(Config(structuredClone(sanitized)))
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** 执行面严格解析:输入键被类型对账丢弃时抛。执行面(挂载/refresh)输入是
|
|
87
|
+
* 宿主刚 resolve 的产物,形态非法 = 宿主契约变化或调用方未解箱,必须炸在
|
|
88
|
+
* 挂载点(接管序列有回滚告警)而非静默回落出厂默认——静默曾让行 config
|
|
89
|
+
* 长期失效(0.2.0-rc.2 真机实证)。持久化读回路径(落盘写坏降级、行不死
|
|
90
|
+
* 可保存修复)仍用 resolveConfig 的宽容语义。 */
|
|
91
|
+
export function resolveConfigStrict(entry) {
|
|
92
|
+
const input = entry ?? {}
|
|
93
|
+
const sanitized = sanitizeConfigEntry(input)
|
|
94
|
+
const dropped = Object.keys(FIELD_TYPES)
|
|
95
|
+
.filter((key) => input[key] !== undefined && !(key in sanitized))
|
|
96
|
+
if (dropped.length > 0) {
|
|
97
|
+
throw new Error(`shell-select: config 字段形态非法(执行面输入须先解箱为普通对象): ${dropped.join(', ')}`)
|
|
98
|
+
}
|
|
99
|
+
return unwrapConfig(Config(structuredClone(sanitized)))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 用户声明值挂载前 coercing:类型清洗(sanitize)+ 语义兜底,任意用户输入
|
|
103
|
+
* 产出可服务配置——插件本体不因配置死(行活、工具活,坏字段告警后降默认)。
|
|
104
|
+
* 语义兜底:shells 清洗后为空(含条目缺 id/kind 或 kind 非法被剔除)补出厂
|
|
105
|
+
* 默认;default 不在 shells id 集归 shells[0];预算数字非正数或超 graceMs
|
|
106
|
+
* 上限降 schema 默认;deny 剔除非字符串与不可编译条目。 */
|
|
107
|
+
export function coerceServiceable(entry) {
|
|
108
|
+
const warnings = []
|
|
109
|
+
const cleaned = sanitizeConfigEntry(entry ?? {})
|
|
110
|
+
const rawShells = Array.isArray(cleaned.shells) ? cleaned.shells : []
|
|
111
|
+
const usable = rawShells.filter((item) => item !== null && typeof item === 'object'
|
|
112
|
+
&& typeof item.id === 'string' && item.id !== ''
|
|
113
|
+
&& typeof item.kind === 'string' && KINDS.includes(item.kind))
|
|
114
|
+
if (usable.length !== rawShells.length) warnings.push(`shells 剔除 ${rawShells.length - usable.length} 个缺 id/kind 或 kind 非法的条目`)
|
|
115
|
+
const shells = usable.length > 0 ? usable : defaultConfig().shells
|
|
116
|
+
if (usable.length === 0) warnings.push('shells 为空或全部非法,回落出厂默认客户端清单')
|
|
117
|
+
let defaultId = cleaned.default
|
|
118
|
+
if (typeof defaultId !== 'string' || !shells.some((item) => item.id === defaultId)) {
|
|
119
|
+
warnings.push(`default ${JSON.stringify(defaultId)} 不在 shells 清单,归 shells[0]`)
|
|
120
|
+
defaultId = shells[0].id
|
|
121
|
+
}
|
|
122
|
+
const rawDeny = Array.isArray(cleaned.deny) ? cleaned.deny : undefined
|
|
123
|
+
const deny = rawDeny?.filter((pattern) => {
|
|
124
|
+
if (typeof pattern !== 'string' || pattern === '') return false
|
|
125
|
+
try {
|
|
126
|
+
new RegExp(pattern, 'i')
|
|
127
|
+
return true
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
if (rawDeny !== undefined && deny.length !== rawDeny.length) warnings.push(`deny 剔除 ${rawDeny.length - deny.length} 个非字符串或不可编译正则的条目`)
|
|
133
|
+
const budgets = {}
|
|
134
|
+
for (const key of ['timeoutMs', 'maxTimeoutMs', 'maxOutputBytes', 'maxSpillBytes', 'graceMs']) {
|
|
135
|
+
const value = cleaned[key]
|
|
136
|
+
if (value === undefined) continue
|
|
137
|
+
const overCeiling = key === 'graceMs' && value > MAX_TIMER_DELAY_MS
|
|
138
|
+
if (!Number.isFinite(value) || value <= 0 || overCeiling) {
|
|
139
|
+
warnings.push(`${key} 必须为正有限数${key === 'graceMs' ? `且不大于 ${MAX_TIMER_DELAY_MS}` : ''},降 schema 默认`)
|
|
140
|
+
// 显式置空覆盖 cleaned 原值,交 schema 默认兜底
|
|
141
|
+
budgets[key] = undefined
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
budgets[key] = value
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
config: { ...cleaned, shells, default: defaultId, deny, ...budgets },
|
|
148
|
+
warnings,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
86
152
|
// 落盘行 config 的类型防御:设置写路径(原生页/mutate)可能把 volatile 字段
|
|
87
153
|
// 以非预期形态(ref 序列化产物 {})持久化,冷启动 Config 直接抛 → 行死 →
|
|
88
154
|
// settings describe 无此节 → 设置页保存 "no longer configurable" 死锁,
|
package/src/executor.mjs
CHANGED
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
|
|
16
16
|
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
|
17
|
-
import { Config, KINDS, buildArgv, requireEntry, resolveConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
|
|
17
|
+
import { Config, KINDS, buildArgv, requireEntry, resolveConfig, resolveConfigStrict, unwrapConfig, assertServiceableConfig, normalizeWin32Path } from './config.mjs'
|
|
18
18
|
import { matchDeny } from './denylist.mjs'
|
|
19
19
|
import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
|
|
20
20
|
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from './sandbox-classify.mjs'
|
|
21
21
|
import { registerShellTool } from './tool.mjs'
|
|
22
|
+
import { mountShellPromptProjection } from './prompt-projection.mjs'
|
|
23
|
+
import { mountPwshTakeover } from './pwsh-takeover.mjs'
|
|
22
24
|
|
|
23
25
|
export const name = 'shell-select'
|
|
24
26
|
|
|
@@ -137,8 +139,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
137
139
|
void loadSandboxUnavailable(ctx).then((resolved) => {
|
|
138
140
|
this.unavailableError = resolved
|
|
139
141
|
})
|
|
140
|
-
this.#
|
|
141
|
-
assertServiceableConfig(resolveConfig(this.#entry))
|
|
142
|
+
this.#setEntry(config)
|
|
142
143
|
this.source = () => resolveConfig(this.#entry)
|
|
143
144
|
// refresh 桥绑定随本 fiber 卸载自动解绑(实例退场后写路径请求静默丢弃)
|
|
144
145
|
ctx.effect(() => bindShellRefreshBridge((next) => this.refresh(next)))
|
|
@@ -150,13 +151,23 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
150
151
|
})
|
|
151
152
|
this.#registerTool()
|
|
152
153
|
this.#mountPromptSection()
|
|
154
|
+
mountShellPromptProjection(ctx)
|
|
155
|
+
this.markPwshTakeover = mountPwshTakeover(ctx)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 挂载/换源共用落定:入参经宿主按 static Config resolve,volatile 字段是
|
|
159
|
+
* boxed ref,存储前解箱成普通对象;落定校验走 strict——解箱后仍存在非法
|
|
160
|
+
* 形态字段即抛(宿主契约变化炸在挂载点,接管序列回滚告警),禁止静默回落
|
|
161
|
+
* 出厂默认(行 config 永不生效缺陷的机械化防线)。 */
|
|
162
|
+
#setEntry(config) {
|
|
163
|
+
this.#entry = unwrapConfig(config ?? {})
|
|
164
|
+
assertServiceableConfig(resolveConfigStrict(this.#entry))
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
/** volatile-only 配置变更的活刷新:换配置源并重建工具注册(写路径经
|
|
156
168
|
* requestShellRefresh 触达;行不重挂,本实例与进行中执行不受扰)。 */
|
|
157
169
|
refresh(config) {
|
|
158
|
-
this.#
|
|
159
|
-
assertServiceableConfig(resolveConfig(this.#entry))
|
|
170
|
+
this.#setEntry(config)
|
|
160
171
|
this.#registerTool()
|
|
161
172
|
}
|
|
162
173
|
|
|
@@ -219,6 +230,21 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
219
230
|
return { id: entry.id, kind: entry.kind, path: resolved, args: entry.args, login: entry.login === true, distro: entry.distro ?? '', env: entry.env ?? {} }
|
|
220
231
|
}
|
|
221
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 官方契约的 pwsh 落点:配置含 pwsh 条目时走 entryFor;条目被删除或不可解析时
|
|
235
|
+
* 仍按候选探测现场解析一台 pwsh(官方 dsh-pwsh-local 语义——pwsh 调用永远
|
|
236
|
+
* PowerShell,不落默认客户端,也不得以配置缺失为由改方言)。探测全失时才抛。
|
|
237
|
+
*/
|
|
238
|
+
contractEntry() {
|
|
239
|
+
try {
|
|
240
|
+
return this.entryFor('pwsh')
|
|
241
|
+
} catch {
|
|
242
|
+
const [detected] = detectCandidates(['pwsh'], process.env, candidateExists)
|
|
243
|
+
if (detected === undefined) throw new Error('no pwsh executable on this machine: the official pwsh contract requires PowerShell (pwsh 7 or Windows PowerShell); reinstall PowerShell or add a pwsh entry in the shell-select settings section')
|
|
244
|
+
return { id: 'pwsh', kind: 'pwsh', path: normalizeWin32Path(detected.path), args: [], login: false, distro: '', env: {} }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
222
248
|
/** 注册/重注册 shell 工具(描述随客户端清单变化)。 */
|
|
223
249
|
#registerTool() {
|
|
224
250
|
this.#toolRegistration?.()
|
|
@@ -229,7 +255,7 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
229
255
|
this.ctx.systemPrompt.section({
|
|
230
256
|
name: 'tool:shell',
|
|
231
257
|
order: this.ctx.systemPrompt.getSectionOrder('TOOL_PWSH'),
|
|
232
|
-
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.',
|
|
258
|
+
text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. 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.',
|
|
233
259
|
})
|
|
234
260
|
}
|
|
235
261
|
|
|
@@ -257,14 +283,17 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
257
283
|
return buildArgv(entry, spec.command)
|
|
258
284
|
}
|
|
259
285
|
|
|
260
|
-
/** 官方 execute seam
|
|
286
|
+
/** 官方 execute seam:pwsh 客户端前台执行(dsh-pwsh-local run 同构,契约钉死 pwsh)。 */
|
|
261
287
|
async run(spec) {
|
|
262
|
-
|
|
288
|
+
this.markPwshTakeover()
|
|
289
|
+
return this.runFor(this.contractEntry(), spec)
|
|
263
290
|
}
|
|
264
291
|
|
|
265
292
|
/**
|
|
266
293
|
* 官方 ctx.shell 唯一执行契约(ShellExecutor 抽象方法,官方 tool-pwsh 与
|
|
267
|
-
* 进程内消费方共同依赖):解析 spec
|
|
294
|
+
* 进程内消费方共同依赖):解析 spec 后按 pwsh 客户端 spawn(官方 dsh-pwsh-local
|
|
295
|
+
* 契约即 PowerShell 专用,落默认客户端会随配置漂移炸方言),返回
|
|
296
|
+
* ShellExecution
|
|
268
297
|
* 句柄(done/readOutput/kill/observed/result)。前台与否是调用方 await 什么的
|
|
269
298
|
* 属性,不是 spawn 的属性。沙箱语义与 runFor 同源:danger 直跑,受限模式
|
|
270
299
|
* confine 包装,定案按 denial/runner-failure 规则附 sandbox 事实。
|
|
@@ -272,8 +301,9 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
272
301
|
* @returns {Promise<object>} ShellExecution 句柄
|
|
273
302
|
*/
|
|
274
303
|
async execute(spec) {
|
|
304
|
+
this.markPwshTakeover()
|
|
275
305
|
this.assertNotDenied(spec.command)
|
|
276
|
-
const entry = this.
|
|
306
|
+
const entry = this.contractEntry()
|
|
277
307
|
const policy = spec.sandboxPolicy
|
|
278
308
|
const { mode } = policy
|
|
279
309
|
if (mode === 'danger-full-access') {
|
|
@@ -413,9 +443,10 @@ export const ShellSelectExecutor = class ShellSelectExecutor extends ShellExecut
|
|
|
413
443
|
return Promise.resolve(proc)
|
|
414
444
|
}
|
|
415
445
|
|
|
416
|
-
/** 官方 start seam
|
|
446
|
+
/** 官方 start seam:pwsh 客户端后台启动(dsh-pwsh-local start 同构,契约钉死 pwsh)。 */
|
|
417
447
|
start(spec) {
|
|
418
|
-
|
|
448
|
+
this.markPwshTakeover()
|
|
449
|
+
return this.startFor(this.contractEntry(), spec)
|
|
419
450
|
}
|
|
420
451
|
|
|
421
452
|
/** 组装一次 spawn 的完整规格(官方 spawnSpec 同构,argv/条目参数化)。 */
|
package/src/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
runtimeDisableRows,
|
|
32
32
|
takeoverDecision,
|
|
33
33
|
} from './takeover.mjs'
|
|
34
|
+
import { coerceServiceable } from './config.mjs'
|
|
34
35
|
|
|
35
36
|
export const name = 'shell-select'
|
|
36
37
|
|
|
@@ -114,7 +115,12 @@ function unwrapConfigSnapshot(config) {
|
|
|
114
115
|
* 快照经 ctx.shell.refresh 换源并重建工具。
|
|
115
116
|
*/
|
|
116
117
|
async function mountExecutor(ctx, config) {
|
|
117
|
-
|
|
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)
|
|
118
124
|
// await 返回 ≠ 激活:executor inject 的宿主服务缺失时 fiber 静默 pending
|
|
119
125
|
// (不 provide 不报错),ctx.shell 空缺会被官方复活行补位——挂载必须核实
|
|
120
126
|
// 激活态,未激活按失败处理(触发回滚,官方接管)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 只收敛模型投影,不改工具注册或执行策略;旧调用保持原 PowerShell 契约。
|
|
2
|
+
export function mountShellPromptProjection(ctx) {
|
|
3
|
+
if (typeof ctx.on !== 'function') return
|
|
4
|
+
let warned = false
|
|
5
|
+
return ctx.on('system-prompt/assemble', async (assembly, context, next) => {
|
|
6
|
+
const result = await next()
|
|
7
|
+
if (ctx.tools.get('shell', context.scope) === undefined) return result
|
|
8
|
+
let sections = result.sections
|
|
9
|
+
const sdk = sections.find((section) => section.name === 'tools:sdk' && section.text.trim() !== '')
|
|
10
|
+
if (sdk !== undefined) {
|
|
11
|
+
try {
|
|
12
|
+
const { renderToolsSdk, renderToolsSdkPy } = await import('@deepseek-ai/dsh-tools')
|
|
13
|
+
const render = { typescript: renderToolsSdk, python: renderToolsSdkPy }[ctx.get('ptcRuntime')?.language]
|
|
14
|
+
if (typeof render !== 'function') throw new Error('PTC SDK renderer is unavailable')
|
|
15
|
+
const schemas = ctx.tools.schemas(context.scope)
|
|
16
|
+
.filter((schema) => schema.name !== 'pwsh' && schema.name !== 'run_code')
|
|
17
|
+
.map((schema) => ({ ...schema, output: ctx.tools.get(schema.name, context.scope).output.schema }))
|
|
18
|
+
const text = render(schemas)
|
|
19
|
+
sections = sections.map((section) => section === sdk ? { ...section, text, interpolate: false } : section)
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!warned) {
|
|
22
|
+
warned = true
|
|
23
|
+
ctx.logger.warn(`shell-select: 模型工具投影未启用,保留原始工具与SDK:${error.message}`)
|
|
24
|
+
}
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...result,
|
|
30
|
+
tools: result.tools.filter((tool) => tool.name !== 'pwsh'),
|
|
31
|
+
sections: sections.filter((section) => section.name !== 'tool:pwsh'),
|
|
32
|
+
}
|
|
33
|
+
}, { prepend: true })
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
export const PWSH_TAKEOVER_BLOCK = Object.freeze({ type: 'shell-select-takeover', tool: 'pwsh' })
|
|
4
|
+
|
|
5
|
+
// 调用级事实独立于stdout,持久化扩展块不会进入模型文本。
|
|
6
|
+
export function mountPwshTakeover(ctx) {
|
|
7
|
+
const calls = new AsyncLocalStorage()
|
|
8
|
+
const taken = new Set()
|
|
9
|
+
ctx.effect(() => () => { calls.disable(); taken.clear() })
|
|
10
|
+
ctx.on('tools/execute', (exec, next) => exec.name === 'pwsh'
|
|
11
|
+
? calls.run(exec.token, next)
|
|
12
|
+
: next())
|
|
13
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
14
|
+
const intercepted = taken.delete(exec.token)
|
|
15
|
+
const decision = await next()
|
|
16
|
+
if (!intercepted || decision.kind !== 'accept' || Object.hasOwn(decision, 'content') || Object.hasOwn(decision, 'value')) return decision
|
|
17
|
+
return { ...decision, content: [...result.content, PWSH_TAKEOVER_BLOCK] }
|
|
18
|
+
}, { prepend: true })
|
|
19
|
+
return () => {
|
|
20
|
+
const token = calls.getStore()
|
|
21
|
+
if (token !== undefined) taken.add(token)
|
|
22
|
+
}
|
|
23
|
+
}
|
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'))
|
package/src/resolve.mjs
CHANGED
|
@@ -4,8 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { lstatSync } from 'node:fs'
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
const
|
|
7
|
+
// POSIX pwsh 探测锚点:发行版包管理器与官方 tarball/Microsoft 源的固定落点
|
|
8
|
+
const POSIX_PWSH_ANCHORS = [
|
|
9
|
+
'/usr/bin/pwsh',
|
|
10
|
+
'/usr/local/bin/pwsh',
|
|
11
|
+
'/opt/microsoft/powershell/7/pwsh',
|
|
12
|
+
'/opt/homebrew/bin/pwsh',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
// PATH 探测的可执行名:win32 带 .exe 后缀,POSIX 裸名
|
|
16
|
+
const executableName = (kind, platform) => {
|
|
17
|
+
const suffix = platform === 'win32' ? '.exe' : ''
|
|
18
|
+
return { pwsh: `pwsh${suffix}`, bash: `bash${suffix}` }[kind]
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
// 各 kind 的探测锚点:主 ProgramFiles;x86 与 LOCALAPPDATA 仅 bash 有常见安装
|
|
11
22
|
const PF_RELATIVE = {
|
|
@@ -29,12 +40,6 @@ const SYSTEM32_FILES = {
|
|
|
29
40
|
wsl: 'wsl.exe',
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
// PATH 探测的可执行名
|
|
33
|
-
const PATH_EXECUTABLES = {
|
|
34
|
-
pwsh: 'pwsh.exe',
|
|
35
|
-
bash: 'bash.exe',
|
|
36
|
-
}
|
|
37
|
-
|
|
38
43
|
// MSYS2 默认安装锚点:真实 bash.exe 优先。msys2.exe(Cygwin 控制台启动器)
|
|
39
44
|
// 在管道 stdio 下 exit 0 且零输出——命令静默失败,任何情形不得进入候选
|
|
40
45
|
const MSYS2_ANCHORS = [
|
|
@@ -43,18 +48,22 @@ const MSYS2_ANCHORS = [
|
|
|
43
48
|
]
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
* 一个 kind 的候选路径,按解析顺序。显式参数化(env)保证纯函数性。
|
|
51
|
+
* 一个 kind 的候选路径,按解析顺序。显式参数化(env/platform)保证纯函数性。
|
|
47
52
|
* @param {string} kind pwsh|bash|cmd|wsl
|
|
48
53
|
* @param {object} env 模拟进程环境
|
|
54
|
+
* @param {string} [platform] 目标平台,缺省 process.platform
|
|
49
55
|
* @returns {string[]}
|
|
50
56
|
*/
|
|
51
|
-
export function candidatePaths(kind, env) {
|
|
57
|
+
export function candidatePaths(kind, env, platform = process.platform) {
|
|
58
|
+
const isWin32 = platform === 'win32'
|
|
59
|
+
const pathSep = isWin32 ? ';' : ':'
|
|
60
|
+
const fileSep = isWin32 ? '\\' : '/'
|
|
52
61
|
const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
|
|
53
62
|
const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
54
63
|
const localAppData = env.LocalAppData ?? ''
|
|
55
64
|
const system32 = `${env.SystemRoot ?? 'C:\\WINDOWS'}\\System32`
|
|
56
65
|
const pathEntries = (env.PATH ?? '')
|
|
57
|
-
.split(
|
|
66
|
+
.split(pathSep)
|
|
58
67
|
.map((entry) => entry.trim().replace(/^"|"$/g, ''))
|
|
59
68
|
.filter((entry) => entry.length > 0)
|
|
60
69
|
// bash 的 PATH 探测排除 SystemRoot 下条目:System32\bash.exe 是 WSL forwarder,
|
|
@@ -65,18 +74,21 @@ export function candidatePaths(kind, env) {
|
|
|
65
74
|
? pathEntries.filter((entry) => !entry.toLowerCase().replace(/\//g, '\\').startsWith(`${systemRoot}\\`))
|
|
66
75
|
: pathEntries)
|
|
67
76
|
const candidates = []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
if (isWin32) {
|
|
78
|
+
for (const relative of PF_RELATIVE[kind] ?? []) candidates.push([programFiles, ...relative].join('\\'))
|
|
79
|
+
for (const relative of PF_X86_RELATIVE[kind] ?? []) candidates.push([programFilesX86, ...relative].join('\\'))
|
|
80
|
+
for (const relative of LAD_RELATIVE[kind] ?? []) {
|
|
81
|
+
if (localAppData.length > 0) candidates.push([localAppData, ...relative].join('\\'))
|
|
82
|
+
}
|
|
72
83
|
}
|
|
73
|
-
if (kind === 'bash') candidates.push(...MSYS2_ANCHORS)
|
|
84
|
+
if (isWin32 && kind === 'bash') candidates.push(...MSYS2_ANCHORS)
|
|
85
|
+
if (!isWin32 && kind === 'pwsh') candidates.push(...POSIX_PWSH_ANCHORS)
|
|
74
86
|
for (const entry of pathEntriesForKind(kind)) {
|
|
75
|
-
const executable =
|
|
76
|
-
if (executable !== undefined) candidates.push(`${entry}
|
|
87
|
+
const executable = executableName(kind, platform)
|
|
88
|
+
if (executable !== undefined) candidates.push(`${entry}${fileSep}${executable}`)
|
|
77
89
|
}
|
|
78
90
|
const system32File = SYSTEM32_FILES[kind]
|
|
79
|
-
if (system32File !== undefined) candidates.push(`${system32}\\${system32File}`)
|
|
91
|
+
if (isWin32 && system32File !== undefined) candidates.push(`${system32}\\${system32File}`)
|
|
80
92
|
return candidates
|
|
81
93
|
}
|
|
82
94
|
|
|
@@ -87,9 +99,9 @@ export function candidatePaths(kind, env) {
|
|
|
87
99
|
* @param {object} [env] 模拟进程环境,缺省用 process.env
|
|
88
100
|
* @returns {string|undefined} 无候选命中时 undefined
|
|
89
101
|
*/
|
|
90
|
-
export function resolveEntryPath(entry, exists, env = process.env) {
|
|
102
|
+
export function resolveEntryPath(entry, exists, env = process.env, platform = process.platform) {
|
|
91
103
|
if (entry.path.length > 0) return entry.path
|
|
92
|
-
for (const candidate of candidatePaths(entry.kind, env)) {
|
|
104
|
+
for (const candidate of candidatePaths(entry.kind, env, platform)) {
|
|
93
105
|
if (exists(candidate)) return candidate
|
|
94
106
|
}
|
|
95
107
|
return undefined
|
|
@@ -103,11 +115,11 @@ export function resolveEntryPath(entry, exists, env = process.env) {
|
|
|
103
115
|
* @param {(candidate: string) => boolean} exists
|
|
104
116
|
* @returns {{kind: string, path: string}[]}
|
|
105
117
|
*/
|
|
106
|
-
export function detectCandidates(kinds, env, exists) {
|
|
118
|
+
export function detectCandidates(kinds, env, exists, platform = process.platform) {
|
|
107
119
|
const seen = new Set()
|
|
108
120
|
const found = []
|
|
109
121
|
for (const kind of kinds) {
|
|
110
|
-
for (const candidate of candidatePaths(kind, env)) {
|
|
122
|
+
for (const candidate of candidatePaths(kind, env, platform)) {
|
|
111
123
|
if (seen.has(candidate)) continue
|
|
112
124
|
if (!exists(candidate)) continue
|
|
113
125
|
seen.add(candidate)
|
package/src/tool.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { TOOL_ABORTED, defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
12
12
|
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
|
13
13
|
import { isAbsolute, resolve } from 'node:path'
|
|
14
14
|
import { renderResult, renderProcessRead } from './render.mjs'
|
|
15
|
+
import { PWSH_TAKEOVER_BLOCK } from './pwsh-takeover.mjs'
|
|
15
16
|
import { parseExitStatus } from '@deepseek-ai/dsh-shell'
|
|
16
17
|
|
|
17
18
|
/** 拒升权/Abort 错误构造:dsh-llm HarnessError 缺失时以同码 Error 降级(错误码通道不变)。 */
|
|
@@ -40,6 +41,25 @@ function blockedMarker(error) {
|
|
|
40
41
|
return `[blocked by shell-select: matches deny pattern ${error.pattern}]`
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* deny 命中 → schema 合法的前台结果:拒绝事实全在 stderr 标记文本,
|
|
46
|
+
* 禁止携带 blocked/blockedBy 等声明外字段——输出 oneOf 各分支
|
|
47
|
+
* additionalProperties:false,违者被宿主判 INVALID_TOOL_OUTPUT,拒绝文案不可达。
|
|
48
|
+
*/
|
|
49
|
+
function deniedForeground(error, shellId) {
|
|
50
|
+
return {
|
|
51
|
+
kind: 'foreground',
|
|
52
|
+
exitCode: null,
|
|
53
|
+
signal: null,
|
|
54
|
+
timedOut: false,
|
|
55
|
+
aborted: false,
|
|
56
|
+
timeoutMs: 0,
|
|
57
|
+
stdout: { text: '', truncated: false },
|
|
58
|
+
stderr: { text: blockedMarker(error), truncated: false },
|
|
59
|
+
shell: shellId,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/** 显式 workdir 先行,相对者落会话工作区;否则用会话 cwd,执行器默认兜底(官方同构)。 */
|
|
44
64
|
function resolveWorkdir(modelWorkdir, exec) {
|
|
45
65
|
const headerCwd = exec.agent?.session.header.cwd
|
|
@@ -56,8 +76,8 @@ function processOutcome(proc) {
|
|
|
56
76
|
return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` }
|
|
57
77
|
}
|
|
58
78
|
|
|
59
|
-
/** 前台结果 DTO 去只读化(官方 canonical 同构)
|
|
60
|
-
function canonicalResult(result) {
|
|
79
|
+
/** 前台结果 DTO 去只读化(官方 canonical 同构);shellId 为执行事实,随块落存储供卡片徽章回放。 */
|
|
80
|
+
function canonicalResult(result, shellId) {
|
|
61
81
|
const output = (stream) => ({
|
|
62
82
|
text: stream.text,
|
|
63
83
|
truncated: stream.truncated,
|
|
@@ -72,6 +92,7 @@ function canonicalResult(result) {
|
|
|
72
92
|
timeoutMs: result.timeoutMs,
|
|
73
93
|
stdout: output(result.stdout),
|
|
74
94
|
stderr: output(result.stderr),
|
|
95
|
+
...shellId !== undefined ? { shell: shellId } : {},
|
|
75
96
|
...result.sandbox !== undefined ? { sandbox: {
|
|
76
97
|
mode: result.sandbox.mode,
|
|
77
98
|
denied: result.sandbox.denied,
|
|
@@ -81,10 +102,11 @@ function canonicalResult(result) {
|
|
|
81
102
|
}
|
|
82
103
|
}
|
|
83
104
|
|
|
84
|
-
/** 后台输出并集公共键(官方同构)
|
|
105
|
+
/** 后台输出并集公共键(官方同构);shell 同前台,执行事实随 DTO 供渲染。 */
|
|
85
106
|
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
86
107
|
kind: { type: 'string', required: true, const: 'background' },
|
|
87
108
|
jobId: { type: 'string', required: true },
|
|
109
|
+
shell: { type: 'string' },
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/** 前台输出分支(官方同构,逐字段)。 */
|
|
@@ -108,6 +130,7 @@ function foregroundOutputProperties() {
|
|
|
108
130
|
timeoutMs: { type: 'number', required: true },
|
|
109
131
|
stdout: streamSchema,
|
|
110
132
|
stderr: streamSchema,
|
|
133
|
+
shell: { type: 'string' },
|
|
111
134
|
sandbox: {
|
|
112
135
|
type: 'object',
|
|
113
136
|
additionalProperties: false,
|
|
@@ -121,8 +144,11 @@ function foregroundOutputProperties() {
|
|
|
121
144
|
}
|
|
122
145
|
}
|
|
123
146
|
|
|
147
|
+
/** bash 路径措辞:bash 输出的 MSYS 拼写不得回流进工具参数(宿主文件面只认 Windows 形式)。 */
|
|
148
|
+
const BASH_PATH_GUIDANCE = 'Bash clients accept Windows-style paths (C:/...): use that form for `workdir` and file-tool arguments — MSYS spellings like `/c/Users/...` or `/tmp` only work inside the bash command itself, never in tool arguments.'
|
|
149
|
+
|
|
124
150
|
/** 工具描述:客户端清单与方言随配置动态变化。 */
|
|
125
|
-
function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
151
|
+
export function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
126
152
|
const listing = executor.listShells()
|
|
127
153
|
const lines = listing.shells
|
|
128
154
|
.map((entry) => `${entry.id} (${entry.kind}${entry.available ? '' : ', executable not found'})`)
|
|
@@ -135,22 +161,15 @@ function shellDescription({ executor, backgroundEnabled, escalationModes }) {
|
|
|
135
161
|
+ (backgroundEnabled
|
|
136
162
|
? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
|
|
137
163
|
: 'Background execution is not available; long-running commands must finish within the timeout.')
|
|
138
|
-
|
|
139
|
-
|
|
164
|
+
const bashPresent = listing.shells.some((candidate) => candidate.kind === 'bash' && candidate.available)
|
|
165
|
+
const text = bashPresent ? `${base} ${BASH_PATH_GUIDANCE}` : base
|
|
166
|
+
if (escalationModes.length === 0) return text
|
|
167
|
+
return text + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
|
|
140
168
|
}
|
|
141
169
|
|
|
142
|
-
/** pwsh
|
|
143
|
-
function pwshDescription(
|
|
144
|
-
|
|
145
|
-
+ 'It executes a PowerShell command and returns its stdout/stderr. '
|
|
146
|
-
+ 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. '
|
|
147
|
-
+ 'Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed environment variables (`DSH_*`); inspect them when needed. '
|
|
148
|
-
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
|
|
149
|
-
+ (backgroundEnabled
|
|
150
|
-
? 'Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.'
|
|
151
|
-
: 'Background execution is not available; long-running commands must finish within the timeout.')
|
|
152
|
-
if (escalationModes.length === 0) return base
|
|
153
|
-
return base + ' Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Never escalate speculatively: ground the request in a real denial. If the session states approval prompts are disabled, a denial is final — do not set `sandbox_permissions`.'
|
|
170
|
+
/** pwsh 工具描述:兼容保留工具,只声明不要调用与去向,零执行语义。 */
|
|
171
|
+
function pwshDescription() {
|
|
172
|
+
return 'Do not call; use `shell`.'
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
/**
|
|
@@ -189,7 +208,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
189
208
|
name: toolName,
|
|
190
209
|
description: pinned === undefined
|
|
191
210
|
? shellDescription({ executor: faces.executor, backgroundEnabled, escalationModes })
|
|
192
|
-
: pwshDescription(
|
|
211
|
+
: pwshDescription(),
|
|
193
212
|
parameters: {
|
|
194
213
|
command: {
|
|
195
214
|
type: 'string',
|
|
@@ -243,8 +262,10 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
243
262
|
}] },
|
|
244
263
|
render: (_args, value) => [{
|
|
245
264
|
type: 'text',
|
|
246
|
-
text: value.kind === 'background'
|
|
247
|
-
|
|
265
|
+
text: value.kind === 'background'
|
|
266
|
+
? `started background job ${value.jobId}${value.shell !== undefined ? `\n[shell: ${value.shell}]` : ''}`
|
|
267
|
+
: renderResult(value, escalationModes, value.shell),
|
|
268
|
+
}, ...(toolName === 'pwsh' ? [PWSH_TAKEOVER_BLOCK] : [])],
|
|
248
269
|
},
|
|
249
270
|
async execute(args, exec) {
|
|
250
271
|
validateArgs(args)
|
|
@@ -254,7 +275,8 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
254
275
|
: undefined
|
|
255
276
|
const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode }
|
|
256
277
|
const workdir = resolveWorkdir(args.workdir, exec)
|
|
257
|
-
|
|
278
|
+
// pwsh 契约:钉死工具永远 PowerShell,配置删条目也现场探测,不落默认客户端
|
|
279
|
+
const entry = pinned !== undefined ? faces.executor.contractEntry() : faces.executor.entryFor(args.shell)
|
|
258
280
|
const request = {
|
|
259
281
|
command: args.command,
|
|
260
282
|
...workdir !== undefined ? { workdir } : {},
|
|
@@ -267,17 +289,18 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
267
289
|
if (jobs === undefined) throw new Error('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
|
|
268
290
|
if (exec.signal.aborted) throw await abortError('tool call aborted')
|
|
269
291
|
try {
|
|
270
|
-
faces.executor.
|
|
292
|
+
faces.executor.assertNotDenied(request.command)
|
|
271
293
|
} catch (error) {
|
|
272
|
-
if (isDenyError(error)) return
|
|
294
|
+
if (isDenyError(error)) return deniedForeground(error, entry.id)
|
|
273
295
|
throw error
|
|
274
296
|
}
|
|
275
297
|
return {
|
|
276
298
|
kind: 'background',
|
|
299
|
+
shell: entry.id,
|
|
277
300
|
jobId: jobs.start({
|
|
278
301
|
kind: 'shell',
|
|
279
302
|
label: args.command,
|
|
280
|
-
...exec.agent ? { owner: exec.agent } : {},
|
|
303
|
+
...exec.agent ? { owner: exec.agent.id } : {},
|
|
281
304
|
run: () => {
|
|
282
305
|
const proc = faces.executor.startFor(entry, faces.executor.resolve(request))
|
|
283
306
|
return {
|
|
@@ -296,11 +319,11 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
296
319
|
signal: exec.signal,
|
|
297
320
|
}))
|
|
298
321
|
} catch (error) {
|
|
299
|
-
if (isDenyError(error)) return
|
|
322
|
+
if (isDenyError(error)) return deniedForeground(error, entry.id)
|
|
300
323
|
throw error
|
|
301
324
|
}
|
|
302
325
|
if (result.aborted) throw await abortError('tool call aborted')
|
|
303
|
-
return canonicalResult(result)
|
|
326
|
+
return canonicalResult(result, entry.id)
|
|
304
327
|
},
|
|
305
328
|
presentCall: (args) => {
|
|
306
329
|
if (args.run_in_background === true) {
|
|
@@ -320,7 +343,7 @@ export function registerShellTool(ctx, { executor }) {
|
|
|
320
343
|
}
|
|
321
344
|
},
|
|
322
345
|
presentResult: (args, result) => {
|
|
323
|
-
const block = result.content.length === 1 ? result.content
|
|
346
|
+
const block = result.content.filter(part => part.type === 'text').length === 1 ? result.content.find(part => part.type === 'text') : undefined
|
|
324
347
|
if (block === undefined || block.type !== 'text') return undefined
|
|
325
348
|
const raw = block.text
|
|
326
349
|
if (typeof args === 'object' && args !== null && args.run_in_background === true || result.isError) {
|
package/src/web-routes.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// settings.describe,写经 settings.replace;探测/扫描用 resolve 纯函数。
|
|
6
6
|
// HTTP 形态(具名 exact 路由 + 跨源守卫 + 业务异常归一 400)照 dsh-maintain。
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { homedir, tmpdir } from 'node:os'
|
|
9
|
+
import { KINDS, resolveConfig, coerceServiceable, assertServiceableConfig, normalizeConfigPaths, normalizeWin32Path } from './config.mjs'
|
|
9
10
|
import { candidateExists, detectCandidates, resolveEntryPath } from './resolve.mjs'
|
|
10
11
|
import { requestShellRefresh } from './executor.mjs'
|
|
11
12
|
import { mountRoutes } from './api.mjs'
|
|
@@ -48,7 +49,9 @@ function readSection(ctx, settings) {
|
|
|
48
49
|
ctx.logger?.warn?.(`shell-select: settings 面无 "${SECTION_NS}" 节,设置页回落出厂默认`)
|
|
49
50
|
return resolveConfig({})
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
// 读出面同走 coercing:落盘坏值(悬空 default/空 shells)投影为可服务配置,
|
|
53
|
+
// 设置页显示与执行面一致,不再暴露不可服务的中间态
|
|
54
|
+
return resolveConfig(coerceServiceable(found.value ?? {}).config)
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/**
|
|
@@ -82,6 +85,8 @@ export function buildFaces(ctx, settings) {
|
|
|
82
85
|
},
|
|
83
86
|
detect: (kinds) => detectCandidates(kinds ?? [...KINDS], process.env, candidateExists),
|
|
84
87
|
probe: (candidatePath) => candidateExists(candidatePath),
|
|
88
|
+
// 宿主路径事实:客户端侧栏路径救援用(翻译 MSYS 拼写回宿主形式的事实来源)
|
|
89
|
+
pathMap: () => ({ platform: process.platform, home: homedir(), tmpDir: tmpdir() }),
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|