From 4d1b021f86abbd6d4a6ec1ddaeea79e844d301cb Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:22:19 -0700 Subject: [PATCH] fix(native-chat): route transcripts by PTY provenance --- .../native-chat-subscribe-lifecycle.test.ts | 43 +++- src/main/ipc/native-chat.test.ts | 38 +++- src/main/ipc/native-chat.ts | 52 ++++- src/main/ipc/register-core-handlers.ts | 4 +- .../host-readable-transcript-path.test.ts | 158 ++++++++++++++- .../host-readable-transcript-path.ts | 93 +++++++++ .../native-chat-provenance.oracle.test.ts | 65 ++++++ .../session-file-resolver-wsl.test.ts | 73 +++++++ src/main/native-chat/session-file-resolver.ts | 46 ++++- src/main/native-chat/transcript-host.ts | 1 + .../transcript-watch-resolve-poll.test.ts | 5 + src/main/native-chat/transcript-watch.ts | 3 +- src/main/runtime/orca-runtime.test.ts | 31 +++ src/main/runtime/orca-runtime.ts | 23 +++ .../rpc/methods/native-chat-rpc-payload.ts | 118 +++++++++++ .../runtime/rpc/methods/native-chat.test.ts | 74 ++++++- src/main/runtime/rpc/methods/native-chat.ts | 185 +++--------------- src/preload/api-types.ts | 5 +- src/preload/index.ts | 12 +- .../components/native-chat/NativeChatView.tsx | 1 + .../native-chat-session-transport.test.ts | 96 +++++++++ .../native-chat-session-transport.ts | 50 +++-- .../use-native-chat-live-session.test.ts | 35 ++++ .../use-native-chat-live-session.ts | 34 +++- .../use-native-chat-retained-session.ts | 3 +- src/renderer/src/web/web-preload-api.test.ts | 56 ++++++ src/renderer/src/web/web-preload-api.ts | 32 ++- src/shared/wsl-paths.test.ts | 4 + src/shared/wsl-paths.ts | 2 +- 29 files changed, 1136 insertions(+), 206 deletions(-) create mode 100644 src/main/native-chat/native-chat-provenance.oracle.test.ts create mode 100644 src/main/native-chat/transcript-host.ts create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-payload.ts diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 1c90227cfd2..59eaa5c3c21 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -100,12 +100,15 @@ function createSender(id: number): SenderHarness { } } -function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { +function subscribe(sender: SenderHarness['sender'], subscriptionId: string, ptyId?: string): void { const listener = listeners.get('nativeChat:subscribe') if (!listener) { throw new Error('subscribe listener not registered') } - listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) + listener( + { sender }, + { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}`, ptyId } + ) } type InitialSnapshotCallback = ( @@ -116,6 +119,42 @@ type InitialSnapshotCallback = ( lifecycle?: NativeChatTurnLifecycle ) => void +it('resolves PTY provenance before installing the desktop watcher', async () => { + clearNativeChatSubscriptions() + handlers.clear() + listeners.clear() + const resolveTranscriptHost = vi.fn(() => ({ kind: 'wsl' as const, distro: 'Ubuntu' })) + registerNativeChatHandlers({ resolveTranscriptHost }) + subscribeTranscript.mockResolvedValue({ unsubscribe: vi.fn(), watching: true }) + const sender = createSender(99) + + subscribe(sender.sender, 'pty-route', 'pty-1') + await vi.waitFor(() => expect(subscribeTranscript).toHaveBeenCalledOnce()) + + expect(resolveTranscriptHost).toHaveBeenCalledWith('pty-1') + expect(subscribeTranscript.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } }) + ) +}) + +it('fails closed without installing a watcher for a missing PTY identity', async () => { + clearNativeChatSubscriptions() + handlers.clear() + listeners.clear() + registerNativeChatHandlers({ resolveTranscriptHost: () => null }) + const sender = createSender(100) + + subscribe(sender.sender, 'missing-route', 'missing-pty') + await vi.waitFor(() => expect(sender.sender.send).toHaveBeenCalledOnce()) + + expect(subscribeTranscript).not.toHaveBeenCalled() + expect(sender.sender.send.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + frame: expect.objectContaining({ error: 'Transcript unavailable' }) + }) + ) +}) + // The onInitialSnapshot callback the handler passed into the Nth subscribeTranscript // call; transcript-watch fires it during setup, so tests invoke it directly. function initialSnapshot(callIndex: number): InitialSnapshotCallback { diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4d0e1765a44..ef2a4dd2c0b 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -54,13 +54,17 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { - registerNativeChatHandlers() +async function invokeReadSession( + args: { + agent: string + sessionId: string + limit?: number + transcriptPath?: string + ptyId?: string + }, + deps?: Parameters[0] +): Promise { + registerNativeChatHandlers(deps) const handler = handlers.get('nativeChat:readSession') if (!handler) { throw new Error('handler not registered') @@ -69,6 +73,26 @@ async function invokeReadSession(args: { } describe('nativeChat:readSession handler', () => { + it('asks the PTY owner for transcript provenance', async () => { + const resolveTranscriptHost = vi.fn(() => ({ kind: 'host' as const })) + + await invokeReadSession( + { agent: 'claude', sessionId: 'missing-session', ptyId: 'pty-1' }, + { resolveTranscriptHost } + ) + + expect(resolveTranscriptHost).toHaveBeenCalledWith('pty-1') + }) + + it('fails closed when a supplied PTY identity is missing', async () => { + const result = (await invokeReadSession( + { agent: 'claude', sessionId: 'same-id', ptyId: 'missing-pty' }, + { resolveTranscriptHost: () => null } + )) as { error?: string; notFound?: true } + + expect(result).toEqual({ error: 'Transcript unavailable' }) + }) + it('preserves notFound so a just-created session stays in retry/loading', async () => { const result = (await invokeReadSession({ agent: 'claude', diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 4ef0f48b872..d509a5208d4 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -12,6 +12,7 @@ import { type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../native-chat/transcript-watch' +import type { NativeChatTranscriptHost } from '../native-chat/transcript-host' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from // this module keep working after the cache moved to transcript-read-cache.ts. @@ -26,20 +27,41 @@ export type NativeChatReadSessionArgs = { /** Authoritative transcript path from the agent hook (providerSession), used to * locate the file when the session id no longer names it (recent Claude Code). */ transcriptPath?: string + /** PTY whose host owns this transcript. */ + ptyId?: string +} + +type NativeChatHandlerDeps = { + resolveTranscriptHost?: (ptyId: string) => NativeChatTranscriptHost | null | undefined +} + +function requestedTranscriptHost( + ptyId: string | undefined, + deps: NativeChatHandlerDeps +): NativeChatTranscriptHost | null | undefined { + return ptyId ? deps.resolveTranscriptHost?.(ptyId) : undefined } // Why: render and parse only the recent window so long transcripts do not stall // either the main process or the message list. Pagination raises this limit. const DESKTOP_READ_WINDOW = 300 -async function readSession(args: NativeChatReadSessionArgs): Promise { +async function readSession( + args: NativeChatReadSessionArgs, + deps: NativeChatHandlerDeps +): Promise { const { agent, sessionId } = args // Clamp to a positive window; default to the desktop window for the first page. const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW + const transcriptHost = requestedTranscriptHost(args.ptyId, deps) + if (transcriptHost === null) { + return { error: 'Transcript unavailable' } + } return readNativeChatTranscriptTail({ agent, sessionId, transcriptPath: args.transcriptPath, + transcriptHost, limit }) } @@ -52,6 +74,7 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + ptyId?: string limit?: number } @@ -165,7 +188,11 @@ function takePendingSubscription( return true } -async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { +async function handleSubscribe( + event: IpcMainEvent, + args: NativeChatSubscribeArgs, + deps: NativeChatHandlerDeps +): Promise { const sender = event.sender if (sender.isDestroyed()) { return @@ -175,11 +202,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg // Replace any prior subscription under the same id (session change/resubscribe). const pending = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) + const transcriptHost = requestedTranscriptHost(args.ptyId, deps) + if (transcriptHost === null) { + takePendingSubscription(sender.id, subscriptionId, pending) + sender.send('nativeChat:appended', { + subscriptionId, + frame: { + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + } + } satisfies NativeChatAppendedPayload) + return + } const subscribeArgs: SubscribeNativeChatTranscriptArgs = { agent, sessionId, transcriptPath, + transcriptHost, initialLimit: limit, onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => { if (sender.isDestroyed()) { @@ -287,12 +329,12 @@ export function _getNativeChatPendingSubscriptionCountForTest(): number { return count } -export function registerNativeChatHandlers(): void { +export function registerNativeChatHandlers(deps: NativeChatHandlerDeps = {}): void { ipcMain.handle('nativeChat:readSession', (_event, args: NativeChatReadSessionArgs) => - readSession(args) + readSession(args, deps) ) ipcMain.on('nativeChat:subscribe', (event, args: NativeChatSubscribeArgs) => { - void handleSubscribe(event, args) + void handleSubscribe(event, args, deps) }) ipcMain.on('nativeChat:unsubscribe', (event, args: { subscriptionId: string }) => { teardownSubscription(event.sender.id, args.subscriptionId) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7825e73c57e..64a191e2b90 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -227,7 +227,9 @@ export function registerCoreHandlers( prepareRuntimeSessionResume: async (environmentId, args) => prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) - registerNativeChatHandlers() + registerNativeChatHandlers({ + resolveTranscriptHost: (ptyId) => runtime.resolveNativeChatTranscriptHost(ptyId) + }) registerClipboardHandlers(store) registerUpdaterHandlers(store) registerSpeechHandlers(store) 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 4b1264911d4..40988b2dd40 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -6,7 +6,9 @@ import { resetHostReadableTranscriptPathCacheForTests, toHostReadableTranscriptPath, wslClaudeProjectsDirs, - wslCodexSessionsDirs + wslClaudeProjectsDirsForDistro, + wslCodexSessionsDirs, + wslCodexSessionsDirsForDistro } from './host-readable-transcript-path' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' @@ -53,6 +55,100 @@ describe('toHostReadableTranscriptPath', () => { ).resolves.toBe(ROLLOUT_UNC) }) + it('translates through only the authoritative WSL distro', async () => { + const listWslHomeDirs = vi.fn(async () => [DEBIAN_HOME]) + const pathExists = vi.fn(async (candidate: string) => candidate === ROLLOUT_UNC) + + await expect( + toHostReadableTranscriptPath(ROLLOUT_LINUX, { + platform: 'win32', + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }, + listWslHomeDirs, + pathExists + }) + ).resolves.toBe(ROLLOUT_UNC) + + expect(listWslHomeDirs).not.toHaveBeenCalled() + expect(pathExists).toHaveBeenCalledTimes(1) + expect(pathExists).toHaveBeenCalledWith(ROLLOUT_UNC) + }) + + it('does not reinterpret a guest path when the PTY belongs to the host', async () => { + const listWslHomeDirs = vi.fn(async () => [UBUNTU_HOME]) + const pathExists = vi.fn(async () => true) + + await expect( + toHostReadableTranscriptPath(ROLLOUT_LINUX, { + platform: 'win32', + transcriptHost: { kind: 'host' }, + listWslHomeDirs, + pathExists + }) + ).resolves.toBeNull() + + expect(listWslHomeDirs).not.toHaveBeenCalled() + expect(pathExists).not.toHaveBeenCalled() + }) + + it('rejects exact paths that contradict authoritative provenance', async () => { + const pathExists = vi.fn(async () => true) + + await expect( + toHostReadableTranscriptPath(ROLLOUT_UNC, { + platform: 'win32', + transcriptHost: { kind: 'host' }, + pathExists + }) + ).resolves.toBeNull() + await expect( + toHostReadableTranscriptPath(ROLLOUT_UNC, { + platform: 'win32', + transcriptHost: { kind: 'wsl', distro: 'Debian' }, + pathExists + }) + ).resolves.toBeNull() + await expect( + toHostReadableTranscriptPath('C:\\Users\\ada\\rollout.jsonl', { + platform: 'win32', + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }, + pathExists + }) + ).resolves.toBeNull() + + expect(pathExists).not.toHaveBeenCalled() + }) + + it('classifies extended WSL UNC paths before authoritative probing', async () => { + const extendedUnc = + '\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl' + const pathExists = vi.fn(async () => true) + + await expect( + toHostReadableTranscriptPath(extendedUnc, { + platform: 'win32', + transcriptHost: { kind: 'host' }, + pathExists + }) + ).resolves.toBeNull() + await expect( + toHostReadableTranscriptPath(extendedUnc, { + platform: 'win32', + transcriptHost: { kind: 'wsl', distro: 'Debian' }, + pathExists + }) + ).resolves.toBeNull() + await expect( + toHostReadableTranscriptPath(extendedUnc, { + platform: 'win32', + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }, + pathExists + }) + ).resolves.toBe(extendedUnc) + + expect(pathExists).toHaveBeenCalledOnce() + expect(pathExists).toHaveBeenCalledWith(extendedUnc) + }) + it('never probes the bare guest path on Windows', async () => { // Why: Win32 resolves `/home/...` against the current drive (`C:\home\...`), // so probing first could bind chat to a local look-alike file. @@ -177,6 +273,57 @@ describe('wslCodexSessionsDirs', () => { ).resolves.toEqual([]) }) + it('loads only the requested distro for targeted resolution', async () => { + const getWslHomeDir = vi.fn(async () => UBUNTU_HOME) + + await expect( + wslCodexSessionsDirsForDistro('Ubuntu', { platform: 'win32', getWslHomeDir }) + ).resolves.toEqual([ + `${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`, + `${UBUNTU_HOME}\\.codex\\sessions` + ]) + expect(getWslHomeDir).toHaveBeenCalledWith('Ubuntu') + }) + + it('coalesces concurrent targeted home probes across agents', async () => { + let finish!: (home: string | null) => void + const pending = new Promise((resolve) => (finish = resolve)) + const getWslHomeDir = vi.fn(() => pending) + + const codex = wslCodexSessionsDirsForDistro('Ubuntu', { + platform: 'win32', + getWslHomeDir + }) + const claude = wslClaudeProjectsDirsForDistro('ubuntu', { + platform: 'win32', + getWslHomeDir + }) + + expect(getWslHomeDir).toHaveBeenCalledTimes(1) + finish(UBUNTU_HOME) + await expect(codex).resolves.toHaveLength(2) + await expect(claude).resolves.toEqual([`${UBUNTU_HOME}\\.claude\\projects`]) + }) + + it('negatively caches a failed targeted home probe for the retry window', async () => { + vi.useFakeTimers() + try { + const getWslHomeDir = vi.fn(async () => null) + const load = (): Promise => + wslCodexSessionsDirsForDistro('Ubuntu', { platform: 'win32', getWslHomeDir }) + + await expect(load()).resolves.toEqual([]) + await expect(load()).resolves.toEqual([]) + expect(getWslHomeDir).toHaveBeenCalledTimes(1) + + vi.setSystemTime(Date.now() + 30_001) + await expect(load()).resolves.toEqual([]) + expect(getWslHomeDir).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it('lists the managed and system Codex roots per distro home', async () => { await expect( wslCodexSessionsDirs({ platform: 'win32', listWslHomeDirs: async () => [UBUNTU_HOME] }) @@ -202,4 +349,13 @@ describe('wslClaudeProjectsDirs', () => { }) ).resolves.toEqual([`${UBUNTU_HOME}\\.claude\\projects`, `${DEBIAN_HOME}\\.claude\\projects`]) }) + + it('loads only the requested distro for targeted resolution', async () => { + const getWslHomeDir = vi.fn(async () => DEBIAN_HOME) + + await expect( + wslClaudeProjectsDirsForDistro('Debian', { platform: 'win32', getWslHomeDir }) + ).resolves.toEqual([`${DEBIAN_HOME}\\.claude\\projects`]) + expect(getWslHomeDir).toHaveBeenCalledWith('Debian') + }) }) diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 45a49ac99da..03346de0e72 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -5,6 +5,7 @@ import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/ws import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate' +import type { NativeChatTranscriptHost } from './transcript-host' /** * True for guest-absolute Linux paths that Win32 cannot open as-is. @@ -34,8 +35,11 @@ export type HostReadableTranscriptPathDeps = { platform?: NodeJS.Platform pathExists?: (path: string) => Promise signal?: AbortSignal + /** PTY-owned execution host. Undefined preserves legacy discovery. */ + transcriptHost?: NativeChatTranscriptHost /** Each installed WSL distro's `$HOME` as a Windows UNC path. */ listWslHomeDirs?: () => Promise + getWslHomeDir?: (distro: string) => Promise } // Why: candidates are `\\wsl.localhost` UNC paths served over 9P. A sync probe @@ -69,6 +73,8 @@ const WSL_HOME_DIRS_TTL_MS = 5 * 60_000 let cachedWslHomeDirs: string[] | null = null let cachedWslHomeDirsExpiresAt = 0 let inflightWslHomeDirs: Promise | null = null +const cachedWslHomeByDistro = new Map() +const inflightWslHomeByDistro = new Map>() async function defaultListWslHomeDirs(): Promise { const homes = await Promise.all( @@ -100,6 +106,8 @@ export function resetHostReadableTranscriptPathCacheForTests(): void { cachedWslHomeDirs = null cachedWslHomeDirsExpiresAt = 0 inflightWslHomeDirs = null + cachedWslHomeByDistro.clear() + inflightWslHomeByDistro.clear() } /** @@ -126,6 +134,21 @@ export async function toHostReadableTranscriptPath( const pathExists = deps.pathExists ?? ((candidate: string) => pathExistsAsync(candidate, deps.signal)) const platform = deps.platform ?? process.platform + if (platform === 'win32' && deps.transcriptHost) { + 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 // look-alike file instead of the real WSL transcript. @@ -133,6 +156,18 @@ export async function toHostReadableTranscriptPath( return (await pathExists(path)) ? path : null } + if (deps.transcriptHost?.kind === 'host') { + return null + } + if (deps.transcriptHost?.kind === 'wsl') { + const distro = deps.transcriptHost.distro.trim() + if (!distro) { + return null + } + const uncPath = toWindowsWslPath(path, distro) + return (await pathExists(uncPath)) ? uncPath : null + } + const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs) // Sequential on purpose: the ranked order picks the owning distro, and probing // every distro at once would fan out 9P calls to ones the user left stopped. @@ -182,6 +217,20 @@ export async function wslCodexSessionsDirs( ]) } +/** Codex roots for one PTY-proven WSL distro, without enumerating others. */ +export async function wslCodexSessionsDirsForDistro( + distro: string, + deps: Pick = {} +): Promise { + const home = await wslHomeDirForDistro(distro, deps) + return home + ? [ + joinUnderWslHome(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS, 'sessions'), + joinUnderWslHome(home, '.codex', 'sessions') + ] + : [] +} + /** Each WSL distro's Claude projects root, searched after the host root misses. */ export async function wslClaudeProjectsDirs( deps: Pick = {} @@ -194,6 +243,50 @@ export async function wslClaudeProjectsDirs( return homeDirs.map((home) => joinUnderWslHome(home, '.claude', 'projects')) } +/** Claude projects root for one PTY-proven WSL distro. */ +export async function wslClaudeProjectsDirsForDistro( + distro: string, + deps: Pick = {} +): Promise { + const home = await wslHomeDirForDistro(distro, deps) + return home ? [joinUnderWslHome(home, '.claude', 'projects')] : [] +} + +async function wslHomeDirForDistro( + distro: string, + deps: Pick +): Promise { + if ((deps.platform ?? process.platform) !== 'win32') { + return null + } + const normalizedDistro = distro.trim() + if (!normalizedDistro) { + return null + } + const key = normalizedDistro.toLowerCase() + const cached = cachedWslHomeByDistro.get(key) + if (cached && Date.now() < cached.expiresAt) { + return cached.home + } + const inflight = inflightWslHomeByDistro.get(key) + if (inflight) { + return inflight + } + const load = deps.getWslHomeDir ?? getWslHomeAsync + const request = load(normalizedDistro) + .catch(() => null) + .then((home) => { + cachedWslHomeByDistro.set(key, { + home, + expiresAt: Date.now() + (home ? WSL_HOME_DIRS_TTL_MS : WSL_HOME_DIRS_EMPTY_RETRY_MS) + }) + return home + }) + .finally(() => inflightWslHomeByDistro.delete(key)) + inflightWslHomeByDistro.set(key, request) + return request +} + // Why: node:path.join is posix-flavoured off Windows and would mangle the // `\\wsl.localhost\` share prefix these roots must keep. function joinUnderWslHome(home: string, ...segments: string[]): string { diff --git a/src/main/native-chat/native-chat-provenance.oracle.test.ts b/src/main/native-chat/native-chat-provenance.oracle.test.ts new file mode 100644 index 00000000000..2767676a752 --- /dev/null +++ b/src/main/native-chat/native-chat-provenance.oracle.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { toHostReadableTranscriptPath } from './host-readable-transcript-path' + +const GUEST_PATH = '/home/ada/.codex/sessions/rollout.jsonl' +const DEBIAN_PATH = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\rollout.jsonl' +const UBUNTU_PATH = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl' +const HOMES = ['\\\\wsl.localhost\\Debian\\home\\ada', '\\\\wsl.localhost\\Ubuntu\\home\\ada'] + +describe('native-chat PTY provenance oracle', () => { + it('routes known WSL provenance directly to its owning distro', async () => { + const enumerate = vi.fn(async () => HOMES) + const probe = vi.fn(async () => true) + + await expect( + toHostReadableTranscriptPath(GUEST_PATH, { + platform: 'win32', + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }, + listWslHomeDirs: enumerate, + pathExists: probe + }) + ).resolves.toBe(UBUNTU_PATH) + expect(enumerate).not.toHaveBeenCalled() + expect(probe).toHaveBeenCalledWith(UBUNTU_PATH) + }) + + it('keeps host provenance out of WSL', async () => { + const enumerate = vi.fn(async () => HOMES) + const probe = vi.fn(async () => true) + + await expect( + toHostReadableTranscriptPath(GUEST_PATH, { + platform: 'win32', + transcriptHost: { kind: 'host' }, + listWslHomeDirs: enumerate, + pathExists: probe + }) + ).resolves.toBeNull() + expect(enumerate).not.toHaveBeenCalled() + expect(probe).not.toHaveBeenCalled() + }) + + it('preserves broad recovery only when provenance is unavailable', async () => { + await expect( + toHostReadableTranscriptPath(GUEST_PATH, { + platform: 'win32', + listWslHomeDirs: async () => HOMES, + pathExists: async () => true + }) + ).resolves.toBe(DEBIAN_PATH) + }) + + it('rejects a WSL device path for host provenance before probing', async () => { + const extended = '\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout.jsonl' + const probe = vi.fn(async () => true) + + await expect( + toHostReadableTranscriptPath(extended, { + platform: 'win32', + transcriptHost: { kind: 'host' }, + pathExists: probe + }) + ).resolves.toBeNull() + expect(probe).not.toHaveBeenCalled() + }) +}) 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 0e80d51e1ad..5a79f1798de 100644 --- a/src/main/native-chat/session-file-resolver-wsl.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl.test.ts @@ -92,6 +92,79 @@ afterEach(() => { }) describe('resolveSessionFilePath on a Windows host with WSL', () => { + it('routes an exact hook path through only the authoritative PTY distro', async () => { + await expect( + resolveSessionFilePath('codex', 'ignored', { + transcriptPath: ROLLOUT_LINUX, + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } + }) + ).resolves.toBe(ROLLOUT_UNC) + + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled() + }) + + it('does not search WSL when authoritative PTY provenance says host', async () => { + const options = { transcriptHost: { kind: 'host' as const } } + + await expect(resolveSessionFilePath('claude', 'missing', options)).resolves.toBeNull() + + expect(scanned.dirs).toHaveLength(1) + expect(scanned.dirs[0]).not.toMatch(/^\\\\wsl/) + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + expect(vi.mocked(getWslHomeAsync)).not.toHaveBeenCalled() + }) + + it('searches only the authoritative PTY distro for a WSL Claude session', async () => { + vi.mocked(listWslDistrosAsync).mockResolvedValueOnce(['Missing', 'Ubuntu']) + scanned.wslClaudeTranscriptDir = WSL_CLAUDE_PROJECTS_DIR + const options = { transcriptHost: { kind: 'wsl' as const, distro: 'Ubuntu' } } + + await expect(resolveSessionFilePath('claude', 'claude-wsl-sess', options)).resolves.toBe( + WSL_CLAUDE_TRANSCRIPT + ) + + expect(scanned.dirs).toEqual([WSL_CLAUDE_PROJECTS_DIR]) + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + expect(vi.mocked(getWslHomeAsync)).toHaveBeenCalledWith('Ubuntu') + }) + + it('applies authoritative PTY routing to Codex without broad discovery', async () => { + await expect( + resolveSessionFilePath('codex', 'missing', { + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } + }) + ).resolves.toBeNull() + + expect(scanned.dirs).toEqual([ + `${UBUNTU_HOME}\\.local\\share\\orca\\codex-runtime-home\\home\\sessions`, + `${UBUNTU_HOME}\\.codex\\sessions` + ]) + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + }) + + it('does not search WSL for a host-owned Codex miss', async () => { + await expect( + resolveSessionFilePath('codex', 'missing', { transcriptHost: { kind: 'host' } }) + ).resolves.toBeNull() + + expect(scanned.dirs.some((dir) => dir.startsWith('\\\\wsl.localhost\\'))).toBe(false) + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + }) + + it.each(['grok', 'omp'] as const)( + 'does not bind a host %s transcript to a WSL-owned PTY', + async (agent) => { + await expect( + resolveSessionFilePath(agent, 'same-id', { + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } + }) + ).resolves.toBeNull() + + expect(vi.mocked(listWslDistrosAsync)).not.toHaveBeenCalled() + } + ) + it('resolves a Claude transcript from WSL when no hook path is known', async () => { scanned.wslClaudeTranscriptDir = WSL_CLAUDE_PROJECTS_DIR diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 73720330a8b..32b49375bbc 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -14,10 +14,13 @@ import { import { toHostReadableTranscriptPath, wslClaudeProjectsDirs, - wslCodexSessionsDirs + wslClaudeProjectsDirsForDistro, + wslCodexSessionsDirs, + wslCodexSessionsDirsForDistro } from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' import { findWslSessionPath } from './wsl-session-path-scan' +import type { NativeChatTranscriptHost } from './transcript-host' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -73,6 +76,8 @@ export type ResolveSessionFileOptions = { * directly — recent Claude Code names the transcript with a UUID that differs * from the hook session_id, so the id-based glob below would miss it. */ transcriptPath?: string + /** PTY-owned execution host. Undefined preserves legacy discovery. */ + transcriptHost?: NativeChatTranscriptHost } /** @@ -102,7 +107,10 @@ export async function resolveSessionFilePath( // stale/missing paths fall through to the id-based search. const hookPath = options.transcriptPath?.trim() if (hookPath && extname(hookPath) === '.jsonl') { - const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal }) + const hostReadable = await toHostReadableTranscriptPath(hookPath, { + signal, + transcriptHost: options.transcriptHost + }) if (hostReadable) { return hostReadable } @@ -115,33 +123,63 @@ export async function resolveSessionFilePath( if (transcriptAgent === 'claude') { const overrideDir = options.claudeProjectsDir + if (!overrideDir && options.transcriptHost?.kind === 'wsl') { + const projectsDirs = await wslClaudeProjectsDirsForDistro(options.transcriptHost.distro) + signal?.throwIfAborted() + return resolveClaudeSessionFileInDirs(trimmedId, projectsDirs, signal) + } return resolveClaudeSessionFile( trimmedId, overrideDir ?? claudeProjectsDir(), - overrideDir ? undefined : wslClaudeProjectsDirs, + overrideDir || options.transcriptHost?.kind === 'host' ? undefined : wslClaudeProjectsDirs, signal ) } if (transcriptAgent === 'codex') { const overrideDirs = options.codexSessionsDirs + if (!overrideDirs && options.transcriptHost?.kind === 'wsl') { + const sessionsDirs = await wslCodexSessionsDirsForDistro(options.transcriptHost.distro) + signal?.throwIfAborted() + return findCodexRolloutInDirs(trimmedId, sessionsDirs, signal) + } return resolveCodexSessionFile( trimmedId, overrideDirs ?? codexSessionsDirs(), // Why: enumerating WSL homes spawns wsl.exe per distro, which boots ones the // user left stopped. Only pay that after this host's own Codex roots miss. - overrideDirs ? undefined : wslCodexSessionsDirs, + overrideDirs || options.transcriptHost?.kind === 'host' ? undefined : wslCodexSessionsDirs, signal ) } if (transcriptAgent === 'grok') { + if (options.transcriptHost?.kind === 'wsl') { + return null + } return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal) } if (transcriptAgent === 'omp') { + if (options.transcriptHost?.kind === 'wsl') { + return null + } return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal) } return null } +async function resolveClaudeSessionFileInDirs( + sessionId: string, + projectsDirs: string[], + signal?: AbortSignal +): Promise { + for (const projectsDir of projectsDirs) { + const hit = await findClaudeTranscript(projectsDir, sessionId, signal) + if (hit) { + return hit + } + } + return null +} + async function resolveClaudeSessionFile( sessionId: string, projectsDir: string, diff --git a/src/main/native-chat/transcript-host.ts b/src/main/native-chat/transcript-host.ts new file mode 100644 index 00000000000..332385f2511 --- /dev/null +++ b/src/main/native-chat/transcript-host.ts @@ -0,0 +1 @@ +export type NativeChatTranscriptHost = { kind: 'host' } | { kind: 'wsl'; distro: string } 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 70a264b93f2..92044d6a59f 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -75,12 +75,17 @@ describe('native chat transcript resolve polling', () => { agent: 'codex', sessionId: 'session-id', transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl', + transcriptHost: { kind: 'wsl', distro: 'Ubuntu' }, resolvePollIntervalMs: 10, onAppend: () => {} }) await vi.advanceTimersByTimeAsync(100) expect(mocks.toHostReadable).toHaveBeenCalledTimes(1) + expect(mocks.toHostReadable).toHaveBeenCalledWith( + '/home/ada/.codex/sessions/rollout-session-id.jsonl', + expect.objectContaining({ transcriptHost: { kind: 'wsl', distro: 'Ubuntu' } }) + ) expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe( false ) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index fff5bc60599..2ac0597969f 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -122,7 +122,8 @@ function subscribeViaResolvePoll( // the current drive (`C:\home\…`) and bind chat to a look-alike file. lastWslTranslateAt = Date.now() hostReadableExactPath = await toHostReadableTranscriptPath(exactPath, { - signal: resolveController.signal + signal: resolveController.signal, + transcriptHost: args.transcriptHost }) } } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec4d930ad79..8b89327c9a1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -10983,6 +10983,37 @@ describe('OrcaRuntimeService', () => { expect(cwds.get('pty-debian')).toBe('\\\\wsl.localhost\\Debian\\home\\me\\repo') }) + it('resolves native-chat transcript hosts from authoritative PTY context', () => { + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true }) + runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID, null, undefined, true) + runtime.registerPty('pty-host', TEST_WORKTREE_ID, null, undefined, false) + runtime.registerPty('pty-unknown', TEST_WORKTREE_ID) + runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-1', undefined, false) + + expect(runtime.resolveNativeChatTranscriptHost('pty-ubuntu')).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect(runtime.resolveNativeChatTranscriptHost('pty-host')).toEqual({ kind: 'host' }) + expect(runtime.resolveNativeChatTranscriptHost('pty-unknown')).toBeUndefined() + expect(runtime.resolveNativeChatTranscriptHost('pty-ssh')).toBeUndefined() + expect(runtime.resolveNativeChatTranscriptHost('missing')).toBeNull() + }) + + it('resolves paired-runtime terminal handles before transcript provenance', () => { + const runtime = new OrcaRuntimeService(store) + const handle = runtime.preAllocateHandleForPty('pty-ubuntu') + runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true }) + runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID, null, undefined, true) + + expect(runtime.resolveNativeChatTranscriptHostForHandle(handle)).toEqual({ + kind: 'wsl', + distro: 'Ubuntu' + }) + expect(runtime.resolveNativeChatTranscriptHostForHandle('term_missing')).toBeNull() + }) + it('does not retain WSL context when a PTY id is reused', () => { setPlatform('win32') const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e81da239ce..be7c72c622c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1062,6 +1062,7 @@ import { resolveNestedRepoSelection } from '../project-groups/nested-repo-import' import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' +import type { NativeChatTranscriptHost } from '../native-chat/transcript-host' function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string { console.warn(`[project-groups] ${context}`, error) @@ -3392,6 +3393,28 @@ export class OrcaRuntimeService { return this.getLocalProviderFn ? this.getLocalProviderFn() : null } + resolveNativeChatTranscriptHost(ptyId: string): NativeChatTranscriptHost | null | undefined { + const pty = this.ptysById.get(ptyId) + const distro = this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? null + if (distro) { + return { kind: 'wsl', distro } + } + if (pty?.connectionId === null && pty.isWsl === false) { + return { kind: 'host' } + } + return pty ? undefined : null + } + + resolveNativeChatTranscriptHostForHandle( + handle: string + ): NativeChatTranscriptHost | null | undefined { + const ptyId = + this.getLivePtyForHandle(handle)?.pty.ptyId ?? + this.resolveLiveLeafForHandle(handle)?.ptyId ?? + null + return ptyId ? this.resolveNativeChatTranscriptHost(ptyId) : null + } + private async stopPtysForDestructiveWorktreeRemoval( worktreeId: string, options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-payload.ts b/src/main/runtime/rpc/methods/native-chat-rpc-payload.ts new file mode 100644 index 00000000000..b4bc8a7f420 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-payload.ts @@ -0,0 +1,118 @@ +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' + +export const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 +export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 +const MOBILE_BLOCK_CHAR_CAP = 4000 +const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string, cap: number): string { + return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text +} + +function sanitizeBlock( + block: NativeChatBlock, + clientKind: RpcContext['clientKind'] +): NativeChatBlock { + if (block.type === 'image-ref') { + return sanitizeNativeChatRpcImageBlock(block) + } + if (clientKind !== 'mobile') { + return block + } + if (block.type === 'text') { + return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP + ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-result') { + return block.output.length > MOBILE_BLOCK_CHAR_CAP + ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } + return block +} + +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.hasOwn(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a truncated prefix must not silently collapse. + if (Object.hasOwn(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} + +function sanitizeMessage( + message: NativeChatMessage, + clientKind: RpcContext['clientKind'] +): NativeChatMessage { + return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } +} + +export function sanitizeAppendForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'] +): NativeChatMessage[] { + return messages.map((message) => sanitizeMessage(message, clientKind)) +} + +export function windowForClient( + messages: readonly NativeChatMessage[], + clientKind: RpcContext['clientKind'], + limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW +): NativeChatMessage[] { + const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) + const windowed = messages.length > window ? messages.slice(-window) : messages.slice() + return windowed.map((message) => sanitizeMessage(message, clientKind)) +} diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index af885e35de3..56f50400103 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -21,9 +21,13 @@ const cachedResult = vi.hoisted(() => ({ } } })) -const tailRead = vi.hoisted(() => ({ signal: undefined as AbortSignal | undefined })) +const tailRead = vi.hoisted(() => ({ + args: undefined as undefined | { transcriptHost?: unknown }, + signal: undefined as AbortSignal | undefined +})) const watcher = vi.hoisted(() => ({ args: null as null | { + transcriptHost?: unknown onInitialSnapshot?: ( messages: NativeChatMessage[], hasMore: boolean, @@ -63,7 +67,12 @@ const watcher = vi.hoisted(() => ({ unsubscribe: vi.fn() })) vi.mock('../../../native-chat/transcript-watch', () => ({ - readNativeChatTranscriptTail: ({ limit }: { limit: number }, signal?: AbortSignal) => { + readNativeChatTranscriptTail: ( + args: { limit: number; transcriptHost?: unknown }, + signal?: AbortSignal + ) => { + const { limit } = args + tailRead.args = args tailRead.signal = signal const messages = cachedResult.value.messages return Promise.resolve({ @@ -140,7 +149,12 @@ function streamingContext(clientKind: RpcContext['clientKind']): RpcContext { } function ctxWith(clientKind: RpcContext['clientKind']): RpcContext { - return { runtime: {} as RpcContext['runtime'], clientKind } + return { + runtime: { + resolveNativeChatTranscriptHostForHandle: vi.fn() + } as unknown as RpcContext['runtime'], + clientKind + } } function firstOutput(result: unknown): string { @@ -165,6 +179,27 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(tailRead.signal).toBe(controller.signal) }) + it('resolves terminal-handle provenance on the transcript-owning runtime', async () => { + const context = ctxWith('runtime') + const resolveHost = vi + .mocked(context.runtime.resolveNativeChatTranscriptHostForHandle) + .mockReturnValue({ kind: 'wsl', distro: 'Ubuntu' }) + + await readSessionHandler()({ agent: 'claude', sessionId: 's', ptyId: 'term-1' }, context) + + expect(resolveHost).toHaveBeenCalledWith('term-1') + expect(tailRead.args?.transcriptHost).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + }) + + it('fails closed when a supplied terminal handle is stale', async () => { + const context = ctxWith('runtime') + vi.mocked(context.runtime.resolveNativeChatTranscriptHostForHandle).mockReturnValue(null) + + await expect( + readSessionHandler()({ agent: 'claude', sessionId: 'same-id', ptyId: 'term-stale' }, context) + ).resolves.toEqual({ error: 'Transcript unavailable' }) + }) + it('clips oversized tool output for mobile clients', async () => { cachedResult.value = { messages: [makeMessage(OVERSIZED)] } const result = await readSessionHandler()( @@ -416,6 +451,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => { }) describe('nativeChat.subscribe initial snapshot', () => { + it('uses the same PTY provenance for the live watcher', async () => { + const context = streamingContext('runtime') + const resolveHost = vi.fn(() => ({ kind: 'wsl' as const, distro: 'Ubuntu' })) + context.runtime.resolveNativeChatTranscriptHostForHandle = resolveHost + + await subscribeHandler()( + { agent: 'claude', sessionId: 's', subscriptionId: 'sub-pty', ptyId: 'term-1' }, + context, + vi.fn() + ) + + expect(resolveHost).toHaveBeenCalledWith('term-1') + expect(activeWatcherArgs().transcriptHost).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + }) + + it('emits an error without watching when a terminal handle is stale', async () => { + const context = streamingContext('runtime') + context.runtime.resolveNativeChatTranscriptHostForHandle = vi.fn(() => null) + const emitted: unknown[] = [] + watcher.args = null + + await subscribeHandler()( + { agent: 'claude', sessionId: 'same-id', ptyId: 'term-stale' }, + context, + (value) => emitted.push(value) + ) + + expect(watcher.args).toBeNull() + expect(emitted).toEqual([ + expect.objectContaining({ type: 'snapshot', error: 'Transcript unavailable' }) + ]) + }) + it('preserves long assistant text across mobile stream frame types', async () => { watcher.watching = true watcher.args = null diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 3b8d42287de..6cf2d93d921 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,17 +1,18 @@ import { z } from 'zod' -import type { - NativeChatBlock, - NativeChatMessage, - AgentType -} from '../../../../shared/native-chat-types' +import type { AgentType } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, type NativeChatTranscriptSubscription, type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' -import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' -import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' +import { + MOBILE_NATIVE_CHAT_DEFAULT_WINDOW, + MOBILE_NATIVE_CHAT_MAX_WINDOW, + sanitizeAppendForClient, + windowForClient +} from './native-chat-rpc-payload' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The // desktop reaches the readers via Electron IPC; mobile/web clients reach the @@ -48,6 +49,9 @@ const NativeChatSession = z.object({ // locate the file directly when the session id no longer names it (recent // Claude Code). Optional for back-compat with older clients. transcriptPath: z.string().min(1).optional(), + // Optional for mixed-version compatibility; the host, not the client, + // resolves this PTY identity into filesystem provenance. + ptyId: z.string().min(1).optional(), beforeOffset: z.number().int().nonnegative().optional() }) @@ -55,162 +59,24 @@ const NativeChatUnsubscribe = z.object({ subscriptionId: z.string().min(1).optional() }) -// Why: a long agent session can hold thousands of turns (with full tool I/O). -// Shipping all of them over the paired connection and rendering them at once -// freezes the mobile app, so the runtime RPC windows to the most recent slice — -// the conversation tail is what the chat view shows first. The desktop IPC path -// is unaffected (it reads locally with a virtualized list). -// Small first page for a fast initial paint; the client raises `limit` to load -// older history as the user scrolls back. -const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 -const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 -// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. -// The mobile view only previews tool block bodies, so truncate them on the wire -// to keep the payload small; the marker tells the user content was clipped. -const MOBILE_BLOCK_CHAR_CAP = 4000 -// Why: text blocks are the message body itself, rendered in full by the chat -// view — a preview-sized cap cut long assistant replies mid-sentence with no way -// to read on (STA-3230). Keep only a generous safety ceiling: a transcript -// record can legally reach 2MB, and shipping that much markdown in one block -// would freeze the phone. -const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 -const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 -const MOBILE_TOOL_INPUT_NODE_CAP = 100 -const TRUNCATION_MARKER = '\n… (truncated)' - -function clip(text: string, cap: number): string { - return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text -} - -function sanitizeBlock( - block: NativeChatBlock, - clientKind: RpcContext['clientKind'] -): NativeChatBlock { - if (block.type === 'image-ref') { - return sanitizeNativeChatRpcImageBlock(block) - } - if (clientKind !== 'mobile') { - return block - } - if (block.type === 'text') { - return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP - ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-result') { - return block.output.length > MOBILE_BLOCK_CHAR_CAP - ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-call') { - const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } - return { ...block, input: sanitizeToolInput(block.input, budget, 0) } - } - return block -} - -function sanitizeToolInput( - value: unknown, - budget: { remaining: number; nodes: number }, - depth: number -): unknown { - budget.nodes-- - if (budget.nodes < 0 || budget.remaining <= 0) { - return '… (truncated)' - } - if (typeof value === 'string') { - const length = Math.min(value.length, budget.remaining) - budget.remaining -= length - return length < value.length ? `${value.slice(0, length)}… (truncated)` : value - } - if (!value || typeof value !== 'object' || depth >= 5) { - return value && typeof value === 'object' ? '… (truncated)' : value - } - if (Array.isArray(value)) { - const result = value - .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) - .map((item) => sanitizeToolInput(item, budget, depth + 1)) - if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { - result.push('… (truncated)') - } - return result - } - const result: Record = {} - let count = 0 - for (const key in value) { - if (!Object.hasOwn(value, key)) { - continue - } - if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { - result['…'] = 'truncated' - break - } - let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) - // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse - // to the same bounded key; suffix collisions so neither field is silently lost. - if (Object.hasOwn(result, boundedKey)) { - boundedKey = `${boundedKey}~${count}` - } - budget.remaining -= boundedKey.length - result[boundedKey] = sanitizeToolInput( - (value as Record)[key], - budget, - depth + 1 - ) - count++ - } - return result -} - -function sanitizeMessage( - message: NativeChatMessage, - clientKind: RpcContext['clientKind'] -): NativeChatMessage { - return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } -} - -function sanitizeAppendForClient( - messages: readonly NativeChatMessage[], - clientKind: RpcContext['clientKind'] -): NativeChatMessage[] { - return messages.map((message) => sanitizeMessage(message, clientKind)) -} - -/** Window a transcript to its most recent `limit` messages so a long session - * can't freeze the client. Windowing by count applies to ALL RPC clients — - * shipping thousands of turns over the paired link is bad for web and mobile - * alike. Char-clipping (the mobile-only payload diet) is applied separately. */ -function windowTranscript( - messages: readonly NativeChatMessage[], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const window = Math.min(Math.max(limit, 1), MOBILE_NATIVE_CHAT_MAX_WINDOW) - return messages.length > window ? messages.slice(-window) : messages.slice() -} - -/** Apply the windowed slice and keep inline image bytes off every RPC transport. - * Mobile clients additionally receive bounded text and tool bodies; runtime - * clients keep those bodies intact. */ -function windowForClient( - messages: readonly NativeChatMessage[], - clientKind: RpcContext['clientKind'], - limit = MOBILE_NATIVE_CHAT_DEFAULT_WINDOW -): NativeChatMessage[] { - const windowed = windowTranscript(messages, limit) - return windowed.map((message) => sanitizeMessage(message, clientKind)) -} - export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ defineMethod({ name: 'nativeChat.readSession', params: NativeChatSession, - handler: async (params, { clientKind, signal }) => { + handler: async (params, { runtime, clientKind, signal }) => { const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + const transcriptHost = params.ptyId + ? runtime.resolveNativeChatTranscriptHostForHandle(params.ptyId) + : undefined + if (transcriptHost === null) { + return { error: 'Transcript unavailable' } + } const result = await readNativeChatTranscriptTail( { agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + transcriptHost, limit, beforeOffset: params.beforeOffset }, @@ -246,6 +112,18 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW + const transcriptHost = params.ptyId + ? runtime.resolveNativeChatTranscriptHostForHandle(params.ptyId) + : undefined + if (transcriptHost === null) { + emit({ + type: 'snapshot', + messages: [], + hasMore: false, + error: 'Transcript unavailable' + }) + return + } const cleanup = (): void => { if (closed) { return @@ -272,6 +150,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, + transcriptHost, initialLimit: limit, onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => { if (closed) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ff541ec8a06..9c0823504c1 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -911,6 +911,8 @@ export type NativeChatSubscribeArgs = { sessionId: string /** Authoritative transcript path from the agent hook (providerSession). */ transcriptPath?: string + /** PTY identity resolved to transcript provenance by its owning host. */ + ptyId?: string /** First snapshot size; later readSession calls grow this for pagination. */ limit?: number } @@ -922,7 +924,8 @@ export type NativeChatApi = { agent: AgentType, sessionId: string, limit?: number, - transcriptPath?: string + transcriptPath?: string, + ptyId?: string ) => Promise /** Live-tail a transcript. The first frame is a bounded race-safe snapshot; * later frames contain only newly appended messages. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 7a1d4030a32..d6342f87643 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4276,9 +4276,16 @@ const api = { agent: AgentType, sessionId: string, limit?: number, - transcriptPath?: string + transcriptPath?: string, + ptyId?: string ): Promise => - ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath }), + ipcRenderer.invoke('nativeChat:readSession', { + agent, + sessionId, + limit, + transcriptPath, + ...(ptyId ? { ptyId } : {}) + }), /** Start live tailing; onAppended fires with only newly-appended messages. Returns an unsubscribe fn that closes the watcher. */ subscribe: ( args: { @@ -4286,6 +4293,7 @@ const api = { agent: AgentType sessionId: string transcriptPath?: string + ptyId?: string limit?: number }, onFrame: (frame: NativeChatSubscriptionFrame) => void diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 4da40f7a1fc..ecf66a94b8d 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -127,6 +127,7 @@ function NativeChatResolvedView({ agent, sessionId, transcriptPath, + ptyId: targetPtyId, runtimeEnvironmentId, enabled: isVisible }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 47832b10897..6129d38a9e1 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -11,6 +11,7 @@ import { RUNTIME_NATIVE_CHAT_RECONNECT_MS, toRuntimeNativeChatErrorMessage } from './native-chat-session-transport' +import { toRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' const nativeChatReadSession = vi.fn() const nativeChatSubscribe = vi.fn() @@ -66,6 +67,15 @@ describe('getNativeChatSessionTransport — selection', () => { expect(runtimeEnvironmentsCall).not.toHaveBeenCalled() }) + it('forwards PTY provenance identity through the local adapter', async () => { + nativeChatReadSession.mockResolvedValue({ messages: [] }) + const transport = getNativeChatSessionTransport(null) + + await transport.readSession('claude', 'sess-1', 40, undefined, 'pty-1') + + expect(nativeChatReadSession).toHaveBeenCalledWith('claude', 'sess-1', 40, undefined, 'pty-1') + }) + it('returns the runtime adapter for an owner on the desktop client', async () => { markRuntimeEnvironmentCompatible(ENV) runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] })) @@ -82,6 +92,47 @@ describe('getNativeChatSessionTransport — selection', () => { expect(nativeChatReadSession).not.toHaveBeenCalled() }) + it('forwards PTY provenance identity to the owning runtime', async () => { + markRuntimeEnvironmentCompatible(ENV) + runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] })) + const transport = getNativeChatSessionTransport(ENV) + + await transport.readSession( + 'claude', + 'sess-1', + 40, + undefined, + toRemoteRuntimePtyId('pty-host-1', ENV) + ) + + expect(runtimeEnvironmentsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ ptyId: 'pty-host-1' }) + }) + ) + }) + + it('routes a scoped PTY to its encoded owner during an owner flip', async () => { + markRuntimeEnvironmentCompatible('env-a') + runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] })) + const transport = getNativeChatSessionTransport('env-b') + + await transport.readSession( + 'claude', + 'sess-1', + 40, + undefined, + toRemoteRuntimePtyId('term-a', 'env-a') + ) + + expect(runtimeEnvironmentsCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-a', + params: expect.objectContaining({ ptyId: 'term-a' }) + }) + ) + }) + it('validates lifecycle metadata on runtime read responses', async () => { markRuntimeEnvironmentCompatible(ENV) const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const @@ -206,6 +257,51 @@ describe('runtime subscribe', () => { }) }) + it('forwards PTY provenance identity to the runtime stream', async () => { + markRuntimeEnvironmentCompatible(ENV) + stubSubscribe() + const transport = getNativeChatSessionTransport(ENV) + + transport.subscribe( + { + subscriptionId: 's-pty', + agent: 'claude', + sessionId: 'sess-1', + ptyId: toRemoteRuntimePtyId('pty-host-1', ENV) + }, + vi.fn() + ) + await Promise.resolve() + + expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ ptyId: 'pty-host-1' }) + }), + expect.any(Object) + ) + }) + + it('subscribes against the scoped PTY owner during an owner flip', async () => { + stubSubscribe() + const transport = getNativeChatSessionTransport('env-b') + + transport.subscribe( + { + subscriptionId: 's-owner-flip', + agent: 'claude', + sessionId: 'sess-1', + ptyId: toRemoteRuntimePtyId('term-a', 'env-a') + }, + vi.fn() + ) + await Promise.resolve() + + expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-a' }), + expect.any(Object) + ) + }) + it('validates lifecycle metadata on runtime stream frames', async () => { markRuntimeEnvironmentCompatible(ENV) const { deliver } = stubSubscribe() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 0fe22211db0..243f1b96012 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -1,16 +1,13 @@ import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types' import { isWebClientLocation } from '@/lib/web-client-location' -import { - callRuntimeRpc, - RuntimeRpcCallError, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { parseRuntimeNativeChatReadSessionResult, parseRuntimeNativeChatTurnLifecycle, RUNTIME_NATIVE_CHAT_READ_ERROR } from './native-chat-runtime-contract' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' /** The read/subscribe surface the live-session hook needs, decoupled from where * the transcript actually lives. Same shape as `window.api.nativeChat`, so the @@ -43,21 +40,39 @@ export function toRuntimeNativeChatErrorMessage(err: unknown): string { * using this adapter (R3). Preserves whatever `subscribe` returns (sync fn on * desktop, promise on the web bridge) — the hook's teardown handles both (R6). */ const localNativeChatTransport: NativeChatSessionTransport = { - readSession: (agent, sessionId, limit, transcriptPath) => - window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), + readSession: (agent, sessionId, limit, transcriptPath, ptyId) => + ptyId + ? window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath, ptyId) + : window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame) } function createRuntimeNativeChatTransport(environmentId: string): NativeChatSessionTransport { - const target: RuntimeClientTarget = { kind: 'environment', environmentId } + const route = ( + ptyId: string | undefined + ): { target: { kind: 'environment'; environmentId: string }; hostPtyId?: string } => { + const parsed = ptyId ? parseRemoteRuntimePtyId(ptyId) : null + const targetEnvironmentId = parsed?.environmentId?.trim() || environmentId + return { + target: { kind: 'environment', environmentId: targetEnvironmentId }, + ...(ptyId ? { hostPtyId: parsed?.handle || ptyId } : {}) + } + } return { - readSession: async (agent, sessionId, limit, transcriptPath) => { + readSession: async (agent, sessionId, limit, transcriptPath, ptyId) => { try { + const { target, hostPtyId } = route(ptyId) const result = await callRuntimeRpc( target, 'nativeChat.readSession', - { agent, sessionId, limit, transcriptPath }, + { + agent, + sessionId, + limit, + transcriptPath, + ...(hostPtyId ? { ptyId: hostPtyId } : {}) + }, { timeoutMs: 15_000 } ) return parseRuntimeNativeChatReadSessionResult(result) @@ -66,7 +81,9 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess } }, subscribe: (args, onFrame) => { - const { subscriptionId, agent, sessionId, transcriptPath, limit } = args + const { subscriptionId, agent, sessionId, transcriptPath, ptyId, limit } = args + const { target, hostPtyId } = route(ptyId) + const targetEnvironmentId = target.environmentId let cancelled = false let receivedInitial = false let handleUnsubscribe: (() => void) | null = null @@ -103,9 +120,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess void window.api.runtimeEnvironments .subscribe( { - selector: environmentId, + selector: targetEnvironmentId, method: 'nativeChat.subscribe', - params: { subscriptionId, agent, sessionId, transcriptPath, limit }, + params: { + subscriptionId, + agent, + sessionId, + transcriptPath, + ...(hostPtyId ? { ptyId: hostPtyId } : {}), + limit + }, timeoutMs: 15_000 }, { diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 3d3a3206cc5..065f1b5f33e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -137,6 +137,41 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(transport.subscribe).toHaveBeenCalledOnce() }) + it('rebinds reads and subscriptions when the owning PTY changes', async () => { + const root = await render({ + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + ptyId: 'pty-1', + runtimeEnvironmentId: 'env-1' + }) + const transport = getMockTransport('env-1') + + expect(transport.readSession).toHaveBeenLastCalledWith( + AGENT, + SESSION, + NATIVE_CHAT_INITIAL_LIMIT, + undefined, + 'pty-1' + ) + expect(transport.subscribe.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ ptyId: 'pty-1' }) + ) + + await rerender(root, { + paneKey: PANE, + agent: AGENT, + sessionId: SESSION, + ptyId: 'pty-2', + runtimeEnvironmentId: 'env-1' + }) + + expect(transport.unsubscribe).toHaveBeenCalledOnce() + expect(transport.subscribe.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ ptyId: 'pty-2' }) + ) + }) + it('re-subscribes against the new owner on an owner flip (R5)', async () => { const root = await render({ paneKey: PANE, diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 6044da30673..126c6a54bec 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -31,6 +31,8 @@ export type UseNativeChatLiveSessionArgs = { sessionId: string | null /** Authoritative transcript path from the hook, preferred over reconstructing it from sessionId. Null when not reported. */ transcriptPath?: string | null + /** PTY identity used by the owning host to resolve transcript provenance. */ + ptyId?: string | null /** Runtime owner (Model B): non-null routes read/subscribe to the remote host; null keeps the local IPC path. */ runtimeEnvironmentId?: string | null /** False suspends transcript IO while retaining the last committed session. */ @@ -122,7 +124,8 @@ export function useNativeChatLiveSession( runtimeEnvironmentId ?? null, agent, sessionId, - transcriptPath ?? null + transcriptPath ?? null, + args.ptyId ?? null ]) const retainedSourceKeyRef = useRef(sourceKey) @@ -175,7 +178,13 @@ export function useNativeChatLiveSession( return } void transport - .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) + .readSession( + agent, + activeSessionId, + limitRef.current, + transcriptPath ?? undefined, + args.ptyId ?? undefined + ) .then((result) => { if (cancelled || !latestEnabled.current || frameArrived) { return @@ -211,6 +220,7 @@ export function useNativeChatLiveSession( agent, sessionId, transcriptPath: transcriptPath ?? undefined, + ptyId: args.ptyId ?? undefined, limit: limitRef.current }, (frame) => { @@ -246,7 +256,16 @@ export function useNativeChatLiveSession( closeStream() } // `transport` identity changes on an owner flip, re-running this effect to re-subscribe against the new host. - }, [agent, enabled, sessionId, sourceKey, transcriptPath, transport, transcriptLifecycleControl]) + }, [ + agent, + enabled, + args.ptyId, + sessionId, + sourceKey, + transcriptPath, + transport, + transcriptLifecycleControl + ]) const loadEarlier = useCallback(() => { if ( @@ -263,7 +282,13 @@ export function useNativeChatLiveSession( const lifecycleRevision = transcriptLifecycleControl.revision() setLoadingEarlier(true) void transport - .readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined) + .readSession( + agent, + sessionId, + nextLimit, + transcriptPath ?? undefined, + args.ptyId ?? undefined + ) .then((result) => { // Ignore a stale resolve from a swapped session or flipped owner — either would paint the wrong host's history. if ( @@ -296,6 +321,7 @@ export function useNativeChatLiveSession( agent, sessionId, transcriptPath, + args.ptyId, transport, hasMore, loadingEarlier, diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts index 6d176f2ac55..ed9c1165c3d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts @@ -19,7 +19,8 @@ export function useNativeChatRetainedSession( args.runtimeEnvironmentId ?? null, args.agent, args.sessionId, - args.transcriptPath ?? null + args.transcriptPath ?? null, + args.ptyId ?? null ]) const activeIdentityRef = useRef(identity) const retentionRef = useRef(createNativeChatTranscriptRetention()) diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 9fcec640925..c1b14397bd6 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1536,6 +1536,62 @@ describe('web native chat preload API', () => { } ]) }) + + it('sends the host PTY handle instead of the client-scoped runtime id', async () => { + const requests: { method: string; params: unknown }[] = [] + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string, params: unknown): Promise> { + requests.push({ method, params }) + return Promise.resolve({ + id: 'read-pty', + ok: true, + result: { messages: [] }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.nativeChat.readSession( + 'claude', + 'session-1', + 40, + undefined, + 'remote:web-env-1@@pty-host-1' + ) + + expect(requests).toEqual([ + { + method: 'nativeChat.readSession', + params: expect.objectContaining({ ptyId: 'pty-host-1' }) + } + ]) + }) + + it('fails closed when a scoped PTY names another runtime owner', async () => { + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'web-env-1') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect( + globals.window.api.nativeChat.readSession( + 'claude', + 'same-id', + 40, + undefined, + 'remote:other-env@@term-1' + ) + ).rejects.toThrow('Unknown Orca runtime environment: other-env') + }) }) describe('web MiniMax preload API', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2baa23a7b28..1478b983894 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -77,6 +77,7 @@ import { } from '../../../shared/execution-host' import { toRuntimeWorktreeSelector } from '../runtime/runtime-worktree-selector' import { callAbortableRuntimeEnvironment } from '../runtime/abortable-runtime-environment-call' +import { parseRemoteRuntimePtyId } from '../runtime/runtime-terminal-stream' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' import { normalizeTuiAgentArgsRecord, @@ -1242,15 +1243,27 @@ function createWebKeybindingsApi(): WebKeybindingsApi { // Why: web has no IPC for native-chat transcripts, so route readSession/subscribe through runtime RPC (as mobile does). function createNativeChatApi(): NativeChatApi { return { - readSession: async (agent, sessionId, limit, transcriptPath) => - parseRuntimeNativeChatReadSessionResult( - await callRuntimeResult('nativeChat.readSession', { + readSession: async (agent, sessionId, limit, transcriptPath, ptyId) => { + const parsedPty = ptyId ? parseRemoteRuntimePtyId(ptyId) : null + const environment = parsedPty?.environmentId + ? resolveEnvironment(parsedPty.environmentId) + : requireActiveEnvironment() + const response = await callEnvironmentEnvelope( + environment.id, + 'nativeChat.readSession', + { agent, sessionId, limit, - transcriptPath - }) - ), + transcriptPath, + ...(ptyId ? { ptyId: parsedPty?.handle || ptyId } : {}) + } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + return parseRuntimeNativeChatReadSessionResult(response.result) + }, subscribe: (args, onFrame) => { // No paired runtime yet: return a no-op teardown so the chat view mounts cleanly; only the not-paired case is swallowed. const environment = requireActiveEnvironmentOrNull() @@ -1266,10 +1279,14 @@ function createNativeChatApi(): NativeChatApi { }) return () => {} } + const parsedPty = args.ptyId ? parseRemoteRuntimePtyId(args.ptyId) : null + const targetEnvironment = parsedPty?.environmentId + ? resolveEnvironment(parsedPty.environmentId) + : environment let handle: { unsubscribe: () => void } | null = null let cancelled = false let receivedInitial = false - void getClientForEnvironment(environment) + void getClientForEnvironment(targetEnvironment) .subscribe( 'nativeChat.subscribe', { @@ -1277,6 +1294,7 @@ function createNativeChatApi(): NativeChatApi { sessionId: args.sessionId, subscriptionId: args.subscriptionId, transcriptPath: args.transcriptPath, + ...(args.ptyId ? { ptyId: parsedPty?.handle || args.ptyId } : {}), limit: args.limit }, { diff --git a/src/shared/wsl-paths.test.ts b/src/shared/wsl-paths.test.ts index 6a9641d41ac..84a158395b5 100644 --- a/src/shared/wsl-paths.test.ts +++ b/src/shared/wsl-paths.test.ts @@ -16,6 +16,10 @@ describe('wsl path helpers', () => { distro: 'Debian', linuxPath: '/home/jin' }) + expect(parseWslUncPath('\\\\?\\UNC\\wsl.localhost\\Ubuntu\\home\\jin')).toEqual({ + distro: 'Ubuntu', + linuxPath: '/home/jin' + }) }) it('rejects ordinary Windows and POSIX paths', () => { diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index f8acb9d69d6..fa79de50a29 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -5,7 +5,7 @@ export type WslUncPathInfo = { export function parseWslUncPath(path: string): WslUncPathInfo | null { const normalized = path.replace(/\\/g, '/') - const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i) + const match = normalized.match(/^(?:\/\/|\/\/\?\/UNC\/)(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i) if (!match) { return null }