@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,801 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, non-interactive execution of verified official CLI entry points.
|
|
3
|
+
*
|
|
4
|
+
* The Host must authorize the request and create an isolated workspace before
|
|
5
|
+
* offering the write mode. The caller supplies a registry id, task text, and
|
|
6
|
+
* an absolute workspace, never a command or arbitrary arguments. Write mode
|
|
7
|
+
* additionally needs a trusted isolation root supplied by the Host;
|
|
8
|
+
* this module rejects a workspace outside that root.
|
|
9
|
+
*
|
|
10
|
+
* Claude: https://code.claude.com/docs/en/headless
|
|
11
|
+
* https://code.claude.com/docs/en/cli-reference
|
|
12
|
+
* Codex: https://learn.chatgpt.com/docs/non-interactive-mode
|
|
13
|
+
* Kimi's `-p` enables auto permissions:
|
|
14
|
+
* https://moonshotai.github.io/kimi-code/en/reference/kimi-command.html
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { access, readFile, realpath, stat } from 'node:fs/promises'
|
|
19
|
+
import { constants } from 'node:fs'
|
|
20
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
21
|
+
import { basename, delimiter, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
22
|
+
import { getOfficialTool, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
|
|
23
|
+
import { ensureNpmPrefixOnPath } from './official-tools-runtime.mjs'
|
|
24
|
+
import { buildMiniMaxInvocation, createMiniMaxStreamParser } from './vendor-minimax-adapter.mjs'
|
|
25
|
+
import { discoverZCodeBundle } from './zcode-bundle.mjs'
|
|
26
|
+
import { resolveMiMoGrokLaunch, createMiMoGrokParser } from './vendor-mimo-grok-adapter.mjs'
|
|
27
|
+
import { usageFromEvents, usageFromOutput } from './task-executors.mjs'
|
|
28
|
+
|
|
29
|
+
const IS_WINDOWS = process.platform === 'win32'
|
|
30
|
+
const MAX_TASK_BYTES = 64_000
|
|
31
|
+
const MAX_OUTPUT_BYTES = 32_000_000
|
|
32
|
+
const OUTPUT_TAIL_CHARS = 32_000
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 45 * 60_000
|
|
34
|
+
const MAX_TIMEOUT_MS = 2 * 60 * 60_000
|
|
35
|
+
const STOP_GRACE_MS = 5_000
|
|
36
|
+
const CLAUDE_MIN_RESTRICTED_VERSION = [2, 1, 259]
|
|
37
|
+
const CLAUDE_SIGNER = 'Anthropic, PBC'
|
|
38
|
+
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
|
+
const WINDOWS_SYSTEM32 = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32')
|
|
43
|
+
const ENVIRONMENT_KEYS = Object.freeze([
|
|
44
|
+
'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'windir', 'ComSpec',
|
|
45
|
+
'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA',
|
|
46
|
+
'PROGRAMDATA', 'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TERM',
|
|
47
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
48
|
+
'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
const CAPABILITIES = Object.freeze({
|
|
52
|
+
'claude-code': Object.freeze(IS_WINDOWS
|
|
53
|
+
? { supported: true, modes: Object.freeze(['read-only', 'workspace-write']) }
|
|
54
|
+
: { supported: false, reason: 'Claude 的受限原生入口目前仅在 Windows 完成适配。' }),
|
|
55
|
+
codex: Object.freeze(IS_WINDOWS
|
|
56
|
+
? { supported: true, modes: Object.freeze(['read-only', 'workspace-write']) }
|
|
57
|
+
: { supported: false, reason: 'Codex 的签名原生入口目前仅在 Windows 完成适配。' }),
|
|
58
|
+
'kimi-code': Object.freeze({
|
|
59
|
+
supported: true, modes: Object.freeze(['workspace-write']),
|
|
60
|
+
}),
|
|
61
|
+
'minimax-code': Object.freeze({ supported: true, modes: Object.freeze(['workspace-write']) }),
|
|
62
|
+
'mimo-code': Object.freeze(IS_WINDOWS
|
|
63
|
+
? { supported: true, modes: Object.freeze(['read-only', 'workspace-write']) }
|
|
64
|
+
: { supported: false, reason: 'MiMo 原生执行入口目前仅在 Windows 完成适配。' }),
|
|
65
|
+
'grok-build': Object.freeze(IS_WINDOWS
|
|
66
|
+
? { supported: true, modes: Object.freeze(['read-only', 'workspace-write']) }
|
|
67
|
+
: { supported: false, reason: 'Grok 原生执行入口目前仅在 Windows 完成适配。' }),
|
|
68
|
+
zcode: Object.freeze(IS_WINDOWS
|
|
69
|
+
? { supported: true, modes: Object.freeze(['workspace-write']) }
|
|
70
|
+
: { supported: false, reason: 'ZCode 固定桌面发行版目前仅核验了 Windows x64。' }),
|
|
71
|
+
gemini: Object.freeze({
|
|
72
|
+
supported: false,
|
|
73
|
+
reason: 'Gemini 由无界面适配器执行(gemini -p),不走签名沙箱入口;未安装或失败时回退模型目录 API。',
|
|
74
|
+
}),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/** A UI/Host can show this without implying that installation means execution readiness. */
|
|
78
|
+
export function officialToolExecutionCapabilities() {
|
|
79
|
+
return OFFICIAL_TOOLS.map(tool => ({ id: tool.id, ...CAPABILITIES[tool.id] }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function inside(parent, child) {
|
|
83
|
+
const part = relative(parent, child)
|
|
84
|
+
return part !== '' && part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function checkedWorkspace(workspace) {
|
|
88
|
+
if (typeof workspace !== 'string' || !isAbsolute(workspace) || workspace.includes('\0')) {
|
|
89
|
+
throw new TypeError('workspace must be an absolute directory path')
|
|
90
|
+
}
|
|
91
|
+
const canonical = await realpath(workspace)
|
|
92
|
+
if (!(await stat(canonical)).isDirectory()) throw new TypeError('workspace must be a directory')
|
|
93
|
+
if (resolve(canonical) === resolve(canonical, '..')) {
|
|
94
|
+
throw new TypeError('a filesystem root is not an acceptable workspace')
|
|
95
|
+
}
|
|
96
|
+
return canonical
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkedWriteWorkspace(workspace, isolatedRoot) {
|
|
100
|
+
if (typeof isolatedRoot !== 'string' || !isAbsolute(isolatedRoot)) {
|
|
101
|
+
throw new TypeError('workspace-write requires a trusted absolute isolatedRoot')
|
|
102
|
+
}
|
|
103
|
+
const root = await checkedWorkspace(isolatedRoot)
|
|
104
|
+
if (!inside(root, workspace)) {
|
|
105
|
+
throw new TypeError('workspace-write is allowed only inside isolatedRoot')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkedMode(mode) {
|
|
110
|
+
if (mode !== 'read-only' && mode !== 'workspace-write') {
|
|
111
|
+
throw new TypeError('mode must be read-only or workspace-write')
|
|
112
|
+
}
|
|
113
|
+
return mode
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function checkedTask(task) {
|
|
117
|
+
if (typeof task !== 'string' || !task.trim() || task.includes('\0') || Buffer.byteLength(task, 'utf8') > MAX_TASK_BYTES) {
|
|
118
|
+
throw new TypeError(`task must be nonempty text of at most ${MAX_TASK_BYTES} UTF-8 bytes`)
|
|
119
|
+
}
|
|
120
|
+
return task
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function checkedModel(modelId) {
|
|
124
|
+
if (modelId === undefined || modelId === null || modelId === '') return null
|
|
125
|
+
if (typeof modelId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/.test(modelId)) {
|
|
126
|
+
throw new TypeError('modelId must be a bounded model identifier')
|
|
127
|
+
}
|
|
128
|
+
return modelId
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function checkedTimeout(timeoutMs) {
|
|
132
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > MAX_TIMEOUT_MS) {
|
|
133
|
+
throw new TypeError(`timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`)
|
|
134
|
+
}
|
|
135
|
+
return timeoutMs
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** API-key variables removed when a run must use the CLI's own subscription login. */
|
|
139
|
+
const API_KEY_VARIABLES = Object.freeze(['ANTHROPIC_API_KEY', 'CODEX_API_KEY', 'OPENAI_API_KEY', 'KIMI_API_KEY',
|
|
140
|
+
'MOONSHOT_API_KEY', 'MINIMAX_API_KEY', 'MIMO_API_KEY', 'XAI_API_KEY', 'ZAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY'])
|
|
141
|
+
|
|
142
|
+
function executionEnvironment(toolId, { sessionOnly = false } = {}) {
|
|
143
|
+
const vendorKeys = {
|
|
144
|
+
'claude-code': ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CONFIG_DIR'],
|
|
145
|
+
codex: ['CODEX_API_KEY', 'OPENAI_API_KEY', 'CODEX_HOME'],
|
|
146
|
+
'kimi-code': ['KIMI_CODE_HOME', 'KIMI_API_KEY', 'MOONSHOT_API_KEY'],
|
|
147
|
+
'minimax-code': ['MINIMAX_API_KEY', 'MINIMAX_BASE_URL'],
|
|
148
|
+
'mimo-code': ['MIMO_API_KEY'],
|
|
149
|
+
'grok-build': ['XAI_API_KEY', 'GROK_HOME'],
|
|
150
|
+
zcode: ['ZAI_API_KEY', 'ZCODE_HOME'],
|
|
151
|
+
}
|
|
152
|
+
const keys = [...ENVIRONMENT_KEYS, ...(vendorKeys[toolId] ?? [])]
|
|
153
|
+
.filter(key => !sessionOnly || !API_KEY_VARIABLES.includes(key))
|
|
154
|
+
const env = {}
|
|
155
|
+
for (const key of keys) {
|
|
156
|
+
if (process.env[key] !== undefined) env[key] = process.env[key]
|
|
157
|
+
}
|
|
158
|
+
return env
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pathDirectories() {
|
|
162
|
+
const value = process.env.PATH ?? process.env.Path ?? ''
|
|
163
|
+
return [...new Set(value.split(delimiter).map(part => part.trim().replace(/^"|"$/g, '')).filter(Boolean))]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function versionAtLeast(value, minimum) {
|
|
167
|
+
const found = /^(\d+)\.(\d+)\.(\d+)(?:$|-)/.exec(String(value ?? ''))
|
|
168
|
+
if (!found) return false
|
|
169
|
+
for (let index = 0; index < 3; index += 1) {
|
|
170
|
+
const difference = Number(found[index + 1]) - minimum[index]
|
|
171
|
+
if (difference !== 0) return difference > 0
|
|
172
|
+
}
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolve the package's real `bin` file, never its Windows .cmd shim. */
|
|
177
|
+
async function findGlobalPackageEntries(packageName, binName, expectedBin, workspace) {
|
|
178
|
+
await ensureNpmPrefixOnPath()
|
|
179
|
+
const packageParts = packageName.split('/')
|
|
180
|
+
const found = []
|
|
181
|
+
const seen = new Set()
|
|
182
|
+
for (const directory of pathDirectories()) {
|
|
183
|
+
const moduleParents = [join(directory, 'node_modules')]
|
|
184
|
+
if (basename(directory).toLowerCase() === '.bin') moduleParents.push(resolve(directory, '..'))
|
|
185
|
+
if (!IS_WINDOWS) moduleParents.push(resolve(directory, '..', 'lib', 'node_modules'))
|
|
186
|
+
for (const modules of moduleParents) {
|
|
187
|
+
const packageDirectory = join(modules, ...packageParts)
|
|
188
|
+
try {
|
|
189
|
+
const manifest = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8'))
|
|
190
|
+
if (manifest.name !== packageName || manifest.bin?.[binName] !== expectedBin) continue
|
|
191
|
+
const packageRoot = await realpath(packageDirectory)
|
|
192
|
+
if (packageRoot === workspace || inside(workspace, packageRoot)) continue
|
|
193
|
+
if (seen.has(packageRoot)) continue
|
|
194
|
+
const entry = await realpath(join(packageRoot, expectedBin))
|
|
195
|
+
if (!inside(packageRoot, entry) || !(await stat(entry)).isFile()) continue
|
|
196
|
+
seen.add(packageRoot)
|
|
197
|
+
found.push({ entry, packageRoot, version: manifest.version, source: 'verified-npm-package' })
|
|
198
|
+
} catch { /* this PATH entry is not the requested package */ }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return found
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The official MiniMax Windows installer uses a versioned releases tree rather than npm -g. */
|
|
205
|
+
export async function findManagedMiniMaxEntry(workspace,
|
|
206
|
+
directories = pathDirectories().flatMap(directory => [directory, join(directory, '.minimax-code')]),
|
|
207
|
+
trustedCliSha256 = MINIMAX_055_CLI_SHA256) {
|
|
208
|
+
if (!IS_WINDOWS) return null
|
|
209
|
+
const version = getOfficialTool('minimax-code').version
|
|
210
|
+
for (const directory of directories) {
|
|
211
|
+
try {
|
|
212
|
+
const root = await realpath(directory)
|
|
213
|
+
if (root === workspace || inside(workspace, root)) continue
|
|
214
|
+
if (!(await stat(join(root, 'mcode.cmd'))).isFile()) continue
|
|
215
|
+
const current = (await readFile(join(root, 'current'), 'utf8')).trim()
|
|
216
|
+
if (current !== version) continue
|
|
217
|
+
const releaseRoot = await realpath(join(root, 'releases', version))
|
|
218
|
+
if (!inside(root, releaseRoot)) continue
|
|
219
|
+
const packageRoot = await realpath(join(releaseRoot, 'node_modules', '@minimax-ai', 'code'))
|
|
220
|
+
if (!inside(releaseRoot, packageRoot)) continue
|
|
221
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
|
|
222
|
+
if (manifest.name !== '@minimax-ai/code' || manifest.version !== version
|
|
223
|
+
|| String(manifest.bin?.mcode).replace(/^\.\//, '') !== 'cli.js') continue
|
|
224
|
+
const entry = await realpath(join(packageRoot, 'cli.js'))
|
|
225
|
+
if (!inside(packageRoot, entry) || !(await stat(entry)).isFile()) continue
|
|
226
|
+
const digest = createHash('sha256').update(await readFile(entry)).digest('hex')
|
|
227
|
+
if (digest !== trustedCliSha256) continue
|
|
228
|
+
const native = await realpath(join(packageRoot, 'node_modules', 'better-sqlite3',
|
|
229
|
+
'build', 'Release', 'better_sqlite3.node'))
|
|
230
|
+
if (!inside(packageRoot, native) || !(await stat(native)).isFile()) continue
|
|
231
|
+
return { entry, packageRoot, version, source: 'verified-official-windows-installer-bundle' }
|
|
232
|
+
} catch { /* inspect the next PATH directory */ }
|
|
233
|
+
}
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function findCodexExecutable(workspace) {
|
|
238
|
+
await ensureNpmPrefixOnPath()
|
|
239
|
+
const filename = IS_WINDOWS ? 'codex.exe' : 'codex'
|
|
240
|
+
for (const directory of pathDirectories()) {
|
|
241
|
+
try {
|
|
242
|
+
const pathRoot = await realpath(directory)
|
|
243
|
+
if (pathRoot === workspace || inside(workspace, pathRoot)) continue
|
|
244
|
+
const entry = await realpath(join(directory, filename))
|
|
245
|
+
if (inside(workspace, entry) || !(await stat(entry)).isFile()) continue
|
|
246
|
+
await access(entry, IS_WINDOWS ? constants.F_OK : constants.X_OK)
|
|
247
|
+
if (IS_WINDOWS && !(await hasOfficialWindowsSignature(entry, CODEX_SIGNER))) continue
|
|
248
|
+
return { entry, source: IS_WINDOWS ? 'signed-native-executable' : 'path-native-executable' }
|
|
249
|
+
} catch { /* try the next PATH entry */ }
|
|
250
|
+
}
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function nodeVersion(entry) {
|
|
255
|
+
return new Promise(resolveVersion => {
|
|
256
|
+
let child
|
|
257
|
+
try { child = spawn(entry, ['--version'], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) }
|
|
258
|
+
catch { resolveVersion(null); return }
|
|
259
|
+
let output = ''
|
|
260
|
+
let settled = false
|
|
261
|
+
const finish = value => {
|
|
262
|
+
if (settled) return
|
|
263
|
+
settled = true
|
|
264
|
+
clearTimeout(timer)
|
|
265
|
+
resolveVersion(value)
|
|
266
|
+
}
|
|
267
|
+
const timer = setTimeout(() => { child.kill('SIGTERM'); finish(null) }, 4_000)
|
|
268
|
+
child.stdout.on('data', bytes => { output = tail(output, String(bytes)) })
|
|
269
|
+
child.on('error', () => finish(null))
|
|
270
|
+
child.on('close', code => finish(code === 0 ? /^v(\d+\.\d+\.\d+)/.exec(output.trim())?.[1] ?? null : null))
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function findNodeExecutable(workspace, minimum = [22, 19, 0], acceptedVersion = () => true) {
|
|
275
|
+
await ensureNpmPrefixOnPath()
|
|
276
|
+
const candidates = [process.execPath, ...pathDirectories().map(directory => join(directory, IS_WINDOWS ? 'node.exe' : 'node'))]
|
|
277
|
+
for (const candidate of candidates) {
|
|
278
|
+
if (basename(candidate).toLowerCase() !== (IS_WINDOWS ? 'node.exe' : 'node')) continue
|
|
279
|
+
try {
|
|
280
|
+
const entry = await realpath(candidate)
|
|
281
|
+
if (inside(workspace, entry) || !(await stat(entry)).isFile()) continue
|
|
282
|
+
await access(entry, IS_WINDOWS ? constants.F_OK : constants.X_OK)
|
|
283
|
+
if (IS_WINDOWS && !(await hasOfficialWindowsSignature(entry, 'OpenJS Foundation'))) continue
|
|
284
|
+
const version = await nodeVersion(entry)
|
|
285
|
+
if (!versionAtLeast(version, minimum) || !acceptedVersion(version)) continue
|
|
286
|
+
return entry
|
|
287
|
+
} catch { /* inspect next candidate */ }
|
|
288
|
+
}
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Resolve the signed native binary carried by the official npm wrapper. */
|
|
293
|
+
async function findSignedPackagedCodex(packageRoot, workspace) {
|
|
294
|
+
if (!IS_WINDOWS) return null
|
|
295
|
+
const target = process.arch === 'arm64' ? ['codex-win32-arm64', 'aarch64-pc-windows-msvc']
|
|
296
|
+
: process.arch === 'x64' ? ['codex-win32-x64', 'x86_64-pc-windows-msvc'] : null
|
|
297
|
+
if (!target) return null
|
|
298
|
+
const candidate = join(packageRoot, 'node_modules', '@openai', target[0], 'vendor', target[1], 'bin', 'codex.exe')
|
|
299
|
+
try {
|
|
300
|
+
const entry = await realpath(candidate)
|
|
301
|
+
if (!inside(packageRoot, entry) || inside(workspace, entry) || !(await stat(entry)).isFile()) return null
|
|
302
|
+
if (!(await hasOfficialWindowsSignature(entry, CODEX_SIGNER))) return null
|
|
303
|
+
return { entry, source: 'signed-official-npm-native' }
|
|
304
|
+
} catch { return null }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** PATH or package metadata alone is insufficient: verify the Windows EXE publisher. */
|
|
308
|
+
function hasOfficialWindowsSignature(entry, expectedSigner) {
|
|
309
|
+
// Some launch environments inherit a PowerShell module path where the
|
|
310
|
+
// built-in Security module does not autoload. Import its fixed system path
|
|
311
|
+
// explicitly, and fail closed if that import or the signature check fails.
|
|
312
|
+
const modulePath = join(WINDOWS_SYSTEM32, 'WindowsPowerShell', 'v1.0', 'Modules',
|
|
313
|
+
'Microsoft.PowerShell.Security', 'Microsoft.PowerShell.Security.psd1')
|
|
314
|
+
const script = '$ErrorActionPreference="Stop"; '
|
|
315
|
+
+ 'Import-Module -Name $env:MODEL_ROUTER_SECURITY_MODULE -ErrorAction Stop; '
|
|
316
|
+
+ '$s=Get-AuthenticodeSignature -LiteralPath $env:MODEL_ROUTER_VERIFY_PATH; '
|
|
317
|
+
+ '@{status=[string]$s.Status;subject=[string]$s.SignerCertificate.Subject} | ConvertTo-Json -Compress'
|
|
318
|
+
const powershell = join(WINDOWS_SYSTEM32, 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
319
|
+
return new Promise(resolveSignature => {
|
|
320
|
+
let child
|
|
321
|
+
try {
|
|
322
|
+
child = spawn(powershell, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
323
|
+
shell: false,
|
|
324
|
+
windowsHide: true,
|
|
325
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
326
|
+
env: { ...process.env, MODEL_ROUTER_VERIFY_PATH: entry,
|
|
327
|
+
MODEL_ROUTER_SECURITY_MODULE: modulePath },
|
|
328
|
+
})
|
|
329
|
+
} catch { resolveSignature(false); return }
|
|
330
|
+
let output = ''
|
|
331
|
+
let done = false
|
|
332
|
+
const settle = valid => {
|
|
333
|
+
if (done) return
|
|
334
|
+
done = true
|
|
335
|
+
clearTimeout(timer)
|
|
336
|
+
resolveSignature(valid)
|
|
337
|
+
}
|
|
338
|
+
const timer = setTimeout(() => { child.kill('SIGTERM'); settle(false) }, 8_000)
|
|
339
|
+
child.stdout.on('data', bytes => { output = tail(output, String(bytes)).slice(-4_096) })
|
|
340
|
+
child.on('error', () => settle(false))
|
|
341
|
+
child.on('close', code => {
|
|
342
|
+
if (code !== 0) { settle(false); return }
|
|
343
|
+
try {
|
|
344
|
+
const signature = JSON.parse(output.trim().replace(/^\uFEFF/, ''))
|
|
345
|
+
settle(signature.status === 'Valid' && String(signature.subject).includes(expectedSigner))
|
|
346
|
+
} catch { settle(false) }
|
|
347
|
+
})
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function launchSpec(toolId, workspace, mode, modelId, task = 'Check readiness.') {
|
|
352
|
+
if (toolId === 'claude-code') {
|
|
353
|
+
const entries = await findGlobalPackageEntries('@anthropic-ai/claude-code', 'claude', 'bin/claude.exe', workspace)
|
|
354
|
+
const compatible = entries.filter(entry => versionAtLeast(entry.version, CLAUDE_MIN_RESTRICTED_VERSION))
|
|
355
|
+
let found = null
|
|
356
|
+
for (const entry of compatible) {
|
|
357
|
+
if (!IS_WINDOWS || await hasOfficialWindowsSignature(entry.entry, CLAUDE_SIGNER)) {
|
|
358
|
+
found = entry
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (!found) return { unsupported: compatible.length > 0
|
|
363
|
+
? '未找到具有有效 Anthropic 签名的 claude.exe。'
|
|
364
|
+
: entries.length > 0 ? 'Claude Code 版本过旧,受限执行所需参数要求 >= 2.1.259。'
|
|
365
|
+
: '未找到官方 @anthropic-ai/claude-code 包的原生 claude.exe;不会调用 .cmd 包装器。' }
|
|
366
|
+
return {
|
|
367
|
+
file: found.entry,
|
|
368
|
+
args: [
|
|
369
|
+
'--no-session-persistence',
|
|
370
|
+
...(mode === 'read-only' ? [
|
|
371
|
+
'--restricted', '--disable-slash-commands', '--strict-mcp-config',
|
|
372
|
+
'--tools', 'Read,Glob,Grep', '--disallowedTools', 'mcp__*',
|
|
373
|
+
] : []),
|
|
374
|
+
'--permission-mode', mode === 'workspace-write' ? 'bypassPermissions' : 'dontAsk',
|
|
375
|
+
...(modelId ? ['--model', modelId] : []),
|
|
376
|
+
'-p', '--output-format', 'json',
|
|
377
|
+
mode === 'workspace-write'
|
|
378
|
+
? 'Carry out the task supplied on standard input. Work inside this isolated Git workspace. You may use your normal official tools, including shell, skills and configured MCP. Report what changed, commands run, and what still needs verification.'
|
|
379
|
+
: 'Read the task supplied on standard input. Analyze the workspace without changing files and give a clear final answer.',
|
|
380
|
+
],
|
|
381
|
+
format: 'claude-json',
|
|
382
|
+
source: found.source,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (toolId === 'codex') {
|
|
386
|
+
if (!IS_WINDOWS) return { unsupported: CAPABILITIES.codex.reason }
|
|
387
|
+
const packages = await findGlobalPackageEntries('@openai/codex', 'codex', 'bin/codex.js', workspace)
|
|
388
|
+
let found = null
|
|
389
|
+
for (const entry of packages) {
|
|
390
|
+
found = await findSignedPackagedCodex(entry.packageRoot, workspace)
|
|
391
|
+
if (found) break
|
|
392
|
+
}
|
|
393
|
+
found ??= await findCodexExecutable(workspace)
|
|
394
|
+
if (!found) return { unsupported: '未找到具有 OpenAI 有效签名的 codex.exe。' }
|
|
395
|
+
return {
|
|
396
|
+
file: found.entry,
|
|
397
|
+
args: [
|
|
398
|
+
'--ask-for-approval', 'never', 'exec',
|
|
399
|
+
...(modelId ? ['--model', modelId] : []),
|
|
400
|
+
'--sandbox', mode, '--json', '-',
|
|
401
|
+
],
|
|
402
|
+
format: 'codex-jsonl',
|
|
403
|
+
source: found.source,
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (toolId === 'kimi-code') {
|
|
407
|
+
const tool = getOfficialTool(toolId)
|
|
408
|
+
const entries = await findGlobalPackageEntries(tool.package, 'kimi', 'dist/main.mjs', workspace)
|
|
409
|
+
const found = entries.find(entry => entry.version === tool.version)
|
|
410
|
+
if (!found) return { unsupported: `未找到固定版本 ${tool.package}@${tool.version} 的官方入口。` }
|
|
411
|
+
const nodeExecutable = await findNodeExecutable(workspace)
|
|
412
|
+
if (!nodeExecutable) return { unsupported: '未找到工作区外的 Node.js 可执行文件。' }
|
|
413
|
+
if (Buffer.byteLength(task, 'utf8') > 16_000) {
|
|
414
|
+
return { unsupported: 'Kimi 单次 -p 参数超过 16 KB;请先拆分任务。' }
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
file: nodeExecutable,
|
|
418
|
+
args: [found.entry, ...(modelId ? ['--model', modelId] : []),
|
|
419
|
+
'--prompt', task, '--output-format', 'stream-json'],
|
|
420
|
+
format: 'kimi-stream-json', source: found.source, stdinTask: false,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (toolId === 'minimax-code') {
|
|
424
|
+
const tool = getOfficialTool(toolId)
|
|
425
|
+
const entries = await findGlobalPackageEntries(tool.package, 'mcode', 'cli.js', workspace)
|
|
426
|
+
const found = entries.find(entry => entry.version === tool.version)
|
|
427
|
+
?? await findManagedMiniMaxEntry(workspace)
|
|
428
|
+
if (!found) return { unsupported: `未找到固定版本 ${tool.package}@${tool.version} 的官方入口。` }
|
|
429
|
+
const nodeExecutable = await findNodeExecutable(workspace, [22, 19, 0], version => {
|
|
430
|
+
const major = Number(String(version).split('.')[0])
|
|
431
|
+
return major === 22 || (major >= 24 && major < 27)
|
|
432
|
+
})
|
|
433
|
+
if (!nodeExecutable) return { unsupported: '未找到工作区外的 Node.js 可执行文件。' }
|
|
434
|
+
if (modelId && !modelId.includes('/')) {
|
|
435
|
+
return { unsupported: 'MiniMax CLI 要求 provider/model;当前模型目录 ID 不能直接传入,请在 MiniMax 中配置别名。' }
|
|
436
|
+
}
|
|
437
|
+
return { ...buildMiniMaxInvocation({
|
|
438
|
+
nodeExecutable, cliEntry: found.entry, workspace, mode,
|
|
439
|
+
modelReference: modelId, permission: mode === 'workspace-write' ? 'full' : 'smart',
|
|
440
|
+
}), source: found.source }
|
|
441
|
+
}
|
|
442
|
+
if (toolId === 'mimo-code' || toolId === 'grok-build') {
|
|
443
|
+
try {
|
|
444
|
+
return await resolveMiMoGrokLaunch({ toolId, workspace, mode, modelId, task })
|
|
445
|
+
} catch (error) {
|
|
446
|
+
return { unsupported: `官方原生入口校验失败:${String(error?.message ?? error).slice(0, 240)}` }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (toolId === 'zcode') {
|
|
450
|
+
const found = await discoverZCodeBundle()
|
|
451
|
+
if (!found) return { unsupported: '未找到已签名、版本匹配且 CLI 脚本哈希正确的 ZCode 桌面版。' }
|
|
452
|
+
const nodeExecutable = await findNodeExecutable(workspace, [24, 14, 0], version =>
|
|
453
|
+
Number(String(version).split('.')[0]) === 24)
|
|
454
|
+
if (!nodeExecutable) return { unsupported: '未找到工作区外的 Node.js 可执行文件。' }
|
|
455
|
+
if (Buffer.byteLength(task, 'utf8') > 16_000) {
|
|
456
|
+
return { unsupported: 'ZCode 单次 --prompt 参数超过 16 KB;请先拆分任务。' }
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
file: nodeExecutable,
|
|
460
|
+
args: [found.cliEntry, '--prompt', task, '--cwd', workspace,
|
|
461
|
+
'--mode', mode === 'workspace-write' ? 'yolo' : 'plan', '--output-format', 'stream-json'],
|
|
462
|
+
format: 'zcode-stream-json', source: found.source, stdinTask: false,
|
|
463
|
+
requestedModel: null,
|
|
464
|
+
modelNotice: modelId
|
|
465
|
+
? 'ZCode 3.14.3 的 CLI 没有 --model 参数;本次使用 ZCode 已配置的默认模型,不能保证与 Harness 建议模型一致。'
|
|
466
|
+
: '本次使用 ZCode 已配置的默认模型。',
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return { unsupported: CAPABILITIES[toolId]?.reason ?? '此官方工具尚无已核验的安全执行适配器。' }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Check the actual trusted launch entry without starting an account call. */
|
|
473
|
+
export async function officialToolReadiness(toolId, workspace = process.cwd()) {
|
|
474
|
+
const tool = getOfficialTool(toolId)
|
|
475
|
+
if (!tool) return { id: String(toolId), ready: false, reason: '工具不在固定官方注册表中。' }
|
|
476
|
+
const capability = CAPABILITIES[tool.id]
|
|
477
|
+
if (!capability?.supported) return { id: tool.id, ready: false, reason: capability?.reason ?? '当前平台不支持托管执行。' }
|
|
478
|
+
try {
|
|
479
|
+
const directory = await checkedWorkspace(workspace)
|
|
480
|
+
const spec = await launchSpec(tool.id, directory, capability.modes[0], null)
|
|
481
|
+
return spec.unsupported
|
|
482
|
+
? { id: tool.id, ready: false, reason: spec.unsupported }
|
|
483
|
+
: { id: tool.id, ready: true, source: spec.source, modes: capability.modes }
|
|
484
|
+
} catch (error) {
|
|
485
|
+
return { id: tool.id, ready: false, reason: String(error?.message ?? error).slice(0, 300) }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function tail(current, addition) {
|
|
490
|
+
const next = current + addition
|
|
491
|
+
return next.length > OUTPUT_TAIL_CHARS ? next.slice(-OUTPUT_TAIL_CHARS) : next
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function stopProcessTree(child) {
|
|
495
|
+
if (child.pid === undefined) return null
|
|
496
|
+
if (IS_WINDOWS) {
|
|
497
|
+
try {
|
|
498
|
+
const killer = spawn(join(WINDOWS_SYSTEM32, 'taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], {
|
|
499
|
+
shell: false, windowsHide: true, stdio: 'ignore',
|
|
500
|
+
})
|
|
501
|
+
killer.on('error', () => { try { child.kill('SIGTERM') } catch { /* gone */ } })
|
|
502
|
+
killer.on('close', code => {
|
|
503
|
+
if (code !== 0) try { child.kill('SIGTERM') } catch { /* gone */ }
|
|
504
|
+
})
|
|
505
|
+
} catch { try { child.kill('SIGTERM') } catch { /* gone */ } }
|
|
506
|
+
return null
|
|
507
|
+
} else {
|
|
508
|
+
try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill('SIGTERM') } catch { /* gone */ } }
|
|
509
|
+
const timer = setTimeout(() => {
|
|
510
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch { /* gone */ }
|
|
511
|
+
}, 2_000)
|
|
512
|
+
return timer
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function captureProcess(spec, task, workspace, signal, timeoutMs, toolId, sessionOnly = false) {
|
|
517
|
+
return new Promise(resolveResult => {
|
|
518
|
+
let child
|
|
519
|
+
try {
|
|
520
|
+
child = spawn(spec.file, spec.args, {
|
|
521
|
+
cwd: workspace,
|
|
522
|
+
shell: false,
|
|
523
|
+
windowsHide: true,
|
|
524
|
+
detached: !IS_WINDOWS,
|
|
525
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
526
|
+
env: executionEnvironment(toolId, { sessionOnly }),
|
|
527
|
+
})
|
|
528
|
+
} catch (error) {
|
|
529
|
+
resolveResult({ status: 'failed', error: String(error?.message ?? error), exitCode: null })
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let finished = false
|
|
534
|
+
let stopReason = null
|
|
535
|
+
let stdout = ''
|
|
536
|
+
let stdoutTail = ''
|
|
537
|
+
let stderrTail = ''
|
|
538
|
+
let outputBytes = 0
|
|
539
|
+
let jsonlBuffer = ''
|
|
540
|
+
let codexUsageLine = ''
|
|
541
|
+
let reportedUsage = null
|
|
542
|
+
let terminal = null
|
|
543
|
+
let failedEvent = false
|
|
544
|
+
let finalText = ''
|
|
545
|
+
let protocolError = null
|
|
546
|
+
let zcodeResult = null
|
|
547
|
+
let zcodeSession = null
|
|
548
|
+
const zcodeStarted = new Set()
|
|
549
|
+
const zcodeCompleted = new Set()
|
|
550
|
+
const miniMaxParser = spec.format === 'minimax-stream-json' ? createMiniMaxStreamParser() : null
|
|
551
|
+
const nativeParser = spec.format === 'mimo-jsonl' || spec.format === 'grok-jsonl'
|
|
552
|
+
? createMiMoGrokParser(toolId) : null
|
|
553
|
+
let kimiAssistant = false
|
|
554
|
+
const stdoutDecoder = new StringDecoder('utf8')
|
|
555
|
+
const stderrDecoder = new StringDecoder('utf8')
|
|
556
|
+
|
|
557
|
+
const readJsonlLine = line => {
|
|
558
|
+
if (!line.trim()) return
|
|
559
|
+
if (nativeParser) { nativeParser.push(line); return }
|
|
560
|
+
try {
|
|
561
|
+
const event = JSON.parse(line)
|
|
562
|
+
if (spec.format === 'zcode-stream-json') {
|
|
563
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)
|
|
564
|
+
|| typeof event.type !== 'string' || typeof event.sessionId !== 'string'
|
|
565
|
+
|| !event.sessionId) {
|
|
566
|
+
protocolError = 'ZCode 返回了无效事件。'
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
if (zcodeResult) {
|
|
570
|
+
protocolError = 'ZCode 在最终结果之后仍返回事件。'
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
if (zcodeSession && zcodeSession !== event.sessionId) {
|
|
574
|
+
protocolError = 'ZCode 混入了其他会话的事件。'
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
zcodeSession = event.sessionId
|
|
578
|
+
if (event.type === 'result') {
|
|
579
|
+
zcodeResult = event
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
if (event.type === 'turn.failed') failedEvent = true
|
|
583
|
+
if (event.type === 'turn.started' || event.type === 'turn.completed') {
|
|
584
|
+
if (typeof event.turnId !== 'string' || !event.turnId) {
|
|
585
|
+
protocolError = 'ZCode 回合事件缺少 turnId。'
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
if (event.type === 'turn.started') zcodeStarted.add(event.turnId)
|
|
589
|
+
else if (event.payload?.resultType === 'success') zcodeCompleted.add(event.turnId)
|
|
590
|
+
else failedEvent = true
|
|
591
|
+
}
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (spec.format === 'kimi-stream-json') {
|
|
595
|
+
if (event.role === 'meta' && /error|failed/i.test(String(event.type ?? ''))) failedEvent = true
|
|
596
|
+
if (event.role === 'assistant' && !event.tool_calls?.length) {
|
|
597
|
+
const content = typeof event.content === 'string' ? event.content
|
|
598
|
+
: Array.isArray(event.content) ? event.content.filter(part => part?.type === 'text')
|
|
599
|
+
.map(part => String(part.text ?? '')).join('') : ''
|
|
600
|
+
if (content.trim()) { finalText = content; kimiAssistant = true }
|
|
601
|
+
}
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
if (event.type === 'turn.completed' && event.usage) codexUsageLine = line
|
|
605
|
+
if (event.type === 'turn.completed' && !failedEvent) terminal = 'completed'
|
|
606
|
+
// Top-level `error` events include transient reconnect notices; only turn.failed is terminal.
|
|
607
|
+
if (event.type === 'turn.failed') {
|
|
608
|
+
failedEvent = true
|
|
609
|
+
terminal = 'failed'
|
|
610
|
+
}
|
|
611
|
+
if (event.type === 'item.completed' && event.item?.type === 'agent_message'
|
|
612
|
+
&& typeof event.item.text === 'string') finalText = event.item.text
|
|
613
|
+
} catch { protocolError = `${toolId} 输出了无效 JSONL。` }
|
|
614
|
+
}
|
|
615
|
+
const readJsonl = chunk => {
|
|
616
|
+
jsonlBuffer += chunk
|
|
617
|
+
for (;;) {
|
|
618
|
+
const newline = jsonlBuffer.indexOf('\n')
|
|
619
|
+
if (newline < 0) break
|
|
620
|
+
readJsonlLine(jsonlBuffer.slice(0, newline))
|
|
621
|
+
jsonlBuffer = jsonlBuffer.slice(newline + 1)
|
|
622
|
+
}
|
|
623
|
+
if (jsonlBuffer.length > 4_000_000) protocolError = `${toolId} JSONL 单行超过限制。`
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let timeoutTimer
|
|
627
|
+
let graceTimer
|
|
628
|
+
let escalationTimer
|
|
629
|
+
const onAbort = () => requestStop('cancelled')
|
|
630
|
+
const settle = result => {
|
|
631
|
+
if (finished) return
|
|
632
|
+
finished = true
|
|
633
|
+
clearTimeout(timeoutTimer)
|
|
634
|
+
clearTimeout(graceTimer)
|
|
635
|
+
clearTimeout(escalationTimer)
|
|
636
|
+
signal?.removeEventListener('abort', onAbort)
|
|
637
|
+
resolveResult({ stdoutTail, stderrTail, terminalEvent: terminal, finalText, ...result })
|
|
638
|
+
}
|
|
639
|
+
function requestStop(reason) {
|
|
640
|
+
if (finished || stopReason) return
|
|
641
|
+
stopReason = reason
|
|
642
|
+
escalationTimer = stopProcessTree(child)
|
|
643
|
+
graceTimer = setTimeout(() => settle({ status: reason, exitCode: null, error: '进程终止等待超时。' }), STOP_GRACE_MS)
|
|
644
|
+
graceTimer.unref?.()
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
child.stdout.on('data', bytes => {
|
|
648
|
+
if (finished || stopReason) return
|
|
649
|
+
outputBytes += bytes.length
|
|
650
|
+
const chunk = stdoutDecoder.write(bytes)
|
|
651
|
+
stdoutTail = tail(stdoutTail, chunk)
|
|
652
|
+
if (spec.format === 'claude-json') stdout += chunk
|
|
653
|
+
else if (miniMaxParser) miniMaxParser.push(chunk)
|
|
654
|
+
else readJsonl(chunk)
|
|
655
|
+
if (outputBytes > MAX_OUTPUT_BYTES) requestStop('output-limit')
|
|
656
|
+
})
|
|
657
|
+
child.stderr.on('data', bytes => {
|
|
658
|
+
if (finished || stopReason) return
|
|
659
|
+
outputBytes += bytes.length
|
|
660
|
+
stderrTail = tail(stderrTail, stderrDecoder.write(bytes))
|
|
661
|
+
if (outputBytes > MAX_OUTPUT_BYTES) requestStop('output-limit')
|
|
662
|
+
})
|
|
663
|
+
child.stdin.on('error', () => { /* a process can exit before reading stdin */ })
|
|
664
|
+
child.on('error', error => settle({ status: stopReason ?? 'failed', exitCode: null, error: error.message }))
|
|
665
|
+
child.on('close', (code, exitSignal) => {
|
|
666
|
+
if (finished) return
|
|
667
|
+
const finalStdout = stdoutDecoder.end()
|
|
668
|
+
if (finalStdout) {
|
|
669
|
+
stdoutTail = tail(stdoutTail, finalStdout)
|
|
670
|
+
if (spec.format === 'claude-json') stdout += finalStdout
|
|
671
|
+
else if (miniMaxParser) miniMaxParser.push(finalStdout)
|
|
672
|
+
else readJsonl(finalStdout)
|
|
673
|
+
}
|
|
674
|
+
stderrTail = tail(stderrTail, stderrDecoder.end())
|
|
675
|
+
if (jsonlBuffer.trim()) readJsonlLine(jsonlBuffer)
|
|
676
|
+
if (stopReason) {
|
|
677
|
+
settle({ status: stopReason, exitCode: code, exitSignal })
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
if (miniMaxParser) {
|
|
681
|
+
const outcome = miniMaxParser.finish(code)
|
|
682
|
+
const reported = usageFromEvents('minimax-result', outcome)
|
|
683
|
+
settle({ ...outcome, usage: reported?.usage ?? null })
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
if (nativeParser) {
|
|
687
|
+
settle({ exitCode: code, ...nativeParser.finish(code) })
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
if (code !== 0) {
|
|
691
|
+
settle({ status: 'failed', exitCode: code, exitSignal, error: '官方 CLI 退出码非零。' })
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (spec.format === 'claude-json') {
|
|
695
|
+
try {
|
|
696
|
+
const result = JSON.parse(stdout)
|
|
697
|
+
if (result.type !== 'result' || result.is_error === true || result.subtype !== 'success'
|
|
698
|
+
|| typeof result.result !== 'string') {
|
|
699
|
+
settle({ status: 'failed', exitCode: code, error: 'Claude 未返回成功终态。' })
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
terminal = 'completed'
|
|
703
|
+
finalText = result.result
|
|
704
|
+
reportedUsage = usageFromOutput('claude-json', stdout)
|
|
705
|
+
} catch {
|
|
706
|
+
settle({ status: 'failed', exitCode: code, error: 'Claude 未返回有效的结果 JSON。' })
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
} else if (spec.format === 'zcode-stream-json') {
|
|
710
|
+
const turnId = zcodeResult?.turnId
|
|
711
|
+
if (protocolError || failedEvent || !zcodeResult || typeof turnId !== 'string'
|
|
712
|
+
|| !turnId || !zcodeStarted.has(turnId) || !zcodeCompleted.has(turnId)
|
|
713
|
+
|| zcodeResult.projection?.status !== 'idle'
|
|
714
|
+
|| typeof zcodeResult.response !== 'string' || !zcodeResult.response.trim()) {
|
|
715
|
+
settle({ status: 'failed', exitCode: code,
|
|
716
|
+
error: protocolError ?? 'ZCode 未返回匹配回合的成功终态和最终答复。' })
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
terminal = 'completed'
|
|
720
|
+
finalText = zcodeResult.response
|
|
721
|
+
} else if (spec.format === 'kimi-stream-json') {
|
|
722
|
+
if (protocolError || failedEvent || !kimiAssistant) {
|
|
723
|
+
settle({ status: 'failed', exitCode: code, error: protocolError ?? 'Kimi 未返回完整的助手答复。' })
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
terminal = 'completed'
|
|
727
|
+
} else if (protocolError || terminal !== 'completed' || !finalText.trim()) {
|
|
728
|
+
settle({ status: 'failed', exitCode: code, error: protocolError ?? 'Codex 未返回完整成功终态和回答。' })
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (!reportedUsage && codexUsageLine) reportedUsage = usageFromOutput('codex-jsonl', codexUsageLine)
|
|
732
|
+
settle({ status: 'succeeded', exitCode: code,
|
|
733
|
+
...(reportedUsage?.usage ? { usage: reportedUsage.usage } : {}),
|
|
734
|
+
...(reportedUsage?.reportedCostUsd ? { reportedCostUsd: reportedUsage.reportedCostUsd } : {}) })
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
timeoutTimer = setTimeout(() => requestStop('timed-out'), timeoutMs)
|
|
738
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
739
|
+
if (signal?.aborted) requestStop('cancelled')
|
|
740
|
+
if (!stopReason) child.stdin.end(spec.stdinTask === false ? '' : task, 'utf8')
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Run one official CLI. The Host must authorize `workspace` against its
|
|
746
|
+
* session; for write mode, it must supply its own isolated workspace root and
|
|
747
|
+
* obtain the appropriate approval before calling this function. No arbitrary
|
|
748
|
+
* executable, shell command, or CLI flag can be supplied by the caller.
|
|
749
|
+
*/
|
|
750
|
+
export async function runOfficialTool({
|
|
751
|
+
toolId, task, workspace, mode = 'read-only', isolatedRoot, modelId, signal, sandbox, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
752
|
+
sessionOnly = false,
|
|
753
|
+
}) {
|
|
754
|
+
const tool = getOfficialTool(toolId)
|
|
755
|
+
if (!tool) return { toolId, status: 'unsupported', reason: '未知的官方工具注册表 ID。' }
|
|
756
|
+
const capability = CAPABILITIES[tool.id]
|
|
757
|
+
if (!capability?.supported) return { toolId: tool.id, status: 'unsupported', reason: capability?.reason ?? '尚无执行适配器。' }
|
|
758
|
+
const prompt = checkedTask(task)
|
|
759
|
+
const requestedModel = checkedModel(modelId)
|
|
760
|
+
const cwd = await checkedWorkspace(workspace)
|
|
761
|
+
const executionMode = checkedMode(mode)
|
|
762
|
+
if (!capability.modes.includes(executionMode)) {
|
|
763
|
+
return { toolId: tool.id, status: 'unsupported', reason: `${tool.label} 的无界面模式会自动执行工具;当前仅允许经审批的独立 Git 工作区可编辑执行。` }
|
|
764
|
+
}
|
|
765
|
+
if (executionMode === 'workspace-write') await checkedWriteWorkspace(cwd, isolatedRoot)
|
|
766
|
+
const timeout = checkedTimeout(timeoutMs)
|
|
767
|
+
if (signal?.aborted) return { toolId: tool.id, status: 'cancelled', workspace: cwd }
|
|
768
|
+
const spec = await launchSpec(tool.id, cwd, executionMode, requestedModel, prompt)
|
|
769
|
+
if (spec.unsupported) return { toolId: tool.id, status: 'unsupported', reason: spec.unsupported }
|
|
770
|
+
if (typeof sandbox?.confine !== 'function') {
|
|
771
|
+
return { toolId: tool.id, status: 'unsupported', reason: '官方 Harness 进程沙箱不可用,拒绝直接启动 CLI。' }
|
|
772
|
+
}
|
|
773
|
+
let confined
|
|
774
|
+
try {
|
|
775
|
+
confined = await sandbox.confine([spec.file, ...spec.args], {
|
|
776
|
+
mode: executionMode, workspaceRoot: cwd,
|
|
777
|
+
}, signal)
|
|
778
|
+
} catch (error) {
|
|
779
|
+
return { toolId: tool.id, status: 'unsupported', reason: `官方进程沙箱拒绝启动:${String(error?.message ?? error).slice(0, 300)}` }
|
|
780
|
+
}
|
|
781
|
+
if (!Array.isArray(confined?.argv) || confined.argv.length < 2 || !['full', 'partial'].includes(confined.enforcement)) {
|
|
782
|
+
return { toolId: tool.id, status: 'unsupported', reason: '官方进程沙箱未返回有效的受限启动参数。' }
|
|
783
|
+
}
|
|
784
|
+
const startedAt = Date.now()
|
|
785
|
+
const outcome = await captureProcess({ ...spec, file: confined.argv[0], args: confined.argv.slice(1) },
|
|
786
|
+
prompt, cwd, signal, timeout, tool.id, sessionOnly === true)
|
|
787
|
+
return {
|
|
788
|
+
toolId: tool.id,
|
|
789
|
+
mode: executionMode,
|
|
790
|
+
requestedModel: spec.requestedModel === undefined ? requestedModel : spec.requestedModel,
|
|
791
|
+
...(spec.modelNotice ? { modelNotice: spec.modelNotice } : {}),
|
|
792
|
+
workspace: cwd,
|
|
793
|
+
source: spec.source,
|
|
794
|
+
sandboxEnforcement: confined.enforcement,
|
|
795
|
+
startedAt,
|
|
796
|
+
finishedAt: Date.now(),
|
|
797
|
+
...outcome,
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export const runOfficialToolTask = runOfficialTool
|