Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
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/upstream-fix-batch-d.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"echadron": patch
---

File tools on Windows now understand Git Bash paths. A model-supplied POSIX path like `/c/Users/you/project` is translated to its win32 form before the path is canonicalized and checked against the workspace, so reads and writes stop failing on paths the shell itself accepts. Drive-letter forms translate lexically, root-relative paths resolve through `cygpath -w` with per-segment caching, and every failure mode falls back to the previous behaviour.
150 changes: 150 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,150 @@
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 Map<string, ShellPathBridge>();

export function getShellPathBridge(env: ShellPathBridgeEnv): ShellPathBridge {
const key = `${env.osKind} ${env.shellName} ${env.shellPath}`;
const cached = bridgeCache.get(key);
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(key, 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 = this.isWindowsBash ? getShellPathBridge(this.env).toShellPath(effectiveCwd) : 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
39 changes: 14 additions & 25 deletions packages/agent-core-v2/src/tool/path-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@

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 +143,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 +236,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 +270,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 +318,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