Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-windows-missing-git-bash.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 19 additions & 5 deletions packages/agent-core-v2/src/_base/execEnv/environmentProbe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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;
Expand Down Expand Up @@ -181,8 +194,9 @@ async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise<str
}
}

throw new Error(
`Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe. Checked: ${checked.join(', ')}.`,
throw new ProbeShellNotFoundError(
'Git Bash was not found on this Windows host. Install Git for Windows from https://gitforwindows.org/ or set KIMI_SHELL_PATH to a bash.exe.',
checked,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
* login-shell PATH enrichment (`applyLoginShellPathFromNode`) at construction
* time; the sync fields become populated once `ready` resolves. Reads before
* `ready` throws with a clear message so misuse fails loudly instead of
* returning stale zeros. Bound at App scope.
* returning stale zeros. A failed probe is translated at this boundary — a
* missing Git Bash on Windows becomes `HostProcessError`
* (`shell.git_bash_not_found`) — and surfaces identically from `ready` and
* from sync field reads, while an internal no-op handler keeps the rejection
* from ever becoming an unhandledRejection during App-scope construction.
* Bound at App scope.
*/

import { LifecycleScope } from '#/app/scopes';

import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { BugIndicatingError } from '#/_base/errors/errors';
import { probeHostEnvironmentFromNode } from '#/_base/execEnv/environmentProbe';
import {
probeHostEnvironmentFromNode,
ProbeShellNotFoundError,
} from '#/_base/execEnv/environmentProbe';
import { applyLoginShellPathFromNode } from '#/_base/execEnv/loginShellPath';

import {
Expand All @@ -22,11 +30,13 @@ import {
type PathClass,
type ShellName,
} from '#/os/interface/hostEnvironment';
import { HostProcessError, OsProcessErrors } from '#/os/interface/hostProcess';

export class HostEnvironmentService implements IHostEnvironment {
declare readonly _serviceBrand: undefined;

private _info?: HostEnvironmentInfo;
private _probeError?: Error;
readonly ready: Promise<void>;

constructor() {
Expand All @@ -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).`,
Expand All @@ -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,
Comment thread
liruifengv marked this conversation as resolved.
{ details: { checkedPaths: error.checked }, cause: error },
);
Comment thread
liruifengv marked this conversation as resolved.
}
return error instanceof Error ? error : new Error(String(error));
}

get osKind(): OsKind {
return this.require('osKind') as OsKind;
}
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/os/interface/hostProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-core-v2/test/_base/execEnv/environmentProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { describe, expect, it } from 'vitest';

import {
probeHostEnvironment,
ProbeShellNotFoundError,
type HostEnvironmentProbeDeps,
} from '#/_base/execEnv/environmentProbe';

Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof import('#/_base/execEnv/environmentProbe')>();
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();
Comment thread
liruifengv marked this conversation as resolved.

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);
});
});
7 changes: 7 additions & 0 deletions packages/node-sdk/src/sdk-rpc-client-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,13 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {

async ensureConfigFile(): Promise<void> {
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;
Comment thread
liruifengv marked this conversation as resolved.
}
}

async close(): Promise<void> {
Expand Down
72 changes: 71 additions & 1 deletion packages/node-sdk/test/sdk-rpc-client-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading