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 40988b2dd40..27a05f09e9f 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { isGuestAbsoluteLinuxPath, + isTranscriptPathCompatibleWithHost, needsWslHostTranslation, resetHostReadableTranscriptPathCacheForTests, toHostReadableTranscriptPath, @@ -44,6 +45,28 @@ describe('needsWslHostTranslation', () => { }) }) +describe('isTranscriptPathCompatibleWithHost', () => { + it('constrains exact Windows paths to their authoritative host', () => { + expect( + isTranscriptPathCompatibleWithHost(ROLLOUT_UNC, { kind: 'wsl', distro: 'Ubuntu' }, 'win32') + ).toBe(true) + expect( + isTranscriptPathCompatibleWithHost(ROLLOUT_UNC, { kind: 'wsl', distro: 'Debian' }, 'win32') + ).toBe(false) + expect(isTranscriptPathCompatibleWithHost(ROLLOUT_UNC, { kind: 'host' }, 'win32')).toBe(false) + expect( + isTranscriptPathCompatibleWithHost('C:\\Users\\ada\\x.jsonl', { kind: 'host' }, 'win32') + ).toBe(true) + expect( + isTranscriptPathCompatibleWithHost( + 'C:\\Users\\ada\\x.jsonl', + { kind: 'wsl', distro: 'Ubuntu' }, + 'win32' + ) + ).toBe(false) + }) +}) + describe('toHostReadableTranscriptPath', () => { it('translates a WSL guest path to its UNC twin on Windows (#10326)', 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 03346de0e72..f6cd7ac3a34 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -31,6 +31,27 @@ export function needsWslHostTranslation( return platform === 'win32' && isGuestAbsoluteLinuxPath(path.trim()) } +export function isTranscriptPathCompatibleWithHost( + path: string, + transcriptHost: NativeChatTranscriptHost, + platform: NodeJS.Platform = process.platform +): boolean { + if (platform !== 'win32') { + return true + } + const unc = parseWslUncPath(path) + if (transcriptHost.kind === 'host') { + return !unc && !needsWslHostTranslation(path, platform) + } + const distro = transcriptHost.distro.trim() + return ( + distro.length > 0 && + (unc + ? unc.distro.toLowerCase() === distro.toLowerCase() + : needsWslHostTranslation(path, platform)) + ) +} + export type HostReadableTranscriptPathDeps = { platform?: NodeJS.Platform pathExists?: (path: string) => Promise @@ -135,19 +156,13 @@ export async function toHostReadableTranscriptPath( deps.pathExists ?? ((candidate: string) => pathExistsAsync(candidate, deps.signal)) const platform = deps.platform ?? process.platform if (platform === 'win32' && deps.transcriptHost) { + if (!isTranscriptPathCompatibleWithHost(path, deps.transcriptHost, platform)) { + return null + } const unc = parseWslUncPath(path) if (unc) { - if ( - deps.transcriptHost.kind === 'host' || - unc.distro.toLowerCase() !== deps.transcriptHost.distro.trim().toLowerCase() - ) { - return null - } return (await pathExists(path)) ? path : null } - if (deps.transcriptHost.kind === 'wsl' && !needsWslHostTranslation(path, platform)) { - return null - } } // Why: classify BEFORE probing — Win32 resolves a bare `/home/…` against the // current drive (`C:\home\…`), so a probe first could bind chat to a local diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts index 92044d6a59f..c2244f3106d 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -117,6 +117,76 @@ describe('native chat transcript resolve polling', () => { subscription.unsubscribe() }) + it.each([ + [ + 'host provenance on WSL UNC', + { kind: 'host' as const }, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\session.jsonl' + ], + [ + 'wrong WSL distro provenance', + { kind: 'wsl' as const, distro: 'Debian' }, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\session.jsonl' + ], + [ + 'WSL provenance on a host drive', + { kind: 'wsl' as const, distro: 'Ubuntu' }, + 'C:\\Users\\ada\\session.jsonl' + ], + [ + 'host provenance on extended WSL UNC', + { kind: 'host' as const }, + '\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\session.jsonl' + ] + ])('does not fast-install %s after an initial miss', async (_name, transcriptHost, path) => { + setPlatform('win32') + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: path, + transcriptHost, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).not.toHaveBeenCalled() + subscription.unsubscribe() + }) + + it('preserves raw exact-path retries when provenance is unavailable', async () => { + setPlatform('win32') + const unc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\session.jsonl' + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: unc, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).toHaveBeenCalledWith(unc, expect.any(Function), expect.any(Object)) + subscription.unsubscribe() + }) + + it('fast-installs an exact path that matches WSL provenance', async () => { + setPlatform('win32') + const unc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\session.jsonl' + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + transcriptPath: unc, + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }, + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + + await vi.advanceTimersByTimeAsync(100) + expect(mocks.install).toHaveBeenCalledWith(unc, expect.any(Function), expect.any(Object)) + subscription.unsubscribe() + }) + it('keeps resolving on every retry when no exact hook path is available', async () => { const subscription = await subscribeNativeChatTranscript({ agent: 'claude', diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 2ac0597969f..f8abd5f03a1 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,6 +1,7 @@ import { extname } from 'node:path' import type { NativeChatMessage } from '../../shared/native-chat-types' import { + isTranscriptPathCompatibleWithHost, needsWslHostTranslation, toHostReadableTranscriptPath } from './host-readable-transcript-path' @@ -111,9 +112,14 @@ function subscribeViaResolvePoll( try { if (exactPath && !hostReadableExactPath) { if (!needsWslHostTranslation(exactPath)) { - // Non-WSL paths stay raw: installTranscriptWatcher already handles a - // not-yet-created file, so don't spend an extra probe per tick. - hostReadableExactPath = exactPath + // Why: live retries must enforce the same PTY provenance as reads + // before installing a watcher on an exact UNC or drive path. + if ( + !args.transcriptHost || + isTranscriptPathCompatibleWithHost(exactPath, args.transcriptHost) + ) { + hostReadableExactPath = exactPath + } } else if (Date.now() - lastWslTranslateAt >= FALLBACK_RESOLVE_POLL_MS) { // Why: translating probes the UNC twin per distro over the 9P // share, and the guest file usually appears well after the hook does,