@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,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MiniMax Code 0.5.5 headless adapter.
|
|
3
|
+
*
|
|
4
|
+
* Source contracts:
|
|
5
|
+
* https://github.com/MiniMax-AI/minimax-code/blob/v0.5.5/packages/tui/src/cli/contract.ts
|
|
6
|
+
* https://github.com/MiniMax-AI/minimax-code/blob/v0.5.5/packages/tui/src/headless/events.ts
|
|
7
|
+
* https://github.com/MiniMax-AI/minimax-code/blob/v0.5.5/packages/tui/src/headless/contract.ts
|
|
8
|
+
*
|
|
9
|
+
* The Host verifies the official npm bundle, Node runtime, account, and
|
|
10
|
+
* workspace. This module never resolves a PATH shim or accepts free-form args.
|
|
11
|
+
*/
|
|
12
|
+
import { basename, isAbsolute } from 'node:path'
|
|
13
|
+
|
|
14
|
+
const TYPES = new Set([
|
|
15
|
+
'exec.started', 'session.started', 'session.resumed', 'turn.started',
|
|
16
|
+
'item.started', 'item.updated', 'item.completed',
|
|
17
|
+
'turn.completed', 'turn.failed', 'exec.completed',
|
|
18
|
+
])
|
|
19
|
+
const STATUSES = new Set(['succeeded', 'failed', 'timeout', 'cancelled', 'limit_exceeded'])
|
|
20
|
+
|
|
21
|
+
function object(value) {
|
|
22
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function id(value, maximum = 240) {
|
|
26
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maximum && !value.includes('\0')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function absolute(value, label, filename) {
|
|
30
|
+
if (typeof value !== 'string' || !isAbsolute(value) || value.includes('\0')) {
|
|
31
|
+
throw new TypeError(label + ' must be an absolute path')
|
|
32
|
+
}
|
|
33
|
+
if (filename && basename(value).toLowerCase() !== filename) {
|
|
34
|
+
throw new TypeError(label + ' must identify ' + filename)
|
|
35
|
+
}
|
|
36
|
+
return value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function checkedModel(value) {
|
|
40
|
+
if (value == null || value === '') return null
|
|
41
|
+
if (typeof value !== 'string' || value.length > 240 || value.includes('\0')
|
|
42
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9_.:-]*\/[A-Za-z0-9][A-Za-z0-9_.:/#-]*$/.test(value)) {
|
|
43
|
+
throw new TypeError('MiniMax model must use provider/model')
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the fixed invocation. The caller sends the task on stdin. MiniMax
|
|
50
|
+
* headless has no read-only switch: the outer Host sandbox enforces that mode.
|
|
51
|
+
* Full vendor permission is available only for a Host-approved isolated run.
|
|
52
|
+
*/
|
|
53
|
+
export function buildMiniMaxInvocation({
|
|
54
|
+
nodeExecutable, cliEntry, workspace, mode = 'read-only',
|
|
55
|
+
modelReference, permission = 'smart', timeoutMs, maxSteps,
|
|
56
|
+
}) {
|
|
57
|
+
const file = absolute(nodeExecutable, 'nodeExecutable', process.platform === 'win32' ? 'node.exe' : 'node')
|
|
58
|
+
const entry = absolute(cliEntry, 'cliEntry', 'cli.js')
|
|
59
|
+
const cwd = absolute(workspace, 'workspace')
|
|
60
|
+
if (mode !== 'read-only' && mode !== 'workspace-write') throw new TypeError('invalid execution mode')
|
|
61
|
+
if (permission !== 'smart' && permission !== 'full') throw new TypeError('invalid MiniMax permission')
|
|
62
|
+
if (permission === 'full' && mode !== 'workspace-write') {
|
|
63
|
+
throw new TypeError('full MiniMax permission requires an isolated workspace-write run')
|
|
64
|
+
}
|
|
65
|
+
const model = checkedModel(modelReference)
|
|
66
|
+
if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 86_400_000)) {
|
|
67
|
+
throw new TypeError('timeoutMs must be between 1000 and 86400000')
|
|
68
|
+
}
|
|
69
|
+
if (maxSteps !== undefined && (!Number.isSafeInteger(maxSteps) || maxSteps < 1 || maxSteps > 10_000)) {
|
|
70
|
+
throw new TypeError('maxSteps must be between 1 and 10000')
|
|
71
|
+
}
|
|
72
|
+
const args = [entry, 'exec', '--input', '-', '--cwd', cwd,
|
|
73
|
+
'--permission', permission, '--output-format', 'stream-json']
|
|
74
|
+
if (model) args.push('--model', model)
|
|
75
|
+
if (timeoutMs !== undefined) args.push('--timeout', String(timeoutMs) + 'ms')
|
|
76
|
+
if (maxSteps !== undefined) args.push('--max-steps', String(maxSteps))
|
|
77
|
+
return { file, args, format: 'minimax-stream-json',
|
|
78
|
+
source: 'verified-official-npm-bundle', requestedModel: model }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validUsage(value) {
|
|
82
|
+
return value === undefined || (object(value) && Object.values(value).every(
|
|
83
|
+
field => field === undefined || (typeof field === 'number' && Number.isFinite(field) && field >= 0)))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validModel(value) {
|
|
87
|
+
return value === undefined || (object(value) && id(value.providerId) && id(value.modelId))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validResult(value) {
|
|
91
|
+
return object(value) && value.schemaVersion === 1 && value.type === 'exec.result'
|
|
92
|
+
&& id(value.runId) && id(value.sessionId) && id(value.turnId)
|
|
93
|
+
&& STATUSES.has(value.status) && Number.isFinite(value.durationMs)
|
|
94
|
+
&& value.durationMs >= 0 && validModel(value.model) && validUsage(value.usage)
|
|
95
|
+
&& (value.usageSource === undefined || [
|
|
96
|
+
'completed_responses', 'analytics_fallback', 'unavailable',
|
|
97
|
+
].includes(value.usageSource))
|
|
98
|
+
&& (value.usageIncomplete === undefined || typeof value.usageIncomplete === 'boolean')
|
|
99
|
+
&& (value.error === undefined || (object(value.error)
|
|
100
|
+
&& ['config', 'runtime', 'internal'].includes(value.error.category)
|
|
101
|
+
&& typeof value.error.message === 'string'))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function answer(result, lastMessage) {
|
|
105
|
+
if (typeof result.output === 'string') return result.output
|
|
106
|
+
if (result.output !== undefined && result.output !== null) {
|
|
107
|
+
try { return JSON.stringify(result.output) } catch { /* malformed vendor payload */ }
|
|
108
|
+
}
|
|
109
|
+
return lastMessage
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Call push with decoded UTF-8 stdout and finish after close. Only a valid
|
|
114
|
+
* exec.completed result, matching turn terminal, and exit code 0 succeeds.
|
|
115
|
+
*/
|
|
116
|
+
export function createMiniMaxStreamParser({ maxLineChars = 4_000_000 } = {}) {
|
|
117
|
+
if (!Number.isSafeInteger(maxLineChars) || maxLineChars < 1024 || maxLineChars > 16_000_000) {
|
|
118
|
+
throw new TypeError('maxLineChars is out of range')
|
|
119
|
+
}
|
|
120
|
+
let pending = ''
|
|
121
|
+
let error = null
|
|
122
|
+
let sequence = 0
|
|
123
|
+
let identity = null
|
|
124
|
+
let stage = 'initial'
|
|
125
|
+
let terminal = null
|
|
126
|
+
let terminalStatus = null
|
|
127
|
+
let result = null
|
|
128
|
+
let lastMessage = ''
|
|
129
|
+
|
|
130
|
+
function reject(message) { error ??= message }
|
|
131
|
+
|
|
132
|
+
function accept(line) {
|
|
133
|
+
if (error || !line.trim()) return
|
|
134
|
+
let event
|
|
135
|
+
try { event = JSON.parse(line) } catch { reject('MiniMax 输出了无效 JSONL。'); return }
|
|
136
|
+
if (!object(event) || event.schemaVersion !== 1 || !TYPES.has(event.type)
|
|
137
|
+
|| !Number.isSafeInteger(event.sequence) || event.sequence !== sequence + 1
|
|
138
|
+
|| !Number.isFinite(event.timestampMs) || event.timestampMs < 0
|
|
139
|
+
|| !id(event.runId) || !id(event.sessionId) || !id(event.turnId)) {
|
|
140
|
+
reject('MiniMax JSONL 事件结构或顺序无效。')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
sequence = event.sequence
|
|
144
|
+
const current = { runId: event.runId, sessionId: event.sessionId, turnId: event.turnId }
|
|
145
|
+
if (identity && Object.keys(current).some(key => current[key] !== identity[key])) {
|
|
146
|
+
reject('MiniMax 会话身份在运行中改变。')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
identity ??= current
|
|
150
|
+
if (stage === 'finished' || (terminal && event.type !== 'exec.completed')) {
|
|
151
|
+
reject('MiniMax 在终态后继续输出事件。')
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (stage === 'initial') {
|
|
155
|
+
if (event.type === 'exec.started') stage = 'started'
|
|
156
|
+
else reject('MiniMax 缺少 exec.started 起始事件。')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
if (event.type === 'session.started' || event.type === 'session.resumed') {
|
|
160
|
+
if (stage === 'started') stage = 'session'
|
|
161
|
+
else reject('MiniMax 会话起始事件顺序无效。')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (event.type === 'turn.started') {
|
|
165
|
+
if (stage === 'session') stage = 'turn'
|
|
166
|
+
else reject('MiniMax turn.started 事件顺序无效。')
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (stage !== 'turn') { reject('MiniMax 缺少会话或任务起始事件。'); return }
|
|
170
|
+
if (event.type.startsWith('item.')) {
|
|
171
|
+
if (!object(event.item) || !id(event.item.id, 500)
|
|
172
|
+
|| !['agent_message', 'reasoning', 'tool_call'].includes(event.item.type)) {
|
|
173
|
+
reject('MiniMax item 事件结构无效。')
|
|
174
|
+
} else if (event.type === 'item.completed' && event.item.type === 'agent_message'
|
|
175
|
+
&& typeof event.item.content === 'string') {
|
|
176
|
+
lastMessage = event.item.content
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (event.type === 'turn.completed' || event.type === 'turn.failed') {
|
|
181
|
+
if (!Number.isFinite(event.durationMs) || event.durationMs < 0
|
|
182
|
+
|| (event.type === 'turn.failed'
|
|
183
|
+
&& (!STATUSES.has(event.status) || event.status === 'succeeded'))) {
|
|
184
|
+
reject('MiniMax turn 终态结构无效。')
|
|
185
|
+
} else {
|
|
186
|
+
terminal = event.type
|
|
187
|
+
terminalStatus = event.type === 'turn.completed' ? 'succeeded' : event.status
|
|
188
|
+
}
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (event.type === 'exec.completed') {
|
|
192
|
+
if (!terminal || !validResult(event.result)
|
|
193
|
+
|| Object.keys(identity).some(key => event.result[key] !== identity[key])
|
|
194
|
+
|| event.result.status !== terminalStatus) {
|
|
195
|
+
reject('MiniMax exec.completed 与任务终态不一致。')
|
|
196
|
+
} else {
|
|
197
|
+
result = event.result
|
|
198
|
+
stage = 'finished'
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
reject('MiniMax JSONL 出现意外事件。')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
push(chunk) {
|
|
207
|
+
if (typeof chunk !== 'string') throw new TypeError('push expects decoded UTF-8 text')
|
|
208
|
+
if (error) return { events: sequence, protocolError: error }
|
|
209
|
+
pending += chunk
|
|
210
|
+
for (;;) {
|
|
211
|
+
const newline = pending.indexOf('\n')
|
|
212
|
+
if (newline < 0) break
|
|
213
|
+
const line = pending.slice(0, newline).replace(/\r$/, '')
|
|
214
|
+
pending = pending.slice(newline + 1)
|
|
215
|
+
if (line.length > maxLineChars) { reject('MiniMax JSONL 单行超出限制。'); break }
|
|
216
|
+
accept(line)
|
|
217
|
+
if (error) break
|
|
218
|
+
}
|
|
219
|
+
if (!error && pending.length > maxLineChars) reject('MiniMax JSONL 单行超出限制。')
|
|
220
|
+
if (error) pending = ''
|
|
221
|
+
return { events: sequence, protocolError: error }
|
|
222
|
+
},
|
|
223
|
+
finish(exitCode) {
|
|
224
|
+
if (!error && pending.trim()) accept(pending.replace(/\r$/, ''))
|
|
225
|
+
pending = ''
|
|
226
|
+
if (error) return { status: 'failed', exitCode, error, terminalEvent: terminal }
|
|
227
|
+
if (!result || !terminal || stage !== 'finished') {
|
|
228
|
+
return { status: 'failed', exitCode, error: 'MiniMax 未返回完整的 exec.completed 结果。', terminalEvent: terminal }
|
|
229
|
+
}
|
|
230
|
+
const finalText = answer(result, lastMessage)
|
|
231
|
+
const details = {
|
|
232
|
+
exitCode, vendorStatus: result.status, terminalEvent: terminal,
|
|
233
|
+
runId: result.runId, sessionId: result.sessionId, turnId: result.turnId,
|
|
234
|
+
reportedModel: result.model ?? null, usage: result.usage ?? null,
|
|
235
|
+
usageSource: result.usageSource ?? null,
|
|
236
|
+
usageIncomplete: result.usageIncomplete ?? null,
|
|
237
|
+
durationMs: result.durationMs, finalText,
|
|
238
|
+
}
|
|
239
|
+
if (exitCode !== 0) return { ...details, status: 'failed', error: 'MiniMax CLI 退出码非零。' }
|
|
240
|
+
if (result.status !== 'succeeded') {
|
|
241
|
+
return { ...details, status: 'failed',
|
|
242
|
+
error: String(result.error?.message ?? result.status).slice(0, 500) }
|
|
243
|
+
}
|
|
244
|
+
return { ...details, status: 'succeeded' }
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate the pinned ZCode desktop bundle on Windows without trusting PATH.
|
|
3
|
+
*
|
|
4
|
+
* Discovery accepts a deliberate operator override, Windows uninstall records,
|
|
5
|
+
* and the conventional installation directories. An install is usable only if
|
|
6
|
+
* its canonical ZCode.exe has a valid signature from the expected publisher,
|
|
7
|
+
* its signed product version is 3.14.3, and the bundled GLM entry remains
|
|
8
|
+
* inside that same canonical installation root.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from 'node:child_process'
|
|
11
|
+
import { createReadStream } from 'node:fs'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { realpath, stat } from 'node:fs/promises'
|
|
14
|
+
import { promisify } from 'node:util'
|
|
15
|
+
import { dirname, isAbsolute, join, relative, sep, win32 } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile)
|
|
18
|
+
|
|
19
|
+
export const ZCODE_SUPPORTED_VERSION = '3.14.3'
|
|
20
|
+
export const ZCODE_SIGNER = '北京智谱华章科技股份有限公司'
|
|
21
|
+
export const ZCODE_INSTALL_ENV_VAR = 'MODEL_ROUTER_ZCODE_HOME'
|
|
22
|
+
export const ZCODE_WINDOWS_INSTALLER_URL =
|
|
23
|
+
'https://cdn-zcode.z.ai/zcode/electron/releases/3.14.3/windows-x64/ZCode-3.14.3-win-x64.exe'
|
|
24
|
+
export const ZCODE_WINDOWS_INSTALLER_SHA256 =
|
|
25
|
+
'404895B46DD1E3066B10A9AB9C187CA07F3082AFC8E108A9D02D86B87186CB93'
|
|
26
|
+
// Extracted from the hash-checked, Authenticode-valid pinned Windows installer.
|
|
27
|
+
// The Desktop EXE signature alone does not authenticate this mutable JS file.
|
|
28
|
+
export const ZCODE_CLI_SHA256 =
|
|
29
|
+
'B1DF2EF3E5BD76C4AF3ECB296BC003A10D3F13191A26610BD0BA940FEADAD529'
|
|
30
|
+
const ZCODE_CLI_SIZE = 14_820_819
|
|
31
|
+
|
|
32
|
+
const REGISTRY_PATHS = Object.freeze([
|
|
33
|
+
'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
|
34
|
+
'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
|
35
|
+
'HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
function powershellPath(env) {
|
|
39
|
+
const systemRoot = env.SystemRoot || env.windir || 'C:\\Windows'
|
|
40
|
+
return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function powershellJson(script, env, extraEnv = {}) {
|
|
44
|
+
if (process.platform !== 'win32') return null
|
|
45
|
+
const command = Buffer.from(
|
|
46
|
+
'[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
|
|
47
|
+
+ '$ErrorActionPreference = "Stop"; ' + script,
|
|
48
|
+
'utf16le',
|
|
49
|
+
).toString('base64')
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileAsync(powershellPath(env), [
|
|
52
|
+
'-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', command,
|
|
53
|
+
], {
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
shell: false,
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: 10_000,
|
|
58
|
+
maxBuffer: 256_000,
|
|
59
|
+
env: { ...env, ...extraEnv },
|
|
60
|
+
})
|
|
61
|
+
const output = stdout.trim().replace(/^\uFEFF/, '')
|
|
62
|
+
return output ? JSON.parse(output) : null
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function uninstallRecords(env) {
|
|
69
|
+
const paths = REGISTRY_PATHS.map(path => `'${path}'`).join(',')
|
|
70
|
+
const script = `$records = @(Get-ItemProperty -Path @(${paths}) -ErrorAction SilentlyContinue |
|
|
71
|
+
Where-Object { [string]$_.DisplayName -match '^ZCode(?:\\s|$)' } |
|
|
72
|
+
ForEach-Object { [pscustomobject]@{
|
|
73
|
+
displayName = [string]$_.DisplayName
|
|
74
|
+
displayVersion = [string]$_.DisplayVersion
|
|
75
|
+
installLocation = [string]$_.InstallLocation
|
|
76
|
+
displayIcon = [string]$_.DisplayIcon
|
|
77
|
+
uninstallString = [string]$_.UninstallString
|
|
78
|
+
} }); ConvertTo-Json -InputObject $records -Compress -Depth 3`
|
|
79
|
+
const value = await powershellJson(script, env)
|
|
80
|
+
return Array.isArray(value) ? value : value ? [value] : []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rootFromCommand(value) {
|
|
84
|
+
if (typeof value !== 'string') return null
|
|
85
|
+
const trimmed = value.trim()
|
|
86
|
+
const quoted = /^"([^"]+)"/.exec(trimmed)
|
|
87
|
+
const executable = quoted?.[1] ?? /^(.+?\.(?:exe|ico))(?=,|\s|$)/i.exec(trimmed)?.[1]
|
|
88
|
+
return executable && win32.isAbsolute(executable) ? dirname(executable) : null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function candidateRoots(env, records) {
|
|
92
|
+
const candidates = []
|
|
93
|
+
const add = (path, source, registryVersion = null) => {
|
|
94
|
+
if (typeof path !== 'string' || !path.trim()) return
|
|
95
|
+
candidates.push({ path: path.trim().replace(/^"|"$/g, ''), source, registryVersion })
|
|
96
|
+
}
|
|
97
|
+
add(env[ZCODE_INSTALL_ENV_VAR], 'operator-env')
|
|
98
|
+
for (const record of records) {
|
|
99
|
+
const version = String(record.displayVersion || '') || null
|
|
100
|
+
add(record.installLocation, 'uninstall-registry', version)
|
|
101
|
+
add(rootFromCommand(record.displayIcon), 'uninstall-registry', version)
|
|
102
|
+
add(rootFromCommand(record.uninstallString), 'uninstall-registry', version)
|
|
103
|
+
}
|
|
104
|
+
for (const parent of [env.LOCALAPPDATA, env.ProgramFiles, env['ProgramFiles(x86)'], env.ProgramW6432]) {
|
|
105
|
+
if (typeof parent !== 'string' || !win32.isAbsolute(parent)) continue
|
|
106
|
+
add(join(parent, 'Programs', 'ZCode'), 'standard-directory')
|
|
107
|
+
add(join(parent, 'ZCode'), 'standard-directory')
|
|
108
|
+
}
|
|
109
|
+
return candidates
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isWithin(root, child) {
|
|
113
|
+
const part = relative(root, child)
|
|
114
|
+
return part !== '' && part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function localWindowsPath(path) {
|
|
118
|
+
if (typeof path !== 'string' || path.includes('\0')) return false
|
|
119
|
+
const local = path.startsWith('\\\\?\\') ? path.slice(4) : path
|
|
120
|
+
return /^[A-Za-z]:[\\/]/.test(local)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function verifiedCliEntry(path) {
|
|
124
|
+
if ((await stat(path)).size !== ZCODE_CLI_SIZE) return false
|
|
125
|
+
const digest = createHash('sha256')
|
|
126
|
+
for await (const chunk of createReadStream(path)) digest.update(chunk)
|
|
127
|
+
return digest.digest('hex').toUpperCase() === ZCODE_CLI_SHA256
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function signedExecutable(executable, env) {
|
|
131
|
+
const script = `$s = Get-AuthenticodeSignature -LiteralPath $env:MODEL_ROUTER_ZCODE_VERIFY_PATH;
|
|
132
|
+
$v = (Get-Item -LiteralPath $env:MODEL_ROUTER_ZCODE_VERIFY_PATH).VersionInfo;
|
|
133
|
+
[pscustomobject]@{
|
|
134
|
+
signatureStatus = [string]$s.Status
|
|
135
|
+
signerSubject = [string]$s.SignerCertificate.Subject
|
|
136
|
+
productVersion = [string]$v.ProductVersion
|
|
137
|
+
fileVersion = [string]$v.FileVersion
|
|
138
|
+
} | ConvertTo-Json -Compress`
|
|
139
|
+
const value = await powershellJson(script, env, { MODEL_ROUTER_ZCODE_VERIFY_PATH: executable })
|
|
140
|
+
if (!value || value.signatureStatus !== 'Valid'
|
|
141
|
+
|| typeof value.signerSubject !== 'string'
|
|
142
|
+
|| !value.signerSubject.includes(ZCODE_SIGNER)) return null
|
|
143
|
+
const buildVersion = String(value.productVersion || value.fileVersion || '')
|
|
144
|
+
if (!new RegExp(`^${ZCODE_SUPPORTED_VERSION.replaceAll('.', '\\.')}(?:\\.|$)`).test(buildVersion)) return null
|
|
145
|
+
return { buildVersion, fileVersion: String(value.fileVersion || ''), publisher: value.signerSubject }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function checkedBundle(candidate, env) {
|
|
149
|
+
const input = candidate.path
|
|
150
|
+
if (!localWindowsPath(input)) return null
|
|
151
|
+
try {
|
|
152
|
+
const initial = input.toLowerCase().endsWith('\\zcode.exe') ? dirname(input) : input
|
|
153
|
+
const root = await realpath(initial)
|
|
154
|
+
if (!localWindowsPath(root) || !(await stat(root)).isDirectory()) return null
|
|
155
|
+
const executable = await realpath(join(root, 'ZCode.exe'))
|
|
156
|
+
const cliEntry = await realpath(join(root, 'resources', 'glm', 'zcode.cjs'))
|
|
157
|
+
if (!isWithin(root, executable) || !isWithin(root, cliEntry)
|
|
158
|
+
|| !(await stat(executable)).isFile() || !(await stat(cliEntry)).isFile()) return null
|
|
159
|
+
const signature = await signedExecutable(executable, env)
|
|
160
|
+
if (!signature || !(await verifiedCliEntry(cliEntry))) return null
|
|
161
|
+
return Object.freeze({
|
|
162
|
+
id: 'zcode',
|
|
163
|
+
version: ZCODE_SUPPORTED_VERSION,
|
|
164
|
+
buildVersion: signature.buildVersion,
|
|
165
|
+
fileVersion: signature.fileVersion,
|
|
166
|
+
registryVersion: candidate.registryVersion,
|
|
167
|
+
root,
|
|
168
|
+
executable,
|
|
169
|
+
cliEntry,
|
|
170
|
+
cliSha256: ZCODE_CLI_SHA256,
|
|
171
|
+
publisher: signature.publisher,
|
|
172
|
+
signatureStatus: 'Valid',
|
|
173
|
+
source: candidate.source,
|
|
174
|
+
})
|
|
175
|
+
} catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Return the first verified local 3.14.3 bundle, or null when none is usable. */
|
|
181
|
+
export async function discoverZCodeBundle({ env = process.env } = {}) {
|
|
182
|
+
if (process.platform !== 'win32') return null
|
|
183
|
+
const records = await uninstallRecords(env)
|
|
184
|
+
const seen = new Set()
|
|
185
|
+
for (const candidate of candidateRoots(env, records)) {
|
|
186
|
+
const key = win32.normalize(candidate.path).toLowerCase()
|
|
187
|
+
if (seen.has(key)) continue
|
|
188
|
+
seen.add(key)
|
|
189
|
+
const bundle = await checkedBundle(candidate, env)
|
|
190
|
+
if (bundle) return bundle
|
|
191
|
+
}
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* ZCode entries in the uninstall registry that did not pass verification, e.g. a
|
|
197
|
+
* newer release the plugin has not pinned yet. Only used to explain why the
|
|
198
|
+
* tool shows as unavailable; nothing found here is ever executed.
|
|
199
|
+
*/
|
|
200
|
+
export async function unverifiedZCodeInstalls({ env = process.env } = {}) {
|
|
201
|
+
if (process.platform !== 'win32') return []
|
|
202
|
+
const records = await uninstallRecords(env)
|
|
203
|
+
return records.map(record => ({
|
|
204
|
+
version: String(record.displayVersion || '') || null,
|
|
205
|
+
root: [record.installLocation, rootFromCommand(record.displayIcon), rootFromCommand(record.uninstallString)]
|
|
206
|
+
.find(value => typeof value === 'string' && value.trim()) ?? null,
|
|
207
|
+
}))
|
|
208
|
+
}
|