Skip to content
Open
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/windows-git-bash-path-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix file tools failing to open files referenced by Git Bash paths such as /tmp or /home on Windows.
166 changes: 166 additions & 0 deletions packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* `_base/execEnv` (L0) — shell path bridge: translate between native win32
* paths and the POSIX path dialect spoken by the MSYS2 / Git Bash shell.
*
* The msys runtime gives the shell a POSIX path view native Node.js cannot
* resolve (`/c/Users/x` is `C:\Users\x`; `/tmp/x` is `%TEMP%\x`, not
* `<git-root>/tmp`). `toShellPath` renders native paths for bash command
* lines; `fromShellPath` resolves model/shell-supplied paths for fs access,
* translating drive-letter forms lexically and other root-relative paths
* through `cygpath -w` next to the probed bash. Anything unconvertible
* passes through unchanged, and both directions are identity outside win32
* bash.
*
* Vendored from `@moonshot-ai/kaos` `shell-path-bridge.ts` — kept as a pure
* helper with no DI dependencies.
*/

import { execFileSync as nodeExecFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import * as nodePath from 'node:path';

import type { HostEnvironmentInfo } from './environmentProbe';

export interface ShellPathBridge {
toShellPath(nativePath: string): string;
fromShellPath(path: string): string;
}

export type ShellPathBridgeEnv = Pick<HostEnvironmentInfo, 'osKind' | 'shellName' | 'shellPath'>;

export interface ShellPathBridgeDeps {
readonly execFileSync: (file: string, args: readonly string[]) => string;
readonly isFile: (path: string) => boolean;
}

const CYGPATH_TIMEOUT_MS = 5_000;

const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/;
const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/;
const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/;

const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/'];

const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/;

function joinDrive(letter: string, rest: string): string {
const normalizedRest = rest.replaceAll('\\', '/');
return normalizedRest === ''
? `${letter.toUpperCase()}:/`
: `${letter.toUpperCase()}:${normalizedRest}`;
}

export function translateShellDrivePath(path: string): string {
const colonMatch = DRIVE_COLON_RE.exec(path);
if (colonMatch !== null) {
return joinDrive(colonMatch[1]!, path.slice(3));
}
const cygdriveMatch = CYGDRIVE_RE.exec(path);
if (cygdriveMatch !== null) {
return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length));
}
const driveMatch = DRIVE_RE.exec(path);
if (driveMatch !== null) {
return joinDrive(driveMatch[1]!, path.slice(2));
}
return path;
}

export function createShellPathBridge(
env: ShellPathBridgeEnv,
deps: ShellPathBridgeDeps,
): ShellPathBridge {
const enabled = env.osKind === 'Windows' && env.shellName === 'bash';

let cygpathExe: string | null | undefined;
const segmentCache = new Map<string, string>();

function locateCygpath(): string | null {
if (cygpathExe !== undefined) return cygpathExe;
const shellDir = nodePath.win32.dirname(env.shellPath);
const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')];
if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') {
candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe'));
}
cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null;
return cygpathExe;
}

function resolveRootSegment(firstSegment: string): string | null {
const cached = segmentCache.get(firstSegment);
if (cached !== undefined) return cached;

const exe = locateCygpath();
if (exe === null) return null;
let resolved: string;
try {
const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]);
const trimmed = output.replace(/\r?\n$/, '');
if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null;
resolved = trimmed.replace(/[\\/]$/, '');
} catch {
return null;
}
segmentCache.set(firstSegment, resolved);
return resolved;
}

function fromShellPath(path: string): string {
if (!enabled) return path;

if (path.startsWith('//')) return path;

if (path.startsWith('/')) {
const normalized = nodePath.posix.normalize(path);
const lexical = translateShellDrivePath(normalized);
if (lexical !== normalized) return lexical;
if (normalized === '/') return normalized;
if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized;
const firstSegment = normalized.slice(1).split('/')[0]!;
const prefix = resolveRootSegment(firstSegment);
if (prefix === null) return normalized;
const remainder = normalized.slice(firstSegment.length + 1);
const joined = `${prefix}${remainder}`.replaceAll('\\', '/');
return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined;
}

return path;
}

function toShellPath(nativePath: string): string {
if (!enabled) return nativePath;

if (nativePath.startsWith('\\\\')) {
return nativePath.replaceAll('\\', '/');
}

const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath);
if (driveMatch !== null) {
const drive = driveMatch[1]!.toLowerCase();
const rest = nativePath.slice(2).replaceAll('\\', '/');
return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
}

return nativePath.replaceAll('\\', '/');
}

return { toShellPath, fromShellPath };
}

const bridgeCache = new WeakMap<ShellPathBridgeEnv, ShellPathBridge>();

export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge {
const cached = bridgeCache.get(env);
if (cached !== undefined) return cached;
const bridge = createShellPathBridge(env, {
execFileSync: (file, args) =>
nodeExecFileSync(file, [...args], {
encoding: 'utf8',
timeout: CYGPATH_TIMEOUT_MS,
windowsHide: true,
}),
isFile: (path) => existsSync(path),
});
bridgeCache.set(env, bridge);
return bridge;
}
18 changes: 2 additions & 16 deletions packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionProcessRunner, type IProcess } from '#/session/process/processRunner';
import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy';
import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge';
import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool/toolContract';
import {
type ExecutableToolResultBuilderResult,
Expand Down Expand Up @@ -193,7 +194,7 @@ export class BashTool implements IBashTool {
}

private spawn(effectiveCwd: string, command: string): Promise<IProcess> {
const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
const shellCwd = getShellPathBridge(this.env).toShellPath(effectiveCwd);
const shellArgs = [
this.env.shellPath,
'-c',
Expand Down Expand Up @@ -487,21 +488,6 @@ function shellQuote(s: string): string {
return `'${s.replaceAll("'", "'\\''")}'`;
}

function windowsPathToPosixPath(path: string): string {
if (path.startsWith('\\\\')) {
return path.replaceAll('\\', '/');
}

const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(path);
if (driveMatch !== null) {
const drive = driveMatch[1]!.toLowerCase();
const rest = path.slice(2).replaceAll('\\', '/');
return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
}

return path.replaceAll('\\', '/');
}

const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g;

function rewriteWindowsNullRedirect(command: string): string {
Expand Down
44 changes: 18 additions & 26 deletions packages/agent-core-v2/src/tool/path-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,19 @@
* running on Windows. Shared-prefix escapes (a path like `/workspace-evil`
* passing a naive `startswith('/workspace')` check) are blocked by
* requiring a path separator (or exact equality) after the base prefix in
* `isWithinDirectory`. Pure policy; no scoped service.
* `isWithinDirectory`. On win32 Git Bash hosts, model-supplied paths are
* first translated through the `_base/execEnv` shell path bridge; without
* a bridge the guard falls back to the lexical-only `normalizeUserPath`.
* Pure policy; no scoped service.
*/

import * as pathe from 'pathe';

import {
getShellPathBridge,
translateShellDrivePath,
type ShellPathBridge,
} from '#/_base/execEnv/shellPathBridge';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';

export interface WorkspaceConfig {
Expand Down Expand Up @@ -138,29 +146,7 @@ function isWin32DriveRelative(path: string): boolean {
}

export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string {
if (pathClass !== 'win32') return path;

if (path === '/') return '/';

if (path.startsWith('//')) {
return path;
}

const cygdriveMatch = /^\/cygdrive\/([A-Za-z])(?:\/|$)/.exec(path);
if (cygdriveMatch !== null) {
const drive = cygdriveMatch[1]!.toUpperCase();
const rest = path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length);
return `${drive}:${rest === '' ? '/' : rest}`;
}

const driveMatch = /^\/([A-Za-z])(?:\/|$)/.exec(path);
if (driveMatch !== null) {
const drive = driveMatch[1]!.toUpperCase();
const rest = path.slice(2);
return `${drive}:${rest === '' ? '/' : rest}`;
}

return path;
return pathClass === 'win32' ? translateShellDrivePath(path) : path;
}

function expandUserPath(path: string, homeDir: string | undefined, pathClass: PathClass): string {
Expand Down Expand Up @@ -253,10 +239,14 @@ export interface ResolvePathAccessOptions {
readonly policy?: WorkspaceAccessPolicy | undefined;
readonly pathClass?: PathClass | undefined;
readonly homeDir?: string;
readonly shellPathBridge?: ShellPathBridge;
}

export interface ResolvePathAccessPathOptions {
readonly env: Pick<IHostEnvironment, 'pathClass' | 'homeDir'>;
readonly env: Pick<
IHostEnvironment,
'pathClass' | 'homeDir' | 'osKind' | 'shellName' | 'shellPath'
>;
readonly workspace: WorkspaceConfig;
readonly operation: PathAccessOperation;
readonly policy?: WorkspaceAccessPolicy;
Expand All @@ -283,7 +273,8 @@ export function resolvePathAccess(
options: ResolvePathAccessOptions,
): PathAccess {
const pathClass = options.pathClass ?? DEFAULT_PATH_CLASS;
const normalizedPath = normalizeUserPath(path, pathClass);
const normalizedPath =
options.shellPathBridge?.fromShellPath(path) ?? normalizeUserPath(path, pathClass);
const expandedPath = expandUserPath(normalizedPath, options.homeDir, pathClass);
const rawIsAbsolute = pathe.isAbsolute(expandedPath);
const canonical = canonicalizePath(expandedPath, cwd, pathClass);
Expand Down Expand Up @@ -330,6 +321,7 @@ export function resolvePathAccessPath(
policy,
pathClass: env.pathClass,
homeDir: expandHome ? env.homeDir : undefined,
shellPathBridge: env.pathClass === 'win32' ? getShellPathBridge(env) : undefined,
}).path;
}

Expand Down
Loading
Loading