@ljwei-stak/dsh-model-router 0.18.0 → 0.18.2

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.
@@ -179,13 +179,25 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
179
179
  executionCapabilities: officialToolExecutionCapabilities(),
180
180
  executionReadiness,
181
181
  hostVersion: HOST_PLUGIN_VERSION,
182
- install: installSummary(),
182
+ install: this.installSettings().install,
183
183
  }
184
184
  }
185
185
 
186
+ /** Refresh saved methods and commands without spawning CLIs or checking downloads. */
187
+ installSettings() {
188
+ const error = this.services?.installSettingsError?.()
189
+ if (error) throw new Error(`官方工具安装设置无效:${error}`)
190
+ const install = installSummary()
191
+ // The saved input can contain Host-only home/environment expansions. Keep
192
+ // it separate from the resolved directory used by the actual command.
193
+ install.requestedInstallDir = this.services?.installRequestedDirectory?.() ?? install.installDir
194
+ return { install, hostVersion: HOST_PLUGIN_VERSION }
195
+ }
196
+
186
197
  /** Start one serialized fixed-registry install; return immediately for UI polling. */
187
198
  async installTool(request) {
188
199
  return settled(async () => {
200
+ this.installSettings()
189
201
  await ensureNpmPrefixOnPath()
190
202
  return { accepted: true, job: startInstall(request.tool, { method: request.method }) }
191
203
  })
@@ -197,6 +209,7 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
197
209
  */
198
210
  async uninstallTool(request) {
199
211
  return settled(async () => {
212
+ this.installSettings()
200
213
  await ensureNpmPrefixOnPath()
201
214
  return { accepted: true, job: startUninstall(request.tool, { method: request.method }) }
202
215
  })
@@ -205,6 +218,7 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
205
218
  /** Reinstall the tool even when it already reports the latest version. */
206
219
  async repairTool(request) {
207
220
  return settled(async () => {
221
+ this.installSettings()
208
222
  await ensureNpmPrefixOnPath()
209
223
  return { accepted: true, job: startRepair(request.tool, { method: request.method }) }
210
224
  })
@@ -345,7 +345,7 @@ function nodeVersion(entry, pattern = /^v(\d+\.\d+\.\d+)/) {
345
345
  })
346
346
  }
347
347
 
348
- async function findNodeExecutable(workspace, minimum = [22, 19, 0], acceptedVersion = () => true) {
348
+ export async function findNodeExecutable(workspace, minimum = [22, 19, 0], acceptedVersion = () => true) {
349
349
  await ensureNpmPrefixOnPath()
350
350
  const candidates = [process.execPath, ...pathDirectories().map(directory => join(directory, IS_WINDOWS ? 'node.exe' : 'node'))]
351
351
  for (const candidate of candidates) {
@@ -676,17 +676,17 @@ export function harnessSandboxBlocker(toolId, platform = process.platform) {
676
676
  /** Check the actual trusted launch entry without starting an account call. */
677
677
  export async function officialToolReadiness(toolId, workspace = process.cwd(), selectionOptions = {}) {
678
678
  const tool = getOfficialTool(toolId)
679
- if (!tool) return { id: String(toolId), ready: false, reason: '工具不在固定官方注册表中。' }
679
+ if (!tool) return { id: String(toolId), supported: false, ready: false, reason: '工具不在固定官方注册表中。' }
680
680
  const capability = CAPABILITIES[tool.id]
681
- if (!capability?.supported) return { id: tool.id, ready: false, reason: capability?.reason ?? '当前平台不支持托管执行。' }
681
+ if (!capability?.supported) return { id: tool.id, supported: false, ready: false, reason: capability?.reason ?? '当前平台不支持托管执行。' }
682
682
  try {
683
683
  const directory = await checkedWorkspace(workspace)
684
684
  const spec = await launchSpec(tool.id, directory, capability.modes[0], null, undefined, selectionOptions)
685
685
  return spec.unsupported
686
- ? { id: tool.id, ready: false, reason: spec.unsupported }
687
- : { id: tool.id, ready: true, source: spec.source, modes: capability.modes }
686
+ ? { id: tool.id, supported: true, ready: false, reason: spec.unsupported }
687
+ : { id: tool.id, supported: true, ready: true, source: spec.source, modes: capability.modes }
688
688
  } catch (error) {
689
- return { id: tool.id, ready: false, reason: String(error?.message ?? error).slice(0, 300) }
689
+ return { id: tool.id, supported: true, ready: false, reason: String(error?.message ?? error).slice(0, 300) }
690
690
  }
691
691
  }
692
692
 
@@ -45,7 +45,7 @@ function scriptMethod(id, label, {
45
45
  shell, scriptUrl, verify, installArg = [], env = [], platforms,
46
46
  supportsInstallDir = false, installDirEnv = '', installDirSuffix = '',
47
47
  defaultScriptInstallDir, defaultScriptInstallDirEnv, scriptBinaryNames,
48
- registryEnv, installDirSubdir, uninstallUnsupportedReason, scriptFallbackUrls,
48
+ registryEnv, installDirSubdir, legacySharedInstallDir = false, sharedDefaultBin = false, uninstallUnsupportedReason, scriptFallbackUrls,
49
49
  }) {
50
50
  return Object.freeze({
51
51
  id,
@@ -64,6 +64,8 @@ function scriptMethod(id, label, {
64
64
  installDirEnv,
65
65
  installDirSuffix,
66
66
  ...(installDirSubdir ? { installDirSubdir } : {}),
67
+ ...(legacySharedInstallDir ? { legacySharedInstallDir: true } : {}),
68
+ ...(sharedDefaultBin ? { sharedDefaultBin: true } : {}),
67
69
  ...(uninstallUnsupportedReason ? { uninstallUnsupportedReason } : {}),
68
70
  ...(defaultScriptInstallDir ? { defaultScriptInstallDir } : {}),
69
71
  ...(defaultScriptInstallDirEnv ? { defaultScriptInstallDirEnv } : {}),
@@ -96,6 +98,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
96
98
  shell: 'bash', scriptUrl: 'https://code.kimi.com/kimi-code/install.sh',
97
99
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
98
100
  installDirEnv: 'KIMI_INSTALL_DIR', installDirSuffix: 'bin',
101
+ installDirSubdir: 'kimi-code', legacySharedInstallDir: true,
99
102
  env: ['KIMI_INSTALL_DIR'],
100
103
  scriptBinaryNames: ['kimi'],
101
104
  verify: Object.freeze({ mustInclude: ['kimi-code', 'code.kimi.com/kimi-code'] }),
@@ -104,6 +107,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
104
107
  shell: 'powershell', scriptUrl: 'https://code.kimi.com/kimi-code/install.ps1',
105
108
  platforms: ['win32'], supportsInstallDir: true,
106
109
  installDirEnv: 'KIMI_INSTALL_DIR', installDirSuffix: 'bin',
110
+ installDirSubdir: 'kimi-code', legacySharedInstallDir: true,
107
111
  env: ['KIMI_INSTALL_DIR'],
108
112
  scriptBinaryNames: ['kimi.exe'],
109
113
  verify: Object.freeze({ mustInclude: ['kimi-code', 'code.kimi.com/kimi-code'] }),
@@ -135,6 +139,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
135
139
  scriptFallbackUrls: ['https://downloads.claude.ai/claude-code-releases/bootstrap.sh'],
136
140
  platforms: ['linux', 'darwin'],
137
141
  scriptBinaryNames: ['claude'],
142
+ sharedDefaultBin: true,
138
143
  verify: Object.freeze({ mustInclude: ['downloads.claude.ai/claude-code-releases', 'manifest.json'] }),
139
144
  }),
140
145
  scriptMethod('script-powershell', 'irm | iex', {
@@ -142,6 +147,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
142
147
  scriptFallbackUrls: ['https://downloads.claude.ai/claude-code-releases/bootstrap.ps1'],
143
148
  platforms: ['win32'],
144
149
  scriptBinaryNames: ['claude.exe'],
150
+ sharedDefaultBin: true,
145
151
  verify: Object.freeze({ mustInclude: ['downloads.claude.ai/claude-code-releases', 'manifest.json'] }),
146
152
  }),
147
153
  ]),
@@ -167,6 +173,8 @@ export const OFFICIAL_TOOLS = Object.freeze([
167
173
  shell: 'sh', scriptUrl: 'https://chatgpt.com/codex/install.sh',
168
174
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
169
175
  installDirEnv: 'CODEX_INSTALL_DIR', env: ['CODEX_INSTALL_DIR'],
176
+ installDirSubdir: 'codex/bin', legacySharedInstallDir: true,
177
+ sharedDefaultBin: true,
170
178
  scriptBinaryNames: ['codex'],
171
179
  verify: Object.freeze({ mustInclude: ['releases.openai.com/codex', 'CODEX_INSTALL_DIR'] }),
172
180
  }),
@@ -210,6 +218,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
210
218
  shell: 'bash', scriptUrl: 'https://filecdn.minimax.chat/public/install.sh',
211
219
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
212
220
  installDirEnv: 'MCODE_INSTALL_DIR', installDirSuffix: 'bin',
221
+ installDirSubdir: 'minimax-code', legacySharedInstallDir: true,
213
222
  registryEnv: 'MCODE_NPM_REGISTRY', env: ['MCODE_INSTALL_DIR', 'MCODE_NPM_REGISTRY'],
214
223
  verify: Object.freeze({ mustInclude: ['MiniMax Code CLI', '@minimax-ai/code'] }),
215
224
  }),
@@ -217,6 +226,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
217
226
  shell: 'powershell', scriptUrl: 'https://filecdn.minimax.chat/public/install.ps1',
218
227
  platforms: ['win32'], supportsInstallDir: true,
219
228
  installDirEnv: 'MCODE_INSTALL_DIR',
229
+ installDirSubdir: 'minimax-code', legacySharedInstallDir: true,
220
230
  registryEnv: 'MCODE_NPM_REGISTRY', env: ['MCODE_INSTALL_DIR', 'MCODE_NPM_REGISTRY'],
221
231
  defaultScriptInstallDir: '.minimax-code',
222
232
  scriptBinaryNames: ['mcode.cmd', 'mcode.ps1', 'mcode-tools.cmd', 'mcode-tools.ps1'],
@@ -250,6 +260,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
250
260
  shell: 'powershell', scriptUrl: 'https://mimo.xiaomi.com/install.ps1',
251
261
  platforms: ['win32'], supportsInstallDir: true,
252
262
  installDirEnv: 'MIMOCODE_INSTALL_DIR', env: ['MIMOCODE_INSTALL_DIR'],
263
+ installDirSubdir: 'mimo-code', legacySharedInstallDir: true,
253
264
  scriptBinaryNames: ['mimo.exe'],
254
265
  verify: Object.freeze({ mustInclude: ['mimo.xiaomi.com', '.mimocode'] }),
255
266
  }),
@@ -276,6 +287,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
276
287
  shell: 'bash', scriptUrl: 'https://x.ai/cli/install.sh',
277
288
  platforms: ['linux', 'darwin'], supportsInstallDir: true,
278
289
  installDirEnv: 'GROK_BIN_DIR', env: ['GROK_BIN_DIR'],
290
+ installDirSubdir: 'grok-build', legacySharedInstallDir: true,
279
291
  scriptBinaryNames: ['grok', 'agent'],
280
292
  verify: Object.freeze({ mustInclude: ['Grok CLI installer', 'x.ai/cli'] }),
281
293
  }),
@@ -283,6 +295,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
283
295
  shell: 'powershell', scriptUrl: 'https://x.ai/cli/install.ps1',
284
296
  platforms: ['win32'], supportsInstallDir: true,
285
297
  installDirEnv: 'GROK_BIN_DIR', env: ['GROK_BIN_DIR'],
298
+ installDirSubdir: 'grok-build', legacySharedInstallDir: true,
286
299
  scriptBinaryNames: ['grok.exe', 'agent.exe', 'grove.exe', 'grove-fsmonitor.exe', 'grove-credential.exe'],
287
300
  verify: Object.freeze({ mustInclude: ['Grok CLI installer', 'x.ai/cli'] }),
288
301
  }),
@@ -365,6 +378,9 @@ export const OFFICIAL_TOOLS = Object.freeze([
365
378
  installArg: ['--install-dir'],
366
379
  env: ['STEP_INSTALL_DIR', 'STEP_RELEASE_BASE_URL'],
367
380
  supportsInstallDir: true,
381
+ // The installer replaces its runtime directories, including the entire
382
+ // node_modules tree on Windows. It must own a dedicated directory.
383
+ installDirSubdir: 'stepcode', legacySharedInstallDir: true,
368
384
  verify: Object.freeze({
369
385
  mustInclude: ['static-openapi.stepfun.com', 'stepcode installer'],
370
386
  mustNotInclude: ['anomalyco/opencode'],
@@ -377,6 +393,7 @@ export const OFFICIAL_TOOLS = Object.freeze([
377
393
  installArg: ['-InstallDir'],
378
394
  env: ['STEP_INSTALL_DIR', 'STEP_RELEASE_BASE_URL'],
379
395
  supportsInstallDir: true,
396
+ installDirSubdir: 'stepcode', legacySharedInstallDir: true,
380
397
  verify: Object.freeze({
381
398
  mustInclude: ['static-openapi.stepfun.com', 'stepcode'],
382
399
  mustNotInclude: ['anomalyco/opencode'],
@@ -193,6 +193,7 @@ const jsonParameter = (name, codec) => Object.freeze({ name, wire: name, source:
193
193
 
194
194
  export const OFFICIAL_TOOLS_REMOTE_DESCRIPTORS = Object.freeze([
195
195
  descriptor('list', [], listResultCodec),
196
+ descriptor('installSettings', [], anyObjectCodec('OfficialInstallSettings')),
196
197
  descriptor('installTool', [jsonParameter('request', toolActionCodec)], installResultCodec),
197
198
  descriptor('uninstallTool', [jsonParameter('request', toolActionCodec)], installResultCodec),
198
199
  descriptor('repairTool', [jsonParameter('request', toolActionCodec)], installResultCodec),
@@ -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'
@@ -56,7 +57,7 @@ export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
56
57
 
57
58
  /**
58
59
  * Host-side install preferences (install directory, npm source, per-tool method
59
- * and script source). Refreshed from plugin config on every apply, so a client
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
63
  let preferences = { installDir: '', registry: '', methods: {}, scriptUrls: {}, allowScriptInstall: true }
@@ -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,
@@ -827,6 +927,22 @@ async function probeUncached(tool, runner, { selectedPreferences, plan, fileExis
827
927
  bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '' }
828
928
  }
829
929
  }
930
+ const legacy = resolveLegacyScriptLocation(tool, selectedPlan.method, selectedPreferences, currentInstallOptions())
931
+ if (legacy) {
932
+ for (const executable of tool.probeExecutables) {
933
+ const names = IS_WINDOWS ? [`${executable}.exe`, `${executable}.cmd`, executable] : [executable]
934
+ for (const name of names) {
935
+ const target = join(legacy.dir, name)
936
+ if (!(await fileExists(target))) continue
937
+ // Discovery is read-only. The old root can never satisfy a new job's
938
+ // target probe or become a script update/uninstall destination.
939
+ return { id: tool.id, installed: false, version: null, status: 'legacy-install',
940
+ installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
941
+ legacyExecutablePath: target,
942
+ detail: `发现旧共享目录中的 ${tool.label}:${target}。请安装到独立目录 ${selectedPlan.installDir};旧文件会保留,不能用旧目录覆盖其他工具。` }
943
+ }
944
+ }
945
+ }
830
946
  return { id: tool.id, installed: false, version: null, status: 'not-installed',
831
947
  installDir: selectedPlan.installDir, binDir: selectedPlan.binDir,
832
948
  detail: `未在所选安装目录 ${selectedPlan.binDir} 中找到官方命令。` }
@@ -1006,6 +1122,7 @@ function jobView(job) {
1006
1122
  exitCode: job.exitCode,
1007
1123
  timedOut: job.timedOut,
1008
1124
  error: job.error,
1125
+ executionReadiness: job.executionReadiness ?? null,
1009
1126
  outputTail: [...job.outputTail],
1010
1127
  outputTruncated: job.outputTruncated,
1011
1128
  postInstallProbe: job.postInstallProbe,
@@ -1272,8 +1389,10 @@ export function installEnvironment(plan, base = process.env) {
1272
1389
  return env
1273
1390
  }
1274
1391
 
1275
- /** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
1276
- async function finishVerifiedInstall(job, probe) {
1392
+ /** Installation and version must pass; verify execution only where an adapter supports it. */
1393
+ export async function finishVerifiedInstall(job, probe, {
1394
+ lookupLatest = tool => latestVersions.lookup(tool), readinessFor = trustedExecutionReadiness,
1395
+ } = {}) {
1277
1396
  job.postInstallProbe = probe
1278
1397
  if (job.cancelRequested) { markCancelled(job); return true }
1279
1398
  if (!probe.installed) {
@@ -1282,14 +1401,15 @@ async function finishVerifiedInstall(job, probe) {
1282
1401
  job.error = `安装命令已完成,但重探测失败:${probe.detail}`
1283
1402
  return true
1284
1403
  }
1285
- const latest = await latestVersions.lookup(job.tool).catch(() => null)
1404
+ const latest = await lookupLatest(job.tool).catch(() => null)
1286
1405
  if (latest?.version && versionStillOlder(probe.version, latest.version)) {
1287
1406
  failBecauseStillOlder(job, probe, latest.version)
1288
1407
  return true
1289
1408
  }
1290
- const readiness = await trustedExecutionReadiness(job.tool.id, { preferences: job.preferences, plan: job.plan })
1409
+ const readiness = await readinessFor(job.tool.id, { preferences: job.preferences, plan: job.plan })
1410
+ job.executionReadiness = readiness
1291
1411
  if (job.cancelRequested) { markCancelled(job); return true }
1292
- if (!readiness.ready) {
1412
+ if (!readiness.ready && readiness.supported !== false) {
1293
1413
  if (IS_WINDOWS && job.tool.id === 'minimax-code' && !job.configured) {
1294
1414
  await runOfficialMiniMaxInstaller(job)
1295
1415
  return true
@@ -1300,8 +1420,11 @@ async function finishVerifiedInstall(job, probe) {
1300
1420
  return true
1301
1421
  }
1302
1422
  job.status = 'succeeded'
1423
+ job.error = null
1303
1424
  job.finishedAt = new Date().toISOString()
1304
- pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
1425
+ pushLine(job, readiness.supported === false
1426
+ ? `— 已安装 ${job.tool.label} ${probe.version ?? ''},版本探测已通过;受限执行未启用:${readiness.reason} —`
1427
+ : `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
1305
1428
  return true
1306
1429
  }
1307
1430
 
@@ -1378,11 +1501,13 @@ async function runInstallJob(installPlan, job) {
1378
1501
  // exactly the case the user pressed 修复 for.
1379
1502
  if (job.repair) {
1380
1503
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,按修复流程重新安装 —`)
1381
- } else if (readiness.ready || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
1504
+ } else if (readiness.ready || readiness.supported === false || !CAPABILITY_REPAIRABLE.has(job.tool.id)) {
1382
1505
  job.status = 'succeeded'
1383
1506
  job.finishedAt = new Date().toISOString()
1384
1507
  job.postInstallProbe = before
1508
+ job.executionReadiness = readiness
1385
1509
  pushLine(job, `— ${job.tool.label} ${before.version} 已是最新版本,无需重复安装 —`)
1510
+ if (readiness.supported === false) pushLine(job, `— 受限执行未启用:${readiness.reason} —`)
1386
1511
  return
1387
1512
  } else {
1388
1513
  pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
@@ -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
  })
@@ -249,12 +249,17 @@ export function resolveInstallPlan(tool, methodId, preferences = {}, { platform
249
249
  const binDir = target
250
250
  ? (method.installDirSuffix ? joinPath(target, method.installDirSuffix) : target)
251
251
  : vendorScriptDir(tool, { home, env, method })
252
- const notices = target ? [] : installDir
252
+ const notices = target && installDir && method.installDirSubdir
253
+ ? [`官方脚本在工具专属子目录 ${target} 维护资源,保护统一安装根中的其他工具。`]
254
+ : target ? [] : installDir
253
255
  ? [`${method.label} 无法使用自定义安装目录(官方脚本写死了安装路径),将安装到 ${vendorScriptDir(tool, { home, env, method })}。`]
254
256
  : []
255
257
  if (method.registryEnv === 'MCODE_NPM_REGISTRY' && preferences.registry && !minimaxScriptRegistry(preferences.registry)) {
256
258
  notices.push('MiniMax 官方脚本只接受 https://registry.npmjs.org/ 或 https://registry.npmmirror.com/,本次不改写它的 npm 源。')
257
259
  }
260
+ if (tool.id === 'codex' && method.id === 'script-powershell') {
261
+ notices.push('Codex Windows 脚本将命令目录联接到用户 Codex 资源目录;统一根控制命令位置,运行资源仍由厂商保存在用户目录。需要所有文件位于统一根时请选 npm/pnpm。')
262
+ }
258
263
  return {
259
264
  toolId: tool.id,
260
265
  method,
@@ -332,6 +337,20 @@ export function resolveInstallLocation(tool, method, preferences = {}, { home =
332
337
  return { kind: 'package-manager', dir: packageManagerBinDir(preferences?.installDir || '', { platform }), installDir: preferences?.installDir || '', binaries: [] }
333
338
  }
334
339
 
340
+ /**
341
+ * Read-only compatibility location for scripts installed directly into a
342
+ * configured shared root before dedicated script directories were introduced.
343
+ * Never use this result as an install/update target: those installers can
344
+ * replace a whole runtime tree belonging to another tool.
345
+ */
346
+ export function resolveLegacyScriptLocation(tool, method, preferences = {}, options = {}) {
347
+ if (!preferences.installDir || !method?.legacySharedInstallDir || !methodHonoursInstallDir(method)) return null
348
+ return {
349
+ ...resolveInstallLocation(tool, { ...method, installDirSubdir: undefined }, preferences, options),
350
+ legacySharedInstallDir: true,
351
+ }
352
+ }
353
+
335
354
  function scriptEnv(method, { installDir, scriptUrl, registry }) {
336
355
  const env = {}
337
356
  if (installDir && method.installDirEnv) env[method.installDirEnv] = installDir
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.2 — 2026-10-12
4
+
5
+ Stable patch under the npm latest tag. Upgrade and fully restart Harness before using Step install, repair or update.
6
+
7
+ - Treat the configured install directory as a shared base. Configurable vendor scripts use dedicated tool subdirectories; npm/pnpm retain their existing shared prefix. Step's official installer replaces its entire runtime directories, so passing the shared root could delete every other npm package there.
8
+ - Check script targets before download or execution: refuse shared roots, other tool/project manifests and commands, package stores, and junctions into another tool's tree. Codex's junction within its own release tree and intentional shared default launcher directories remain supported. Legacy script roots are discovered read-only and cannot satisfy or redirect new install jobs.
9
+ - Separate successful version/install verification from unsupported restricted execution capabilities. Gemini, OpenCode and Step can be installed successfully while retaining explicit execution limitations; supported tools still require their original trusted-entry checks.
10
+ - Resolve Windows Gemini through its selected npm/pnpm package bin and a verified Node executable. Check package-tree containment and official registry file digests before launch, and preserve API fallback when the selected copy is missing or fails verification.
11
+ - Add regressions for destructive vendor runtime replacement, shared-prefix sentinels, junctions, legacy discovery, install capability status and Windows Gemini entry selection. Release notes record validation and the original-version recovery performed on the reported machine.
12
+
13
+ ## 0.18.1 — 2026-10-12
14
+
15
+ Stable patch under the npm latest tag. Fully quit Harness, including the tray or WebUI host process, and restart after upgrading.
16
+
17
+ - Fix official-tool method selection stuck on saving: configuration persistence no longer waits for CLI inspection, and selectors reflect the acknowledged settings snapshot.
18
+ - Refresh install commands through a lightweight Host endpoint; delayed tool inspections cannot overwrite a newer install preview. Install actions wait for confirmed settings while method selection remains available.
19
+ - Synchronize volatile install settings into Host preferences without requiring plugin reload. Invalid configuration is reported and cannot start an operation with older settings.
20
+ - Add regression coverage using the real settings controller and Loader, plus a mock UI scenario with a hanging tool inspection. Release validation is recorded in the release notes.
21
+
3
22
  ## 0.18.0 — 2026-10-12
4
23
 
5
24
  Stable minor under the npm latest tag. Fully quit Harness, including the tray or WebUI host process, and restart after upgrading. Existing releases remain unchanged.