fix(native-chat): read WSL transcripts through guest directory symlinks (#33172)

On Windows, WSL Claude transcripts reported through an Orca account-profile symlink (claude-profiles/<id>/home/projects → ~/.claude/projects) are found: when the Windows-visible path is missing, Orca resolves the folder's realpath inside the same running distro (realpath -m, cached 30s) and reads it from there.

Fixes #33165

Co-authored-by: Kooooojun <99903251+Kooooojun@users.noreply.github.com>
This commit is contained in:
Kooooojun
2026-10-25 11:00:38 -07:00
committed by GitHub
parent 82a3214049
commit 52c7f59fc7
4 changed files with 211 additions and 8 deletions
@@ -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 `<profile>/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<string | null> =>
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<string | null> =>
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<string | null>>()
.mockResolvedValueOnce(null)
.mockImplementation(resolveGuestRealDir)
const call = (): Promise<string | null> =>
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(
@@ -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<string[]>
wslSnapshot?: WslTranscriptResolutionSnapshot
resolveGuestRealDir?: (distro: string, guestDir: string) => Promise<string | null>
}
// 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<string[]> | 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<string, { realDir: Promise<string | null>; 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<string | null> {
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<boolean>,
deps: HostReadableTranscriptPathDeps
): Promise<string | null> {
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)
@@ -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.
+16
View File
@@ -269,6 +269,22 @@ export async function getWslHomeAsync(distro: string): Promise<string | null> {
return probe
}
/** Canonical guest path of a directory; Windows cannot follow guest symlinks over 9P. */
export async function resolveWslGuestRealDirAsync(
distro: string,
guestDir: string
): Promise<string | null> {
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<string[]> {
const homes = await Promise.all(