fix(agent-hooks): WSL relay link-death recovery + Codex runtime-home hook install (STA-1515)

Follow-ups from the first Windows-rig validation of PR #7903:

Link death: a mux protocol error or keepalive timeout could kill the
host<->guest link while the guest relay stayed alive returning 204s —
the manager stayed 'running' and every later envelope blackholed
silently (the exact observed signature: Claude hooks POST 204, store
never populates). wsl-hook-relay-link.ts now guarantees exactly-once
death handling from either signal (mux dispose OR child exit); the
manager breadcrumbs it, kills the child, and self-restarts after a
short cooldown since a live agent session produces no new PTY spawns
to re-trigger ensure. ORCA_WSL_HOOK_RELAY_DEBUG=1 traces each received
envelope pre-ingest. A live integration test pins the full host chain:
the real esbuild bundle over real child stdio through the real manager
into a real AgentHookServer.ingestRemote, exact Claude POST shape.

Codex: Orca launches WSL Codex with CODEX_HOME redirected to the
managed runtime home (~/.local/share/orca/codex-runtime-home/home), so
hooks installed to ~/.codex were never read. installRemote now accepts
an explicit codex home (flat layout), threaded from the relay manager;
the config.toml trust write is deferred while the 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 byte-equality-idempotent
installers on later ensures (30s throttle) to upsert trust once the
seed lands.

Also: WSL test suites now run on Windows dev hosts (fs-backed suites
skip with rig coverage noted; manager suite uses a fixed POSIX home).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-09 13:44:36 -07:00
co-authored by Orca
parent d92dc576ab
commit 9245852d5a
12 changed files with 650 additions and 147 deletions
+28 -3
View File
@@ -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.
@@ -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<string, { hooks: { command: string }[] }[]>
}
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')
@@ -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<AgentHookInstallStatus>
(
sftp: SFTPWrapper,
remoteHome: string,
options?: RemoteManagedHookInstallOptions
) => Promise<AgentHookInstallStatus>
]
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<AgentHookInstallStatus[]> {
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(
@@ -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<void> {
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<T = void> = (err: Error | null, value?: T) => void
// Why: installer-utils-remote classifies errors by ssh2's numeric SFTP status
@@ -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<string, string>,
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<string, string>)
return env
}
@@ -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<string, unknown>, 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'))
}
@@ -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<number> {
const probe = createServer()
const port = await new Promise<number>((resolve) => {
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
await new Promise<void>((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<AgentHookServer['ingestRemote']>[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<string, unknown>): Promise<Response> =>
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)
}
)
@@ -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<Error & { code?: number }>((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<void>((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<Error & { code?: number }>((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<void>((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<string>((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<void>((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<Error & { code?: number }>((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<string>((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<void>((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<Error & { code?: number }>((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`
+117 -67
View File
@@ -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<typeof setTimeout>
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<string, string>)
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<void> {
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<void> {
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<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export const wslHookRelayManager = new WslHookRelayManager()
@@ -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(
+30 -4
View File
@@ -962,9 +962,25 @@ export class CodexHookService {
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
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<AgentHookInstallStatus> {
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)
+16
View File
@@ -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) {