From 058888800dccf36f1e5d93a2eb81dd193ba525e6 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 8 Jun 2026 11:41:57 +0800 Subject: [PATCH 1/3] fix: detect Scoop Git Bash on Windows --- .changeset/detect-scoop-git-bash.md | 6 + docs/en/configuration/env-vars.md | 4 +- docs/zh/configuration/env-vars.md | 4 +- packages/agent-core/src/utils/environment.ts | 171 +++++++++++++++--- .../agent-core/test/utils/environment.test.ts | 86 ++++++++- 5 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 .changeset/detect-scoop-git-bash.md diff --git a/.changeset/detect-scoop-git-bash.md b/.changeset/detect-scoop-git-bash.md new file mode 100644 index 0000000000..5f98517457 --- /dev/null +++ b/.changeset/detect-scoop-git-bash.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Detect Git Bash installed through Scoop and other Git shims on Windows. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 5e004434a0..072288af51 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -107,7 +107,7 @@ Kimi Code CLI also reads a handful of standard system environment variables to d - `HOME`: the user's home directory, used to resolve the default data path. - `VISUAL`, `EDITOR`: the executable invoked as the external editor, with `VISUAL` taking precedence. -- `PATH`: used to locate external dependencies such as `rg` and `git`. +- `PATH`: used to locate external dependencies such as `rg` and `git`; on Windows, Git Bash detection checks each `git.exe` found on `PATH`, including package-manager shims such as Scoop. - `NO_COLOR`: when set and non-empty, forces color and theme detection off, falling back to the dark theme. Follows the [no-color.org](https://no-color.org) convention. - `FORCE_COLOR`: when set to `"0"`, also disables color and theme detection, falling back to the dark theme. - `CI`: when non-empty and not `"0"`, disables theme detection and falls back to the dark theme; the telemetry module also reads this variable to mark the CI environment. @@ -119,6 +119,6 @@ Kimi Code CLI also reads a handful of standard system environment variables to d - `DISPLAY`, `WAYLAND_DISPLAY`, `XDG_SESSION_TYPE`: detect a Linux graphical session, used by clipboard and image-related features. A `XDG_SESSION_TYPE` value of `wayland` is also treated as a Wayland session. - `WSL_DISTRO_NAME`, `WSLENV`: detect whether the CLI is running inside WSL, used for the PowerShell-bridged clipboard fallback. - `TERMUX_VERSION`: detects whether the CLI is running inside Termux. -- `LOCALAPPDATA`: used on Windows when probing for the Git Bash installation path. +- `LOCALAPPDATA`: used on Windows as a fallback when probing for the Git Bash installation path. These variables follow the usual conventions of each operating system; `kimi` only reads them and never modifies them. diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index e413cbefe9..e3cfc97d81 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -108,7 +108,7 @@ Kimi Code CLI 也会读取一些标准的系统环境变量,用于检测运行 - `HOME`:用户主目录,用于解析默认数据路径。 - `VISUAL`、`EDITOR`:调用外部编辑器时的可执行命令,`VISUAL` 优先。 -- `PATH`:定位 `rg`、`git` 等外部依赖。 +- `PATH`:定位 `rg`、`git` 等外部依赖;在 Windows 上,Git Bash 探测会检查 `PATH` 中找到的每个 `git.exe`,包括 Scoop 等包管理器提供的 shim。 - `NO_COLOR`:设置且非空时,强制关闭颜色与主题检测,界面回退到深色主题。遵循 [no-color.org](https://no-color.org) 约定。 - `FORCE_COLOR`:值为 `"0"` 时,同样关闭颜色与主题检测,界面回退到深色主题。 - `CI`:非空且非 `"0"` 时,关闭主题检测并回退到深色主题;遥测模块也会读取此变量以标记 CI 环境。 @@ -120,6 +120,6 @@ Kimi Code CLI 也会读取一些标准的系统环境变量,用于检测运行 - `DISPLAY`、`WAYLAND_DISPLAY`、`XDG_SESSION_TYPE`:检测 Linux 图形会话,用于剪贴板与图片相关功能。`XDG_SESSION_TYPE` 值为 `wayland` 时也判定为 Wayland 会话。 - `WSL_DISTRO_NAME`、`WSLENV`:检测是否运行在 WSL 内,用于剪贴板的 PowerShell 桥接回退。 - `TERMUX_VERSION`:检测是否运行在 Termux 中。 -- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时使用。 +- `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时作为 fallback 使用。 这些变量遵循各操作系统的常规约定,`kimi` 仅读取不修改。 diff --git a/packages/agent-core/src/utils/environment.ts b/packages/agent-core/src/utils/environment.ts index a69065a1bb..8bd110c0ae 100644 --- a/packages/agent-core/src/utils/environment.ts +++ b/packages/agent-core/src/utils/environment.ts @@ -2,9 +2,9 @@ * Environment — cross-platform probe of OS / shell. * * Detection is a pure function of injected probes (`platform` / `arch` / - * `release` / `env` / `isFile` / `findExecutable`) so the same suite runs - * identically on any host OS. `detectEnvironmentFromNode()` bundles the - * Node defaults for production callers. + * `release` / `env` / `isFile` / `execFileText`) so the same suite runs + * 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 @@ -13,9 +13,11 @@ * to override. */ +import { execFile as nodeExecFile } from 'node:child_process'; import { constants as fsConstants } from 'node:fs'; import { access } from 'node:fs/promises'; import * as nodeOs from 'node:os'; +import * as nodePath from 'node:path'; import { ErrorCodes, KimiError } from '#/errors'; @@ -33,6 +35,11 @@ export interface Environment { readonly shellPath: string; } +export interface ExecFileTextResult { + readonly exitCode: number; + readonly stdout: string; +} + export interface EnvironmentDeps { // Accepts the full Node `Platform` enum plus arbitrary strings for // forward-compatible OS kinds. @@ -41,9 +48,16 @@ export interface EnvironmentDeps { readonly release: string; readonly env: Record; readonly isFile: (path: string) => Promise; - readonly findExecutable: (name: string) => Promise; + readonly execFileText: ( + file: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; } +const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; +const WHERE_GIT_TIMEOUT_MS = 5_000; + function resolveOsKind(platform: string): OsKind { switch (platform) { case 'darwin': @@ -92,13 +106,21 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { } } - const gitExe = await deps.findExecutable('git.exe'); - if (gitExe !== undefined) { - const inferred = inferGitBashFromGitExe(gitExe); - if (inferred !== undefined) { - checked.push(inferred); - if (await deps.isFile(inferred)) { - return inferred; + for (const gitExe of await findWindowsGitExecutables(deps)) { + const inferred = gitBashCandidateFromGitExe(gitExe); + checked.push(inferred); + if (await deps.isFile(inferred)) { + return inferred; + } + + const gitExecPath = await readGitExecPath(deps, gitExe); + if (gitExecPath === undefined) { + continue; + } + for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; } } } @@ -124,21 +146,92 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { ); } +async function readGitExecPath( + deps: EnvironmentDeps, + gitExe: string, +): Promise { + const result = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); + if (result === undefined || result.exitCode !== 0) return undefined; + + for (const line of result.stdout.split(/\r?\n/)) { + const execPath = line.trim(); + if (execPath.length > 0) { + return execPath; + } + } + return undefined; +} + +async function findWindowsGitExecutables(deps: EnvironmentDeps): Promise { + const whereResult = await deps.execFileText('where.exe', ['git'], WHERE_GIT_TIMEOUT_MS); + if (whereResult !== undefined && whereResult.exitCode === 0) { + const wherePaths = parseExecutableLines(whereResult.stdout).filter((path) => + nodePath.win32.basename(path).toLowerCase() === 'git.exe' + ); + if (wherePaths.length > 0) { + return dedupeWindowsPaths(wherePaths); + } + } + + return findExecutablesOnPath('git.exe', deps.env['PATH'], deps.platform, deps.isFile); +} + +function parseExecutableLines(stdout: string): readonly string[] { + const paths: string[] = []; + for (const line of stdout.split(/\r?\n/)) { + const path = line.trim(); + if (path.length > 0) { + paths.push(path); + } + } + return paths; +} + // Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, // with bash at `\bin\bash.exe`. Portable installs sometimes put -// both in `\bin\`. Walk back to the parent of `cmd` / `bin` and -// re-anchor under `bin\bash.exe`. -function inferGitBashFromGitExe(gitExe: string): string | undefined { - const sep = gitExe.includes('\\') ? '\\' : '/'; - const parts = gitExe.split(sep); - for (let i = parts.length - 2; i >= 0; i -= 1) { - const segment = parts[i]; - if (segment === 'cmd' || segment === 'bin') { - const root = parts.slice(0, i).join(sep); - return root.length === 0 ? `bin${sep}bash.exe` : `${root}${sep}bin${sep}bash.exe`; +// both in `\bin\`. Scoop shims live elsewhere, but this cheap +// candidate is still checked before falling back to `git --exec-path`. +function gitBashCandidateFromGitExe(gitExe: string): string { + return nodePath.win32.normalize( + nodePath.win32.join( + normalizeWindowsPath(nodePath.win32.dirname(gitExe)), + '..', + 'bin', + 'bash.exe', + ), + ); +} + +function gitBashCandidatesFromGitExecPath(execPath: string): readonly string[] { + const normalized = nodePath.win32.normalize(normalizeWindowsPath(execPath)); + const parts = normalized.split('\\'); + for (let i = parts.length - 1; i >= 0; i -= 1) { + const segment = parts[i]?.toLowerCase(); + if (segment === 'mingw32' || segment === 'mingw64') { + const root = parts.slice(0, i).join('\\'); + if (root.length > 0) { + return [nodePath.win32.join(root, 'bin', 'bash.exe')]; + } } } - return undefined; + + return [nodePath.win32.normalize(nodePath.win32.join(normalized, '..', '..', 'bin', 'bash.exe'))]; +} + +function normalizeWindowsPath(path: string): string { + return path.replaceAll('/', '\\'); +} + +function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { + const deduped: string[] = []; + const seen = new Set(); + for (const path of paths) { + const key = normalizeWindowsPath(path).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(path); + } + return deduped; } /** @@ -161,26 +254,48 @@ export async function detectEnvironmentFromNode(): Promise { release: nodeOs.release(), env, isFile, - findExecutable: (name: string) => findExecutableOnPath(name, env['PATH'], platform, isFile), + execFileText, }); } -async function findExecutableOnPath( +async function findExecutablesOnPath( name: string, pathEnv: string | undefined, platform: string, isFile: (p: string) => Promise, -): Promise { - if (pathEnv === undefined || pathEnv.length === 0) return undefined; +): Promise { + if (pathEnv === undefined || pathEnv.length === 0) return []; const listSep = platform === 'win32' ? ';' : ':'; const dirSep = platform === 'win32' ? '\\' : '/'; + const paths: string[] = []; for (const rawDir of pathEnv.split(listSep)) { const dir = rawDir.trim(); if (dir.length === 0) continue; const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; if (await isFile(candidate)) { - return candidate; + paths.push(candidate); } } - return undefined; + return platform === 'win32' ? dedupeWindowsPaths(paths) : paths; +} + +async function execFileText( + file: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + nodeExecFile( + file, + [...args], + { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, + (error, stdout) => { + if (error !== null) { + resolve(undefined); + return; + } + resolve({ exitCode: 0, stdout }); + }, + ); + }); } diff --git a/packages/agent-core/test/utils/environment.test.ts b/packages/agent-core/test/utils/environment.test.ts index 145df37a29..0022c699bf 100644 --- a/packages/agent-core/test/utils/environment.test.ts +++ b/packages/agent-core/test/utils/environment.test.ts @@ -6,9 +6,9 @@ * - macOS / Linux / Windows / unknown → `osKind` * - 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 `shell.git_bash_not_found` - * if none are present. + * - Windows resolves Git Bash via `KIMI_SHELL_PATH`, `git.exe` on PATH + * (including `git --exec-path` for shims), or well-known install + * locations; throws `shell.git_bash_not_found` if none are present. * - `osArch` / `osVersion` are populated from the Node OS APIs. * * All tests expect `detectEnvironment()` to be a pure function of @@ -32,23 +32,29 @@ interface StubOpts { readonly release?: string; readonly env?: Record; readonly existingPaths?: readonly string[]; - readonly executables?: Readonly>; + readonly execFileResults?: Readonly< + Record + >; } /** Build a stub deps bag mimicking Node's `os` + `process` surface. */ function stubDeps(opts: StubOpts): Parameters[0] { const existing = new Set(opts.existingPaths ?? []); - const executables = opts.executables ?? {}; return { platform: opts.platform, arch: opts.arch ?? 'x86_64', release: opts.release ?? '1.2.3', env: opts.env ?? {}, isFile: async (path: string) => existing.has(path), - findExecutable: async (name: string) => executables[name], + execFileText: async (file: string, args: readonly string[]) => + opts.execFileResults?.[execFileKey(file, args)], }; } +function execFileKey(file: string, args: readonly string[]): string { + return [file, ...args].join('\0'); +} + describe('detectEnvironment', () => { it('reports osKind "macOS" on darwin', async () => { const env: Environment = await detectEnvironment( @@ -146,10 +152,16 @@ describe('detectEnvironment', () => { }); it('infers Git Bash from git.exe on PATH when override is absent', async () => { + const gitExe = 'C:\\Program Files\\Git\\cmd\\git.exe'; const env = await detectEnvironment( stubDeps({ platform: 'win32', - executables: { 'git.exe': 'C:\\Program Files\\Git\\cmd\\git.exe' }, + execFileResults: { + [execFileKey('where.exe', ['git'])]: { + exitCode: 0, + stdout: `${gitExe}\n`, + }, + }, existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], }), ); @@ -157,6 +169,66 @@ describe('detectEnvironment', () => { expect(env.shellPath).toBe('C:\\Program Files\\Git\\bin\\bash.exe'); }); + it('resolves a Scoop git shim through git --exec-path', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + execFileResults: { + [execFileKey('where.exe', ['git'])]: { + exitCode: 0, + stdout: `${gitExe}\n`, + }, + [execFileKey(gitExe, ['--exec-path'])]: { + exitCode: 0, + stdout: 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + }, + existingPaths: ['C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('checks later git.exe matches when the first one cannot resolve Git Bash', async () => { + const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + execFileResults: { + [execFileKey('where.exe', ['git'])]: { + exitCode: 0, + stdout: `${scoopGit}\n${portableGit}\n`, + }, + [execFileKey(scoopGit, ['--exec-path'])]: { exitCode: 1, stdout: '' }, + }, + existingPaths: ['D:\\PortableGit\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); + }); + + it('falls back to manual PATH scanning when where.exe git returns no git.exe paths', async () => { + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'D:\\PortableGit\\cmd' }, + execFileResults: { + [execFileKey('where.exe', ['git'])]: { + exitCode: 1, + stdout: '', + }, + }, + existingPaths: ['D:\\PortableGit\\cmd\\git.exe', 'D:\\PortableGit\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); + }); + it('falls back to the well-known Program Files install location', async () => { const env = await detectEnvironment( stubDeps({ From 7b3c644f6683afe91e6b43335c7313fe38f12543 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 8 Jun 2026 12:17:51 +0800 Subject: [PATCH 2/3] fix: harden Git Bash shim detection --- packages/agent-core/src/utils/environment.ts | 101 +++++++----------- .../agent-core/test/utils/environment.test.ts | 72 +++++++------ 2 files changed, 78 insertions(+), 95 deletions(-) diff --git a/packages/agent-core/src/utils/environment.ts b/packages/agent-core/src/utils/environment.ts index 8bd110c0ae..faf6fa4dff 100644 --- a/packages/agent-core/src/utils/environment.ts +++ b/packages/agent-core/src/utils/environment.ts @@ -35,11 +35,6 @@ export interface Environment { readonly shellPath: string; } -export interface ExecFileTextResult { - readonly exitCode: number; - readonly stdout: string; -} - export interface EnvironmentDeps { // Accepts the full Node `Platform` enum plus arbitrary strings for // forward-compatible OS kinds. @@ -52,11 +47,10 @@ export interface EnvironmentDeps { file: string, args: readonly string[], timeoutMs: number, - ) => Promise; + ) => Promise; } const GIT_EXEC_PATH_TIMEOUT_MS = 5_000; -const WHERE_GIT_TIMEOUT_MS = 5_000; function resolveOsKind(platform: string): OsKind { switch (platform) { @@ -106,21 +100,19 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { } } - for (const gitExe of await findWindowsGitExecutables(deps)) { - const inferred = gitBashCandidateFromGitExe(gitExe); - checked.push(inferred); - if (await deps.isFile(inferred)) { - return inferred; - } + const gitExecutables = await findExecutablesOnPath( + 'git.exe', + deps.env['PATH'], + deps.platform, + deps.isFile, + ); - const gitExecPath = await readGitExecPath(deps, gitExe); - if (gitExecPath === undefined) { - continue; - } - for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { - checked.push(candidate); - if (await deps.isFile(candidate)) { - return candidate; + for (const gitExe of gitExecutables) { + const inferred = gitBashCandidateFromGitExe(gitExe); + if (inferred !== undefined) { + checked.push(inferred); + if (await deps.isFile(inferred)) { + return inferred; } } } @@ -140,6 +132,19 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { } } + for (const gitExe of gitExecutables) { + const gitExecPath = await readGitExecPath(deps, gitExe); + if (gitExecPath === undefined) { + continue; + } + for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } + } + throw new KimiError( ErrorCodes.SHELL_GIT_BASH_NOT_FOUND, `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(', ')}.`, @@ -150,10 +155,10 @@ async function readGitExecPath( deps: EnvironmentDeps, gitExe: string, ): Promise { - const result = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); - if (result === undefined || result.exitCode !== 0) return undefined; + const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); + if (stdout === undefined) return undefined; - for (const line of result.stdout.split(/\r?\n/)) { + for (const line of stdout.split(/\r?\n/)) { const execPath = line.trim(); if (execPath.length > 0) { return execPath; @@ -162,43 +167,19 @@ async function readGitExecPath( return undefined; } -async function findWindowsGitExecutables(deps: EnvironmentDeps): Promise { - const whereResult = await deps.execFileText('where.exe', ['git'], WHERE_GIT_TIMEOUT_MS); - if (whereResult !== undefined && whereResult.exitCode === 0) { - const wherePaths = parseExecutableLines(whereResult.stdout).filter((path) => - nodePath.win32.basename(path).toLowerCase() === 'git.exe' - ); - if (wherePaths.length > 0) { - return dedupeWindowsPaths(wherePaths); - } - } - - return findExecutablesOnPath('git.exe', deps.env['PATH'], deps.platform, deps.isFile); -} - -function parseExecutableLines(stdout: string): readonly string[] { - const paths: string[] = []; - for (const line of stdout.split(/\r?\n/)) { - const path = line.trim(); - if (path.length > 0) { - paths.push(path); - } - } - return paths; -} - // Most Git for Windows installs put `git.exe` in `\cmd\git.exe`, // with bash at `\bin\bash.exe`. Portable installs sometimes put -// both in `\bin\`. Scoop shims live elsewhere, but this cheap -// candidate is still checked before falling back to `git --exec-path`. -function gitBashCandidateFromGitExe(gitExe: string): string { +// both in `\bin\`. Only infer from those anchored layouts; package +// manager shims live elsewhere and must resolve through `git --exec-path`. +function gitBashCandidateFromGitExe(gitExe: string): string | undefined { + const normalizedGitExe = nodePath.win32.normalize(normalizeWindowsPath(gitExe)); + const gitDir = nodePath.win32.dirname(normalizedGitExe); + const gitDirName = nodePath.win32.basename(gitDir).toLowerCase(); + if (gitDirName !== 'cmd' && gitDirName !== 'bin') { + return undefined; + } return nodePath.win32.normalize( - nodePath.win32.join( - normalizeWindowsPath(nodePath.win32.dirname(gitExe)), - '..', - 'bin', - 'bash.exe', - ), + nodePath.win32.join(nodePath.win32.dirname(gitDir), 'bin', 'bash.exe'), ); } @@ -283,7 +264,7 @@ async function execFileText( file: string, args: readonly string[], timeoutMs: number, -): Promise { +): Promise { return new Promise((resolve) => { nodeExecFile( file, @@ -294,7 +275,7 @@ async function execFileText( resolve(undefined); return; } - resolve({ exitCode: 0, stdout }); + resolve(stdout); }, ); }); diff --git a/packages/agent-core/test/utils/environment.test.ts b/packages/agent-core/test/utils/environment.test.ts index 0022c699bf..05652f8508 100644 --- a/packages/agent-core/test/utils/environment.test.ts +++ b/packages/agent-core/test/utils/environment.test.ts @@ -32,9 +32,8 @@ interface StubOpts { readonly release?: string; readonly env?: Record; readonly existingPaths?: readonly string[]; - readonly execFileResults?: Readonly< - Record - >; + readonly execFileResults?: Readonly>; + readonly execFileText?: Parameters[0]['execFileText']; } /** Build a stub deps bag mimicking Node's `os` + `process` surface. */ @@ -46,8 +45,9 @@ function stubDeps(opts: StubOpts): Parameters[0] { release: opts.release ?? '1.2.3', env: opts.env ?? {}, isFile: async (path: string) => existing.has(path), - execFileText: async (file: string, args: readonly string[]) => - opts.execFileResults?.[execFileKey(file, args)], + execFileText: + opts.execFileText ?? + (async (file: string, args: readonly string[]) => opts.execFileResults?.[execFileKey(file, args)]), }; } @@ -156,13 +156,11 @@ describe('detectEnvironment', () => { const env = await detectEnvironment( stubDeps({ platform: 'win32', - execFileResults: { - [execFileKey('where.exe', ['git'])]: { - exitCode: 0, - stdout: `${gitExe}\n`, - }, + env: { PATH: 'C:\\Program Files\\Git\\cmd' }, + existingPaths: [gitExe, 'C:\\Program Files\\Git\\bin\\bash.exe'], + execFileText: async (file: string) => { + throw new Error(`unexpected execFileText call for ${file}`); }, - existingPaths: ['C:\\Program Files\\Git\\bin\\bash.exe'], }), ); expect(env.shellName).toBe('bash'); @@ -174,17 +172,33 @@ describe('detectEnvironment', () => { const env = await detectEnvironment( stubDeps({ platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, + execFileResults: { + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [gitExe, 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('does not treat shim-adjacent bash.exe as the Git installation shell', async () => { + const gitExe = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims' }, execFileResults: { - [execFileKey('where.exe', ['git'])]: { - exitCode: 0, - stdout: `${gitExe}\n`, - }, - [execFileKey(gitExe, ['--exec-path'])]: { - exitCode: 0, - stdout: 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', - }, + [execFileKey(gitExe, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', }, - existingPaths: ['C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'], + existingPaths: [ + gitExe, + 'C:\\Users\\me\\scoop\\bin\\bash.exe', + 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', + ], }), ); expect(env.shellName).toBe('bash'); @@ -197,31 +211,19 @@ describe('detectEnvironment', () => { const env = await detectEnvironment( stubDeps({ platform: 'win32', - execFileResults: { - [execFileKey('where.exe', ['git'])]: { - exitCode: 0, - stdout: `${scoopGit}\n${portableGit}\n`, - }, - [execFileKey(scoopGit, ['--exec-path'])]: { exitCode: 1, stdout: '' }, - }, - existingPaths: ['D:\\PortableGit\\bin\\bash.exe'], + env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, + existingPaths: [scoopGit, portableGit, 'D:\\PortableGit\\bin\\bash.exe'], }), ); expect(env.shellName).toBe('bash'); expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); }); - it('falls back to manual PATH scanning when where.exe git returns no git.exe paths', async () => { + it('scans PATH directly for git.exe candidates', async () => { const env = await detectEnvironment( stubDeps({ platform: 'win32', env: { PATH: 'D:\\PortableGit\\cmd' }, - execFileResults: { - [execFileKey('where.exe', ['git'])]: { - exitCode: 1, - stdout: '', - }, - }, existingPaths: ['D:\\PortableGit\\cmd\\git.exe', 'D:\\PortableGit\\bin\\bash.exe'], }), ); From 5b582d63b89fed356cf27ce654229c10abe95998 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 8 Jun 2026 13:12:04 +0800 Subject: [PATCH 3/3] fix(kaos): preserve Git Bash PATH priority --- packages/kaos/src/environment.ts | 31 +++++++++++-------- packages/kaos/test/environment.test.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/kaos/src/environment.ts b/packages/kaos/src/environment.ts index b218999c0c..9b08baff26 100644 --- a/packages/kaos/src/environment.ts +++ b/packages/kaos/src/environment.ts @@ -116,6 +116,17 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { } } } + + const gitExecPath = await readGitExecPath(deps, gitExe); + if (gitExecPath === undefined) { + continue; + } + for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { + checked.push(candidate); + if (await deps.isFile(candidate)) { + return candidate; + } + } } const candidates: string[] = [ @@ -136,19 +147,6 @@ async function locateWindowsGitBash(deps: EnvironmentDeps): Promise { } } - for (const gitExe of gitExecutables) { - const gitExecPath = await readGitExecPath(deps, gitExe); - if (gitExecPath === undefined) { - continue; - } - for (const candidate of gitBashCandidatesFromGitExecPath(gitExecPath)) { - checked.push(candidate); - if (await deps.isFile(candidate)) { - return candidate; - } - } - } - 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(', ')}.`, ); @@ -158,6 +156,8 @@ async function readGitExecPath( deps: EnvironmentDeps, gitExe: string, ): Promise { + if (deps.platform === 'win32' && !isAbsoluteWindowsPath(gitExe)) return undefined; + const stdout = await deps.execFileText(gitExe, ['--exec-path'], GIT_EXEC_PATH_TIMEOUT_MS); if (stdout === undefined) return undefined; @@ -211,6 +211,10 @@ function normalizeWindowsPath(path: string): string { return path.replaceAll('/', '\\'); } +function isAbsoluteWindowsPath(path: string): boolean { + return nodePath.win32.isAbsolute(normalizeWindowsPath(path)); +} + function dedupeWindowsPaths(paths: readonly string[]): readonly string[] { const deduped: string[] = []; const seen = new Set(); @@ -271,6 +275,7 @@ async function findExecutablesOnPath( for (const rawDir of pathEnv.split(listSep)) { const dir = rawDir.trim(); if (dir.length === 0) continue; + if (platform === 'win32' && !isAbsoluteWindowsPath(dir)) continue; const candidate = dir.endsWith(dirSep) ? `${dir}${name}` : `${dir}${dirSep}${name}`; if (await isFile(candidate)) { paths.push(candidate); diff --git a/packages/kaos/test/environment.test.ts b/packages/kaos/test/environment.test.ts index 79a2abcf60..91922ae1a0 100644 --- a/packages/kaos/test/environment.test.ts +++ b/packages/kaos/test/environment.test.ts @@ -237,6 +237,49 @@ describe('detectEnvironment', () => { expect(env.shellPath).toBe('D:\\PortableGit\\bin\\bash.exe'); }); + it('keeps PATH order when an earlier shim resolves through git --exec-path', async () => { + const scoopGit = 'C:\\Users\\me\\scoop\\shims\\git.exe'; + const portableGit = 'D:\\PortableGit\\cmd\\git.exe'; + const env = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'C:\\Users\\me\\scoop\\shims;D:\\PortableGit\\cmd' }, + execFileResults: { + [execFileKey(scoopGit, ['--exec-path'])]: + 'C:/Users/me/scoop/apps/git/current/mingw64/libexec/git-core\n', + }, + existingPaths: [ + scoopGit, + portableGit, + 'C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe', + 'D:\\PortableGit\\bin\\bash.exe', + ], + }), + ); + expect(env.shellName).toBe('bash'); + expect(env.shellPath).toBe('C:\\Users\\me\\scoop\\apps\\git\\current\\bin\\bash.exe'); + }); + + it('skips relative Windows PATH entries before git --exec-path probing', async () => { + const relativeGit = 'tools\\git.exe'; + const error = await detectEnvironment( + stubDeps({ + platform: 'win32', + env: { PATH: 'tools' }, + existingPaths: [relativeGit], + execFileText: async (file: string) => { + throw new Error(`unexpected execFileText call for ${file}`); + }, + }), + ).then( + () => { + throw new Error('expected throw'); + }, + (error: unknown) => error, + ); + expect(error).toBeInstanceOf(KaosShellNotFoundError); + }); + it('scans PATH directly for git.exe candidates', async () => { const env = await detectEnvironment( stubDeps({