Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
17 changes: 13 additions & 4 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,11 @@
* 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 whose message carries an install hint and the checked paths,
* 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 +26,13 @@ export type OsKind = string;
export type ShellName = 'bash' | 'sh';
export type PathClass = 'posix' | 'win32';

export class ProbeShellNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'ProbeShellNotFoundError';
}
}

export interface HostEnvironmentInfo {
readonly osKind: OsKind;
readonly osArch: string;
Expand Down Expand Up @@ -181,7 +190,7 @@ async function locateWindowsGitBash(deps: HostEnvironmentProbeDeps): Promise<str
}
}

throw new Error(
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: ${checked.join(', ')}.`,
);
}
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,16 @@ 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.
);
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
13 changes: 13 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,16 @@ 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 () => {
await expect(
probeHostEnvironment(
stubDeps({
platform: 'win32',
env: { PATH: 'C:\\Windows\\System32' },
existingPaths: [],
}),
),
).rejects.toBeInstanceOf(ProbeShellNotFoundError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* 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 { 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)')),
};
});

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('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);
});
});
4 changes: 4 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,10 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {

async ensureConfigFile(): Promise<void> {
await ensureConfigFile(this.configPath);
// Wait for the App-scope host-environment probe so that a missing Git Bash
// on Windows is reported early, before the TUI starts, instead of surfacing
// as an unhandledRejection later in startup.
await this.app.accessor.get(IHostEnvironment).ready;
Comment thread
liruifengv marked this conversation as resolved.
Outdated
}

async close(): Promise<void> {
Expand Down
38 changes: 37 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,21 @@ 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)'))
: actual.probeHostEnvironmentFromNode(),
};
});

const tempDirs: string[] = [];

afterEach(async () => {
Expand Down Expand Up @@ -147,6 +164,25 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => {
}
});

it('surfaces a missing Git Bash probe failure during ensureConfigFile', async () => {
hostEnvProbe.failWithMissingShell = true;
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;
}
});

it('serves getExperimentalFeatures from the v2 engine', async () => {
const { harness } = await makeHarness();
try {
Expand Down
Loading