From a9ce7a2b48403e6e4c977ca71b3e5f94b77ddc05 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 22 Aug 2026 00:57:58 -0400 Subject: [PATCH] fix(native-chat): make transcript routing server-authoritative --- config/reliability-gates.jsonc | 73 ++-- docs/reference/remote-wire-compatibility.md | 32 +- .../mobile-native-chat-eligibility.test.ts | 10 +- .../session/mobile-native-chat-eligibility.ts | 5 +- .../use-mobile-native-chat-controller.ts | 2 +- .../use-mobile-native-chat-session.test.ts | 31 +- .../session/use-mobile-native-chat-session.ts | 33 +- .../server-ai-vault-liveness.test.ts | 5 +- .../server-hook-http-ingest.test.ts | 30 -- .../agent-hooks/server-ingest-remote.test.ts | 31 +- .../server-ingest-terminal-status.test.ts | 21 -- .../server-interrupt-inference-guards.test.ts | 12 +- ...ver-last-status-hydrate-validation.test.ts | 88 ----- .../server-last-status-write.test.ts | 6 +- src/main/agent-hooks/server.ts | 35 +- .../wsl-hook-relay-live.integration.test.ts | 19 +- src/main/ipc/filesystem-path-containment.ts | 31 +- src/main/ipc/native-chat.test.ts | 25 +- src/main/ipc/native-chat.ts | 11 +- .../native-chat-transcript-owner.ts | 81 +++-- src/main/native-chat/session-file-resolver.ts | 4 +- .../ssh-transcript-host-routing.test.ts | 244 ++++++++++---- src/main/native-chat/ssh-transcript-read.ts | 3 +- .../ssh-transcript-relay-errno.test.ts | 145 ++++++++ .../ssh-transcript-subscription.ts | 54 +-- .../native-chat/transcript-host-verdict.ts | 11 +- .../native-chat/transcript-tail-reader.ts | 6 +- .../native-chat/transcript-watch-contract.ts | 3 - .../transcript-watch-resolve-poll.test.ts | 314 ------------------ ...transcript-watch-unresolved-notice.test.ts | 151 --------- .../transcript-watch-wsl-exact-path.test.ts | 12 - .../transcript-watch-wsl-stall.test.ts | 38 +++ src/main/native-chat/transcript-watch.ts | 207 +++++++----- .../rpc/methods/native-chat-rpc-schema.ts | 12 +- .../runtime/rpc/methods/native-chat.test.ts | 31 +- src/main/runtime/rpc/methods/native-chat.ts | 4 +- src/preload/api/native-chat-api.ts | 12 +- src/preload/index.ts | 6 +- src/relay/dispatcher-structured-error.test.ts | 32 ++ src/relay/dispatcher.ts | 13 +- src/relay/integration.test.ts | 37 ++- .../native-chat/NativeChatSessionGate.tsx | 2 +- .../components/native-chat/NativeChatView.tsx | 6 +- .../native-chat-live-session-types.ts | 4 +- .../native-chat-live-source-key.ts | 8 +- .../native-chat-pane-resolution.test.ts | 28 +- .../native-chat-pane-resolution.ts | 5 +- .../native-chat-runtime-contract.ts | 3 +- .../native-chat-session-transport.test.ts | 32 +- .../native-chat-session-transport.ts | 14 +- .../native-chat/native-chat-view-types.ts | 3 +- .../use-native-chat-live-session.test.ts | 29 -- .../use-native-chat-live-session.ts | 44 +-- ...se-native-chat-provider-session-locator.ts | 23 -- .../use-native-chat-retained-session.ts | 2 +- ...-pty-transport-host-session-launch.test.ts | 3 +- .../remote-runtime-pty-transport.ts | 7 +- ...me-session-terminal-host-authority.test.ts | 3 +- .../src/runtime/web-runtime-session.ts | 9 +- src/renderer/src/web/web-preload-api.ts | 6 +- src/shared/agent-session-resume.test.ts | 55 --- src/shared/agent-session-resume.ts | 31 +- src/shared/definitive-filesystem-absence.ts | 42 ++- .../workspace-session-sleeping-agents.ts | 8 +- ...adless-native-chat-runtime-routing.spec.ts | 24 +- 65 files changed, 891 insertions(+), 1420 deletions(-) create mode 100644 src/main/native-chat/ssh-transcript-relay-errno.test.ts delete mode 100644 src/main/native-chat/transcript-watch-resolve-poll.test.ts delete mode 100644 src/main/native-chat/transcript-watch-unresolved-notice.test.ts delete mode 100644 src/renderer/src/components/native-chat/use-native-chat-provider-session-locator.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index fedcd175b71..826d632fde4 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4648,7 +4648,7 @@ "owner": "native-chat", "layer": "cross-boundary", "surfaces": [ - "provider-session identity", + "pane-scoped provider-session identity", "agent hook ingress", "transcript snapshot", "transcript live tail", @@ -4660,17 +4660,18 @@ "providers": ["local", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos", "linux"], "coveredProviders": ["local", "ssh", "wsl", "remote-runtime"], - "coverageNotes": "A deterministic host-routing oracle covers local, exact SSH, unknown ownership, reconnect, provider replacement, bounded range reads, WSL ingress ownership, and WSL exact-path contracts. A Docker Linux SSH/relay target plus headed macOS Electron/CDP proves remote snapshot and tail rendering for SSH git and folder workspaces. A compiled windowless serve journey covers the shared runtime boundary.", + "coverageNotes": "A deterministic host-routing oracle covers server-owned pane selection, caller-path spoof rejection, local, SSH, WSL, unknown ownership, reconnect, provider replacement, bounded range reads, and relay errno classification. A Docker Linux SSH/relay target plus headed macOS Electron/CDP proves remote snapshot and tail rendering for SSH git and folder workspaces. A compiled windowless serve journey covers the shared runtime boundary.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/13663", "https://github.com/stablyai/orca/pull/15515", "https://github.com/stablyai/orca/pull/15517" ], - "invariant": "A located provider session resolves and reads transcript bytes only through its stamped execution owner. Unknown or unavailable ownership is unverifiable and never falls back to the desktop filesystem. SSH snapshots and tails use bounded positional reads, forward cancellation, and reject stale provider generations; locator-free legacy requests retain host-local behavior.", - "oracle": "The focused oracle gives the desktop and SSH providers identical session ids and transcript paths but different bytes, then requires the exact SSH owner for snapshot and live append. It also proves unknown ownership cannot install a local watcher, repeated provider loss rearms one advisory per outage, a restored-but-missing remote path replaces stale unverifiable text, throwing advisory callbacks cannot kill retry, cancellation reaches remote stat, and large appends never call whole-file read or reread the prefix. The identical baseline/candidate/disabled/revert comparison and a real Docker SSH plus Electron/CDP journey supply independent red/green and rendered evidence.", + "invariant": "Main or the serving runtime resolves optional pane identity plus session id against its own agent-hook row, then uses only that row's connection id and transcript path. Unknown or ambiguous ownership is unverifiable and never falls back to the desktop filesystem. SSH snapshots and tails use bounded positional reads, forward cancellation, reject stale provider generations, and classify definitive remote absence without losing errno at the relay boundary; locator-free legacy requests remain local only when no authoritative row exists.", + "oracle": "The focused oracle gives desktop and SSH providers colliding session ids and paths but different bytes, then requires the requested pane's server-owned row for snapshot and live append. It proves caller path spoofing cannot select a source, ambiguous or missing ownership cannot install a local watcher, WSL provenance remains local, owner and provider loss fail closed and recover, cancellation reaches remote stat, definitive absence survives the relay with an old-relay fallback, and large appends never call whole-file read or reread the prefix. The baseline/candidate comparison and real Docker SSH plus Electron/CDP journey supply independent red/green and rendered evidence.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/ssh-transcript-host-routing.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/server-hook-http-ingest.test.ts src/main/agent-hooks/server-ingest-remote.test.ts src/main/agent-hooks/server-ingest-terminal-status.test.ts src/main/agent-hooks/server-last-status-hydrate-validation.test.ts src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts src/main/ipc/native-chat.test.ts src/main/native-chat/transcript-watch-error.test.ts src/main/native-chat/transcript-watch-resolve-poll.test.ts src/main/native-chat/transcript-watch-unresolved-notice.test.ts src/main/providers/ssh-filesystem-dispatch.test.ts src/main/providers/ssh-filesystem-provider-range.test.ts src/main/runtime/rpc/methods/native-chat.test.ts src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts src/renderer/src/components/native-chat/native-chat-session-transport.test.ts src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts src/shared/agent-session-resume.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/ssh-transcript-relay-errno.test.ts src/relay/dispatcher-structured-error.test.ts src/relay/integration.test.ts src/main/ipc/filesystem-path-containment-remote-enoent.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/main/agent-hooks/server-hook-http-ingest.test.ts src/main/agent-hooks/server-ingest-remote.test.ts src/main/agent-hooks/server-ingest-terminal-status.test.ts src/main/agent-hooks/server-last-status-hydrate-validation.test.ts src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts src/main/ipc/native-chat.test.ts src/main/native-chat/transcript-watch-error.test.ts src/main/providers/ssh-filesystem-dispatch.test.ts src/main/providers/ssh-filesystem-provider-range.test.ts src/main/runtime/rpc/methods/native-chat.test.ts src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts src/renderer/src/components/native-chat/native-chat-session-transport.test.ts src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts src/shared/agent-session-resume.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/transcript-watch-wsl-exact-path.test.ts --reporter=dot", "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-eligibility.test.ts mobile/src/session/use-mobile-native-chat-session.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-native-chat-runtime-routing.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -4678,9 +4679,8 @@ ], "testFiles": [ "src/main/native-chat/ssh-transcript-host-routing.test.ts", + "src/main/native-chat/ssh-transcript-relay-errno.test.ts", "src/main/native-chat/transcript-watch-error.test.ts", - "src/main/native-chat/transcript-watch-resolve-poll.test.ts", - "src/main/native-chat/transcript-watch-unresolved-notice.test.ts", "src/main/native-chat/transcript-watch-wsl-exact-path.test.ts", "src/main/agent-hooks/server-hook-http-ingest.test.ts", "src/main/agent-hooks/server-ingest-remote.test.ts", @@ -4688,6 +4688,7 @@ "src/main/agent-hooks/server-last-status-hydrate-validation.test.ts", "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts", "src/main/ipc/native-chat.test.ts", + "src/main/ipc/filesystem-path-containment-remote-enoent.test.ts", "src/main/providers/ssh-filesystem-dispatch.test.ts", "src/main/providers/ssh-filesystem-provider-range.test.ts", "src/main/runtime/rpc/methods/native-chat.test.ts", @@ -4697,6 +4698,8 @@ "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts", "src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts", "src/shared/agent-session-resume.test.ts", + "src/relay/dispatcher-structured-error.test.ts", + "src/relay/integration.test.ts", "mobile/src/session/mobile-native-chat-eligibility.test.ts", "mobile/src/session/use-mobile-native-chat-session.test.ts", "tests/e2e/headless-native-chat-runtime-routing.spec.ts" @@ -4705,23 +4708,36 @@ { "file": "src/main/native-chat/ssh-transcript-host-routing.test.ts", "assertions": [ - "the exact SSH owner wins over desktop and other-SSH same-id/path lookalikes", + "the requested pane's server-owned SSH row wins over desktop and other-SSH same-id/path lookalikes", + "caller transcript paths cannot override a hook-owned path or disambiguate locator-free ownership", "unknown ownership never reads or watches locally", - "remote snapshot and append survive provider loss, replacement, and advisory callback failure", + "WSL ownership stays local while SSH owner and provider loss fail closed and recover", "every remote request is at most 256 KiB and a large append does not reread the prefix or call readFile" ] }, { - "file": "src/main/agent-hooks/server-hook-http-ingest.test.ts", + "file": "src/main/native-chat/ssh-transcript-relay-errno.test.ts", "assertions": [ - "local hook ingress stamps local ownership", - "authenticated remote hook ingress overwrites a spoofed owner with the connection's SSH owner" + "structured ENOENT from the owning relay is retryable notFound", + "structured ENOTDIR from the owning relay is retryable notFound", + "new clients retain canonical ENOENT and ENOTDIR compatibility with older relays", + "a transcript removed after stat is retryable notFound", + "structured non-absence or malformed errno data overrides a contradictory legacy-looking message" ] }, { - "file": "src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts", + "file": "src/relay/dispatcher-structured-error.test.ts", "assertions": [ - "WSL relay hook ingress retains local transcript ownership for host-side UNC translation" + "only recognized system errno codes are published as errno data", + "invalid structured skill-failure data remains private" + ] + }, + { + "file": "src/relay/integration.test.ts", + "assertions": [ + "definitive filesystem absence retains optional errno data through the real dispatcher and SSH multiplexer", + "a real non-directory filesystem path retains ENOTDIR through the dispatcher and SSH multiplexer", + "a non-absence errno survives the real relay path and overrides a contradictory legacy-looking message" ] }, { @@ -4730,19 +4746,26 @@ }, { "file": "src/main/runtime/rpc/methods/native-chat.test.ts", - "assertions": [ - "runtime read and subscription forward the normalized provider-session locator" - ] + "assertions": ["runtime read and subscription forward optional pane identity"] }, { "file": "tests/e2e/headless-native-chat-runtime-routing.spec.ts", "assertions": [ - "a compiled windowless runtime reads an explicit local owner", - "the same real file is unverifiable through a located session whose owner is absent" + "a compiled windowless runtime retains locator-free local compatibility", + "the same real file is unverifiable through a pane-scoped request with no authoritative row" ] } ], "evidenceRuns": [ + { + "date": "2026-08-22", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/ssh-transcript-relay-errno.test.ts src/relay/dispatcher-structured-error.test.ts src/relay/integration.test.ts src/main/ipc/filesystem-path-containment-remote-enoent.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 3, + "summary": "The post-OpenCode errno oracle passed 44/44, adding real-filesystem ENOTDIR transport, old-relay ENOTDIR fallback, post-stat disappearance, malformed-data precedence, and non-errno publication coverage. The prior mutation regressed publication to absence-only errnos and native-chat classification to ENOENT-only, failing exactly three targeted assertions (37/40); restoration returned to 40/40 before the expanded cases." + }, { "date": "2026-08-21", "runner": "local", @@ -4750,10 +4773,10 @@ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/native-chat/ssh-transcript-host-routing.test.ts --reporter=dot", "result": "passed", "durationSeconds": 3, - "summary": "The post-review candidate passed all 17 deterministic ownership, reconnect, repeated-outage, replacement-race, advisory, legacy, cancellation, binding, and bounded-range cases. Disabling remote stat cancellation, outage rearming, and remote-specific unresolved copy failed 3/17; restoration returned to 17/17. The prior 16-case candidate passed 16/16, failed 11/16 with exact SSH-owner recognition disabled, and returned to 16/16. The byte-identical earlier 13-case oracle failed 11/13 on latest origin/main and the advisory-only PR head, passed 13/13 on the candidate, failed 9/13 with the owner fix disabled, and returned to 13/13 after restoration." + "summary": "The server-authoritative candidate passed all 20 deterministic pane selection, caller-path spoof rejection, WSL-local, unknown-owner, reconnect, replacement, cancellation, binding, and bounded-range cases. Earlier red/green runs failed when SSH-owner recognition, cancellation, and outage recovery were disabled, then returned to green after restoration." }, { - "date": "2026-08-21", + "date": "2026-08-22", "runner": "manual", "platform": "macos", "command": "Isolated headed Orca Electron/CDP against a throwaway Docker Linux SSH/relay target, with same-path desktop poison and remote-only live append", @@ -4768,12 +4791,12 @@ "command": "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-native-chat-runtime-routing.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "result": "passed", "durationSeconds": 9, - "summary": "The compiled headless runtime passed explicit-owner read and unknown-owner no-fallback assertions through the registered runtime RPC." + "summary": "The compiled headless runtime passed locator-free local compatibility and pane-scoped unknown-owner no-fallback assertions through the registered runtime RPC." } ], "runtimeBudget": { "p95Seconds": 15, - "scope": "17-case deterministic routing oracle plus one compiled headless runtime journey; Docker/headed Electron remains manual" + "scope": "20-case deterministic routing oracle, 44-case relay errno slice, and one compiled headless runtime journey; Docker/headed Electron remains manual" }, "flakeHistory": { "status": "soaking", @@ -4781,7 +4804,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The byte-identical 13-case oracle failed 11/13 on latest origin/main 59892a2f13 and advisory-only PR head aba8aafafa, passed 13/13 on the candidate, failed 9/13 when only exact SSH-owner recognition was disabled, and passed 13/13 after restoration. The prior 16-case candidate passed 16/16, failed 11/16 under the same owner mutation, and returned to 16/16. The post-review 17-case candidate passed 17/17, failed 3/17 when remote stat cancellation, outage rearming, and remote-specific unresolved copy were disabled together, and returned to 17/17. Baseline headed Electron visibly selected desktop lookalike bytes in both SSH workspace kinds; the candidate rendered only remote snapshot and tail bytes." + "evidence": "The byte-identical 13-case oracle failed 11/13 on latest origin/main 59892a2f13 and advisory-only PR head aba8aafafa, passed 13/13 on the candidate, failed 9/13 when only exact SSH-owner recognition was disabled, and passed 13/13 after restoration. The prior 16-case candidate passed 16/16, failed 11/16 under the same owner mutation, and returned to 16/16. The post-review 17-case candidate passed 17/17, failed 3/17 when remote stat cancellation, outage rearming, and remote-specific unresolved copy were disabled together, and returned to 17/17. The third-review relay errno slice passed 37/37, failed exactly four targeted assertions (33/37) when errno publication and relay-aware absence classification were disabled, and returned to 37/37 after restoration. The final mutation regressed publication to absence-only errnos and native-chat classification to ENOENT-only; the three targeted assertions failed (37/40), and restoration returned the slice to 40/40. Four additional OpenCode-review cases expanded the passing slice to 44/44. Baseline headed Electron visibly selected desktop lookalike bytes in both SSH workspace kinds; the candidate rendered only remote snapshot and tail bytes." }, "performanceBudget": { "required": true, @@ -4795,7 +4818,7 @@ ], "knownGaps": [ "Physical Windows SSH and live WSL transcript journeys were not run; WSL exact-path behavior is contract-tested.", - "The live Docker target used the current relay; older live relay and mixed-version pairing remain contract-only.", + "The live Docker target used the current relay; older relay fallback and mixed-version behavior are contract-tested rather than exercised in a live pairing.", "SSH provider sessions without an authoritative transcript path fail closed instead of running a broad remote session-id scan." ], "demotionRule": "Demote if a located session can read a non-owner filesystem, provider loss selects local bytes, reconnect leaves duplicate or stale watchers, any SSH poll uses whole-file reads, or the bounded-range oracle flakes without a product or harness defect." diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 87ed2586948..1443bcfc1b6 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -113,7 +113,7 @@ getting that wrong turns a skew into a false "nothing is blocked". unverifiable, the pane was unreadable, or the agent probe did not answer in time. A new client against an old host sees the field absent, which is why absence must read as -*unknown* and never as *not waiting*. Collapsing absent into `null` at any hop — including a +_unknown_ and never as _not waiting_. Collapsing absent into `null` at any hop — including a convenience `?? null` in an RPC handler — makes an old or unreachable peer indistinguishable from a healthy idle worker, which is the exact failure the field exists to remove. @@ -121,20 +121,22 @@ An old client against a new host ignores the key, as Rule 1 allows. New members `RuntimeTerminalWaitBlockedReason` are also Rule 1: no consumer switches exhaustively on it, and both the CLI and worker-start interpolate it as an opaque string. -## Known debt: JSON-RPC errors drop Node's string code +## Compatibility example: preserving Node's string error code -An error raised on an SSH host crosses the relay as JSON-RPC, and -`ssh-channel-multiplexer` rebuilds it with the TRANSPORT's numeric `code`. Node's -string code — `'ENOENT'`, `'EACCES'` — does not survive, so a caller on this side -cannot ask what kind of failure it was. +When a client-initiated request fails on an SSH host, the relay's numeric JSON-RPC +`code` cannot directly carry Node's string code such as `'ENOENT'` or `'EACCES'`. +The dispatcher copies recognized system codes into optional `error.data.errno`, and +`ssh-channel-multiplexer` retains the data when it rebuilds the error. This follows +Rule 1: old clients ignore the field, while new clients prefer it over message +inference. Host-initiated requests use a separate response path and do not publish +this field. -`isENOENT` in `src/main/ipc/filesystem-path-containment.ts` pays for that by also -matching Node's canonical message text, which is what makes remote worktree creation -work. The cost is that a host can make an unrelated failure read as "absent" by -putting that sentence in a message. +Relays predating the field omit it even for system errors, so the classifiers still +match canonical `ENOENT` and `ENOTDIR` messages where mixed-version compatibility +requires it. The fallback is used only when no string code or structured errno is +present, preventing a new relay's non-absence or malformed code from being overridden +by a contradictory message. -The exit is Rule 1: carry the original string code in a new optional field on the -error payload and read that instead. An old host omits it and the message match still -covers them; once hosts that send it are the floor, the message match can be deleted -rather than lived with at its ~10 call sites. Narrowing `isENOENT` back to `.code` -without doing this reinstates the bug — the transport has already overwritten it. +Once relays that publish `error.data.errno` are the supported floor, the message +fallback can be deleted. Narrowing `isENOENT` before then breaks new-client/old-host +pairings. diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts index bc0cceda048..7dfd96e35f8 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.test.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts @@ -60,11 +60,7 @@ describe('resolveMobileNativeChat', () => { agent: 'claude', sessionId: 'sess-1', transcriptPath: '/tmp/claude-real-transcript.jsonl', - providerSession: { - key: 'session_id', - id: 'sess-1', - transcriptPath: '/tmp/claude-real-transcript.jsonl' - } + paneKey: 'tab:leaf' }) }) @@ -74,7 +70,7 @@ describe('resolveMobileNativeChat', () => { type: 'terminal', agentStatus: status({ agentType: 'codex' }) }) - ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null, providerSession: null }) + ).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null, paneKey: 'tab:leaf' }) }) it('admits OpenClaude with its distinct agent identity', () => { @@ -82,7 +78,7 @@ describe('resolveMobileNativeChat', () => { agent: 'openclaude', sessionId: null, transcriptPath: null, - providerSession: null + paneKey: null }) }) diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts index 712e07e66c4..0074745ff60 100644 --- a/mobile/src/session/mobile-native-chat-eligibility.ts +++ b/mobile/src/session/mobile-native-chat-eligibility.ts @@ -1,5 +1,4 @@ import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' -import type { AgentProviderSessionMetadata } from '../../../src/shared/agent-session-resume' import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host' import { isNativeChatSupportedAgent, @@ -24,7 +23,7 @@ export type MobileNativeChatResolution = { /** Hook-reported transcript path. Recent Claude sessions cannot always be * resolved from the provider session id, so mobile forwards this to runtime. */ transcriptPath: string | null - providerSession: AgentProviderSessionMetadata | null + paneKey: string | null } export type MobileNativeChatTab = { @@ -63,7 +62,7 @@ export function resolveMobileNativeChat( agent, sessionId: tab.agentStatus?.providerSession?.id ?? null, transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null, - providerSession: tab.agentStatus?.providerSession ?? null + paneKey: tab.agentStatus?.paneKey ?? null } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 9ebba69a4e6..4eaa893f7e4 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -86,7 +86,7 @@ export function useMobileNativeChatController(args: { agent: activeChatResolution?.agent ?? null, sessionId: activeChatSessionId, transcriptPath: activeChatResolution?.transcriptPath ?? null, - providerSession: activeChatResolution?.providerSession ?? null + paneKey: activeChatResolution?.paneKey ?? null }) const { composerText: chatComposerText, diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index d3cc25019d9..c0b8acda278 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -2,7 +2,6 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { AgentProviderSessionMetadata } from '../../../src/shared/agent-session-resume' import type { RpcClient } from '../transport/rpc-client' import { useMobileNativeChatSession, @@ -33,47 +32,33 @@ describe('useMobileNativeChatSession', () => { renderer = null }) - function Harness({ - client, - providerSession - }: { - client: RpcClient | null - providerSession?: AgentProviderSessionMetadata - }): null { + function Harness({ client, paneKey }: { client: RpcClient | null; paneKey?: string }): null { state = useMobileNativeChatSession({ client, sourceIdentity: 'host-a\0workspace-a', agent: 'claude', sessionId: 'session', transcriptPath: null, - providerSession + paneKey: paneKey ?? null }) return null } - async function mount( - client: RpcClient, - providerSession?: AgentProviderSessionMetadata - ): Promise { + async function mount(client: RpcClient, paneKey?: string): Promise { await act(async () => { - renderer = create(createElement(Harness, { client, providerSession })) + renderer = create(createElement(Harness, { client, paneKey })) }) } - it('forwards SSH ownership through the mobile subscribe request', async () => { - const providerSession = { - key: 'session_id' as const, - id: 'session', - transcriptPath: '/tmp/session.jsonl', - executionHostId: 'ssh:builder' as const - } + it('forwards pane identity through the mobile subscribe request', async () => { + const paneKey = 'tab:leaf' const subscribe = vi.fn(() => () => {}) - await mount({ subscribe } as unknown as RpcClient, providerSession) + await mount({ subscribe } as unknown as RpcClient, paneKey) expect(subscribe).toHaveBeenCalledWith( 'nativeChat.subscribe', - expect.objectContaining({ providerSession }), + expect.objectContaining({ paneKey }), expect.any(Function) ) }) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index a12093b0fcf..d9b801e493d 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -1,11 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createNativeChatTranscriptRetention, encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { createNativeChatMerger, replaceList } from '../../../src/shared/native-chat-merge' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import type { AgentProviderSessionMetadata } from '../../../src/shared/agent-session-resume' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' import { @@ -52,32 +51,16 @@ export function useMobileNativeChatSession(args: { agent: string | null sessionId: string | null transcriptPath: string | null - providerSession?: AgentProviderSessionMetadata | null + paneKey: string | null }): MobileNativeChatSession { - const { client, sourceIdentity, agent, sessionId, transcriptPath, providerSession } = args + const { client, sourceIdentity, agent, sessionId, transcriptPath, paneKey } = args const [messages, setMessages] = useState([]) - const providerSessionKey = providerSession?.key - const providerSessionId = providerSession?.id - const providerTranscriptPath = providerSession?.transcriptPath - const providerExecutionHostId = providerSession?.executionHostId - const providerSessionLocator = useMemo( - () => - providerSessionKey && providerSessionId - ? { - key: providerSessionKey, - id: providerSessionId, - ...(providerTranscriptPath ? { transcriptPath: providerTranscriptPath } : {}), - ...(providerExecutionHostId ? { executionHostId: providerExecutionHostId } : {}) - } - : null, - [providerExecutionHostId, providerSessionId, providerSessionKey, providerTranscriptPath] - ) const identity = encodeNativeChatTranscriptIdentity([ sourceIdentity, agent, sessionId, transcriptPath, - providerSessionLocator?.executionHostId ?? null + paneKey ]) // Pre-read status is a pure function of the props, so derive it rather than // letting the effect write it a commit later. @@ -165,7 +148,7 @@ export function useMobileNativeChatSession(args: { limit: limitRef.current, subscriptionId: buildNativeChatSubscriptionId(agent, sessionId), ...(transcriptPath ? { transcriptPath } : {}), - ...(providerSessionLocator ? { providerSession: providerSessionLocator } : {}) + ...(paneKey ? { paneKey } : {}) }, (raw) => { if (cancelled) { @@ -226,7 +209,7 @@ export function useMobileNativeChatSession(args: { cancelled = true unsubscribe() } - }, [client, agent, sessionId, transcriptPath, providerSessionLocator, identity, setList]) + }, [client, agent, sessionId, transcriptPath, paneKey, identity, setList]) const loadEarlier = useCallback(() => { if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) { @@ -253,7 +236,7 @@ export function useMobileNativeChatSession(args: { limit: beforeOffset === null ? nextLimit : pageLimit, ...(beforeOffset === null ? {} : { beforeOffset }), ...(transcriptPath ? { transcriptPath } : {}), - ...(providerSessionLocator ? { providerSession: providerSessionLocator } : {}) + ...(paneKey ? { paneKey } : {}) }) if (!response.ok) { return @@ -292,7 +275,7 @@ export function useMobileNativeChatSession(args: { } } })() - }, [client, agent, sessionId, transcriptPath, providerSessionLocator, hasMore, setList]) + }, [client, agent, sessionId, transcriptPath, paneKey, hasMore, setList]) // Held for any unsettled read, not just an in-flight one: a stream error or a // dropped client would otherwise trade the conversation for an error card. diff --git a/src/main/agent-hooks/server-ai-vault-liveness.test.ts b/src/main/agent-hooks/server-ai-vault-liveness.test.ts index 43e25683908..5afb167927c 100644 --- a/src/main/agent-hooks/server-ai-vault-liveness.test.ts +++ b/src/main/agent-hooks/server-ai-vault-liveness.test.ts @@ -7,7 +7,6 @@ import { AgentHookServer } from './server' const PANE = makePaneKey('external-live', '11111111-1111-4111-8111-111111111111') const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-live' } -const LOCAL_PROVIDER_SESSION = { ...PROVIDER_SESSION, executionHostId: 'local' as const } function seedLocalIdentity(server: AgentHookServer): void { server.ingestRemote( @@ -34,7 +33,7 @@ describe('AgentHookServer AI Vault liveness identity', () => { expect(server.getStatusSnapshot()).toEqual([ expect.objectContaining({ connectionId: null, - providerSession: LOCAL_PROVIDER_SESSION, + providerSession: PROVIDER_SESSION, providerSessionOnly: true }) ]) @@ -56,7 +55,7 @@ describe('AgentHookServer AI Vault liveness identity', () => { try { expect(second.getStatusSnapshot()).toEqual([ expect.objectContaining({ - providerSession: LOCAL_PROVIDER_SESSION, + providerSession: PROVIDER_SESSION, providerSessionOnly: true, restoredUnconfirmed: true }) diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 1040ad1a143..622fc7d428b 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -29,36 +29,6 @@ afterEach(() => { }) describe('AgentHookServer listener replay', () => { - it('stamps authenticated loopback provider sessions as local', async () => { - const server = new AgentHookServer() - await server.start({ env: 'production' }) - try { - const env = server.buildPtyEnv() - const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN - }, - body: JSON.stringify( - buildBody({ - hook_event_name: 'UserPromptSubmit', - prompt: 'local owner', - session_id: 'local-session' - }) - ) - }) - - expect(response.status).toBe(204) - expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({ - key: 'session_id', - id: 'local-session', - executionHostId: 'local' - }) - } finally { - server.stop() - } - }) it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index d0e17431200..bb4802fb0ed 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -65,8 +65,7 @@ describe('AgentHookServer ingestRemote', () => { providerSession: { key: 'session_id', id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl', - executionHostId: 'ssh:conn-1' + transcriptPath: '/tmp/pi-session-1.jsonl' } }) ) @@ -91,34 +90,6 @@ describe('AgentHookServer ingestRemote', () => { } }) - it('keeps WSL relay transcript ownership on the local host', () => { - const server = new AgentHookServer() - const listener = vi.fn() - server.setListener(listener) - - server.ingestRemote( - { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSession: { - key: 'session_id', - id: 'claude-wsl-session', - transcriptPath: '/home/dev/.claude/projects/repo/claude-wsl-session.jsonl' - }, - payload: { state: 'working', prompt: 'p', agentType: 'claude' } - }, - 'wsl:Ubuntu' - ) - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - connectionId: 'wsl:Ubuntu', - providerSession: expect.objectContaining({ executionHostId: 'local' }) - }) - ) - }) - it('fans a Pi session-only status out to plugins, not just the renderer', () => { const server = new AgentHookServer() const rendererListener = vi.fn() diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 49d102416dd..8661701e1dc 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,27 +27,6 @@ afterEach(() => { }) describe('AgentHookServer ingestTerminalStatus', () => { - it('overwrites remote provider-session ownership with the authenticated connection', () => { - const server = new AgentHookServer() - server.ingestRemote( - { - paneKey: PANE, - providerSession: { - key: 'session_id', - id: 'session-1', - executionHostId: 'ssh:spoofed' - }, - payload: { state: 'working', prompt: 'route transcript', agentType: 'claude' } - }, - 'real-owner' - ) - - expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({ - key: 'session_id', - id: 'session-1', - executionHostId: 'ssh:real-owner' - }) - }) it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index c801aee3c64..8d0d6a3975c 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -69,11 +69,7 @@ describe('AgentHookServer listener replay', () => { state: 'done', prompt: 'long task', agentType: 'codex', - providerSession: { - key: 'session_id', - id: 'codex-interrupt-session-1', - executionHostId: 'ssh:conn-1' - }, + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, interrupted: true, receivedAt: 1_500, stateStartedAt: 1_500 @@ -82,11 +78,7 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenLastCalledWith( expect.objectContaining({ paneKey: PANE, - providerSession: { - key: 'session_id', - id: 'codex-interrupt-session-1', - executionHostId: 'ssh:conn-1' - }, + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, payload: expect.objectContaining({ state: 'done', interrupted: true }) }) ) diff --git a/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts b/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts index 17f20373d16..3ad27b64a38 100644 --- a/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts +++ b/src/main/agent-hooks/server-last-status-hydrate-validation.test.ts @@ -121,94 +121,6 @@ describe('Last-status persistence', () => { } }) - it('upgrades persisted provider sessions with their stored execution owner', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'persisted-ssh-owner', - receivedAt, - stateStartedAt: receivedAt, - providerSession: { - key: 'session_id', - id: 'persisted-session', - transcriptPath: '/remote/session.jsonl' - }, - payload: { state: 'done', prompt: '', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - providerSession: expect.objectContaining({ - id: 'persisted-session', - executionHostId: 'ssh:persisted-ssh-owner' - }) - }) - ) - } finally { - server.stop() - } - }) - - it('hydrates WSL relay provider sessions as local transcript owners', async () => { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - const receivedAt = recentTs() - writeFileSync( - lastStatusPath(), - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: 'wsl:Ubuntu', - receivedAt, - stateStartedAt: receivedAt, - providerSession: { - key: 'session_id', - id: 'persisted-wsl-session', - transcriptPath: '/home/dev/.claude/projects/repo/session.jsonl' - }, - payload: { state: 'done', prompt: '', agentType: 'claude' } - } - } - }), - 'utf8' - ) - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - try { - const listener = vi.fn() - server.setListener(listener) - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - providerSession: expect.objectContaining({ - id: 'persisted-wsl-session', - executionHostId: 'local' - }) - }) - ) - } finally { - server.stop() - } - }) - it('rejects a stale version mismatch on hydrate', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) writeFileSync( diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index b91b55b910a..f693c2d4c36 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -248,8 +248,7 @@ describe('Last-status persistence', () => { providerSession: { key: 'session_id', id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl', - executionHostId: 'local' + transcriptPath: '/tmp/pi-session-1.jsonl' } }) ) @@ -263,8 +262,7 @@ describe('Last-status persistence', () => { providerSession: { key: 'session_id', id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl', - executionHostId: 'local' + transcriptPath: '/tmp/pi-session-1.jsonl' } }) } finally { diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ecbee36d282..2b0457e064a 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -97,8 +97,6 @@ import { normalizeAgentProviderSession, type AgentProviderSessionMetadata } from '../../shared/agent-session-resume' -import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' -import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' export type { AgentHookSource } @@ -144,12 +142,6 @@ type PersistedAgentHookAuthorityCommitment = { observedAt: number } -function agentHookExecutionHostId(connectionId: string | null | undefined) { - return connectionId && !isWslHookRelayConnectionId(connectionId) - ? toSshExecutionHostId(connectionId) - : LOCAL_EXECUTION_HOST_ID -} - export type AgentHookStatusChangeEntry = { state: AgentStatusState receivedAt: number @@ -337,14 +329,7 @@ function sanitizeHydratedEntry( if (!payload) { return null } - const normalizedProviderSession = - normalizeAgentProviderSession(record.providerSession) ?? undefined - const providerSession: AgentProviderSessionMetadata | undefined = normalizedProviderSession - ? { - ...normalizedProviderSession, - executionHostId: agentHookExecutionHostId(connectionId) - } - : undefined + const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined const providerSessionOnly = record.providerSessionOnly === true const retainedForLiveness = record.retainedForLiveness === true const validRetainedIdentity = Boolean( @@ -1355,15 +1340,6 @@ export class AgentHookServer { onAccepted?: () => void, origin: AgentStatusObservationOrigin = 'hook' ): EnrichedAgentHookEventPayload { - if (payload.providerSession) { - payload = { - ...payload, - providerSession: { - ...payload.providerSession, - executionHostId: agentHookExecutionHostId(payload.connectionId) - } - } - } if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) @@ -2334,14 +2310,7 @@ export class AgentHookServer { typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 ? envelope.toolAgentType.trim() : undefined - const normalizedProviderSession = - normalizeAgentProviderSession(envelope.providerSession) ?? undefined - const providerSession: AgentProviderSessionMetadata | undefined = normalizedProviderSession - ? { - ...normalizedProviderSession, - executionHostId: agentHookExecutionHostId(trimmedConnectionId) - } - : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). const validatedPayload = normalizeAgentStatusPayload(envelope.payload) if (!validatedPayload) { diff --git a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts index 0af69cf1408..7212a28b7ce 100644 --- a/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -63,18 +63,12 @@ describe.skipIf(process.platform === 'win32')( const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim() orcaServer = new AgentHookServer() - const events: { - paneKey: string - payload: unknown - connectionId: string | null - providerSession: unknown - }[] = [] + const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = [] orcaServer.setListener((event) => { events.push({ paneKey: event.paneKey, payload: event.payload, - connectionId: event.connectionId, - providerSession: event.providerSession + connectionId: event.connectionId }) }) const server = orcaServer @@ -180,9 +174,7 @@ describe.skipIf(process.platform === 'win32')( const promptRes = await postClaude({ hook_event_name: 'UserPromptSubmit', - prompt: 'live roundtrip', - session_id: 'wsl-live-session', - transcript_path: '/home/dev/.claude/projects/repo/wsl-live-session.jsonl' + prompt: 'live roundtrip' }) expect(promptRes.status).toBe(204) await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: 10_000 }) @@ -192,11 +184,6 @@ describe.skipIf(process.platform === 'win32')( expect(working.state).toBe('working') expect(working.prompt).toBe('live roundtrip') expect(working.agentType).toBe('claude') - expect(events[0].providerSession).toMatchObject({ - id: 'wsl-live-session', - executionHostId: 'local' - }) - const stopRes = await postClaude({ hook_event_name: 'Stop' }) expect(stopRes.status).toBe(204) await vi.waitFor( diff --git a/src/main/ipc/filesystem-path-containment.ts b/src/main/ipc/filesystem-path-containment.ts index b236c90a94e..678cd4af25b 100644 --- a/src/main/ipc/filesystem-path-containment.ts +++ b/src/main/ipc/filesystem-path-containment.ts @@ -1,5 +1,8 @@ import { resolve, relative, isAbsolute, sep } from 'node:path' import { realpath } from 'node:fs/promises' +import { isENOENT } from '../../shared/definitive-filesystem-absence' + +export { isENOENT } /** * Check whether resolvedTarget is equal to or a descendant of resolvedBase. @@ -15,34 +18,6 @@ export function isDescendantOrEqual(resolvedTarget: string, resolvedBase: string return rel !== '' && !(rel === '..' || rel.startsWith(`..${sep}`)) && !isAbsolute(rel) } -/** - * Node's canonical ENOENT message. Matched in full so a message that merely mentions the word — a - * log line, a user's branch name — cannot be mistaken for a missing path. - */ -const ENOENT_MESSAGE = /\bENOENT: no such file or directory\b/ - -export function isENOENT(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - if ('code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { - return true - } - // Why the message is also consulted: an error raised on an SSH host crosses the relay as JSON-RPC, - // and ssh-channel-multiplexer rebuilds it with the TRANSPORT's numeric code (msg.error.code), so - // Node's 'ENOENT' string code no longer exists on the object by the time any caller sees it. Every - // caller here asks the same question — "is this path simply absent?" — and for a remote path the - // answer was unreachable: remotePathExists rethrew instead of returning false, which surfaced as a - // raw "ENOENT: no such file or directory, lstat ''" when creating a worktree over SSH. - // - // The cost, accepted rather than overlooked: a remote host can make an unrelated failure read as - // "absent" by putting that sentence in a message. Narrowing back to the code alone is not an - // option — the transport overwrites it, which IS the bug — and the blast radius is a host already - // trusted to run our relay. normalizeExistingPath is unaffected either way: its realpath runs - // locally and always carries a real .code, so symlink containment cannot be spoofed this way. - return ENOENT_MESSAGE.test(error.message) -} - export async function normalizeExistingPath(resolvedPath: string): Promise { try { return resolve(await realpath(resolvedPath)) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4950b0dc1a3..3af2208ef4a 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -2,12 +2,12 @@ import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { IFilesystemProvider } from '../providers/types' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { agentHookServer } from '../agent-hooks/server' const { handlers, listeners } = vi.hoisted(() => ({ handlers: new Map unknown>(), @@ -65,7 +65,7 @@ async function invokeReadSession(args: { sessionId: string limit?: number transcriptPath?: string - providerSession?: AgentProviderSessionMetadata + paneKey?: string }): Promise { registerNativeChatHandlers() const handler = handlers.get('nativeChat:readSession') @@ -107,24 +107,33 @@ describe('nativeChat:readSession handler', () => { }, supportsFileRangeRead: async () => true } as unknown as IFilesystemProvider + const paneKey = 'ipc-tab:11111111-1111-4111-8111-111111111111' + agentHookServer.ingestRemote( + { + paneKey, + providerSession: { + key: 'session_id', + id: 'same-session', + transcriptPath + }, + payload: { state: 'working', prompt: '', agentType: 'claude' } + }, + 'ipc-owner' + ) registerSshFilesystemProvider('ipc-owner', provider) try { const result = await invokeReadSession({ agent: 'claude', sessionId: 'same-session', transcriptPath, - providerSession: { - key: 'session_id', - id: 'same-session', - transcriptPath, - executionHostId: 'ssh:ipc-owner' - } + paneKey }) expect(result).toMatchObject({ messages: [{ id: 'remote-real' }] }) expect(result).not.toMatchObject({ messages: [{ id: 'desktop-poison' }] }) expect(readFile).not.toHaveBeenCalled() } finally { unregisterSshFilesystemProvider('ipc-owner') + agentHookServer.dropStatusEntry(paneKey) } }) it('preserves notFound so a just-created session stays in retry/loading', async () => { diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 2cc2a637c51..b59729acfa9 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -4,7 +4,6 @@ import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { clearNativeChatTranscriptCache } from '../native-chat/transcript-read-cache' import type { ReadTranscriptResult } from '../native-chat/transcript-reader' import { @@ -27,7 +26,7 @@ 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 - providerSession?: AgentProviderSessionMetadata + paneKey?: string } // Why: render and parse only the recent window so long transcripts do not stall @@ -42,7 +41,7 @@ async function readSession(args: NativeChatReadSessionArgs): Promise 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pending = beginPendingSubscription(sender.id, subscriptionId) @@ -184,7 +183,7 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg agent, sessionId, transcriptPath, - providerSession, + paneKey, initialLimit: limit, onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => { if (sender.isDestroyed()) { diff --git a/src/main/native-chat/native-chat-transcript-owner.ts b/src/main/native-chat/native-chat-transcript-owner.ts index 9c99490185a..41f18ec39bb 100644 --- a/src/main/native-chat/native-chat-transcript-owner.ts +++ b/src/main/native-chat/native-chat-transcript-owner.ts @@ -1,44 +1,61 @@ import { extname } from 'node:path' -import { - normalizeAgentProviderSession, - type AgentProviderSessionMetadata -} from '../../shared/agent-session-resume' -import { parseExecutionHostId } from '../../shared/execution-host' +import type { AgentType } from '../../shared/native-chat-types' +import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' +import { agentHookServer } from '../agent-hooks/server' export type NativeChatTranscriptOwner = | { kind: 'legacy-local' } - | { kind: 'local'; providerSession: AgentProviderSessionMetadata } - | { - kind: 'ssh' - connectionId: string - providerSession: AgentProviderSessionMetadata - transcriptPath: string | null - } + | { kind: 'local'; transcriptPath?: string } + | { kind: 'ssh'; connectionId: string; transcriptPath: string | null } | { kind: 'unknown' } -export function resolveNativeChatTranscriptOwner(args: { +type NativeChatOwnerArgs = { + agent?: AgentType sessionId: string - providerSession?: AgentProviderSessionMetadata -}): NativeChatTranscriptOwner { - if (args.providerSession === undefined) { - return { kind: 'legacy-local' } - } - const providerSession = normalizeAgentProviderSession(args.providerSession) - if (!providerSession || providerSession.id !== args.sessionId) { + paneKey?: string + transcriptPath?: string +} + +function exactTranscriptPath(value: string | undefined): string | undefined { + const transcriptPath = value?.trim() + return transcriptPath && extname(transcriptPath) === '.jsonl' ? transcriptPath : undefined +} + +export function resolveNativeChatTranscriptOwner( + args: NativeChatOwnerArgs +): NativeChatTranscriptOwner { + const sessionId = args.sessionId.trim() + if (!sessionId) { return { kind: 'unknown' } } - const host = parseExecutionHostId(providerSession.executionHostId) - if (host?.kind === 'local') { - return { kind: 'local', providerSession } + const rows = args.paneKey + ? agentHookServer.getStatusSnapshotForPane(args.paneKey) + : agentHookServer.getStatusSnapshot() + const matches = rows.filter( + (row) => + row.providerSession?.id === sessionId && + (!args.agent || !row.agentType || row.agentType === args.agent) + ) + if (matches.length === 0) { + return args.paneKey ? { kind: 'unknown' } : { kind: 'legacy-local' } } - if (host?.kind === 'ssh') { - const transcriptPath = providerSession.transcriptPath?.trim() - return { - kind: 'ssh', - connectionId: host.targetId, - providerSession, - transcriptPath: transcriptPath && extname(transcriptPath) === '.jsonl' ? transcriptPath : null - } + const owners = new Map() + for (const row of matches) { + const connectionId = row.connectionId ?? null + const transcriptPath = exactTranscriptPath(row.providerSession?.transcriptPath) ?? null + owners.set(`${connectionId ?? 'local'}\0${transcriptPath ?? ''}`, row) } - return { kind: 'unknown' } + if (owners.size !== 1) { + return { kind: 'unknown' } + } + const row = owners.values().next().value + if (!row) { + return { kind: 'unknown' } + } + const transcriptPath = exactTranscriptPath(row.providerSession?.transcriptPath) + const connectionId = row.connectionId?.trim() + if (!connectionId || isWslHookRelayConnectionId(connectionId)) { + return { kind: 'local', ...(transcriptPath ? { transcriptPath } : {}) } + } + return { kind: 'ssh', connectionId, transcriptPath: transcriptPath ?? null } } diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index dc138bffec5..29faba7fb86 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,7 +1,6 @@ import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import { resolveNativeChatTranscriptAgent, type NativeChatTranscriptAgent @@ -73,7 +72,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 - providerSession?: AgentProviderSessionMetadata + /** Stable pane identity for server-owned transcript routing. */ + paneKey?: string } /** diff --git a/src/main/native-chat/ssh-transcript-host-routing.test.ts b/src/main/native-chat/ssh-transcript-host-routing.test.ts index 265f6662667..de16c2aba8c 100644 --- a/src/main/native-chat/ssh-transcript-host-routing.test.ts +++ b/src/main/native-chat/ssh-transcript-host-routing.test.ts @@ -2,14 +2,27 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' import type { FileRangeReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read' const mocks = vi.hoisted(() => ({ getProvider: vi.fn<(connectionId: string) => IFilesystemProvider | undefined>(), generation: 1, - changeListeners: new Set<(connectionId: string, generation: number) => void>() + changeListeners: new Set<(connectionId: string, generation: number) => void>(), + ownerListeners: new Set<() => void>(), + statusRows: [] as Record[] +})) + +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + getStatusSnapshot: () => mocks.statusRows, + getStatusSnapshotForPane: (paneKey: string) => + mocks.statusRows.filter((row) => row.paneKey === paneKey), + subscribeProviderSessionChanges: (listener: () => void) => { + mocks.ownerListeners.add(listener) + return () => mocks.ownerListeners.delete(listener) + } + } })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ @@ -37,7 +50,7 @@ import { readIncrementalTranscriptMessages } from './transcript-incremental-read import { TranscriptRangeReadInvalidatedError, type TranscriptRangeFs } from './transcript-range-fs' const SESSION_ID = 'session-ssh-owner' -const EXECUTION_HOST_ID = 'ssh:ssh-owner' +const PANE_KEY = 'tab-owner:11111111-1111-4111-8111-111111111111' function claudeLine(id: string, text: string): string { return `${JSON.stringify({ @@ -86,18 +99,36 @@ function routedArgs(transcriptPath: string): { agent: 'claude' sessionId: string transcriptPath: string - providerSession: AgentProviderSessionMetadata + paneKey: string } { + setOwner('ssh-owner', transcriptPath) return { agent: 'claude' as const, sessionId: SESSION_ID, transcriptPath, - providerSession: { - key: 'session_id' as const, - id: SESSION_ID, - transcriptPath, - executionHostId: EXECUTION_HOST_ID + paneKey: PANE_KEY + } +} + +function setOwner(connectionId: string | null, transcriptPath?: string): void { + setStatusRows([ + { + paneKey: PANE_KEY, + agentType: 'claude', + connectionId, + providerSession: { + key: 'session_id', + id: SESSION_ID, + ...(transcriptPath ? { transcriptPath } : {}) + } } + ]) +} + +function setStatusRows(rows: Record[]): void { + mocks.statusRows = rows + for (const listener of mocks.ownerListeners) { + listener() } } @@ -115,6 +146,8 @@ beforeEach(() => { mocks.getProvider.mockReset() mocks.generation = 1 mocks.changeListeners.clear() + mocks.ownerListeners.clear() + mocks.statusRows = [] tempRoots = [] }) @@ -149,6 +182,31 @@ describe('native chat SSH transcript host routing (#13663)', () => { expect(stat).toHaveBeenCalledWith(transcriptPath, { signal: controller.signal }) }) + it('ignores a caller transcript path that contradicts the hook-owned path', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-path-authority-')) + tempRoots.push(root) + const callerPath = join(root, 'caller.jsonl') + const ownedPath = '/remote/owned.jsonl' + await writeFile(callerPath, claudeLine('caller-poison', 'wrong path')) + const remote = memoryProvider( + new Map([[ownedPath, Buffer.from(claudeLine('hook-owned', 'right path'))]]) + ) + mocks.getProvider.mockReturnValue(remote.provider) + setOwner('ssh-owner', ownedPath) + + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: SESSION_ID, + transcriptPath: callerPath, + paneKey: PANE_KEY, + limit: 40 + }) + + expect(result).toMatchObject({ messages: [{ id: 'hook-owned' }] }) + expect(remote.stat).toHaveBeenCalledWith(ownedPath, expect.anything()) + expect(remote.stat).not.toHaveBeenCalledWith(callerPath, expect.anything()) + }) + it('retries consecutive ranged snapshots when the remote file is replaced mid-read', async () => { const transcriptPath = '/tmp/replaced-mid-read.jsonl' const oldBytes = Buffer.from( @@ -302,14 +360,11 @@ describe('native chat SSH transcript host routing (#13663)', () => { agent: 'claude', sessionId: SESSION_ID, transcriptPath, - providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath }, + paneKey: PANE_KEY, limit: 40 }) - expect(result).toEqual({ - error: 'Transcript unverifiable on the remote host', - unverifiable: true - }) + expect(result).toEqual({ error: 'Transcript unverifiable on the remote host' }) expect(mocks.getProvider).not.toHaveBeenCalled() }) @@ -325,7 +380,7 @@ describe('native chat SSH transcript host routing (#13663)', () => { agent: 'claude', sessionId: SESSION_ID, transcriptPath, - providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath }, + paneKey: PANE_KEY, onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => snapshots.push({ ids: messages.map((message) => message.id), error }), onAppend: () => {} @@ -342,17 +397,13 @@ describe('native chat SSH transcript host routing (#13663)', () => { tempRoots.push(root) const transcriptPath = join(root, 'session.jsonl') await writeFile(transcriptPath, claudeLine('local-real', 'local host')) + setOwner(null, transcriptPath) const result = await readNativeChatTranscriptTail({ agent: 'claude', sessionId: SESSION_ID, transcriptPath, - providerSession: { - key: 'session_id', - id: SESSION_ID, - transcriptPath, - executionHostId: 'local' - }, + paneKey: PANE_KEY, limit: 40 }) @@ -360,6 +411,25 @@ describe('native chat SSH transcript host routing (#13663)', () => { expect(mocks.getProvider).not.toHaveBeenCalled() }) + it('treats WSL hook provenance as local transcript ownership', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-host-wsl-')) + tempRoots.push(root) + const transcriptPath = join(root, 'session.jsonl') + await writeFile(transcriptPath, claudeLine('wsl-local', 'local WSL bridge')) + setOwner('wsl:Ubuntu', transcriptPath) + + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: SESSION_ID, + transcriptPath, + paneKey: PANE_KEY, + limit: 40 + }) + + expect(result).toMatchObject({ messages: [{ id: 'wsl-local' }] }) + expect(mocks.getProvider).not.toHaveBeenCalled() + }) + it('keeps locator-free legacy requests host-local for older clients', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-host-legacy-local-')) tempRoots.push(root) @@ -377,7 +447,7 @@ describe('native chat SSH transcript host routing (#13663)', () => { expect(mocks.getProvider).not.toHaveBeenCalled() }) - it('selects the exact owner when two hosts expose the same id and path', async () => { + it('selects the requested pane when two hosts expose the same session id and path', async () => { const transcriptPath = '/tmp/same-session.jsonl' const wrong = memoryProvider( new Map([[transcriptPath, Buffer.from(claudeLine('wrong-remote', 'wrong host'))]]) @@ -388,9 +458,26 @@ describe('native chat SSH transcript host routing (#13663)', () => { mocks.getProvider.mockImplementation((connectionId) => connectionId === 'ssh-owner' ? right.provider : wrong.provider ) + setStatusRows([ + { + paneKey: 'other-pane:11111111-1111-4111-8111-111111111111', + agentType: 'claude', + connectionId: 'wrong-owner', + providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath } + }, + { + paneKey: PANE_KEY, + agentType: 'claude', + connectionId: 'ssh-owner', + providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath } + } + ]) const result = await readNativeChatTranscriptTail({ - ...routedArgs(transcriptPath), + agent: 'claude', + sessionId: SESSION_ID, + transcriptPath, + paneKey: PANE_KEY, limit: 40 }) @@ -399,22 +486,51 @@ describe('native chat SSH transcript host routing (#13663)', () => { expect(wrong.readFileRange).not.toHaveBeenCalled() }) + it('does not use a caller path to disambiguate locator-free ownership', async () => { + const requestedPath = '/tmp/requested.jsonl' + setStatusRows([ + { + paneKey: PANE_KEY, + agentType: 'claude', + connectionId: 'ssh-owner', + providerSession: { key: 'session_id', id: SESSION_ID, transcriptPath: requestedPath } + }, + { + paneKey: 'other-pane:11111111-1111-4111-8111-111111111111', + agentType: 'claude', + connectionId: 'wrong-owner', + providerSession: { + key: 'session_id', + id: SESSION_ID, + transcriptPath: '/tmp/other.jsonl' + } + } + ]) + + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: SESSION_ID, + transcriptPath: requestedPath, + limit: 40 + }) + + expect(result).toEqual({ error: 'Transcript unverifiable on the remote host' }) + expect(mocks.getProvider).not.toHaveBeenCalled() + }) + it('does not run desktop id discovery when an SSH locator has no transcript path', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-host-no-path-')) tempRoots.push(root) const projectDir = join(root, 'project') await mkdir(projectDir, { recursive: true }) await writeFile(join(projectDir, `${SESSION_ID}.jsonl`), claudeLine('desktop-id-hit', 'wrong')) + setOwner('ssh-owner') const result = await readNativeChatTranscriptTail({ agent: 'claude', sessionId: SESSION_ID, claudeProjectsDir: root, - providerSession: { - key: 'session_id', - id: SESSION_ID, - executionHostId: EXECUTION_HOST_ID - }, + paneKey: PANE_KEY, limit: 40 }) @@ -427,11 +543,7 @@ describe('native chat SSH transcript host routing (#13663)', () => { agent: 'claude', sessionId: SESSION_ID, claudeProjectsDir: root, - providerSession: { - key: 'session_id', - id: SESSION_ID, - executionHostId: EXECUTION_HOST_ID - }, + paneKey: PANE_KEY, onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => snapshots.push({ ids: messages.map((message) => message.id), error }), onAppend: () => {} @@ -472,6 +584,45 @@ describe('native chat SSH transcript host routing (#13663)', () => { } }) + it('fails closed when the owner row disappears and rebinds when it returns', async () => { + const transcriptPath = '/tmp/rebound-owner-session.jsonl' + const remote = memoryProvider( + new Map([[transcriptPath, Buffer.from(claudeLine('remote-owner', 'remote'))]]) + ) + mocks.getProvider.mockReturnValue(remote.provider) + const snapshots: { ids: string[]; error?: string }[] = [] + const subscription = await subscribeNativeChatTranscript({ + ...routedArgs(transcriptPath), + initialLimit: 40, + onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => + snapshots.push({ ids: messages.map((message) => message.id), error }), + onAppend: () => {}, + debounceMs: 0, + reconciliationIntervalMs: 20 + }) + + try { + await vi.waitFor(() => + expect(snapshots).toContainEqual({ ids: ['remote-owner'], error: undefined }) + ) + setStatusRows([]) + await vi.waitFor(() => + expect(snapshots).toContainEqual({ + ids: [], + error: 'Transcript unverifiable on the remote host' + }) + ) + setOwner('ssh-owner', transcriptPath) + await vi.waitFor(() => + expect(snapshots.filter((snapshot) => snapshot.ids.includes('remote-owner'))).toHaveLength( + 2 + ) + ) + } finally { + subscription.unsubscribe() + } + }) + it('recovers from provider loss with one fresh watcher and no stale snapshot', async () => { const transcriptPath = '/work/folder-workspace/session.jsonl' const first = memoryProvider( @@ -584,33 +735,6 @@ describe('native chat SSH transcript host routing (#13663)', () => { } }) - it('replaces an unverifiable advisory after the remote host becomes reachable', async () => { - const transcriptPath = '/tmp/not-yet-written.jsonl' - const { provider } = memoryProvider(new Map()) - mocks.getProvider.mockReturnValue(undefined) - const errors: (string | undefined)[] = [] - const subscription = await subscribeNativeChatTranscript({ - ...routedArgs(transcriptPath), - onInitialSnapshot: (_messages, _hasMore, _beforeOffset, error) => errors.push(error), - onAppend: () => {}, - resolvePollIntervalMs: 5, - unresolvedNoticeMs: 0 - }) - - try { - await vi.waitFor(() => expect(errors).toEqual(['Transcript unverifiable on the remote host'])) - changeProvider(provider) - await vi.waitFor(() => - expect(errors).toEqual([ - 'Transcript unverifiable on the remote host', - 'No transcript found for this session on its remote host. It may not have been written yet.' - ]) - ) - } finally { - subscription.unsubscribe() - } - }) - it('keeps provider methods bound to their owning instance', async () => { const transcriptPath = '/tmp/bound-provider-session.jsonl' const provider = { diff --git a/src/main/native-chat/ssh-transcript-read.ts b/src/main/native-chat/ssh-transcript-read.ts index 61f07f58f91..5857982f9f8 100644 --- a/src/main/native-chat/ssh-transcript-read.ts +++ b/src/main/native-chat/ssh-transcript-read.ts @@ -1,4 +1,5 @@ import type { AgentType } from '../../shared/native-chat-types' +import { isDefinitiveAbsenceFromRelay } from '../../shared/definitive-filesystem-absence' import type { ResolveSessionFileOptions } from './session-file-resolver' import type { NativeChatTranscriptOwner } from './native-chat-transcript-owner' import { createSshTranscriptRangeFs } from './ssh-transcript-range-fs' @@ -80,7 +81,7 @@ export async function readOwnedSshNativeChatTranscriptTail( } } catch (error) { signal?.throwIfAborted() - if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + if (isDefinitiveAbsenceFromRelay(error)) { return { error: error instanceof Error ? error.message : String(error), notFound: true } } if (isTranscriptHostUnverifiableError(error)) { diff --git a/src/main/native-chat/ssh-transcript-relay-errno.test.ts b/src/main/native-chat/ssh-transcript-relay-errno.test.ts new file mode 100644 index 00000000000..99895625ee1 --- /dev/null +++ b/src/main/native-chat/ssh-transcript-relay-errno.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { IFilesystemProvider } from '../providers/types' + +const mocks = vi.hoisted(() => ({ + provider: undefined as IFilesystemProvider | undefined +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProviderSnapshot: () => + mocks.provider ? { provider: mocks.provider, generation: 1 } : null, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.' +})) + +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + getStatusSnapshot: () => [], + getStatusSnapshotForPane: () => [ + { + paneKey: 'relay-errno-pane', + agentType: 'claude', + connectionId: 'owner', + providerSession: { + key: 'session_id', + id: 'remote-session', + transcriptPath: '/home/user/session.jsonl' + } + } + ] + } +})) + +import { readNativeChatTranscriptTail } from './transcript-tail-reader' + +const transcriptPath = '/home/user/session.jsonl' + +function missingProvider(error: Error): IFilesystemProvider { + return { + stat: vi.fn(async () => { + throw error + }), + readFileRange: vi.fn(), + supportsFileRangeRead: vi.fn(async () => true) + } as unknown as IFilesystemProvider +} + +function missingRangeProvider(error: Error): IFilesystemProvider { + return { + stat: vi.fn(async () => ({ + size: 1, + type: 'file', + mtime: 1, + mtimeMs: 1, + dev: 1, + ino: 1 + })), + readFileRange: vi.fn(async () => { + throw error + }), + supportsFileRangeRead: vi.fn(async () => true) + } as unknown as IFilesystemProvider +} + +async function readTranscriptWithProvider(provider: IFilesystemProvider) { + mocks.provider = provider + return readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'remote-session', + limit: 20, + paneKey: 'relay-errno-pane' + }) +} + +function readMissingTranscript(error: Error) { + return readTranscriptWithProvider(missingProvider(error)) +} + +describe('SSH transcript relay errno classification', () => { + beforeEach(() => { + mocks.provider = undefined + }) + + it('classifies the relay structured errno as a retryable missing transcript', async () => { + const error = Object.assign(new Error('remote path missing'), { + code: -32000, + data: { errno: 'ENOENT' } + }) + + await expect(readMissingTranscript(error)).resolves.toMatchObject({ notFound: true }) + }) + + it('classifies structured ENOTDIR as a retryable missing transcript', async () => { + const error = Object.assign(new Error('remote path component is not a directory'), { + code: -32000, + data: { errno: 'ENOTDIR' } + }) + + await expect(readMissingTranscript(error)).resolves.toMatchObject({ notFound: true }) + }) + + it('keeps canonical-message compatibility with an older relay', async () => { + const error = Object.assign( + new Error(`ENOENT: no such file or directory, stat '${transcriptPath}'`), + { code: -32000 } + ) + + await expect(readMissingTranscript(error)).resolves.toMatchObject({ notFound: true }) + }) + + it('keeps canonical ENOTDIR compatibility with an older relay', async () => { + const error = Object.assign(new Error(`ENOTDIR: not a directory, stat '${transcriptPath}'`), { + code: -32000 + }) + + await expect(readMissingTranscript(error)).resolves.toMatchObject({ notFound: true }) + }) + + it('classifies a transcript removed after stat as retryable notFound', async () => { + const error = Object.assign(new Error('remote path disappeared'), { + code: -32000, + data: { errno: 'ENOENT' } + }) + + await expect(readTranscriptWithProvider(missingRangeProvider(error))).resolves.toMatchObject({ + notFound: true + }) + }) + + it('prefers a structured non-absence errno over a legacy-looking message', async () => { + const error = Object.assign( + new Error(`ENOENT: no such file or directory, stat '${transcriptPath}'`), + { code: -32000, data: { errno: 'EACCES' } } + ) + + await expect(readMissingTranscript(error)).resolves.toEqual({ error: error.message }) + }) + + it('does not let malformed structured errno data enable message fallback', async () => { + const error = Object.assign( + new Error(`ENOENT: no such file or directory, stat '${transcriptPath}'`), + { code: -32000, data: { errno: 2 } } + ) + + await expect(readMissingTranscript(error)).resolves.toEqual({ error: error.message }) + }) +}) diff --git a/src/main/native-chat/ssh-transcript-subscription.ts b/src/main/native-chat/ssh-transcript-subscription.ts index 948fb936863..821753f6581 100644 --- a/src/main/native-chat/ssh-transcript-subscription.ts +++ b/src/main/native-chat/ssh-transcript-subscription.ts @@ -2,9 +2,7 @@ import { onSshFilesystemProviderChanged } from '../providers/ssh-filesystem-disp import type { NativeChatTranscriptOwner } from './native-chat-transcript-owner' import { createSshTranscriptRangeFs } from './ssh-transcript-range-fs' import { - DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS, isTranscriptHostUnverifiableError, - REMOTE_UNRESOLVED_TRANSCRIPT_MESSAGE, TRANSCRIPT_UNVERIFIABLE_MESSAGE } from './transcript-host-verdict' import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' @@ -27,6 +25,15 @@ export function subscribeSshNativeChatTranscript( if (!decode || setupSignal?.aborted) { return { unsubscribe: () => {}, watching: false } } + if (!owner.transcriptPath) { + try { + args.onInitialSnapshot?.([], false, 0, 'Transcript unavailable') + } catch { + // The server-owned locator will rebind if the hook later reports a path. + } + return { unsubscribe: () => {}, watching: true } + } + const transcriptPath = owner.transcriptPath const lineDecoder = decode let closed = false let installed: NativeChatTranscriptSubscription | null = null @@ -37,10 +44,6 @@ export function subscribeSshNativeChatTranscript( let attemptInFlight = false let rerunRequested = false let unverifiableEmitted = false - let unresolvedNoticeEmitted = false - let missingPathEmitted = false - const startedAt = Date.now() - const unresolvedNoticeMs = args.unresolvedNoticeMs ?? DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS const stopProviderChanges = onSshFilesystemProviderChanged((connectionId) => { if (connectionId !== owner.connectionId || closed) { @@ -76,23 +79,6 @@ export function subscribeSshNativeChatTranscript( } } - function emitUnresolvedNotice(): void { - if ( - unverifiableEmitted || - unresolvedNoticeEmitted || - !args.onInitialSnapshot || - Date.now() - startedAt < unresolvedNoticeMs - ) { - return - } - unresolvedNoticeEmitted = true - try { - args.onInitialSnapshot([], false, 0, REMOTE_UNRESOLVED_TRANSCRIPT_MESSAGE) - } catch { - // The advisory cannot own retry liveness when its subscriber is closing. - } - } - function scheduleRetry(): void { if (closed || installed || retryTimer) { return @@ -120,25 +106,14 @@ export function subscribeSshNativeChatTranscript( const controller = new AbortController() attemptController = controller try { - if (!owner.transcriptPath) { - if (!missingPathEmitted) { - missingPathEmitted = true - try { - args.onInitialSnapshot?.([], false, 0, 'Transcript unavailable') - } catch { - // A terminal missing-path result must not escape a closing subscriber. - } - } - return - } const rangeFs = await createSshTranscriptRangeFs(owner.connectionId, controller.signal) const result = await installTranscriptWatcher( - owner.transcriptPath, + transcriptPath, lineDecoder, { ...args, - filePath: owner.transcriptPath, - transcriptPath: owner.transcriptPath, + filePath: transcriptPath, + transcriptPath, rangeFs }, controller.signal @@ -154,14 +129,12 @@ export function subscribeSshNativeChatTranscript( return } scheduleRetry() - emitUnresolvedNotice() } catch (error) { if (!closed && !controller.signal.aborted) { if (isTranscriptHostUnverifiableError(error)) { emitUnverifiable() } scheduleRetry() - emitUnresolvedNotice() } } finally { if (attemptController === controller) { @@ -191,6 +164,9 @@ export function subscribeSshNativeChatTranscript( installed = null } } + if (setupSignal?.aborted) { + subscription.unsubscribe() + } void runAttempt() return subscription } diff --git a/src/main/native-chat/transcript-host-verdict.ts b/src/main/native-chat/transcript-host-verdict.ts index 91928e16d74..2a665a865e9 100644 --- a/src/main/native-chat/transcript-host-verdict.ts +++ b/src/main/native-chat/transcript-host-verdict.ts @@ -3,15 +3,8 @@ import { SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-fi import { WslTranscriptFsError } from './wsl-transcript-fs-gate' export const TRANSCRIPT_UNVERIFIABLE_MESSAGE = 'Transcript unverifiable on the remote host' -export const DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS = 60_000 -export const UNRESOLVED_TRANSCRIPT_MESSAGE = - 'No transcript found for this session on this machine. If the agent runs on a remote host its transcript lives there; otherwise it may not have been written yet.' -export const REMOTE_UNRESOLVED_TRANSCRIPT_MESSAGE = - 'No transcript found for this session on its remote host. It may not have been written yet.' export class TranscriptHostUnverifiableError extends Error { - readonly verdict = 'unverifiable' as const - constructor(message = TRANSCRIPT_UNVERIFIABLE_MESSAGE) { super(message) this.name = 'TranscriptHostUnverifiableError' @@ -34,8 +27,8 @@ export function isTranscriptHostUnverifiableError(error: unknown): boolean { ) } -export function transcriptUnverifiableResult(): { error: string; unverifiable: true } { - return { error: TRANSCRIPT_UNVERIFIABLE_MESSAGE, unverifiable: true } +export function transcriptUnverifiableResult(): { error: string } { + return { error: TRANSCRIPT_UNVERIFIABLE_MESSAGE } } export function transcriptInitialReadErrorMessage(error: unknown): string { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 82fe2c25417..d03502c7319 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -241,7 +241,7 @@ export type NativeChatTranscriptTailResult = hasMore: boolean beforeOffset: number } - | { error: string; notFound?: true; unverifiable?: true } + | { error: string; notFound?: true } export async function readNativeChatTranscriptTail( args: ResolveSessionFileOptions & { @@ -268,9 +268,7 @@ export async function readNativeChatTranscriptTail( }) } const localArgs = - owner.kind === 'local' - ? { ...args, transcriptPath: owner.providerSession.transcriptPath } - : args + owner.kind === 'local' ? { ...args, transcriptPath: owner.transcriptPath } : args const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) if (!decode) { diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index 0236facd88d..3ab37fa5fda 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -29,9 +29,6 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { debounceMs?: number /** Test-only override for the production resolve-poll backoff. */ resolvePollIntervalMs?: number - /** Test-only override for how long an unresolved transcript stays silent - * before the first "unavailable" snapshot. */ - unresolvedNoticeMs?: number /** Test-only override for the host-side watcher reconciliation interval. */ reconciliationIntervalMs?: number /** Positional filesystem for a transcript owned by another execution host. */ diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts deleted file mode 100644 index ec8b69bf16b..00000000000 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as HostReadableTranscriptPathModule from './host-readable-transcript-path' - -const mocks = vi.hoisted(() => ({ - install: vi.fn(), - resolve: vi.fn(), - toHostReadable: vi.fn() -})) - -vi.mock('./session-file-resolver', () => ({ - resolveSessionFilePath: mocks.resolve -})) -vi.mock('./transcript-watch-engine', () => ({ - getActiveNativeChatWatcherCount: vi.fn(() => 0), - installTranscriptWatcher: mocks.install -})) -vi.mock('./host-readable-transcript-path', async (importOriginal) => ({ - ...(await importOriginal()), - toHostReadableTranscriptPath: mocks.toHostReadable -})) - -import { subscribeNativeChatTranscript } from './transcript-watch' -import { WslTranscriptFsError } from './wsl-transcript-fs-gate' - -const realPlatform = process.platform - -function setPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { value: platform, configurable: true }) -} - -describe('native chat transcript resolve polling', () => { - beforeEach(() => { - vi.useFakeTimers() - mocks.install.mockReset().mockReturnValue(null) - mocks.resolve.mockReset().mockResolvedValue(null) - mocks.toHostReadable.mockReset().mockResolvedValue(null) - // Why: a POSIX exact path is a WSL guest path on win32 and is deliberately - // never installed raw there, so pin the platform instead of inheriting the - // host's — otherwise these cases only hold on non-Windows machines. - setPlatform('linux') - }) - - afterEach(() => { - setPlatform(realPlatform) - vi.useRealTimers() - }) - - it('fast-probes an exact hook path without repeatedly scanning the session tree', async () => { - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - transcriptPath: '/missing/exact.jsonl', - resolvePollIntervalMs: 10, - onAppend: () => {} - }) - expect(mocks.resolve).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(100) - expect(mocks.install.mock.calls.length).toBeGreaterThan(1) - expect(mocks.resolve).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(4_900) - expect(mocks.resolve).toHaveBeenCalledTimes(2) - - subscription.unsubscribe() - const callsAfterUnsubscribe = mocks.install.mock.calls.length - await vi.advanceTimersByTimeAsync(100) - expect(mocks.install).toHaveBeenCalledTimes(callsAfterUnsubscribe) - }) - - it('retries the WSL translation on the slow cadence, never installing the raw guest path', async () => { - // Why: each translation probes the UNC twin per distro over the 9P - // share; doing it every fast tick would hammer the main process (#10326). - setPlatform('win32') - const subscription = await subscribeNativeChatTranscript({ - agent: 'codex', - sessionId: 'session-id', - transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl', - resolvePollIntervalMs: 10, - onAppend: () => {} - }) - - await vi.advanceTimersByTimeAsync(100) - expect(mocks.toHostReadable).toHaveBeenCalledTimes(1) - expect(mocks.install.mock.calls.some(([filePath]) => String(filePath).startsWith('/'))).toBe( - false - ) - - await vi.advanceTimersByTimeAsync(5_100) - expect(mocks.toHostReadable).toHaveBeenCalledTimes(2) - - subscription.unsubscribe() - }) - - it('installs the translated UNC path once the WSL transcript becomes readable', async () => { - setPlatform('win32') - const unc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-session-id.jsonl' - mocks.toHostReadable.mockResolvedValue(unc) - - const subscription = await subscribeNativeChatTranscript({ - agent: 'codex', - sessionId: 'session-id', - transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl', - resolvePollIntervalMs: 10, - onAppend: () => {} - }) - - await vi.advanceTimersByTimeAsync(100) - expect(mocks.install.mock.calls.some(([filePath]) => filePath === unc)).toBe(true) - // Memoized: a successful translation is not re-probed on later ticks. - expect(mocks.toHostReadable).toHaveBeenCalledTimes(1) - - subscription.unsubscribe() - }) - - it('keeps resolving on every retry when no exact hook path is available', async () => { - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - resolvePollIntervalMs: 10, - onAppend: () => {} - }) - - await vi.advanceTimersByTimeAsync(35) - expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) - subscription.unsubscribe() - }) - - it('degrades a gate-refused initial resolve to the poll fallback instead of failing', async () => { - const engine = { unsubscribe: vi.fn(), watching: true } - mocks.resolve - .mockRejectedValueOnce(new WslTranscriptFsError('unavailable', 'stuck permits')) - .mockResolvedValue('/home/ada/found.jsonl') - mocks.install.mockImplementation((filePath: string) => (filePath ? engine : null)) - - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - resolvePollIntervalMs: 10, - onAppend: () => {} - }) - expect(subscription.watching).toBe(true) - - await vi.advanceTimersByTimeAsync(20) - expect( - mocks.install.mock.calls.some(([filePath]) => filePath === '/home/ada/found.jsonl') - ).toBe(true) - subscription.unsubscribe() - expect(engine.unsubscribe).toHaveBeenCalled() - }) - - // The deadline notice is generic; the gate's message names a stalled distro and - // says to retry. A distro that goes unhealthy after the deadline must not be - // silenced by the notice that fired first. - it('still surfaces a WSL gate refusal that follows the unresolved-transcript notice', async () => { - const errors: (string | undefined)[] = [] - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - resolvePollIntervalMs: 10, - unresolvedNoticeMs: 100, - onAppend: () => {}, - onInitialSnapshot: (_messages, _hasMore, _beforeOffset, error) => errors.push(error) - }) - - await vi.advanceTimersByTimeAsync(150) - expect(errors).toEqual([ - expect.stringMatching(/no transcript found for this session on this machine/i) - ]) - - mocks.resolve.mockRejectedValue(new WslTranscriptFsError('unavailable', 'stuck permits')) - await vi.advanceTimersByTimeAsync(50) - expect(errors).toEqual([ - expect.stringMatching(/no transcript found for this session on this machine/i), - 'stuck permits' - ]) - - subscription.unsubscribe() - }) - - it('keeps polling when the advisory subscriber throws', async () => { - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - resolvePollIntervalMs: 10, - unresolvedNoticeMs: 100, - onAppend: () => {}, - onInitialSnapshot: () => { - throw new Error('subscriber blew up') - } - }) - - await vi.advanceTimersByTimeAsync(150) - const resolvesAtNotice = mocks.resolve.mock.calls.length - await vi.advanceTimersByTimeAsync(100) - expect(mocks.resolve.mock.calls.length).toBeGreaterThan(resolvesAtNotice) - - subscription.unsubscribe() - }) - - // Sibling of the advisory case: the gate's emit runs inside runAttempt's catch, - // so a throw there escapes `void runAttempt()` and skips the reschedule below it. - it('keeps polling when the WSL-gate subscriber throws', async () => { - mocks.resolve.mockRejectedValue(new WslTranscriptFsError('unavailable', 'stuck permits')) - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - resolvePollIntervalMs: 10, - unresolvedNoticeMs: 10_000, - onAppend: () => {}, - onInitialSnapshot: () => { - throw new Error('subscriber blew up') - } - }) - - await vi.advanceTimersByTimeAsync(50) - const resolvesAtGateError = mocks.resolve.mock.calls.length - await vi.advanceTimersByTimeAsync(100) - expect(mocks.resolve.mock.calls.length).toBeGreaterThan(resolvesAtGateError) - - subscription.unsubscribe() - }) - - it('still fails subscribe on non-gate resolver errors', async () => { - mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed')) - - await expect( - subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'session-id', - onAppend: () => {} - }) - ).rejects.toThrow('resolver crashed') - }) - - it('cancels queued WSL resolution when the subscription closes', async () => { - setPlatform('win32') - let receivedSignal: AbortSignal | undefined - mocks.toHostReadable.mockImplementation( - (_path: string, deps: { signal?: AbortSignal }) => - new Promise((_resolve, reject) => { - receivedSignal = deps.signal - deps.signal?.addEventListener('abort', () => reject(deps.signal?.reason), { once: true }) - }) - ) - const subscription = await subscribeNativeChatTranscript({ - agent: 'codex', - sessionId: 'session-id', - transcriptPath: '/home/ada/.codex/sessions/rollout-session-id.jsonl', - resolvePollIntervalMs: 10, - onAppend: () => {} - }) - - await vi.advanceTimersByTimeAsync(10) - expect(receivedSignal?.aborted).toBe(false) - subscription.unsubscribe() - expect(receivedSignal?.aborted).toBe(true) - }) - - it('does not install after initial resolution is cancelled', async () => { - let finishResolve: ((path: string) => void) | undefined - mocks.resolve.mockImplementation( - () => - new Promise((resolve) => { - finishResolve = resolve - }) - ) - const controller = new AbortController() - const cancelled = new Error('setup cancelled') - const setup = subscribeNativeChatTranscript( - { - agent: 'codex', - sessionId: 'session-id', - onAppend: () => {} - }, - controller.signal - ) - - controller.abort(cancelled) - finishResolve?.('/transcript.jsonl') - await expect(setup).rejects.toBe(cancelled) - expect(mocks.install).not.toHaveBeenCalled() - }) - - it('tears down a watcher returned after initial setup is cancelled', async () => { - mocks.resolve.mockResolvedValue('/transcript.jsonl') - const installControl: { - finish?: (subscription: { unsubscribe: () => void; watching: boolean }) => void - } = {} - const unsubscribe = vi.fn() - mocks.install.mockImplementation( - () => - new Promise((resolve) => { - installControl.finish = resolve - }) - ) - const controller = new AbortController() - const cancelled = new Error('setup cancelled') - const setup = subscribeNativeChatTranscript( - { - agent: 'codex', - sessionId: 'session-id', - onAppend: () => {} - }, - controller.signal - ) - await vi.waitFor(() => expect(mocks.install).toHaveBeenCalledOnce()) - - controller.abort(cancelled) - installControl.finish?.({ unsubscribe, watching: true }) - await expect(setup).rejects.toBe(cancelled) - expect(unsubscribe).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/native-chat/transcript-watch-unresolved-notice.test.ts b/src/main/native-chat/transcript-watch-unresolved-notice.test.ts deleted file mode 100644 index 4d936b24dc2..00000000000 --- a/src/main/native-chat/transcript-watch-unresolved-notice.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { NativeChatMessage } from '../../shared/native-chat-types' -import { - getActiveNativeChatWatcherCount, - subscribeNativeChatTranscript, - type NativeChatTranscriptSubscription, - type SubscribeNativeChatTranscriptArgs -} from './transcript-watch' - -let tempRoots: string[] = [] -let subscriptions: NativeChatTranscriptSubscription[] = [] - -beforeEach(() => { - tempRoots = [] - subscriptions = [] -}) - -afterEach(async () => { - // Tear down before the temp dirs go away: a failed assertion must not leak a - // poll loop into the next test's watcher-count assertions. - for (const subscription of subscriptions) { - subscription.unsubscribe() - } - subscriptions = [] - await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) - tempRoots = [] -}) - -async function pendingFilePath(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unresolved-')) - tempRoots.push(root) - return join(root, 'rollout.jsonl') -} - -type Snapshot = { messages: NativeChatMessage[]; error?: string } - -async function subscribe( - args: Omit, - snapshots: Snapshot[] -): Promise { - const subscription = await subscribeNativeChatTranscript({ - agent: 'claude', - sessionId: 'ignored', - onAppend: () => {}, - onInitialSnapshot: (messages, _hasMore, _beforeOffset, error) => - snapshots.push({ messages, error }), - ...args - }) - subscriptions.push(subscription) - return subscription -} - -/** Why: a plain write creates the file empty first, and a fast poll tick can - * install on the zero-byte file — the initial snapshot would then be empty and - * the turn would arrive as an append instead. Rename publishes it complete. */ -async function publishTranscript(filePath: string, content: string): Promise { - const staging = `${filePath}.staging` - await writeFile(staging, content) - await rename(staging, filePath) -} - -function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { - return `${JSON.stringify({ - type: role, - uuid, - timestamp: new Date().toISOString(), - message: { role, content: [{ type: 'text', text }] } - })}\n` -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (predicate()) { - return - } - await new Promise((resolve) => setTimeout(resolve, 10)) - } - throw new Error('waitFor timed out') -} - -// #13663: an agent in an SSH worktree writes its transcript on the remote host, -// so this host resolves nothing and the poll spins forever with no frame — the -// client sits on a spinner indefinitely. The poll is still correct for a -// not-yet-flushed local session (#8401), so the notice must be advisory. -describe('subscribeNativeChatTranscript unresolved-transcript notice (#13663)', () => { - it('reports the transcript as unavailable once the deadline passes', async () => { - const snapshots: Snapshot[] = [] - - await subscribe( - { filePath: await pendingFilePath(), resolvePollIntervalMs: 10, unresolvedNoticeMs: 30 }, - snapshots - ) - - await waitFor(() => snapshots.length > 0) - expect(snapshots[0]?.messages).toEqual([]) - expect(snapshots[0]?.error).toMatch(/no transcript found for this session on this machine/i) - }) - - // The #8401 false-positive guard: before the deadline an unresolved transcript - // is just slow, and one that arrives in that window must load clean. - it('stays silent before the deadline and loads a late transcript without an advisory', async () => { - const snapshots: Snapshot[] = [] - const filePath = await pendingFilePath() - - await subscribe( - { filePath, debounceMs: 5, resolvePollIntervalMs: 10, unresolvedNoticeMs: 10_000 }, - snapshots - ) - - await new Promise((resolve) => setTimeout(resolve, 120)) - expect(snapshots).toHaveLength(0) - - await publishTranscript(filePath, claudeLine('u-1', 'user', 'slow first flush')) - await waitFor(() => snapshots.length > 0) - expect(snapshots[0]?.error).toBeUndefined() - expect(snapshots[0]?.messages.map((m) => m.id)).toEqual(['u-1']) - }) - - // The notice must not be a terminal state: the poll keeps running, so a - // transcript that shows up late still loads. - it('keeps polling after the notice and still loads the transcript once it appears', async () => { - const snapshots: Snapshot[] = [] - const filePath = await pendingFilePath() - - const sub = await subscribe( - { filePath, debounceMs: 5, resolvePollIntervalMs: 10, unresolvedNoticeMs: 30 }, - snapshots - ) - - await waitFor(() => snapshots.some((s) => s.error)) - - // Sit past the deadline for ~10 further poll ticks: the advisory is latched, - // so it must not repeat on every miss. - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(snapshots).toHaveLength(1) - - await publishTranscript(filePath, claudeLine('u-1', 'user', 'late but real')) - await waitFor(() => snapshots.some((s) => s.messages.some((m) => m.id === 'u-1'))) - - expect(snapshots.at(-1)?.error).toBeUndefined() - expect(getActiveNativeChatWatcherCount()).toBe(1) - expect(snapshots.filter((s) => s.error)).toHaveLength(1) - - sub.unsubscribe() - expect(getActiveNativeChatWatcherCount()).toBe(0) - }) -}) diff --git a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts index f061dcbaae1..814addff83c 100644 --- a/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-exact-path.test.ts @@ -63,12 +63,6 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { agent: 'codex', sessionId: 'wsl-sess', transcriptPath: ROLLOUT_LINUX, - providerSession: { - key: 'session_id', - id: 'wsl-sess', - transcriptPath: ROLLOUT_LINUX, - executionHostId: 'local' - }, resolvePollIntervalMs: 10, onAppend: () => {} }) @@ -90,12 +84,6 @@ describe('exact hook path install on a Windows host with WSL (#10326)', () => { agent: 'codex', sessionId: 'wsl-sess', transcriptPath: ROLLOUT_LINUX, - providerSession: { - key: 'session_id', - id: 'wsl-sess', - transcriptPath: ROLLOUT_LINUX, - executionHostId: 'local' - }, resolvePollIntervalMs: 10, onAppend: () => {} }) diff --git a/src/main/native-chat/transcript-watch-wsl-stall.test.ts b/src/main/native-chat/transcript-watch-wsl-stall.test.ts index 3c508151016..f39ac4d8c6a 100644 --- a/src/main/native-chat/transcript-watch-wsl-stall.test.ts +++ b/src/main/native-chat/transcript-watch-wsl-stall.test.ts @@ -142,6 +142,44 @@ describe('native chat transcript subscription with a stalled install stat', () = subscription.unsubscribe() }) + it('enters the poll loop when the first resolve throws a non-gate error', async () => { + mocks.resolve.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' })) + const snapshots: Snapshot[] = [] + + const subscription = await subscribeCollecting(snapshots) + const attemptsAfterSetup = mocks.resolve.mock.calls.length + await vi.advanceTimersByTimeAsync(500) + + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(attemptsAfterSetup) + expect(snapshots).toEqual([]) + expect(unhandled).toEqual([]) + subscription.unsubscribe() + }) + + it('keeps polling when the retryable snapshot callback throws', async () => { + mocks.resolve.mockResolvedValue(UNC_PATH) + mocks.stat.mockImplementation(stalls) + + const subscribing = subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + onAppend: () => {}, + onInitialSnapshot: () => { + throw new Error('subscriber closed') + }, + initialLimit: 10, + resolvePollIntervalMs: 50, + reconciliationIntervalMs: 1_000 + }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 1) + const subscription = await subscribing + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + 500) + + expect(mocks.resolve.mock.calls.length).toBeGreaterThan(1) + expect(unhandled).toEqual([]) + subscription.unsubscribe() + }) + it('detaches drain waiters on unsubscribe and starts no further gated work', async () => { mocks.resolve.mockResolvedValue(UNC_PATH) mocks.stat.mockResolvedValue({ ...EMPTY_STATS, size: 64 }) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index eb5d3c3acad..10c26912880 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -1,5 +1,6 @@ import { extname } from 'node:path' import type { NativeChatMessage } from '../../shared/native-chat-types' +import { agentHookServer } from '../agent-hooks/server' import { needsWslHostTranslation, toHostReadableTranscriptPath @@ -11,14 +12,10 @@ import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' -import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' import { resolveNativeChatTranscriptOwner } from './native-chat-transcript-owner' import { subscribeSshNativeChatTranscript } from './ssh-transcript-subscription' -import { - DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS, - TRANSCRIPT_UNVERIFIABLE_MESSAGE, - UNRESOLVED_TRANSCRIPT_MESSAGE -} from './transcript-host-verdict' +import { TRANSCRIPT_UNVERIFIABLE_MESSAGE } from './transcript-host-verdict' export { readNativeChatTranscriptTail } from './transcript-tail-reader' export { getActiveNativeChatWatcherCount } from './transcript-watch-engine' @@ -56,12 +53,6 @@ async function attemptInstall( const INITIAL_RESOLVE_POLL_MS = 500 const MAX_RESOLVE_POLL_MS = 5_000 const FALLBACK_RESOLVE_POLL_MS = 5_000 -// Why: a transcript this host can never open (the agent runs on an SSH remote — -// #13663) is indistinguishable from one that just hasn't flushed yet, and both -// poll forever. Past this deadline say so instead of spinning silently. A slow -// first flush can legitimately exceed it (#8401), so this is advisory only: the -// poll keeps running and the real transcript replaces the notice. - function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null { const path = args.transcriptPath?.trim() return path && extname(path) === '.jsonl' ? path : null @@ -74,8 +65,6 @@ function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | * unsubscribe() cancels it. Reports watching:true — the engine's first drain * delivers the initial snapshot once the file appears, so subscribers must not * settle a merely not-yet-flushed transcript into a permanent error (#8401). - * Past the unresolved deadline it emits one advisory error snapshot (#13663) and - * keeps polling, so a late transcript still replaces it. */ function subscribeViaResolvePoll( args: SubscribeNativeChatTranscriptArgs, @@ -92,40 +81,9 @@ function subscribeViaResolvePoll( // the exact-path install doesn't wait on the slower id-glob (#10326). let hostReadableExactPath: string | null = null let lastWslTranslateAt = 0 - // Separate latches: the WSL gate's retryable message is the actionable one, so - // it must still reach a subscriber that already got the generic deadline - // notice — never the reverse. Both set only after an actual emit, so a - // subscriber without the callback can't consume the one frame each gets. let gateErrorEmitted = false - let unresolvedNoticeEmitted = false - const startedAt = Date.now() - const unresolvedNoticeMs = args.unresolvedNoticeMs ?? DEFAULT_TRANSCRIPT_UNRESOLVED_NOTICE_MS const resolveController = new AbortController() - /** Both advisories run under `void runAttempt()`, so a subscriber that throws - * (an emit on a dying RPC connection) would surface as an unhandled rejection - * and skip the rest of the attempt — including its reschedule. Contain it. */ - function emitAdvisory(message: string): void { - try { - args.onInitialSnapshot?.([], false, 0, message) - } catch { - // Advisory only: keep polling so a later real snapshot still replaces it. - } - } - - /** Non-terminal: the poll keeps running and a later real snapshot replaces - * this, so a slow-to-flush session still recovers (#8401). */ - function emitUnresolvedNotice(): void { - if (gateErrorEmitted || unresolvedNoticeEmitted || !args.onInitialSnapshot) { - return - } - if (Date.now() - startedAt < unresolvedNoticeMs) { - return - } - unresolvedNoticeEmitted = true - emitAdvisory(UNRESOLVED_TRANSCRIPT_MESSAGE) - } - function scheduleAttempt(): void { if (closed) { return @@ -193,11 +151,14 @@ function subscribeViaResolvePoll( // stalled WSL distro would otherwise poll silently forever, leaving the // client at 'loading'; emit its retryable message once and keep polling, // so a later tick's real snapshot still replaces it. Narrowed with a bare - // instanceof, never wslTranscriptFsRefusal — that helper rethrows, and - // runAttempt is invoked as `void runAttempt()`. + // instanceof; runAttempt is invoked as `void runAttempt()`. if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) { gateErrorEmitted = true - emitAdvisory(error.message) + try { + args.onInitialSnapshot([], false, 0, error.message) + } catch { + // A closing subscriber cannot own retry liveness. + } } result = null } @@ -210,9 +171,7 @@ function subscribeViaResolvePoll( installed = result return } - // Schedule first: a subscriber callback that throws must not kill the poll. scheduleAttempt() - emitUnresolvedNotice() } scheduleAttempt() @@ -235,6 +194,121 @@ function subscribeViaResolvePoll( } } +async function subscribeLocalNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + signal?: AbortSignal +): Promise { + let installed: NativeChatTranscriptSubscription | null + try { + installed = await attemptInstall(args, decode, signal) + } catch { + signal?.throwIfAborted() + installed = null + } + if (installed) { + return installed + } + signal?.throwIfAborted() + return subscribeViaResolvePoll(args, decode) +} + +async function subscribeHostResolvedNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + setupSignal?: AbortSignal +): Promise { + let closed = false + let installed: NativeChatTranscriptSubscription | null = null + let ownerKey: string | null = null + let attemptVersion = 0 + let attemptController: AbortController | null = null + let unverifiableEmitted = false + + function emitUnverifiable(): void { + if (unverifiableEmitted) { + return + } + unverifiableEmitted = true + try { + args.onInitialSnapshot?.([], false, 0, TRANSCRIPT_UNVERIFIABLE_MESSAGE) + } catch { + // Ownership changes must remain observable even if the client is closing. + } + } + + async function rebind(): Promise { + if (closed) { + return + } + const owner = resolveNativeChatTranscriptOwner(args) + const nextOwnerKey = + owner.kind === 'ssh' + ? `ssh:${owner.connectionId}:${owner.transcriptPath ?? ''}` + : owner.kind === 'local' + ? `local:${owner.transcriptPath ?? ''}` + : owner.kind + if (nextOwnerKey === ownerKey && installed) { + return + } + ownerKey = nextOwnerKey + const version = ++attemptVersion + attemptController?.abort() + installed?.unsubscribe() + installed = null + if (owner.kind === 'unknown') { + emitUnverifiable() + return + } + unverifiableEmitted = false + const controller = new AbortController() + attemptController = controller + try { + const next = + owner.kind === 'ssh' + ? subscribeSshNativeChatTranscript(owner, args, controller.signal) + : await subscribeLocalNativeChatTranscript( + owner.kind === 'local' ? { ...args, transcriptPath: owner.transcriptPath } : args, + decode, + controller.signal + ) + if (closed || controller.signal.aborted || version !== attemptVersion) { + next.unsubscribe() + return + } + installed = next + } catch { + if (!closed && !controller.signal.aborted && version === attemptVersion) { + emitUnverifiable() + } + } + } + + const stopOwnerChanges = agentHookServer.subscribeProviderSessionChanges(() => void rebind()) + const abortFromSetup = (): void => subscription.unsubscribe() + const subscription: NativeChatTranscriptSubscription = { + watching: true, + unsubscribe: () => { + if (closed) { + return + } + closed = true + attemptVersion++ + setupSignal?.removeEventListener('abort', abortFromSetup) + stopOwnerChanges() + attemptController?.abort() + installed?.unsubscribe() + installed = null + } + } + setupSignal?.addEventListener('abort', abortFromSetup, { once: true }) + if (setupSignal?.aborted) { + subscription.unsubscribe() + } + await rebind() + return subscription +} + /** * Subscribe to live appends on an agent's transcript file. Returns an * unsubscribe fn that tears the watcher down completely. @@ -266,36 +340,7 @@ export async function subscribeNativeChatTranscript( } if (!args.filePath) { - const owner = resolveNativeChatTranscriptOwner(args) - if (owner.kind === 'unknown') { - try { - args.onInitialSnapshot?.([], false, 0, TRANSCRIPT_UNVERIFIABLE_MESSAGE) - } catch { - // The caller owns teardown; unknown ownership must still avoid local IO. - } - return { unsubscribe: () => {}, watching: true } - } - if (owner.kind === 'ssh') { - return subscribeSshNativeChatTranscript(owner, args, setupSignal) - } - if (owner.kind === 'local') { - args = { ...args, transcriptPath: owner.providerSession.transcriptPath } - } + return subscribeHostResolvedNativeChatTranscript(args, decode, setupSignal) } - - let installed: NativeChatTranscriptSubscription | null - try { - installed = await attemptInstall(args, decode, setupSignal) - } catch (error) { - setupSignal?.throwIfAborted() - // Why: a gate-refused resolve (stalled WSL distro) must degrade to the - // resolve-poll fallback below, not fail the subscribe outright. - void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal - installed = null - } - if (installed) { - return installed - } - setupSignal?.throwIfAborted() - return subscribeViaResolvePoll(args, decode) + return subscribeLocalNativeChatTranscript(args, decode, setupSignal) } diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-schema.ts b/src/main/runtime/rpc/methods/native-chat-rpc-schema.ts index b3aa651edde..90ec80fec94 100644 --- a/src/main/runtime/rpc/methods/native-chat-rpc-schema.ts +++ b/src/main/runtime/rpc/methods/native-chat-rpc-schema.ts @@ -1,18 +1,8 @@ import { z } from 'zod' -import { normalizeAgentProviderSession } from '../../../../shared/agent-session-resume' import type { AgentType } from '../../../../shared/native-chat-types' export const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 -const NativeChatProviderSession = z.unknown().transform((value, context) => { - const normalized = normalizeAgentProviderSession(value) - if (!normalized) { - context.addIssue({ code: 'custom', message: 'Invalid provider session' }) - return z.NEVER - } - return normalized -}) - export const NativeChatSession = z.object({ agent: z .unknown() @@ -31,7 +21,7 @@ export const NativeChatSession = z.object({ .optional(), subscriptionId: z.string().min(1).optional(), transcriptPath: z.string().min(1).optional(), - providerSession: NativeChatProviderSession.optional(), + paneKey: z.string().min(1).optional(), beforeOffset: z.number().int().nonnegative().optional() }) diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 9b50a38171d..a8fb648a267 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -56,7 +56,7 @@ const watcher = vi.hoisted(() => ({ timestamp: number | null } ) => void - providerSession?: unknown + paneKey?: string }, watching: true, setupSignal: undefined as AbortSignal | undefined, @@ -163,20 +163,12 @@ function activeWatcherArgs(): NonNullable { } describe('nativeChat.readSession clientKind truncation gating', () => { - it('forwards the provider-session locator through runtime RPC reads', async () => { - const providerSession = { - key: 'session_id', - id: 's', - transcriptPath: '/tmp/s.jsonl', - executionHostId: 'ssh:builder' - } + it('forwards pane identity through runtime RPC reads', async () => { + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' - await readSessionHandler()( - { agent: 'claude', sessionId: 's', providerSession }, - ctxWith('mobile') - ) + await readSessionHandler()({ agent: 'claude', sessionId: 's', paneKey }, ctxWith('mobile')) - expect(tailRead.args).toMatchObject({ providerSession }) + expect(tailRead.args).toMatchObject({ paneKey }) }) it('passes request cancellation to transcript resolution', async () => { const controller = new AbortController() @@ -437,23 +429,18 @@ describe('nativeChat.readSession clientKind truncation gating', () => { }) describe('nativeChat.subscribe initial snapshot', () => { - it('forwards the provider-session locator through runtime RPC subscriptions', async () => { + it('forwards pane identity through runtime RPC subscriptions', async () => { watcher.watching = true watcher.args = null - const providerSession = { - key: 'session_id', - id: 's', - transcriptPath: '/tmp/s.jsonl', - executionHostId: 'ssh:builder' - } + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' await subscribeHandler()( - { agent: 'claude', sessionId: 's', providerSession }, + { agent: 'claude', sessionId: 's', paneKey }, streamingContext('mobile'), vi.fn() ) - expect(activeWatcherArgs()).toMatchObject({ providerSession }) + expect(activeWatcherArgs()).toMatchObject({ paneKey }) }) it('preserves long assistant text across mobile stream frame types', async () => { diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 9c0dd4d7341..b256c8a8098 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -173,7 +173,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, - providerSession: params.providerSession, + paneKey: params.paneKey, limit, beforeOffset: params.beforeOffset }, @@ -235,7 +235,7 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ agent: params.agent, sessionId: params.sessionId, transcriptPath: params.transcriptPath, - providerSession: params.providerSession, + paneKey: params.paneKey, initialLimit: limit, onInitialSnapshot: (messages, hasMore, beforeOffset, error, lifecycle) => { if (closed) { diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index cd5f881a9cf..ad2bf6a5c43 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -3,7 +3,6 @@ import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' -import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' // notFound marks a not-yet-on-disk miss (retry-worthy) vs a real read/parse error (#8401). export type NativeChatReadSessionResult = @@ -11,7 +10,7 @@ export type NativeChatReadSessionResult = messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle } - | { error: string; notFound?: true; unverifiable?: true } + | { error: string; notFound?: true } /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] @@ -48,22 +47,23 @@ export type NativeChatSubscribeArgs = { subscriptionId: string agent: AgentType sessionId: string - /** Authoritative transcript path from the agent hook (providerSession). */ + /** Hook-reported path hint; pane-scoped requests are re-resolved by main/runtime. */ transcriptPath?: string - providerSession?: AgentProviderSessionMetadata + /** Stable pane identity used by main/runtime to resolve the authoritative hook owner. */ + paneKey?: string /** First snapshot size; later readSession calls grow this for pagination. */ limit?: number } export type NativeChatApi = { /** Read the on-disk transcript for an agent + session id, windowed to the most recent `limit` - * turns. `transcriptPath` is the hook-reported authoritative path, preferred over the id glob. */ + * turns. `transcriptPath` is a hook-reported hint for legacy/local resolution. */ readSession: ( agent: AgentType, sessionId: string, limit?: number, transcriptPath?: string, - providerSession?: AgentProviderSessionMetadata + paneKey?: 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 dd68dfa856f..b00d4441703 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4467,14 +4467,14 @@ const api = { sessionId: string, limit?: number, transcriptPath?: string, - providerSession?: AgentProviderSessionMetadata + paneKey?: string ): Promise => ipcRenderer.invoke('nativeChat:readSession', { agent, sessionId, limit, transcriptPath, - providerSession + paneKey }), /** Start live tailing; `onFrame` receives snapshots, replacements, and appends. */ subscribe: ( @@ -4483,7 +4483,7 @@ const api = { agent: AgentType sessionId: string transcriptPath?: string - providerSession?: AgentProviderSessionMetadata + paneKey?: string limit?: number }, onFrame: (frame: NativeChatSubscriptionFrame) => void diff --git a/src/relay/dispatcher-structured-error.test.ts b/src/relay/dispatcher-structured-error.test.ts index 0ba8e164216..1a61f2b8538 100644 --- a/src/relay/dispatcher-structured-error.test.ts +++ b/src/relay/dispatcher-structured-error.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { SKILL_INSTALL_RPC_ERROR_CODE } from '../shared/skill-install-failure' import { RelayDispatcher } from './dispatcher' import { encodeJsonRpcFrame, MessageType, type JsonRpcResponse } from './protocol' @@ -36,9 +37,26 @@ describe('RelayDispatcher structured errors', () => { dispatcher.onRequest('fail.private', async () => { throw Object.assign(new Error('boom'), { data: { secret: 'do-not-publish' } }) }) + dispatcher.onRequest('fail.enoent', async () => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }) + dispatcher.onRequest('fail.eacces', async () => { + throw Object.assign(new Error('denied'), { code: 'EACCES' }) + }) + dispatcher.onRequest('fail.invalid-skill', async () => { + throw Object.assign(new Error('invalid skill failure'), { + code: SKILL_INSTALL_RPC_ERROR_CODE, + data: { secret: 'do-not-publish' } + }) + }) dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 6, method: 'fail.structured' }, 1, 0)) dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 7, method: 'fail.private' }, 2, 0)) + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 8, method: 'fail.enoent' }, 3, 0)) + dispatcher.feed(encodeJsonRpcFrame({ jsonrpc: '2.0', id: 9, method: 'fail.eacces' }, 4, 0)) + dispatcher.feed( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: 10, method: 'fail.invalid-skill' }, 5, 0) + ) await vi.advanceTimersByTimeAsync(0) const responses = written.map(decodeResponse) @@ -51,5 +69,19 @@ describe('RelayDispatcher structured errors', () => { code: -32000, message: 'boom' }) + expect(responses.find((message) => message?.id === 8)?.error).toEqual({ + code: -32000, + message: 'missing', + data: { errno: 'ENOENT' } + }) + expect(responses.find((message) => message?.id === 9)?.error).toEqual({ + code: -32000, + message: 'denied', + data: { errno: 'EACCES' } + }) + expect(responses.find((message) => message?.id === 10)?.error).toEqual({ + code: -32000, + message: 'invalid skill failure' + }) }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 00e1836d9d7..36135ae12b0 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines -- dispatcher keeps client routing, cancellation, and framing state together */ +import { constants as osConstants } from 'node:os' import { FrameDecoder, HEADER_LENGTH, @@ -72,6 +73,14 @@ export type MethodHandler = ( context: RequestContext ) => Promise +function systemErrnoCode(code: unknown): string | null { + if (typeof code !== 'string') { + return null + } + // libuv can surface UNKNOWN even though Node omits it from os.constants.errno. + return code === 'UNKNOWN' || Object.hasOwn(osConstants.errno, code) ? code : null +} + export type NotificationHandler = (params: Record, context: RequestContext) => void type RelayClient = { @@ -1055,7 +1064,9 @@ export class RelayDispatcher { errorCode === SKILL_INSTALL_RPC_ERROR_CODE ? SkillInstallFailureSchema.safeParse((err as { data?: unknown }).data) : null - const data = skillFailure?.success === true ? skillFailure.data : undefined + const errno = systemErrnoCode(errorCode) + const data = + skillFailure?.success === true ? skillFailure.data : errno ? { errno } : undefined const accepted = this.sendResponse( client, req.id, diff --git a/src/relay/integration.test.ts b/src/relay/integration.test.ts index 71d09c78de6..3f24a2a8bc4 100644 --- a/src/relay/integration.test.ts +++ b/src/relay/integration.test.ts @@ -23,6 +23,7 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' +import { isENOENT } from '../shared/definitive-filesystem-absence' function gitInit(dir: string): void { execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' }) @@ -195,9 +196,39 @@ describe('Integration: Client Mux ↔ Relay Dispatcher', () => { }) it('errors propagate correctly through the protocol', async () => { - await expect( - mux.request('fs.stat', { filePath: '/nonexistent/path/that/does/not/exist' }) - ).rejects.toThrow() + const error = await mux + .request('fs.stat', { filePath: path.join(tmpDir, 'missing') }) + .catch((reason: unknown) => reason) + + expect(error).toBeInstanceOf(Error) + expect(error).toMatchObject({ code: -32000, data: { errno: 'ENOENT' } }) + }) + + it.skipIf(process.platform === 'win32')( + 'preserves ENOTDIR from the real filesystem through the protocol', + async () => { + const filePath = path.join(tmpDir, 'not-a-directory') + writeFileSync(filePath, 'file') + + const error = await mux + .request('fs.stat', { filePath: path.join(filePath, 'child') }) + .catch((reason: unknown) => reason) + + expect(error).toMatchObject({ code: -32000, data: { errno: 'ENOTDIR' } }) + } + ) + + it('preserves a non-absence errno over a legacy-looking message', async () => { + dispatcher.onRequest('test.fsDenied', async () => { + throw Object.assign(new Error("ENOENT: no such file or directory, stat '/private'"), { + code: 'EACCES' + }) + }) + + const error = await mux.request('test.fsDenied', {}).catch((reason: unknown) => reason) + + expect(error).toMatchObject({ code: -32000, data: { errno: 'EACCES' } }) + expect(isENOENT(error)).toBe(false) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx b/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx index 27cd6dc7924..e6e19d57e53 100644 --- a/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx +++ b/src/renderer/src/components/native-chat/NativeChatSessionGate.tsx @@ -34,7 +34,7 @@ export function NativeChatSessionGate({ ...currentResolution, sessionId: previousResolution.sessionId, transcriptPath: previousResolution.transcriptPath, - providerSession: previousResolution.providerSession + connectionId: previousResolution.connectionId } } return currentResolution diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index d8b117d083c..2d5a6dc7d91 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -95,7 +95,7 @@ export default function NativeChatView({ agent={resolution.agent} sessionId={resolution.sessionId} transcriptPath={resolution.transcriptPath} - providerSession={resolution.providerSession} + connectionId={resolution.connectionId} isVisible={isVisible} targetPtyId={targetPtyId} terminalTabId={terminalTabId} @@ -113,7 +113,7 @@ function NativeChatResolvedView({ agent, sessionId, transcriptPath, - providerSession, + connectionId, isVisible, targetPtyId, terminalTabId, @@ -131,7 +131,7 @@ function NativeChatResolvedView({ agent, sessionId, transcriptPath, - providerSession, + transcriptConnectionId: connectionId, runtimeEnvironmentId, enabled: isVisible }) diff --git a/src/renderer/src/components/native-chat/native-chat-live-session-types.ts b/src/renderer/src/components/native-chat/native-chat-live-session-types.ts index 50911ba6e6b..e814d249891 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-session-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-session-types.ts @@ -1,4 +1,3 @@ -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { AgentType, NativeChatMessage, @@ -10,7 +9,8 @@ export type UseNativeChatLiveSessionArgs = { agent: AgentType sessionId: string | null transcriptPath?: string | null - providerSession?: AgentProviderSessionMetadata | null + /** Source-generation hint only; main/runtime resolves read authority from paneKey. */ + transcriptConnectionId?: string | null runtimeEnvironmentId?: string | null enabled?: boolean } diff --git a/src/renderer/src/components/native-chat/native-chat-live-source-key.ts b/src/renderer/src/components/native-chat/native-chat-live-source-key.ts index 08e4856d43f..b8d1ce19d64 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-source-key.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-source-key.ts @@ -1,16 +1,12 @@ -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { UseNativeChatLiveSessionArgs } from './native-chat-live-session-types' -export function nativeChatLiveSourceKey( - args: UseNativeChatLiveSessionArgs, - providerSession: AgentProviderSessionMetadata | null -): string { +export function nativeChatLiveSourceKey(args: UseNativeChatLiveSessionArgs): string { return JSON.stringify([ args.paneKey, args.runtimeEnvironmentId ?? null, args.agent, args.sessionId, args.transcriptPath ?? null, - providerSession?.executionHostId ?? null + args.transcriptConnectionId ?? null ]) } diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts index a97268bf47d..491f9cabc3d 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts @@ -26,10 +26,10 @@ describe('resolveNativeChatSession', () => { agentStatusEntry: entry({ paneKey, agentType: 'claude', + connectionId: 'builder', providerSession: { key: 'session_id', - id: 'sess-abc', - executionHostId: 'ssh:builder' + id: 'sess-abc' } }), ptyId: 'pty-1' @@ -38,11 +38,7 @@ describe('resolveNativeChatSession', () => { agent: 'claude', sessionId: 'sess-abc', transcriptPath: null, - providerSession: { - key: 'session_id', - id: 'sess-abc', - executionHostId: 'ssh:builder' - }, + connectionId: 'builder', ptyId: 'pty-1', paneKey }) @@ -69,11 +65,7 @@ describe('resolveNativeChatSession', () => { agent: 'claude', sessionId: 'sess-abc', transcriptPath: '/home/u/.claude/projects/slug/real-uuid.jsonl', - providerSession: { - key: 'session_id', - id: 'sess-abc', - transcriptPath: '/home/u/.claude/projects/slug/real-uuid.jsonl' - }, + connectionId: null, ptyId: 'pty-1', paneKey }) @@ -93,7 +85,7 @@ describe('resolveNativeChatSession', () => { agent: 'claude', sessionId: null, transcriptPath: null, - providerSession: null, + connectionId: null, ptyId: 'pty-1', paneKey }) @@ -126,7 +118,7 @@ describe('resolveNativeChatSession', () => { agent: 'claude', sessionId: 'left-sess', transcriptPath: null, - providerSession: { key: 'session_id', id: 'left-sess' }, + connectionId: null, ptyId: 'pty-left', paneKey: leftKey }) @@ -134,7 +126,7 @@ describe('resolveNativeChatSession', () => { agent: 'codex', sessionId: 'right-sess', transcriptPath: null, - providerSession: { key: 'session_id', id: 'right-sess' }, + connectionId: null, ptyId: 'pty-right', paneKey: rightKey }) @@ -157,7 +149,7 @@ describe('resolveNativeChatSession', () => { agent: 'codex', sessionId: 'codex-1', transcriptPath: null, - providerSession: { key: 'session_id', id: 'codex-1' }, + connectionId: null, ptyId: 'pty-1', paneKey }) @@ -178,7 +170,7 @@ describe('resolveNativeChatSession', () => { agent: resolvedAgent, sessionId: null, transcriptPath: null, - providerSession: null, + connectionId: null, ptyId: 'pty-1', paneKey }) @@ -320,7 +312,7 @@ describe('resolveNativeChatSession', () => { agent: 'codex', sessionId: 'codex-live', transcriptPath: null, - providerSession: { key: 'session_id', id: 'codex-live' }, + connectionId: null, ptyId: 'pty-1', paneKey }) diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts index 8d2513c4ec9..f38db9a2e74 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.ts @@ -1,5 +1,4 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { TuiAgent } from '../../../../shared/tui-agent' import { isNativeChatSupportedAgent } from './native-chat-availability' @@ -34,7 +33,7 @@ export type NativeChatPaneResolution = { /** Authoritative transcript path from the hook, when reported. Preferred over * reconstructing the path from sessionId (recent Claude Code diverges them). */ transcriptPath: string | null - providerSession: AgentProviderSessionMetadata | null + connectionId: string | null ptyId: string | null paneKey: string } @@ -56,7 +55,7 @@ export function resolveNativeChatSession( agent, sessionId: input.agentStatusEntry?.providerSession?.id ?? null, transcriptPath: input.agentStatusEntry?.providerSession?.transcriptPath ?? null, - providerSession: input.agentStatusEntry?.providerSession ?? null, + connectionId: input.agentStatusEntry?.connectionId ?? null, ptyId: input.ptyId, paneKey: input.paneKey } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts index 32d1b31638f..329c93f852f 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -53,8 +53,7 @@ export function parseRuntimeNativeChatReadSessionResult( if (typeof record.error === 'string') { return { error: record.error, - ...(record.notFound === true ? { notFound: true } : {}), - ...(record.unverifiable === true ? { unverifiable: true } : {}) + ...(record.notFound === true ? { notFound: true } : {}) } } return { error: RUNTIME_NATIVE_CHAT_READ_ERROR } 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 54000e98579..070e63bcacb 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 @@ -82,30 +82,19 @@ describe('getNativeChatSessionTransport — selection', () => { expect(nativeChatReadSession).not.toHaveBeenCalled() }) - it('forwards the whole provider-session locator through desktop IPC', async () => { + it('forwards pane identity through desktop IPC', async () => { nativeChatReadSession.mockResolvedValue({ messages: [] }) - const providerSession = { - key: 'session_id' as const, - id: 'sess-1', - transcriptPath: '/t/path', - executionHostId: 'ssh:builder' as const - } + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' await getNativeChatSessionTransport(null).readSession( 'claude', 'sess-1', 40, '/t/path', - providerSession + paneKey ) - expect(nativeChatReadSession).toHaveBeenCalledWith( - 'claude', - 'sess-1', - 40, - '/t/path', - providerSession - ) + expect(nativeChatReadSession).toHaveBeenCalledWith('claude', 'sess-1', 40, '/t/path', paneKey) }) it('validates lifecycle metadata on runtime read responses', async () => { @@ -232,15 +221,10 @@ describe('runtime subscribe', () => { }) }) - it('forwards the whole provider-session locator through runtime subscriptions', async () => { + it('forwards pane identity through runtime subscriptions', async () => { markRuntimeEnvironmentCompatible(ENV) stubSubscribe() - const providerSession = { - key: 'session_id' as const, - id: 'sess-1', - transcriptPath: '/t/path', - executionHostId: 'ssh:builder' as const - } + const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' getNativeChatSessionTransport(ENV).subscribe( { @@ -248,7 +232,7 @@ describe('runtime subscribe', () => { agent: 'claude', sessionId: 'sess-1', transcriptPath: '/t/path', - providerSession + paneKey }, vi.fn() ) @@ -257,7 +241,7 @@ describe('runtime subscribe', () => { expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith( expect.objectContaining({ method: 'nativeChat.subscribe', - params: expect.objectContaining({ providerSession }) + params: expect.objectContaining({ paneKey }) }), expect.any(Object) ) 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 f13427135b9..ed4f8e4ed48 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 @@ -43,9 +43,9 @@ 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, providerSession) => - providerSession - ? window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath, providerSession) + readSession: (agent, sessionId, limit, transcriptPath, paneKey) => + paneKey + ? window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath, paneKey) : window.api.nativeChat.readSession(agent, sessionId, limit, transcriptPath), subscribe: (args, onFrame) => window.api.nativeChat.subscribe(args, onFrame) } @@ -54,7 +54,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess const target: RuntimeClientTarget = { kind: 'environment', environmentId } return { - readSession: async (agent, sessionId, limit, transcriptPath, providerSession) => { + readSession: async (agent, sessionId, limit, transcriptPath, paneKey) => { try { const result = await callRuntimeRpc( target, @@ -64,7 +64,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess sessionId, limit, transcriptPath, - ...(providerSession ? { providerSession } : {}) + paneKey }, { timeoutMs: 15_000 } ) @@ -74,7 +74,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess } }, subscribe: (args, onFrame) => { - const { subscriptionId, agent, sessionId, transcriptPath, providerSession, limit } = args + const { subscriptionId, agent, sessionId, transcriptPath, paneKey, limit } = args let cancelled = false let receivedInitial = false let handleUnsubscribe: (() => void) | null = null @@ -118,7 +118,7 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess agent, sessionId, transcriptPath, - providerSession, + paneKey, limit }, timeoutMs: 15_000 diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index 8020841a501..67a08e27daa 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -1,6 +1,5 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { NativeChatSession } from '../../../../shared/native-chat-types' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' export type NativeChatViewProps = { @@ -28,7 +27,7 @@ export type NativeChatResolvedViewProps = { agent: NativeChatSession['agent'] sessionId: string | null transcriptPath: string | null - providerSession: AgentProviderSessionMetadata | null + connectionId: string | null isVisible: boolean targetPtyId: string | null terminalTabId: string 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 9415ecac24f..10b5fec4f56 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 @@ -657,35 +657,6 @@ describe('useNativeChatLiveSession — notFound retry (#8401)', () => { expect(latest?.messages.map((m) => m.id)).toContain('a-1') }) - // #13663: the main-side unresolved-transcript notice and this hook's notFound - // window both expire at ~60s on the same unreadable transcript. The stream's - // copy is the specific one, so the seed's generic miss must not replace it. - it('keeps the stream error copy when the read retry window expires after it', async () => { - vi.useFakeTimers() - const transport = getMockTransport('env-1', { autoSnapshot: false }) - transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) - - await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) - - await act(async () => { - await vi.advanceTimersByTimeAsync(55_000) - transport.emit({ - type: 'snapshot', - messages: [], - hasMore: false, - error: 'No transcript found for this session on this machine.' - }) - }) - expect(latest?.error).toBe('No transcript found for this session on this machine.') - - // Past the seed's 60s window: its miss resolves last and must stay silent. - await act(async () => { - await vi.advanceTimersByTimeAsync(30_000) - }) - expect(latest?.status).toBe('error') - expect(latest?.error).toBe('No transcript found for this session on this machine.') - }) - it('renders live-appended content instead of loading while the read is still retrying', async () => { vi.useFakeTimers() const transport = getMockTransport('env-1', { autoSnapshot: false }) 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 7397ea3d7ee..55eec4b5416 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 @@ -20,7 +20,6 @@ import { useNativeChatHookStatus } from './use-native-chat-hook-status' import { useNativeChatAssembledMessages } from './use-native-chat-assembled-messages' import { createNativeChatReadRetryTimer } from './native-chat-read-retry-timer' import { openNativeChatTranscriptStream } from './native-chat-stream-teardown' -import { useNativeChatProviderSessionLocator } from './use-native-chat-provider-session-locator' import { nextNativeChatSubscriptionId, NOTFOUND_RETRY_WINDOW_MS @@ -58,21 +57,12 @@ const EMPTY_MESSAGES: readonly NativeChatMessage[] = [] export function useNativeChatLiveSession( args: UseNativeChatLiveSessionArgs ): NativeChatLiveSession { - const { - paneKey, - agent, - sessionId, - transcriptPath, - providerSession, - runtimeEnvironmentId, - enabled = true - } = args + const { paneKey, agent, sessionId, transcriptPath, runtimeEnvironmentId, enabled = true } = args // Stable per owner id so a re-render without an owner flip keeps the same transport and doesn't re-subscribe. const transport = useMemo( () => getNativeChatSessionTransport(runtimeEnvironmentId ?? null), [runtimeEnvironmentId] ) - const providerSessionLocator = useNativeChatProviderSessionLocator(providerSession) const [read, setRead] = useState({ phase: 'loading' }) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) @@ -98,7 +88,7 @@ export function useNativeChatLiveSession( const latestTransport = useRef(transport) latestTransport.current = transport const transcriptEpochRef = useRef(0) - const sourceKey = nativeChatLiveSourceKey(args, providerSessionLocator) + const sourceKey = nativeChatLiveSourceKey(args) const retainedSourceKeyRef = useRef(sourceKey) useEffect(() => { @@ -131,8 +121,6 @@ export function useNativeChatLiveSession( let cancelled = false // Set by the first authoritative frame so the readSession seed below can't clobber a live snapshot. let frameArrived = false - // Prefer the recovery-owning stream error over concurrent seed-read errors. - let streamErrorShown = false const retryTimer = createNativeChatReadRetryTimer() const retryStartedAt = Date.now() // Re-bound as a const: TS drops the `!sessionId` narrowing inside the hoisted nested function. @@ -152,13 +140,7 @@ export function useNativeChatLiveSession( return } void transport - .readSession( - agent, - activeSessionId, - limitRef.current, - transcriptPath ?? undefined, - providerSessionLocator ?? undefined - ) + .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined, paneKey) .then((result) => { if (cancelled || !latestEnabled.current || frameArrived) { return @@ -169,9 +151,6 @@ export function useNativeChatLiveSession( retryTimer.schedule(attempt, () => loadSession(attempt + 1)) return } - if (streamErrorShown) { - return - } setRead({ phase: 'error', error: result.error }) return } @@ -181,7 +160,7 @@ export function useNativeChatLiveSession( setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) }) .catch((err: unknown) => { - if (!cancelled && latestEnabled.current && !frameArrived && !streamErrorShown) { + if (!cancelled && latestEnabled.current && !frameArrived) { setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) } }) @@ -197,7 +176,7 @@ export function useNativeChatLiveSession( agent, sessionId, transcriptPath: transcriptPath ?? undefined, - providerSession: providerSessionLocator ?? undefined, + paneKey, limit: limitRef.current }, (frame) => { @@ -210,7 +189,6 @@ export function useNativeChatLiveSession( setLoadingEarlier(false) if ('error' in frame && frame.error) { // Why: an error frame carries no transcript, so it must not consume the seed — a healthy read still has to repair the pane. - streamErrorShown = true const error = frame.error // Retain existing transcript history after a stream error. setRead((prev) => @@ -241,7 +219,7 @@ export function useNativeChatLiveSession( }, [ agent, enabled, - providerSessionLocator, + paneKey, sessionId, sourceKey, transcriptPath, @@ -264,13 +242,7 @@ export function useNativeChatLiveSession( const lifecycleRevision = transcriptLifecycleControl.revision() setLoadingEarlier(true) void transport - .readSession( - agent, - sessionId, - nextLimit, - transcriptPath ?? undefined, - providerSessionLocator ?? undefined - ) + .readSession(agent, sessionId, nextLimit, transcriptPath ?? undefined, paneKey) .then((result) => { // Ignore a stale resolve from a swapped session or flipped owner — either would paint the wrong host's history. if ( @@ -301,7 +273,7 @@ export function useNativeChatLiveSession( }) }, [ agent, - providerSessionLocator, + paneKey, sessionId, transcriptPath, transport, diff --git a/src/renderer/src/components/native-chat/use-native-chat-provider-session-locator.ts b/src/renderer/src/components/native-chat/use-native-chat-provider-session-locator.ts deleted file mode 100644 index 24b3bc47618..00000000000 --- a/src/renderer/src/components/native-chat/use-native-chat-provider-session-locator.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useMemo } from 'react' -import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' - -export function useNativeChatProviderSessionLocator( - providerSession: AgentProviderSessionMetadata | null | undefined -): AgentProviderSessionMetadata | null { - const key = providerSession?.key - const id = providerSession?.id - const transcriptPath = providerSession?.transcriptPath - const executionHostId = providerSession?.executionHostId - return useMemo( - () => - key && id - ? { - key, - id, - ...(transcriptPath ? { transcriptPath } : {}), - ...(executionHostId ? { executionHostId } : {}) - } - : null, - [executionHostId, id, key, transcriptPath] - ) -} 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 0cc6812a2e7..7be7682658c 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 @@ -20,7 +20,7 @@ export function useNativeChatRetainedSession( args.agent, args.sessionId, args.transcriptPath ?? null, - args.providerSession?.executionHostId ?? null + args.transcriptConnectionId ?? null ]) const activeIdentityRef = useRef(identity) const retentionRef = useRef(createNativeChatTranscriptRetention()) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index 7073737ff07..f1d71c879c4 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -318,8 +318,7 @@ describe('createRemoteRuntimePtyTransport', () => { agentArgsOverride: '--permission-mode plan', resumeProviderSession: { key: 'session_id', - id: 'provider-session', - executionHostId: 'ssh:builder' + id: 'provider-session' }, tabId: 'tab-1', leafId: '11111111-1111-4111-8111-111111111111' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index fe3a8a6a536..7d50b9f48de 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -9,7 +9,6 @@ import type { RuntimeCreateAgentSessionResult, RuntimeEnsureAgentSessionResult } from '../../../../shared/agent-session-host-authority' -import { agentProviderSessionForResume } from '../../../../shared/agent-session-resume' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { TabActivationIntent } from '../../../../shared/tab-activation-intent' import type { @@ -2011,11 +2010,7 @@ export function createRemoteRuntimePtyTransport( const envToSend = options.env ?? env const envToDeleteToSend = options.envToDelete ?? envToDelete const launchConfigToSend = options.launchConfig ?? launchConfig - const resumeProviderSessionCandidate = - options.resumeProviderSession ?? resumeProviderSession - const resumeProviderSessionToSend = resumeProviderSessionCandidate - ? agentProviderSessionForResume(resumeProviderSessionCandidate) - : undefined + const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession const launchTokenToSend = options.launchToken ?? launchToken const launchAgentToSend = options.launchAgent ?? launchAgent const legacyCreateParams = { diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 5e7d6e8303c..5aa73198f6d 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -169,8 +169,7 @@ describe('createWebRuntimeSessionTerminal', () => { command: "codex resume 'session-1'", providerSession: { key: 'session_id' as const, - id: 'session-1', - executionHostId: 'ssh:builder' as const + id: 'session-1' } } : {}), diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 1df30e87994..ab97d1e2821 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -15,10 +15,9 @@ import type { } from '../../../shared/runtime-types' import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' -import { - agentProviderSessionForResume, - type AgentProviderSessionMetadata, - type SleepingAgentLaunchConfig +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import { BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability' @@ -295,7 +294,7 @@ async function createWebRuntimeSessionTerminalResult( kind: 'explicit', worktree: toRuntimeWorktreeSelector(args.worktreeId), agent, - providerSession: agentProviderSessionForResume(args.providerSession!), + providerSession: args.providerSession!, ...(args.launchConfig?.ompResumeFilePath ? { ompResumeFilePath: args.launchConfig.ompResumeFilePath } : {}), diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f6b5fbd473a..c2cc94c83d5 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1280,13 +1280,14 @@ 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) => + readSession: async (agent, sessionId, limit, transcriptPath, paneKey) => parseRuntimeNativeChatReadSessionResult( await callRuntimeResult('nativeChat.readSession', { agent, sessionId, limit, - transcriptPath + transcriptPath, + paneKey }) ), subscribe: (args, onFrame) => { @@ -1315,6 +1316,7 @@ function createNativeChatApi(): NativeChatApi { sessionId: args.sessionId, subscriptionId: args.subscriptionId, transcriptPath: args.transcriptPath, + paneKey: args.paneKey, limit: args.limit }, { diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index a58979de7ec..c6025936431 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - agentProviderSessionForResume, agentProviderSessionsEqual, extractAgentProviderSession, getAgentResumeArgv, @@ -167,58 +166,4 @@ describe('agent session resume metadata', () => { }) ).toEqual({ key: 'session_id', id: 'ok' }) }) - - it('normalizes host ownership and keeps it in identity comparisons', () => { - const local = normalizeAgentProviderSession({ - key: 'session_id', - id: 'same', - executionHostId: 'local' - }) - const remote = normalizeAgentProviderSession({ - key: 'session_id', - id: 'same', - executionHostId: 'ssh:builder' - }) - - expect(local).toMatchObject({ executionHostId: 'local' }) - expect(remote).toMatchObject({ executionHostId: 'ssh:builder' }) - expect(agentProviderSessionsEqual('claude', local ?? undefined, remote ?? undefined)).toBe( - false - ) - expect( - normalizeAgentProviderSession({ - key: 'session_id', - id: 'same', - executionHostId: 'invalid-host' - }) - ).toEqual({ key: 'session_id', id: 'same' }) - }) - - it('treats omitted legacy ownership as local during identity comparison', () => { - expect( - agentProviderSessionsEqual( - 'claude', - { key: 'session_id', id: 'same' }, - { key: 'session_id', id: 'same', executionHostId: 'local' } - ) - ).toBe(true) - expect( - agentProviderSessionsEqual( - 'claude', - { key: 'session_id', id: 'same' }, - { key: 'session_id', id: 'same', executionHostId: 'ssh:builder' } - ) - ).toBe(false) - }) - - it('strips host ownership at strict legacy resume seams', () => { - expect( - agentProviderSessionForResume({ - key: 'session_id', - id: 'same', - transcriptPath: '/tmp/same.jsonl', - executionHostId: 'ssh:builder' - }) - ).toEqual({ key: 'session_id', id: 'same', transcriptPath: '/tmp/same.jsonl' }) - }) }) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index ddfd8cb463d..3e763fc20a6 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -1,10 +1,5 @@ import type { AgentHookSource } from './agent-hook-relay' import type { AgentStatusState } from './agent-status-types' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - type ExecutionHostId -} from './execution-host' import type { TuiAgent } from './tui-agent' export const RESUMABLE_TUI_AGENTS = [ @@ -31,8 +26,6 @@ export type AgentProviderSessionKey = 'session_id' | 'conversation_id' export type AgentProviderSessionMetadata = { key: AgentProviderSessionKey id: string - /** Execution host that owns the provider session and its transcript bytes. */ - executionHostId?: ExecutionHostId /** Authoritative on-disk transcript/rollout path reported by the agent's hook * (Claude/Codex `transcript_path`), when available. Native chat reads this * directly because recent Claude Code versions name the transcript file with a @@ -168,16 +161,7 @@ export function normalizeAgentProviderSession(raw: unknown): AgentProviderSessio // Why: persisted/relay metadata crosses a trust boundary too; apply the same // control-character rejection used for hook-reported transcript paths. const transcriptPath = readTranscriptPathFromKeys(record, ['transcriptPath']) - const executionHostId = - typeof record.executionHostId === 'string' - ? (normalizeExecutionHostId(record.executionHostId) ?? undefined) - : undefined - return { - key, - id, - ...(transcriptPath ? { transcriptPath } : {}), - ...(executionHostId ? { executionHostId } : {}) - } + return transcriptPath ? { key, id, transcriptPath } : { key, id } } /** Compare the provider-owned values that identify the CLI resume target. @@ -193,23 +177,10 @@ export function agentProviderSessionsEqual( return ( left.key === right.key && left.id === right.id && - (left.executionHostId ?? LOCAL_EXECUTION_HOST_ID) === - (right.executionHostId ?? LOCAL_EXECUTION_HOST_ID) && ((agent !== 'pi' && agent !== 'prime-agent') || left.transcriptPath === right.transcriptPath) ) } -/** Older runtime resume schemas do not know transcript ownership metadata. */ -export function agentProviderSessionForResume( - providerSession: AgentProviderSessionMetadata -): Omit { - return { - key: providerSession.key, - id: providerSession.id, - ...(providerSession.transcriptPath ? { transcriptPath: providerSession.transcriptPath } : {}) - } -} - export function extractAgentProviderSession( source: AgentHookSource, payload: Record diff --git a/src/shared/definitive-filesystem-absence.ts b/src/shared/definitive-filesystem-absence.ts index 8140d0588e3..967c00f13b9 100644 --- a/src/shared/definitive-filesystem-absence.ts +++ b/src/shared/definitive-filesystem-absence.ts @@ -11,7 +11,45 @@ * An unknown code is never absence. Mapping the unknown to a verdict is the * category error this predicate exists to prevent. */ -export function isDefinitiveAbsence(error: unknown): boolean { +const ENOENT_MESSAGE = /\bENOENT: no such file or directory\b/ +const ENOTDIR_MESSAGE = /\bENOTDIR: not a directory\b/ +const NO_FILESYSTEM_CODE = Symbol('no-filesystem-code') + +function filesystemCode(error: unknown): unknown { const code = (error as NodeJS.ErrnoException | null)?.code - return code === 'ENOENT' || code === 'ENOTDIR' + if (typeof code === 'string') { + return code + } + const data = (error as { data?: unknown } | null)?.data + return typeof data === 'object' && data !== null && 'errno' in data + ? (data as { errno?: unknown }).errno + : NO_FILESYSTEM_CODE +} + +export function isDefinitiveAbsence(error: unknown): boolean { + return definitiveAbsenceCode(error) !== null +} + +function definitiveAbsenceCode(error: unknown): 'ENOENT' | 'ENOTDIR' | null { + const code = filesystemCode(error) + return code === 'ENOENT' || code === 'ENOTDIR' ? code : null +} + +export function isENOENT(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const code = filesystemCode(error) + // Old relays overwrite Node's string code with JSON-RPC's numeric code. + return code === 'ENOENT' || (code === NO_FILESYSTEM_CODE && ENOENT_MESSAGE.test(error.message)) +} + +export function isDefinitiveAbsenceFromRelay(error: unknown): boolean { + if (isDefinitiveAbsence(error)) { + return true + } + if (!(error instanceof Error) || filesystemCode(error) !== NO_FILESYSTEM_CODE) { + return false + } + return ENOENT_MESSAGE.test(error.message) || ENOTDIR_MESSAGE.test(error.message) } diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index f8730591646..4ee620efa82 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { normalizeExecutionHostId, type ExecutionHostId } from './execution-host' import { getAgentResumeArgv, normalizeAgentProviderSession, @@ -20,12 +19,7 @@ const agentProviderSessionSchema = z.preprocess( id: z.string().min(1).max(512), // Why: Pi resumes by its authoritative session file, so dropping this // field during hydration makes an otherwise valid record unusable. - transcriptPath: z.string().min(1).optional(), - executionHostId: z - .custom( - (value) => typeof value === 'string' && normalizeExecutionHostId(value) === value - ) - .optional() + transcriptPath: z.string().min(1).optional() }) ) diff --git a/tests/e2e/headless-native-chat-runtime-routing.spec.ts b/tests/e2e/headless-native-chat-runtime-routing.spec.ts index 9059a5c670d..baf70e14c19 100644 --- a/tests/e2e/headless-native-chat-runtime-routing.spec.ts +++ b/tests/e2e/headless-native-chat-runtime-routing.spec.ts @@ -27,12 +27,6 @@ test('routes native-chat ownership through a headless runtime', async () => { agent: 'claude', sessionId: 'headless-session', transcriptPath, - providerSession: { - key: 'session_id', - id: 'headless-session', - transcriptPath, - executionHostId: 'local' - }, limit: 40 }) expect(owned.result).toMatchObject({ @@ -40,25 +34,15 @@ test('routes native-chat ownership through a headless runtime', async () => { hasMore: false }) - // A located session with unknown ownership must not inherit the server's filesystem. - const unowned = await host.client.call<{ - error: string - unverifiable: boolean - }>('nativeChat.readSession', { + // A pane-scoped request with unknown ownership must not inherit the server's filesystem. + const unowned = await host.client.call<{ error: string }>('nativeChat.readSession', { agent: 'claude', sessionId: 'headless-session', transcriptPath, - providerSession: { - key: 'session_id', - id: 'headless-session', - transcriptPath - }, + paneKey: 'missing-pane:11111111-1111-4111-8111-111111111111', limit: 40 }) - expect(unowned.result).toEqual({ - error: 'Transcript unverifiable on the remote host', - unverifiable: true - }) + expect(unowned.result).toEqual({ error: 'Transcript unverifiable on the remote host' }) } finally { if (transcriptPath) { rmSync(transcriptPath, { force: true })