@ljwei-stak/dsh-model-router 0.14.0-beta.3 → 0.14.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 +37 -34
- package/.dsh-plugin/index.mjs +14 -8
- package/.dsh-plugin/shared/harness-plan.mjs +1 -1
- package/.dsh-plugin/shared/latest-versions.mjs +200 -0
- package/.dsh-plugin/shared/npm-attestation.mjs +315 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +37 -20
- package/.dsh-plugin/shared/official-tool-registry.mjs +14 -19
- package/.dsh-plugin/shared/official-tools-runtime.mjs +53 -54
- package/.dsh-plugin/shared/tool-health.mjs +25 -8
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +55 -37
- package/.dsh-plugin/shared/zcode-bundle.mjs +95 -32
- package/.dsh-plugin/shared/zcode-installer.mjs +33 -32
- package/CHANGELOG.md +20 -29
- package/INSTALLATION_GUIDE.zh.md +10 -10
- package/MIGRATION.md +3 -3
- package/README.md +26 -16
- package/README.zh.md +42 -30
- package/package.json +4 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry-attested file digests for unsigned files shipped in official npm
|
|
3
|
+
* packages (MiMo's mimo.exe, Grok's grok.exe.br, MiniMax's cli.js).
|
|
4
|
+
*
|
|
5
|
+
* These vendors do not Authenticode-sign their binaries, so a publisher check
|
|
6
|
+
* is impossible. Instead of a per-version hash compiled into the plugin, the
|
|
7
|
+
* expected digest is derived at runtime from the official npm registry for the
|
|
8
|
+
* exact installed version: the version document (over TLS from
|
|
9
|
+
* registry.npmjs.org) gives the tarball's sha512 `dist.integrity`; the tarball
|
|
10
|
+
* (from npm's local cache when present, otherwise downloaded from the same
|
|
11
|
+
* registry) must match it; the file is then hashed straight out of the tarball.
|
|
12
|
+
* Installed files must equal those digests. Results are cached per
|
|
13
|
+
* package@version, so only the first use of a new version needs network.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { createReadStream } from 'node:fs'
|
|
17
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
import { Readable } from 'node:stream'
|
|
21
|
+
import { createGunzip } from 'node:zlib'
|
|
22
|
+
import { resolveStateHome } from './router-state.mjs'
|
|
23
|
+
import { NPM_REGISTRY, isReleaseVersion } from './latest-versions.mjs'
|
|
24
|
+
|
|
25
|
+
const PACKAGE_NAME = /^@[a-z0-9][a-z0-9._-]{0,100}\/[a-z0-9][a-z0-9._-]{0,100}$|^[a-z0-9][a-z0-9._-]{0,100}$/
|
|
26
|
+
const MAX_TARBALL_BYTES = 400_000_000
|
|
27
|
+
const METADATA_TIMEOUT_MS = 15_000
|
|
28
|
+
const TARBALL_TIMEOUT_MS = 180_000
|
|
29
|
+
/** Files that run as code: what the executor must not trust without attestation. */
|
|
30
|
+
export const CODE_FILE = /\.(?:js|cjs|mjs|node|exe|dll|br|wasm)$/i
|
|
31
|
+
|
|
32
|
+
export function attestationCachePath(env = process.env) {
|
|
33
|
+
return join(resolveStateHome(env), 'model-router', 'npm-attestations.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** npm's content-addressed cache location for one sha512 integrity string. */
|
|
37
|
+
export function npmCacheContentPath(cacheDir, integrity) {
|
|
38
|
+
const match = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(String(integrity ?? ''))
|
|
39
|
+
if (!match || !cacheDir) return null
|
|
40
|
+
const hex = Buffer.from(match[1], 'base64').toString('hex')
|
|
41
|
+
if (hex.length !== 128) return null
|
|
42
|
+
return join(cacheDir, '_cacache', 'content-v2', 'sha512', hex.slice(0, 2), hex.slice(2, 4), hex.slice(4))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defaultNpmCacheDirectory(env = process.env, platform = process.platform) {
|
|
46
|
+
const configured = env.npm_config_cache ?? env.NPM_CONFIG_CACHE
|
|
47
|
+
if (typeof configured === 'string' && configured.trim()) return configured.trim()
|
|
48
|
+
if (platform === 'win32') return env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'npm-cache') : null
|
|
49
|
+
return join(homedir(), '.npm')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse one 512-byte tar header; returns null for the end-of-archive block. */
|
|
53
|
+
function tarHeader(block) {
|
|
54
|
+
if (block.every(byte => byte === 0)) return null
|
|
55
|
+
const field = (start, length) => {
|
|
56
|
+
const raw = block.subarray(start, start + length)
|
|
57
|
+
const end = raw.indexOf(0)
|
|
58
|
+
return raw.subarray(0, end === -1 ? length : end).toString('utf8')
|
|
59
|
+
}
|
|
60
|
+
const sizeField = block.subarray(124, 136)
|
|
61
|
+
let size
|
|
62
|
+
if (sizeField[0] & 0x80) {
|
|
63
|
+
size = 0
|
|
64
|
+
for (let index = 1; index < 12; index += 1) size = size * 256 + sizeField[index]
|
|
65
|
+
} else {
|
|
66
|
+
size = Number.parseInt(field(124, 12).trim() || '0', 8)
|
|
67
|
+
}
|
|
68
|
+
const magic = field(257, 6)
|
|
69
|
+
const prefix = magic.startsWith('ustar') ? field(345, 155) : ''
|
|
70
|
+
const name = field(0, 100)
|
|
71
|
+
return { name: prefix ? `${prefix}/${name}` : name, size, type: String.fromCharCode(block[156] || 48) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function paxPath(buffer) {
|
|
75
|
+
let offset = 0
|
|
76
|
+
while (offset < buffer.length) {
|
|
77
|
+
const space = buffer.indexOf(0x20, offset)
|
|
78
|
+
if (space === -1) break
|
|
79
|
+
const length = Number.parseInt(buffer.subarray(offset, space).toString('utf8'), 10)
|
|
80
|
+
if (!Number.isSafeInteger(length) || length <= 0) break
|
|
81
|
+
const record = buffer.subarray(space + 1, offset + length - 1).toString('utf8')
|
|
82
|
+
if (record.startsWith('path=')) return record.slice(5)
|
|
83
|
+
offset += length
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stream a .tgz and return a Map of every regular member below `package/`
|
|
90
|
+
* (path relative to the package root) to { sha256: 'sha256-<base64>', size }.
|
|
91
|
+
*/
|
|
92
|
+
export async function digestTarMembers(tgzStream) {
|
|
93
|
+
const gunzip = tgzStream.pipe(createGunzip())
|
|
94
|
+
const members = new Map()
|
|
95
|
+
let pending = Buffer.alloc(0)
|
|
96
|
+
let state = { kind: 'header' }
|
|
97
|
+
let nextName = null
|
|
98
|
+
for await (const chunk of gunzip) {
|
|
99
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk
|
|
100
|
+
for (;;) {
|
|
101
|
+
if (state.kind === 'done') break
|
|
102
|
+
if (state.kind === 'header') {
|
|
103
|
+
if (pending.length < 512) break
|
|
104
|
+
const header = tarHeader(pending.subarray(0, 512))
|
|
105
|
+
pending = pending.subarray(512)
|
|
106
|
+
if (!header) { state = { kind: 'done' }; break }
|
|
107
|
+
const padded = Math.ceil(header.size / 512) * 512
|
|
108
|
+
const name = nextName ?? header.name
|
|
109
|
+
nextName = null
|
|
110
|
+
if (header.type === 'x' || header.type === 'L') {
|
|
111
|
+
state = { kind: 'meta', type: header.type, remaining: header.size, skip: padded - header.size, parts: [] }
|
|
112
|
+
} else if (header.type === '0' || header.type === '\0') {
|
|
113
|
+
state = { kind: 'data', name, remaining: header.size, skip: padded - header.size, hash: createHash('sha256'), size: header.size }
|
|
114
|
+
} else {
|
|
115
|
+
state = { kind: 'skip', remaining: padded }
|
|
116
|
+
}
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (state.kind === 'meta' || state.kind === 'data') {
|
|
120
|
+
if (state.remaining > 0) {
|
|
121
|
+
if (pending.length === 0) break
|
|
122
|
+
const take = Math.min(state.remaining, pending.length)
|
|
123
|
+
const piece = pending.subarray(0, take)
|
|
124
|
+
if (state.kind === 'meta') state.parts.push(Buffer.from(piece)); else state.hash.update(piece)
|
|
125
|
+
state.remaining -= take
|
|
126
|
+
pending = pending.subarray(take)
|
|
127
|
+
if (state.remaining > 0) break
|
|
128
|
+
}
|
|
129
|
+
if (state.kind === 'meta') {
|
|
130
|
+
const body = Buffer.concat(state.parts)
|
|
131
|
+
nextName = state.type === 'x' ? paxPath(body) : body.toString('utf8').replace(/\0+$/, '')
|
|
132
|
+
} else {
|
|
133
|
+
// npm packs every file under one top-level directory (usually "package/").
|
|
134
|
+
const relativePath = state.name.replace(/^\.?\/?[^/]+\//, '')
|
|
135
|
+
if (relativePath && !relativePath.split('/').includes('..')) {
|
|
136
|
+
members.set(relativePath, { sha256: `sha256-${state.hash.digest('base64')}`, size: state.size })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
state = { kind: 'skip', remaining: state.skip }
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (state.kind === 'skip') {
|
|
143
|
+
const take = Math.min(state.remaining, pending.length)
|
|
144
|
+
state.remaining -= take
|
|
145
|
+
pending = pending.subarray(take)
|
|
146
|
+
if (state.remaining > 0) break
|
|
147
|
+
state = { kind: 'header' }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (state.kind === 'done') break
|
|
151
|
+
}
|
|
152
|
+
gunzip.destroy()
|
|
153
|
+
return members
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function sha512Of(stream) {
|
|
157
|
+
const hash = createHash('sha512')
|
|
158
|
+
for await (const chunk of stream) hash.update(chunk)
|
|
159
|
+
return `sha512-${hash.digest('base64')}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Digest of a local file in the same `sha256-<base64>` form. */
|
|
163
|
+
export async function localFileDigest(path) {
|
|
164
|
+
const hash = createHash('sha256')
|
|
165
|
+
let size = 0
|
|
166
|
+
for await (const chunk of createReadStream(path)) { hash.update(chunk); size += chunk.length }
|
|
167
|
+
return { sha256: `sha256-${hash.digest('base64')}`, size }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function createNpmAttestor({
|
|
171
|
+
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
172
|
+
cachePath = () => attestationCachePath(),
|
|
173
|
+
npmCacheDirectory = async () => defaultNpmCacheDirectory(),
|
|
174
|
+
now = Date.now,
|
|
175
|
+
persist = true,
|
|
176
|
+
} = {}) {
|
|
177
|
+
let entries = null
|
|
178
|
+
const inflight = new Map()
|
|
179
|
+
|
|
180
|
+
async function load() {
|
|
181
|
+
if (entries) return entries
|
|
182
|
+
entries = {}
|
|
183
|
+
if (!persist) return entries
|
|
184
|
+
try {
|
|
185
|
+
const saved = JSON.parse(await readFile(cachePath(), 'utf8'))
|
|
186
|
+
if (saved?.entries && typeof saved.entries === 'object') {
|
|
187
|
+
for (const [key, value] of Object.entries(saved.entries)) {
|
|
188
|
+
if (value && typeof value.files === 'object' && /^sha512-/.test(value.integrity ?? '')) entries[key] = value
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch { /* empty */ }
|
|
192
|
+
return entries
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function save() {
|
|
196
|
+
if (!persist) return
|
|
197
|
+
try {
|
|
198
|
+
const path = cachePath()
|
|
199
|
+
await mkdir(dirname(path), { recursive: true })
|
|
200
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
201
|
+
await writeFile(temporary, `${JSON.stringify({ version: 1, entries }, null, 2)}\n`, 'utf8')
|
|
202
|
+
await rename(temporary, path)
|
|
203
|
+
} catch { /* memory cache still applies */ }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function registryTarball(name, version) {
|
|
207
|
+
const response = await fetchImpl(`${NPM_REGISTRY}/${name}/${version}`, {
|
|
208
|
+
headers: { accept: 'application/json' }, signal: AbortSignal.timeout(METADATA_TIMEOUT_MS),
|
|
209
|
+
})
|
|
210
|
+
if (!response?.ok) throw new Error(`npm 元数据 HTTP ${response?.status ?? '失败'}`)
|
|
211
|
+
const document = await response.json()
|
|
212
|
+
const integrity = document?.dist?.integrity
|
|
213
|
+
const tarball = document?.dist?.tarball
|
|
214
|
+
if (document?.name !== name || document?.version !== version || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(integrity ?? '')) {
|
|
215
|
+
throw new Error('npm 元数据缺少 sha512 完整性信息')
|
|
216
|
+
}
|
|
217
|
+
const url = new URL(tarball)
|
|
218
|
+
if (url.protocol !== 'https:' || url.hostname !== 'registry.npmjs.org') throw new Error('npm tarball 不在官方 registry 上')
|
|
219
|
+
return { integrity, url: url.href }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function verifiedTarballStream({ integrity, url }) {
|
|
223
|
+
const cached = npmCacheContentPath(await npmCacheDirectory().catch(() => null), integrity)
|
|
224
|
+
if (cached) {
|
|
225
|
+
try {
|
|
226
|
+
if (await sha512Of(createReadStream(cached)) === integrity) return createReadStream(cached)
|
|
227
|
+
} catch { /* not in npm's cache */ }
|
|
228
|
+
}
|
|
229
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(TARBALL_TIMEOUT_MS) })
|
|
230
|
+
if (!response?.ok || !response.body) throw new Error(`npm tarball HTTP ${response?.status ?? '失败'}`)
|
|
231
|
+
const chunks = []
|
|
232
|
+
let length = 0
|
|
233
|
+
const hash = createHash('sha512')
|
|
234
|
+
for await (const chunk of Readable.fromWeb(response.body)) {
|
|
235
|
+
length += chunk.length
|
|
236
|
+
if (length > MAX_TARBALL_BYTES) throw new Error('npm tarball 超出大小上限')
|
|
237
|
+
hash.update(chunk)
|
|
238
|
+
chunks.push(chunk)
|
|
239
|
+
}
|
|
240
|
+
if (`sha512-${hash.digest('base64')}` !== integrity) throw new Error('npm tarball 与 registry 完整性值不符')
|
|
241
|
+
return Readable.from([Buffer.concat(chunks)])
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Every file of name@version → { sha256, size }, attested by the npm registry. */
|
|
245
|
+
async function attestPackage(name, version) {
|
|
246
|
+
if (!PACKAGE_NAME.test(String(name)) || !isReleaseVersion(version)) throw new TypeError('无效的 npm 包或版本')
|
|
247
|
+
const key = `${name}@${version}`
|
|
248
|
+
const cache = await load()
|
|
249
|
+
if (cache[key]) return cache[key]
|
|
250
|
+
if (inflight.has(key)) return inflight.get(key)
|
|
251
|
+
const pending = (async () => {
|
|
252
|
+
try {
|
|
253
|
+
const source = await registryTarball(name, version)
|
|
254
|
+
const members = await digestTarMembers(await verifiedTarballStream(source))
|
|
255
|
+
if (members.size === 0) throw new Error(`官方 npm 包 ${key} 为空`)
|
|
256
|
+
cache[key] = { integrity: source.integrity, attestedAt: now(), files: Object.fromEntries(members) }
|
|
257
|
+
await save()
|
|
258
|
+
return cache[key]
|
|
259
|
+
} finally {
|
|
260
|
+
inflight.delete(key)
|
|
261
|
+
}
|
|
262
|
+
})()
|
|
263
|
+
inflight.set(key, pending)
|
|
264
|
+
return pending
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function attest(name, version, path) {
|
|
268
|
+
if (typeof path !== 'string' || !path || path.split('/').includes('..')) throw new TypeError('无效的文件路径')
|
|
269
|
+
const entry = (await attestPackage(name, version)).files[path]
|
|
270
|
+
if (!entry) throw new Error(`官方 npm 包 ${name}@${version} 中没有 ${path}`)
|
|
271
|
+
return entry
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
attest,
|
|
276
|
+
attestPackage,
|
|
277
|
+
/** Does one local file equal the registry-attested member? Never throws. */
|
|
278
|
+
async matches(localPath, name, version, path) {
|
|
279
|
+
try {
|
|
280
|
+
const expected = await attest(name, version, path)
|
|
281
|
+
const actual = await localFileDigest(localPath)
|
|
282
|
+
return actual.size === expected.size && actual.sha256 === expected.sha256
|
|
283
|
+
? { ok: true, expected }
|
|
284
|
+
: { ok: false, reason: `本地文件与官方 npm ${name}@${version} 的 ${path} 摘要不符` }
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return { ok: false, reason: String(error?.message ?? error).slice(0, 200) }
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
/**
|
|
290
|
+
* Compare every attested member selected by `include` (default: code files)
|
|
291
|
+
* with the installed package directory. Missing or different files fail.
|
|
292
|
+
*/
|
|
293
|
+
async matchesPackage(packageRoot, name, version, include = CODE_FILE) {
|
|
294
|
+
try {
|
|
295
|
+
const { files } = await attestPackage(name, version)
|
|
296
|
+
const selected = Object.entries(files).filter(([path]) => include.test(path))
|
|
297
|
+
if (selected.length === 0) return { ok: false, reason: `官方 npm 包 ${name}@${version} 没有可核验的代码文件` }
|
|
298
|
+
for (const [path, expected] of selected) {
|
|
299
|
+
let actual
|
|
300
|
+
try { actual = await localFileDigest(join(packageRoot, ...path.split('/'))) }
|
|
301
|
+
catch { return { ok: false, reason: `已安装的 ${name}@${version} 缺少 ${path}` } }
|
|
302
|
+
if (actual.size !== expected.size || actual.sha256 !== expected.sha256) {
|
|
303
|
+
return { ok: false, reason: `已安装的 ${name}@${version} 中 ${path} 与官方 npm 发行文件不符` }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { ok: true, files: selected.length }
|
|
307
|
+
} catch (error) {
|
|
308
|
+
return { ok: false, reason: String(error?.message ?? error).slice(0, 200) }
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
reset() { entries = null; inflight.clear() },
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export const npmAttestor = createNpmAttestor()
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* https://moonshotai.github.io/kimi-code/en/reference/kimi-command.html
|
|
15
15
|
*/
|
|
16
16
|
import { spawn } from 'node:child_process'
|
|
17
|
-
import { createHash } from 'node:crypto'
|
|
18
17
|
import { access, readFile, realpath, stat } from 'node:fs/promises'
|
|
19
18
|
import { constants } from 'node:fs'
|
|
20
19
|
import { StringDecoder } from 'node:string_decoder'
|
|
@@ -23,6 +22,8 @@ import { getOfficialTool, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
|
|
|
23
22
|
import { ensureNpmPrefixOnPath } from './official-tools-runtime.mjs'
|
|
24
23
|
import { buildMiniMaxInvocation, createMiniMaxStreamParser } from './vendor-minimax-adapter.mjs'
|
|
25
24
|
import { discoverZCodeBundle } from './zcode-bundle.mjs'
|
|
25
|
+
import { npmAttestor } from './npm-attestation.mjs'
|
|
26
|
+
import { compareReleaseVersions, isReleaseVersion } from './latest-versions.mjs'
|
|
26
27
|
import { resolveMiMoGrokLaunch, createMiMoGrokParser } from './vendor-mimo-grok-adapter.mjs'
|
|
27
28
|
import { emptyOutputError, outputIsEmpty, usageFromEvents, usageFromOutput } from './task-executors.mjs'
|
|
28
29
|
|
|
@@ -36,9 +37,6 @@ const STOP_GRACE_MS = 5_000
|
|
|
36
37
|
const CLAUDE_MIN_RESTRICTED_VERSION = [2, 1, 259]
|
|
37
38
|
const CLAUDE_SIGNER = 'Anthropic, PBC'
|
|
38
39
|
const CODEX_SIGNER = 'OpenAI OpCo, LLC'
|
|
39
|
-
// SHA-256 of package/cli.js in the official @minimax-ai/code@0.5.5 npm tarball.
|
|
40
|
-
// The Windows installer-managed release carries the exact same entry file.
|
|
41
|
-
const MINIMAX_055_CLI_SHA256 = '8d36dec74e93beddf392459557a7afd8655bcdc874121981608d2c82658814ab'
|
|
42
40
|
const WINDOWS_SYSTEM32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
|
43
41
|
const ENVIRONMENT_KEYS = Object.freeze([
|
|
44
42
|
'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'windir', 'ComSpec',
|
|
@@ -201,19 +199,23 @@ async function findGlobalPackageEntries(packageName, binName, expectedBin, works
|
|
|
201
199
|
return found
|
|
202
200
|
}
|
|
203
201
|
|
|
204
|
-
/**
|
|
202
|
+
/**
|
|
203
|
+
* The official MiniMax Windows installer uses a versioned releases tree rather
|
|
204
|
+
* than npm -g. Any release named by its `current` file is accepted, but every
|
|
205
|
+
* code file of that release must equal the official npm tarball of the same
|
|
206
|
+
* version (MiniMax does not sign its CLI, so the npm registry is the authority).
|
|
207
|
+
*/
|
|
205
208
|
export async function findManagedMiniMaxEntry(workspace,
|
|
206
209
|
directories = pathDirectories().flatMap(directory => [directory, join(directory, '.minimax-code')]),
|
|
207
|
-
|
|
210
|
+
attestor = npmAttestor) {
|
|
208
211
|
if (!IS_WINDOWS) return null
|
|
209
|
-
const version = getOfficialTool('minimax-code').version
|
|
210
212
|
for (const directory of directories) {
|
|
211
213
|
try {
|
|
212
214
|
const root = await realpath(directory)
|
|
213
215
|
if (root === workspace || inside(workspace, root)) continue
|
|
214
216
|
if (!(await stat(join(root, 'mcode.cmd'))).isFile()) continue
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
+
const version = (await readFile(join(root, 'current'), 'utf8')).trim()
|
|
218
|
+
if (!isReleaseVersion(version)) continue
|
|
217
219
|
const releaseRoot = await realpath(join(root, 'releases', version))
|
|
218
220
|
if (!inside(root, releaseRoot)) continue
|
|
219
221
|
const packageRoot = await realpath(join(releaseRoot, 'node_modules', '@minimax-ai', 'code'))
|
|
@@ -223,17 +225,22 @@ export async function findManagedMiniMaxEntry(workspace,
|
|
|
223
225
|
|| String(manifest.bin?.mcode).replace(/^\.\//, '') !== 'cli.js') continue
|
|
224
226
|
const entry = await realpath(join(packageRoot, 'cli.js'))
|
|
225
227
|
if (!inside(packageRoot, entry) || !(await stat(entry)).isFile()) continue
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
+
const attested = await attestor.matchesPackage(packageRoot, '@minimax-ai/code', version)
|
|
229
|
+
if (!attested.ok) continue
|
|
228
230
|
const native = await realpath(join(packageRoot, 'node_modules', 'better-sqlite3',
|
|
229
231
|
'build', 'Release', 'better_sqlite3.node'))
|
|
230
232
|
if (!inside(packageRoot, native) || !(await stat(native)).isFile()) continue
|
|
231
|
-
return { entry, packageRoot, version, source: '
|
|
233
|
+
return { entry, packageRoot, version, source: 'official-windows-installer-npm-attested' }
|
|
232
234
|
} catch { /* inspect the next PATH directory */ }
|
|
233
235
|
}
|
|
234
236
|
return null
|
|
235
237
|
}
|
|
236
238
|
|
|
239
|
+
/** Newest installed copy first; versions are not pinned. */
|
|
240
|
+
function newestFirst(entries) {
|
|
241
|
+
return [...entries].sort((left, right) => (compareReleaseVersions(right.version, left.version) ?? 0))
|
|
242
|
+
}
|
|
243
|
+
|
|
237
244
|
async function findCodexExecutable(workspace) {
|
|
238
245
|
await ensureNpmPrefixOnPath()
|
|
239
246
|
const filename = IS_WINDOWS ? 'codex.exe' : 'codex'
|
|
@@ -304,6 +311,17 @@ async function findSignedPackagedCodex(packageRoot, workspace) {
|
|
|
304
311
|
} catch { return null }
|
|
305
312
|
}
|
|
306
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Publisher-only acceptance: a valid Authenticode signature whose signer is the
|
|
316
|
+
* vendor. No version or file hash is involved, so newer releases are accepted.
|
|
317
|
+
*/
|
|
318
|
+
export function signatureAccepted(signature, expectedSigner) {
|
|
319
|
+
return signature?.status === 'Valid' && typeof expectedSigner === 'string' && expectedSigner.length > 0
|
|
320
|
+
&& String(signature.subject ?? '').includes(expectedSigner)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const OFFICIAL_SIGNERS = Object.freeze({ codex: CODEX_SIGNER, 'claude-code': CLAUDE_SIGNER })
|
|
324
|
+
|
|
307
325
|
/** PATH or package metadata alone is insufficient: verify the Windows EXE publisher. */
|
|
308
326
|
function hasOfficialWindowsSignature(entry, expectedSigner) {
|
|
309
327
|
// Some launch environments inherit a PowerShell module path where the
|
|
@@ -342,7 +360,7 @@ function hasOfficialWindowsSignature(entry, expectedSigner) {
|
|
|
342
360
|
if (code !== 0) { settle(false); return }
|
|
343
361
|
try {
|
|
344
362
|
const signature = JSON.parse(output.trim().replace(/^\uFEFF/, ''))
|
|
345
|
-
settle(signature
|
|
363
|
+
settle(signatureAccepted(signature, expectedSigner))
|
|
346
364
|
} catch { settle(false) }
|
|
347
365
|
})
|
|
348
366
|
})
|
|
@@ -402,8 +420,8 @@ async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readin
|
|
|
402
420
|
if (toolId === 'kimi-code') {
|
|
403
421
|
const tool = getOfficialTool(toolId)
|
|
404
422
|
const entries = await findGlobalPackageEntries(tool.package, 'kimi', 'dist/main.mjs', workspace)
|
|
405
|
-
const found = entries
|
|
406
|
-
if (!found) return { unsupported:
|
|
423
|
+
const found = newestFirst(entries)[0]
|
|
424
|
+
if (!found) return { unsupported: `未找到官方 npm 包 ${tool.package} 的 kimi 入口(dist/main.mjs)。` }
|
|
407
425
|
const nodeExecutable = await findNodeExecutable(workspace)
|
|
408
426
|
if (!nodeExecutable) return { unsupported: '未找到工作区外的 Node.js 可执行文件。' }
|
|
409
427
|
if (Buffer.byteLength(task, 'utf8') > 16_000) {
|
|
@@ -419,9 +437,8 @@ async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readin
|
|
|
419
437
|
if (toolId === 'minimax-code') {
|
|
420
438
|
const tool = getOfficialTool(toolId)
|
|
421
439
|
const entries = await findGlobalPackageEntries(tool.package, 'mcode', 'cli.js', workspace)
|
|
422
|
-
const found = entries
|
|
423
|
-
|
|
424
|
-
if (!found) return { unsupported: `未找到固定版本 ${tool.package}@${tool.version} 的官方入口。` }
|
|
440
|
+
const found = newestFirst(entries)[0] ?? await findManagedMiniMaxEntry(workspace)
|
|
441
|
+
if (!found) return { unsupported: `未找到官方 npm 包 ${tool.package} 的 mcode 入口(cli.js)。` }
|
|
425
442
|
const nodeExecutable = await findNodeExecutable(workspace, [22, 19, 0], version => {
|
|
426
443
|
const major = Number(String(version).split('.')[0])
|
|
427
444
|
return major === 22 || (major >= 24 && major < 27)
|
|
@@ -444,7 +461,7 @@ async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readin
|
|
|
444
461
|
}
|
|
445
462
|
if (toolId === 'zcode') {
|
|
446
463
|
const found = await discoverZCodeBundle()
|
|
447
|
-
if (!found) return { unsupported: '
|
|
464
|
+
if (!found) return { unsupported: '未找到具有智谱有效签名、且 CLI 脚本与首次核验记录一致的 ZCode 桌面版。' }
|
|
448
465
|
const nodeExecutable = await findNodeExecutable(workspace, [24, 14, 0], version =>
|
|
449
466
|
Number(String(version).split('.')[0]) === 24)
|
|
450
467
|
if (!nodeExecutable) return { unsupported: '未找到工作区外的 Node.js 可执行文件。' }
|
|
@@ -458,7 +475,7 @@ async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readin
|
|
|
458
475
|
format: 'zcode-stream-json', source: found.source, stdinTask: false,
|
|
459
476
|
requestedModel: null,
|
|
460
477
|
modelNotice: modelId
|
|
461
|
-
? 'ZCode
|
|
478
|
+
? 'ZCode 的 CLI 没有 --model 参数;本次使用 ZCode 已配置的默认模型,不能保证与 Harness 建议模型一致。'
|
|
462
479
|
: '本次使用 ZCode 已配置的默认模型。',
|
|
463
480
|
}
|
|
464
481
|
}
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
* (probe + install) and the Desktop panel (display). Pure data: no Node or
|
|
4
4
|
* browser APIs, so both sides and the test suite import it unchanged.
|
|
5
5
|
*
|
|
6
|
-
* The registry is fail-closed by design:
|
|
7
|
-
*
|
|
8
|
-
* registry id — never an arbitrary package or command.
|
|
6
|
+
* The registry is fail-closed by design: installer sources are fixed (the
|
|
7
|
+
* official npm registry, the vendor's signed installer) and clients can only
|
|
8
|
+
* name a registry id — never an arbitrary package or command. Versions are not
|
|
9
|
+
* pinned: installs take each vendor's latest release (`@latest`), and the
|
|
10
|
+
* executor trusts entries by publisher signature or registry-attested digest
|
|
11
|
+
* rather than by version.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
14
|
export const OFFICIAL_TOOLS = Object.freeze([
|
|
@@ -15,9 +18,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
15
18
|
vendor: 'Moonshot AI',
|
|
16
19
|
purpose: 'Kimi 官方编程 CLI,提供 kimi 命令与 ACP 会话。',
|
|
17
20
|
package: '@moonshot-ai/kimi-code',
|
|
18
|
-
version: '2.1.1',
|
|
19
21
|
manager: 'npm',
|
|
20
|
-
installArgs: ['install', '-g', '@moonshot-ai/kimi-code@
|
|
22
|
+
installArgs: ['install', '-g', '@moonshot-ai/kimi-code@latest', '--registry=https://registry.npmjs.org/'],
|
|
21
23
|
probeExecutables: ['kimi'],
|
|
22
24
|
probeNote: 'kimi 与旧 Python 版 kimi-cli 同名;请核对可执行文件来源和版本。',
|
|
23
25
|
providerHints: ['moonshot', 'kimi'],
|
|
@@ -28,9 +30,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
28
30
|
vendor: 'Anthropic',
|
|
29
31
|
purpose: 'Anthropic 官方编程 CLI,提供 claude 命令。',
|
|
30
32
|
package: '@anthropic-ai/claude-code',
|
|
31
|
-
version: '2.1.283',
|
|
32
33
|
manager: 'npm',
|
|
33
|
-
installArgs: ['install', '-g', '@anthropic-ai/claude-code@
|
|
34
|
+
installArgs: ['install', '-g', '@anthropic-ai/claude-code@latest', '--registry=https://registry.npmjs.org/'],
|
|
34
35
|
probeExecutables: ['claude'],
|
|
35
36
|
providerHints: ['anthropic', 'claude'],
|
|
36
37
|
}),
|
|
@@ -40,11 +41,10 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
40
41
|
vendor: 'OpenAI',
|
|
41
42
|
purpose: 'OpenAI 官方编程 CLI,提供 codex 命令。',
|
|
42
43
|
package: '@openai/codex',
|
|
43
|
-
|
|
44
|
-
installArgs: ['install', '-g', '@openai/codex@0.157.1', '--registry=https://registry.npmjs.org/'],
|
|
44
|
+
installArgs: ['install', '-g', '@openai/codex@latest', '--registry=https://registry.npmjs.org/'],
|
|
45
45
|
manager: 'npm',
|
|
46
46
|
probeExecutables: ['codex'],
|
|
47
|
-
probeNote: 'Codex
|
|
47
|
+
probeNote: 'Codex 版本横幅由适配层宽匹配;安装时取 npm 最新版。',
|
|
48
48
|
providerHints: ['openai', 'gpt', 'codex'],
|
|
49
49
|
}),
|
|
50
50
|
Object.freeze({
|
|
@@ -53,9 +53,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
53
53
|
vendor: 'MiniMax',
|
|
54
54
|
purpose: 'MiniMax 官方编程 CLI,提供 mcode 命令。',
|
|
55
55
|
package: '@minimax-ai/code',
|
|
56
|
-
version: '0.5.5',
|
|
57
56
|
manager: 'npm',
|
|
58
|
-
installArgs: ['install', '-g', '@minimax-ai/code@
|
|
57
|
+
installArgs: ['install', '-g', '@minimax-ai/code@latest', '--registry=https://registry.npmjs.org/', '--ignore-scripts=false', '--include=optional', '--allow-scripts=@minimax-ai/code,better-sqlite3'],
|
|
59
58
|
probeExecutables: ['mcode'],
|
|
60
59
|
providerHints: ['minimax'],
|
|
61
60
|
}),
|
|
@@ -65,9 +64,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
65
64
|
vendor: 'XiaoMi',
|
|
66
65
|
purpose: '小米 MiMo 官方编程 CLI,提供 mimo 命令。',
|
|
67
66
|
package: '@mimo-ai/cli',
|
|
68
|
-
version: '0.1.15',
|
|
69
67
|
manager: 'npm',
|
|
70
|
-
installArgs: ['install', '-g', '@mimo-ai/cli@
|
|
68
|
+
installArgs: ['install', '-g', '@mimo-ai/cli@latest', '--registry=https://registry.npmjs.org/'],
|
|
71
69
|
probeExecutables: ['mimo'],
|
|
72
70
|
providerHints: ['mimo', 'xiaomi'],
|
|
73
71
|
}),
|
|
@@ -77,9 +75,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
77
75
|
vendor: 'xAI',
|
|
78
76
|
purpose: 'xAI 官方编程 CLI,提供 grok 命令与 ACP 会话。',
|
|
79
77
|
package: '@xai-official/grok',
|
|
80
|
-
version: '1.0.41',
|
|
81
78
|
manager: 'npm',
|
|
82
|
-
installArgs: ['install', '-g', '@xai-official/grok@
|
|
79
|
+
installArgs: ['install', '-g', '@xai-official/grok@latest', '--registry=https://registry.npmjs.org/'],
|
|
83
80
|
probeExecutables: ['grok'],
|
|
84
81
|
providerHints: ['xai', 'grok'],
|
|
85
82
|
}),
|
|
@@ -89,9 +86,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
89
86
|
vendor: 'Google',
|
|
90
87
|
purpose: 'Google 官方 Gemini CLI,无界面模式使用 gemini -p。',
|
|
91
88
|
package: '@google/gemini-cli',
|
|
92
|
-
version: '0.62.0',
|
|
93
89
|
manager: 'npm',
|
|
94
|
-
installArgs: ['install', '-g', '@google/gemini-cli@
|
|
90
|
+
installArgs: ['install', '-g', '@google/gemini-cli@latest', '--registry=https://registry.npmjs.org/'],
|
|
95
91
|
probeExecutables: ['gemini'],
|
|
96
92
|
providerHints: ['gemini', 'google'],
|
|
97
93
|
// Headless runs go through the task adapter. The signed sandbox runner does
|
|
@@ -103,7 +99,6 @@ export const OFFICIAL_TOOLS = Object.freeze([
|
|
|
103
99
|
label: 'ZCode',
|
|
104
100
|
vendor: 'Z.ai',
|
|
105
101
|
purpose: '智谱官方 ZCode 桌面版,内含 GLM 编程代理。Windows 安装器可选择 D 盘目录。',
|
|
106
|
-
version: '3.14.3',
|
|
107
102
|
manager: 'signed-windows-installer',
|
|
108
103
|
installArgs: [],
|
|
109
104
|
probeExecutables: [],
|