diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md index 5376dc5ccd4..c84c37246d0 100644 --- a/docs/agent-status-over-wsl.md +++ b/docs/agent-status-over-wsl.md @@ -206,6 +206,30 @@ config); harmless under mirrored networking and inert on non-WSL platforms. Afte restart with the WSL agent still running (daemon-surviving PTY), status events resume without relaunching the agent. +## 2026-07-09 Windows-rig validation follow-ups + +The first live GUI run proved every mechanism in isolation but failed end-to-end, yielding +two fixes: + +1. **Link death must be handled, not just child death.** A mux protocol error or timeout + can kill the host↔guest link while the guest process (and its 204-returning receiver) + stays alive — the exact observed signature: hooks POST 204, store never populates. + `wsl-hook-relay-link.ts` now guarantees exactly-once death handling from either signal; + the manager breadcrumbs it, kills the child, and self-restarts after a short cooldown + (a live agent session produces no new PTY spawns to re-trigger ensure). + `ORCA_WSL_HOOK_RELAY_DEBUG=1` traces every received envelope pre-ingest so a live rig + 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 + 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 + launch path seeds it only-if-absent — creating it first would cancel the seed), and the + manager re-runs the idempotent installers on later ensures (throttled) to upsert trust + once the seed lands. Consequence: the very first WSL Codex session after a cold relay + may miss hooks; the next one has them. + ## Implementation map - Guest: `src/relay/wsl-agent-hook-relay.ts` (entry; exits on stdin close), @@ -213,9 +237,10 @@ without relaunching the agent. `src/relay/agent-hook-server.ts` (`token`/`preferredPort` options + `EADDRINUSE` fallback). Bundled by `config/scripts/build-relay.mjs` → `out/relay/wsl/`. - Host: `src/main/agent-hooks/wsl-hook-relay-manager.ts` (per-distro state machine), - `wsl-hook-relay-launch.ts` (bundle resolve, guest launch/install scripts, sentinel wait), - `wsl-hook-relay-deps.ts` (DI seam), `wsl-hook-fs-adapter.ts` (SFTP-shaped adapter so the - unchanged `installRemoteManagedAgentHooks` writes into the guest home). + `wsl-hook-relay-launch.ts` (bundle resolve, guest launch/install scripts, spawn env, + sentinel wait), `wsl-hook-relay-link.ts` (envelope forward + exactly-once link-death + handling), `wsl-hook-relay-deps.ts` (DI seam), `wsl-hook-fs-adapter.ts` (SFTP-shaped + adapter + `installWslGuestHooks`, which targets Codex's managed runtime home). - Wiring: `buildPtyHostEnv` (`src/main/ipc/pty.ts`) ensures the relay on every WSL spawn and repoints `ORCA_AGENT_HOOK_ENDPOINT` at the guest endpoint file once known; `src/main/pty/wsl-orca-env.ts` picks `/u` vs `/p` by value shape. diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 550af50bc42..79539599922 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -253,6 +253,48 @@ describe('remote hook service installers', () => { expect(toml).toContain('trusted_hash = "sha256:') }) + it('installs Codex hooks into an explicit redirected CODEX_HOME (WSL managed runtime home)', async () => { + const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home' + const { sftp, fs } = createFakeSftp({ + [`${runtimeHome}/config.toml`]: 'model = "gpt-5.2-codex"\n' + }) + + const status = await new CodexHookService().installRemote(sftp, '/home/dev', { + codexHomeDir: runtimeHome, + deferTrustUntilConfigToml: true + }) + + expect(status.state).toBe('installed') + expect(status.configPath).toBe(`${runtimeHome}/hooks.json`) + expect(fs.files.has('/home/dev/.codex/hooks.json')).toBe(false) + const hooks = JSON.parse(fs.files.get(`${runtimeHome}/hooks.json`)!) as { + hooks: Record + } + expect(hooks.hooks.Stop?.[0]?.hooks?.[0]?.command).toContain( + '/home/dev/.orca/agent-hooks/codex-hook.sh' + ) + const toml = fs.files.get(`${runtimeHome}/config.toml`) + expect(toml).toContain('model = "gpt-5.2-codex"') + expect(toml).toContain(`${runtimeHome}/hooks.json:stop:0:0`) + }) + + it('defers Codex trust writes until the redirected config.toml exists (launch-path seed race)', async () => { + const runtimeHome = '/home/dev/.local/share/orca/codex-runtime-home/home' + const { sftp, fs } = createFakeSftp() + + const status = await new CodexHookService().installRemote(sftp, '/home/dev', { + codexHomeDir: runtimeHome, + deferTrustUntilConfigToml: true + }) + + expect(status.state).toBe('installed') + expect(status.detail).toContain('deferred') + expect(fs.files.get(`${runtimeHome}/hooks.json`)).toContain('codex-hook.sh') + // Why: creating config.toml here would make the launch path's + // only-if-absent seed skip the user's real config. + expect(fs.files.has(`${runtimeHome}/config.toml`)).toBe(false) + }) + it('reports Codex trust-write failures without rolling back installed hooks', async () => { const { sftp, fs } = createFakeSftp() fs.failRenameTo.add('/home/dev/.codex/config.toml') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index e68ec2a8dcb..0151f63755c 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -15,15 +15,37 @@ import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +export type RemoteManagedHookInstallOptions = { + /** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime + * home). Codex-only: it is the one agent whose home Orca redirects. Also + * defers the config.toml trust write until that file exists, so the + * launch path's only-if-absent seed is never pre-empted. */ + codexHomeDir?: string +} + type RemoteManagedHookInstaller = readonly [ AgentHookInstallStatus['agent'], - (sftp: SFTPWrapper, remoteHome: string) => Promise + ( + sftp: SFTPWrapper, + remoteHome: string, + options?: RemoteManagedHookInstallOptions + ) => Promise ] const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)], ['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)], - ['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)], + [ + 'codex', + (sftp, remoteHome, options) => + codexHookService.installRemote( + sftp, + remoteHome, + options?.codexHomeDir + ? { codexHomeDir: options.codexHomeDir, deferTrustUntilConfigToml: true } + : undefined + ) + ], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], @@ -45,12 +67,13 @@ export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStat export async function installRemoteManagedAgentHooks( sftp: SFTPWrapper, - remoteHome: string + remoteHome: string, + options?: RemoteManagedHookInstallOptions ): Promise { const results: AgentHookInstallStatus[] = [] for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) { try { - const result = await install(sftp, remoteHome) + const result = await install(sftp, remoteHome, options) results.push(result) if (result.state === 'error') { console.warn( diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index 4f264a5dcfc..d47dd400199 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -5,9 +5,33 @@ // `installer-utils-remote.ts` touches are implemented. import type { SFTPWrapper } from 'ssh2' +import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env' import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract' +/** Run the shared remote hook installers against a WSL guest over the relay's + * fs bridge. Codex is the one agent whose home Orca redirects for WSL + * sessions, so its hooks go to the managed runtime home. */ +export async function installWslGuestHooks(options: { + mux: SshChannelMultiplexer + guestHome: string + distro: string + installHooks: typeof installRemoteManagedAgentHooks + warn: (message: string) => void +}): Promise { + const { mux, guestHome, distro, installHooks, warn } = options + const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { + codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome) + }) + const failed = results.filter((r) => r.state === 'error').length + if (failed > 0) { + warn( + `[agent-hooks] WSL hook install for '${distro}': ${failed}/${results.length} agents failed` + ) + } +} + type SftpCallback = (err: Error | null, value?: T) => void // Why: installer-utils-remote classifies errors by ssh2's numeric SFTP status diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 0f065b1d87a..8620febd456 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -10,6 +10,7 @@ import { app } from 'electron' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from '../ssh/relay-protocol' import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' +import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { WSL_HOOK_RELAY_BUNDLE_NAME, WSL_HOOK_RELAY_DIR, @@ -254,3 +255,29 @@ function formatStartupFailure(failure: WslRelayStartupFailure): string { } return `WSL hook relay exited (code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}` } + +export function formatWslRelayFailure(failure: WslRelayStartupFailure): string { + const detail = failure.stderr.trim() + return `startup failed (${failure.kind}, code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}` +} + +/** Env for the relay's wsl.exe spawn: the live hook coordinates plus the + * host-expected bundle version, all crossed via WSLENV. */ +export function buildWslRelaySpawnEnv( + coords: Record, + bundleVersion: string +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + ORCA_AGENT_HOOK_PORT: coords.ORCA_AGENT_HOOK_PORT, + ORCA_AGENT_HOOK_TOKEN: coords.ORCA_AGENT_HOOK_TOKEN, + ORCA_AGENT_HOOK_ENV: coords.ORCA_AGENT_HOOK_ENV, + ORCA_AGENT_HOOK_VERSION: coords.ORCA_AGENT_HOOK_VERSION, + [WSL_HOOK_RELAY_VERSION_ENV]: bundleVersion + } + // Why: the relay derives its own guest endpoint path; a /p-translated + // Windows endpoint here would only add WSLENV noise. + delete env.ORCA_AGENT_HOOK_ENDPOINT + addOrcaWslInteropEnv(env as Record) + return env +} diff --git a/src/main/agent-hooks/wsl-hook-relay-link.ts b/src/main/agent-hooks/wsl-hook-relay-link.ts new file mode 100644 index 00000000000..8662366fbe4 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-link.ts @@ -0,0 +1,57 @@ +// Envelope forwarding and death handling for one live WSL relay link. +// Extracted from the manager so its state machine stays readable; the manager +// decides what a dead link means (cooldown, restart), this module guarantees +// it finds out exactly once, whichever signal fires first. +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' + +export type WslRelayLinkOptions = { + mux: SshChannelMultiplexer + child: ChildProcessWithoutNullStreams + distro: string + ingest: (envelope: Record, connectionId: string) => void + warn: (message: string) => void + /** Called exactly once when the link dies — from EITHER a mux dispose + * (protocol error, keepalive timeout) or the child exiting. A mux death + * without child death would otherwise blackhole every later envelope + * while the guest keeps returning 204 to hook clients. */ + onDead: (reason: string) => void +} + +export function wireWslRelayLink(options: WslRelayLinkOptions): void { + const { mux, child, distro, ingest, warn, onDead } = options + const connectionId = `wsl:${distro}` + + mux.onNotification((method, params) => { + if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { + return + } + if (typeof (params as { paneKey?: unknown }).paneKey !== 'string') { + return + } + if (process.env.ORCA_WSL_HOOK_RELAY_DEBUG === '1') { + const p = params as { paneKey?: string; payload?: { state?: string } } + warn( + `[agent-hooks] WSL relay envelope (${distro}): pane=${p.paneKey} state=${p.payload?.state ?? '?'}` + ) + } + // Trust boundary: ingestRemote re-validates paneKey/tabId and + // re-normalizes the payload, same as the SSH relay path. + ingest(params, connectionId) + }) + + let dead = false + const die = (reason: string): void => { + if (dead) { + return + } + dead = true + mux.dispose() + child.kill() + onDead(reason) + } + mux.onDispose((reason) => die(`mux disposed (${reason})`)) + child.on('close', () => die('process exited')) +} 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 new file mode 100644 index 00000000000..120c1cdb676 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-live.integration.test.ts @@ -0,0 +1,202 @@ +// Live end-to-end oracle for the WSL hook relay HOST side: the real esbuild +// bundle runs as a real child process (spawned via `node` instead of wsl.exe +// — everything else identical), the real manager connects over the child's +// actual stdio pipes, the real installers write through the fs bridge, and a +// real HTTP POST in the exact Claude hook shape must land in a real +// AgentHookServer.ingestRemote. This is the chain the Windows-rig GUI run +// exercises minus the wsl.exe byte transport (validated separately on-rig). +import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { AgentHookServer } from './server' +import { WslHookRelayManager } from './wsl-hook-relay-manager' + +const BUNDLE_DIR = join(process.cwd(), 'out', 'relay', 'wsl') +const BUNDLE_JS = join(BUNDLE_DIR, 'wsl-agent-hook-relay.js') +const LEAF = '11111111-1111-4111-8111-111111111111' + +async function pickFreePort(): Promise { + const probe = createServer() + const port = await new Promise((resolve) => { + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + resolve(typeof address === 'object' && address ? address.port : 0) + }) + }) + await new Promise((resolve) => probe.close(() => resolve())) + return port +} + +// Why skipIf: the guest is always POSIX — the bundle derives its home from +// $HOME, which os.homedir() ignores on Windows. Windows coverage comes from +// the live rig runs against a real distro. +describe.skipIf(process.platform === 'win32')( + 'WSL hook relay live host chain (real bundle over real child stdio)', + () => { + let fakeHome: string + let manager: WslHookRelayManager | null + let orcaServer: AgentHookServer | null + let child: ChildProcessWithoutNullStreams | null + + beforeAll(() => { + if (!existsSync(BUNDLE_JS)) { + execFileSync(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], { + cwd: process.cwd(), + stdio: 'ignore' + }) + } + }, 120_000) + + afterEach(() => { + manager?.disposeAll() + orcaServer?.stop() + child?.kill() + rmSync(fakeHome, { recursive: true, force: true }) + }) + + it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => { + fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-')) + const preferredPort = await pickFreePort() + const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim() + + orcaServer = new AgentHookServer() + const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = [] + orcaServer.setListener((event) => { + events.push({ + paneKey: event.paneKey, + payload: event.payload, + connectionId: event.connectionId + }) + }) + const server = orcaServer + + const warns: string[] = [] + manager = new WslHookRelayManager({ + platform: () => 'win32', + remoteHooksEnabled: () => true, + hookCoordsEnv: () => ({ + ORCA_AGENT_HOOK_PORT: String(preferredPort), + ORCA_AGENT_HOOK_TOKEN: 'live-token', + ORCA_AGENT_HOOK_ENV: 'production', + ORCA_AGENT_HOOK_VERSION: '1' + }), + resolveBundle: () => ({ jsPath: BUNDLE_JS, version }), + listDistros: async () => ['LiveDistro'], + spawnRelay: (_distro, env) => { + child = spawn(process.execPath, [BUNDLE_JS], { + env: { ...env, HOME: fakeHome }, + stdio: ['pipe', 'pipe', 'pipe'] + }) as ChildProcessWithoutNullStreams + return child + }, + runInstall: async () => { + throw new Error('guest install must not run for a direct node spawn') + }, + ingest: (envelope, connectionId) => + server.ingestRemote( + envelope as Parameters[0], + connectionId + ), + warn: (message) => warns.push(message), + transientRetryDelayMs: 1 + }) + + manager.ensureForDistro('LiveDistro') + + // Codex hooks land in the redirected managed runtime home. Waiting on + // this artifact (not Claude's, which is written first) keeps the + // assertions behind the still-running 14-agent installer loop. + const codexRuntimeHome = join( + fakeHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + await vi.waitFor(() => expect(existsSync(join(codexRuntimeHome, 'hooks.json'))).toBe(true), { + timeout: 15_000 + }) + expect(existsSync(join(fakeHome, '.claude', 'settings.json'))).toBe(true) + const claudeScript = readFileSync( + join(fakeHome, '.orca', 'agent-hooks', 'claude-hook.sh'), + 'utf8' + ) + expect(claudeScript).toContain('/hook/claude') + + // Trust TOML is deferred so the launch-path seed is never pre-empted. + expect(existsSync(join(codexRuntimeHome, 'config.toml'))).toBe(false) + expect(existsSync(join(fakeHome, '.codex', 'hooks.json'))).toBe(false) + + // Re-coordinate exactly like a hook script: read the relay-written + // endpoint file rather than assuming the preferred port bind won. + const endpointFile = join( + fakeHome, + '.orca-wsl', + 'agent-hooks', + `port-${preferredPort}`, + 'endpoint.env' + ) + expect(existsSync(endpointFile)).toBe(true) + const endpointText = readFileSync(endpointFile, 'utf8') + const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(endpointText)?.[1]) + const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([A-Za-z0-9-]+)/.exec(endpointText)?.[1] + expect(port).toBeGreaterThan(0) + expect(token).toBe('live-token') + + const paneKey = `tab-live:${LEAF}` + const postClaude = async (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token ?? '' + }, + body: JSON.stringify({ + paneKey, + tabId: 'tab-live', + worktreeId: 'wt-live', + env: 'remote', + version: '1', + payload + }) + }) + + const promptRes = await postClaude({ + hook_event_name: 'UserPromptSubmit', + prompt: 'live roundtrip' + }) + expect(promptRes.status).toBe(204) + await vi.waitFor(() => expect(events.length).toBeGreaterThan(0), { timeout: 10_000 }) + expect(events[0].paneKey).toBe(paneKey) + expect(events[0].connectionId).toBe('wsl:LiveDistro') + const working = events[0].payload as { state: string; prompt: string; agentType: string } + expect(working.state).toBe('working') + expect(working.prompt).toBe('live roundtrip') + expect(working.agentType).toBe('claude') + + const stopRes = await postClaude({ hook_event_name: 'Stop' }) + expect(stopRes.status).toBe(204) + await vi.waitFor( + () => { + const done = events.find((e) => (e.payload as { state?: string }).state === 'done') + expect(done).toBeTruthy() + }, + { timeout: 10_000 } + ) + + // Link death must be breadcrumbed and scheduled for restart — a silent + // mux/child death would blackhole every later envelope while the guest + // keeps returning 204 (the exact failure signature from the Windows rig). + child?.kill() + await vi.waitFor( + () => expect(warns.some((w) => w.includes('scheduling restart'))).toBe(true), + { timeout: 10_000 } + ) + }, 40_000) + } +) diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 2c358ed3388..f2359812498 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -54,79 +54,89 @@ function createGuestHarness(): GuestHarness { return { transport, guestDispatcher, mux } } -describe('createWslHookSftpAdapter over the guest fs bridge', () => { - let home: string - let harness: GuestHarness +// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by +// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the +// bridge correctly refuses; Windows coverage comes from the live rig runs. +describe.skipIf(process.platform === 'win32')( + 'createWslHookSftpAdapter over the guest fs bridge', + () => { + let home: string + let harness: GuestHarness - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-')) - harness = createGuestHarness() - registerWslHookFsHandlers(harness.guestDispatcher, home) - }) - - afterEach(() => { - harness.mux.dispose() - harness.guestDispatcher.dispose() - rmSync(home, { recursive: true, force: true }) - }) - - it('maps guest ENOENT onto ssh2 status code 2', async () => { - const adapter = createWslHookSftpAdapter(harness.mux) - const err = await new Promise((resolve) => { - adapter.readFile(`${home}/missing.json`, 'utf8', ((e: Error) => resolve(e)) as never) + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-')) + harness = createGuestHarness() + registerWslHookFsHandlers(harness.guestDispatcher, home) }) - expect(err).toBeInstanceOf(Error) - expect(err.code).toBe(2) - }) - it('round-trips write/read/stat/rename and rejects paths outside home', async () => { - const adapter = createWslHookSftpAdapter(harness.mux) - await new Promise((resolve, reject) => { - adapter.writeFile( - `${home}/a.txt`, - 'hello', - { encoding: 'utf8', mode: 0o600 } as never, - ((e: Error | null) => (e ? reject(e) : resolve())) as never + afterEach(() => { + harness.mux.dispose() + harness.guestDispatcher.dispose() + rmSync(home, { recursive: true, force: true }) + }) + + it('maps guest ENOENT onto ssh2 status code 2', async () => { + const adapter = createWslHookSftpAdapter(harness.mux) + const err = await new Promise((resolve) => { + adapter.readFile(`${home}/missing.json`, 'utf8', ((e: Error) => resolve(e)) as never) + }) + expect(err).toBeInstanceOf(Error) + expect(err.code).toBe(2) + }) + + it('round-trips write/read/stat/rename and rejects paths outside home', async () => { + const adapter = createWslHookSftpAdapter(harness.mux) + await new Promise((resolve, reject) => { + adapter.writeFile( + `${home}/a.txt`, + 'hello', + { encoding: 'utf8', mode: 0o600 } as never, + ((e: Error | null) => (e ? reject(e) : resolve())) as never + ) + }) + const content = await new Promise((resolve, reject) => { + adapter.readFile(`${home}/a.txt`, 'utf8', ((e: Error | null, value: string) => + e ? reject(e) : resolve(value)) as never) + }) + expect(content).toBe('hello') + + await new Promise((resolve, reject) => { + adapter.ext_openssh_rename(`${home}/a.txt`, `${home}/b.txt`, ((e: Error | null) => + e ? reject(e) : resolve()) as never) + }) + expect(existsSync(`${home}/b.txt`)).toBe(true) + + const outside = await new Promise((resolve) => { + adapter.readFile('/etc/passwd', 'utf8', ((e: Error) => resolve(e)) as never) + }) + expect(outside).toBeInstanceOf(Error) + }) + + it('runs the unchanged remote managed hook installers against a WSL guest home', async () => { + const adapter = createWslHookSftpAdapter(harness.mux) + const results = await installRemoteManagedAgentHooks(adapter, home) + + expect(results.length).toBeGreaterThan(0) + expect(results.every((r) => r.state !== 'error')).toBe(true) + + const claudeSettings = JSON.parse( + readFileSync(join(home, '.claude', 'settings.json'), 'utf8') ) - }) - const content = await new Promise((resolve, reject) => { - adapter.readFile(`${home}/a.txt`, 'utf8', ((e: Error | null, value: string) => - e ? reject(e) : resolve(value)) as never) - }) - expect(content).toBe('hello') - - await new Promise((resolve, reject) => { - adapter.ext_openssh_rename(`${home}/a.txt`, `${home}/b.txt`, ((e: Error | null) => - e ? reject(e) : resolve()) as never) - }) - expect(existsSync(`${home}/b.txt`)).toBe(true) - - const outside = await new Promise((resolve) => { - adapter.readFile('/etc/passwd', 'utf8', ((e: Error) => resolve(e)) as never) - }) - expect(outside).toBeInstanceOf(Error) - }) - - it('runs the unchanged remote managed hook installers against a WSL guest home', async () => { - const adapter = createWslHookSftpAdapter(harness.mux) - const results = await installRemoteManagedAgentHooks(adapter, home) - - expect(results.length).toBeGreaterThan(0) - expect(results.every((r) => r.state !== 'error')).toBe(true) - - const claudeSettings = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8')) - expect(claudeSettings.hooks).toBeTruthy() - const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8') - expect(script).toContain('/hook/claude') - }, 20_000) -}) + expect(claudeSettings.hooks).toBeTruthy() + const script = readFileSync(join(home, '.orca', 'agent-hooks', 'claude-hook.sh'), 'utf8') + expect(script).toContain('/hook/claude') + }, 20_000) + } +) describe('WslHookRelayManager', () => { - let home: string + // Why: a fixed POSIX guest home keeps this suite runnable on Windows dev + // hosts — installHooks is mocked here, so the fs bridge only ever serves + // the wslfs.home request and never touches the real filesystem. + const home = '/home/wsl-test-user' let harnesses: GuestHarness[] beforeEach(() => { - home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-')) harnesses = [] }) @@ -135,7 +145,6 @@ describe('WslHookRelayManager', () => { h.mux.dispose() h.guestDispatcher.dispose() } - rmSync(home, { recursive: true, force: true }) }) function fakeChild(): ChildProcessWithoutNullStreams & { emitClose: () => void } { @@ -204,7 +213,10 @@ describe('WslHookRelayManager', () => { manager.ensureForDistro('Ubuntu') await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) expect(deps.spawnRelay).toHaveBeenCalledTimes(1) - expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home) + // Codex is the one agent whose home Orca redirects for WSL sessions. + expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, { + codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home` + }) expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe( `${home}/.orca-wsl/agent-hooks/port-43117/endpoint.env` diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 8e40e1b0c49..af12f958d80 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -5,19 +5,20 @@ // configs into the guest over the relay's fs bridge. import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { createWslHookSftpAdapter } from './wsl-hook-fs-adapter' -import { buildGuestInstallScript, type WslRelayStartupFailure } from './wsl-hook-relay-launch' -import { defaultWslHookRelayDeps, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' -import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' -import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { installWslGuestHooks } from './wsl-hook-fs-adapter' import { - AGENT_HOOK_NOTIFICATION_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD -} from '../../shared/agent-hook-relay' + buildGuestInstallScript, + buildWslRelaySpawnEnv, + formatWslRelayFailure, + type WslRelayStartupFailure +} from './wsl-hook-relay-launch' +import { defaultWslHookRelayDeps, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' +import { wireWslRelayLink } from './wsl-hook-relay-link' +import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' +import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay' import { WSL_HOOK_FS_METHODS, WSL_HOOK_RELAY_NO_NODE_EXIT_CODE, - WSL_HOOK_RELAY_VERSION_ENV, wslHookRelayEndpointFilePath } from '../../shared/wsl-hook-relay-contract' @@ -29,14 +30,26 @@ const FAILURE_COOLDOWN_MAX_MS = 10 * 60_000 // Why: a distro without node >= 18 will not grow one mid-session; probe // rarely instead of once per PTY spawn. const NO_NODE_COOLDOWN_MS = 10 * 60_000 +// Why: a previously-healthy relay dying mid-session (mux protocol error, WSL +// restart) must self-recover — a live agent session produces no new PTY +// spawns, so waiting for the next ensure would leave status dead for good. +const RUNNING_TEARDOWN_COOLDOWN_MS = 10_000 +// Why: re-running the (byte-equality idempotent) installers on later spawns +// picks up configs that appeared after first install — e.g. Codex's runtime +// home config.toml is seeded by the launch path, so its hook-trust entries +// can only be written once that file exists. Throttled: cheap but not free. +const REINSTALL_MIN_INTERVAL_MS = 30_000 type DistroState = { phase: 'starting' | 'running' | 'failed' child?: ChildProcessWithoutNullStreams mux?: SshChannelMultiplexer + guestHome?: string guestEndpointFilePath?: string failures: number cooldownUntil: number + restartTimer?: ReturnType + lastInstallAt?: number } export class WslHookRelayManager { @@ -76,6 +89,9 @@ export class WslHookRelayManager { disposeAll(): void { this.disposed = true for (const state of this.states.values()) { + if (state.restartTimer) { + clearTimeout(state.restartTimer) + } state.mux?.dispose() state.child?.kill() } @@ -89,6 +105,10 @@ export class WslHookRelayManager { } const existing = this.states.get(distro) if (existing) { + if (existing.phase === 'running') { + void this.maybeReinstallHooks(distro, existing) + return + } if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) { return } @@ -103,6 +123,9 @@ export class WslHookRelayManager { this.deps.warn('[agent-hooks] WSL hook relay bundle not found; run build:relay') return } + if (existing?.restartTimer) { + clearTimeout(existing.restartTimer) + } const state: DistroState = { phase: 'starting', failures: existing?.failures ?? 0, @@ -110,28 +133,21 @@ export class WslHookRelayManager { } this.states.set(distro, state) - const env: NodeJS.ProcessEnv = { - ...process.env, - ORCA_AGENT_HOOK_PORT: coords.ORCA_AGENT_HOOK_PORT, - ORCA_AGENT_HOOK_TOKEN: coords.ORCA_AGENT_HOOK_TOKEN, - ORCA_AGENT_HOOK_ENV: coords.ORCA_AGENT_HOOK_ENV, - ORCA_AGENT_HOOK_VERSION: coords.ORCA_AGENT_HOOK_VERSION, - [WSL_HOOK_RELAY_VERSION_ENV]: bundle.version - } - // Why: the relay derives its own guest endpoint path; a /p-translated - // Windows endpoint here would only add WSLENV noise. - delete env.ORCA_AGENT_HOOK_ENDPOINT - addOrcaWslInteropEnv(env as Record) + const env = buildWslRelaySpawnEnv(coords, bundle.version) try { await this.launchWithInstall(distro, state, env, bundle.jsPath, bundle.version, port) } catch (err) { - this.markFailed( - distro, - state, - err instanceof Error ? err.message : String(err), - FAILURE_COOLDOWN_BASE_MS - ) + // Why: the teardown handler may have already recorded this failure + // (child died mid-connect); don't double-count it. + if (state.phase !== 'failed') { + this.markFailed( + distro, + state, + err instanceof Error ? err.message : String(err), + FAILURE_COOLDOWN_BASE_MS + ) + } } } @@ -174,7 +190,7 @@ export class WslHookRelayManager { transientRetries < TRANSIENT_RETRY_LIMIT ) { transientRetries++ - await delay(this.deps.transientRetryDelayMs) + await new Promise((resolve) => setTimeout(resolve, this.deps.transientRetryDelayMs)) continue } if (!installTried) { @@ -192,7 +208,7 @@ export class WslHookRelayManager { ) return } - this.markFailed(distro, state, formatFailure(failure), FAILURE_COOLDOWN_BASE_MS) + this.markFailed(distro, state, formatWslRelayFailure(failure), FAILURE_COOLDOWN_BASE_MS) return } } @@ -207,29 +223,26 @@ export class WslHookRelayManager { ): Promise { const mux = new SshChannelMultiplexer(transport) state.mux = mux - const connectionId = `wsl:${distro}` - mux.onNotification((method, params) => { - if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { - return + wireWslRelayLink({ + mux, + child, + distro, + ingest: this.deps.ingest, + warn: this.deps.warn, + onDead: (reason) => { + if (this.disposed || state.mux !== mux) { + return + } + state.mux = undefined + const wasRunning = state.phase === 'running' + this.markFailed( + distro, + state, + `relay link for '${distro}' ${reason}; scheduling restart`, + wasRunning ? RUNNING_TEARDOWN_COOLDOWN_MS : FAILURE_COOLDOWN_BASE_MS + ) + this.scheduleRestart(distro, state) } - if (typeof (params as { paneKey?: unknown }).paneKey !== 'string') { - return - } - // Trust boundary: ingestRemote re-validates paneKey/tabId and - // re-normalizes the payload, same as the SSH relay path. - this.deps.ingest(params, connectionId) - }) - child.on('close', () => { - if (state.mux !== mux || this.disposed) { - return - } - mux.dispose() - this.markFailed( - distro, - state, - `relay for '${distro}' exited; will re-ensure on next WSL PTY spawn`, - FAILURE_COOLDOWN_BASE_MS - ) }) const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as { @@ -237,14 +250,9 @@ export class WslHookRelayManager { home?: string } if (homeResult?.ok === true && typeof homeResult.home === 'string') { + state.guestHome = homeResult.home state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, windowsPort) - const results = await this.deps.installHooks(createWslHookSftpAdapter(mux), homeResult.home) - const failed = results.filter((r) => r.state === 'error').length - if (failed > 0) { - this.deps.warn( - `[agent-hooks] WSL hook install for '${distro}': ${failed}/${results.length} agents failed` - ) - } + await this.runInstallers(distro, state, mux, homeResult.home) } else { this.deps.warn(`[agent-hooks] WSL hook relay for '${distro}' returned no home dir`) } @@ -261,6 +269,57 @@ export class WslHookRelayManager { }) } + private async runInstallers( + distro: string, + state: DistroState, + mux: SshChannelMultiplexer, + guestHome: string + ): Promise { + state.lastInstallAt = Date.now() + await installWslGuestHooks({ + mux, + guestHome, + distro, + installHooks: this.deps.installHooks, + warn: this.deps.warn + }) + } + + /** Re-run the byte-equality-idempotent installers on later ensure calls so + * configs that appear after first install (e.g. Codex's launch-seeded + * runtime-home config.toml) pick up their hook entries. */ + private async maybeReinstallHooks(distro: string, state: DistroState): Promise { + const mux = state.mux + const guestHome = state.guestHome + if ( + !mux || + !guestHome || + mux.isDisposed() || + Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS + ) { + return + } + try { + await this.runInstallers(distro, state, mux, guestHome) + } catch (err) { + this.deps.warn( + `[agent-hooks] WSL hook reinstall for '${distro}' failed: ${err instanceof Error ? err.message : String(err)}` + ) + } + } + + private scheduleRestart(distro: string, state: DistroState): void { + if (this.disposed || state.restartTimer) { + return + } + const delayMs = Math.max(state.cooldownUntil - Date.now(), 0) + 250 + state.restartTimer = setTimeout(() => { + state.restartTimer = undefined + this.ensureForDistro(distro) + }, delayMs) + state.restartTimer.unref?.() + } + private markFailed( distro: string, state: DistroState, @@ -290,13 +349,4 @@ export class WslHookRelayManager { } } -function formatFailure(failure: WslRelayStartupFailure): string { - const detail = failure.stderr.trim() - return `startup failed (${failure.kind}, code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}` -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - export const wslHookRelayManager = new WslHookRelayManager() diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 67120f15e53..ecfdb450fc2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -33,6 +33,7 @@ import { import { app } from 'electron' import type { CodexManagedAccount } from '../../shared/types' import type { Store } from '../persistence' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { writeFileAtomically } from './fs-utils' import { getOrcaManagedCodexHomePath, @@ -611,9 +612,7 @@ export class CodexRuntimeHomeService { private getWslRuntimeHomePath(distro: string): string | null { const home = getWslHome(distro) - return home - ? this.joinWslPath(home, '.local', 'share', 'orca', 'codex-runtime-home', 'home') - : null + return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null } private safeReadBackActiveWslAccountBeforeRestart( diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0b01d65554f..8318938f8e2 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -962,9 +962,25 @@ export class CodexHookService { return this.getStatus() } - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json` - const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml` + async installRemote( + sftp: SFTPWrapper, + remoteHome: string, + options?: { + /** Explicit CODEX_HOME dir (flat layout: hooks.json/config.toml at its + * root). WSL sessions read Orca's managed runtime home, not ~/.codex — + * installing to the default location leaves those sessions hookless. */ + codexHomeDir?: string + /** Skip the trust write when config.toml doesn't exist yet. The WSL + * runtime home's config.toml is seeded only-if-absent by the launch + * path; creating it here first would silently cancel that seed. A + * later (idempotent) reinstall upserts trust once the seed lands. */ + deferTrustUntilConfigToml?: boolean + } + ): Promise { + const codexHomeBase = + options?.codexHomeDir?.replace(/\/$/, '') ?? `${remoteHome.replace(/\/$/, '')}/.codex` + const remoteConfigPath = `${codexHomeBase}/hooks.json` + const remoteTomlPath = `${codexHomeBase}/config.toml` const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh` try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) @@ -1023,7 +1039,17 @@ export class CodexHookService { // Preserve non-Orca top-level metadata while replacing the hooks tree. await writeHooksJsonRemote(sftp, remoteConfigPath, { ...config, hooks: nextHooks }) try { - const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? '' + const existingTomlRaw = await readTextFileRemote(sftp, remoteTomlPath) + if (existingTomlRaw === null && options?.deferTrustUntilConfigToml === true) { + return { + agent: 'codex', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: 'Trust entries deferred until config.toml is seeded by the launch path' + } + } + const existingToml = existingTomlRaw ?? '' const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries) if (updatedToml !== existingToml) { await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml) diff --git a/src/main/pty/codex-home-wsl-env.ts b/src/main/pty/codex-home-wsl-env.ts index b642e7661f6..f61272de8c3 100644 --- a/src/main/pty/codex-home-wsl-env.ts +++ b/src/main/pty/codex-home-wsl-env.ts @@ -1,3 +1,19 @@ +/** Guest-relative layout of Orca's managed WSL CODEX_HOME. Must stay in sync + * with getWslRuntimeHomePath (codex-accounts/runtime-home-service.ts), which + * builds the UNC twin of this path. */ +export const WSL_CODEX_RUNTIME_HOME_SEGMENTS = [ + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' +] as const + +export function wslCodexRuntimeHomeForGuestHome(guestHome: string): string { + const home = guestHome.endsWith('/') ? guestHome.slice(0, -1) : guestHome + return `${home}/${WSL_CODEX_RUNTIME_HOME_SEGMENTS.join('/')}` +} + export function isHostCodexHomeForWsl(value: string | undefined): boolean { const trimmed = value?.trim() if (!trimmed) {