mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user