From 4241ee1a14745b8667670c84ffe3005b3f178b69 Mon Sep 17 00:00:00 2001 From: Fengzdadi <453788063@qq.com> Date: Sun, 31 May 2026 15:29:03 -0400 Subject: [PATCH] fix: allow Windows startup without Git Bash --- .changeset/windows-git-bash-startup.md | 7 +++ docs/en/reference/tools.md | 2 +- docs/zh/reference/tools.md | 2 +- packages/agent-core/src/agent/tool/index.ts | 7 +-- .../agent-core/src/profile/default/system.md | 9 +++- packages/agent-core/src/profile/resolve.ts | 2 + packages/agent-core/test/agent/config.test.ts | 24 ++++++++++ .../agent-core/test/agent/harness/agent.ts | 11 +++-- .../test/profile/agent-profile-loader.test.ts | 18 +++++++ packages/kaos/src/environment.ts | 48 ++++++++++++------- packages/kaos/src/errors.ts | 5 +- packages/kaos/test/environment.test.ts | 34 ++++++------- 12 files changed, 120 insertions(+), 49 deletions(-) create mode 100644 .changeset/windows-git-bash-startup.md diff --git a/.changeset/windows-git-bash-startup.md b/.changeset/windows-git-bash-startup.md new file mode 100644 index 00000000..f19d613d --- /dev/null +++ b/.changeset/windows-git-bash-startup.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kaos": patch +"@moonshot-ai/kimi-code": patch +--- + +Allow Kimi Code CLI to start on Windows when Git Bash is not installed, with shell execution disabled until a bash.exe is available. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index fc5cc759..58106b85 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -37,7 +37,7 @@ File tools handle reading, writing, and searching the local filesystem, and are **`Bash`** is the most versatile and the most permission-sensitive tool. It accepts `command` (required) along with the optional `cwd` (working directory), `timeout` (milliseconds), `description` (background task description, required when `run_in_background=true`), `run_in_background` (whether to run as a background task), and `disable_timeout` (whether to disable the timeout for a background task). The foreground `timeout` defaults to 60 seconds and is capped at 5 minutes; the background `timeout` defaults to 10 minutes and is also capped at 10 minutes. -In foreground mode `Bash` blocks the current turn until the command finishes or times out; in background mode it returns a task ID immediately. Background tasks time out after 10 minutes by default; if a task really needs to run without a timeout, set `disable_timeout=true`. When the task completes, fails, or is stopped, the agent is automatically notified to continue processing; during execution, the result can also be inspected explicitly via `TaskOutput`. stdin is always closed, so interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures processes terminate reliably after a timeout. On Windows, Git Bash is used as the shell by default. +In foreground mode `Bash` blocks the current turn until the command finishes or times out; in background mode it returns a task ID immediately. Background tasks time out after 10 minutes by default; if a task really needs to run without a timeout, set `disable_timeout=true`. When the task completes, fails, or is stopped, the agent is automatically notified to continue processing; during execution, the result can also be inspected explicitly via `TaskOutput`. stdin is always closed, so interactive commands receive EOF immediately. A two-phase termination strategy (SIGTERM → 5-second grace period → SIGKILL) ensures processes terminate reliably after a timeout. On Windows, Git Bash is used as the shell by default; if Git Bash is not installed and `KIMI_SHELL_PATH` does not point to a `bash.exe`, Kimi Code CLI still starts, but the `Bash` tool is omitted until a shell is available. ## Network tools diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 4d14b657..ecb2eefe 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -37,7 +37,7 @@ **`Bash`** 是最通用也是权限要求最严格的工具。它接受 `command`(必填)以及可选的 `cwd`(工作目录)、`timeout`(毫秒)、`description`(后台任务描述,`run_in_background=true` 时必填)、`run_in_background`(是否以后台任务运行)和 `disable_timeout`(后台任务是否取消超时)。前台 `timeout` 默认 60 秒、最长 5 分钟;后台 `timeout` 默认 10 分钟、最长 10 分钟。 -前台模式下 `Bash` 会阻塞当前轮次,直到命令结束或超时;后台模式会立即返回任务 ID。后台任务默认 10 分钟后超时;如果确实需要让任务不受超时限制,可以设置 `disable_timeout=true`。任务结束、失败或被停止时会自动通知 Agent 继续处理,过程中也可通过 `TaskOutput` 主动查看结果。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程能可靠结束。Windows 平台下默认使用 Git Bash 作为 shell。 +前台模式下 `Bash` 会阻塞当前轮次,直到命令结束或超时;后台模式会立即返回任务 ID。后台任务默认 10 分钟后超时;如果确实需要让任务不受超时限制,可以设置 `disable_timeout=true`。任务结束、失败或被停止时会自动通知 Agent 继续处理,过程中也可通过 `TaskOutput` 主动查看结果。stdin 始终被关闭,交互式命令会立即收到 EOF。两阶段终止策略(SIGTERM → 5 秒宽限期 → SIGKILL)确保超时后进程能可靠结束。Windows 平台下默认使用 Git Bash 作为 shell;如果未安装 Git Bash,且 `KIMI_SHELL_PATH` 没有指向 `bash.exe`,Kimi Code CLI 仍会启动,但 `Bash` 工具会被省略,直到 shell 可用。 ## 网络类 diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 550cfeba..305ad2a4 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -366,9 +366,10 @@ export class ToolManager { new b.EditTool(kaos, workspace), new b.GrepTool(kaos, workspace), new b.GlobTool(kaos, workspace), - new b.BashTool(kaos, cwd, background, { - allowBackground, - }), + kaos.osEnv.shellAvailable !== false && + new b.BashTool(kaos, cwd, background, { + allowBackground, + }), (modelCapabilities.image_in || modelCapabilities.video_in) && new b.ReadMediaFileTool(kaos, workspace, modelCapabilities, videoUploader), new b.EnterPlanModeTool(this.agent), diff --git a/packages/agent-core/src/profile/default/system.md b/packages/agent-core/src/profile/default/system.md index 0436f944..dde56324 100644 --- a/packages/agent-core/src/profile/default/system.md +++ b/packages/agent-core/src/profile/default/system.md @@ -69,11 +69,18 @@ The user may ask you to research on certain topics, process or generate certain ## Operating System -You are running on **{{ KIMI_OS }}**. The Bash tool executes commands using **{{ KIMI_SHELL }}**. +You are running on **{{ KIMI_OS }}**. +{% if KIMI_SHELL_AVAILABLE == "true" %} + +The Bash tool executes commands using **{{ KIMI_SHELL }}**. {% if KIMI_OS == "Windows" %} IMPORTANT: You are on Windows. The Bash tool runs through Git Bash, so use Unix shell syntax inside Bash commands — `/dev/null` not `NUL`, and forward slashes in paths. For file operations, always prefer the built-in tools (Read, Write, Edit, Glob, Grep) over Bash commands — they work reliably across all platforms. {% endif %} +{% else %} + +The Bash tool is unavailable: {{ KIMI_SHELL_UNAVAILABLE_REASON }} For file operations, use the built-in tools (Read, Write, Edit, Glob, Grep), which work reliably across all platforms. +{% endif %} The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory. diff --git a/packages/agent-core/src/profile/resolve.ts b/packages/agent-core/src/profile/resolve.ts index 001f7d19..b3a7511a 100644 --- a/packages/agent-core/src/profile/resolve.ts +++ b/packages/agent-core/src/profile/resolve.ts @@ -154,6 +154,8 @@ function buildTemplateVars( ...promptVars, KIMI_OS: context.osEnv.osKind, KIMI_SHELL: `${context.osEnv.shellName} (\`${context.osEnv.shellPath}\`)`, + KIMI_SHELL_AVAILABLE: context.osEnv.shellAvailable === false ? 'false' : 'true', + KIMI_SHELL_UNAVAILABLE_REASON: context.osEnv.shellUnavailableReason ?? '', KIMI_NOW: now, KIMI_WORK_DIR: context.cwd, KIMI_WORK_DIR_LS: context.cwdListing ?? '', diff --git a/packages/agent-core/test/agent/config.test.ts b/packages/agent-core/test/agent/config.test.ts index d72937f7..b3e0770a 100644 --- a/packages/agent-core/test/agent/config.test.ts +++ b/packages/agent-core/test/agent/config.test.ts @@ -2,6 +2,7 @@ import type { ModelCapability, ProviderConfig, ToolCall } from '@moonshot-ai/kos import { describe, expect, it } from 'vitest'; import type { ResolvedAgentProfile } from '../../src/profile'; +import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createCommandKaos, testAgent } from './harness/agent'; import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots'; @@ -94,6 +95,29 @@ describe('Agent config', () => { await ctx.expectResumeMatches(); }); + it('omits Bash when the runtime shell is unavailable', async () => { + const ctx = testAgent({ + kaos: createFakeKaos({ + osEnv: { + osKind: 'Windows', + osArch: 'x64', + osVersion: '10.0.22631.0', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', + shellAvailable: false, + shellUnavailableReason: 'Git Bash was not found.', + }, + }), + }); + ctx.configure(); + + const tools = await ctx.rpc.getTools({}); + + expect(toolNames(tools)).not.toContain('Bash'); + expect(toolNames(tools)).toEqual(expect.arrayContaining(['Read', 'Write', 'Edit'])); + await ctx.expectResumeMatches(); + }); + it('keeps turn-start config for later steps and applies updates to the next turn', async () => { const bashCall: ToolCall = { type: 'function', diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index 1944de83..000551bd 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -726,7 +726,7 @@ export class AgentTestContext { async expectResumeMatches(): Promise { const resumed = testAgent({ - kaos: createResumeNoSideEffectKaos(this.agent.config.cwd), + kaos: createResumeNoSideEffectKaos(this.agent.config.cwd, this.agent.kaos.osEnv), runtime: { urlFetcher: this.agent.toolServices?.urlFetcher, webSearcher: this.agent.toolServices?.webSearcher, @@ -948,7 +948,10 @@ const failOnResumeGenerate: GenerateFn = async () => { throw new Error('Resume replay unexpectedly called the LLM'); }; -function createResumeNoSideEffectKaos(initialCwd: string): Kaos { +function createResumeNoSideEffectKaos( + initialCwd: string, + osEnv: Environment = TEST_OS_ENV, +): Kaos { const fail = (method: string): never => { throw new Error(`Resume replay unexpectedly called kaos.${method}`); }; @@ -959,12 +962,12 @@ function createResumeNoSideEffectKaos(initialCwd: string): Kaos { let cwd = initialCwd; return { name: 'resume-no-side-effects', - osEnv: TEST_OS_ENV, + osEnv, pathClass: () => 'posix', normpath: (p: string) => p, gethome: () => '/home/test', getcwd: () => cwd, - withCwd: (next: string) => createResumeNoSideEffectKaos(next), + withCwd: (next: string) => createResumeNoSideEffectKaos(next, osEnv), chdir: async (next: string) => { cwd = next; }, diff --git a/packages/agent-core/test/profile/agent-profile-loader.test.ts b/packages/agent-core/test/profile/agent-profile-loader.test.ts index fb4e7283..99ada76e 100644 --- a/packages/agent-core/test/profile/agent-profile-loader.test.ts +++ b/packages/agent-core/test/profile/agent-profile-loader.test.ts @@ -220,6 +220,24 @@ describe('default agent profiles', () => { expect(second).toContain('/workspace/two'); expect(second).not.toContain('/workspace/one'); }); + + it('renders unavailable shell guidance without Windows Git Bash command advice', () => { + const prompt = DEFAULT_AGENT_PROFILES['agent']?.systemPrompt({ + ...promptContext, + osEnv: { + osKind: 'Windows', + osArch: 'x64', + osVersion: '10.0.22631.0', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', + shellAvailable: false, + shellUnavailableReason: 'Git Bash was not found.', + }, + }); + + expect(prompt).toContain('The Bash tool is unavailable: Git Bash was not found.'); + expect(prompt).not.toContain('The Bash tool runs through Git Bash, so use Unix shell syntax'); + }); }); async function write(fileName: string, content: string): Promise { diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts index ff00a405..502dc23f 100644 --- a/packages/kaos/src/environment.ts +++ b/packages/kaos/src/environment.ts @@ -6,18 +6,15 @@ * identically on any host OS. `detectEnvironmentFromNode()` bundles the * Node defaults for production callers. * - * On Windows the probe expects Git Bash (the canonical POSIX shell that - * ships with Git for Windows). If it cannot be located the function - * throws `KaosShellNotFoundError`; the SDK layer can wrap that into a - * user-facing install hint. Set `KIMI_SHELL_PATH` to override. + * On Windows the probe prefers Git Bash (the canonical POSIX shell that + * ships with Git for Windows). If it cannot be located the function still + * returns the OS metadata with `shellAvailable=false` so the CLI can start + * and register the non-shell tools. Set `KIMI_SHELL_PATH` to override. */ import { constants as fsConstants } from 'node:fs'; import { access } from 'node:fs/promises'; import * as nodeOs from 'node:os'; - -import { KaosShellNotFoundError } from './errors'; - // `OsKind` carries 'macOS' / 'Linux' / 'Windows' for known platforms and // falls back to the raw `process.platform` string for unknown ones (e.g. // 'freebsd'). Typed as `string` so the union isn't inhabited-by-string. @@ -30,6 +27,8 @@ export interface Environment { readonly osVersion: string; readonly shellName: ShellName; readonly shellPath: string; + readonly shellAvailable?: boolean; + readonly shellUnavailableReason?: string; } export interface EnvironmentDeps { @@ -62,8 +61,16 @@ export async function detectEnvironment(deps: EnvironmentDeps): Promise { +interface WindowsShellProbe { + readonly path: string; + readonly available: boolean; + readonly unavailableReason?: string; +} + +async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { const checked: string[] = []; const override = deps.env['KIMI_SHELL_PATH']?.trim(); if (override !== undefined && override.length > 0) { checked.push(override); if (await deps.isFile(override)) { - return override; + return { path: override, available: true }; } } @@ -97,7 +110,7 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { if (inferred !== undefined) { checked.push(inferred); if (await deps.isFile(inferred)) { - return inferred; + return { path: inferred, available: true }; } } } @@ -113,13 +126,16 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { for (const candidate of candidates) { checked.push(candidate); if (await deps.isFile(candidate)) { - return candidate; + return { path: candidate, available: true }; } } - throw new KaosShellNotFoundError( - `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(', ')}.`, - ); + const fallbackPath = override !== undefined && override.length > 0 ? override : candidates[0]!; + return { + path: fallbackPath, + available: false, + unavailableReason: `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(', ')}.`, + }; } // Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, diff --git a/packages/kaos/src/errors.ts b/packages/kaos/src/errors.ts index 0283c9b0..672654dd 100644 --- a/packages/kaos/src/errors.ts +++ b/packages/kaos/src/errors.ts @@ -29,9 +29,8 @@ export class KaosFileExistsError extends KaosError { } /** - * Thrown by `detectEnvironment` on Windows when no Git Bash install can be - * located. Carries the list of paths that were probed so callers can include - * them in install hints. + * Legacy shell discovery error retained for compatibility with callers that + * still surface a hard failure when no shell is available. */ export class KaosShellNotFoundError extends KaosError { constructor(message: string) { diff --git a/packages/kaos/test/environment.test.ts b/packages/kaos/test/environment.test.ts index 1d0e01d2..265c6c57 100644 --- a/packages/kaos/test/environment.test.ts +++ b/packages/kaos/test/environment.test.ts @@ -7,8 +7,8 @@ * - POSIX path probing prefers /bin/bash, falls back to /usr/bin/bash, * /usr/local/bin/bash, then /bin/sh (with shellName 'sh'). * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH, - * or well-known install locations; throws `KaosShellNotFoundError` - * if none are present. + * or well-known install locations; if none are present, it returns an + * unavailable shell marker so the CLI can still start. * - `osArch` / `osVersion` are populated from the Node OS APIs. * * All tests expect `detectEnvironment()` to be a pure function of @@ -24,7 +24,6 @@ import { type OsKind, type ShellName, } from '#/environment'; -import { KaosShellNotFoundError } from '#/errors'; interface StubOpts { readonly platform: NodeJS.Platform; @@ -178,37 +177,32 @@ describe('detectEnvironment', () => { expect(env.shellPath).toBe('C:\\Users\\me\\AppData\\Local\\Programs\\Git\\bin\\bash.exe'); }); - it('throws KaosShellNotFoundError when no Git Bash candidate is found', async () => { - const error = await detectEnvironment( + it('returns an unavailable shell marker when no Windows Git Bash candidate is found', async () => { + const env = await detectEnvironment( stubDeps({ platform: 'win32', env: { LOCALAPPDATA: 'C:\\Users\\me\\AppData\\Local' }, existingPaths: [], }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error, ); - expect(error).toBeInstanceOf(KaosShellNotFoundError); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(env.shellAvailable).toBe(false); + expect(env.shellUnavailableReason).toContain('Git Bash was not found'); }); - it('includes attempted paths in the thrown error message', async () => { - const error = await detectEnvironment( + it('includes attempted paths in the unavailable shell reason', async () => { + const env = await detectEnvironment( stubDeps({ platform: 'win32', env: { KIMI_SHELL_PATH: 'D:\\custom\\bash.exe' }, existingPaths: [], }), - ).then( - () => { - throw new Error('expected throw'); - }, - (error: unknown) => error as KaosShellNotFoundError, ); - expect(error.message).toContain('D:\\custom\\bash.exe'); - expect(error.message).toContain('C:\\Program Files\\Git\\bin\\bash.exe'); + expect(env.shellPath).toBe('D:\\custom\\bash.exe'); + expect(env.shellAvailable).toBe(false); + expect(env.shellUnavailableReason).toContain('D:\\custom\\bash.exe'); + expect(env.shellUnavailableReason).toContain('C:\\Program Files\\Git\\bin\\bash.exe'); }); // ── arch / version passthrough ─────────────────────────────────────