@ljwei-stak/dsh-model-router 0.18.2 → 0.20.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.
@@ -9,10 +9,12 @@
9
9
  * into that interactive process, exactly as in their own terminal, so a
10
10
  * session starts only after an explicit confirmation.
11
11
  */
12
- import { getOfficialTool } from './official-tool-registry.mjs'
12
+ import { getOfficialTool, OFFICIAL_TOOLS } from './official-tool-registry.mjs'
13
13
 
14
- /** Registry CLIs that have an interactive terminal UI (ZCode is a desktop app). */
15
- export const TERMINAL_TOOL_IDS = Object.freeze(['codex', 'claude-code', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build', 'gemini'])
14
+ /** Follow the tool registry, including the TUI bundled with ZCode desktop. */
15
+ export const TERMINAL_TOOL_IDS = Object.freeze(OFFICIAL_TOOLS
16
+ .filter(tool => !tool.unsupported && (tool.probeExecutables?.length || tool.manager === 'signed-windows-installer'))
17
+ .map(tool => tool.id))
16
18
  export const TERMINAL_SHELL_ID = 'shell'
17
19
  export const TERMINAL_TARGETS = Object.freeze([TERMINAL_SHELL_ID, ...TERMINAL_TOOL_IDS])
18
20
  export const TERMINAL_MODES = Object.freeze(['interactive', 'login'])
@@ -46,8 +48,16 @@ export const TERMINAL_LOGIN_ARGS = Object.freeze({
46
48
  'mimo-code': Object.freeze(['auth', 'login']),
47
49
  'grok-build': Object.freeze(['login']),
48
50
  gemini: Object.freeze([]),
51
+ opencode: Object.freeze(['auth', 'login']),
52
+ stepcode: Object.freeze(['login']),
53
+ zcode: Object.freeze([]),
49
54
  })
50
55
 
56
+ /** Tools without a separate login command authenticate from their TUI. */
57
+ export function terminalCanLogin(target) {
58
+ return Boolean(TERMINAL_LOGIN_ARGS[target]?.length)
59
+ }
60
+
51
61
  const SESSION_ID = /^term-[a-z0-9]{6,40}$/
52
62
 
53
63
  function plainObject(value, subject) {
@@ -82,7 +92,7 @@ export function terminalTargetLabel(target) {
82
92
  export function terminalCommandPreview(target, mode = 'interactive', platform = 'win32') {
83
93
  if (target === TERMINAL_SHELL_ID) return platform === 'win32' ? 'powershell.exe -NoLogo' : '$SHELL -l'
84
94
  const tool = getOfficialTool(target)
85
- const executable = tool?.probeExecutables?.[0] ?? String(target)
95
+ const executable = target === 'zcode' ? 'ZCode 桌面版内置 TUI' : tool?.probeExecutables?.[0] ?? String(target)
86
96
  const args = mode === 'login' ? TERMINAL_LOGIN_ARGS[target] ?? [] : []
87
97
  return [executable, ...args].join(' ')
88
98
  }
@@ -92,7 +102,7 @@ export function parseTerminalStart(value) {
92
102
  if (!TERMINAL_TARGETS.includes(request.target)) throw new TypeError('target must be the shell or a fixed official CLI')
93
103
  const mode = request.mode === undefined ? 'interactive' : request.mode
94
104
  if (!TERMINAL_MODES.includes(mode)) throw new TypeError('mode must be interactive or login')
95
- if (mode === 'login' && request.target === TERMINAL_SHELL_ID) throw new TypeError('the shell has no login mode')
105
+ if (mode === 'login' && !terminalCanLogin(request.target)) throw new TypeError('this target has no login mode')
96
106
  if (!isAbsoluteDirectoryText(request.cwd)) throw new TypeError('cwd must be an absolute directory path')
97
107
  if (request.confirmed !== true) throw new TypeError('starting a terminal requires the user\'s confirmation')
98
108
  return {
@@ -13,11 +13,14 @@
13
13
  */
14
14
  import { spawn } from 'node:child_process'
15
15
  import { randomBytes } from 'node:crypto'
16
- import { stat } from 'node:fs/promises'
16
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
17
17
  import { createRequire } from 'node:module'
18
18
  import { StringDecoder } from 'node:string_decoder'
19
- import { extname } from 'node:path'
20
- import { getOfficialTool } from './official-tool-registry.mjs'
19
+ import { homedir } from 'node:os'
20
+ import { extname, isAbsolute, join, relative, sep } from 'node:path'
21
+ import { getOfficialTool, installMethodFor } from './official-tool-registry.mjs'
22
+ import { resolveInstallPlan } from './tool-install-preferences.mjs'
23
+ import { discoverZCodeBundle } from './zcode-bundle.mjs'
21
24
  import {
22
25
  TERMINAL_LIMITS,
23
26
  TERMINAL_LOGIN_ARGS,
@@ -84,6 +87,75 @@ export function pickWindowsExecutable(candidates) {
84
87
 
85
88
  const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
86
89
 
90
+ function within(root, child) {
91
+ const part = relative(root, child)
92
+ return Boolean(part) && part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
93
+ }
94
+
95
+ /** Bind a configured npm/pnpm tool to its declared package entry, not a shared shim. */
96
+ export async function findTerminalPackageEntry(tool, plan, { platform = process.platform } = {}) {
97
+ let roots = plan.packageModuleRoots
98
+ let boundary
99
+ if (!Array.isArray(roots)) {
100
+ if (!plan.installDir) return null
101
+ if (plan.manager === 'pnpm') {
102
+ const global = join(plan.installDir, '.pnpm', 'global')
103
+ boundary = await realpath(global).catch(() => null)
104
+ const versions = await readdir(global, { withFileTypes: true }).catch(() => [])
105
+ roots = versions.filter(item => item.isDirectory() && /^\d+$/.test(item.name))
106
+ .sort((left, right) => Number(left.name) - Number(right.name))
107
+ .map(item => join(global, item.name, 'node_modules'))
108
+ } else {
109
+ const modules = platform === 'win32' ? join(plan.installDir, 'node_modules') : join(plan.installDir, 'lib', 'node_modules')
110
+ boundary = await realpath(modules).catch(() => null)
111
+ roots = [modules]
112
+ }
113
+ } else {
114
+ boundary = await realpath(plan.globalDir || plan.installDir || roots[0]).catch(() => null)
115
+ }
116
+ if (!boundary) return null
117
+ const entries = []
118
+ for (const modules of roots) {
119
+ try {
120
+ const packageRoot = await realpath(join(modules, ...tool.package.split('/')))
121
+ if (!within(boundary, packageRoot)) continue
122
+ const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
123
+ if (manifest.name !== tool.package) continue
124
+ for (const executable of tool.probeExecutables) {
125
+ const raw = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[executable]
126
+ const bin = typeof raw === 'string' ? raw.replace(/\\/g, '/').replace(/^\.\//, '') : ''
127
+ if (!bin || bin.split('/').some(part => !part || part === '..') || isAbsolute(bin)) continue
128
+ const entry = await realpath(join(packageRoot, ...bin.split('/')))
129
+ if (!within(packageRoot, entry) || !(await stat(entry)).isFile()) continue
130
+ entries.push({ entry, packageRoot, version: String(manifest.version ?? '') })
131
+ }
132
+ } catch { /* not the selected tool's usable package */ }
133
+ }
134
+ // pnpm normally has one active global tree. Prefer the highest numbered
135
+ // layout when multiple layouts survive an upgrade, following its directories.
136
+ return entries.at(-1) ?? null
137
+ }
138
+
139
+ async function terminalNode(workspace, minimum, acceptedVersion) {
140
+ const { findNodeExecutable } = await import('./official-tool-executor.mjs')
141
+ return findNodeExecutable(workspace, minimum, acceptedVersion)
142
+ }
143
+
144
+ function executableLaunch(file, args, display, { platform, env }) {
145
+ if (platform !== 'win32') return { file, args, verbatim: false, display }
146
+ const extension = extname(file).toLowerCase()
147
+ if (extension === '.cmd' || extension === '.bat') {
148
+ const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
149
+ const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
150
+ return { file: comspec, args: tail, verbatim: true, display }
151
+ }
152
+ if (extension === '.ps1') {
153
+ const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
154
+ return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
155
+ }
156
+ return { file, args, verbatim: false, display }
157
+ }
158
+
87
159
  /**
88
160
  * Build the fixed launch for one target. `locate(executable)` returns the
89
161
  * candidate paths `where`/`which` printed. Returns
@@ -92,6 +164,9 @@ const quoteWindows = path => `"${String(path).replace(/"/g, '')}"`
92
164
  */
93
165
  export async function resolveTerminalLaunch(target, mode = 'interactive', {
94
166
  platform = process.platform, env = process.env, locate = defaultLocate,
167
+ preferences = {}, home = homedir(), cwd = process.cwd(),
168
+ packageEntry = findTerminalPackageEntry, fileExists = async path => (await stat(path).catch(() => null))?.isFile() === true,
169
+ discoverBundle = discoverZCodeBundle, findNode = terminalNode,
95
170
  } = {}) {
96
171
  if (target === TERMINAL_SHELL_ID) {
97
172
  if (platform === 'win32') {
@@ -104,9 +179,47 @@ export async function resolveTerminalLaunch(target, mode = 'interactive', {
104
179
  }
105
180
  if (!TERMINAL_TOOL_IDS.includes(target)) throw new Error('只能启动系统终端或固定注册表中的官方 CLI。')
106
181
  const tool = getOfficialTool(target)
182
+ if (target === 'zcode') {
183
+ if (platform !== 'win32') throw new Error('当前仅支持 Windows ZCode 桌面版内置 TUI。')
184
+ const bundle = await discoverBundle({ installDir: preferences.zcodeInstallDir, env })
185
+ if (!bundle) throw new Error('未找到通过智谱签名与组件核验的 ZCode 桌面版;请先配置 ZCode 独立安装目录并重新体检。')
186
+ const node = await findNode(cwd, [24, 14, 0], version => Number(String(version).split('.')[0]) === 24)
187
+ // The verified desktop's electron-node entry is usable independently of
188
+ // its GUI. It supplies its own runtime when external Node 24 is absent.
189
+ return { file: node || bundle.executable, args: [bundle.cliEntry], verbatim: false,
190
+ display: `ZCode TUI · ${bundle.cliEntry}`, ...(!node ? { env: { ELECTRON_RUN_AS_NODE: '1' } } : {}) }
191
+ }
107
192
  const executable = tool.probeExecutables[0]
108
193
  const args = mode === 'login' ? [...(TERMINAL_LOGIN_ARGS[target] ?? [])] : []
109
194
  const display = [executable, ...args].join(' ')
195
+ const selectedScript = installMethodFor(tool, preferences.methods?.[target])?.kind === 'script'
196
+ const selectedPreferences = selectedScript ? { ...preferences, allowScriptInstall: true } : preferences
197
+ const plan = resolveInstallPlan(tool, undefined, selectedPreferences, { platform, env, home })
198
+ const configured = Boolean(preferences.installDir || preferences.methods?.[target])
199
+ if (plan.kind === 'package-manager' && configured) {
200
+ const boundPlan = plan.installDir ? plan : await (await import('./official-tools-runtime.mjs')).resolvePackageJobPlan(tool, plan, selectedPreferences, { options: { platform, env, home } })
201
+ const found = await packageEntry(tool, boundPlan, { platform })
202
+ if (!found) throw new Error(`未在所选 ${boundPlan.manager} 安装目录中找到 ${tool.label} 的真实命令入口;请重新体检或修复。`)
203
+ const extension = extname(found.entry).toLowerCase()
204
+ if (['.js', '.mjs', '.cjs'].includes(extension) || (platform === 'win32' && !extension)) {
205
+ const node = await findNode(cwd)
206
+ if (!node) throw new Error(`未找到可核验的 Node.js 运行时,无法启动 ${tool.label}。`)
207
+ return { file: node, args: [found.entry, ...args], verbatim: false, display }
208
+ }
209
+ return executableLaunch(found.entry, args, display, { platform, env })
210
+ }
211
+ if (plan.kind === 'script' && plan.binDir) {
212
+ for (const name of tool.probeExecutables) {
213
+ const filenames = platform === 'win32' ? WINDOWS_PREFERENCE.map(extension => `${name}${extension}`) : [name]
214
+ for (const filename of filenames) {
215
+ const file = join(plan.binDir, filename)
216
+ if (await fileExists(file)) return executableLaunch(file, args, display, { platform, env })
217
+ }
218
+ }
219
+ // A saved method or directory is authoritative: an older PATH copy cannot
220
+ // satisfy a missing install in the user's selected tool directory.
221
+ if (configured || tool.manager === 'script-installer') throw new Error(`未在所选安装目录 ${plan.binDir} 中找到 ${tool.label};请重新体检或安装。`)
222
+ }
110
223
  const candidates = await locate(executable, { platform, env })
111
224
  if (platform !== 'win32') {
112
225
  const file = candidates.map(item => String(item).trim()).find(item => item.startsWith('/'))
@@ -115,18 +228,7 @@ export async function resolveTerminalLaunch(target, mode = 'interactive', {
115
228
  }
116
229
  const file = pickWindowsExecutable(candidates)
117
230
  if (!file) throw new Error(`未在 PATH 中找到 ${executable};请先在“官方工具 · 体检”中安装 ${tool.label}。`)
118
- const extension = extname(file).toLowerCase()
119
- if (extension === '.cmd' || extension === '.bat') {
120
- // npm shims only run under cmd.exe. Every token is fixed by the registry.
121
- const comspec = env.ComSpec || env.COMSPEC || 'cmd.exe'
122
- const tail = `/d /s /c "${[quoteWindows(file), ...args].join(' ')}"`
123
- return { file: comspec, args: tail, verbatim: true, display }
124
- }
125
- if (extension === '.ps1') {
126
- const root = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
127
- return { file: `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, args: ['-NoLogo', '-NoProfile', '-File', file, ...args], verbatim: false, display }
128
- }
129
- return { file, args, verbatim: false, display }
231
+ return executableLaunch(file, args, display, { platform, env })
130
232
  }
131
233
 
132
234
  /** Locate an executable with where.exe / which (fixed registry names only). */
@@ -161,7 +263,10 @@ function spawnPtyProcess(pty, launch, { cwd, cols, rows, env }) {
161
263
 
162
264
  /** Pipe fallback: no TTY; the Host echoes input and maps Enter/Ctrl+C. */
163
265
  export function spawnPipeProcess(launch, { cwd, env, platform = process.platform, spawnImpl = spawn }) {
164
- const child = spawnImpl(launch.file, launch.args, {
266
+ // node-pty accepts a Windows command-line string; node:child_process needs
267
+ // an array even with windowsVerbatimArguments. Keep the prepared tail intact.
268
+ const args = typeof launch.args === 'string' ? [launch.args] : launch.args
269
+ const child = spawnImpl(launch.file, args, {
165
270
  cwd, env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: false,
166
271
  windowsVerbatimArguments: launch.verbatim === true,
167
272
  })
@@ -220,6 +325,8 @@ export function createTerminalManager({
220
325
  statPath = stat,
221
326
  prepare = async () => {},
222
327
  environment = () => terminalEnvironment(process.env),
328
+ preferences = () => ({}),
329
+ launchOptions = () => ({}),
223
330
  onSessionEnd = () => {},
224
331
  now = Date.now,
225
332
  platform = process.platform,
@@ -329,10 +436,11 @@ export function createTerminalManager({
329
436
  try { info = await statPath(request.cwd) } catch { info = null }
330
437
  if (!info?.isDirectory?.()) throw new Error(`工作目录不存在或不是目录:${request.cwd}`)
331
438
  await prepare()
332
- const launch = await resolveLaunch(request.target, request.mode, { platform })
333
439
  const env = environment()
440
+ const launch = await resolveLaunch(request.target, request.mode, { ...launchOptions(), platform, env,
441
+ preferences: preferences(), cwd: request.cwd })
334
442
  const backend = backendInfo()
335
- const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env, platform }
443
+ const options = { cwd: request.cwd, cols: request.cols, rows: request.rows, env: { ...env, ...launch.env }, platform }
336
444
  let proc
337
445
  try {
338
446
  proc = backend.backend === 'pty'
@@ -5,11 +5,13 @@
5
5
  * worktree for review. No package supplies an executable or shell fragment.
6
6
  */
7
7
  import { spawn } from 'node:child_process'
8
- import { appendFile, mkdir, realpath, stat } from 'node:fs/promises'
9
- import { randomUUID } from 'node:crypto'
8
+ import { appendFile, lstat, mkdir, realpath, stat } from 'node:fs/promises'
9
+ import { createHash, randomUUID } from 'node:crypto'
10
+ import { tmpdir } from 'node:os'
10
11
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
11
12
  import { runOfficialTool, officialToolExecutionCapabilities, officialToolReadiness } from './official-tool-executor.mjs'
12
13
  import { toolForProvider } from './official-tool-registry.mjs'
14
+ import { redactDiagnostic, verifiedDiagnostic } from './task-executors.mjs'
13
15
 
14
16
  const MAX_GIT_OUTPUT = 64_000_000
15
17
  const GIT_TIMEOUT_MS = 120_000
@@ -26,6 +28,61 @@ async function canonicalDirectory(path) {
26
28
  return canonical
27
29
  }
28
30
 
31
+ function pathIdentity(path) {
32
+ return process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path)
33
+ }
34
+
35
+ async function approvedSource(workspace, allowedRoot) {
36
+ const source = await canonicalDirectory(workspace)
37
+ const root = await canonicalDirectory(allowedRoot ?? source)
38
+ if (!within(root, source)) throw new Error('原工作区不在当前 Harness 会话批准的工作区范围内。')
39
+ return source
40
+ }
41
+
42
+ function sourceStorageKey(source) {
43
+ return createHash('sha256').update(pathIdentity(source)).digest('hex')
44
+ }
45
+
46
+ function requireDisjointStorage(source, storage) {
47
+ if (within(source, storage) || within(storage, source)) {
48
+ throw new Error('Host 独立工作区存储必须与原工作区互不包含。')
49
+ }
50
+ }
51
+
52
+ /** Reject junctions before creating deeper directories in Host-managed storage. */
53
+ async function storageDirectory(parent, name) {
54
+ const target = join(parent, name)
55
+ try { await mkdir(target) }
56
+ catch (error) { if (error?.code !== 'EEXIST') throw error }
57
+ const entry = await lstat(target)
58
+ const canonical = await canonicalDirectory(target)
59
+ if (!entry.isDirectory() || entry.isSymbolicLink() || pathIdentity(canonical) !== pathIdentity(target)) {
60
+ throw new Error('Host 独立工作区存储不能经过符号链接或目录联接。')
61
+ }
62
+ return canonical
63
+ }
64
+
65
+ /**
66
+ * Windows ACL grants need WRITE_OWNER as well as ordinary file modification.
67
+ * Use the user's existing private temp storage rather than changing a source
68
+ * drive's ACL. Only the individual run receives the sandbox write capability;
69
+ * the SDK's separate random private temp remains disjoint from that run.
70
+ */
71
+ async function windowsStorageParent(source, create = false) {
72
+ const temporary = await canonicalDirectory(tmpdir())
73
+ const rootPath = join(temporary, '.model-router-workspaces')
74
+ requireDisjointStorage(source, rootPath)
75
+ const root = create ? await storageDirectory(temporary, '.model-router-workspaces')
76
+ : await canonicalDirectory(rootPath)
77
+ if (pathIdentity(root) !== pathIdentity(rootPath)) throw new Error('Host 独立工作区存储路径校验失败。')
78
+ const key = sourceStorageKey(source)
79
+ const parentPath = join(root, key)
80
+ const parent = create ? await storageDirectory(root, key) : await canonicalDirectory(parentPath)
81
+ if (pathIdentity(parent) !== pathIdentity(parentPath)) throw new Error('Host 独立工作区存储路径校验失败。')
82
+ requireDisjointStorage(source, parent)
83
+ return parent
84
+ }
85
+
29
86
  /** The session owner, never model arguments, selects the filesystem scope. */
30
87
  export async function sessionWorkspace(ctx, exec) {
31
88
  const session = exec?.agent?.session
@@ -97,6 +154,13 @@ async function cleanRepoRoot(workspace, signal) {
97
154
  return repo
98
155
  }
99
156
 
157
+ /** Preview-only validation; never creates a worktree or changes ignore rules. */
158
+ export async function preflightOfficialWorkspace(workspace, mode = 'read-only', signal) {
159
+ const source = await canonicalDirectory(workspace)
160
+ if (mode === 'workspace-write') await cleanRepoRoot(source, signal)
161
+ return source
162
+ }
163
+
100
164
  /** Keep nested worktrees out of ordinary git status without changing tracked files. */
101
165
  async function excludeNestedWorktrees(workspace, signal) {
102
166
  const ignored = await git(['check-ignore', '-q', '--', '.model-router-workspaces/'], { cwd: workspace, signal })
@@ -114,25 +178,31 @@ async function excludeNestedWorktrees(workspace, signal) {
114
178
  if (!confirmed.ok) throw gitError('确认独立工作区忽略规则', confirmed)
115
179
  }
116
180
 
117
- /** Keep generated worktrees beside the project so they use the same drive. */
181
+ /** Source authorization is independent from the Host's isolated output storage. */
118
182
  async function isolatedWorktree(workspace, signal, allowedRoot) {
183
+ workspace = await approvedSource(workspace, allowedRoot)
119
184
  await cleanRepoRoot(workspace, signal)
120
185
  const base = await git(['rev-parse', 'HEAD'], { cwd: workspace, signal })
121
186
  if (!base.ok || !/^[0-9a-f]{40,64}$/i.test(base.output.toString('utf8').trim())) {
122
187
  throw gitError('记录原仓库基线', base)
123
188
  }
124
189
  const baseCommit = base.output.toString('utf8').trim()
125
- const policyRoot = await canonicalDirectory(allowedRoot ?? workspace)
126
- const sibling = join(dirname(workspace), '.model-router-workspaces', basename(workspace))
127
- const parent = within(policyRoot, sibling) ? sibling : join(workspace, '.model-router-workspaces')
128
- if (parent === join(workspace, '.model-router-workspaces')) await excludeNestedWorktrees(workspace, signal)
129
- await mkdir(parent, { recursive: true })
130
- const isolatedRoot = await canonicalDirectory(parent)
190
+ let isolatedRoot
191
+ if (process.platform === 'win32') isolatedRoot = await windowsStorageParent(workspace, true)
192
+ else {
193
+ const policyRoot = await canonicalDirectory(allowedRoot ?? workspace)
194
+ const sibling = join(dirname(workspace), '.model-router-workspaces', basename(workspace))
195
+ const parent = within(policyRoot, sibling) ? sibling : join(workspace, '.model-router-workspaces')
196
+ if (parent === join(workspace, '.model-router-workspaces')) await excludeNestedWorktrees(workspace, signal)
197
+ await mkdir(parent, { recursive: true })
198
+ isolatedRoot = await canonicalDirectory(parent)
199
+ }
131
200
  const target = join(isolatedRoot, `run-${randomUUID()}`)
132
201
  const added = await git(['worktree', 'add', '--detach', target, 'HEAD'], { cwd: workspace, signal })
133
202
  if (!added.ok) throw gitError('创建独立 Git 工作区', added)
134
203
  const runPath = await canonicalDirectory(target)
135
- if (!within(isolatedRoot, runPath) || runPath === isolatedRoot) throw new Error('独立工作区路径校验失败')
204
+ if (pathIdentity(dirname(runPath)) !== pathIdentity(isolatedRoot)
205
+ || pathIdentity(runPath) !== pathIdentity(target)) throw new Error('独立工作区路径校验失败')
136
206
  return { workspace: runPath, isolatedRoot, baseCommit, source: workspace }
137
207
  }
138
208
 
@@ -198,13 +268,11 @@ function checkedTeamCliModels(cliModels, assigned) {
198
268
 
199
269
  function teamCliModel(item, cliModels) {
200
270
  // Harness directory IDs and vendor CLI aliases are independent namespaces.
201
- // Only the two CLIs with a verified direct model argument contract receive
202
- // a planned ID. All other vendors use their configured default until the
203
- // user supplies a verified CLI mapping through that vendor's own settings.
271
+ // Every vendor uses its own configured default unless a package or tool
272
+ // explicitly binds the model name accepted by that CLI.
204
273
  if (cliModels[item.id]) return cliModels[item.id]
205
274
  if (cliModels[item.toolId]) return cliModels[item.toolId]
206
- return item.toolId === 'claude-code' || item.toolId === 'codex'
207
- ? item.recommendedModel : null
275
+ return null
208
276
  }
209
277
 
210
278
  function reportedCliModel(toolId, value) {
@@ -241,8 +309,8 @@ function packagePrompt(task, item, completed) {
241
309
  }
242
310
 
243
311
  /** Preflight every assignment before any model request or workspace write. */
244
- export function executableTeamPackages(plan, installedIds = [], mode = 'read-only') {
245
- const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
312
+ export function executableTeamPackages(plan, installedIds = [], mode = 'read-only', executionEnvironment = 'harness') {
313
+ const capabilities = new Map(officialToolExecutionCapabilities({ executionEnvironment }).map(item => [item.id, item]))
246
314
  const packages = Array.isArray(plan?.team?.workPackages) ? plan.team.workPackages : []
247
315
  const blocking = []
248
316
  const assigned = packages.map(item => {
@@ -266,6 +334,34 @@ async function removeWorktree(source, target) {
266
334
  try { await git(['worktree', 'remove', '--force', target], { cwd: source }) } catch { /* left for manual cleanup */ }
267
335
  }
268
336
 
337
+ function executionDiagnostic(value) {
338
+ let detail = String(value ?? '')
339
+ for (const key of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN',
340
+ 'OPENAI_API_KEY', 'CODEX_API_KEY', 'KIMI_API_KEY', 'MOONSHOT_API_KEY', 'MINIMAX_API_KEY',
341
+ 'MIMO_API_KEY', 'XAI_API_KEY', 'ZAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY']) {
342
+ const secret = process.env[key]
343
+ if (typeof secret === 'string' && secret) detail = detail.split(secret).join('[redacted]')
344
+ }
345
+ return redactDiagnostic(detail, '', 2_000)
346
+ }
347
+
348
+ function executionException(error, signal) {
349
+ const detail = executionDiagnostic(error?.message ?? error)
350
+ const cancelled = signal?.aborted === true || error?.name === 'AbortError'
351
+ return { status: cancelled ? 'cancelled' : 'failed', exitCode: null, finalText: '',
352
+ error: `${cancelled ? '官方 CLI 执行已取消' : '官方 CLI 执行器异常'}${detail ? `:${detail}` : '。'}`,
353
+ detail }
354
+ }
355
+
356
+ /** The ledger accepts this bounded, scrubbed field instead of raw CLI streams. */
357
+ function withFailureDiagnostic(result) {
358
+ if (result.status === 'succeeded') return result
359
+ const diagnostic = executionDiagnostic(result.diagnostic ?? verifiedDiagnostic(result))
360
+ return { ...result, ...(result.error ? { error: executionDiagnostic(result.error) } : {}),
361
+ ...(result.reason ? { reason: executionDiagnostic(result.reason) } : {}),
362
+ ...(diagnostic ? { diagnostic } : {}) }
363
+ }
364
+
269
365
  /**
270
366
  * Copy the changes of an earlier, never integrated worktree into a fresh one.
271
367
  * Refuses when the original checkout moved since that run (the patch would be
@@ -279,11 +375,25 @@ async function seedWorktree(isolated, seedFrom, signal) {
279
375
  let prior
280
376
  try { prior = await canonicalDirectory(seedFrom.workspace) }
281
377
  catch { throw new Error('原运行的独立工作区已不存在,无法套用之前的改动;请重新执行整个团队任务。') }
282
- // Only a worktree of this repository that the plugin created (run-<uuid>) is accepted.
283
- const listed = await git(['worktree', 'list', '--porcelain'], { cwd: isolated.source, signal })
284
- const worktrees = listed.ok ? listed.output.toString('utf8').split(/\r?\n/).filter(line => line.startsWith('worktree ')).map(line => line.slice(9)) : []
378
+ // A basename is not ownership evidence: require this repository's membership
379
+ // and an exact canonical parent derived from this source, including old runs.
380
+ const listed = await git(['worktree', 'list', '--porcelain', '-z'], { cwd: isolated.source, signal })
381
+ const worktrees = listed.ok ? listed.output.toString('utf8').split('\0').filter(line => line.startsWith('worktree ')).map(line => line.slice(9)) : []
285
382
  const known = await Promise.all(worktrees.map(path => canonicalDirectory(path).catch(() => null)))
286
- if (!known.includes(prior) || !/^run-[0-9a-f-]{36}$/i.test(basename(prior)) || basename(dirname(dirname(prior))) !== '.model-router-workspaces' && basename(dirname(prior)) !== '.model-router-workspaces') {
383
+ const expectedParents = [join(isolated.source, '.model-router-workspaces'),
384
+ join(dirname(isolated.source), '.model-router-workspaces', basename(isolated.source))]
385
+ if (process.platform === 'win32') {
386
+ try { expectedParents.push(await windowsStorageParent(isolated.source)) } catch { /* a removed/redirected Host parent is not trusted */ }
387
+ }
388
+ const parents = await Promise.all(expectedParents.map(async path => {
389
+ try {
390
+ const canonical = await canonicalDirectory(path)
391
+ return pathIdentity(canonical) === pathIdentity(path) ? canonical : null
392
+ } catch { return null }
393
+ }))
394
+ if (!known.some(path => path && pathIdentity(path) === pathIdentity(prior))
395
+ || !/^run-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(basename(prior))
396
+ || !parents.some(path => path && pathIdentity(path) === pathIdentity(dirname(prior)))) {
287
397
  throw new Error('原独立工作区不是本仓库由插件创建的 Git 工作树,拒绝读取。')
288
398
  }
289
399
  const staged = await git(['add', '-A'], { cwd: prior, signal })
@@ -301,7 +411,9 @@ async function seedWorktree(isolated, seedFrom, signal) {
301
411
  }
302
412
 
303
413
  /** Sequential execution preserves DAG dependencies and avoids edit conflicts. */
304
- export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode = 'read-only', installedIds = [], cliModels, signal, sandbox, runtime, previous = [], onlyIds = null, seedFrom = null }) {
414
+ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode = 'read-only', executionEnvironment = 'harness', installedIds = [], cliModels, signal, sandbox, runtime, previous = [], onlyIds = null, seedFrom = null }) {
415
+ if (!['harness', 'cli-native'].includes(executionEnvironment)
416
+ || (executionEnvironment === 'cli-native' && mode !== 'workspace-write')) throw new TypeError('invalid team CLI execution environment')
305
417
  // A retry runs only `onlyIds`; earlier results feed dependency context. An
306
418
  // editable retry needs `seedFrom` (the earlier, never integrated worktree and
307
419
  // its base commit): a fresh worktree gets that worktree's changes first, so
@@ -310,22 +422,23 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
310
422
  if (retry && mode !== 'read-only' && !(seedFrom && typeof seedFrom.workspace === 'string' && typeof seedFrom.baseCommit === 'string')) {
311
423
  throw new TypeError('an editable team retry needs the earlier isolated worktree (seedFrom)')
312
424
  }
313
- const selected = executableTeamPackages(plan, installedIds, mode)
425
+ const selected = executableTeamPackages(plan, installedIds, mode, executionEnvironment)
314
426
  const assigned = retry ? selected.assigned.filter(item => onlyIds.includes(item.id)) : selected.assigned
315
427
  const blocking = retry ? selected.blocking.filter(item => onlyIds.includes(item.id)) : selected.blocking
316
428
  if (assigned.length === 0) return { status: 'blocked', blocking: [{ reason: '计划没有工作包。' }], results: [] }
317
429
  if (blocking.length > 0) return { status: 'blocked', blocking, results: [] }
318
430
  if (mode !== 'read-only' && mode !== 'workspace-write') throw new TypeError('team mode must be read-only or workspace-write')
319
431
  const configuredCliModels = checkedTeamCliModels(cliModels, assigned)
320
- const source = await canonicalDirectory(workspace)
321
- if (typeof sandbox?.confine !== 'function') return { status: 'blocked', blocking: [{ reason: '官方 Harness 进程沙箱不可用。' }], results: [] }
432
+ const source = await approvedSource(workspace, allowedRoot)
433
+ if (executionEnvironment === 'harness' && typeof sandbox?.confine !== 'function') return { status: 'blocked', blocking: [{ reason: '官方 Harness 进程沙箱不可用。' }], results: [] }
322
434
  // The Host passes no runtime override. Tests can inject inert CLI adapters to
323
435
  // exercise assignment, stopping, and Git integration without provider accounts.
324
436
  const readinessCheck = runtime?.readiness ?? officialToolReadiness
325
437
  const runTool = runtime?.runTool ?? runOfficialTool
326
- const readiness = await Promise.all([...new Set(assigned.map(item => item.toolId))].map(id => readinessCheck(id, source)))
327
- const unready = readiness.filter(item => !item.ready)
328
- if (unready.length > 0) return { status: 'blocked', blocking: unready.map(item => ({ toolId: item.id, reason: item.reason })), results: [] }
438
+ const readiness = await Promise.all([...new Set(assigned.map(item => item.toolId))].map(id => readinessCheck(id, source, { executionEnvironment })))
439
+ const unready = readiness.filter(item => !item.ready || !(item.readyModes ?? item.modes ?? [mode]).includes(mode))
440
+ if (unready.length > 0) return { status: 'blocked', blocking: unready.map(item => ({ toolId: item.id,
441
+ reason: item.reason ?? `官方 CLI 尚未就绪,或不支持 ${mode} 模式。` })), results: [] }
329
442
  const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
330
443
  const runWorkspace = isolated?.workspace ?? source
331
444
  let seeded = null
@@ -339,18 +452,28 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
339
452
  const completed = []
340
453
  const context = retry ? (Array.isArray(previous) ? previous : []).filter(item => item?.id && typeof item.finalText === 'string') : []
341
454
  for (const item of assigned) {
342
- if (signal?.aborted) return { status: 'cancelled', workspace: runWorkspace, results: completed }
455
+ if (signal?.aborted) return { status: 'cancelled', workspace: runWorkspace, results: completed,
456
+ ...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
343
457
  const cliModel = teamCliModel(item, configuredCliModels)
344
- const result = await runTool({
345
- toolId: item.toolId,
346
- modelId: cliModel,
347
- task: packagePrompt(task, item, [...context, ...completed]),
348
- workspace: runWorkspace,
349
- mode,
350
- isolatedRoot: isolated?.isolatedRoot,
351
- sandbox,
352
- signal,
353
- })
458
+ let result
459
+ try {
460
+ result = await runTool({
461
+ toolId: item.toolId,
462
+ modelId: cliModel,
463
+ task: packagePrompt(task, item, [...context, ...completed]),
464
+ workspace: runWorkspace,
465
+ mode,
466
+ isolatedRoot: isolated?.isolatedRoot,
467
+ sandbox,
468
+ signal,
469
+ executionEnvironment,
470
+ })
471
+ if (!result || typeof result.status !== 'string') throw new TypeError('官方 CLI 执行器没有返回有效状态。')
472
+ } catch (error) {
473
+ // Keep earlier paid results and the isolated worktree available for the
474
+ // ledger and retry even when a later adapter rejects before returning.
475
+ result = executionException(error, signal)
476
+ }
354
477
  const reportedModel = reportedCliModel(item.toolId, result.reportedModel)
355
478
  const modelMismatch = result.status === 'succeeded' && cliModel && reportedModel
356
479
  && cliModel !== reportedModel
@@ -358,19 +481,26 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
358
481
  completed.push({ id: item.id, name: item.name,
359
482
  ...(item.objective ? { objective: item.objective } : {}), provider: item.recommendedProvider,
360
483
  recommendedModel: item.recommendedModel, toolId: item.toolId,
361
- requestedCliModel: result.requestedModel ?? null,
484
+ requestedCliModel: Object.hasOwn(result, 'requestedModel') ? result.requestedModel : cliModel,
362
485
  actualModel: reportedModel,
486
+ ...(result.reportedModel !== undefined ? { reportedModel: result.reportedModel } : {}),
487
+ ...(result.usageIncomplete === true ? { usageIncomplete: true } : {}),
488
+ ...(typeof result.source === 'string' ? { source: result.source } : {}),
489
+ ...(typeof result.sandboxEnforcement === 'string' ? { sandboxEnforcement: result.sandboxEnforcement } : {}),
490
+ executionEnvironment,
363
491
  modelNotice: modelMismatch
364
492
  ? `官方 CLI 回报模型 ${reportedModel},与本包指定的 ${cliModel} 不一致;后续工作包已停止。`
365
493
  : result.modelNotice ?? (!cliModel
366
494
  ? 'Harness 推荐模型 ID 未经此厂商 CLI 验证;本包使用厂商 CLI 已配置的默认模型。'
367
495
  : reportedModel ? null : '该 CLI 未返回可核验的实际模型 ID;请以厂商运行记录核对。'),
368
496
  status: packageStatus, finalText: result.finalText ?? '',
497
+ exitCode: result.exitCode ?? null,
369
498
  ...(result.usage ? { usage: result.usage } : {}),
370
499
  ...(Number.isFinite(result.reportedCostUsd) ? { reportedCostUsd: result.reportedCostUsd } : {}),
371
- error: modelMismatch ? '官方 CLI 实际模型与指定模型不一致。' : result.error ?? result.reason ?? null,
372
- outputTail: result.status === 'succeeded' ? null : result.stderrTail ?? result.stdoutTail ?? null })
373
- if (packageStatus !== 'succeeded') return { status: 'incomplete', workspace: runWorkspace, results: completed,
500
+ error: modelMismatch ? '官方 CLI 实际模型与指定模型不一致。'
501
+ : (result.error || result.reason) ? executionDiagnostic(result.error || result.reason) : null,
502
+ outputTail: result.status === 'succeeded' ? null : executionDiagnostic(verifiedDiagnostic(result)) || null })
503
+ if (packageStatus !== 'succeeded') return { status: packageStatus === 'cancelled' ? 'cancelled' : 'incomplete', workspace: runWorkspace, results: completed,
374
504
  ...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
375
505
  }
376
506
  if (!isolated) return { status: 'cli-completed', workspace: runWorkspace, results: completed,
@@ -390,14 +520,24 @@ export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode
390
520
  }
391
521
 
392
522
  /** One editable CLI task also uses the same isolated workspace and merge gate. */
393
- export async function runOfficialTask({ toolId, task, workspace, allowedRoot, modelId, mode = 'read-only', signal, sandbox }) {
394
- const source = await canonicalDirectory(workspace)
523
+ export async function runOfficialTask({ toolId, task, workspace, allowedRoot, modelId, mode = 'read-only', signal, sandbox, runtime }) {
524
+ const source = await approvedSource(workspace, allowedRoot)
395
525
  if (typeof sandbox?.confine !== 'function') return { toolId, status: 'unsupported', reason: '官方 Harness 进程沙箱不可用。' }
396
- const readiness = await officialToolReadiness(toolId, source)
397
- if (!readiness.ready) return { toolId, status: 'unsupported', reason: readiness.reason }
526
+ // The Host supplies no runtime override; offline tests use inert adapters.
527
+ const readiness = await (runtime?.readiness ?? officialToolReadiness)(toolId, source)
528
+ const readyModes = readiness.readyModes ?? readiness.modes ?? []
529
+ if (!readiness.ready || !readyModes.includes(mode)) return { toolId, status: 'unsupported',
530
+ reason: readiness.reason ?? readiness.modeReasons?.[mode] ?? `官方 CLI 尚未就绪,或不支持 ${mode} 模式。` }
398
531
  const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
399
- const result = await runOfficialTool({ toolId, task, modelId, workspace: isolated?.workspace ?? source,
400
- mode, isolatedRoot: isolated?.isolatedRoot, signal, sandbox })
532
+ let result
533
+ try {
534
+ result = await (runtime?.runTool ?? runOfficialTool)({ toolId, task, modelId, workspace: isolated?.workspace ?? source,
535
+ mode, isolatedRoot: isolated?.isolatedRoot, signal, sandbox })
536
+ if (!result || typeof result.status !== 'string') throw new TypeError('官方 CLI 执行器没有返回有效状态。')
537
+ } catch (error) {
538
+ result = executionException(error, signal)
539
+ }
540
+ result = withFailureDiagnostic(result)
401
541
  if (!isolated || result.status !== 'succeeded') return { ...result, ...(isolated ? { isolatedWorkspace: isolated.workspace, baseCommit: isolated.baseCommit } : {}) }
402
542
  try {
403
543
  const integration = await integrateWorktree(source, isolated.workspace, isolated.baseCommit, signal)