@ljwei-stak/dsh-model-router 0.18.1 → 0.19.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.
@@ -26,6 +26,7 @@ import {
26
26
  import {
27
27
  expandInstallDir,
28
28
  resolveInstallLocation,
29
+ resolveLegacyScriptLocation,
29
30
  resolveInstallPlan,
30
31
  resolveUninstallPlan,
31
32
  } from './tool-install-preferences.mjs'
@@ -59,7 +60,7 @@ export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
59
60
  * and script source). Refreshed on apply and live config updates, so a client
60
61
  * can only ever pick a method id — these addresses never arrive over the wire.
61
62
  */
62
- let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
63
+ let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true, zcodeInstallDir: '' }
63
64
 
64
65
  export function setInstallPreferences(next) {
65
66
  if (next && typeof next === 'object') {
@@ -308,16 +309,22 @@ async function fetchOfficialMiniMaxInstaller(signal) {
308
309
  }
309
310
 
310
311
  export function minimaxOfficialInstallDir(selectedPreferences = {}, home = homedir(), existingRoot = '') {
311
- return selectedPreferences.installDir || existingRoot || join(home, '.minimax-code')
312
+ return selectedPreferences.installDir
313
+ ? resolveInstallPlan(getOfficialTool('minimax-code'), 'script-powershell', selectedPreferences, { platform: 'win32', home }).installDir
314
+ : existingRoot || join(home, '.minimax-code')
312
315
  }
313
316
 
314
317
  /** Keep a discovered native installation in place when its legacy updater runs. */
315
318
  export function miniMaxFallbackPlan(selectedPreferences, beforeProbe, prefix, options = currentInstallOptions()) {
316
319
  const installDir = minimaxOfficialInstallDir(selectedPreferences, options.home,
317
320
  beforeProbe?.installed ? beforeProbe.installDir : '')
318
- const executionPreferences = { ...selectedPreferences, installDir,
321
+ const executionPreferences = { ...selectedPreferences,
319
322
  methods: { ...selectedPreferences.methods, 'minimax-code': 'script-powershell' } }
320
- return { plan: resolveInstallPlan(getOfficialTool('minimax-code'), 'script-powershell', executionPreferences, options), executionPreferences }
323
+ const plan = resolveInstallPlan(getOfficialTool('minimax-code'), 'script-powershell', selectedPreferences, options)
324
+ // installDir is already the discovered exclusive native root or the resolved
325
+ // child of the configured base. Never reinterpret it as another shared base.
326
+ return { plan: { ...plan, installDir, binDir: installDir,
327
+ env: { ...plan.env, MCODE_INSTALL_DIR: installDir } }, executionPreferences }
321
328
  }
322
329
 
323
330
  async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
@@ -330,6 +337,7 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
330
337
  pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
331
338
  const { plan, executionPreferences } = miniMaxFallbackPlan(job.preferences, job.beforeProbe, '')
332
339
  const installDir = plan.installDir
340
+ await assertVendorInstallDirectory(job.tool, plan, job.preferences)
333
341
  await mkdir(installDir, { recursive: true })
334
342
  prefixRoot = await realpath(installDir)
335
343
  const script = await fetchOfficialMiniMaxInstaller(job.controller.signal)
@@ -354,6 +362,7 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
354
362
  + 'Import-Module -Name $env:MODEL_ROUTER_UTILITY_MODULE -ErrorAction Stop; '
355
363
  + '& $env:MODEL_ROUTER_MINIMAX_INSTALL_SCRIPT'
356
364
  job.phase = 'installing'
365
+ await assertVendorInstallDirectory(job.tool, plan, job.preferences)
357
366
  const outcome = await runCapture(powershell,
358
367
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', command], {
359
368
  timeoutMs: INSTALL_TIMEOUT_MS, signal: job.controller.signal,
@@ -378,7 +387,7 @@ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
378
387
  probeCache.delete(job.tool.id)
379
388
  const probe = await probeToolWith(job.tool, defaultRunner, { preferences: executionPreferences, plan, cacheMs: 0 })
380
389
  job.postInstallProbe = probe
381
- const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: executionPreferences })
390
+ const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: executionPreferences, plan })
382
391
  if (!probe.installed || !readiness.ready) {
383
392
  throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
384
393
  }
@@ -516,6 +525,95 @@ export function vendorScriptInvocation(plan, scriptPath) {
516
525
  return { file: plan.shell === 'sh' ? 'sh' : 'bash', args: [scriptPath, ...directoryArgs] }
517
526
  }
518
527
 
528
+ /** Resolve an existing ancestor too, so a missing child under a junction is checked. */
529
+ async function canonicalInstallPath(path) {
530
+ let current = resolve(path)
531
+ const suffix = []
532
+ for (;;) {
533
+ try { return join(await realpath(current), ...suffix) }
534
+ catch (error) {
535
+ if (error.code !== 'ENOENT') throw error
536
+ const parent = dirname(current)
537
+ if (parent === current) throw error
538
+ suffix.unshift(current.slice(parent.length).replace(/^[\\/]+/, ''))
539
+ current = parent
540
+ }
541
+ }
542
+ }
543
+
544
+ /** A vendor owns its target; its cleanup must never touch a shared npm prefix. */
545
+ export async function assertVendorInstallDirectory(tool, plan, selectedPreferences = {}, options = currentInstallOptions()) {
546
+ const directory = plan.installDir || plan.binDir
547
+ if (!directory) throw new Error('官方脚本安装目录无法确定,已停止安装。')
548
+ const canonical = await canonicalInstallPath(directory)
549
+ if (canonical.split(/[\\/]+/).some(part => ['node_modules', '.pnpm'].includes(part.toLowerCase()))) {
550
+ throw new Error('官方脚本不能写入 npm/pnpm 的包目录或共享存储目录。')
551
+ }
552
+ if (canonical === dirname(canonical) || canonical === await canonicalInstallPath(homedir())) {
553
+ throw new Error('官方脚本不能在磁盘根目录或用户主目录中执行。')
554
+ }
555
+ let codexOwnedRelease = false
556
+ if (tool.id === 'codex' && plan.shell === 'powershell') {
557
+ // The official Windows script keeps releases under the user's existing
558
+ // CODEX_HOME. CODEX_INSTALL_DIR selects only the visible bin junction.
559
+ // Respect that documented layout without redirecting the user's config.
560
+ const codexHome = options.env?.CODEX_HOME || join(options.home || homedir(), '.codex')
561
+ const homeRoot = await canonicalInstallPath(codexHome)
562
+ const releases = join(homeRoot, 'packages', 'standalone', 'releases')
563
+ const member = relative(releases, canonical)
564
+ const expectedVisible = !selectedPreferences.installDir || resolve(directory) === resolve(join(selectedPreferences.installDir, plan.method.installDirSubdir))
565
+ codexOwnedRelease = Boolean(expectedVisible && member && member !== '..' && !member.startsWith(`..${sep}`) && !isAbsolute(member))
566
+ }
567
+ if (selectedPreferences.installDir && plan.method?.supportsInstallDir) {
568
+ const base = await canonicalInstallPath(selectedPreferences.installDir)
569
+ const child = relative(base, canonical)
570
+ if (!codexOwnedRelease && (!child || child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child))) {
571
+ throw new Error('官方脚本必须使用统一安装根目录下的工具专属子目录;已拒绝覆盖共享目录。')
572
+ }
573
+ const owner = plan.method.installDirSubdir?.split(/[\\/]+/)[0]
574
+ const ownedRoot = owner ? join(base, owner) : null
575
+ const ownedChild = ownedRoot ? relative(ownedRoot, canonical) : '..'
576
+ if (!codexOwnedRelease && (ownedChild === '..' || ownedChild.startsWith(`..${sep}`) || isAbsolute(ownedChild))) {
577
+ throw new Error('官方脚本的目录联接必须留在本工具专属目录中,不能指向其他工具。')
578
+ }
579
+ }
580
+ // These fixed/default bin installers replace only their own launcher, not
581
+ // another CLI's runtime directory. Sharing ~/.local/bin is intentional.
582
+ if (plan.method?.sharedDefaultBin && (!selectedPreferences.installDir || !plan.method.supportsInstallDir)) return
583
+ const targetStat = await stat(directory).catch(error => {
584
+ if (error.code === 'ENOENT') return null
585
+ throw error
586
+ })
587
+ if (targetStat && !targetStat.isDirectory()) throw new Error('官方脚本安装目标不是目录。')
588
+ const packageText = await readFile(join(directory, 'package.json'), 'utf8').catch(error => {
589
+ if (error.code === 'ENOENT') return null
590
+ throw error
591
+ })
592
+ if (packageText) {
593
+ let name
594
+ try { name = JSON.parse(packageText).name } catch { throw new Error('安装目录已有无法核验归属的 package.json,已停止运行官方脚本。') }
595
+ const ownPackage = tool.package || (tool.id === 'stepcode' ? '@step-harness/coding-agent' : '')
596
+ if (!name || name !== ownPackage) throw new Error('安装目录已有其他工具或项目的 package.json,已停止运行官方脚本。')
597
+ }
598
+ for (const other of OFFICIAL_TOOLS) {
599
+ if (other.id === tool.id) continue
600
+ const commandDirectories = [...new Set([directory, plan.binDir].filter(Boolean))]
601
+ const candidates = commandDirectories.flatMap(dir => (other.probeExecutables ?? []).flatMap(name =>
602
+ [name, `${name}.exe`, `${name}.cmd`, `${name}.ps1`].map(file => join(dir, file))))
603
+ if (other.package) {
604
+ candidates.push(join(directory, 'node_modules', ...other.package.split('/'), 'package.json'))
605
+ candidates.push(join(directory, 'lib', 'node_modules', ...other.package.split('/'), 'package.json'))
606
+ }
607
+ for (const path of candidates) {
608
+ const found = await stat(path).catch(error => {
609
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
610
+ throw error
611
+ })
612
+ if (found?.isFile()) throw new Error(`安装目录中已有 ${other.label} 的文件,已拒绝运行可能替换其资源的官方脚本。请使用独立目录。`)
613
+ }
614
+ }
615
+ }
616
+
519
617
  /**
520
618
  * Install one tool from its vendor script: download, verify, stage, run the
521
619
  * staged copy. The binary lands in a directory the plugin appends to its own
@@ -527,6 +625,7 @@ async function runVendorScriptInstallJob(job, plan) {
527
625
  let staging = null
528
626
  try {
529
627
  job.phase = 'preflight'
628
+ await assertVendorInstallDirectory(job.tool, plan, job.preferences)
530
629
  const before = await probeToolWith(job.tool, defaultRunner, { plan, preferences: job.preferences, cacheMs: 0 })
531
630
  job.beforeProbe = before
532
631
  const latestBefore = before.installed ? await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null) : null
@@ -548,6 +647,7 @@ async function runVendorScriptInstallJob(job, plan) {
548
647
  await writeFile(scriptPath, isPowerShell ? `\uFEFF${source}` : source, 'utf8')
549
648
  const invocation = vendorScriptInvocation(plan, scriptPath)
550
649
  job.phase = 'installing'
650
+ await assertVendorInstallDirectory(job.tool, plan, job.preferences)
551
651
  pushLine(job, `$ ${plan.label}${invocation.args.length > 1 ? ` ${invocation.args.slice(1).join(' ')}` : ''}`)
552
652
  const outcome = await runCapture(invocation.file, invocation.args, {
553
653
  timeoutMs: INSTALL_TIMEOUT_MS,
@@ -768,10 +868,13 @@ async function probeUncached(tool, runner, { selectedPreferences, plan, fileExis
768
868
  }
769
869
  if (tool.manager === 'signed-windows-installer') {
770
870
  let rejected = null
771
- const bundle = await discoverZCodeBundle({ onRejected: value => { rejected ??= value } })
871
+ const bundle = await discoverZCodeBundle({ installDir: (selectedPreferences ?? preferences).zcodeInstallDir,
872
+ onRejected: value => { rejected ??= value } })
772
873
  if (bundle) return { id: tool.id, installed: true, version: bundle.version, status: 'installed',
773
- detail: `${tool.probeNote} 安装目录:${bundle.root}`, bannerLine: `${tool.label} ${bundle.buildVersion}` }
774
- if (rejected) return { id: tool.id, installed: false, version: null, status: 'not-installed', detail: `检测到已安装的 ZCode ${rejected.version}(${rejected.root}):${rejected.rejected}` }
874
+ detail: `${tool.probeNote} 安装目录:${bundle.root}`, bannerLine: `${tool.label} 桌面版 ${bundle.buildVersion}`,
875
+ installDir: bundle.root, cliEntry: bundle.cliEntry, source: bundle.source }
876
+ if (rejected) return { id: tool.id, installed: false, version: null, status: 'not-installed',
877
+ detail: rejected.version ? `检测到 ZCode ${rejected.version}(${rejected.root}):${rejected.rejected}` : rejected.rejected }
775
878
  const other = (await unverifiedZCodeInstalls().catch(() => []))[0]
776
879
  return { id: tool.id, installed: false, version: null, status: 'not-installed',
777
880
  detail: other
@@ -827,6 +930,22 @@ async function probeUncached(tool, runner, { selectedPreferences, plan, fileExis
827
930
  bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '' }
828
931
  }
829
932
  }
933
+ const legacy = resolveLegacyScriptLocation(tool, selectedPlan.method, selectedPreferences, currentInstallOptions())
934
+ if (legacy) {
935
+ for (const executable of tool.probeExecutables) {
936
+ const names = IS_WINDOWS ? [`${executable}.exe`, `${executable}.cmd`, executable] : [executable]
937
+ for (const name of names) {
938
+ const target = join(legacy.dir, name)
939
+ if (!(await fileExists(target))) continue
940
+ // Discovery is read-only. The old root can never satisfy a new job's
941
+ // target probe or become a script update/uninstall destination.
942
+ return { id: tool.id, installed: false, version: null, status: 'legacy-install',
943
+ installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
944
+ legacyExecutablePath: target,
945
+ detail: `发现旧共享目录中的 ${tool.label}:${target}。请安装到独立目录 ${selectedPlan.installDir};旧文件会保留,不能用旧目录覆盖其他工具。` }
946
+ }
947
+ }
948
+ }
830
949
  return { id: tool.id, installed: false, version: null, status: 'not-installed',
831
950
  installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
832
951
  detail: `未在所选安装目录 ${selectedPlan.binDir} 中找到官方命令。` }
@@ -1006,6 +1125,7 @@ function jobView(job) {
1006
1125
  exitCode: job.exitCode,
1007
1126
  timedOut: job.timedOut,
1008
1127
  error: job.error,
1128
+ executionReadiness: job.executionReadiness ?? null,
1009
1129
  outputTail: [...job.outputTail],
1010
1130
  outputTruncated: job.outputTruncated,
1011
1131
  postInstallProbe: job.postInstallProbe,
@@ -1272,8 +1392,10 @@ export function installEnvironment(plan, base = process.env) {
1272
1392
  return env
1273
1393
  }
1274
1394
 
1275
- /** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
1276
- async function finishVerifiedInstall(job, probe) {
1395
+ /** Installation and version must pass; verify execution only where an adapter supports it. */
1396
+ export async function finishVerifiedInstall(job, probe, {
1397
+ lookupLatest = tool => latestVersions.lookup(tool), readinessFor = trustedExecutionReadiness,
1398
+ } = {}) {
1277
1399
  job.postInstallProbe = probe
1278
1400
  if (job.cancelRequested) { markCancelled(job); return true }
1279
1401
  if (!probe.installed) {
@@ -1282,14 +1404,15 @@ async function finishVerifiedInstall(job, probe) {
1282
1404
  job.error = `安装命令已完成,但重探测失败:${probe.detail}`
1283
1405
  return true
1284
1406
  }
1285
- const latest = await latestVersions.lookup(job.tool).catch(() => null)
1407
+ const latest = await lookupLatest(job.tool).catch(() => null)
1286
1408
  if (latest?.version && versionStillOlder(probe.version, latest.version)) {
1287
1409
  failBecauseStillOlder(job, probe, latest.version)
1288
1410
  return true
1289
1411
  }
1290
- const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
1412
+ const readiness = await readinessFor(job.tool.id, { preferences: job.preferences, plan: job.plan })
1413
+ job.executionReadiness = readiness
1291
1414
  if (job.cancelRequested) { markCancelled(job); return true }
1292
- if (!readiness.ready) {
1415
+ if (!readiness.ready && readiness.supported !== false) {
1293
1416
  if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1294
1417
  await runOfficialMiniMaxInstaller(job)
1295
1418
  return true
@@ -1300,8 +1423,11 @@ async function finishVerifiedInstall(job, probe) {
1300
1423
  return true
1301
1424
  }
1302
1425
  job.status = 'succeeded'
1426
+ job.error = null
1303
1427
  job.finishedAt = new Date().toISOString()
1304
- pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
1428
+ pushLine(job, readiness.supported === false
1429
+ ? `— 已安装 ${job.tool.label} ${probe.version ?? ''},版本探测已通过;受限执行未启用:${readiness.reason} —`
1430
+ : `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
1305
1431
  return true
1306
1432
  }
1307
1433
 
@@ -1378,11 +1504,13 @@ async function runInstallJob(installPlan, job) {
1378
1504
  // exactly the case the user pressed 修复 for.
1379
1505
  if (job.repair) {
1380
1506
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,按修复流程重新安装 —`)
1381
- } else if (readiness.ready || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
1507
+ } else if (readiness.ready || readiness.supported === false || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
1382
1508
  job.status = 'succeeded'
1383
1509
  job.finishedAt = new Date().toISOString()
1384
1510
  job.postInstallProbe = before
1511
+ job.executionReadiness = readiness
1385
1512
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,无需重复安装 —`)
1513
+ if (readiness.supported === false) pushLine(job, `— 受限执行未启用:${readiness.reason} —`)
1386
1514
  return
1387
1515
  } else {
1388
1516
  pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
@@ -1464,30 +1592,43 @@ async function runInstallJob(installPlan, job) {
1464
1592
  await finishVerifiedInstall(job, probe)
1465
1593
  }
1466
1594
 
1467
- async function runZCodeInstallJob(job) {
1595
+ export async function runZCodeInstallJob(job, {
1596
+ probe = probeToolWith, lookup = (...args) => latestVersions.lookup(...args),
1597
+ openInstaller = openZCodeInstaller, capture = runCapture,
1598
+ } = {}) {
1468
1599
  if (job.status !== 'running') return
1469
1600
  job.phase = 'preflight'
1470
1601
  probeCache.delete(job.tool.id)
1471
- const before = await probeToolWith(job.tool, defaultRunner)
1602
+ const before = await probe(job.tool, defaultRunner, { preferences: job.preferences, cacheMs: 0 })
1472
1603
  if (job.cancelRequested) { markCancelled(job); return }
1473
- const latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
1604
+ const latest = await lookup(job.tool, { fresh: true }).catch(() => null)
1474
1605
  if (job.cancelRequested) { markCancelled(job); return }
1475
- if (before.installed && latest?.version && (compareReleaseVersions(before.version, latest.version) ?? -1) >= 0) {
1606
+ const desktopOrder = before.installed && latest?.version ? compareReleaseVersions(before.version, latest.version) : null
1607
+ if (before.installed && desktopOrder === null) {
1608
+ job.status = 'failed'
1609
+ job.phase = 'done'
1610
+ job.finishedAt = new Date().toISOString()
1611
+ job.error = '无法核实 ZCode 最新桌面版本;保留当前安装,网络恢复后重试检查更新。'
1612
+ return
1613
+ }
1614
+ if (desktopOrder === 1 || (job.operation !== 'repair' && desktopOrder === 0)) {
1476
1615
  job.status = 'succeeded'
1477
1616
  job.phase = 'done'
1478
1617
  job.finishedAt = new Date().toISOString()
1479
1618
  job.postInstallProbe = before
1480
- pushLine(job, `— 已找到 ZCode ${before.version}(最新 ${latest.version}),无需重复打开安装器 —`)
1619
+ pushLine(job, desktopOrder === 1
1620
+ ? `— ZCode ${before.version} 高于下载页的 ${latest.version},不会打开旧版安装器降级;TUI 使用当前桌面内置入口 —`
1621
+ : `— 已找到 ZCode ${before.version}(最新 ${latest.version}),无需重复打开安装器;TUI 使用当前桌面内置入口 —`)
1481
1622
  return
1482
1623
  }
1483
- const prefix = await runCapture('npm', ['config', 'get', 'prefix'], {
1624
+ const prefix = await capture('npm', ['config', 'get', 'prefix'], {
1484
1625
  timeoutMs: 5_000, useShell: IS_WINDOWS, signal: job.controller.signal,
1485
1626
  })
1486
1627
  if (job.cancelRequested) { markCancelled(job); return }
1487
1628
  job.phase = 'downloading-and-verifying'
1488
1629
  let lastProgress = 0
1489
1630
  try {
1490
- const opened = await openZCodeInstaller({
1631
+ const opened = await openInstaller({
1491
1632
  latest,
1492
1633
  npmPrefix: prefix.ok ? prefix.stdout.trim() : undefined,
1493
1634
  signal: job.controller.signal,
@@ -1504,7 +1645,11 @@ async function runZCodeInstallJob(job) {
1504
1645
  job.finishedAt = new Date().toISOString()
1505
1646
  probeCache.delete(job.tool.id)
1506
1647
  pushLine(job, `已验证发布者签名并打开 ZCode ${opened.version} 官方安装器:${opened.installerPath}`)
1507
- pushLine(job, '请在原厂安装界面选择非 C 盘目录;完成后点击“重新检测”。')
1648
+ const desktopDirectory = before.installDir || (/[\\/]ZCode\.exe$/i.test(job.preferences.zcodeInstallDir ?? '')
1649
+ ? dirname(job.preferences.zcodeInstallDir) : job.preferences.zcodeInstallDir)
1650
+ pushLine(job, desktopDirectory
1651
+ ? `请在原厂安装界面选择原桌面安装目录 ${desktopDirectory};完成后重新体检,内置 TUI 随桌面版更新。`
1652
+ : '请在原厂安装界面选择独立的 ZCode 桌面目录;完成后保存该目录并重新体检,内置 TUI 随桌面版更新。')
1508
1653
  } catch (error) {
1509
1654
  if (job.cancelRequested || job.controller.signal.aborted) { markCancelled(job); return }
1510
1655
  job.status = 'failed'
@@ -1575,5 +1720,5 @@ export function resetForTests() {
1575
1720
  installJobs.clear()
1576
1721
  installChain = Promise.resolve()
1577
1722
  prefixPromise = null
1578
- preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
1723
+ preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true, zcodeInstallDir: '' }
1579
1724
  }
@@ -14,8 +14,11 @@
14
14
  import { spawn } from 'node:child_process'
15
15
  import { StringDecoder } from 'node:string_decoder'
16
16
  import { realpath, stat } from 'node:fs/promises'
17
- import { isAbsolute, win32 } from 'node:path'
17
+ import { dirname, isAbsolute, join, relative, sep, win32 } from 'node:path'
18
18
  import { toolForProvider } from './official-tool-registry.mjs'
19
+ import { findGlobalPackageEntries, findNodeExecutable, officialToolEntrySelection } from './official-tool-executor.mjs'
20
+ import { installedModuleRoots } from './vendor-mimo-grok-adapter.mjs'
21
+ import { CODE_FILE, npmAttestor } from './npm-attestation.mjs'
19
22
  import { normalizeExecutionPreference } from './model-profiles.mjs'
20
23
  import { BILLING_MODE_LABEL, billingPlan, detectQuotaExhaustion, vendorKey } from './subscription-billing.mjs'
21
24
 
@@ -666,6 +669,45 @@ function officialSuccess({ route, adapter, preference, answer, exitCode, version
666
669
  }
667
670
  }
668
671
 
672
+ /** Gemini's Windows npm shim cannot be spawned without a shell; use its selected package bin. */
673
+ export async function resolvePortableToolLaunch(adapter, workspace, {
674
+ selection = adapter?.id === 'gemini' ? officialToolEntrySelection('gemini') : null,
675
+ findEntries = findGlobalPackageEntries, findNode = findNodeExecutable, attestor = npmAttestor,
676
+ } = {}) {
677
+ if (adapter?.id !== 'gemini') return { file: adapter.executable, args: [] }
678
+ const entries = await findEntries('@google/gemini-cli', 'gemini', 'dist/index.js', workspace, { selection })
679
+ const found = entries[0]
680
+ if (!found) return { reason: '未在所选安装目录中找到 @google/gemini-cli 的 gemini 入口;已回退模型目录 API。' }
681
+ const inside = (root, entry) => {
682
+ const part = relative(root, entry)
683
+ return part !== '' && part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)
684
+ }
685
+ const roots = Array.isArray(selection?.plan?.packageModuleRoots) ? selection.plan.packageModuleRoots
686
+ : await installedModuleRoots(selection?.directories ?? [], { manager: selection?.plan?.manager })
687
+ const prefix = selection?.plan?.installDir ? await realpath(selection.plan.installDir).catch(() => null) : null
688
+ if (selection?.plan?.installDir && !prefix) return { reason: 'Gemini 所选安装根无法确认,已拒绝启动并回退模型目录 API。' }
689
+ let contained = false
690
+ for (const declared of roots) {
691
+ const boundary = await realpath(declared).catch(() => null)
692
+ const parent = await realpath(dirname(declared)).catch(() => null)
693
+ if (!boundary || !parent || !inside(parent, boundary) || (prefix && !inside(prefix, boundary))) continue
694
+ if (inside(boundary, found.packageRoot) && inside(found.packageRoot, found.entry)
695
+ && !inside(workspace, found.packageRoot) && found.packageRoot !== workspace) { contained = true; break }
696
+ }
697
+ if (!contained) return { reason: 'Gemini 包或入口离开了所选安装目录的包存储树,已拒绝启动并回退模型目录 API。' }
698
+ // The manifest controls bin/import resolution, so attest it as well as all
699
+ // package code. Checking name/version or a signed Node alone is insufficient.
700
+ const include = new RegExp(`${CODE_FILE.source}|(?:^|/)package\\.json$`, 'i')
701
+ const checked = await attestor.matchesPackage(found.packageRoot, '@google/gemini-cli', found.version, include)
702
+ if (!checked.ok) return { reason: `Gemini 安装文件未通过官方 npm 摘要核验:${checked.reason};已回退模型目录 API。` }
703
+ const member = relative(found.packageRoot, found.entry).split(sep).join('/')
704
+ const entryChecked = await attestor.matches(found.entry, '@google/gemini-cli', found.version, member)
705
+ if (!entryChecked.ok) return { reason: `Gemini 入口未通过官方 npm 摘要核验:${entryChecked.reason};已回退模型目录 API。` }
706
+ const node = await findNode(workspace, [20, 0, 0])
707
+ if (!node) return { reason: '未找到工作区外的 Node.js 20+ 可执行文件;已回退模型目录 API。' }
708
+ return { file: node, args: [found.entry], version: found.version, source: 'official-npm-registry-attested' }
709
+ }
710
+
669
711
  /**
670
712
  * Run one assigned model task. `auto` and `official` try the vendor CLI first.
671
713
  * `api` skips the CLI. A missing tool or a failed process uses `apiFallback`.
@@ -673,6 +715,7 @@ function officialSuccess({ route, adapter, preference, answer, exitCode, version
673
715
  export async function executeAssignedTask({
674
716
  route, task, workspace, timeoutMs, signal, credentials = null, spawnImpl = spawn,
675
717
  apiFallback, runVerified = null, onChunk = null, skipOfficial = null, stopGraceMs, credentialMode = 'default',
718
+ resolvePortableLaunch = resolvePortableToolLaunch,
676
719
  } = {}) {
677
720
  const prompt = checkedTask(task)
678
721
  const preference = executionPreference(route)
@@ -734,7 +777,13 @@ export async function executeAssignedTask({
734
777
  })
735
778
  }
736
779
  const { env, secret, credentialSource } = childEnvironment(adapter, credentials, credentialMode)
737
- const probe = await captureProcess(spawnImpl, adapter.executable, ['--version'], {
780
+ let launch
781
+ try { launch = await resolvePortableLaunch(adapter, cwd) } catch (error) {
782
+ launch = { reason: `${adapter.label} 的安装入口无法读取:${String(error?.message ?? error).slice(0, 200)};已回退模型目录 API。` }
783
+ }
784
+ if (!launch?.file) return useApi(apiFallback, { route, task: prompt, signal, adapter, preference,
785
+ reason: launch?.reason ?? `${adapter.label} 未安装或无法启动,已回退模型目录 API。` })
786
+ const probe = await captureProcess(spawnImpl, launch.file, [...launch.args, '--version'], {
738
787
  cwd, env, stdin: '', timeoutMs: Math.min(PROBE_TIMEOUT_MS, timeout), signal, secret,
739
788
  })
740
789
  if (probe.spawnError || probe.cancelled || probe.timedOut || probe.exitCode !== 0) {
@@ -748,7 +797,7 @@ export async function executeAssignedTask({
748
797
  return useApi(apiFallback, { route, task: prompt, signal, adapter, preference, reason })
749
798
  }
750
799
  const args = adapter.buildArgs(modelId)
751
- const run = await captureProcess(spawnImpl, adapter.executable, args, {
800
+ const run = await captureProcess(spawnImpl, launch.file, [...launch.args, ...args], {
752
801
  cwd, env, stdin: prompt, timeoutMs: timeout, signal, onChunk, secret,
753
802
  ...(Number.isSafeInteger(stopGraceMs) && stopGraceMs >= 10 && stopGraceMs <= STOP_GRACE_MS ? { stopGraceMs } : {}),
754
803
  })
@@ -70,6 +70,26 @@ export function expandInstallDir(value, { home = '', env = {} } = {}) {
70
70
  return expanded
71
71
  }
72
72
 
73
+ /** ZCode's existing desktop bundle is selected independently of npm's prefix. */
74
+ export function normalizeZCodeInstallDir(value, options = {}) {
75
+ const input = String(value ?? '')
76
+ if (/[\x00-\x1f]/.test(input)) throw new Error('ZCode 安装位置包含无效字符。')
77
+ const raw = input.trim().replace(/^"(.*)"$/, '$1')
78
+ if (!raw) return ''
79
+ let path
80
+ try { path = expandInstallDir(raw, options) } catch (error) {
81
+ throw new Error(`ZCode 安装位置无效:${error.message}`)
82
+ }
83
+ // Discovery and Authenticode verification operate on local Windows files.
84
+ if (!/^[A-Za-z]:[\\/]/.test(path)) {
85
+ throw new Error('ZCode 安装位置必须是 Windows 本地绝对目录或 ZCode.exe 路径。')
86
+ }
87
+ if (/\.exe[\\/]*$/i.test(path) && !/[\\/]ZCode\.exe[\\/]*$/i.test(path)) {
88
+ throw new Error('ZCode 程序路径必须指向官方 ZCode.exe。')
89
+ }
90
+ return path.replace(/[\\/]+$/, '')
91
+ }
92
+
73
93
  /** Only https mirrors are accepted; a plain-http script source can be tampered with in transit. */
74
94
  export function normalizeSourceUrl(value, subject) {
75
95
  const raw = String(value ?? '').trim()
@@ -116,6 +136,7 @@ function keepKnownToolIds(raw, known, subject) {
116
136
  export function parseInstallPreferences(config = {}, { tools = [], home = '', env = {} } = {}) {
117
137
  const known = new Set(tools.map(tool => tool.id))
118
138
  const installDir = expandInstallDir(valueOf(config, 'toolInstallDir', ''), { home, env })
139
+ const zcodeInstallDir = normalizeZCodeInstallDir(valueOf(config, 'zcodeInstallDir', ''), { home, env })
119
140
  const registry = normalizeSourceUrl(valueOf(config, 'toolNpmRegistry', DEFAULT_NPM_REGISTRY), 'npm 源') || DEFAULT_NPM_REGISTRY
120
141
  const methodRaw = keepKnownToolIds(
121
142
  parseJsonObject(valueOf(config, 'toolInstallMethodsJson', '{}'), '安装方式覆盖'), known, '安装方式覆盖')
@@ -134,6 +155,7 @@ export function parseInstallPreferences(config = {}, { tools = [], home = '', en
134
155
  }
135
156
  return {
136
157
  installDir,
158
+ zcodeInstallDir,
137
159
  registry,
138
160
  methods,
139
161
  scriptUrls,
@@ -249,12 +271,17 @@ export function resolveInstallPlan(tool, methodId, preferences = {}, { platform
249
271
  const binDir = target
250
272
  ? (method.installDirSuffix ? joinPath(target, method.installDirSuffix) : target)
251
273
  : vendorScriptDir(tool, { home, env, method })
252
- const notices = target ? [] : installDir
274
+ const notices = target && installDir && method.installDirSubdir
275
+ ? [`官方脚本在工具专属子目录 ${target} 维护资源,保护统一安装根中的其他工具。`]
276
+ : target ? [] : installDir
253
277
  ? [`${method.label} 无法使用自定义安装目录(官方脚本写死了安装路径),将安装到 ${vendorScriptDir(tool, { home, env, method })}。`]
254
278
  : []
255
279
  if (method.registryEnv === 'MCODE_NPM_REGISTRY' && preferences.registry && !minimaxScriptRegistry(preferences.registry)) {
256
280
  notices.push('MiniMax 官方脚本只接受 https://registry.npmjs.org/ 或 https://registry.npmmirror.com/,本次不改写它的 npm 源。')
257
281
  }
282
+ if (tool.id === 'codex' && method.id === 'script-powershell') {
283
+ notices.push('Codex Windows 脚本将命令目录联接到用户 Codex 资源目录;统一根控制命令位置,运行资源仍由厂商保存在用户目录。需要所有文件位于统一根时请选 npm/pnpm。')
284
+ }
258
285
  return {
259
286
  toolId: tool.id,
260
287
  method,
@@ -332,6 +359,20 @@ export function resolveInstallLocation(tool, method, preferences = {}, { home =
332
359
  return { kind: 'package-manager', dir: packageManagerBinDir(preferences?.installDir || '', { platform }), installDir: preferences?.installDir || '', binaries: [] }
333
360
  }
334
361
 
362
+ /**
363
+ * Read-only compatibility location for scripts installed directly into a
364
+ * configured shared root before dedicated script directories were introduced.
365
+ * Never use this result as an install/update target: those installers can
366
+ * replace a whole runtime tree belonging to another tool.
367
+ */
368
+ export function resolveLegacyScriptLocation(tool, method, preferences = {}, options = {}) {
369
+ if (!preferences.installDir || !method?.legacySharedInstallDir || !methodHonoursInstallDir(method)) return null
370
+ return {
371
+ ...resolveInstallLocation(tool, { ...method, installDirSubdir: undefined }, preferences, options),
372
+ legacySharedInstallDir: true,
373
+ }
374
+ }
375
+
335
376
  function scriptEnv(method, { installDir, scriptUrl, registry }) {
336
377
  const env = {}
337
378
  if (installDir && method.installDirEnv) env[method.installDirEnv] = installDir
@@ -46,6 +46,14 @@ function powershellPath(env) {
46
46
  return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
47
47
  }
48
48
 
49
+ /** PowerShell 7's module path can prevent Windows PowerShell 5.1 loading Security. */
50
+ export function zcodeWindowsPowerShellEnvironment(env = process.env) {
51
+ return {
52
+ ...env,
53
+ PSModulePath: join(env.SystemRoot || env.windir || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'Modules'),
54
+ }
55
+ }
56
+
49
57
  async function powershellJson(script, env, extraEnv = {}) {
50
58
  if (process.platform !== 'win32') return null
51
59
  const command = Buffer.from(
@@ -62,7 +70,7 @@ async function powershellJson(script, env, extraEnv = {}) {
62
70
  encoding: 'utf8',
63
71
  timeout: 10_000,
64
72
  maxBuffer: 256_000,
65
- env: { ...env, ...extraEnv },
73
+ env: { ...zcodeWindowsPowerShellEnvironment(env), ...extraEnv },
66
74
  })
67
75
  const output = stdout.trim().replace(/^\uFEFF/, '')
68
76
  return output ? JSON.parse(output) : null
@@ -94,13 +102,21 @@ function rootFromCommand(value) {
94
102
  return executable && win32.isAbsolute(executable) ? dirname(executable) : null
95
103
  }
96
104
 
97
- function candidateRoots(env, records) {
105
+ /** Explicit selection is authoritative, even when the selected bundle is missing. */
106
+ export function zcodeDiscoveryCandidates({ installDir = '', env = process.env, records = [] } = {}) {
98
107
  const candidates = []
99
108
  const add = (path, source, registryVersion = null) => {
100
109
  if (typeof path !== 'string' || !path.trim()) return
101
110
  candidates.push({ path: path.trim().replace(/^"|"$/g, ''), source, registryVersion })
102
111
  }
103
- add(env[ZCODE_INSTALL_ENV_VAR], 'operator-env')
112
+ if (installDir != null && String(installDir).trim()) {
113
+ add(installDir, 'operator-config')
114
+ return candidates
115
+ }
116
+ if (env[ZCODE_INSTALL_ENV_VAR] != null && String(env[ZCODE_INSTALL_ENV_VAR]).trim()) {
117
+ add(env[ZCODE_INSTALL_ENV_VAR], 'operator-env')
118
+ return candidates
119
+ }
104
120
  for (const record of records) {
105
121
  const version = String(record.displayVersion || '') || null
106
122
  add(record.installLocation, 'uninstall-registry', version)
@@ -204,7 +220,7 @@ async function checkedBundle(candidate, env, trustPath) {
204
220
  const input = candidate.path
205
221
  if (!localWindowsPath(input)) return null
206
222
  try {
207
- const initial = input.toLowerCase().endsWith('\\zcode.exe') ? dirname(input) : input
223
+ const initial = /[\\/]zcode\.exe$/i.test(input) ? dirname(input) : input
208
224
  const root = await realpath(initial)
209
225
  if (!localWindowsPath(root) || !(await stat(root)).isDirectory()) return null
210
226
  const executable = await realpath(join(root, 'ZCode.exe'))
@@ -239,18 +255,23 @@ async function checkedBundle(candidate, env, trustPath) {
239
255
  }
240
256
  }
241
257
 
242
- /** Return the first verified local bundle (any version), or null when none is usable. */
243
- export async function discoverZCodeBundle({ env = process.env, trustPath = zcodeTrustPath(env), onRejected } = {}) {
258
+ /** A configured desktop location never falls back to a different installation. */
259
+ export async function discoverZCodeBundle({ installDir = '', env = process.env, trustPath = zcodeTrustPath(env), onRejected } = {}) {
244
260
  if (process.platform !== 'win32') return null
245
- const records = await uninstallRecords(env)
261
+ const explicit = Boolean(String(installDir || env[ZCODE_INSTALL_ENV_VAR] || '').trim())
262
+ const records = explicit ? [] : await uninstallRecords(env)
246
263
  const seen = new Set()
247
- for (const candidate of candidateRoots(env, records)) {
264
+ for (const candidate of zcodeDiscoveryCandidates({ installDir, env, records })) {
248
265
  const key = win32.normalize(candidate.path).toLowerCase()
249
266
  if (seen.has(key)) continue
250
267
  seen.add(key)
251
268
  const bundle = await checkedBundle(candidate, env, trustPath)
252
269
  if (bundle?.rejected) { onRejected?.(bundle); continue }
253
270
  if (bundle) return bundle
271
+ if (explicit) onRejected?.({
272
+ root: candidate.path,
273
+ rejected: `指定的 ZCode 安装位置 ${candidate.path} 不存在,或未通过官方签名、桌面内置 CLI 和目录边界核验;请检查独立安装位置。`,
274
+ })
254
275
  }
255
276
  return null
256
277
  }
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, relative, sep } from 'node:path'
19
19
  import { Readable, Transform } from 'node:stream'
20
20
  import { pipeline } from 'node:stream/promises'
21
21
  import { promisify } from 'node:util'
22
- import { ZCODE_SIGNER } from './zcode-bundle.mjs'
22
+ import { ZCODE_SIGNER, zcodeWindowsPowerShellEnvironment } from './zcode-bundle.mjs'
23
23
  import { getOfficialTool } from './official-tool-registry.mjs'
24
24
  import { latestVersions } from './latest-versions.mjs'
25
25
 
@@ -118,7 +118,7 @@ async function verifyInstallerSignature(path, env) {
118
118
  encoding: 'utf8',
119
119
  timeout: 120_000,
120
120
  maxBuffer: 16_384,
121
- env: { ...env, MODEL_ROUTER_ZCODE_INSTALLER_PATH: path },
121
+ env: { ...zcodeWindowsPowerShellEnvironment(env), MODEL_ROUTER_ZCODE_INSTALLER_PATH: path },
122
122
  })
123
123
  const signature = JSON.parse(stdout.trim().replace(/^\uFEFF/, ''))
124
124
  return signature.status === 'Valid'