From 0bc005400e9448491cdbd7af5e0a9765bff2b89c Mon Sep 17 00:00:00 2001 From: maxwellgeng Date: Fri, 7 Aug 2026 14:59:15 +0800 Subject: [PATCH] fix(agent-core-v2): detect PowerShell 7 via PATH and fix Windows installer probes --- .changeset/kimi-cu-windows-pwsh-fix.md | 5 + .../src/app/capability/entries/context.ts | 1 + .../src/app/capability/entries/kimiCu.ts | 106 +++++-- .../test/app/capability/kimiCu.test.ts | 289 ++++++++++++++++++ 4 files changed, 377 insertions(+), 24 deletions(-) create mode 100644 .changeset/kimi-cu-windows-pwsh-fix.md diff --git a/.changeset/kimi-cu-windows-pwsh-fix.md b/.changeset/kimi-cu-windows-pwsh-fix.md new file mode 100644 index 0000000000..9dba385eee --- /dev/null +++ b/.changeset/kimi-cu-windows-pwsh-fix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Kimi Computer Use for Windows installation failing when PowerShell 7 is installed as an MSIX package, or when a PowerShell 7 module path shadows Windows PowerShell 5.1 built-in commands. diff --git a/packages/agent-core-v2/src/app/capability/entries/context.ts b/packages/agent-core-v2/src/app/capability/entries/context.ts index 55d01fa363..024b6c61a5 100644 --- a/packages/agent-core-v2/src/app/capability/entries/context.ts +++ b/packages/agent-core-v2/src/app/capability/entries/context.ts @@ -19,5 +19,6 @@ export interface CapabilityEntryContext { readonly applicationsDir?: string; readonly webbridgeBaseUrl?: string; readonly detectProbeTimeoutMs?: number; + readonly installerProbeTimeoutMs?: number; readonly commandTimeoutMs?: number; } diff --git a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts index af1c418ea9..8e6aa5aca9 100644 --- a/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts +++ b/packages/agent-core-v2/src/app/capability/entries/kimiCu.ts @@ -21,7 +21,10 @@ * The Windows path downloads and runs the official `setup_windows.ps1`, so * its signature verification, rollback, and agent autostart stay upstream. * It selects a trusted PowerShell installation that satisfies the script's - * command requirements before changing plugin wiring. + * command requirements before changing plugin wiring, probing `pwsh` installs + * found via `where.exe` and the MSIX WindowsApps alias when the standard + * locations fail, and pins PSModulePath to `$PSHOME` first so a PowerShell 7 + * installation cannot shadow the cmdlets the installer needs. */ import { constants } from 'node:fs'; @@ -59,7 +62,9 @@ const WINDOWS_INSTALLER_PROBE_TIMEOUT_MS = 10_000; const WINDOWS_INSTALL_TIMEOUT_MS = 180_000; const DEFAULT_WINDOWS_SYSTEM_ROOT = 'C:\\Windows'; const DEFAULT_WINDOWS_PROGRAM_FILES = 'C:\\Program Files'; +const MAX_WINDOWS_POWERSHELL_CANDIDATES = 6; const WINDOWS_INSTALLER_PROBE_SCRIPT = + "$env:PSModulePath = (Join-Path $PSHOME 'Modules') + ';' + $env:PSModulePath; " + "$required = @('Get-FileHash', 'Expand-Archive', 'Get-AuthenticodeSignature', 'Get-CimInstance', 'Invoke-WebRequest', 'Invoke-RestMethod', 'ConvertFrom-Json', 'ConvertTo-Json'); " + '$missing = @($required | Where-Object { -not (Get-Command $_ -CommandType Cmdlet,Function -ErrorAction SilentlyContinue) }); ' + '$issues = @(); ' + @@ -163,6 +168,7 @@ function powerShellStringLiteral(value: string): string { function powerShellSetupCommand(setupPath: string): string { return ( + "$env:PSModulePath = (Join-Path $PSHOME 'Modules') + ';' + $env:PSModulePath; " + '$utf8 = New-Object System.Text.UTF8Encoding($false); ' + '[Console]::OutputEncoding = $utf8; $OutputEncoding = $utf8; ' + `& ${powerShellStringLiteral(setupPath)}` @@ -532,33 +538,76 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry const supported = ctx.platform === 'win32' && ctx.arch === 'x64'; const probeTimeoutMs = ctx.detectProbeTimeoutMs ?? DETECT_PROBE_TIMEOUT_MS; const installerProbeTimeoutMs = - ctx.detectProbeTimeoutMs ?? WINDOWS_INSTALLER_PROBE_TIMEOUT_MS; + ctx.installerProbeTimeoutMs ?? WINDOWS_INSTALLER_PROBE_TIMEOUT_MS; const installTimeoutMs = ctx.commandTimeoutMs ?? WINDOWS_INSTALL_TIMEOUT_MS; const powershellPath = windowsPowerShellPath(); const powershell7Path = windowsPowerShell7Path(); + async function discoverAdditionalPowerShellCandidates(): Promise< + Array<{ label: string; command: string }> + > { + const candidates: Array<{ label: string; command: string }> = []; + const seen = new Set([powershellPath.toLowerCase(), powershell7Path.toLowerCase()]); + const add = (label: string, command: string): void => { + const normalized = command.trim().toLowerCase(); + if (normalized.length === 0 || seen.has(normalized)) return; + seen.add(normalized); + candidates.push({ label, command: command.trim() }); + }; + const where = await runCommand(ctx.hostProcess, 'where.exe', ['pwsh'], { + timeout: installerProbeTimeoutMs, + }).catch(() => undefined); + if (where?.code === 0) { + for (const line of where.stdout.split(/\r?\n/)) { + add('PowerShell 7 (from PATH)', line); + } + } + const localAppData = process.env['LOCALAPPDATA']; + if (localAppData !== undefined && localAppData.length > 0) { + add( + 'PowerShell 7 (WindowsApps alias)', + path.win32.join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe'), + ); + } + return candidates.slice(0, MAX_WINDOWS_POWERSHELL_CANDIDATES - 2); + } + + async function probeInstallerCandidate( + candidate: { label: string; command: string }, + failures: string[], + ): Promise { + try { + const result = await runCommand( + ctx.hostProcess, + candidate.command, + ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_INSTALLER_PROBE_SCRIPT], + { timeout: installerProbeTimeoutMs }, + ); + if (result.code === 0) return candidate.command; + failures.push( + `${candidate.label} (${candidate.command}): ${ + result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}` + }`, + ); + } catch (error) { + failures.push(`${candidate.label} (${candidate.command}): ${errorMessage(error)}`); + } + return undefined; + } + async function installerPowerShell(): Promise { const failures: string[] = []; - for (const candidate of [ + const hardcoded: Array<{ label: string; command: string }> = [ { label: 'Windows PowerShell', command: powershellPath }, { label: 'PowerShell 7', command: powershell7Path }, - ]) { - try { - const result = await runCommand( - ctx.hostProcess, - candidate.command, - ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_INSTALLER_PROBE_SCRIPT], - { timeout: installerProbeTimeoutMs }, - ); - if (result.code === 0) return candidate.command; - failures.push( - `${candidate.label} (${candidate.command}): ${ - result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}` - }`, - ); - } catch (error) { - failures.push(`${candidate.label} (${candidate.command}): ${errorMessage(error)}`); - } + ]; + for (const candidate of hardcoded) { + const accepted = await probeInstallerCandidate(candidate, failures); + if (accepted !== undefined) return accepted; + } + for (const candidate of await discoverAdditionalPowerShellCandidates()) { + const accepted = await probeInstallerCandidate(candidate, failures); + if (accepted !== undefined) return accepted; } throw new Error( 'Kimi Computer Use requires Windows PowerShell 5.1 or PowerShell 7 with the commands required by its official installer. ' + @@ -566,7 +615,10 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry ); } - async function runtimeStep(command: string): Promise<{ + async function runtimeStep( + command: string, + timeoutMs = probeTimeoutMs, + ): Promise<{ readonly step: CapabilityStep; readonly version?: string; }> { @@ -576,7 +628,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry ctx.hostProcess, command, ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_DOCTOR_SCRIPT], - { timeout: probeTimeoutMs }, + { timeout: timeoutMs }, ); } catch (error) { return { step: { id: 'runtime', state: 'failed', detail: errorMessage(error) } }; @@ -616,7 +668,13 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry if (systemRuntime.step.state !== 'failed') return systemRuntime; const fallbackRuntime = await runtimeStep(powershell7Path); - return fallbackRuntime.step.state === 'ok' ? fallbackRuntime : systemRuntime; + if (fallbackRuntime.step.state === 'ok') return fallbackRuntime; + + for (const candidate of await discoverAdditionalPowerShellCandidates()) { + const runtime = await runtimeStep(candidate.command); + if (runtime.step.state === 'ok') return runtime; + } + return systemRuntime; } async function detect(): Promise { @@ -703,7 +761,7 @@ function createWindowsKimiCuEntry(ctx: CapabilityEntryContext): CapabilityEntry await rm(workDir, { recursive: true, force: true }).catch(() => undefined); } - const runtime = await runtimeStep(installPowerShell); + const runtime = await runtimeStep(installPowerShell, installerProbeTimeoutMs); if (runtime.step.state !== 'ok') { throw new Error( `kimi-cu Windows runtime is not ready after install: ${runtime.step.detail ?? runtime.step.state}`, diff --git a/packages/agent-core-v2/test/app/capability/kimiCu.test.ts b/packages/agent-core-v2/test/app/capability/kimiCu.test.ts index c17e30194f..72ecff9136 100644 --- a/packages/agent-core-v2/test/app/capability/kimiCu.test.ts +++ b/packages/agent-core-v2/test/app/capability/kimiCu.test.ts @@ -705,6 +705,295 @@ describe('kimi-cu entry', () => { expect(host.calls).toHaveLength(1); }); + it('installs through a PowerShell 7 found only via where.exe on an MSIX-only machine', async () => { + const plugins = fakePlugins([]); + const calls: string[] = []; + // MSIX installs have no C:\Program Files\PowerShell\7\pwsh.exe; the + // working binary is the WindowsApps execution alias on PATH. + const alias = 'C:\\Users\\probe\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe'; + const previousLocalAppData = process.env['LOCALAPPDATA']; + process.env['LOCALAPPDATA'] = 'C:\\Users\\probe\\AppData\\Local'; + const doctorResults = [ + { code: 3, stdout: '', stderr: '' }, + { + code: 0, + stdout: 'version=0.2.14\r\nmcp=true\r\nhelper=embedded\r\nagent=running\r\n', + stderr: '', + }, + ]; + try { + const hostProcess = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + calls.push(`${command} ${args.join(' ')}`); + if (command === windowsPowerShell7Path()) { + return Promise.reject( + new Error(`Failed to spawn "${command}": spawn ${command} ENOENT`), + ); + } + if (command === 'where.exe') { + return Promise.resolve(fakeProc(0, `${alias}\r\n`)); + } + if (args.some((arg) => arg.includes('Get-FileHash'))) { + return Promise.resolve( + command === windowsPowerShellPath() + ? fakeProc(2, '', 'missing commands: Get-FileHash, Get-AuthenticodeSignature') + : fakeProc(0, 'PowerShell 7.5.2'), + ); + } + if (args.some((arg) => arg.includes('setup_windows.ps1'))) { + return Promise.resolve(fakeProc(0)); + } + if (args.includes('-Command')) { + const result = doctorResults.shift(); + return Promise.resolve( + fakeProc( + result?.code ?? 1, + result?.stdout ?? '', + result?.stderr ?? 'unexpected doctor', + ), + ); + } + return Promise.resolve(fakeProc(1)); + }, + } as IHostProcessService; + const entry = createKimiCuEntry( + makeCtx({ + platform: 'win32', + arch: 'x64', + plugins: plugins.service, + hostProcess, + fetchImpl: (() => + Promise.resolve( + new Response("Write-Host 'official setup'", { + headers: { 'content-length': '27' }, + }), + )) as typeof fetch, + }), + ); + + await entry.install(() => undefined); + + // The setup script ran through the WindowsApps alias. + expect( + calls.some((call) => call.startsWith(alias) && call.includes('setup_windows.ps1')), + ).toBe(true); + // The where.exe result and the explicit WindowsApps candidate dedupe + // into a single probe — the alias path is spawned exactly once for the + // installer probe, never twice as separate candidates. + expect( + calls.filter((call) => call.startsWith(alias) && call.includes('Get-FileHash')), + ).toHaveLength(1); + expect(plugins.installs).toEqual([ + 'https://cdn.kimi.com/kimi-computer-use-windows/latest/kimi-cu-win-plugin.zip', + ]); + } finally { + if (previousLocalAppData === undefined) { + delete process.env['LOCALAPPDATA']; + } else { + process.env['LOCALAPPDATA'] = previousLocalAppData; + } + } + }); + + it('pins PSModulePath to $PSHOME before the installer probe and the setup script', async () => { + const plugins = fakePlugins([]); + const calls: string[] = []; + const doctorResults = [ + { code: 3, stdout: '', stderr: '' }, + { + code: 0, + stdout: 'version=0.2.14\r\nmcp=true\r\nhelper=embedded\r\nagent=running\r\n', + stderr: '', + }, + ]; + const hostProcess = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + calls.push(`${command} ${args.join(' ')}`); + if (args.some((arg) => arg.includes('Get-FileHash'))) { + return Promise.resolve(fakeProc(0, 'PowerShell 5.1')); + } + if (args.some((arg) => arg.includes('setup_windows.ps1'))) { + return Promise.resolve(fakeProc(0)); + } + if (args.includes('-Command')) { + const result = doctorResults.shift(); + return Promise.resolve( + fakeProc( + result?.code ?? 1, + result?.stdout ?? '', + result?.stderr ?? 'unexpected doctor', + ), + ); + } + return Promise.resolve(fakeProc(1)); + }, + } as IHostProcessService; + const entry = createKimiCuEntry( + makeCtx({ + platform: 'win32', + arch: 'x64', + plugins: plugins.service, + hostProcess, + fetchImpl: (() => + Promise.resolve( + new Response("Write-Host 'official setup'", { + headers: { 'content-length': '27' }, + }), + )) as typeof fetch, + }), + ); + + await entry.install(() => undefined); + + const probeCall = calls.find((call) => call.includes('Get-FileHash'))!; + expect(probeCall).toContain("(Join-Path $PSHOME 'Modules')"); + expect(probeCall.indexOf("(Join-Path $PSHOME 'Modules')")).toBeLessThan( + probeCall.indexOf('$required'), + ); + const setupCall = calls.find((call) => call.includes('setup_windows.ps1'))!; + expect(setupCall).toContain("(Join-Path $PSHOME 'Modules')"); + expect(setupCall.indexOf("(Join-Path $PSHOME 'Modules')")).toBeLessThan( + setupCall.indexOf('setup_windows.ps1'), + ); + }); + + it('gives the post-install doctor a longer timeout than the detect probes', async () => { + const plugins = fakePlugins([]); + const doctorResults = [{ code: 3, stdout: '', stderr: '' }]; + const hostProcess = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + if (args.some((arg) => arg.includes('Get-FileHash'))) { + return Promise.resolve(fakeProc(0, 'PowerShell 5.1')); + } + if (args.some((arg) => arg.includes('setup_windows.ps1'))) { + return Promise.resolve(fakeProc(0)); + } + if (args.includes('-Command')) { + const result = doctorResults.shift(); + if (result !== undefined) { + return Promise.resolve(fakeProc(result.code, result.stdout, result.stderr)); + } + // A fresh install's first `doctor` run outlives the 3s detect probe + // budget — the post-install check must use the longer installer one. + return Promise.resolve({ + _serviceBrand: undefined, + pid: 1234, + exitCode: null, + stdin: new Writable({ + write: (_c, _e, cb) => { + cb(); + }, + }), + stdout: Readable.from(['']), + stderr: Readable.from(['']), + wait: () => new Promise(() => {}), + kill: () => Promise.resolve(), + dispose: () => undefined, + } as IHostProcess); + } + return Promise.resolve(fakeProc(1)); + }, + } as IHostProcessService; + const entry = createKimiCuEntry( + makeCtx({ + platform: 'win32', + arch: 'x64', + plugins: plugins.service, + hostProcess, + fetchImpl: (() => + Promise.resolve( + new Response("Write-Host 'official setup'", { + headers: { 'content-length': '27' }, + }), + )) as typeof fetch, + detectProbeTimeoutMs: 5, + installerProbeTimeoutMs: 50, + }), + ); + + const install = entry.install(() => undefined); + await expect(install).rejects.toThrow(/command timed out after 50ms/); + }); + + it('fails cleanly when where.exe cannot find pwsh', async () => { + const plugins = fakePlugins([]); + let downloads = 0; + const hostProcess = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + if (command === 'where.exe') { + return Promise.resolve(fakeProc(1)); + } + if (args.some((arg) => arg.includes('Get-FileHash'))) { + return Promise.resolve( + fakeProc(2, '', `${command}: missing commands: Get-FileHash, Expand-Archive`), + ); + } + return Promise.resolve(fakeProc(3)); + }, + } as IHostProcessService; + const entry = createKimiCuEntry( + makeCtx({ + platform: 'win32', + arch: 'x64', + plugins: plugins.service, + hostProcess, + fetchImpl: (() => { + downloads += 1; + return Promise.reject(new Error('download should not start')); + }) as typeof fetch, + }), + ); + + await expect(entry.install(() => undefined)).rejects.toThrow( + /requires Windows PowerShell 5\.1 or PowerShell 7.*Get-FileHash, Expand-Archive/, + ); + + expect(plugins.installs).toEqual([]); + expect(downloads).toBe(0); + }); + + it('fails cleanly when where.exe itself cannot be spawned', async () => { + const plugins = fakePlugins([]); + let downloads = 0; + const hostProcess = { + _serviceBrand: undefined, + spawn: (command: string, args: readonly string[] = []) => { + if (command === 'where.exe') { + return Promise.reject(new Error('Failed to spawn "where.exe": spawn where.exe ENOENT')); + } + if (args.some((arg) => arg.includes('Get-FileHash'))) { + return Promise.resolve( + fakeProc(2, '', `${command}: missing commands: Get-FileHash, Expand-Archive`), + ); + } + return Promise.resolve(fakeProc(3)); + }, + } as IHostProcessService; + const entry = createKimiCuEntry( + makeCtx({ + platform: 'win32', + arch: 'x64', + plugins: plugins.service, + hostProcess, + fetchImpl: (() => { + downloads += 1; + return Promise.reject(new Error('download should not start')); + }) as typeof fetch, + }), + ); + + await expect(entry.install(() => undefined)).rejects.toThrow( + /requires Windows PowerShell 5\.1 or PowerShell 7.*Get-FileHash, Expand-Archive/, + ); + + expect(plugins.installs).toEqual([]); + expect(downloads).toBe(0); + }); + it('detects all four layers with details', async () => { const applicationsDir = await fakeAppBundle(); const plugins = fakePlugins([{ id: 'kimi-cu', enabled: true, state: 'ok', version: '0.5.4' }]);