Skip to content

Commit 30e7f62

Browse files
authored
fix(kaos): resolve Git Bash POSIX paths for file tools on Windows (MoonshotAI#2200)
Add a shell path bridge that translates between win32 paths and the MSYS2/Git Bash path dialect. File tools resolve model-supplied paths through it before canonicalization and workspace checks: drive-letter forms translate lexically, root-relative paths resolve via cygpath -w with per-segment caching, and every failure mode falls back to the previous behavior. Fixes MoonshotAI#2199
1 parent 381142a commit 30e7f62

15 files changed

Lines changed: 1056 additions & 91 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Fix file tools and shell working directories failing to resolve Git Bash paths such as /c/Users or /tmp on Windows.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { execFileSync as nodeExecFileSync } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
3+
import * as nodePath from 'node:path';
4+
5+
import type { HostEnvironmentInfo } from './environmentProbe';
6+
7+
export interface ShellPathBridge {
8+
toShellPath(nativePath: string): string;
9+
fromShellPath(path: string): string;
10+
}
11+
12+
export type ShellPathBridgeEnv = Pick<HostEnvironmentInfo, 'osKind' | 'shellName' | 'shellPath'>;
13+
14+
export interface ShellPathBridgeDeps {
15+
readonly execFileSync: (file: string, args: readonly string[]) => string;
16+
readonly isFile: (path: string) => boolean;
17+
}
18+
19+
const CYGPATH_TIMEOUT_MS = 5_000;
20+
21+
const DRIVE_COLON_RE = /^\/([a-zA-Z]):(?:[\\/]|$)/;
22+
const CYGDRIVE_RE = /^\/cygdrive\/([a-zA-Z])(?:\/|$)/;
23+
const DRIVE_RE = /^\/([a-zA-Z])(?:\/|$)/;
24+
25+
const VIRTUAL_FS_PREFIXES: readonly string[] = ['/dev/', '/proc/', '/sys/'];
26+
27+
const WIN32_DRIVE_ABSOLUTE_RE = /^[A-Za-z]:[\\/]/;
28+
29+
function joinDrive(letter: string, rest: string): string {
30+
const normalizedRest = rest.replaceAll('\\', '/');
31+
return normalizedRest === ''
32+
? `${letter.toUpperCase()}:/`
33+
: `${letter.toUpperCase()}:${normalizedRest}`;
34+
}
35+
36+
export function translateShellDrivePath(path: string): string {
37+
const colonMatch = DRIVE_COLON_RE.exec(path);
38+
if (colonMatch !== null) {
39+
return joinDrive(colonMatch[1]!, path.slice(3));
40+
}
41+
const cygdriveMatch = CYGDRIVE_RE.exec(path);
42+
if (cygdriveMatch !== null) {
43+
return joinDrive(cygdriveMatch[1]!, path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length));
44+
}
45+
const driveMatch = DRIVE_RE.exec(path);
46+
if (driveMatch !== null) {
47+
return joinDrive(driveMatch[1]!, path.slice(2));
48+
}
49+
return path;
50+
}
51+
52+
export function createShellPathBridge(
53+
env: ShellPathBridgeEnv,
54+
deps: ShellPathBridgeDeps,
55+
): ShellPathBridge {
56+
const enabled = env.osKind === 'Windows' && env.shellName === 'bash';
57+
58+
let cygpathExe: string | null | undefined;
59+
const segmentCache = new Map<string, string>();
60+
61+
function locateCygpath(): string | null {
62+
if (cygpathExe !== undefined) return cygpathExe;
63+
const shellDir = nodePath.win32.dirname(env.shellPath);
64+
const candidates = [nodePath.win32.join(shellDir, 'cygpath.exe')];
65+
if (nodePath.win32.basename(shellDir).toLowerCase() === 'bin') {
66+
candidates.push(nodePath.win32.join(shellDir, '..', 'usr', 'bin', 'cygpath.exe'));
67+
}
68+
cygpathExe = candidates.find((candidate) => deps.isFile(candidate)) ?? null;
69+
return cygpathExe;
70+
}
71+
72+
function resolveRootSegment(firstSegment: string): string | null {
73+
const cached = segmentCache.get(firstSegment);
74+
if (cached !== undefined) return cached;
75+
76+
const exe = locateCygpath();
77+
if (exe === null) return null;
78+
let resolved: string;
79+
try {
80+
const output = deps.execFileSync(exe, ['-w', '-C', 'UTF8', '--', `/${firstSegment}`]);
81+
const trimmed = output.replace(/\r?\n$/, '');
82+
if (!WIN32_DRIVE_ABSOLUTE_RE.test(trimmed) && !trimmed.startsWith('\\\\')) return null;
83+
resolved = trimmed.replace(/[\\/]$/, '');
84+
} catch {
85+
return null;
86+
}
87+
segmentCache.set(firstSegment, resolved);
88+
return resolved;
89+
}
90+
91+
function fromShellPath(path: string): string {
92+
if (!enabled) return path;
93+
94+
if (path.startsWith('//')) return path;
95+
96+
if (path.startsWith('/')) {
97+
const normalized = nodePath.posix.normalize(path);
98+
const lexical = translateShellDrivePath(normalized);
99+
if (lexical !== normalized) return lexical;
100+
if (normalized === '/') return normalized;
101+
if (VIRTUAL_FS_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return normalized;
102+
const firstSegment = normalized.slice(1).split('/')[0]!;
103+
const prefix = resolveRootSegment(firstSegment);
104+
if (prefix === null) return normalized;
105+
const remainder = normalized.slice(firstSegment.length + 1);
106+
const joined = `${prefix}${remainder}`.replaceAll('\\', '/');
107+
return /^[A-Za-z]:$/.test(joined) ? `${joined}/` : joined;
108+
}
109+
110+
return path;
111+
}
112+
113+
function toShellPath(nativePath: string): string {
114+
if (!enabled) return nativePath;
115+
116+
if (nativePath.startsWith('\\\\')) {
117+
return nativePath.replaceAll('\\', '/');
118+
}
119+
120+
const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(nativePath);
121+
if (driveMatch !== null) {
122+
const drive = driveMatch[1]!.toLowerCase();
123+
const rest = nativePath.slice(2).replaceAll('\\', '/');
124+
return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
125+
}
126+
127+
return nativePath.replaceAll('\\', '/');
128+
}
129+
130+
return { toShellPath, fromShellPath };
131+
}
132+
133+
const bridgeCache = new Map<string, ShellPathBridge>();
134+
135+
export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge {
136+
const key = `${env.osKind} ${env.shellName} ${env.shellPath}`;
137+
const cached = bridgeCache.get(key);
138+
if (cached !== undefined) return cached;
139+
const bridge = createShellPathBridge(env, {
140+
execFileSync: (file, args) =>
141+
nodeExecFileSync(file, [...args], {
142+
encoding: 'utf8',
143+
timeout: CYGPATH_TIMEOUT_MS,
144+
windowsHide: true,
145+
}),
146+
isFile: (path) => existsSync(path),
147+
});
148+
bridgeCache.set(key, bridge);
149+
return bridge;
150+
}

packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo
88
import { IAgentRuntimeService, inspectAgentRuntime } from '#/agent/runtimeBinding/agentRuntime';
99
import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView';
1010
import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy';
11+
import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge';
1112
import type { ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool/toolContract';
1213
import {
1314
type ExecutableToolResultBuilderResult,
@@ -150,7 +151,7 @@ export class BashTool implements IBashTool {
150151
effectiveCwd: string,
151152
command: string,
152153
): Promise<IHostProcess> {
153-
const shellCwd = env.osKind === 'Windows' ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
154+
const shellCwd = getShellPathBridge(env).toShellPath(effectiveCwd);
154155
const shellCommand = `cd ${shellQuote(shellCwd)} && ${command}`;
155156
const noninteractiveEnv: Record<string, string> = {
156157
NO_COLOR: '1',
@@ -448,21 +449,6 @@ function shellQuote(s: string): string {
448449
return `'${s.replaceAll("'", "'\\''")}'`;
449450
}
450451

451-
function windowsPathToPosixPath(path: string): string {
452-
if (path.startsWith('\\\\')) {
453-
return path.replaceAll('\\', '/');
454-
}
455-
456-
const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(path);
457-
if (driveMatch !== null) {
458-
const drive = driveMatch[1]!.toLowerCase();
459-
const rest = path.slice(2).replaceAll('\\', '/');
460-
return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
461-
}
462-
463-
return path.replaceAll('\\', '/');
464-
}
465-
466452
const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g;
467453

468454
function rewriteWindowsNullRedirect(command: string): string {

packages/agent-core-v2/src/runtime/fakeRuntime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export class FakeRuntime implements Runtime {
2525
readonly status?: RuntimeStatus;
2626
readonly capabilities?: readonly RuntimeCapability[];
2727
readonly pathClass?: 'posix' | 'win32';
28+
readonly environment?: Partial<Runtime['environment']>;
2829
readonly mapWorkspaceRoots?: Runtime['workspace']['mapRoots'];
2930
} = {},
3031
) {
@@ -39,6 +40,7 @@ export class FakeRuntime implements Runtime {
3940
shellPath: '/bin/sh',
4041
pathClass: options.pathClass ?? 'posix',
4142
homeDir: options.pathClass === 'win32' ? 'C:\\Users\\fake' : '/home/fake',
43+
...options.environment,
4244
};
4345
this.path = {
4446
separator: path.sep as '/' | '\\',

packages/agent-core-v2/src/runtime/runtimeWorkspaceView.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ErrorCodes, Error2 } from '#/errors';
2+
import { getShellPathBridge } from '#/_base/execEnv/shellPathBridge';
23

34
import type { Runtime, RuntimeBinding, RuntimeWorkspaceRoots } from './runtime';
45

@@ -27,9 +28,11 @@ export class RuntimeWorkspaceView {
2728
}
2829

2930
resolve(path: string, cwd = this.workDir): string {
30-
const resolved = this.runtime.path.isAbsolute(path)
31-
? this.runtime.path.resolve(path)
32-
: this.runtime.path.resolve(cwd, path);
31+
const env = this.runtime.environment;
32+
const bridged = env.pathClass === 'win32' ? getShellPathBridge(env).fromShellPath(path) : path;
33+
const resolved = this.runtime.path.isAbsolute(bridged)
34+
? this.runtime.path.resolve(bridged)
35+
: this.runtime.path.resolve(cwd, bridged);
3336
this.assertAllowed(resolved);
3437
return resolved;
3538
}

packages/agent-core-v2/src/tool/path-access.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import * as pathe from 'pathe';
22

3+
import {
4+
getShellPathBridge,
5+
translateShellDrivePath,
6+
type ShellPathBridge,
7+
} from '#/_base/execEnv/shellPathBridge';
38
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
49

510
export interface WorkspaceConfig {
@@ -118,29 +123,7 @@ function isWin32DriveRelative(path: string): boolean {
118123
}
119124

120125
export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string {
121-
if (pathClass !== 'win32') return path;
122-
123-
if (path === '/') return '/';
124-
125-
if (path.startsWith('//')) {
126-
return path;
127-
}
128-
129-
const cygdriveMatch = /^\/cygdrive\/([A-Za-z])(?:\/|$)/.exec(path);
130-
if (cygdriveMatch !== null) {
131-
const drive = cygdriveMatch[1]!.toUpperCase();
132-
const rest = path.slice(`/cygdrive/${cygdriveMatch[1]!}`.length);
133-
return `${drive}:${rest === '' ? '/' : rest}`;
134-
}
135-
136-
const driveMatch = /^\/([A-Za-z])(?:\/|$)/.exec(path);
137-
if (driveMatch !== null) {
138-
const drive = driveMatch[1]!.toUpperCase();
139-
const rest = path.slice(2);
140-
return `${drive}:${rest === '' ? '/' : rest}`;
141-
}
142-
143-
return path;
126+
return pathClass === 'win32' ? translateShellDrivePath(path) : path;
144127
}
145128

146129
function expandUserPath(path: string, homeDir: string | undefined, pathClass: PathClass): string {
@@ -233,10 +216,14 @@ export interface ResolvePathAccessOptions {
233216
readonly policy?: WorkspaceAccessPolicy | undefined;
234217
readonly pathClass?: PathClass | undefined;
235218
readonly homeDir?: string;
219+
readonly shellPathBridge?: ShellPathBridge;
236220
}
237221

238222
export interface ResolvePathAccessPathOptions {
239-
readonly env: Pick<IHostEnvironment, 'pathClass' | 'homeDir'>;
223+
readonly env: Pick<
224+
IHostEnvironment,
225+
'pathClass' | 'homeDir' | 'osKind' | 'shellName' | 'shellPath'
226+
>;
240227
readonly workspace: WorkspaceConfig;
241228
readonly operation: PathAccessOperation;
242229
readonly policy?: WorkspaceAccessPolicy;
@@ -263,7 +250,8 @@ export function resolvePathAccess(
263250
options: ResolvePathAccessOptions,
264251
): PathAccess {
265252
const pathClass = options.pathClass ?? DEFAULT_PATH_CLASS;
266-
const normalizedPath = normalizeUserPath(path, pathClass);
253+
const normalizedPath =
254+
options.shellPathBridge?.fromShellPath(path) ?? normalizeUserPath(path, pathClass);
267255
const expandedPath = expandUserPath(normalizedPath, options.homeDir, pathClass);
268256
const rawIsAbsolute = pathe.isAbsolute(expandedPath);
269257
const canonical = canonicalizePath(expandedPath, cwd, pathClass);
@@ -310,6 +298,7 @@ export function resolvePathAccessPath(
310298
policy,
311299
pathClass: env.pathClass,
312300
homeDir: expandHome ? env.homeDir : undefined,
301+
shellPathBridge: env.pathClass === 'win32' ? getShellPathBridge(env) : undefined,
313302
}).path;
314303
}
315304

0 commit comments

Comments
 (0)