diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index f0930b0d39e..e4285f572a3 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -2,10 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const wslMocks = vi.hoisted(() => ({ filterPathsToRunningWslDistrosAsync: vi.fn(), - listRunningWslHomeDirsAsync: vi.fn() + listRunningWslHomeDirsAsync: vi.fn(), + resolveWslGuestRealDirAsync: vi.fn() })) -vi.mock('../wsl', () => ({ listRunningWslHomeDirsAsync: wslMocks.listRunningWslHomeDirsAsync })) +vi.mock('../wsl', () => ({ + listRunningWslHomeDirsAsync: wslMocks.listRunningWslHomeDirsAsync, + resolveWslGuestRealDirAsync: wslMocks.resolveWslGuestRealDirAsync +})) vi.mock('../wsl-running-path-filter', () => ({ filterPathsToRunningWslDistrosAsync: wslMocks.filterPathsToRunningWslDistrosAsync })) @@ -33,6 +37,7 @@ beforeEach(() => { .mockReset() .mockImplementation(async (paths: readonly string[]) => [...paths]) wslMocks.listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) + wslMocks.resolveWslGuestRealDirAsync.mockReset().mockResolvedValue(null) }) describe('isGuestAbsoluteLinuxPath', () => { @@ -307,6 +312,141 @@ describe('toHostReadableTranscriptPath', () => { }) }) +describe('toHostReadableTranscriptPath through guest symlinks', () => { + // Orca's Claude profile links `/home/projects` to `~/.claude/projects`; Windows cannot follow it. + const PROFILE_DIR = '/home/ada/.local/share/orca/claude-profiles/acct/home/projects/-home-ada-app' + const REAL_DIR = '/home/ada/.claude/projects/-home-ada-app' + const LINKED_PATH = `${PROFILE_DIR}/sess.jsonl` + const REAL_UNC = + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.claude\\projects\\-home-ada-app\\sess.jsonl' + const resolveGuestRealDir = async (_distro: string, dir: string): Promise => + dir === PROFILE_DIR ? REAL_DIR : dir + + it('reads an attested transcript through a guest directory symlink', async () => { + await expect( + toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists: async (candidate) => candidate === REAL_UNC, + resolveGuestRealDir + }) + ).resolves.toBe(REAL_UNC) + }) + + it('reads an unattested transcript through a guest directory symlink', async () => { + await expect( + toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + pathExists: async (candidate) => candidate === REAL_UNC, + listWslHomeDirs: async () => [UBUNTU_HOME], + resolveGuestRealDir + }) + ).resolves.toBe(REAL_UNC) + }) + + it('does not ask the guest when the literal path is readable', async () => { + const resolver = vi.fn(resolveGuestRealDir) + await toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists: async () => true, + resolveGuestRealDir: resolver + }) + expect(resolver).not.toHaveBeenCalled() + }) + + it('resolves each directory once across resolve-poll ticks', async () => { + // A transcript that does not exist yet must not spawn wsl.exe on every poll. + const resolver = vi.fn(resolveGuestRealDir) + for (let tick = 0; tick < 5; tick += 1) { + await toHostReadableTranscriptPath(ROLLOUT_LINUX, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists: async () => false, + resolveGuestRealDir: resolver + }) + } + expect(resolver).toHaveBeenCalledTimes(1) + }) + + it('returns null when the guest cannot resolve the directory', async () => { + const pathExists = vi.fn(async () => false) + await expect( + toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists, + resolveGuestRealDir: async () => null + }) + ).resolves.toBeNull() + expect(pathExists).toHaveBeenCalledTimes(1) + }) + + it('shares one guest resolution between concurrent lookups of the same directory', async () => { + const resolver = vi.fn(resolveGuestRealDir) + const lookups = Array.from({ length: 3 }, () => + toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists: async (candidate) => candidate === REAL_UNC, + resolveGuestRealDir: resolver + }) + ) + await expect(Promise.all(lookups)).resolves.toEqual([REAL_UNC, REAL_UNC, REAL_UNC]) + expect(resolver).toHaveBeenCalledTimes(1) + }) + + it('follows a retargeted symlink within 30 seconds', async () => { + vi.useFakeTimers() + try { + const MOVED_UNC = REAL_UNC.replace('.claude', '.claude-moved') + let target = REAL_DIR + const call = (): Promise => + toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists: async (candidate) => candidate === REAL_UNC || candidate === MOVED_UNC, + resolveGuestRealDir: async () => target + }) + + await expect(call()).resolves.toBe(REAL_UNC) + target = REAL_DIR.replace('.claude', '.claude-moved') + vi.setSystemTime(Date.now() + 31_000) + await expect(call()).resolves.toBe(MOVED_UNC) + } finally { + vi.useRealTimers() + } + }) + + it('retries a directory the guest could not resolve only after a short delay', async () => { + // The project directory may not exist until Claude writes the first turn. + vi.useFakeTimers() + try { + const resolver = vi + .fn<(distro: string, dir: string) => Promise>() + .mockResolvedValueOnce(null) + .mockImplementation(resolveGuestRealDir) + const call = (): Promise => + toHostReadableTranscriptPath(LINKED_PATH, { + platform: 'win32', + wslDistro: 'Ubuntu', + pathExists: async (candidate) => candidate === REAL_UNC, + resolveGuestRealDir: resolver + }) + + await expect(call()).resolves.toBeNull() + await expect(call()).resolves.toBeNull() + expect(resolver).toHaveBeenCalledTimes(1) + + vi.setSystemTime(Date.now() + 31_000) + await expect(call()).resolves.toBe(REAL_UNC) + expect(resolver).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) + describe('wslAgentSessionsDirs', () => { it('returns nothing off Windows', async () => { await expect( diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 48e806e2616..d120a489a0b 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -1,12 +1,12 @@ import { existsSync } from 'node:fs' -import { isAbsolute } from 'node:path' +import { isAbsolute, posix } from 'node:path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { uniqueDirs, wslHomeSessionDirs, type SessionHomeAgent } from '../ai-vault/session-scanner-roots' -import { getWslHomeAsync, listRunningWslHomeDirsAsync } from '../wsl' +import { getWslHomeAsync, listRunningWslHomeDirsAsync, resolveWslGuestRealDirAsync } from '../wsl' import { filterPathsToRunningWslDistrosAsync, filterPathsToWslDistros @@ -60,6 +60,7 @@ export type HostReadableTranscriptPathDeps = { wslDistro?: string listWslHomeDirs?: () => Promise wslSnapshot?: WslTranscriptResolutionSnapshot + resolveGuestRealDir?: (distro: string, guestDir: string) => Promise } // Probe asynchronously: unreachable WSL 9P shares can stall the main thread. @@ -89,6 +90,10 @@ let cachedWslHomeDirs: string[] | null = null let cachedWslHomeDirsExpiresAt = 0 let inflightWslHomeDirs: Promise | null = null let getAdditionalCodexHomePaths: (() => readonly string[]) | undefined +// Share in-flight and recent answers so polling a missing transcript does not spawn wsl.exe every tick. +const guestRealDirs = new Map; expiresAt: number }>() +// Also bounds how long a retargeted symlink keeps resolving to its former directory. +const GUEST_REAL_DIR_TTL_MS = 30_000 export function configureHostReadableTranscriptPathSources(options: { getAdditionalCodexHomePaths?: () => readonly string[] @@ -133,6 +138,44 @@ export function resetHostReadableTranscriptPathCacheForTests(): void { cachedWslHomeDirsExpiresAt = 0 inflightWslHomeDirs = null getAdditionalCodexHomePaths = undefined + guestRealDirs.clear() +} + +/** UNC twin of a guest path whose directory is a symlink (e.g. a Claude profile's `projects`). */ +async function guestSymlinkTarget( + guestPath: string, + distro: string, + deps: HostReadableTranscriptPathDeps +): Promise { + const dir = posix.dirname(guestPath) + const key = `${distro}\0${dir}` + let entry = guestRealDirs.get(key) + if (!entry || Date.now() >= entry.expiresAt) { + const resolve = deps.resolveGuestRealDir ?? resolveWslGuestRealDirAsync + entry = { + realDir: resolve(distro, dir).catch(() => null), + expiresAt: Date.now() + GUEST_REAL_DIR_TTL_MS + } + guestRealDirs.set(key, entry) + } + const realDir = await entry.realDir + return !realDir || realDir === dir + ? null + : toWindowsWslPath(posix.join(realDir, posix.basename(guestPath)), distro) +} + +async function probeGuestPath( + guestPath: string, + distro: string, + pathExists: (path: string) => Promise, + deps: HostReadableTranscriptPathDeps +): Promise { + const uncPath = toWindowsWslPath(guestPath, distro) + if (await pathExists(uncPath)) { + return uncPath + } + const linkedPath = await guestSymlinkTarget(guestPath, distro, deps) + return linkedPath && (await pathExists(linkedPath)) ? linkedPath : null } /** Translate WSL guest paths to host-readable UNC paths, preferring homes matching the guest path. */ @@ -167,6 +210,9 @@ export async function toHostReadableTranscriptPath( ) { return null } + if (candidate !== path) { + return probeGuestPath(path, exactWslDistro, pathExists, deps) + } return (await pathExists(candidate)) ? candidate : null } // Classify before probing: Win32 resolves /home on the current drive and may find a local look-alike. @@ -190,10 +236,10 @@ export async function toHostReadableTranscriptPath( // Probe ranked distros sequentially to avoid unnecessary 9P calls into stopped distros. let unavailable: WslTranscriptFsError | undefined for (const distro of rankDistrosForGuestPath(homeDirs, path)) { - const uncPath = toWindowsWslPath(path, distro) try { - if (await pathExists(uncPath)) { - return uncPath + const hostPath = await probeGuestPath(path, distro, pathExists, deps) + if (hostPath) { + return hostPath } } catch (error) { unavailable = wslTranscriptFsRefusal(error) diff --git a/src/main/native-chat/session-file-resolver-wsl.test.ts b/src/main/native-chat/session-file-resolver-wsl.test.ts index ea72fcfad20..fc19fb7f414 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -16,7 +16,8 @@ vi.mock('../wsl', () => ({ UBUNTU_HOME, UBUNTU_HOME.replace('Ubuntu', 'Debian') ]), - getWslHomeAsync: vi.fn(async (distro: string) => UBUNTU_HOME.replace('Ubuntu', distro)) + getWslHomeAsync: vi.fn(async (distro: string) => UBUNTU_HOME.replace('Ubuntu', distro)), + resolveWslGuestRealDirAsync: vi.fn(async (_distro: string, dir: string) => dir) })) // Accept only exact UNC fixtures so wrong-distro resolutions fail; native paths use real filesystem checks. diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 87efad75e09..2e5b7124b6b 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -269,6 +269,22 @@ export async function getWslHomeAsync(distro: string): Promise { return probe } +/** Canonical guest path of a directory; Windows cannot follow guest symlinks over 9P. */ +export async function resolveWslGuestRealDirAsync( + distro: string, + guestDir: string +): Promise { + try { + // -m: a session's folder may not exist yet, but the profile symlink above it does. + const realDir = ( + await runWslExe(buildWslExecArgs(distro, ['realpath', '-m', '--', guestDir])) + ).trim() + return realDir.startsWith('/') ? realDir : null + } catch { + return null + } +} + /** UNC home roots for distros that are running at discovery time. */ export async function listRunningWslHomeDirsAsync(): Promise { const homes = await Promise.all(