mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user