@ljwei-stak/dsh-model-router 0.13.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/.dsh-plugin/client.js +3092 -0
- package/.dsh-plugin/index.mjs +1651 -0
- package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
- package/.dsh-plugin/shared/harness-plan.mjs +179 -0
- package/.dsh-plugin/shared/livebench.mjs +264 -0
- package/.dsh-plugin/shared/model-profiles.mjs +142 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
- package/.dsh-plugin/shared/router-state.mjs +207 -0
- package/.dsh-plugin/shared/router.mjs +1134 -0
- package/.dsh-plugin/shared/routing-presets.mjs +49 -0
- package/.dsh-plugin/shared/run-ledger.mjs +348 -0
- package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
- package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
- package/.dsh-plugin/shared/task-executors.mjs +1154 -0
- package/.dsh-plugin/shared/tool-health.mjs +311 -0
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
- package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
- package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
- package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
- package/CHANGELOG.md +36 -0
- package/INSTALLATION_GUIDE.zh.md +134 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +53 -0
- package/README.i18n.yaml +3 -0
- package/README.md +424 -0
- package/README.zh.md +413 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +96 -0
- package/docs/assets/workbench-usage.svg +119 -0
- package/package.json +161 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side probe and install runtime for the official tool registry.
|
|
3
|
+
*
|
|
4
|
+
* The DeepSeek Harness plugin host runs as a full Node module, so probing and
|
|
5
|
+
* installing official CLIs uses node:child_process directly. Every command is
|
|
6
|
+
* built exclusively from the fixed registry: callers supply a tool id, never
|
|
7
|
+
* a package name, executable or argument. Installs are serialized (npm global
|
|
8
|
+
* state must not interleave), output is kept as a bounded tail, and success is
|
|
9
|
+
* always confirmed by a fresh probe rather than the installer's exit code.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
15
|
+
import { getOfficialTool, installCommandLine, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
|
|
16
|
+
import { ZCODE_SUPPORTED_VERSION, discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
|
|
17
|
+
import { openZCodeInstaller } from './zcode-installer.mjs'
|
|
18
|
+
|
|
19
|
+
const PROBE_TIMEOUT_MS = 8_000
|
|
20
|
+
const INSTALL_TIMEOUT_MS = 900_000
|
|
21
|
+
const OUTPUT_LINE_CAP = 120
|
|
22
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
23
|
+
const CREATE_NO_WINDOW = 0x0800_0000
|
|
24
|
+
const MINIMAX_INSTALLER_URL = 'https://filecdn.minimax.chat/public/install.ps1'
|
|
25
|
+
const MINIMAX_INSTALLER_SHA256 = '96508631f5874b0ab1519c8a6a463ebc0a80833698346a549c2e03f538680236'
|
|
26
|
+
const MAX_INSTALLER_BYTES = 200_000
|
|
27
|
+
/** Windows ships where.exe in System32; POSIX which is ubiquitous. */
|
|
28
|
+
const LOCATOR = IS_WINDOWS ? 'where' : 'which'
|
|
29
|
+
/** Cached probe results keep plan generation fast; installs invalidate them. */
|
|
30
|
+
const probeCache = new Map()
|
|
31
|
+
const PROBE_CACHE_MS = 60_000
|
|
32
|
+
const installJobs = new Map()
|
|
33
|
+
let installChain = Promise.resolve()
|
|
34
|
+
|
|
35
|
+
async function trustedExecutionReadiness(toolId) {
|
|
36
|
+
// Loaded only during an install to avoid coupling fast version probes to
|
|
37
|
+
// the executor. The executor itself uses this runtime for PATH discovery.
|
|
38
|
+
const { officialToolReadiness } = await import('./official-tool-executor.mjs')
|
|
39
|
+
return officialToolReadiness(toolId)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, signal, env } = {}) {
|
|
43
|
+
return new Promise(resolve => {
|
|
44
|
+
if (signal?.aborted) {
|
|
45
|
+
resolve({ ok: false, code: null, stdout: '', stderr: '', timedOut: false, cancelled: true })
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
let stdout = ''
|
|
49
|
+
let stderr = ''
|
|
50
|
+
let settled = false
|
|
51
|
+
let stopReason = null
|
|
52
|
+
let child
|
|
53
|
+
try {
|
|
54
|
+
child = spawn(executable, args, {
|
|
55
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
// Windows npm CLIs install as .cmd shims; only cmd.exe can execute
|
|
58
|
+
// them. Safe here because every executable and argument comes from
|
|
59
|
+
// the fixed registry — caller input never reaches this boundary.
|
|
60
|
+
shell: useShell,
|
|
61
|
+
...(env ? { env } : {}),
|
|
62
|
+
})
|
|
63
|
+
} catch (error) {
|
|
64
|
+
resolve({ ok: false, code: null, stdout: '', stderr: String(error?.message ?? error), timedOut: false, cancelled: false })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
let graceTimer
|
|
68
|
+
const finish = result => {
|
|
69
|
+
if (settled) return
|
|
70
|
+
settled = true
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
clearTimeout(graceTimer)
|
|
73
|
+
signal?.removeEventListener('abort', onAbort)
|
|
74
|
+
resolve(result)
|
|
75
|
+
}
|
|
76
|
+
const stop = reason => {
|
|
77
|
+
if (settled || stopReason) return
|
|
78
|
+
stopReason = reason
|
|
79
|
+
if (IS_WINDOWS && useShell && Number.isInteger(child.pid)) {
|
|
80
|
+
try {
|
|
81
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
stdio: 'ignore',
|
|
84
|
+
})
|
|
85
|
+
killer.on('error', () => { try { child.kill('SIGTERM') } catch { /* already gone */ } })
|
|
86
|
+
killer.on('close', code => { if (code !== 0) try { child.kill('SIGTERM') } catch { /* already gone */ } })
|
|
87
|
+
} catch { try { child.kill('SIGTERM') } catch { /* already gone */ } }
|
|
88
|
+
} else {
|
|
89
|
+
try { child.kill('SIGTERM') } catch { /* already gone */ }
|
|
90
|
+
}
|
|
91
|
+
graceTimer = setTimeout(() => finish({ ok: false, code: null, stdout, stderr,
|
|
92
|
+
timedOut: reason === 'timeout', cancelled: reason === 'cancel' }), 5_000)
|
|
93
|
+
graceTimer.unref?.()
|
|
94
|
+
}
|
|
95
|
+
const onAbort = () => stop('cancel')
|
|
96
|
+
const timer = setTimeout(() => stop('timeout'), timeoutMs)
|
|
97
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
98
|
+
if (signal?.aborted) onAbort()
|
|
99
|
+
child.stdout?.on('data', data => {
|
|
100
|
+
if (settled || stopReason) return
|
|
101
|
+
stdout = appendBounded(stdout, String(data))
|
|
102
|
+
onOutput?.(String(data))
|
|
103
|
+
})
|
|
104
|
+
child.stderr?.on('data', data => {
|
|
105
|
+
if (settled || stopReason) return
|
|
106
|
+
stderr = appendBounded(stderr, String(data))
|
|
107
|
+
onOutput?.(String(data))
|
|
108
|
+
})
|
|
109
|
+
child.on('error', error => {
|
|
110
|
+
finish({ ok: false, code: null, stdout, stderr: `${stderr}${error.message}`.trim(),
|
|
111
|
+
timedOut: stopReason === 'timeout', cancelled: stopReason === 'cancel' })
|
|
112
|
+
})
|
|
113
|
+
child.on('close', (code, signal) => {
|
|
114
|
+
finish({ ok: !stopReason && code === 0, code, stdout, stderr,
|
|
115
|
+
timedOut: stopReason === 'timeout', cancelled: stopReason === 'cancel', signal })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Preserve the reviewed upstream installer bytes and patch only npm's version selector. */
|
|
121
|
+
export function pinnedMiniMaxInstaller(source, expectedHash = MINIMAX_INSTALLER_SHA256) {
|
|
122
|
+
const bytes = Buffer.isBuffer(source) ? source : Buffer.from(source)
|
|
123
|
+
if (createHash('sha256').update(bytes).digest('hex') !== expectedHash) {
|
|
124
|
+
throw new Error('MiniMax 官方 Windows 安装脚本哈希与审核版本不符,已拒绝执行。')
|
|
125
|
+
}
|
|
126
|
+
const marker = '"$PackageName@latest"'
|
|
127
|
+
const text = bytes.toString('utf8')
|
|
128
|
+
if (text.split(marker).length !== 2) {
|
|
129
|
+
throw new Error('MiniMax 官方安装脚本的版本选择结构已变化,已拒绝执行。')
|
|
130
|
+
}
|
|
131
|
+
return text.replace(marker, '"$PackageName@0.5.5"')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function fetchPinnedMiniMaxInstaller(signal) {
|
|
135
|
+
const response = await fetch(MINIMAX_INSTALLER_URL, {
|
|
136
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
|
|
137
|
+
})
|
|
138
|
+
if (!response.ok || !response.body) throw new Error(`官方脚本下载失败:HTTP ${response.status}`)
|
|
139
|
+
const chunks = []
|
|
140
|
+
let length = 0
|
|
141
|
+
for await (const chunk of response.body) {
|
|
142
|
+
length += chunk.length
|
|
143
|
+
if (length > MAX_INSTALLER_BYTES) throw new Error('官方安装脚本超过大小上限。')
|
|
144
|
+
chunks.push(chunk)
|
|
145
|
+
}
|
|
146
|
+
return pinnedMiniMaxInstaller(Buffer.concat(chunks))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runPinnedMiniMaxInstaller(job) {
|
|
150
|
+
let staging = null
|
|
151
|
+
let prefixRoot = null
|
|
152
|
+
try {
|
|
153
|
+
job.finishedAt = null
|
|
154
|
+
job.error = null
|
|
155
|
+
job.phase = 'downloading-and-verifying'
|
|
156
|
+
pushLine(job, '— npm 原生依赖未就绪;校验 MiniMax 官方 Windows 安装脚本并锁定 0.5.5 —')
|
|
157
|
+
const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
|
|
158
|
+
timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
|
|
159
|
+
})
|
|
160
|
+
const rawPrefix = prefixResult.stdout.trim().replace(/^\\\\\?\\/, '')
|
|
161
|
+
if (!prefixResult.ok || !isAbsolute(rawPrefix)) throw new Error('无法确定 npm 全局安装目录。')
|
|
162
|
+
await mkdir(rawPrefix, { recursive: true })
|
|
163
|
+
prefixRoot = await realpath(rawPrefix)
|
|
164
|
+
const script = await fetchPinnedMiniMaxInstaller(job.controller.signal)
|
|
165
|
+
staging = await mkdtemp(join(prefixRoot, '.model-router-minimax-installer-'))
|
|
166
|
+
const scriptPath = join(staging, 'install.ps1')
|
|
167
|
+
const tempPath = join(staging, 'temp')
|
|
168
|
+
await mkdir(tempPath)
|
|
169
|
+
// Windows PowerShell 5 reads a BOM-less .ps1 using the system ANSI page.
|
|
170
|
+
// The upstream installer contains Unicode art, so write a UTF-8 BOM.
|
|
171
|
+
await writeFile(scriptPath, `\uFEFF${script}`, 'utf8')
|
|
172
|
+
const installDir = join(prefixRoot, '.minimax-code')
|
|
173
|
+
const powershell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
174
|
+
'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
175
|
+
const utilityModule = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32',
|
|
176
|
+
'WindowsPowerShell', 'v1.0', 'Modules', 'Microsoft.PowerShell.Utility',
|
|
177
|
+
'Microsoft.PowerShell.Utility.psd1')
|
|
178
|
+
const command = '$ErrorActionPreference="Stop"; '
|
|
179
|
+
+ 'Import-Module -Name $env:MODEL_ROUTER_UTILITY_MODULE -ErrorAction Stop; '
|
|
180
|
+
+ '& $env:MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT'
|
|
181
|
+
job.phase = 'installing'
|
|
182
|
+
const outcome = await runCapture(powershell,
|
|
183
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
|
|
184
|
+
timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
|
|
185
|
+
env: { ...process.env, MCODE_INSTALL_DIR: installDir,
|
|
186
|
+
MCODE_NO_MODIFY_PATH: '1', MCODE_DOWNLOAD_MIRROR: 'global',
|
|
187
|
+
MCODE_NPM_REGISTRY: 'https://registry.npmjs.org/',
|
|
188
|
+
npm_config_cache: join(prefixRoot, '.model-router-npm-cache'),
|
|
189
|
+
TEMP: tempPath, TMP: tempPath,
|
|
190
|
+
MODEL_ROUTER_UTILITY_MODULE: utilityModule,
|
|
191
|
+
MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT: scriptPath },
|
|
192
|
+
onOutput: chunk => {
|
|
193
|
+
for (const line of chunk.split(/\r?\n/)) if (line.trim()) pushLine(job, line.slice(0, 500))
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
197
|
+
job.exitCode = outcome.code
|
|
198
|
+
job.timedOut = outcome.timedOut
|
|
199
|
+
if (!outcome.ok) throw new Error(outcome.timedOut
|
|
200
|
+
? '官方安装脚本超时并已终止。'
|
|
201
|
+
: `官方安装脚本退出码 ${outcome.code ?? '信号终止'}。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 180) ?? ''}`)
|
|
202
|
+
const currentPath = process.env.PATH ?? ''
|
|
203
|
+
if (!currentPath.split(';').includes(installDir)) process.env.PATH = `${currentPath};${installDir}`
|
|
204
|
+
job.phase = 'verifying'
|
|
205
|
+
probeCache.delete(job.tool.id)
|
|
206
|
+
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
207
|
+
job.postInstallProbe = probe
|
|
208
|
+
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
209
|
+
if (!probe.installed || probe.version !== job.tool.version || !readiness.ready) {
|
|
210
|
+
throw new Error(`官方安装器完成,但入口未通过固定版本与哈希验收:${readiness.reason ?? probe.detail}`)
|
|
211
|
+
}
|
|
212
|
+
job.status = 'succeeded'
|
|
213
|
+
job.finishedAt = new Date().toISOString()
|
|
214
|
+
pushLine(job, '— 官方安装器、固定版本和执行入口均已核验 —')
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
|
|
217
|
+
job.status = 'failed'
|
|
218
|
+
job.error = `MiniMax 官方安装兜底失败:${String(error?.message ?? error).slice(0, 300)}`
|
|
219
|
+
job.finishedAt = new Date().toISOString()
|
|
220
|
+
} finally {
|
|
221
|
+
if (staging && prefixRoot) {
|
|
222
|
+
const part = relative(prefixRoot, staging)
|
|
223
|
+
if (part.startsWith('.model-router-minimax-installer-') && !part.includes(sep)) {
|
|
224
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {})
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function appendBounded(current, addition) {
|
|
231
|
+
const merged = current + addition
|
|
232
|
+
return merged.length > 64_000 ? merged.slice(merged.length - 32_000) : merged
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function compareStableVersions(left, right) {
|
|
236
|
+
const parse = value => /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(String(value ?? ''))
|
|
237
|
+
const a = parse(left)
|
|
238
|
+
const b = parse(right)
|
|
239
|
+
if (!a || !b) return null
|
|
240
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
241
|
+
const difference = Number(a[index]) - Number(b[index])
|
|
242
|
+
if (difference) return Math.sign(difference)
|
|
243
|
+
}
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Extract the first version-looking token from a CLI banner. */
|
|
248
|
+
export function versionFromBanner(banner) {
|
|
249
|
+
const match = String(banner ?? '').match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)
|
|
250
|
+
return match ? match[0] : null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Probe one registry tool. `runner` is injectable for offline tests and must
|
|
255
|
+
* return { ok, code, stdout, stderr, timedOut }.
|
|
256
|
+
*/
|
|
257
|
+
export async function probeToolWith(tool, runner, { cache = probeCache, cacheMs = PROBE_CACHE_MS, now = Date.now } = {}) {
|
|
258
|
+
const cached = cache.get(tool.id)
|
|
259
|
+
if (cached && now() - cached.at < cacheMs) return cached.value
|
|
260
|
+
const probe = await probeUncached(tool, runner)
|
|
261
|
+
cache.set(tool.id, { at: now(), value: probe })
|
|
262
|
+
return probe
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function probeUncached(tool, runner) {
|
|
266
|
+
if (tool.unsupported) {
|
|
267
|
+
return { id: tool.id, installed: false, version: null, status: 'unsupported', detail: tool.unsupportedReason }
|
|
268
|
+
}
|
|
269
|
+
if (tool.manager === 'signed-windows-installer') {
|
|
270
|
+
const bundle = await discoverZCodeBundle()
|
|
271
|
+
if (bundle) return { id: tool.id, installed: true, version: bundle.version, status: 'installed',
|
|
272
|
+
detail: `${tool.probeNote} 安装目录:${bundle.root}`, bannerLine: `${tool.label} ${bundle.buildVersion}` }
|
|
273
|
+
const other = (await unverifiedZCodeInstalls().catch(() => []))[0]
|
|
274
|
+
return { id: tool.id, installed: false, version: null, status: 'not-installed',
|
|
275
|
+
detail: other
|
|
276
|
+
? `检测到已安装的 ZCode ${other.version ?? '(版本未知)'}${other.root ? `(${other.root})` : ''},但插件只启用已核验签名与 CLI 哈希的 ${ZCODE_SUPPORTED_VERSION} 版,因此暂不通过插件调用;可以继续直接使用 ZCode 桌面版,或等待插件更新核验信息。`
|
|
277
|
+
: '未找到官方签名、版本和 CLI 脚本哈希均匹配的 ZCode 桌面版。' }
|
|
278
|
+
}
|
|
279
|
+
if (tool.id === 'minimax-code' && IS_WINDOWS) {
|
|
280
|
+
// The official Windows installer keeps mcode under a versioned releases
|
|
281
|
+
// directory. A partial npm install can leave a broken shim earlier on PATH;
|
|
282
|
+
// prefer the checksum-verified installer release in that case.
|
|
283
|
+
const { findManagedMiniMaxEntry } = await import('./official-tool-executor.mjs')
|
|
284
|
+
const managed = await findManagedMiniMaxEntry(process.cwd())
|
|
285
|
+
if (managed) return { id: tool.id, installed: true, version: managed.version,
|
|
286
|
+
status: 'installed', detail: '已核验 MiniMax 官方 Windows 安装器版本及 CLI 哈希。',
|
|
287
|
+
bannerLine: `MiniMax Code ${managed.version}` }
|
|
288
|
+
}
|
|
289
|
+
for (const executable of tool.probeExecutables) {
|
|
290
|
+
// Existence check first: a missing command is indistinguishable from a
|
|
291
|
+
// broken one by exit code alone once a shell is involved.
|
|
292
|
+
const located = await runner(LOCATOR, [executable], { timeoutMs: PROBE_TIMEOUT_MS })
|
|
293
|
+
if (located.timedOut) {
|
|
294
|
+
return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `定位 ${executable} 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
|
|
295
|
+
}
|
|
296
|
+
if (!located.ok || !located.stdout.trim()) {
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
const attempt = await runner(executable, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS })
|
|
300
|
+
if (attempt.timedOut) {
|
|
301
|
+
return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
|
|
302
|
+
}
|
|
303
|
+
if (attempt.ok) {
|
|
304
|
+
const banner = `${attempt.stdout}\n${attempt.stderr}`.trim()
|
|
305
|
+
return {
|
|
306
|
+
id: tool.id,
|
|
307
|
+
installed: true,
|
|
308
|
+
version: versionFromBanner(banner),
|
|
309
|
+
status: 'installed',
|
|
310
|
+
detail: tool.probeNote ?? '',
|
|
311
|
+
bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
id: tool.id,
|
|
316
|
+
installed: false,
|
|
317
|
+
version: null,
|
|
318
|
+
status: 'probe-failed',
|
|
319
|
+
detail: `${executable} --version 退出码 ${attempt.code ?? '信号终止'};程序可能损坏或版本过旧。${attempt.stderr.split('\n').find(Boolean)?.slice(0, 120) ?? ''}`,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { id: tool.id, installed: false, version: null, status: 'not-installed', detail: '未在 PATH 中找到官方命令。' }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Default runner: real spawn with the platform's shell for .cmd shims. */
|
|
326
|
+
export const defaultRunner = (executable, args, options = {}) =>
|
|
327
|
+
runCapture(executable, args, { ...options, useShell: IS_WINDOWS })
|
|
328
|
+
|
|
329
|
+
let prefixPromise = null
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Users frequently move the npm global prefix off the default location (for
|
|
333
|
+
* example to save C-drive space), so globally installed CLIs are missing from
|
|
334
|
+
* PATH. Resolve the real prefix once and append it to this process's PATH so
|
|
335
|
+
* probes and installs see the user's actual tools. Best effort: any failure
|
|
336
|
+
* leaves the environment untouched.
|
|
337
|
+
*/
|
|
338
|
+
export function ensureNpmPrefixOnPath() {
|
|
339
|
+
if (prefixPromise === null) {
|
|
340
|
+
prefixPromise = (async () => {
|
|
341
|
+
try {
|
|
342
|
+
const outcome = await runCapture('npm', ['config', 'get', 'prefix'], {
|
|
343
|
+
timeoutMs: 5_000,
|
|
344
|
+
useShell: IS_WINDOWS,
|
|
345
|
+
})
|
|
346
|
+
if (!outcome.ok) return
|
|
347
|
+
let prefix = outcome.stdout.trim()
|
|
348
|
+
if (!prefix) return
|
|
349
|
+
if (prefix.startsWith('\\\\?\\')) prefix = prefix.slice(4)
|
|
350
|
+
if (IS_WINDOWS && !process.env.GROK_HOME && /^[D-Z]:[\\/]/i.test(prefix)) {
|
|
351
|
+
// Grok's npm bootstrap otherwise expands its native binary under
|
|
352
|
+
// the Windows user profile on C. Keep plugin-managed downloads on
|
|
353
|
+
// the same non-C drive as this user's npm global prefix.
|
|
354
|
+
process.env.GROK_HOME = join(prefix, '.model-router-grok')
|
|
355
|
+
}
|
|
356
|
+
const current = process.env.PATH ?? ''
|
|
357
|
+
if (current.split(IS_WINDOWS ? ';' : ':').includes(prefix)) return
|
|
358
|
+
process.env.PATH = current ? `${current}${IS_WINDOWS ? ';' : ':'}${prefix}` : prefix
|
|
359
|
+
} catch { /* environment stays untouched */ }
|
|
360
|
+
})()
|
|
361
|
+
}
|
|
362
|
+
return prefixPromise
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Probe every registry tool and return the ids whose CLI is installed. */
|
|
366
|
+
export async function installedToolIds() {
|
|
367
|
+
await ensureNpmPrefixOnPath()
|
|
368
|
+
const probes = await probeAllTools()
|
|
369
|
+
return probes.filter(probe => probe.installed).map(probe => probe.id)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Probe every registry tool in parallel (the slowest single probe bounds the
|
|
374
|
+
* wall time; the cache keeps repeat calls instant).
|
|
375
|
+
*/
|
|
376
|
+
export async function probeAllTools({ fresh = false } = {}) {
|
|
377
|
+
await ensureNpmPrefixOnPath()
|
|
378
|
+
return Promise.all(OFFICIAL_TOOLS.map(tool => probeToolWith(tool, defaultRunner,
|
|
379
|
+
fresh ? { cacheMs: 0 } : {})))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function probeSnapshot() {
|
|
383
|
+
return [...probeCache.values()].map(entry => entry.value)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function jobView(job) {
|
|
387
|
+
if (!job) return null
|
|
388
|
+
return {
|
|
389
|
+
tool: job.tool.id,
|
|
390
|
+
command: job.command,
|
|
391
|
+
status: job.status,
|
|
392
|
+
phase: job.phase,
|
|
393
|
+
cancelRequested: job.cancelRequested,
|
|
394
|
+
startedAt: job.startedAt,
|
|
395
|
+
finishedAt: job.finishedAt,
|
|
396
|
+
exitCode: job.exitCode,
|
|
397
|
+
timedOut: job.timedOut,
|
|
398
|
+
error: job.error,
|
|
399
|
+
outputTail: [...job.outputTail],
|
|
400
|
+
outputTruncated: job.outputTruncated,
|
|
401
|
+
postInstallProbe: job.postInstallProbe,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function pushLine(job, line) {
|
|
406
|
+
if (job.outputTail.length >= OUTPUT_LINE_CAP) {
|
|
407
|
+
job.outputTail.shift()
|
|
408
|
+
job.outputTruncated = true
|
|
409
|
+
}
|
|
410
|
+
job.outputTail.push(line)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Start the fixed install for one registry tool id. Returns the job snapshot
|
|
415
|
+
* synchronously; poll installStatus until status is succeeded/failed.
|
|
416
|
+
*/
|
|
417
|
+
export function startInstall(toolId) {
|
|
418
|
+
const tool = getOfficialTool(toolId)
|
|
419
|
+
if (!tool) throw new Error(`未知工具:${toolId}。只允许安装注册表中的官方工具。`)
|
|
420
|
+
if (tool.unsupported) throw new Error(tool.unsupportedReason)
|
|
421
|
+
const existing = installJobs.get(tool.id)
|
|
422
|
+
if (existing?.status === 'running') throw new Error(`${tool.label} 已有安装任务正在执行。`)
|
|
423
|
+
const command = installCommandLine(tool)
|
|
424
|
+
if (!command) throw new Error(tool.unsupportedReason)
|
|
425
|
+
// With shell mode on Windows the bare name resolves npm.cmd/uv.exe.
|
|
426
|
+
const manager = tool.manager === 'npm' ? 'npm' : tool.manager === 'signed-windows-installer' ? 'signed-windows-installer' : 'uv'
|
|
427
|
+
const job = {
|
|
428
|
+
tool,
|
|
429
|
+
command,
|
|
430
|
+
status: 'running',
|
|
431
|
+
phase: 'queued',
|
|
432
|
+
cancelRequested: false,
|
|
433
|
+
controller: new AbortController(),
|
|
434
|
+
startedAt: new Date().toISOString(),
|
|
435
|
+
finishedAt: null,
|
|
436
|
+
exitCode: null,
|
|
437
|
+
timedOut: false,
|
|
438
|
+
error: null,
|
|
439
|
+
outputTail: [`$ ${command}`],
|
|
440
|
+
outputTruncated: false,
|
|
441
|
+
postInstallProbe: null,
|
|
442
|
+
}
|
|
443
|
+
installJobs.set(tool.id, job)
|
|
444
|
+
probeCache.delete(tool.id)
|
|
445
|
+
installChain = installChain.then(() => manager === 'signed-windows-installer'
|
|
446
|
+
? runZCodeInstallJob(job) : runInstallJob(manager, tool.installArgs, job)).catch(error => {
|
|
447
|
+
if (job.status === 'cancelled') return
|
|
448
|
+
job.status = 'failed'
|
|
449
|
+
job.finishedAt = new Date().toISOString()
|
|
450
|
+
job.error = `安装流程失败:${String(error?.message ?? error).slice(0, 200)}`
|
|
451
|
+
})
|
|
452
|
+
return jobView(job)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function runInstallJob(manager, args, job) {
|
|
456
|
+
if (job.status !== 'running') return
|
|
457
|
+
job.phase = 'preflight'
|
|
458
|
+
await ensureNpmPrefixOnPath()
|
|
459
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
460
|
+
probeCache.delete(job.tool.id)
|
|
461
|
+
const before = await probeToolWith(job.tool, defaultRunner)
|
|
462
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
463
|
+
if (before.installed && job.tool.version) {
|
|
464
|
+
const order = compareStableVersions(before.version, job.tool.version)
|
|
465
|
+
if (order === null || order > 0) {
|
|
466
|
+
job.status = 'failed'
|
|
467
|
+
job.finishedAt = new Date().toISOString()
|
|
468
|
+
job.postInstallProbe = before
|
|
469
|
+
job.error = `已安装 ${before.version ?? '未知版本'},无法证明升级到 ${job.tool.version} 不会降级;请先人工核对。`
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (order === 0) {
|
|
473
|
+
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
474
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
475
|
+
if (readiness.ready) {
|
|
476
|
+
job.status = 'succeeded'
|
|
477
|
+
job.finishedAt = new Date().toISOString()
|
|
478
|
+
job.postInstallProbe = before
|
|
479
|
+
pushLine(job, `— ${job.tool.label} ${before.version} 的官方执行入口已核验,无需重复安装 —`)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
pushLine(job, `— 版本横幅已是 ${before.version},但执行入口未就绪:${readiness.reason};按固定来源修复 —`)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
job.phase = 'installing'
|
|
486
|
+
const outcome = await runCapture(manager, args, {
|
|
487
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
488
|
+
useShell: IS_WINDOWS,
|
|
489
|
+
signal: job.controller.signal,
|
|
490
|
+
onOutput: chunk => {
|
|
491
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
492
|
+
if (line.trim()) pushLine(job, line.slice(0, 500))
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
if (job.status !== 'running') return
|
|
497
|
+
if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
|
|
498
|
+
job.finishedAt = new Date().toISOString()
|
|
499
|
+
job.exitCode = outcome.code
|
|
500
|
+
job.timedOut = outcome.timedOut
|
|
501
|
+
if (outcome.timedOut) {
|
|
502
|
+
job.status = 'failed'
|
|
503
|
+
job.error = `安装超时(${INSTALL_TIMEOUT_MS / 1000}s),已终止。`
|
|
504
|
+
} else if (outcome.ok) {
|
|
505
|
+
job.phase = 'verifying'
|
|
506
|
+
await ensureNpmPrefixOnPath()
|
|
507
|
+
probeCache.delete(job.tool.id)
|
|
508
|
+
const probe = await probeToolWith(job.tool, defaultRunner)
|
|
509
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
510
|
+
job.postInstallProbe = probe
|
|
511
|
+
if (!probe.installed || (job.tool.version && probe.version !== job.tool.version)) {
|
|
512
|
+
job.status = 'failed'
|
|
513
|
+
job.error = probe.installed
|
|
514
|
+
? `安装命令已完成,但 PATH 上读到 ${probe.version ?? '未知版本'};预期 ${job.tool.version}。请检查重复安装。`
|
|
515
|
+
: `安装命令已完成,但重探测失败:${probe.detail}`
|
|
516
|
+
} else {
|
|
517
|
+
const readiness = await trustedExecutionReadiness(job.tool.id)
|
|
518
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
519
|
+
if (!readiness.ready) {
|
|
520
|
+
if (IS_WINDOWS && job.tool.id === 'minimax-code') await runPinnedMiniMaxInstaller(job)
|
|
521
|
+
else {
|
|
522
|
+
job.status = 'failed'
|
|
523
|
+
job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
job.status = 'succeeded'
|
|
527
|
+
pushLine(job, '— 安装、版本和官方执行入口均已核验 —')
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
} else if (IS_WINDOWS && job.tool.id === 'minimax-code') {
|
|
531
|
+
await runPinnedMiniMaxInstaller(job)
|
|
532
|
+
} else {
|
|
533
|
+
job.status = 'failed'
|
|
534
|
+
job.error = `安装命令失败(退出码 ${outcome.code ?? '信号终止'})。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 200) ?? ''}`
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function runZCodeInstallJob(job) {
|
|
539
|
+
if (job.status !== 'running') return
|
|
540
|
+
job.phase = 'preflight'
|
|
541
|
+
probeCache.delete(job.tool.id)
|
|
542
|
+
const before = await probeToolWith(job.tool, defaultRunner)
|
|
543
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
544
|
+
if (before.installed && before.version === job.tool.version) {
|
|
545
|
+
job.status = 'succeeded'
|
|
546
|
+
job.phase = 'done'
|
|
547
|
+
job.finishedAt = new Date().toISOString()
|
|
548
|
+
job.postInstallProbe = before
|
|
549
|
+
pushLine(job, `— 已找到 ZCode ${before.version},无需重复打开安装器 —`)
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
const prefix = await runCapture('npm', ['config', 'get', 'prefix'], {
|
|
553
|
+
timeoutMs: 5_000, useShell: IS_WINDOWS, signal: job.controller.signal,
|
|
554
|
+
})
|
|
555
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
556
|
+
job.phase = 'downloading-and-verifying'
|
|
557
|
+
let lastProgress = 0
|
|
558
|
+
try {
|
|
559
|
+
const opened = await openZCodeInstaller({
|
|
560
|
+
npmPrefix: prefix.ok ? prefix.stdout.trim() : undefined,
|
|
561
|
+
signal: job.controller.signal,
|
|
562
|
+
onProgress({ receivedBytes, declaredBytes }) {
|
|
563
|
+
const now = Date.now()
|
|
564
|
+
if (now - lastProgress < 3_000) return
|
|
565
|
+
lastProgress = now
|
|
566
|
+
pushLine(job, `已下载 ${(receivedBytes / 1_000_000).toFixed(1)} MB${declaredBytes ? ` / ${(declaredBytes / 1_000_000).toFixed(1)} MB` : ''}`)
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
if (job.cancelRequested) { markCancelled(job); return }
|
|
570
|
+
job.status = opened.status
|
|
571
|
+
job.phase = 'installer-opened'
|
|
572
|
+
job.finishedAt = new Date().toISOString()
|
|
573
|
+
probeCache.delete(job.tool.id)
|
|
574
|
+
pushLine(job, `已验证并打开官方安装器:${opened.installerPath}`)
|
|
575
|
+
pushLine(job, '请在原厂安装界面选择非 C 盘目录;完成后点击“重新检测”。')
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
|
|
578
|
+
job.status = 'failed'
|
|
579
|
+
job.phase = 'done'
|
|
580
|
+
job.finishedAt = new Date().toISOString()
|
|
581
|
+
job.error = `ZCode 安装器未打开:${String(error?.message ?? error).slice(0, 300)}`
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function markCancelled(job) {
|
|
586
|
+
job.status = 'cancelled'
|
|
587
|
+
job.phase = 'done'
|
|
588
|
+
job.finishedAt = new Date().toISOString()
|
|
589
|
+
job.error = '已取消安装。若 npm 已开始写入,可能需要重新检测或修复该工具。'
|
|
590
|
+
probeCache.delete(job.tool.id)
|
|
591
|
+
pushLine(job, '— 安装已取消;请重新检测实际状态 —')
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Cancel a queued or running install; cancellation is reflected after its process exits. */
|
|
595
|
+
export function cancelInstall(toolId) {
|
|
596
|
+
const tool = getOfficialTool(toolId)
|
|
597
|
+
if (!tool) throw new Error(`未知工具:${toolId}。`)
|
|
598
|
+
const job = installJobs.get(tool.id)
|
|
599
|
+
if (!job || job.status !== 'running') throw new Error(`${tool.label} 当前没有进行中的安装任务。`)
|
|
600
|
+
if (!job.cancelRequested) {
|
|
601
|
+
job.cancelRequested = true
|
|
602
|
+
job.controller.abort()
|
|
603
|
+
if (job.phase === 'queued') markCancelled(job)
|
|
604
|
+
}
|
|
605
|
+
return jobView(job)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function installStatus(toolId) {
|
|
609
|
+
const job = installJobs.get(String(toolId ?? '').trim())
|
|
610
|
+
return job ? jobView(job) : null
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Plan-side projection: whether each provider can run through an installed CLI. */
|
|
614
|
+
export async function executionChannels(providers) {
|
|
615
|
+
const unique = [...new Set(providers.filter(Boolean))]
|
|
616
|
+
await Promise.all(unique.map(provider => {
|
|
617
|
+
const tool = OFFICIAL_TOOLS.find(entry => !entry.unsupported
|
|
618
|
+
&& entry.providerHints.some(hint => provider.toLowerCase().includes(hint)))
|
|
619
|
+
return tool ? probeToolWith(tool, defaultRunner) : Promise.resolve(null)
|
|
620
|
+
}))
|
|
621
|
+
const resolved = new Map()
|
|
622
|
+
for (const provider of unique) {
|
|
623
|
+
const tool = OFFICIAL_TOOLS.find(entry => !entry.unsupported
|
|
624
|
+
&& entry.providerHints.some(hint => provider.toLowerCase().includes(hint)))
|
|
625
|
+
if (!tool) {
|
|
626
|
+
resolved.set(provider, { kind: 'harness-llm', detail: '通过官方模型目录 API 调用。' })
|
|
627
|
+
continue
|
|
628
|
+
}
|
|
629
|
+
const probe = await probeToolWith(tool, defaultRunner)
|
|
630
|
+
resolved.set(provider, probe.installed
|
|
631
|
+
? { kind: 'harness-llm', tool: tool.id, label: tool.label, version: probe.version, detail: 'CLI 已安装;执行入口仍须单独核验,当前使用官方模型目录 API。' }
|
|
632
|
+
: { kind: 'harness-llm', tool: tool.id, label: tool.label, detail: `${tool.label} 未安装;将使用官方模型目录 API 调用。可在会话中运行 /tools install ${tool.id} 安装。` })
|
|
633
|
+
}
|
|
634
|
+
return resolved
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Test-only: drop caches and jobs. */
|
|
638
|
+
export function resetForTests() {
|
|
639
|
+
probeCache.clear()
|
|
640
|
+
installJobs.clear()
|
|
641
|
+
installChain = Promise.resolve()
|
|
642
|
+
}
|