diff --git a/.changeset/windows-git-bash-path-bridge.md b/.changeset/windows-git-bash-path-bridge.md new file mode 100644 index 0000000000..a2a1fc868f --- /dev/null +++ b/.changeset/windows-git-bash-path-bridge.md @@ -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. diff --git a/packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts b/packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts new file mode 100644 index 0000000000..1412adcc28 --- /dev/null +++ b/packages/agent-core-v2/src/_base/execEnv/shellPathBridge.ts @@ -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 + * `/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; + +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(); + + 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(); + +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; +} diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 88c3bb6667..433d2467ca 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -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, @@ -193,7 +194,7 @@ export class BashTool implements IBashTool { } private spawn(effectiveCwd: string, command: string): Promise { - const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; + const shellCwd = getShellPathBridge(this.env).toShellPath(effectiveCwd); const shellArgs = [ this.env.shellPath, '-c', @@ -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 { diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index aaaab3140d..5611b38d8f 100644 --- a/packages/agent-core-v2/src/tool/path-access.ts +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -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 { @@ -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 { @@ -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; + readonly env: Pick< + IHostEnvironment, + 'pathClass' | 'homeDir' | 'osKind' | 'shellName' | 'shellPath' + >; readonly workspace: WorkspaceConfig; readonly operation: PathAccessOperation; readonly policy?: WorkspaceAccessPolicy; @@ -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); @@ -330,6 +321,7 @@ export function resolvePathAccessPath( policy, pathClass: env.pathClass, homeDir: expandHome ? env.homeDir : undefined, + shellPathBridge: env.pathClass === 'win32' ? getShellPathBridge(env) : undefined, }).path; } diff --git a/packages/agent-core-v2/test/_base/execEnv/shellPathBridge.test.ts b/packages/agent-core-v2/test/_base/execEnv/shellPathBridge.test.ts new file mode 100644 index 0000000000..12c6bd356f --- /dev/null +++ b/packages/agent-core-v2/test/_base/execEnv/shellPathBridge.test.ts @@ -0,0 +1,268 @@ +/** + * Shell path bridge (vendored `_base/execEnv` module). + * + * Mirrors `packages/kaos/test/shell-path-bridge.test.ts` against the vendored + * copy — same injected `execFileSync` / `isFile` fakes, so the suite runs + * identically on macOS/Linux/Windows CI runners. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + createShellPathBridge, + type ShellPathBridgeDeps, + type ShellPathBridgeEnv, +} from '#/_base/execEnv/shellPathBridge'; + +const WINDOWS_ENV: ShellPathBridgeEnv = { + osKind: 'Windows', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', +}; + +const POSIX_ENV: ShellPathBridgeEnv = { + osKind: 'Linux', + shellName: 'bash', + shellPath: '/bin/bash', +}; + +const BIN_CYGPATH = 'C:\\Program Files\\Git\\bin\\cygpath.exe'; +const USR_BIN_CYGPATH = 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe'; + +interface StubOpts { + readonly existingPaths?: readonly string[]; + readonly execFileResults?: Readonly>; + readonly execFileSync?: ShellPathBridgeDeps['execFileSync']; +} + +function stubDeps(opts: StubOpts = {}) { + const existing = new Set(opts.existingPaths ?? []); + const execFileSync = vi.fn( + opts.execFileSync ?? + ((file: string, args: readonly string[]): string => { + const result = opts.execFileResults?.[[file, ...args].join(' ')]; + if (result === undefined) throw new Error(`unexpected execFileSync: ${file}`); + return result; + }), + ); + const deps: ShellPathBridgeDeps = { + execFileSync, + isFile: (path: string) => existing.has(path), + }; + return { deps, execFileSync }; +} + +function cygpathKey(firstSegment: string): string { + return `${USR_BIN_CYGPATH} -w -C UTF8 -- /${firstSegment}`; +} + +describe('fromShellPath lexical drive forms', () => { + const cases: ReadonlyArray = [ + ['/c:/Users/foo', 'C:/Users/foo'], + ['/c:', 'C:/'], + ['/cygdrive/c/Users/foo', 'C:/Users/foo'], + ['/cygdrive/d', 'D:/'], + ['/c/Users/foo', 'C:/Users/foo'], + ['/C/Users/foo', 'C:/Users/foo'], + ['/c/', 'C:/'], + ['/c', 'C:/'], + ]; + + for (const [input, expected] of cases) { + it(`rewrites "${input}"`, () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(expected); + expect(execFileSync).not.toHaveBeenCalled(); + }); + } +}); + +describe('fromShellPath pass-through', () => { + it.each(['/dev/null', '/dev/pty0', '/proc/self/status', '/sys/kernel'])( + 'leaves virtual-fs path %s unchanged', + (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(input); + expect(execFileSync).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '/', + '//server/share', + '//server/share/file.txt', + 'relative/path', + 'relative\\path', + 'file.txt', + 'C:\\Users\\foo', + 'C:/Users/foo', + '~/Documents', + ])('leaves %s unchanged without consulting cygpath', (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(input); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('fromShellPath cygpath resolution', () => { + it('resolves a root-relative path through cygpath and caches per first segment', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { + [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\\\n', + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/scratch/a.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/scratch/a.txt', + ); + expect(bridge.fromShellPath('/tmp/other')).toBe('C:/Users/me/AppData/Local/Temp/other'); + expect(bridge.fromShellPath('/tmp')).toBe('C:/Users/me/AppData/Local/Temp'); + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync).toHaveBeenCalledWith(USR_BIN_CYGPATH, [ + '-w', + '-C', + 'UTF8', + '--', + '/tmp', + ]); + }); + + it('folds dot segments before resolving the mount segment', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { + [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\n', + [cygpathKey('home')]: 'C:\\Program Files\\Git\\home\n', + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/./tmp/note.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/note.txt', + ); + expect(bridge.fromShellPath('/../tmp/note.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/note.txt', + ); + expect(bridge.fromShellPath('/tmp/../home/x.txt')).toBe('C:/Program Files/Git/home/x.txt'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('folds dot segments before lexical drive translation', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/./c/Projects')).toBe('C:/Projects'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it.each(['/.', '/..'])('normalizes %s to / without consulting cygpath', (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath(input)).toBe('/'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('resolves a drive-root mount and keeps it absolute', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { [cygpathKey('work')]: 'D:\\\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/work/app')).toBe('D:/app'); + expect(bridge.fromShellPath('/work')).toBe('D:/'); + expect(execFileSync).toHaveBeenCalledTimes(1); + }); + + it('prefers cygpath.exe next to bash.exe when present', () => { + const key = `${BIN_CYGPATH} -w -C UTF8 -- /home`; + const { deps, execFileSync } = stubDeps({ + existingPaths: [BIN_CYGPATH, USR_BIN_CYGPATH], + execFileResults: { [key]: 'C:\\Users\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/home/u/f.txt')).toBe('C:/Users/u/f.txt'); + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync).toHaveBeenCalledWith(BIN_CYGPATH, ['-w', '-C', 'UTF8', '--', '/home']); + }); + + it('passes through and retries on the next access when cygpath fails', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileSync: () => { + throw new Error('cygpath exited 1'); + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('passes through and retries when cygpath output is not an absolute win32 path', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { [cygpathKey('tmp')]: 'not a win32 path\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('passes through without spawning when cygpath.exe is missing', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/home/u')).toBe('/home/u'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('identity outside win32 bash', () => { + it('is identity on posix', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(POSIX_ENV, deps); + expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('is identity on Windows without bash', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge( + { osKind: 'Windows', shellName: 'sh', shellPath: 'C:\\sh.exe' }, + deps, + ); + expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); + expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('toShellPath', () => { + it.each([ + ['C:\\Users\\foo', '/c/Users/foo'], + ['C:/Users/foo', '/c/Users/foo'], + ['C:\\', '/c/'], + ['D:\\Projects', '/d/Projects'], + ['\\\\server\\share\\dir', '//server/share/dir'], + ['relative\\path', 'relative/path'], + ['already/posix', 'already/posix'], + ])('maps %s → %s', (input, expected) => { + const { deps } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.toShellPath(input)).toBe(expected); + }); +}); diff --git a/packages/agent-core-v2/test/tool/path-access.test.ts b/packages/agent-core-v2/test/tool/path-access.test.ts index 9bc6ed5d9a..f92d07dc34 100644 --- a/packages/agent-core-v2/test/tool/path-access.test.ts +++ b/packages/agent-core-v2/test/tool/path-access.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { extendWorkspaceWithSkillRoots, isSensitiveFile } from '#/tool/path-access'; +import type { ShellPathBridge } from '#/_base/execEnv/shellPathBridge'; +import { + DEFAULT_WORKSPACE_ACCESS_POLICY, + extendWorkspaceWithSkillRoots, + isSensitiveFile, + resolvePathAccess, + resolvePathAccessPath, +} from '#/tool/path-access'; describe('isSensitiveFile', () => { it('flags base .env files in any directory', () => { @@ -102,3 +109,50 @@ describe('extendWorkspaceWithSkillRoots', () => { ).toEqual([]); }); }); + +describe('resolvePathAccess shell path bridge', () => { + const WIN_ENV = { + pathClass: 'win32' as const, + homeDir: 'C:\\Users\\test', + osKind: 'Windows', + shellName: 'bash' as const, + shellPath: 'C:\\kimi-test-nonexistent\\Git\\bin\\bash.exe', + }; + + it('routes win32 file-tool paths through the shell path bridge', () => { + const result = resolvePathAccessPath('/c/workspace/file.txt', { + env: WIN_ENV, + workspace: { workspaceDir: 'C:\\workspace', additionalDirs: [] }, + operation: 'read', + }); + expect(result).toBe('C:/workspace/file.txt'); + }); + + it('passes root-relative POSIX paths through when cygpath is unavailable', () => { + const result = resolvePathAccessPath('/tmp/scratch.txt', { + env: WIN_ENV, + workspace: { workspaceDir: 'C:\\workspace', additionalDirs: [] }, + operation: 'read', + }); + expect(result).toBe('/tmp/scratch.txt'); + }); + + it('normalizes through an explicitly injected shell path bridge', () => { + const bridge: ShellPathBridge = { + toShellPath: (p) => p, + fromShellPath: (p) => (p.startsWith('/tmp/') ? `C:/Temp/${p.slice('/tmp/'.length)}` : p), + }; + const result = resolvePathAccess( + '/tmp/notes.txt', + 'C:\\workspace', + { workspaceDir: 'C:\\workspace', additionalDirs: [] }, + { + operation: 'read', + pathClass: 'win32', + policy: DEFAULT_WORKSPACE_ACCESS_POLICY, + shellPathBridge: bridge, + }, + ); + expect(result).toEqual({ path: 'C:/Temp/notes.txt', outsideWorkspace: true }); + }); +}); diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 020dd597dc..ebcd41db06 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -22,7 +22,7 @@ * foreground runs pass a callback to collect chunks for this call. */ -import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; +import { getShellPathBridge, type Kaos, type KaosProcess } from '@moonshot-ai/kaos'; import { z } from 'zod'; import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; @@ -271,7 +271,7 @@ export class BashTool implements BuiltinTool { } private spawn(effectiveCwd: string, command: string): Promise { - const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; + const shellCwd = getShellPathBridge(this.kaos.osEnv).toShellPath(effectiveCwd); const shellArgs = [ this.kaos.osEnv.shellPath, '-c', @@ -607,21 +607,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 { diff --git a/packages/agent-core/src/tools/policies/path-access.ts b/packages/agent-core/src/tools/policies/path-access.ts index d930cb8100..7e1d3d4078 100644 --- a/packages/agent-core/src/tools/policies/path-access.ts +++ b/packages/agent-core/src/tools/policies/path-access.ts @@ -14,7 +14,12 @@ import * as pathe from 'pathe'; -import type { Kaos } from '@moonshot-ai/kaos'; +import { + getShellPathBridge, + translateShellDrivePath, + type Kaos, + type ShellPathBridge, +} from '@moonshot-ai/kaos'; import type { WorkspaceConfig } from '../support/workspace'; import { isSensitiveFile } from './sensitive'; @@ -60,31 +65,7 @@ function isWin32DriveRelative(path: string): boolean { } export function normalizeUserPath(path: string, pathClass: PathClass = DEFAULT_PATH_CLASS): string { - if (pathClass !== 'win32') return path; - - // A bare root slash stays forward so downstream pathe operations - // treat it consistently. Matches the py helper's behavior. - 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 { @@ -175,10 +156,15 @@ export interface ResolvePathAccessOptions { readonly policy?: WorkspaceAccessPolicy | undefined; readonly pathClass?: PathClass | undefined; readonly homeDir?: string; + /** + * Shell path bridge used to normalize model-supplied paths on win32 bash; + * without it paths go through the lexical-only {@link normalizeUserPath}. + */ + readonly shellPathBridge?: ShellPathBridge; } export interface ResolvePathAccessPathOptions { - readonly kaos: Pick; + readonly kaos: Pick; readonly workspace: WorkspaceConfig; readonly operation: PathAccessOperation; readonly policy?: WorkspaceAccessPolicy; @@ -205,7 +191,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); @@ -247,11 +234,15 @@ export function resolvePathAccessPath( options: ResolvePathAccessPathOptions, ): string { const { kaos, workspace, operation, policy, expandHome = true } = options; + const pathClass = kaos.pathClass(); return resolvePathAccess(path, workspace.workspaceDir, workspace, { operation, policy, - pathClass: kaos.pathClass(), + pathClass, homeDir: expandHome ? kaos.gethome() : undefined, + // Only win32 needs the bridge (identity on posix), and `osEnv` is + // unavailable in probing-free environments (SSH throws). + shellPathBridge: pathClass === 'win32' ? getShellPathBridge(kaos.osEnv) : undefined, }).path; } diff --git a/packages/agent-core/test/tools/path-guard.test.ts b/packages/agent-core/test/tools/path-guard.test.ts index 44cd3b8098..838a6aa7e5 100644 --- a/packages/agent-core/test/tools/path-guard.test.ts +++ b/packages/agent-core/test/tools/path-guard.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import type { Environment, ShellPathBridge } from '@moonshot-ai/kaos'; + import { canonicalizePath, DEFAULT_WORKSPACE_ACCESS_POLICY, @@ -27,6 +29,26 @@ const WIN_WORKSPACE: WorkspaceConfig = { const POSIX_KAOS = { pathClass: () => 'posix' as const, gethome: () => '/home/test', + osEnv: { + osKind: 'Linux', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + shellPath: '/bin/bash', + } satisfies Environment, +}; + +const WIN_KAOS = { + pathClass: () => 'win32' as const, + gethome: () => 'C:\\Users\\test', + osEnv: { + osKind: 'Windows', + osArch: 'x86_64', + osVersion: 'test', + shellName: 'bash', + // Deliberately nonexistent so no cygpath.exe is ever found — resolution degrades to pass-through. + shellPath: 'C:\\kimi-test-nonexistent\\Git\\bin\\bash.exe', + } satisfies Environment, }; describe('path access policy', () => { @@ -137,6 +159,38 @@ describe('path access policy', () => { ).toBe('/workspace/~/notes/today.txt'); }); + it('routes win32 file-tool paths through the shell path bridge', () => { + const result = resolvePathAccessPath('/c/workspace/file.txt', { + kaos: WIN_KAOS, + workspace: { workspaceDir: 'C:\\workspace', additionalDirs: [] }, + operation: 'read', + }); + expect(result).toBe('C:/workspace/file.txt'); + }); + + it('passes root-relative POSIX paths through when cygpath is unavailable', () => { + const result = resolvePathAccessPath('/tmp/scratch.txt', { + kaos: WIN_KAOS, + workspace: { workspaceDir: 'C:\\workspace', additionalDirs: [] }, + operation: 'read', + }); + expect(result).toBe('/tmp/scratch.txt'); + }); + + it('normalizes through an explicitly injected shell path bridge', () => { + const bridge: ShellPathBridge = { + toShellPath: (p) => p, + fromShellPath: (p) => (p.startsWith('/tmp/') ? `C:/Temp/${p.slice('/tmp/'.length)}` : p), + }; + const result = resolvePathAccess('/tmp/notes.txt', 'C:\\workspace', WIN_WORKSPACE, { + operation: 'read', + pathClass: 'win32', + policy: DEFAULT_WORKSPACE_ACCESS_POLICY, + shellPathBridge: bridge, + }); + expect(result).toEqual({ path: 'C:/Temp/notes.txt', outsideWorkspace: true }); + }); + it('legacy assertPathAllowed allows absolute outside paths but rejects relative escapes', () => { expect( assertPathAllowed('/workspace-evil/secrets.txt', '/workspace', WORKSPACE, { @@ -383,6 +437,7 @@ describe('path access policy', () => { const cases: ReadonlyArray = [ ['/c/Users/foo', 'C:/Users/foo'], ['/d/Projects/kimi', 'D:/Projects/kimi'], + ['/c:/Users/foo', 'C:/Users/foo'], ['/C/Users/foo', 'C:/Users/foo'], ['/c/', 'C:/'], ['/c', 'C:/'], diff --git a/packages/kaos/src/index.ts b/packages/kaos/src/index.ts index f69286cf58..c27584ebde 100644 --- a/packages/kaos/src/index.ts +++ b/packages/kaos/src/index.ts @@ -8,6 +8,12 @@ export type { ShellName, } from './environment'; export { detectEnvironment, detectEnvironmentFromNode } from './environment'; +export type { + ShellPathBridge, + ShellPathBridgeDeps, + ShellPathBridgeEnv, +} from './shell-path-bridge'; +export { createShellPathBridge, getShellPathBridge, translateShellDrivePath } from './shell-path-bridge'; export { KaosError, KaosValueError, diff --git a/packages/kaos/src/shell-path-bridge.ts b/packages/kaos/src/shell-path-bridge.ts new file mode 100644 index 0000000000..a7668a0424 --- /dev/null +++ b/packages/kaos/src/shell-path-bridge.ts @@ -0,0 +1,188 @@ +/** + * 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 + * `/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. + * + * Synchronous and self-contained (node builtins only): + * `createShellPathBridge` takes injectable deps for tests; + * `getShellPathBridge` bundles the Node defaults, memoised per env object. + */ + +import { execFileSync as nodeExecFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as nodePath from 'node:path'; + +import type { Environment } from './environment'; + +export interface ShellPathBridge { + /** Native win32 path → shell dialect, for building bash commands. Identity on posix. */ + toShellPath(nativePath: string): string; + /** Model/shell-supplied path → native, for fs access. Identity when not convertible. */ + fromShellPath(path: string): string; +} + +export type ShellPathBridgeEnv = Pick; + +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])(?:\/|$)/; + +// cygpath semantics are undefined for the virtual filesystems. +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}`; +} + +/** + * Lexical translation of shell-dialect drive paths (`/c/x`, `/c:/x`, + * `/cygdrive/c/x`) to native win32 form — pure string rewriting, no cygpath + * involved. Anything else is returned unchanged. + */ +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'; + + // Lazily located on first use; `null` = not found → permanent pass-through. + let cygpathExe: string | null | undefined; + // Cache successes only: a missing cygpath.exe is a stable fact, but an + // execution failure may be transient. First-segment caching is exact for + // default (first-level) mount tables; deeper user mounts are out of scope. + const segmentCache = new Map(); + + 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}`]); + // cygpath appends a newline and may emit a trailing separator (`D:\`). + 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; + + // Keep UNC out first: posix.normalize would collapse the leading `//`. + if (path.startsWith('//')) return path; + + if (path.startsWith('/')) { + // Fold dot segments first: `/tmp/..` is `/` in the shell VFS, not `%TEMP%\..`. + 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('\\', '/'); + // A mounted drive root resolved from a bare segment (`D:`) stays absolute. + 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(); + +/** + * Production convenience — Node's ambient `execFileSync` / `existsSync`, + * memoised per env object (osEnv snapshots are process-lifetime memoised + * already, so the same object identity recurs at every call site). + */ +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; +} diff --git a/packages/kaos/test/shell-path-bridge.test.ts b/packages/kaos/test/shell-path-bridge.test.ts new file mode 100644 index 0000000000..d1ae756dde --- /dev/null +++ b/packages/kaos/test/shell-path-bridge.test.ts @@ -0,0 +1,267 @@ +/** + * Shell path bridge — drives `createShellPathBridge` with injected + * `execFileSync` / `isFile` fakes (no real processes): lexical drive forms, + * pass-through tiers, cygpath resolution and caching, `toShellPath`. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import { + createShellPathBridge, + type ShellPathBridgeDeps, + type ShellPathBridgeEnv, +} from '#/shell-path-bridge'; + +const WINDOWS_ENV: ShellPathBridgeEnv = { + osKind: 'Windows', + shellName: 'bash', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', +}; + +const POSIX_ENV: ShellPathBridgeEnv = { + osKind: 'Linux', + shellName: 'bash', + shellPath: '/bin/bash', +}; + +// cygpath.exe candidates probed for `C:\Program Files\Git\bin\bash.exe`. +const BIN_CYGPATH = 'C:\\Program Files\\Git\\bin\\cygpath.exe'; +const USR_BIN_CYGPATH = 'C:\\Program Files\\Git\\usr\\bin\\cygpath.exe'; + +interface StubOpts { + readonly existingPaths?: readonly string[]; + readonly execFileResults?: Readonly>; + readonly execFileSync?: ShellPathBridgeDeps['execFileSync']; +} + +function stubDeps(opts: StubOpts = {}) { + const existing = new Set(opts.existingPaths ?? []); + const execFileSync = vi.fn( + opts.execFileSync ?? + ((file: string, args: readonly string[]): string => { + const result = opts.execFileResults?.[[file, ...args].join(' ')]; + if (result === undefined) throw new Error(`unexpected execFileSync: ${file}`); + return result; + }), + ); + const deps: ShellPathBridgeDeps = { + execFileSync, + isFile: (path: string) => existing.has(path), + }; + return { deps, execFileSync }; +} + +function cygpathKey(firstSegment: string): string { + return `${USR_BIN_CYGPATH} -w -C UTF8 -- /${firstSegment}`; +} + +describe('fromShellPath lexical drive forms', () => { + const cases: ReadonlyArray = [ + ['/c:/Users/foo', 'C:/Users/foo'], + ['/c:', 'C:/'], + ['/cygdrive/c/Users/foo', 'C:/Users/foo'], + ['/cygdrive/d', 'D:/'], + ['/c/Users/foo', 'C:/Users/foo'], + ['/C/Users/foo', 'C:/Users/foo'], + ['/c/', 'C:/'], + ['/c', 'C:/'], + ]; + + for (const [input, expected] of cases) { + it(`rewrites "${input}"`, () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(expected); + expect(execFileSync).not.toHaveBeenCalled(); + }); + } +}); + +describe('fromShellPath pass-through', () => { + it.each(['/dev/null', '/dev/pty0', '/proc/self/status', '/sys/kernel'])( + 'leaves virtual-fs path %s unchanged', + (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(input); + expect(execFileSync).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '/', + '//server/share', + '//server/share/file.txt', + 'relative/path', + 'relative\\path', + 'file.txt', + 'C:\\Users\\foo', + 'C:/Users/foo', + '~/Documents', + ])('leaves %s unchanged without consulting cygpath', (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.fromShellPath(input)).toBe(input); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('fromShellPath cygpath resolution', () => { + it('resolves a root-relative path through cygpath and caches per first segment', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { + [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\\\n', + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/scratch/a.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/scratch/a.txt', + ); + expect(bridge.fromShellPath('/tmp/other')).toBe('C:/Users/me/AppData/Local/Temp/other'); + expect(bridge.fromShellPath('/tmp')).toBe('C:/Users/me/AppData/Local/Temp'); + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync).toHaveBeenCalledWith(USR_BIN_CYGPATH, [ + '-w', + '-C', + 'UTF8', + '--', + '/tmp', + ]); + }); + + it('folds dot segments before resolving the mount segment', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { + [cygpathKey('tmp')]: 'C:\\Users\\me\\AppData\\Local\\Temp\n', + [cygpathKey('home')]: 'C:\\Program Files\\Git\\home\n', + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/./tmp/note.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/note.txt', + ); + expect(bridge.fromShellPath('/../tmp/note.txt')).toBe( + 'C:/Users/me/AppData/Local/Temp/note.txt', + ); + expect(bridge.fromShellPath('/tmp/../home/x.txt')).toBe('C:/Program Files/Git/home/x.txt'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('folds dot segments before lexical drive translation', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/./c/Projects')).toBe('C:/Projects'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it.each(['/.', '/..'])('normalizes %s to / without consulting cygpath', (input) => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath(input)).toBe('/'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('resolves a drive-root mount and keeps it absolute', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { [cygpathKey('work')]: 'D:\\\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/work/app')).toBe('D:/app'); + expect(bridge.fromShellPath('/work')).toBe('D:/'); + expect(execFileSync).toHaveBeenCalledTimes(1); + }); + + it('prefers cygpath.exe next to bash.exe when present', () => { + const key = `${BIN_CYGPATH} -w -C UTF8 -- /home`; + const { deps, execFileSync } = stubDeps({ + existingPaths: [BIN_CYGPATH, USR_BIN_CYGPATH], + execFileResults: { [key]: 'C:\\Users\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/home/u/f.txt')).toBe('C:/Users/u/f.txt'); + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync).toHaveBeenCalledWith(BIN_CYGPATH, ['-w', '-C', 'UTF8', '--', '/home']); + }); + + it('passes through and retries on the next access when cygpath fails', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileSync: () => { + throw new Error('cygpath exited 1'); + }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('passes through and retries when cygpath output is not an absolute win32 path', () => { + const { deps, execFileSync } = stubDeps({ + existingPaths: [USR_BIN_CYGPATH], + execFileResults: { [cygpathKey('tmp')]: 'not a win32 path\n' }, + }); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/tmp/y')).toBe('/tmp/y'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('passes through without spawning when cygpath.exe is missing', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.fromShellPath('/home/u')).toBe('/home/u'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('identity outside win32 bash', () => { + it('is identity on posix', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge(POSIX_ENV, deps); + expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); + expect(bridge.fromShellPath('/tmp/x')).toBe('/tmp/x'); + expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('is identity on Windows without bash', () => { + const { deps, execFileSync } = stubDeps(); + const bridge = createShellPathBridge( + { osKind: 'Windows', shellName: 'sh', shellPath: 'C:\\sh.exe' }, + deps, + ); + expect(bridge.fromShellPath('/c/Users/foo')).toBe('/c/Users/foo'); + expect(bridge.toShellPath('C:\\Users\\foo')).toBe('C:\\Users\\foo'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('toShellPath', () => { + it.each([ + ['C:\\Users\\foo', '/c/Users/foo'], + ['C:/Users/foo', '/c/Users/foo'], + ['C:\\', '/c/'], + ['D:\\Projects', '/d/Projects'], + ['\\\\server\\share\\dir', '//server/share/dir'], + ['relative\\path', 'relative/path'], + ['already/posix', 'already/posix'], + ])('maps %s → %s', (input, expected) => { + const { deps } = stubDeps(); + const bridge = createShellPathBridge(WINDOWS_ENV, deps); + expect(bridge.toShellPath(input)).toBe(expected); + }); +});