diff --git a/.changeset/fix-windows-missing-git-bash.md b/.changeset/fix-windows-missing-git-bash.md new file mode 100644 index 0000000000..256ec5aa78 --- /dev/null +++ b/.changeset/fix-windows-missing-git-bash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show a clear error message on Windows when Git for Windows is not installed, instead of exiting silently. diff --git a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts index 3e4d03d978..a9c4e5ffc8 100644 --- a/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts +++ b/packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts @@ -7,9 +7,12 @@ * same suite runs identically on any host OS. `probeHostEnvironmentFromNode()` * bundles the Node defaults for production callers and memoises the promise. * - * On Windows the probe expects bash from Git for Windows or MSYS2. If it - * cannot be located the function throws a plain `Error` with the checked paths - * in the message. Set `KIMI_SHELL_PATH` to override. + * On Windows the probe expects bash from Git for Windows or MSYS2. If no + * shell can be located the function throws `ProbeShellNotFoundError`, a + * distinct type carrying the checked paths (`checked`) with an install hint + * in its message, so the DI boundary can tell a missing shell apart from + * other probe errors and translate it into a coded error. Set + * `KIMI_SHELL_PATH` to override. * * Kept as a pure helper with no DI dependencies. */ @@ -24,6 +27,16 @@ export type OsKind = string; export type ShellName = 'bash' | 'sh'; export type PathClass = 'posix' | 'win32'; +export class ProbeShellNotFoundError extends Error { + readonly checked: readonly string[]; + + constructor(message: string, checked: readonly string[]) { + super(message); + this.name = 'ProbeShellNotFoundError'; + this.checked = checked; + } +} + export interface HostEnvironmentInfo { readonly osKind: OsKind; readonly osArch: string; @@ -181,8 +194,9 @@ async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise; constructor() { @@ -35,10 +45,20 @@ export class HostEnvironmentService implements IHostEnvironment { this._info = info; }), applyLoginShellPathFromNode(), - ]).then(() => {}); + ]) + .then(() => {}) + .catch((error: unknown) => { + const translated = this.toHostProcessError(error); + this._probeError = translated; + throw translated; + }); + this.ready.catch(() => {}); } private require(field: keyof HostEnvironmentInfo): never | HostEnvironmentInfo[typeof field] { + if (this._probeError !== undefined) { + throw this._probeError; + } if (this._info === undefined) { throw new BugIndicatingError( `IHostEnvironment.${field} accessed before ready — await IHostEnvironment.ready first (composition root should do so before creating a Session scope).`, @@ -47,6 +67,17 @@ export class HostEnvironmentService implements IHostEnvironment { return this._info[field]; } + private toHostProcessError(error: unknown): Error { + if (error instanceof ProbeShellNotFoundError) { + return new HostProcessError( + OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, + error.message, + { details: { checkedPaths: error.checked }, cause: error }, + ); + } + return error instanceof Error ? error : new Error(String(error)); + } + get osKind(): OsKind { return this.require('osKind') as OsKind; } diff --git a/packages/agent-core-v2/src/os/interface/hostProcess.ts b/packages/agent-core-v2/src/os/interface/hostProcess.ts index 20242ab82f..d81aaee464 100644 --- a/packages/agent-core-v2/src/os/interface/hostProcess.ts +++ b/packages/agent-core-v2/src/os/interface/hostProcess.ts @@ -82,6 +82,7 @@ registerErrorDomain(OsProcessErrors); export const HostProcessErrorCode = { SpawnFailed: OsProcessErrors.codes.OS_PROCESS_SPAWN_FAILED, KillFailed: OsProcessErrors.codes.OS_PROCESS_KILL_FAILED, + ShellGitBashNotFound: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, } as const; export type HostProcessErrorCode = (typeof HostProcessErrorCode)[keyof typeof HostProcessErrorCode]; diff --git a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts index 9e084a0ebc..7cc4b7f225 100644 --- a/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts +++ b/packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it } from 'vitest'; import { probeHostEnvironment, + ProbeShellNotFoundError, type HostEnvironmentProbeDeps, } from '#/_base/execEnv/environmentProbe'; @@ -96,4 +97,20 @@ describe('probeHostEnvironment', () => { expect(env.shellName).toBe('bash'); expect(env.shellPath).toBe('C:\\msys64\\usr\\bin\\bash.exe'); }); + + it('throws ProbeShellNotFoundError when Git Bash is missing on Windows', async () => { + const rejected: unknown = await probeHostEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Windows\\System32' }, + existingPaths: [], + }), + ).catch((error: unknown) => error); + + expect(rejected).toBeInstanceOf(ProbeShellNotFoundError); + const probeError = rejected as ProbeShellNotFoundError; + expect(probeError.message).toContain('https://gitforwindows.org/'); + expect(probeError.message).not.toContain('Checked:'); + expect(probeError.checked.length).toBeGreaterThan(0); + }); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostEnvironmentService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostEnvironmentService.test.ts new file mode 100644 index 0000000000..4215819450 --- /dev/null +++ b/packages/agent-core-v2/test/os/backends/node-local/hostEnvironmentService.test.ts @@ -0,0 +1,72 @@ +/** + * HostEnvironmentService — shell-probe error handling. + * + * Stubs the host-environment probe to fail the way a Windows host without Git + * Bash does, so the suite runs identically on any platform. Pins the failure + * contract: `ready` rejects with the translated `HostProcessError` + * (`shell.git_bash_not_found`), sync field reads after a failed probe throw + * the same coded error, and the rejection never surfaces as an + * unhandledRejection while the App scope is being constructed (vitest fails + * the file on any unhandled rejection). + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { ProbeShellNotFoundError } from '#/_base/execEnv/environmentProbe'; +import { HostEnvironmentService } from '#/os/backends/node-local/hostEnvironmentService'; +import { HostProcessError, OsProcessErrors } from '#/os/interface/hostProcess'; + +vi.mock('#/_base/execEnv/environmentProbe', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeHostEnvironmentFromNode: () => + Promise.reject( + new actual.ProbeShellNotFoundError('Git Bash missing (stubbed)', [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + ]), + ), + }; +}); + +vi.mock('#/_base/execEnv/loginShellPath', () => ({ + applyLoginShellPathFromNode: () => Promise.resolve(), +})); + +describe('HostEnvironmentService', () => { + it('rejects ready with the translated HostProcessError when the probe fails', async () => { + const service = new HostEnvironmentService(); + + await expect(service.ready).rejects.toBeInstanceOf(HostProcessError); + await expect(service.ready).rejects.toMatchObject({ + code: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, + }); + }); + + it('preserves the probe error as cause and checked paths as details', async () => { + const service = new HostEnvironmentService(); + + const rejected: unknown = await service.ready.catch((error: unknown) => error); + + expect(rejected).toBeInstanceOf(HostProcessError); + const hostError = rejected as HostProcessError; + expect(hostError.details).toEqual({ checkedPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'] }); + expect(hostError.cause).toBeInstanceOf(ProbeShellNotFoundError); + }); + + it('does not surface the ready rejection as an unhandledRejection', async () => { + const service = new HostEnvironmentService(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + await expect(service.ready).rejects.toBeInstanceOf(HostProcessError); + }); + + it('throws HostProcessError when reading fields after a failed probe', async () => { + const service = new HostEnvironmentService(); + await service.ready.catch(() => {}); + + expect(() => service.shellPath).toThrow(HostProcessError); + expect(() => service.osKind).toThrow(HostProcessError); + }); +}); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index e95bcee56f..2969b42b93 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -482,6 +482,13 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { async ensureConfigFile(): Promise { await ensureConfigFile(this.configPath); + // Surface a missing Git Bash early, before the TUI starts. The wait is + // Windows-only: the failure cannot happen on POSIX, and `ready` also + // covers the login-shell PATH enrichment, which spawns the user's login + // shell (5s timeout) — config-only commands must not block on that. + if (process.platform === 'win32') { + await this.app.accessor.get(IHostEnvironment).ready; + } } async close(): Promise { diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 5c6bad93a4..f99266eb00 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createKimiHarnessV2, @@ -26,7 +26,9 @@ import { foldAgentWireReplay } from '#/v2/resume-replay'; import { drainQueryStoreDisposals, drainSessionIndexMirror, + HostProcessError, IHostRequestHeaders, + OsProcessErrors, } from '@moonshot-ai/agent-core-v2'; import { McpOAuthService } from '../../agent-core/src/mcp/oauth/service'; @@ -35,6 +37,25 @@ import { TEST_IDENTITY } from './test-identity'; import { startMcpAuthStatusServer } from './mcp-auth-status-server'; import { recordingTelemetry, type TelemetryRecord } from './telemetry'; +const hostEnvProbe = vi.hoisted(() => ({ failWithMissingShell: false })); + +vi.mock('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe', async (importOriginal) => { + const actual = await importOriginal< + typeof import('@moonshot-ai/agent-core-v2/_base/execEnv/environmentProbe') + >(); + return { + ...actual, + probeHostEnvironmentFromNode: () => + hostEnvProbe.failWithMissingShell + ? Promise.reject( + new actual.ProbeShellNotFoundError('Git Bash missing (stubbed)', [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + ]), + ) + : actual.probeHostEnvironmentFromNode(), + }; +}); + const tempDirs: string[] = []; afterEach(async () => { @@ -47,6 +68,16 @@ afterEach(async () => { } }); +function stubProcessPlatform(platform: NodeJS.Platform): () => void { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + return () => { + if (descriptor !== undefined) { + Object.defineProperty(process, 'platform', descriptor); + } + }; +} + async function makeHarness(): Promise<{ harness: KimiHarness; homeDir: string }> { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir); @@ -147,6 +178,45 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { } }); + it('surfaces a missing Git Bash probe failure during ensureConfigFile on Windows', async () => { + hostEnvProbe.failWithMissingShell = true; + const restorePlatform = stubProcessPlatform('win32'); + try { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + try { + await expect(harness.ensureConfigFile()).rejects.toBeInstanceOf(HostProcessError); + await expect(harness.ensureConfigFile()).rejects.toMatchObject({ + code: OsProcessErrors.codes.SHELL_GIT_BASH_NOT_FOUND, + }); + } finally { + await harness.close(); + } + } finally { + hostEnvProbe.failWithMissingShell = false; + restorePlatform(); + } + }); + + it('does not block ensureConfigFile on the host environment probe on POSIX', async () => { + hostEnvProbe.failWithMissingShell = true; + const restorePlatform = stubProcessPlatform('darwin'); + try { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); + tempDirs.push(homeDir); + const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY }); + try { + await expect(harness.ensureConfigFile()).resolves.toBeUndefined(); + } finally { + await harness.close(); + } + } finally { + hostEnvProbe.failWithMissingShell = false; + restorePlatform(); + } + }); + it('serves getExperimentalFeatures from the v2 engine', async () => { const { harness } = await makeHarness(); try {