diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts new file mode 100644 index 000000000..f6532c94c --- /dev/null +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -0,0 +1,316 @@ +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import { describe, expect, it, vi } from 'vitest' + +import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' + +type HookHandler = (event?: unknown) => Promise | void + +type FakeCurlChild = { + on: ReturnType + stdin: { + on: ReturnType + end: ReturnType + } +} + +type Harness = { + fetchMock: ReturnType + spawnMock: ReturnType + spawnedChildren: FakeCurlChild[] + fsMock: { + existsSync: ReturnType + readFileSync: ReturnType + } + handlers: Record + callHook: (name: string, event?: unknown) => Promise +} + +const BASE_ENV = { + ORCA_PANE_KEY: 'pane-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'tree-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'token-1', + ORCA_AGENT_HOOK_ENV: 'env-1', + ORCA_AGENT_HOOK_VERSION: '1.2.3' +} satisfies Record + +function createHarness(args: { + kind: 'pi' | 'omp' + env?: Record + title?: string + argv?: string[] + existsSync?: (path: string) => boolean + readFileSync?: (path: string, encoding: string) => string + fetchImpl?: (...params: Parameters) => Promise +}): Harness { + const fetchMock = vi.fn( + args.fetchImpl ?? + (async () => ({ + ok: true + })) + ) + + const spawnedChildren: FakeCurlChild[] = [] + const spawnMock = vi.fn(() => { + const child: FakeCurlChild = { + on: vi.fn(), + stdin: { + on: vi.fn(), + end: vi.fn() + } + } + spawnedChildren.push(child) + return child + }) + + const fsMock = { + existsSync: vi.fn(args.existsSync ?? (() => false)), + readFileSync: vi.fn( + args.readFileSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ) + } + + const module = { + exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void } + } + const requireMock = vi.fn((specifier: string) => { + if (specifier === 'fs') { + return fsMock + } + if (specifier === 'child_process') { + return { spawn: spawnMock } + } + throw new Error(`unexpected require(${specifier})`) + }) + + const processMock = { + env: { + ...BASE_ENV, + ...args.env + }, + title: args.title ?? 'node', + argv: args.argv ?? ['node', '/usr/bin/orca'] + } + + const context = { + module, + exports: module.exports, + require: requireMock, + process: processMock, + fetch: fetchMock, + console: { + warn: vi.fn(), + error: vi.fn(), + log: vi.fn() + }, + Promise, + Buffer, + URL, + setTimeout, + clearTimeout + } as Record + context.globalThis = context + + const source = getPiAgentStatusExtensionSource(args.kind) + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020 + } + }).outputText + runInNewContext(output, context) + + const register = module.exports.default + if (!register) { + throw new Error('expected default export from generated source') + } + + const handlers: Record = {} + register({ + on(name: string, handler: HookHandler) { + handlers[name] = handler + } + }) + + return { + fetchMock, + spawnMock, + spawnedChildren, + fsMock, + handlers, + callHook: async (name, event) => { + await handlers[name]?.(event) + } + } +} + +describe('getPiAgentStatusExtensionSource', () => { + it('routes an OMP executable through /hook/omp', async () => { + const harness = createHarness({ + kind: 'pi', + title: 'omp', + existsSync: () => false + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:4321/hook/omp') + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('keeps native fetch as the only path even when the runtime looks like WSL', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + existsSync: () => true + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('falls back to Windows curl from WSL when fetch fails', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).toHaveBeenCalledTimes(1) + + const [command, args, options] = harness.spawnMock.mock.calls[0] ?? [] + expect(command).toBe('/mnt/c/Windows/System32/curl.exe') + expect(args).toEqual([ + '-sS', + '--connect-timeout', + '3', + '--max-time', + '10', + '--noproxy', + '127.0.0.1', + '-o', + 'NUL', + '-X', + 'POST', + '-H', + 'Content-Type: application/json', + '-H', + 'X-Orca-Agent-Hook-Token: token-1', + '--data-binary', + '@-', + 'http://127.0.0.1:4321/hook/omp' + ]) + // Why: delivery must be fire-and-forget off the pi event loop — no + // blocking wait — with the payload fed via stdin, never argv. + expect(options).toEqual({ stdio: ['pipe', 'ignore', 'ignore'] }) + const child = harness.spawnedChildren[0] + expect(child?.on).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child?.stdin.on).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child?.stdin.end).toHaveBeenCalledWith( + JSON.stringify({ + paneKey: 'pane-1', + launchToken: 'launch-1', + tabId: 'tab-1', + worktreeId: 'tree-1', + env: 'env-1', + version: '1.2.3', + payload: { hook_event_name: 'agent_start' } + }) + ) + }) + + it('probes WSL evidence and the curl path once per process', async () => { + const harness = createHarness({ + kind: 'omp', + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + readFileSync: (path) => { + if (path === '/proc/sys/kernel/osrelease') { + return 'microsoft-standard-WSL2' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + await harness.callHook('agent_end') + + expect(harness.spawnMock).toHaveBeenCalledTimes(2) + // Why: WSL-ness and curl.exe presence are process-lifetime constants; + // the per-event failure path must not re-probe /proc or /mnt/c. + const procReads = harness.fsMock.readFileSync.mock.calls.filter(([path]) => + String(path).startsWith('/proc/') + ) + expect(procReads).toHaveLength(1) + expect(harness.fsMock.existsSync).toHaveBeenCalledTimes(1) + }) + + it('stays fail-open on ordinary Linux', async () => { + const harness = createHarness({ + kind: 'omp', + existsSync: () => true, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('does not treat WSLENV alone as WSL evidence', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSLENV: 'FOO/u' }, + existsSync: () => true, + readFileSync: (path) => { + if (path === '/proc/sys/kernel/osrelease' || path === '/proc/version') { + return 'Linux 6.8.0 generic' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('remains fail-open when the Windows curl bridge is missing', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + existsSync: () => false, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await expect(harness.callHook('agent_start')).resolves.toBeUndefined() + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 48172dfbf..a607199e8 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -130,6 +130,88 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' } catch {', ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', + ' if (!isWslRuntime()) return', + ' postViaWindowsCurl(url, coords, body)', + ' }', + '}', + '', + '// Why: WSL-ness and curl.exe presence cannot change within a process', + '// lifetime; re-probing /proc and /mnt/c on every failed event would add', + '// filesystem work to the per-event path.', + 'let cachedIsWslRuntime: boolean | null = null', + 'let cachedWindowsCurlPath: string | null | undefined', + '', + 'function isWslRuntime(): boolean {', + ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', + ' cachedIsWslRuntime = detectWslRuntime()', + ' return cachedIsWslRuntime', + '}', + '', + 'function detectWslRuntime(): boolean {', + ' if (process.env.WSL_DISTRO_NAME) return true', + ' try {', + " const fs = require('fs')", + " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", + ' try {', + " const contents = String(fs.readFileSync(path, 'utf8'))", + ' if (/microsoft|wsl/i.test(contents)) return true', + ' } catch {', + ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', + ' }', + ' }', + ' } catch {', + ' return false', + ' }', + ' return false', + '}', + '', + 'function resolveWindowsCurlPath(): string | null {', + ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', + ' try {', + " const fs = require('fs')", + " const curlPath = '/mnt/c/Windows/System32/curl.exe'", + ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', + ' } catch {', + ' cachedWindowsCurlPath = null', + ' }', + ' return cachedWindowsCurlPath', + '}', + '', + '// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot', + '// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the', + '// listener Orca binds. Fire-and-forget: blocking on the spawn would stall', + '// the pi event loop (and the TUI) on every hook event.', + 'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {', + ' const curlPath = resolveWindowsCurlPath()', + ' if (!curlPath) return', + ' try {', + " const { spawn } = require('child_process')", + ' const child = spawn(', + ' curlPath,', + ' [', + " '-sS',", + ' // Why: the spawn is detached from the event loop, so these bounds', + ' // size a background process, not TUI latency. WSL->Win32 interop', + ' // connects can exceed 0.5s on loaded machines (observed 3/3 drops', + ' // to a healthy listener); size for delivery, not snappiness.', + " '--connect-timeout', '3',", + " '--max-time', '10',", + " '--noproxy', '127.0.0.1',", + " '-o', 'NUL',", + " '-X', 'POST',", + " '-H', 'Content-Type: application/json',", + " '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,", + " '--data-binary', '@-',", + ' url', + ' ],', + " { stdio: ['pipe', 'ignore', 'ignore'] }", + ' )', + " child.on('error', () => {})", + " child.stdin.on('error', () => {})", + ' child.stdin.end(body)', + ' } catch {', + ' // Why: the bridge is best-effort; a failed spawn must not surface', + ' // inside the pi TUI.', ' }', '}', '',