From 4556f2f1a24659a11615dee20fc911bcfe2d6811 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 9 Jul 2026 14:50:43 -0700 Subject: [PATCH] fix(agent-hooks): renderer ownership gate treats wsl:* connection ids as local (STA-1515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 rig finding: with the link fixed, WSL hook envelopes reached ingestRemote and the durable cache, but useIpcEvents.applyAgentStatus drops any status whose stamped connectionId differs from the owning repo's — 'wsl:' !== null for a local repo, so every WSL-relayed status died before setAgentStatus and notifications. wsl:* ids are transport provenance, not ownership: the gate now normalizes them to local via isWslHookRelayConnectionId (shared contract, also used by the relay link when stamping), while still rejecting WSL-stamped events against SSH-owned repos. Provenance stays stamped — it is what made this drop diagnosable. Co-authored-by: Orca --- docs/agent-status-over-wsl.md | 11 +- src/main/agent-hooks/wsl-hook-relay-link.ts | 3 +- src/renderer/src/hooks/useIpcEvents.test.ts | 132 ++++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 17 ++- src/shared/wsl-hook-relay-contract.ts | 13 ++ 5 files changed, 170 insertions(+), 6 deletions(-) diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md index c84c37246d0..db55ac14b08 100644 --- a/docs/agent-status-over-wsl.md +++ b/docs/agent-status-over-wsl.md @@ -221,7 +221,16 @@ two fixes: can pinpoint any residual drop. The full host chain is pinned by a live integration test (real bundle over real child stdio through the real manager into a real `ingestRemote`). -2. **Codex reads a redirected home.** Orca launches WSL Codex with `CODEX_HOME` pointed at +2. **(Round 2) The renderer's SSH-era ownership gate dropped `wsl:*` events.** With the + link fixed, envelopes reached `ingestRemote` and the durable cache, but + `useIpcEvents.applyAgentStatus` compares the stamped connectionId against the owning + repo's — `"wsl:" !== null` for a local repo, so every WSL-relayed status died + before `setAgentStatus`/notifications. Fix: `wsl:*` ids are transport provenance, not + ownership — the gate normalizes them to local (null) via + `isWslHookRelayConnectionId`, while still rejecting WSL-stamped events against + SSH-owned repos. Provenance stays stamped (it made the drop diagnosable in the first + place). +3. **Codex reads a redirected home.** Orca launches WSL Codex with `CODEX_HOME` pointed at the managed runtime home (`~/.local/share/orca/codex-runtime-home/home`), so installing hooks to `~/.codex` left Codex dark. The installers now accept an explicit codex home; the trust write into `config.toml` is deferred while that file doesn't exist (the diff --git a/src/main/agent-hooks/wsl-hook-relay-link.ts b/src/main/agent-hooks/wsl-hook-relay-link.ts index 8662366fbe4..2e9fede9512 100644 --- a/src/main/agent-hooks/wsl-hook-relay-link.ts +++ b/src/main/agent-hooks/wsl-hook-relay-link.ts @@ -6,6 +6,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_NOTIFICATION_METHOD } from '../../shared/agent-hook-relay' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' export type WslRelayLinkOptions = { mux: SshChannelMultiplexer @@ -22,7 +23,7 @@ export type WslRelayLinkOptions = { export function wireWslRelayLink(options: WslRelayLinkOptions): void { const { mux, child, distro, ingest, warn, onDead } = options - const connectionId = `wsl:${distro}` + const connectionId = wslHookRelayConnectionId(distro) mux.onNotification((method, params) => { if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0623acbba53..48515521015 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -5999,6 +5999,138 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('accepts WSL-relayed status events for a local repo (wsl:* is transport provenance, not ownership)', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'WSL Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'wsl p', + agentType: 'claude', + worktreeId: 'wt-1', + connectionId: 'wsl:Ubuntu', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ state: 'working', prompt: 'wsl p', agentType: 'claude' }), + 'WSL Tab', + { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, + expectWorktreeRouting('wt-1'), + undefined + ) + }) + + it('still rejects WSL-relayed status events against an SSH-owned repo', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + repos: [{ id: 'repo-1', connectionId: 'ssh-1' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'wsl p', + agentType: 'claude', + worktreeId: 'wt-1', + connectionId: 'wsl:Ubuntu', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }) + + expect(setAgentStatus).not.toHaveBeenCalled() + }) + it('still rejects remote status events once the pane resolves to a local repo', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e318b7120d2..fb169669859 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -31,6 +31,7 @@ import type { } from '../../../shared/remote-workspace-types' import type { RateLimitState } from '../../../shared/rate-limit-types' import type { SshConnectionState } from '../../../shared/ssh-types' +import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { RuntimeBrowserDriverState, RuntimeTerminalPresentation, @@ -2984,15 +2985,23 @@ export function useIpcEvents(): void { // matches that tab's worktree, accept the status until repo ownership // becomes available; once ownership is resolved, keep the strict // connectionId check below. + // Why: the WSL hook relay stamps a transport-provenance connectionId + // (`wsl:`), but the pane is a LOCAL pane on a local repo — + // ownership-wise it is null. Without this normalization the strict + // check below drops every WSL-relayed status for a local repo (while + // still rejecting WSL-stamped events against SSH-owned repos). + const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) + ? null + : data.connectionId const canAcceptPendingRemoteOwnership = - data.connectionId !== undefined && - data.connectionId !== null && + ownershipConnectionId !== undefined && + ownershipConnectionId !== null && !repoConnectionResolved && data.worktreeId !== undefined && data.worktreeId === owningWorktreeId if ( - data.connectionId !== undefined && - data.connectionId !== repoConnectionId && + ownershipConnectionId !== undefined && + ownershipConnectionId !== repoConnectionId && !canAcceptPendingRemoteOwnership ) { return 'dropped' diff --git a/src/shared/wsl-hook-relay-contract.ts b/src/shared/wsl-hook-relay-contract.ts index 52083b4ef4d..297af1c551e 100644 --- a/src/shared/wsl-hook-relay-contract.ts +++ b/src/shared/wsl-hook-relay-contract.ts @@ -54,6 +54,19 @@ export function wslHookRelayEndpointDir(guestHome: string, windowsPort: number): * `endpoint.cmd`. Matches the POSIX branch of that helper. */ export const WSL_HOOK_RELAY_ENDPOINT_FILE = 'endpoint.env' +/** connectionId stamped on WSL-relayed hook envelopes. Transport provenance + * only: the pane is a LOCAL pane on a local repo, so ownership checks must + * treat these ids as local (null), not as a remote connection. */ +export const WSL_HOOK_RELAY_CONNECTION_PREFIX = 'wsl:' + +export function wslHookRelayConnectionId(distro: string): string { + return `${WSL_HOOK_RELAY_CONNECTION_PREFIX}${distro}` +} + +export function isWslHookRelayConnectionId(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(WSL_HOOK_RELAY_CONNECTION_PREFIX) +} + export function wslHookRelayEndpointFilePath(guestHome: string, windowsPort: number): string { return `${wslHookRelayEndpointDir(guestHome, windowsPort)}/${WSL_HOOK_RELAY_ENDPOINT_FILE}` }