diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 0e291d83e15..77606ff3d5b 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -14,6 +14,7 @@ import { import { tmpdir } from 'os' import { join } from 'path' import { AgentHookServer, _internals } from './server' +import { parseAgentStatusPayload } from '../../shared/agent-status-types' const PANE = 'tab-1:0' @@ -77,6 +78,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + connectionId: null, payload: expect.objectContaining({ state: 'working', prompt: 'replay me', @@ -151,6 +153,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'repo::/tmp/worktree with "quotes"', + connectionId: null, payload: expect.objectContaining({ state: 'working', prompt: 'form encoded', @@ -1202,3 +1205,157 @@ describe('Endpoint file lifecycle', () => { } }) }) + +describe('AgentHookServer ingestRemote', () => { + it('stamps connectionId and forwards a valid relay envelope to the listener', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'conn-1', + payload + }) + }) + + it('drops envelopes whose payload state is not in AGENT_STATUS_STATES', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + // Why: bypass parseAgentStatusPayload (which itself rejects bad states) by + // constructing an obviously-invalid payload — `ingestRemote` is the trust + // boundary we're testing, not the parser. + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'nonsense', prompt: '', agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('drops envelopes whose paneKey exceeds MAX_PANE_KEY_LEN', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + // 201 chars — one past the listener's 200-char cap. + const oversized = 'a'.repeat(201) + server.ingestRemote( + { paneKey: oversized, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + '' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects whitespace-only connectionId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', payload }, + ' ' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects non-string tabId', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: PANE, tabId: 123 as unknown as string, worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects empty paneKey after trim', () => { + const server = new AgentHookServer() + const payload = parseAgentStatusPayload( + JSON.stringify({ state: 'working', prompt: 'p', agentType: 'claude' }) + ) + if (!payload) { + throw new Error('parseAgentStatusPayload returned null for a known-good fixture') + } + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { paneKey: ' ', tabId: 'tab-1', worktreeId: 'wt-1', payload }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('normalizes inner payload via normalizeAgentStatusPayload — clamps oversized prompt', () => { + // Why: the relay normally normalizes the payload on the wire, but a buggy + // or malicious relay could forward an over-cap field. ingestRemote must + // re-run the canonical normalizer so the AGENT_STATUS_MAX_FIELD_LENGTH + // cap (200 chars) is enforced at the trust boundary. + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', prompt: 'x'.repeat(500), agentType: 'claude' } + }, + 'conn-1' + ) + expect(listener).toHaveBeenCalledTimes(1) + const event = listener.mock.calls[0][0] as { payload: { prompt: string } } + expect(event.payload.prompt.length).toBe(200) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e88d08ad9ae..9dff04237c9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,1187 +1,36 @@ -/* eslint-disable max-lines -- Why: the hook server owns the full HTTP ingest surface (routing, body parsing, per-CLI normalization, transcript scan, pane dispatch) in one place so the contract with Claude/Codex/Gemini hooks stays consistent and doesn't drift across files. */ +// Why: this module is the Orca-main-process adapter for the shared +// agent-hook listener pipeline (`src/shared/agent-hook-listener.ts`). The +// listener internals (request parsing, payload normalization, endpoint-file +// writing, validation) live in `shared/` so the relay can host the same +// pipeline on the remote without dragging Electron in. This file owns: +// - the loopback HTTP socket + bearer-token auth +// - the IPC fanout (setListener / lastStatusByPaneKey replay) +// - the `ingestRemote` entry point that bypasses HTTP for relay-forwarded +// events (see docs/design/agent-status-over-ssh.md §5) import { createServer, type IncomingMessage, type ServerResponse } from 'http' import { randomUUID } from 'crypto' -import { - chmodSync, - closeSync, - mkdirSync, - openSync, - readdirSync, - readSync, - renameSync, - statSync, - unlinkSync, - writeFileSync -} from 'fs' import { join } from 'path' -import { - parseAgentStatusPayload, - type ParsedAgentStatusPayload -} from '../../shared/agent-status-types' + import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + getEndpointFileName, + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeHookPayload, + parseFormEncodedBody, + readRequestBody, + resolveHookSource, + writeEndpointFile, + type AgentHookEventPayload, + type HookListenerState +} from '../../shared/agent-hook-listener' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { normalizeAgentStatusPayload } from '../../shared/agent-status-types' -// Why: Pi rides this server via a bundled extension (see -// pi/agent-status-extension-source) that fetch()es /hook/pi from inside -// the pi Node process. Like OpenCode, pi has no settings.json hook -// surface — its extensibility is the in-process TypeScript extension API -// (pi.on('agent_start'), 'tool_call', etc.), so the extension pre-maps -// pi's event names to the same hook_event_name vocabulary used here -// before POSTing. See normalizePiEvent below for the mapping. -// -// OpenCode rides this server via a bundled plugin (see opencode/hook-service) -// that fetch()es /hook/opencode from inside the OpenCode process. Unlike -// Claude/Codex/Gemini, OpenCode's event names are in-process plugin events -// (session.status, session.idle, permission.asked) rather than settings.json -// hook names, so the plugin pre-maps them to our hook_event_name vocabulary -// before POSTing. See normalizeOpenCodeEvent below for the mapping. -// -// Cursor (cursor-agent) exposes a declarative hooks.json surface that is -// conceptually similar to Claude's settings.json hooks but uses camelCase -// event names (beforeSubmitPrompt, preToolUse, postToolUse, stop, etc.) per -// https://cursor.com/docs/hooks. See normalizeCursorEvent below. -type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' - -type AgentHookEventPayload = { - paneKey: string - tabId?: string - worktreeId?: string - payload: ParsedAgentStatusPayload -} - -// Why: only log a given version/env mismatch once per process so a stale hook -// script that fires on every keystroke doesn't flood the logs. -const warnedVersions = new Set() -const warnedEnvs = new Set() -// Why: cap the warning Sets so a buggy or malicious local client that varies -// its `version`/`env` fields per request cannot grow these Sets without bound -// for the process lifetime. Once saturated, we drop further warnings (and skip -// inserting the new key) — the diagnostic value of "warn once" is preserved -// for the common case while memory stays bounded against untrusted input. -const MAX_WARNED_KEYS = 32 -// Why: hook events can arrive while Orca is windowless (common on macOS when -// the user closes the window but leaves the app running). Retain the latest -// normalized status per pane so reopening the window can replay current agent -// state instead of showing nothing until the next hook event happens. -const lastStatusByPaneKey = new Map() - -// Why: Claude documents `prompt` on UserPromptSubmit; other agents may use -// different field names. Probe a small allowlist so we can surface the real -// user prompt in the dashboard regardless of which agent is reporting. -function extractPromptText(hookPayload: Record): string { - const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message'] - for (const key of candidateKeys) { - const value = hookPayload[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - // Why: OpenCode's plugin sends MessagePart events with { role, text }. When - // role === 'user', the text *is* the prompt — surface it so the dashboard - // shows the user's most recent input even though OpenCode has no dedicated - // UserPromptSubmit event we can hook into. - if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { - const trimmed = hookPayload.text.trim() - if (trimmed.length > 0) { - return hookPayload.text - } - } - return '' -} - -function parseFormEncodedBody(body: string): Record { - const params = new URLSearchParams(body) - const parsed: Record = {} - for (const [key, value] of params.entries()) { - parsed[key] = value - } - return parsed -} - -function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - let byteLength = 0 - let settled = false - req.on('data', (chunk: Buffer) => { - if (settled) { - return - } - // Why: check size in bytes (not UTF-16 code units) and stop accumulating - // after rejection so a malicious client cannot push memory past the - // advertised 1 MB cap. - if (byteLength + chunk.length > 1_000_000) { - settled = true - reject(new Error('payload too large')) - req.destroy() - return - } - byteLength += chunk.length - chunks.push(chunk) - }) - req.on('end', () => { - if (settled) { - return - } - settled = true - try { - // Why: decode once via Buffer.concat so multi-byte UTF-8 characters that - // straddle a chunk boundary are reassembled correctly. Per-chunk - // `.toString('utf8')` would corrupt emoji or non-ASCII inside assistant - // messages. - const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' - const contentType = req.headers['content-type'] ?? '' - if (typeof contentType === 'string' && contentType.includes('application/json')) { - resolve(body ? JSON.parse(body) : {}) - return - } - if ( - typeof contentType === 'string' && - contentType.includes('application/x-www-form-urlencoded') - ) { - resolve(parseFormEncodedBody(body)) - return - } - // Why: existing managed scripts POST JSON and the updated Unix scripts - // POST form-encoded data. Default to JSON for unknown/missing content - // types so legacy callers that omit the header still behave as before. - resolve(body ? JSON.parse(body) : {}) - } catch (error) { - reject(error) - } - }) - req.on('error', (err) => { - if (settled) { - return - } - settled = true - reject(err) - }) - // Why: req.destroy() (called by the slowloris setTimeout in the route - // handler) emits 'close'/'aborted' but not 'end' or 'error'. Without this - // handler the promise would never settle and the chunk buffers would be - // retained for the process lifetime, letting a slow client that holds a - // valid token accumulate pending closures indefinitely. - req.on('close', () => { - if (settled) { - return - } - settled = true - reject(new Error('aborted')) - }) - }) -} - -// Why: only UserPromptSubmit carries the user's prompt. Subsequent events in -// the same turn (PostToolUse, PermissionRequest, Stop, …) arrive with no -// prompt, so we cache the last prompt per pane and reuse it until a new -// prompt arrives. The cache survives across `done` so the user can still see -// what finished; it's reset on the next UserPromptSubmit. -const lastPromptByPaneKey = new Map() - -function resolvePrompt( - paneKey: string, - promptText: string, - options?: { resetOnNewTurn?: boolean } -): string { - if (options?.resetOnNewTurn) { - // Why: some turn-boundary events (e.g. Codex SessionStart, OpenCode - // SessionBusy) do not carry the new prompt yet. Clearing here prevents the - // previous turn's prompt from leaking into the new working state until a - // later prompt-bearing event arrives. - lastPromptByPaneKey.delete(paneKey) - } - if (promptText) { - lastPromptByPaneKey.set(paneKey, promptText) - return promptText - } - return lastPromptByPaneKey.get(paneKey) ?? '' -} - -type ToolSnapshot = { - toolName?: string - toolInput?: string - lastAssistantMessage?: string -} - -// Why: mirrors `lastPromptByPaneKey`. Tool + assistant metadata arrives -// piecemeal (PreToolUse gives name+input; PostToolUse gives response; -// Stop gives the final message), and later events typically omit fields -// the earlier ones provided. Caching per-pane lets the renderer show a -// coherent snapshot instead of blinking whenever a field is missing. -const lastToolByPaneKey = new Map() - -function resolveToolState( - paneKey: string, - update: ToolSnapshot, - options: { resetOnNewTurn: boolean } -): ToolSnapshot { - if (options.resetOnNewTurn) { - // Why: a fresh user turn shouldn't inherit the previous turn's - // tool/assistant state — it would look like the agent is still on - // the old step until the first new tool event lands. - lastToolByPaneKey.delete(paneKey) - } - const previous = lastToolByPaneKey.get(paneKey) ?? {} - const merged: ToolSnapshot = { - toolName: update.toolName ?? previous.toolName, - toolInput: update.toolInput ?? previous.toolInput, - lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage - } - lastToolByPaneKey.set(paneKey, merged) - return merged -} - -// Why: per-tool allowlist (noqa style) — explicit mapping from tool name to -// the single input field worth surfacing. Tools that aren't listed render -// name-only. This avoids noisy fallbacks (e.g. "TaskUpdate 3" from the -// task_id field) and keeps the preview honest: if we don't know how to -// describe a tool's input meaningfully, we show nothing rather than guess. -// -// Ordering matters when a tool sends multiple well-known keys (e.g. Grep -// sends both `pattern` and `path`); the first match wins. -const TOOL_INPUT_KEYS_BY_TOOL: Record = { - // Claude tools (PascalCase). - Read: ['file_path', 'filePath', 'path'], - Write: ['file_path', 'filePath', 'path'], - Edit: ['file_path', 'filePath', 'path'], - MultiEdit: ['file_path', 'filePath', 'path'], - NotebookEdit: ['file_path', 'filePath', 'path'], - Bash: ['command'], - Glob: ['pattern'], - Grep: ['pattern'], - WebFetch: ['url'], - WebSearch: ['query'], - // Gemini tools (snake_case). - read_file: ['file_path', 'path'], - write_file: ['file_path', 'path'], - read_many_files: ['file_path', 'paths', 'path'], - edit_file: ['file_path', 'path'], - replace: ['file_path', 'path'], - run_shell_command: ['command'], - glob: ['pattern'], - search_file_content: ['pattern'], - web_fetch: ['url'], - google_web_search: ['query'], - // Codex tools. `exec_command` and `shell_command` both carry their command - // text under `cmd` (the Rust payload) or `command` (some wrappers); list - // both so whichever field is populated wins. `apply_patch` surfaces the - // touched path. `view_image` is path-only. `write_stdin` gets nothing - // meaningful — intentionally omitted so the row stays name-only. - exec_command: ['cmd', 'command'], - shell_command: ['cmd', 'command'], - apply_patch: ['path', 'file_path'], - view_image: ['path', 'file_path'], - // Pi tools (lowercase names matching pi's built-in tool registry). - // Why: pi's tool_call event forwards the raw input object; surface the - // canonical preview field per tool so dashboard rows show useful context - // (file path, command, search pattern) without the receiver knowing - // anything pi-specific beyond these key names. `glob` is shared with - // Gemini (same shape: { pattern }), so no separate entry is needed. - bash: ['command'], - read: ['path', 'file_path'], - write: ['path', 'file_path'], - edit: ['path', 'file_path'], - grep: ['pattern'], - web_search: ['query'], - fetch_content: ['url'] -} - -function deriveToolInputPreview( - toolName: string | undefined, - toolInput: unknown -): string | undefined { - if (typeof toolInput === 'string') { - return toolInput - } - if (typeof toolInput !== 'object' || toolInput === null) { - return undefined - } - if (!toolName) { - return undefined - } - const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] - if (!keys) { - return undefined - } - const record = toolInput as Record - for (const key of keys) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value - } - } - return undefined -} - -function readString(record: Record, key: string): string | undefined { - const value = record[key] - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -// Why: Claude `tool_response` can be a string, or an object with a `content` -// array shaped like `[{type: 'text', text: '...'}]`. Surface the first text -// block so PostToolUse for Task/Agent subagents carries something useful into -// the `lastAssistantMessage` slot. -function extractToolResponseText(toolResponse: unknown): string | undefined { - if (typeof toolResponse === 'string' && toolResponse.length > 0) { - return toolResponse - } - if (typeof toolResponse !== 'object' || toolResponse === null) { - return undefined - } - const record = toolResponse as Record - const content = record.content - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - const text = record.text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - return undefined -} - -// Why: Claude's Stop event carries `transcript_path` to a JSONL transcript. -// Reading the last assistant message gives us the "what did the agent just -// say" preview without needing to buffer tool_response text across PostToolUse -// events. We scan backward from the end of the file in chunks, stopping as -// soon as we find an assistant text entry — bounded work in the common case -// (one chunk) even when transcripts grow to hundreds of MB. -const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 -// Why: ultimate safety cap so a malformed transcript (or a turn with -// pathologically many tool calls and no assistant text) cannot stall the Stop -// handler. 4 MB easily accommodates dozens of tool rounds before the final -// reply; past that, we give up rather than block the hook response. -const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 - -function extractAssistantTextFromLine(line: string): string | undefined { - let entry: unknown - try { - entry = JSON.parse(line) - } catch { - return undefined - } - if (typeof entry !== 'object' || entry === null) { - return undefined - } - const record = entry as Record - const nestedMessage = record.message as Record | undefined - const role = record.role ?? nestedMessage?.role - if (role !== 'assistant') { - return undefined - } - const content = (nestedMessage ?? record).content - if (typeof content === 'string' && content.trim().length > 0) { - return content - } - // Why: assistant entries can be pure tool_use turns with no text parts. - // Return undefined so the caller keeps scanning backward for the most - // recent entry that actually contains assistant text. - if (Array.isArray(content)) { - for (const part of content) { - if (typeof part === 'object' && part !== null) { - const text = (part as Record).text - if (typeof text === 'string' && text.trim().length > 0) { - return text - } - } - } - } - return undefined -} - -function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { - if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { - return undefined - } - try { - const stats = statSync(transcriptPath) - const size = stats.size - if (size <= 0) { - return undefined - } - const fd = openSync(transcriptPath, 'r') - try { - // Why: track unhandled leading bytes as a raw Buffer across iterations - // so multi-byte UTF-8 codepoints that straddle chunk boundaries are not - // corrupted. A previous implementation decoded the combined chunk then - // re-encoded `lines[0]` back to UTF-8 for the carry; when a chunk - // started mid-codepoint the decode produced U+FFFD replacement chars - // and the re-encode baked those replacements into the carry bytes - // permanently, mis-joining every subsequent chunk. Splitting on \n at - // the byte level (0x0a) and only decoding complete-line regions keeps - // the carry byte-exact. - let carryBytes: Buffer = Buffer.alloc(0) - let bytesRead = 0 - while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) - const position = size - bytesRead - chunkSize - const buffer = Buffer.alloc(chunkSize) - // Why: readSync may return fewer bytes than requested (short reads). - // Loop until the full window is read (or EOF) before processing so the - // backward scan windows stay aligned — a bare `bytesRead += n` would - // advance from the last read's tail and either re-read overlapping - // bytes on the next iteration or miss lines entirely if short reads - // accumulate. Rare on local regular files but fs quirks exist. - let filled = 0 - while (filled < chunkSize) { - const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) - if (n === 0) { - break - } - filled += n - } - const n = filled - bytesRead += n - if (n === 0) { - break - } - // Why: the newly-read chunk is earlier in the file than `carryBytes` - // (which came from the *previous* iteration's partial first line), - // so concatenation order is chunk first, carry second. - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - const atStart = bytesRead >= size - - // Find the first newline in the raw bytes. Everything before it is - // a (still) potentially-partial line when we haven't reached SOF; - // everything from it onward is a sequence of complete lines we can - // decode safely. - const firstNewline = combined.indexOf(0x0a) - let completeRegion: Buffer - let nextCarry: Buffer - if (atStart) { - // At start-of-file there is no earlier chunk; every line is complete. - completeRegion = combined - nextCarry = Buffer.alloc(0) - } else if (firstNewline === -1) { - // No newline in the combined bytes: the entire region is one - // potentially-partial line — carry all of it forward. - completeRegion = Buffer.alloc(0) - nextCarry = combined - } else { - // Bytes [0, firstNewline) are the partial-line carry; bytes - // [firstNewline+1, end) are the complete-line region. Dropping the - // newline itself avoids an empty leading "" line after split. - nextCarry = combined.subarray(0, firstNewline) - completeRegion = combined.subarray(firstNewline + 1) - } - - if (completeRegion.length > 0) { - const lines = completeRegion.toString('utf8').split('\n') - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i].trim() - if (line.length === 0) { - continue - } - const extracted = extractAssistantTextFromLine(line) - if (extracted !== undefined) { - return extracted - } - } - } - carryBytes = nextCarry - } - return undefined - } finally { - closeSync(fd) - } - } catch { - return undefined - } -} - -function extractClaudeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - const update: ToolSnapshot = {} - if ( - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ) { - const toolName = readString(hookPayload, 'tool_name') - update.toolName = toolName - update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - } - if (eventName === 'PostToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_response) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'PostToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_response) ?? - readString(hookPayload, 'error') ?? - readString(hookPayload, 'message') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - if (eventName === 'Stop') { - // Why: newer Claude versions include `last_assistant_message` directly on - // the Stop payload, which is both cheaper and more reliable than reading - // the JSONL transcript. Prefer it when present; fall back to transcript - // scanning for older Claude versions that omit the field. - const direct = readString(hookPayload, 'last_assistant_message') - if (direct) { - update.lastAssistantMessage = direct - } else { - const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) - if (lastFromTranscript) { - update.lastAssistantMessage = lastFromTranscript - } - } - } - return update -} - -function extractCodexToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { - // Why: Codex emits tool metadata under `tool_name` + `tool_input` - // (matching Claude's shape). We surface both so the dashboard row can - // show what the agent is currently doing during the otherwise-silent - // gap between UserPromptSubmit and Stop. See TOOL_INPUT_KEYS_BY_TOOL - // for which input field is previewed per Codex tool name. - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) - return { toolName, toolInput } - } - if (eventName === 'Stop') { - // Why: Codex documents `last_assistant_message` on Stop. - const message = readString(hookPayload, 'last_assistant_message') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractGeminiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { - const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.args) ?? - deriveToolInputPreview(toolName, hookPayload.input) - return { toolName, toolInput } - } - if (eventName === 'AfterAgent') { - // Why: Gemini's AfterAgent payload carries the final reply under - // `prompt_response` (per geminicli.com/docs/hooks/reference). This is - // Gemini's analogue of Claude/Codex's `last_assistant_message` on Stop; - // surfacing it lets the dashboard show the agent's response on done. - const message = readString(hookPayload, 'prompt_response') - if (message) { - return { lastAssistantMessage: message } - } - } - return {} -} - -function extractOpenCodeToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { - // Why: OpenCode streams the assistant's reply via repeated MessagePart - // events (one per text delta flush). Each event carries the cumulative - // text-so-far for that TextPart, so the latest one we see is the most - // complete snapshot to surface on `done`. We do NOT gate on SessionIdle - // because the plugin emits parts *before* session.idle fires, and gating - // would lose them. - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -// Why: Cursor's preToolUse / postToolUse / postToolUseFailure payloads carry -// `tool_name` + `tool_input` (same shape as Claude). beforeShellExecution / -// beforeMCPExecution carry a `command` field directly — surface that via a -// synthetic "Shell" / "MCP" tool name so the dashboard row can show the -// pending command while cursor-agent is blocked on approval. -// afterAgentResponse carries a `text` field that is cursor's analogue of -// Claude's last_assistant_message (the final composed reply for the turn). -function extractCursorToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' - ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - const update: ToolSnapshot = { toolName, toolInput } - if (eventName === 'postToolUse') { - const responseText = extractToolResponseText(hookPayload.tool_output) - if (responseText) { - update.lastAssistantMessage = responseText - } - } - if (eventName === 'postToolUseFailure') { - const errorText = - extractToolResponseText(hookPayload.tool_output) ?? - readString(hookPayload, 'error_message') ?? - readString(hookPayload, 'error') - if (errorText) { - update.lastAssistantMessage = errorText - } - } - return update - } - if (eventName === 'beforeShellExecution') { - const command = readString(hookPayload, 'command') - return { toolName: 'Shell', toolInput: command } - } - if (eventName === 'beforeMCPExecution') { - const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' - const toolInput = - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - readString(hookPayload, 'command') ?? - readString(hookPayload, 'url') - return { toolName, toolInput } - } - if (eventName === 'afterAgentResponse') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -// Why: pi's tool_call / tool_execution_start / tool_execution_end events all -// carry `tool_name` + `tool_input` in the same shape, so they share one -// extraction branch; resolveToolState merges across events last-write-wins so -// the most recent one wins naturally. message_end (assistant role) carries -// `text` — pi's analogue of Claude's last_assistant_message on Stop. -function extractPiToolFields( - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if ( - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' - ) { - const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) - return { toolName, toolInput } - } - if (eventName === 'message_end' && hookPayload.role === 'assistant') { - const text = readString(hookPayload, 'text') - if (text) { - return { lastAssistantMessage: text } - } - } - return {} -} - -function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { - if (source === 'claude') { - return eventName === 'UserPromptSubmit' - } - if (source === 'codex') { - // Why: Codex fires SessionStart at resume AND startup. Both mark the - // boundary of a fresh interactive turn from the hook's perspective, so - // clear the tool cache on either one. - return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' - } - if (source === 'gemini') { - return eventName === 'BeforeAgent' - } - if (source === 'cursor') { - // Why: Cursor's beforeSubmitPrompt is the new-turn boundary (it carries - // the fresh prompt). sessionStart also begins a fresh session and should - // not inherit any cached tool state from whatever was left on disk. - return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' - } - if (source === 'pi') { - // Why: pi fires before_agent_start at the start of every user turn, - // carrying the fresh prompt. Reset cached tool state then so a previous - // turn's tool preview does not bleed into the new one. - return eventName === 'before_agent_start' - } - // Why: OpenCode has no UserPromptSubmit analogue, AND the plugin emits the - // user's MessagePart *before* SessionBusy (message.updated fires on prompt - // submission; session.status goes busy only once OpenCode begins processing). - // So resetting on SessionBusy would clobber the user prompt that was just - // cached. The role=user MessagePart itself naturally overwrites the cache - // with each new turn, so no separate reset is needed here. - return false -} - -function extractToolFields( - source: AgentHookSource, - eventName: unknown, - hookPayload: Record -): ToolSnapshot { - if (source === 'claude') { - return extractClaudeToolFields(eventName, hookPayload) - } - if (source === 'codex') { - return extractCodexToolFields(eventName, hookPayload) - } - if (source === 'gemini') { - return extractGeminiToolFields(eventName, hookPayload) - } - if (source === 'cursor') { - return extractCursorToolFields(eventName, hookPayload) - } - if (source === 'pi') { - return extractPiToolFields(eventName, hookPayload) - } - return extractOpenCodeToolFields(eventName, hookPayload) -} - -function normalizeClaudeEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('claude', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }) - - // Why: Claude Code's `Stop` hook sets `is_interrupt: true` when the turn - // ended because the user hit ESC / Ctrl+C rather than completing normally. - // This is the authoritative signal (the agent itself reports it), so we - // forward it through only on Stop — other hook events don't carry it. - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('claude', eventName) - }), - agentType: 'claude', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - ) -} - -// Why: Gemini CLI exposes BeforeAgent/AfterAgent/AfterTool hooks. BeforeAgent -// fires at turn start and AfterTool resumes the working state after a tool -// call completes; AfterAgent fires when the agent becomes idle. Gemini has no -// permission-prompt hook, so we cannot surface a waiting state for Gemini. -function normalizeGeminiEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'BeforeAgent' || - eventName === 'AfterTool' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'AfterAgent' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('gemini', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('gemini', eventName) - }), - agentType: 'gemini', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: we deliberately do NOT map Codex `PreToolUse` to `waiting`. That event -// fires for every tool call, not just ones that actually need approval, so -// mapping it would flicker the dashboard. Instead we keep it at `working` -// (same as Claude) and use it only to update tool-name / tool-input previews -// so a running Codex turn has visible progress between UserPromptSubmit and -// Stop. Real approval signals travel through Codex's separate `notify` -// callback (different install surface); wiring that up is deferred. -function normalizeCodexEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'SessionStart' || - eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || - eventName === 'PostToolUse' - ? 'working' - : eventName === 'Stop' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('codex', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }), - agentType: 'codex', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: OpenCode has no declarative hook surface — it exposes in-process plugin -// events (session.status busy/idle, session.idle, permission.asked, -// question.asked, message.updated, message.part.updated). The bundled plugin -// (see opencode/hook-service) pre-maps those to our stable hook_event_name -// vocabulary before POSTing so this normalizer can share the same switch -// shape as Claude/Codex/Gemini. SessionBusy = turn started, SessionIdle = -// turn finished, PermissionRequest = blocked on user approval, AskUserQuestion = -// blocked on user reply to an ask-the-user tool (both map to `waiting` so the -// sidebar renders the red "needs attention" indicator), MessagePart = -// incremental text from user prompt or assistant reply (stays in `working` -// because streaming chunks must not flip the row to done mid-turn). -function normalizeOpenCodeEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'SessionBusy' || eventName === 'MessagePart' - ? 'working' - : eventName === 'SessionIdle' - ? 'done' - : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' - ? 'waiting' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState( - paneKey, - extractToolFields('opencode', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } - ) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('opencode', eventName) - }), - agentType: 'opencode', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -// Why: Cursor (cursor-agent) installs hooks via ~/.cursor/hooks.json with -// camelCase event names, per https://cursor.com/docs/hooks. The CLI fires -// stdin-JSON payloads for each subscribed event; we subscribe to the subset -// that marks turn boundaries and produces meaningful working/done/waiting -// transitions for the Orca sidebar. afterAgentResponse carries the final -// assistant reply text, which is cursor's analogue of Claude's Stop -// last_assistant_message — we keep the row in `working` there because the -// true turn-end signal is `stop`. -function normalizeCursorEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'beforeSubmitPrompt' || - eventName === 'sessionStart' || - eventName === 'preToolUse' || - eventName === 'postToolUse' || - eventName === 'postToolUseFailure' || - eventName === 'afterAgentResponse' - ? 'working' - : eventName === 'stop' || eventName === 'sessionEnd' - ? 'done' - : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' - ? 'waiting' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('cursor', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }) - - // Why: cursor-agent reports turn interrupts via `stop` with status !== - // "completed" (e.g. "cancelled", "stopped"). Forward the boolean so the - // sidebar can render the interrupted-turn treatment that Claude uses. - const interrupted = - eventName === 'stop' && - typeof hookPayload.status === 'string' && - hookPayload.status !== 'completed' - ? true - : undefined - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('cursor', eventName) - }), - agentType: 'cursor', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage, - interrupted - }) - ) -} - -// Why: pi exposes an in-process TypeScript extension API (no settings.json -// hook surface). The bundled orca-agent-status extension installed into the -// per-PTY pi overlay (PiTitlebarExtensionService) translates pi's lifecycle -// events into the hook_event_name vocabulary used here so the dashboard -// row sees the same working/done shape as Claude/Codex/Gemini. -// -// Mapping: -// before_agent_start | agent_start | tool_call | tool_execution_start | -// tool_execution_end | message_end → working -// agent_end | session_shutdown → done -// -// pi has no permission-prompt event we can hook today (tool_call CAN block -// via { block: true, reason } but that's a synchronous return, not a -// separate event), so there is no `waiting` state for pi yet. message_end -// stays in `working` because the true turn-end signal is agent_end — the -// assistant message body just updates lastAssistantMessage on the -// already-active turn. -function normalizePiEvent( - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - const state = - eventName === 'before_agent_start' || - eventName === 'agent_start' || - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' || - eventName === 'message_end' - ? 'working' - : eventName === 'agent_end' || eventName === 'session_shutdown' - ? 'done' - : null - - if (!state) { - return null - } - - const snapshot = resolveToolState(paneKey, extractToolFields('pi', eventName, hookPayload), { - resetOnNewTurn: isNewTurnEvent('pi', eventName) - }) - - return parseAgentStatusPayload( - JSON.stringify({ - state, - prompt: resolvePrompt(paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('pi', eventName) - }), - agentType: 'pi', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - lastAssistantMessage: snapshot.lastAssistantMessage - }) - ) -} - -function readStringField(record: Record, key: string): string | undefined { - const value = record[key] - if (typeof value !== 'string') { - return undefined - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined -} - -function normalizeHookPayload( - source: AgentHookSource, - body: unknown, - expectedEnv: string -): AgentHookEventPayload | null { - if (typeof body !== 'object' || body === null) { - return null - } - - const record = body as Record - const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const rawPayload = record.payload - const hookPayload = - typeof rawPayload === 'string' - ? (() => { - try { - return JSON.parse(rawPayload) - } catch { - return null - } - })() - : rawPayload - // Why: paneKey comes from an authenticated-but-potentially-malicious local - // client; bound its size so pathological clients cannot blow up the - // per-pane caches (lastPromptByPaneKey / lastToolByPaneKey) with multi-MB - // keys. 200 chars is well above any legitimate `${tabId}:${paneId}` value. - const MAX_PANE_KEY_LEN = 200 - if ( - !paneKey || - paneKey.length > MAX_PANE_KEY_LEN || - typeof hookPayload !== 'object' || - hookPayload === null - ) { - return null - } - - // Why: scripts installed by an older app build may send a different shape. - // We accept the request (fail-open) but log once so stale installs are - // diagnosable instead of silently degrading. - const version = readStringField(record, 'version') - if ( - version && - version !== ORCA_HOOK_PROTOCOL_VERSION && - !warnedVersions.has(version) && - warnedVersions.size < MAX_WARNED_KEYS - ) { - warnedVersions.add(version) - console.warn( - `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + - 'Reinstall agent hooks from Settings to upgrade the managed script.' - ) - } - - // Why: detects dev-vs-prod cross-talk. A hook installed by a dev build but - // triggered inside a prod terminal (or vice versa) still points at whichever - // loopback port the shell env captured, so the *other* instance may receive - // it. Logging the mismatch lets a user know their terminals are wired to the - // wrong Orca. - const clientEnv = readStringField(record, 'env') - if (clientEnv && clientEnv !== expectedEnv) { - const key = `${clientEnv}->${expectedEnv}` - if (!warnedEnvs.has(key) && warnedEnvs.size < MAX_WARNED_KEYS) { - warnedEnvs.add(key) - console.warn( - `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + - 'Likely a stale terminal from another Orca install.' - ) - } - } - - const tabId = readStringField(record, 'tabId') - const worktreeId = readStringField(record, 'worktreeId') - - const eventName = (hookPayload as Record).hook_event_name - const promptText = extractPromptText(hookPayload as Record) - const hookPayloadRecord = hookPayload as Record - const payload = - source === 'claude' - ? normalizeClaudeEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'codex' - ? normalizeCodexEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'gemini' - ? normalizeGeminiEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'cursor' - ? normalizeCursorEvent(eventName, promptText, paneKey, hookPayloadRecord) - : source === 'pi' - ? normalizePiEvent(eventName, promptText, paneKey, hookPayloadRecord) - : normalizeOpenCodeEvent(eventName, promptText, paneKey, hookPayloadRecord) - - return payload ? { paneKey, tabId, worktreeId, payload } : null -} - -// Why: the endpoint file lives under userData so each Orca install (dev vs. -// packaged) has its own path and the two cannot clobber each other. Using a -// per-platform extension (`.env` on POSIX, `.cmd` on Windows) lets the hook -// scripts source the file with their platform-native syntax (`.` on POSIX, -// `call` on Windows); the OpenCode plugin's regex accepts both shapes so no -// platform detection is needed inside the plugin source either. -function getEndpointFileName(): string { - return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' -} - -// Why: every value in the endpoint file is sourced as shell. Reject any -// value that contains shell/cmd metacharacters so a future field whose -// value is not shell-safe-by-construction cannot command-inject via the -// sourced file. Keep to a conservative allowlist of common printable -// chars plus hyphen/dot/slash/colon/underscore — sufficient for ports, -// UUIDs, version strings, and env names. -// Rejects empty values (`+` quantifier) as defense-in-depth for future -// callers — an empty sourced `KEY=` would silently clear the env var in -// the sourcing shell, masking whatever legitimate value was previously set. -function isShellSafeEndpointValue(value: string): boolean { - return /^[A-Za-z0-9._:/-]+$/.test(value) -} +export type { AgentHookSource } export class AgentHookServer { private server: ReturnType | null = null @@ -1194,19 +43,14 @@ export class AgentHookServer { private onAgentStatus: ((payload: AgentHookEventPayload) => void) | null = null // Why: directory that holds the on-disk endpoint file. Set via start()'s // `userDataPath` option so the class has no direct Electron dependency - // (keeps it mockable in the vitest node environment). When unset, we skip - // the endpoint-file write entirely — hooks still work via PTY env, just - // without survive-a-restart semantics. + // (keeps it mockable in the vitest node environment). private endpointDir: string | null = null private endpointFilePathCache: string | null = null - // Why: tracks whether writeEndpointFile() succeeded for the *current* - // start(). Without this flag, buildPtyEnv() would expose - // ORCA_AGENT_HOOK_ENDPOINT pointing at a path that may hold stale - // coordinates from a prior crashed instance — hook scripts would source - // those stale coords and silently post to a dead server. Gating the - // ENDPOINT env var on a successful write preserves the - // fail-open-to-fresh-env guarantee. private endpointFileWritten = false + // Why: per-instance caches (warn-once Sets, lastPrompt/lastTool/lastStatus + // by paneKey). Held on the instance instead of as module-level Maps so + // tests can spin up multiple servers without state cross-contamination. + private state: HookListenerState = createHookListenerState() setListener(listener: ((payload: AgentHookEventPayload) => void) | null): void { this.onAgentStatus = listener @@ -1215,7 +59,7 @@ export class AgentHookServer { } // Why: replay is best-effort per pane so one throwing listener call can't // starve subsequent panes from being replayed. - for (const payload of lastStatusByPaneKey.values()) { + for (const payload of this.state.lastStatusByPaneKey.values()) { try { listener(payload) } catch (err) { @@ -1224,6 +68,86 @@ export class AgentHookServer { } } + /** Ingest a payload that arrived over the relay JSON-RPC channel rather + * than the local HTTP server. `connectionId` is the SshChannelMultiplexer + * identity Orca holds (the wire envelope carries connectionId: null and + * Orca stamps the real value here). The relay pre-normalizes the inner + * payload via the shared listener module; we re-run the canonical + * normalizer here as a defense-in-depth check at the trust boundary + * before feeding the event into the same `onAgentStatus` fanout the HTTP + * path uses. See docs/design/agent-status-over-ssh.md §5. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + // Why: forwarded verbatim from the agent CLI POST body on the remote so + // the warn-once cross-build / dev-vs-prod diagnostics fire identically + // to the local HTTP path. Declared on the type now; consumed in PR2. + env?: string + version?: string + payload: unknown + }, + connectionId: string + ): void { + // Why: signature says non-empty, but the wire crosses a trust boundary — + // re-check at runtime (and trim) so a whitespace-only or empty + // connectionId can't poison caches. + if (typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId.trim() + if (trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string' || envelope.paneKey.length === 0) { + return + } + // Why: match the listener's HTTP path — `normalizeHookPayload` trims and + // length-caps paneKey before caching, so the cache key here must follow + // the same rule or remote-vs-local events for the same pane would diverge. + const paneKey = envelope.paneKey.trim() + if (paneKey.length === 0 || paneKey.length > MAX_PANE_KEY_LEN) { + return + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's `readStringField` behavior — trim and treat + // empty-after-trim as undefined rather than letting a literal "" leak + // into the event. + const tabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + // Why: the relay is across a trust boundary; re-run the canonical + // normalizer on the inner payload so prompt/agentType/toolName/toolInput + // length caps, embedded-newline collapse, and the `interrupted`-only-on- + // done invariant are enforced here too. Returns null on malformed input + // (including invalid state), which subsumes the prior explicit state + // check. + const normalizedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!normalizedPayload) { + return + } + const event: AgentHookEventPayload = { + paneKey, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + payload: normalizedPayload + } + this.state.lastStatusByPaneKey.set(paneKey, event) + this.onAgentStatus?.(event) + } + async start(options?: { env?: string; userDataPath?: string }): Promise { if (this.server) { return @@ -1254,39 +178,23 @@ export class AgentHookServer { // Why: bound request time so a slow/stalled client cannot hold a socket // open indefinitely (slowloris-style). The hook endpoints are local and // should complete in well under a second. - req.setTimeout(5000, () => { + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { req.destroy() }) try { const body = await readRequestBody(req) - // Why: match on pathname only so a future debugging addition of a - // query string or trailing slash from a hook sender does not silently - // 404 a valid, token-authenticated request. const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - const source: AgentHookSource | null = - pathname === '/hook/claude' - ? 'claude' - : pathname === '/hook/codex' - ? 'codex' - : pathname === '/hook/gemini' - ? 'gemini' - : pathname === '/hook/opencode' - ? 'opencode' - : pathname === '/hook/cursor' - ? 'cursor' - : pathname === '/hook/pi' - ? 'pi' - : null + const source = resolveHookSource(pathname) if (!source) { res.writeHead(404) res.end() return } - const payload = normalizeHookPayload(source, body, this.env) + const payload = normalizeHookPayload(this.state, source, body, this.env) if (payload) { - lastStatusByPaneKey.set(payload.paneKey, payload) + this.state.lastStatusByPaneKey.set(payload.paneKey, payload) this.onAgentStatus?.(payload) } @@ -1301,12 +209,9 @@ export class AgentHookServer { }) await new Promise((resolve, reject) => { - // Why: the startup error handler must only reject the start() promise for - // errors that happen before 'listening'. Without swapping it out on - // success, any later runtime error (e.g. EADDRINUSE during rebind, - // socket errors) would call reject() on an already-settled promise and, - // more importantly, leaving it as the only 'error' listener means node - // treats runtime errors as unhandled and crashes the main process. + // Why: swap the startup error handler on success so a later runtime + // error (e.g. EADDRINUSE during rebind, socket errors) doesn't reject + // an already-settled promise or crash the main process as unhandled. const onStartupError = (err: Error): void => { this.server?.off('listening', onListening) reject(err) @@ -1320,11 +225,7 @@ export class AgentHookServer { if (address && typeof address === 'object') { this.port = address.port } - // Why: the endpoint file is the core of the survives-Orca-restart - // design. Write it *after* we have a concrete port — hooks that source - // the file must see a usable coordinate set, not a stale one left over - // from a previous process (e.g. one that crashed before getting here). - this.writeEndpointFile() + this.maybeWriteEndpointFile() resolve() } this.server!.once('error', onStartupError) @@ -1340,36 +241,16 @@ export class AgentHookServer { this.env = 'production' this.onAgentStatus = null // Why: intentionally do NOT delete the endpoint file on stop(). A stale - // file points at a dead port, which matches the fail-open policy (hook - // POSTs silently fail → same as pre-endpoint-file behavior). Attempting to unlink - // introduces a TOCTOU race: a concurrent Orca instance sharing userData - // could rewrite the file between our token check and unlink, and we'd - // delete their live endpoint file. The next successful start() overwrites - // the file atomically; the tmp-file sweep inside writeEndpointFile() - // handles orphan hygiene. + // file points at a dead port, which matches the fail-open policy. Unlink + // would introduce a TOCTOU race vs. a concurrent Orca instance. this.endpointDir = null this.endpointFilePathCache = null this.endpointFileWritten = false - // Why: drop all per-pane cache entries on shutdown so a subsequent start() - // in the same process (e.g. during tests or a settings-driven restart) - // does not inherit stale prompt/tool state from the previous run. - lastPromptByPaneKey.clear() - lastToolByPaneKey.clear() - lastStatusByPaneKey.clear() - // Why: across stop()/start() cycles the warn-once Sets would otherwise - // suppress legitimate new warnings after a restart. - warnedVersions.clear() - warnedEnvs.clear() + clearAllListenerCaches(this.state) } clearPaneState(paneKey: string): void { - // Why: callers invoke this on PTY teardown so the per-pane caches do not - // accumulate entries for dead panes over the process lifetime. Without - // this, every closed pane leaves its prompt + tool snapshot pinned in - // memory for the life of the main process. - lastPromptByPaneKey.delete(paneKey) - lastToolByPaneKey.delete(paneKey) - lastStatusByPaneKey.delete(paneKey) + clearPaneCacheState(this.state, paneKey) } buildPtyEnv(): Record { @@ -1377,12 +258,6 @@ export class AgentHookServer { return {} } - // Why: ORCA_AGENT_HOOK_ENDPOINT is the key that lets a surviving PTY reach - // the *current* Orca after a restart. The other four variables are retained - // for back-compat so pre-endpoint-file hook scripts (which do not know to - // source the endpoint file) continue to work on freshly spawned PTYs, and - // so the current script can fall through to env if the file is - // missing/unreadable for any reason. const env: Record = { ORCA_AGENT_HOOK_PORT: String(this.port), ORCA_AGENT_HOOK_TOKEN: this.token, @@ -1395,154 +270,49 @@ export class AgentHookServer { return env } - // Why: exposed as a read-only getter so tests (and any future main-process - // caller that needs the path for diagnostics) do not have to reconstruct - // the path convention. get endpointFilePath(): string | null { return this.endpointFilePathCache } - // Why: writes the four coordinates atomically via a tmp-then-rename so a - // hook reading concurrently either sees the old file or the new one, never - // a half-written one. Fail-open: on EACCES / ENOSPC / etc. we log and move - // on — start() remains usable via PTY env for freshly-spawned PTYs. Only - // survivors lose the endpoint-file path, matching the hook-payload - // fail-open policy already enforced on the receiving end. - private writeEndpointFile(): void { + private maybeWriteEndpointFile(): void { if (!this.endpointDir || !this.endpointFilePathCache) { return } - // Why: defensive reset — buildPtyEnv() must not see a stale `true` from - // a previous start() if this write fails before reaching the success - // assignment below. this.endpointFileWritten = false - const finalPath = this.endpointFilePathCache - // Why: unique-per-call tmp name (mirrors persistence.ts / installer-utils.ts); prevents cross-process collision if two writers race on the same endpoint dir. - const tmpPath = join(this.endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) - const prefix = process.platform === 'win32' ? 'set ' : '' - // Why: every value written here is sourced as shell (`. "$file"` on - // POSIX, `call "%file%"` on Windows) — the file format IS shell, not - // key=value data. The current four inputs are shell-safe by - // construction: PORT is a number from listen(), TOKEN is randomUUID() - // output (hex + dashes only), VERSION is a compile-time string - // constant, and ENV is a fixed 'production' / 'development' literal - // passed from index.ts. Any future change that relaxes these - // invariants (user-supplied env name, persisted token, arbitrary - // free-form field) MUST add escaping or a safe-character validator - // before the write — otherwise a value like `foo&malicious` on Windows - // would command-inject via `call`, and a newline in any value would - // corrupt the POSIX sourceable output. The isShellSafeEndpointValue - // check below enforces this contract at runtime. - const valuesToWrite: [string, string][] = [ - ['ORCA_AGENT_HOOK_PORT', String(this.port)], - ['ORCA_AGENT_HOOK_TOKEN', this.token], - ['ORCA_AGENT_HOOK_ENV', this.env], - ['ORCA_AGENT_HOOK_VERSION', ORCA_HOOK_PROTOCOL_VERSION] - ] - for (const [key, value] of valuesToWrite) { - if (!isShellSafeEndpointValue(value)) { - console.error( - `[agent-hooks] refusing to write endpoint file: ${key} contains ` + - 'characters unsafe for shell sourcing. Falling back to PTY env.' - ) - return - } - } - const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] - let tmpWritten = false - try { - // Why: mode 0o700 — match the file's owner-only policy so the - // agent-hooks/ directory itself does not leak the existence of this - // Orca install (or the presence of the endpoint file) to other local - // users on a multi-user POSIX host. Default umask would otherwise - // leave the dir at 0o755 even though the file inside is 0o600. - mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - // Why: mkdirSync's `mode` only applies when the dir is newly created — - // a pre-existing agent-hooks/ dir (from an earlier build or user - // intervention) keeps its original permissions. Re-chmod on every - // start() so the directory matches the 0600 file inside it. POSIX - // only; chmod semantics differ on Windows and the filesystem-level - // ACL model makes this check meaningless there. - try { - chmodSync(this.endpointDir, 0o700) - } catch { - // Why: best-effort — a chmod failure (exotic fs, read-only mount) - // must not block the endpoint-file write itself. - } - } - // Why: a crash between writeFileSync and renameSync leaves stale - // `.endpoint--.tmp` in this directory. Sweep older-than-5-min - // orphans so the dir does not grow unboundedly. Fresh tmps are left - // alone so a legitimate concurrent instance is not disturbed. - try { - const entries = readdirSync(this.endpointDir) - const cutoff = Date.now() - 5 * 60 * 1000 - for (const entry of entries) { - if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { - continue - } - const entryPath = join(this.endpointDir, entry) - try { - if (statSync(entryPath).mtimeMs < cutoff) { - unlinkSync(entryPath) - } - } catch { - // best-effort sweep - } - } - } catch { - // readdirSync can fail on exotic filesystems; never block the write - } - // Why: 0o600 — the token is a loopback bearer credential and must not - // be readable by other local users. Parity with PTY env exposure via - // /proc//environ (owner-only on modern Linux). - // Why: `.cmd` files require CRLF for consistent `set` parsing across - // Windows versions — LF-only terminators are silently mis-parsed by - // some cmd.exe versions, which would break hook coord refresh (exactly - // the bug this file exists to fix). POSIX stays LF. - const separator = process.platform === 'win32' ? '\r\n' : '\n' - writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, finalPath) - this.endpointFileWritten = true - } catch (err) { - console.error('[agent-hooks] failed to write endpoint file:', err) - // Why: clean up tmp; never nuke the prior finalPath when we cannot - // guarantee we have replaced it. Stale finalPath → dead port → silent - // fail on hook POST matches the fail-open policy documented on the - // receiver side. Destroying the prior file would strand surviving PTYs - // that *could* have continued to fail silently against a dead port — - // strictly worse than leaving the prior coords in place until the next - // successful start() overwrites them. - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // Why: tmp may already be gone (rename partially succeeded, or an - // external process cleaned it). Nothing to do. - } - } - } + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + this.endpointFileWritten = ok } } export const agentHookServer = new AgentHookServer() -// Why: exported for test coverage of the per-agent field extractors. The -// `normalizeHookPayload` function wraps these with the cache + routing logic -// the tests need to exercise end-to-end; making it test-visible avoids -// having to spin up a real HTTP server just to assert field shaping. +// Why: exported for test coverage of the per-agent field extractors. export const _internals = { - normalizeHookPayload, + // Why: bind the test-helper to the singleton's state so existing tests keep + // exercising the same caches the live server uses. + normalizeHookPayload: ( + source: AgentHookSource, + body: unknown, + expectedEnv: string + ): AgentHookEventPayload | null => + normalizeHookPayload(_singletonState(), source, body, expectedEnv), parseFormEncodedBody, resetCachesForTests: (): void => { - lastPromptByPaneKey.clear() - lastToolByPaneKey.clear() - lastStatusByPaneKey.clear() - // Why: across test runs the warn-once Sets would otherwise suppress - // legitimate new warnings that a later test expects to observe. - warnedVersions.clear() - warnedEnvs.clear() + clearAllListenerCaches(_singletonState()) } } + +// Why: ergonomic accessor so the `_internals` shim can reach the singleton's +// per-instance state without exposing `state` on the public class surface. +function _singletonState(): HookListenerState { + // The runtime field is private, but tests access this module exclusively + // through `_internals`, which only fires after the module-level + // `agentHookServer` is constructed. The cast keeps the compile-time + // private invariant intact. + return (agentHookServer as unknown as { state: HookListenerState }).state +} diff --git a/src/main/index.ts b/src/main/index.ts index e9820c997e6..fa075da9d55 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -276,15 +276,16 @@ function openMainWindow(): BrowserWindow { } }) mainWindow = window - agentHookServer.setListener(({ paneKey, tabId, worktreeId, payload }) => { + agentHookServer.setListener(({ paneKey, tabId, worktreeId, connectionId, payload }) => { if (mainWindow?.isDestroyed()) { return } mainWindow?.webContents.send('agentStatus:set', { + ...payload, paneKey, tabId, worktreeId, - ...payload + connectionId }) // Why: cursor-agent's OSC title stays "Cursor Agent" for the whole turn, // and opencode's stays bare "OpenCode" — neither carries a working/idle diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3b330129099..80582f4c2e7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1236,6 +1236,11 @@ export type PreloadApi = { paneKey: string tabId?: string worktreeId?: string + // Why: stamped by main from the SshChannelMultiplexer the event + // arrived on (or null for local). The renderer uses it to drop + // in-flight events when an SSH connection tears down — see + // docs/design/agent-status-over-ssh.md §5. + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 45539461eac..0250700d737 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2303,6 +2303,11 @@ const api = { paneKey: string tabId?: string worktreeId?: string + // Why: stamped by main from the SshChannelMultiplexer the event + // arrived on (or null for local). The renderer uses it to drop + // in-flight events when an SSH connection tears down — see + // docs/design/agent-status-over-ssh.md §5. + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string @@ -2318,6 +2323,7 @@ const api = { paneKey: string tabId?: string worktreeId?: string + connectionId: string | null state: AgentStatusState prompt?: string agentType?: string diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts new file mode 100644 index 00000000000..700cb6426fa --- /dev/null +++ b/src/relay/agent-hook-server.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' + +describe('RelayAgentHookServer', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await 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: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } + }) + }) + expect(res.status).toBe(204) + expect(forward).toHaveBeenCalledTimes(1) + const envelope = forward.mock.calls[0][0] + expect(envelope.source).toBe('claude') + expect(envelope.paneKey).toBe('tab-1:0') + expect(envelope.tabId).toBe('tab-1') + expect(envelope.connectionId).toBeNull() + expect(envelope.payload.state).toBe('working') + expect(envelope.payload.prompt).toBe('hi') + // Why: the relay forwards body env/version verbatim so Orca's existing + // warn-once cross-build / dev-vs-prod diagnostics still fire on remote. + expect(envelope.env).toBe('remote') + expect(envelope.version).toBe('1') + } finally { + server.stop() + } + }) + + it('rejects requests with the wrong bearer token (403)', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': 'wrong' + }, + body: '{}' + }) + expect(res.status).toBe(403) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('replays cached payloads on demand', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await 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: 'tab-1:0', + tabId: 'tab-1', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' } + }) + }) + forward.mockClear() + const replayed = server.replayCachedPayloadsForPanes() + expect(replayed).toBe(1) + expect(forward).toHaveBeenCalledTimes(1) + expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me') + // Why: replay must preserve the wire envelope's env/version (and source) + // so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on + // replayed events the same as on live POST events. + expect(forward.mock.calls[0][0].source).toBe('claude') + expect(forward.mock.calls[0][0].env).toBe('remote') + expect(forward.mock.calls[0][0].version).toBe('1') + } finally { + server.stop() + } + }) + + it('does not replay paneKeys after clearPaneState', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await 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: 'tab-1:0', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' } + }) + }) + server.clearPaneState('tab-1:0') + forward.mockClear() + const replayed = server.replayCachedPayloadsForPanes() + expect(replayed).toBe(0) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + + it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => { + const forward = vi.fn() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const env = server.buildPtyEnv() + expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/) + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy() + expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote') + expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1') + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy() + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts new file mode 100644 index 00000000000..680e65fd01f --- /dev/null +++ b/src/relay/agent-hook-server.ts @@ -0,0 +1,266 @@ +// Why: relay-side adapter for the shared agent-hook listener pipeline. Hosts +// a loopback HTTP server (same shape as Orca's main-process server: bind +// 127.0.0.1:0, bearer-token auth, /hook/ routing) and forwards every +// parsed payload via a callback so `relay.ts` can re-emit it as an +// `agent.hook` JSON-RPC notification across the existing SSH channel. +// +// Per-instance state (warn-once Sets, last-status cache, last-prompt / +// last-tool caches) lives on `HookListenerState`. The cache is bounded to one +// entry per paneKey — see docs/design/agent-status-over-ssh.md §5 (Path 3, +// request-driven replay) for the rationale. +import { createServer, type IncomingMessage, type ServerResponse } from 'http' +import { randomUUID } from 'crypto' +import { join } from 'path' +import { homedir } from 'os' + +import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' +import { + clearAllListenerCaches, + clearPaneCacheState, + createHookListenerState, + getEndpointFileName, + HOOK_REQUEST_SLOWLORIS_MS, + normalizeHookPayload, + readRequestBody, + resolveHookSource, + writeEndpointFile, + type AgentHookEventPayload, + type HookListenerState +} from '../shared/agent-hook-listener' +import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' + +export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void + +// Why: relay's userData equivalent. Lives under $HOME so each user on a +// shared dev box gets their own dir, owned 0o700. Mirrors RELAY_REMOTE_DIR +// from `ssh-relay-deploy.ts` but stays local to this module — the hook +// server is the only consumer. +const RELAY_HOOKS_DIR_NAME = '.orca-relay' +const RELAY_HOOKS_SUBDIR = 'agent-hooks' + +function defaultEndpointDir(): string { + return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) +} + +export type RelayHookServerOptions = { + /** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */ + endpointDir?: string + /** Env tag forwarded into hook payloads (warn-once cross-build diagnostic). + * Defaults to "remote" — distinct from Orca's local 'production'/'development'. */ + env?: string + /** Called once per parsed payload. The relay wires this to + * `dispatcher.notify('agent.hook', envelope)`. */ + forward: RelayHookForward +} + +export class RelayAgentHookServer { + private server: ReturnType | null = null + private port = 0 + private token = '' + private env: string + private endpointDir: string + private endpointFilePath: string + private endpointFileWritten = false + private state: HookListenerState = createHookListenerState() + // Why: the shared `HookListenerState.lastStatusByPaneKey` cache only stores + // `AgentHookEventPayload` (no wire-envelope fields). Replay must still emit + // the original `source`/`env`/`version` so Orca's warn-once diagnostics fire + // identically to the live POST path. Keep this as a per-instance sidecar map + // so the shared listener type stays unchanged. Invariant: every key present + // in `state.lastStatusByPaneKey` must also be present here — populated and + // cleared in lockstep on the live POST path, clearPaneState, and stop(). + private lastEnvelopeMetaByPaneKey: Map< + string, + { source: AgentHookSource; env?: string; version?: string } + > = new Map() + private forward: RelayHookForward + + constructor(options: RelayHookServerOptions) { + this.env = options.env ?? 'remote' + this.endpointDir = options.endpointDir ?? defaultEndpointDir() + this.endpointFilePath = join(this.endpointDir, getEndpointFileName()) + this.forward = options.forward + } + + async start(): Promise { + if (this.server) { + return + } + this.token = randomUUID() + this.endpointFileWritten = false + this.server = createServer((req, res) => this.handleRequest(req, res)) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + process.stderr.write(`[relay-hook-server] server error: ${err.message}\n`) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.endpointFileWritten = writeEndpointFile(this.endpointDir, this.endpointFilePath, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION + }) + resolve() + } + this.server!.once('error', onStartupError) + // Why: bind 127.0.0.1:0 so the OS assigns a free port. Loopback only — + // the agent CLI inside the same remote box reaches us via curl + // 127.0.0.1:PORT; nobody outside the box can. + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.endpointFileWritten = false + clearAllListenerCaches(this.state) + this.lastEnvelopeMetaByPaneKey.clear() + } + + /** Request-driven replay: walks the per-paneKey last-payload cache and + * forwards each entry as a fresh notification. Called after Orca has + * re-wired its `agent.hook` handler on the new mux post-`--connect`. + * The relay-driver issues the replay forwards BEFORE returning from the + * request handler so the response strictly trails all replayed + * notifications on the dispatcher's single write callback. */ + replayCachedPayloadsForPanes(): number { + let count = 0 + for (const [paneKey, event] of this.state.lastStatusByPaneKey.entries()) { + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + // Why: invariant — every paneKey in the shared status cache is populated + // in lockstep with `lastEnvelopeMetaByPaneKey`. If meta is missing, + // something has drifted; skip rather than fall back to a guessed source + // that would mis-tag the event downstream. + if (!meta) { + continue + } + this.forwardEvent(event, meta.source, meta.env, meta.version) + count++ + } + return count + } + + /** Drop a paneKey's cached entries on PTY exit so a terminated pane never + * resurfaces as a ghost event on a later reconnect. Symmetric with the + * local server's clearPaneState on PTY teardown. */ + clearPaneState(paneKey: string): void { + clearPaneCacheState(this.state, paneKey) + this.lastEnvelopeMetaByPaneKey.delete(paneKey) + } + + /** Env vars to inject into every relay-spawned PTY so the hook script / + * in-process plugin POSTs to this loopback server. */ + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION + } + if (this.endpointFileWritten) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePath + } + return env + } + + /** Test-only / diagnostics accessor. */ + getCoordinates(): { port: number; token: string; endpointFilePath: string } { + return { port: this.port, token: this.token, endpointFilePath: this.endpointFilePath } + } + + // ─── Private ────────────────────────────────────────────────────── + + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + req.destroy() + }) + try { + const body = await readRequestBody(req) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (event) { + this.state.lastStatusByPaneKey.set(event.paneKey, event) + // TODO: once normalizeHookPayload returns validated env/version, drop + // bodyEnv/bodyVersion and source those from the listener result instead. + const env = this.bodyEnv(body) + const version = this.bodyVersion(body) + this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) + this.forwardEvent(event, source, env, version) + } + res.writeHead(204) + res.end() + } catch { + // Why: agent hooks must fail open — return success on parse / size / + // timeout errors so a buggy agent script never blocks the agent run. + res.writeHead(204) + res.end() + } + } + + private forwardEvent( + event: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string + ): void { + const envelope: AgentHookRelayEnvelope = { + source, + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + connectionId: null, + env, + version, + payload: event.payload + } + this.forward(envelope) + } + + private bodyEnv(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined + } + const v = (body as Record).env + return typeof v === 'string' && v.length > 0 ? v : undefined + } + + private bodyVersion(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) { + return undefined + } + const v = (body as Record).version + return typeof v === 'string' && v.length > 0 ? v : undefined + } +} + diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts new file mode 100644 index 00000000000..e0fa65dc713 --- /dev/null +++ b/src/shared/agent-hook-listener.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + createHookListenerState, + getEndpointFileName, + isShellSafeEndpointValue, + normalizeHookPayload, + parseFormEncodedBody, + resolveHookSource, + writeEndpointFile, + type HookListenerState +} from './agent-hook-listener' + +describe('shared agent-hook-listener', () => { + let state: HookListenerState + + beforeEach(() => { + state = createHookListenerState() + }) + + it('parses form-encoded bodies', () => { + const decoded = parseFormEncodedBody('paneKey=tab-1%3A0&worktreeId=foo') + expect(decoded.paneKey).toBe('tab-1:0') + expect(decoded.worktreeId).toBe('foo') + }) + + it('routes pathnames to a known source or null', () => { + expect(resolveHookSource('/hook/claude')).toBe('claude') + expect(resolveHookSource('/hook/cursor')).toBe('cursor') + expect(resolveHookSource('/hook/unknown')).toBeNull() + expect(resolveHookSource('/')).toBeNull() + }) + + it('rejects shell-unsafe endpoint values', () => { + expect(isShellSafeEndpointValue('1234')).toBe(true) + expect(isShellSafeEndpointValue('abc-DEF.0_1')).toBe(true) + expect(isShellSafeEndpointValue('')).toBe(false) + expect(isShellSafeEndpointValue('foo&bar')).toBe(false) + expect(isShellSafeEndpointValue('foo bar')).toBe(false) + expect(isShellSafeEndpointValue('foo;bar')).toBe(false) + }) + + it('normalizes a Claude UserPromptSubmit body to a working state', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hello' } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.paneKey).toBe('tab-1:0') + expect(event!.connectionId).toBeNull() + expect(event!.payload.state).toBe('working') + expect(event!.payload.prompt).toBe('hello') + expect(event!.payload.agentType).toBe('claude') + }) + + it('trims surrounding whitespace from extracted prompt text', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: 'tab-1:0', + payload: { hook_event_name: 'UserPromptSubmit', prompt: ' hi ' } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe('hi') + }) + + it('rejects oversized paneKey', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: 'x'.repeat(300), + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' } + }, + 'production' + ) + expect(event).toBeNull() + }) + + it('isolates caches between listener instances', () => { + const a = createHookListenerState() + const b = createHookListenerState() + normalizeHookPayload( + a, + 'claude', + { paneKey: 'p', payload: { hook_event_name: 'UserPromptSubmit', prompt: 'first' } }, + 'production' + ) + // The second listener has no cached prompt for this paneKey, so a tool + // event without a fresh prompt should produce empty prompt string. + const event = normalizeHookPayload( + b, + 'claude', + { + paneKey: 'p', + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'Read', + tool_input: { file_path: '/etc/hosts' } + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe('') + }) + + describe('writeEndpointFile', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'agent-hook-listener-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes the endpoint file atomically with the right contents and mode', () => { + const finalPath = join(dir, getEndpointFileName()) + const ok = writeEndpointFile(dir, finalPath, { + port: 12345, + token: 'abcdef-0123', + env: 'production', + version: '1' + }) + expect(ok).toBe(true) + const text = readFileSync(finalPath, 'utf8') + expect(text).toContain('ORCA_AGENT_HOOK_PORT=12345') + expect(text).toContain('ORCA_AGENT_HOOK_TOKEN=abcdef-0123') + expect(text).toContain('ORCA_AGENT_HOOK_VERSION=1') + // POSIX 0o600 — owner read/write only. + if (process.platform !== 'win32') { + const mode = statSync(finalPath).mode & 0o777 + expect(mode).toBe(0o600) + } + }) + + it('refuses unsafe values', () => { + const finalPath = join(dir, getEndpointFileName()) + const ok = writeEndpointFile(dir, finalPath, { + port: 12345, + token: 'safe-token', + env: 'foo&bar', + version: '1' + }) + expect(ok).toBe(false) + }) + }) +}) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts new file mode 100644 index 00000000000..6193ce76102 --- /dev/null +++ b/src/shared/agent-hook-listener.ts @@ -0,0 +1,1155 @@ +/* eslint-disable max-lines -- Why: this module is the canonical, transport- + agnostic agent-hook listener. The HTTP request parser, payload normalizer, + per-CLI extractors, and on-disk endpoint-file writer all share invariants + (size caps, warn-once Sets, shell-safe value rules) that must not drift + between Orca's main process and the relay. Splitting by line count would + force the same invariants to be re-derived in two places. */ + +// Why: extracted from `src/main/agent-hooks/server.ts` so the relay can host +// the same listener pipeline on the remote without dragging Electron in. The +// module uses only Node builtins (http/fs/crypto/net/path/url/os) — none of +// which pull `electron` — so it is safe to import from `src/relay/`. See +// docs/design/agent-status-over-ssh.md §3 ("relay normalizes; Orca routes"). +import type { IncomingMessage } from 'http' +import { randomUUID } from 'crypto' +import { + chmodSync, + closeSync, + mkdirSync, + openSync, + readdirSync, + readSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'fs' +import { join } from 'path' + +import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' +import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' +import type { AgentHookSource } from './agent-hook-relay' + +/** Maximum request body size accepted by the listener (1 MB). */ +export const HOOK_REQUEST_MAX_BYTES = 1_000_000 + +/** Bound the warn-once Sets so a buggy/malicious local client that varies its + * `version` / `env` fields per request cannot grow them without bound for the + * process lifetime. */ +const MAX_WARNED_KEYS = 32 + +/** Slowloris cap: drop requests that have not finished sending after 5 s. */ +export const HOOK_REQUEST_SLOWLORIS_MS = 5_000 + +/** Bound paneKey size — `${tabId}:${paneId}` is well under 200 chars in + * practice; cap defends per-pane caches against pathological input. */ +export const MAX_PANE_KEY_LEN = 200 + +/** Per-listener-instance state that holds caches needing per-PTY teardown + * (last prompt, last tool snapshot, last status replay). Both Orca's main + * process and the relay get their own instance — they never share. */ +export type HookListenerState = { + warnedVersions: Set + warnedEnvs: Set + lastPromptByPaneKey: Map + lastToolByPaneKey: Map + lastStatusByPaneKey: Map +} + +export function createHookListenerState(): HookListenerState { + return { + warnedVersions: new Set(), + warnedEnvs: new Set(), + lastPromptByPaneKey: new Map(), + lastToolByPaneKey: new Map(), + lastStatusByPaneKey: new Map() + } +} + +export function clearPaneCacheState(state: HookListenerState, paneKey: string): void { + state.lastPromptByPaneKey.delete(paneKey) + state.lastToolByPaneKey.delete(paneKey) + state.lastStatusByPaneKey.delete(paneKey) +} + +export function clearAllListenerCaches(state: HookListenerState): void { + state.lastPromptByPaneKey.clear() + state.lastToolByPaneKey.clear() + state.lastStatusByPaneKey.clear() + state.warnedVersions.clear() + state.warnedEnvs.clear() +} + +export type AgentHookEventPayload = { + paneKey: string + tabId?: string + worktreeId?: string + /** Identifies the SSH connection the event arrived on, or null for local. + * Stamped only on the remote-ingest path (Orca's `ingestRemote`); the + * HTTP path always sets null because it cannot know which mux a request + * came from. See docs/design/agent-status-over-ssh.md §5. */ + connectionId: string | null + payload: ParsedAgentStatusPayload +} + +// ─── Body parsing ─────────────────────────────────────────────────── + +export function parseFormEncodedBody(body: string): Record { + const params = new URLSearchParams(body) + const parsed: Record = {} + for (const [key, value] of params.entries()) { + parsed[key] = value + } + return parsed +} + +export function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let byteLength = 0 + let settled = false + req.on('data', (chunk: Buffer) => { + if (settled) { + return + } + // Why: check size in bytes (not UTF-16 code units) and stop accumulating + // after rejection so a malicious client cannot push memory past the cap. + if (byteLength + chunk.length > HOOK_REQUEST_MAX_BYTES) { + settled = true + reject(new Error('payload too large')) + req.destroy() + return + } + byteLength += chunk.length + chunks.push(chunk) + }) + req.on('end', () => { + if (settled) { + return + } + settled = true + try { + // Why: decode once via Buffer.concat so multi-byte UTF-8 characters + // that straddle a chunk boundary are reassembled correctly. + const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf8') : '' + const contentType = req.headers['content-type'] ?? '' + if (typeof contentType === 'string' && contentType.includes('application/json')) { + resolve(body ? JSON.parse(body) : {}) + return + } + if ( + typeof contentType === 'string' && + contentType.includes('application/x-www-form-urlencoded') + ) { + resolve(parseFormEncodedBody(body)) + return + } + // Why: existing managed scripts POST JSON; updated POSIX scripts POST + // form-encoded. Default to JSON for unknown content types. + resolve(body ? JSON.parse(body) : {}) + } catch (error) { + reject(error) + } + }) + req.on('error', (err) => { + if (settled) { + return + } + settled = true + reject(err) + }) + // Why: req.destroy() (called by the slowloris timer) emits 'close' but + // not 'end'/'error'. Without this handler the promise would never settle + // and the chunk buffers would be retained for the process lifetime. + req.on('close', () => { + if (settled) { + return + } + settled = true + reject(new Error('aborted')) + }) + }) +} + +// ─── Per-pane field caches + extractors ───────────────────────────── + +function extractPromptText(hookPayload: Record): string { + const candidateKeys = ['prompt', 'user_prompt', 'userPrompt', 'message'] + for (const key of candidateKeys) { + const value = hookPayload[key] + if (typeof value === 'string' && value.trim().length > 0) { + // Why: trim so prompts match what readStringField produces elsewhere — + // surrounding whitespace would otherwise leak into UI and caches. + return value.trim() + } + } + // Why: OpenCode's plugin sends MessagePart events with { role, text }. When + // role === 'user', the text *is* the prompt — surface it even though + // OpenCode has no UserPromptSubmit-equivalent. + if (hookPayload.role === 'user' && typeof hookPayload.text === 'string') { + const trimmed = hookPayload.text.trim() + if (trimmed.length > 0) { + return trimmed + } + } + return '' +} + +function resolvePrompt( + state: HookListenerState, + paneKey: string, + promptText: string, + options?: { resetOnNewTurn?: boolean } +): string { + if (options?.resetOnNewTurn) { + state.lastPromptByPaneKey.delete(paneKey) + } + if (promptText) { + state.lastPromptByPaneKey.set(paneKey, promptText) + return promptText + } + return state.lastPromptByPaneKey.get(paneKey) ?? '' +} + +export type ToolSnapshot = { + toolName?: string + toolInput?: string + lastAssistantMessage?: string +} + +function resolveToolState( + state: HookListenerState, + paneKey: string, + update: ToolSnapshot, + options: { resetOnNewTurn: boolean } +): ToolSnapshot { + if (options.resetOnNewTurn) { + state.lastToolByPaneKey.delete(paneKey) + } + const previous = state.lastToolByPaneKey.get(paneKey) ?? {} + const merged: ToolSnapshot = { + toolName: update.toolName ?? previous.toolName, + toolInput: update.toolInput ?? previous.toolInput, + lastAssistantMessage: update.lastAssistantMessage ?? previous.lastAssistantMessage + } + state.lastToolByPaneKey.set(paneKey, merged) + return merged +} + +const TOOL_INPUT_KEYS_BY_TOOL: Record = { + Read: ['file_path', 'filePath', 'path'], + Write: ['file_path', 'filePath', 'path'], + Edit: ['file_path', 'filePath', 'path'], + MultiEdit: ['file_path', 'filePath', 'path'], + NotebookEdit: ['file_path', 'filePath', 'path'], + Bash: ['command'], + Glob: ['pattern'], + Grep: ['pattern'], + WebFetch: ['url'], + WebSearch: ['query'], + read_file: ['file_path', 'path'], + write_file: ['file_path', 'path'], + read_many_files: ['file_path', 'paths', 'path'], + edit_file: ['file_path', 'path'], + replace: ['file_path', 'path'], + run_shell_command: ['command'], + glob: ['pattern'], + search_file_content: ['pattern'], + web_fetch: ['url'], + google_web_search: ['query'], + exec_command: ['cmd', 'command'], + shell_command: ['cmd', 'command'], + apply_patch: ['path', 'file_path'], + view_image: ['path', 'file_path'], + bash: ['command'], + read: ['path', 'file_path'], + write: ['path', 'file_path'], + edit: ['path', 'file_path'], + grep: ['pattern'], + web_search: ['query'], + fetch_content: ['url'] +} + +function deriveToolInputPreview( + toolName: string | undefined, + toolInput: unknown +): string | undefined { + if (typeof toolInput === 'string') { + return toolInput + } + if (typeof toolInput !== 'object' || toolInput === null) { + return undefined + } + if (!toolName) { + return undefined + } + const keys = TOOL_INPUT_KEYS_BY_TOOL[toolName] + if (!keys) { + return undefined + } + const record = toolInput as Record + for (const key of keys) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + } + return undefined +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function extractToolResponseText(toolResponse: unknown): string | undefined { + if (typeof toolResponse === 'string' && toolResponse.length > 0) { + return toolResponse + } + if (typeof toolResponse !== 'object' || toolResponse === null) { + return undefined + } + const record = toolResponse as Record + const content = record.content + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + const text = record.text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + return undefined +} + +const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 +const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 + +function extractAssistantTextFromLine(line: string): string | undefined { + let entry: unknown + try { + entry = JSON.parse(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null) { + return undefined + } + const record = entry as Record + const nestedMessage = record.message as Record | undefined + const role = record.role ?? nestedMessage?.role + if (role !== 'assistant') { + return undefined + } + const content = (nestedMessage ?? record).content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = (part as Record).text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} + +function readLastAssistantFromTranscript(transcriptPath: unknown): string | undefined { + if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { + return undefined + } + try { + const stats = statSync(transcriptPath) + const size = stats.size + if (size <= 0) { + return undefined + } + const fd = openSync(transcriptPath, 'r') + try { + let carryBytes: Buffer = Buffer.alloc(0) + let bytesRead = 0 + while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) + const position = size - bytesRead - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + const n = filled + bytesRead += n + if (n === 0) { + break + } + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + const atStart = bytesRead >= size + const firstNewline = combined.indexOf(0x0a) + let completeRegion: Buffer + let nextCarry: Buffer + if (atStart) { + completeRegion = combined + nextCarry = Buffer.alloc(0) + } else if (firstNewline === -1) { + completeRegion = Buffer.alloc(0) + nextCarry = combined + } else { + nextCarry = combined.subarray(0, firstNewline) + completeRegion = combined.subarray(firstNewline + 1) + } + if (completeRegion.length > 0) { + const lines = completeRegion.toString('utf8').split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (line.length === 0) { + continue + } + const extracted = extractAssistantTextFromLine(line) + if (extracted !== undefined) { + return extracted + } + } + } + carryBytes = nextCarry + } + return undefined + } finally { + closeSync(fd) + } + } catch { + return undefined + } +} + +function extractClaudeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + const update: ToolSnapshot = {} + if ( + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ) { + const toolName = readString(hookPayload, 'tool_name') + update.toolName = toolName + update.toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + } + if (eventName === 'PostToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_response) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'PostToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_response) ?? + readString(hookPayload, 'error') ?? + readString(hookPayload, 'message') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + if (eventName === 'Stop') { + const direct = readString(hookPayload, 'last_assistant_message') + if (direct) { + update.lastAssistantMessage = direct + } else { + const lastFromTranscript = readLastAssistantFromTranscript(hookPayload.transcript_path) + if (lastFromTranscript) { + update.lastAssistantMessage = lastFromTranscript + } + } + } + return update +} + +function extractCodexToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse') { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.input) ?? + deriveToolInputPreview(toolName, hookPayload.arguments) + return { toolName, toolInput } + } + if (eventName === 'Stop') { + const message = readString(hookPayload, 'last_assistant_message') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} + +function extractGeminiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'PreToolUse' || eventName === 'PostToolUse' || eventName === 'AfterTool') { + const toolName = readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + deriveToolInputPreview(toolName, hookPayload.args) ?? + deriveToolInputPreview(toolName, hookPayload.input) + return { toolName, toolInput } + } + if (eventName === 'AfterAgent') { + const message = readString(hookPayload, 'prompt_response') + if (message) { + return { lastAssistantMessage: message } + } + } + return {} +} + +function extractOpenCodeToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if (eventName === 'MessagePart' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function extractCursorToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' + ) { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + const update: ToolSnapshot = { toolName, toolInput } + if (eventName === 'postToolUse') { + const responseText = extractToolResponseText(hookPayload.tool_output) + if (responseText) { + update.lastAssistantMessage = responseText + } + } + if (eventName === 'postToolUseFailure') { + const errorText = + extractToolResponseText(hookPayload.tool_output) ?? + readString(hookPayload, 'error_message') ?? + readString(hookPayload, 'error') + if (errorText) { + update.lastAssistantMessage = errorText + } + } + return update + } + if (eventName === 'beforeShellExecution') { + const command = readString(hookPayload, 'command') + return { toolName: 'Shell', toolInput: command } + } + if (eventName === 'beforeMCPExecution') { + const toolName = readString(hookPayload, 'tool_name') ?? 'MCP' + const toolInput = + deriveToolInputPreview(toolName, hookPayload.tool_input) ?? + readString(hookPayload, 'command') ?? + readString(hookPayload, 'url') + return { toolName, toolInput } + } + if (eventName === 'afterAgentResponse') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function extractPiToolFields( + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + if ( + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' + ) { + const toolName = readString(hookPayload, 'tool_name') + const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + return { toolName, toolInput } + } + if (eventName === 'message_end' && hookPayload.role === 'assistant') { + const text = readString(hookPayload, 'text') + if (text) { + return { lastAssistantMessage: text } + } + } + return {} +} + +function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { + // Why: exhaustive switch so adding a 7th source to AgentHookSource fails + // typecheck here instead of silently falling through to `false`. + switch (source) { + case 'claude': + return eventName === 'UserPromptSubmit' + case 'codex': + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' + case 'gemini': + return eventName === 'BeforeAgent' + case 'opencode': + return false + case 'cursor': + return eventName === 'beforeSubmitPrompt' || eventName === 'sessionStart' + case 'pi': + return eventName === 'before_agent_start' + default: { + const _exhaustive: never = source + void _exhaustive + return false + } + } +} + +function extractToolFields( + source: AgentHookSource, + eventName: unknown, + hookPayload: Record +): ToolSnapshot { + // Why: exhaustive switch so adding a 7th source to AgentHookSource fails + // typecheck here instead of silently routing through OpenCode's extractor. + switch (source) { + case 'claude': + return extractClaudeToolFields(eventName, hookPayload) + case 'codex': + return extractCodexToolFields(eventName, hookPayload) + case 'gemini': + return extractGeminiToolFields(eventName, hookPayload) + case 'opencode': + return extractOpenCodeToolFields(eventName, hookPayload) + case 'cursor': + return extractCursorToolFields(eventName, hookPayload) + case 'pi': + return extractPiToolFields(eventName, hookPayload) + default: { + const _exhaustive: never = source + void _exhaustive + return {} + } + } +} + +function normalizeClaudeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('claude', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('claude', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('claude', eventName) + }), + agentType: 'claude', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + +function normalizeGeminiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'BeforeAgent' || + eventName === 'AfterTool' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'AfterAgent' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('gemini', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('gemini', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('gemini', eventName) + }), + agentType: 'gemini', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function normalizeCodexEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ? 'working' + : eventName === 'Stop' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('codex', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('codex', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }), + agentType: 'codex', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function normalizeOpenCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'SessionBusy' || eventName === 'MessagePart' + ? 'working' + : eventName === 'SessionIdle' + ? 'done' + : eventName === 'PermissionRequest' || eventName === 'AskUserQuestion' + ? 'waiting' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('opencode', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('opencode', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('opencode', eventName) + }), + agentType: 'opencode', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function normalizeCursorEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'beforeSubmitPrompt' || + eventName === 'sessionStart' || + eventName === 'preToolUse' || + eventName === 'postToolUse' || + eventName === 'postToolUseFailure' || + eventName === 'afterAgentResponse' + ? 'working' + : eventName === 'stop' || eventName === 'sessionEnd' + ? 'done' + : eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution' + ? 'waiting' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('cursor', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('cursor', eventName) } + ) + + const interrupted = + eventName === 'stop' && + typeof hookPayload.status === 'string' && + hookPayload.status !== 'completed' + ? true + : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('cursor', eventName) + }), + agentType: 'cursor', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + +function normalizePiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'before_agent_start' || + eventName === 'agent_start' || + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' || + eventName === 'message_end' + ? 'working' + : eventName === 'agent_end' || eventName === 'session_shutdown' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('pi', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('pi', eventName) } + ) + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('pi', eventName) + }), + agentType: 'pi', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage + }) + ) +} + +function readStringField(record: Record, key: string): string | undefined { + const value = record[key] + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +export function normalizeHookPayload( + state: HookListenerState, + source: AgentHookSource, + body: unknown, + expectedEnv: string +): AgentHookEventPayload | null { + if (typeof body !== 'object' || body === null) { + return null + } + + const record = body as Record + const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const rawPayload = record.payload + const hookPayload = + typeof rawPayload === 'string' + ? (() => { + try { + return JSON.parse(rawPayload) + } catch { + return null + } + })() + : rawPayload + if ( + !paneKey || + paneKey.length > MAX_PANE_KEY_LEN || + typeof hookPayload !== 'object' || + hookPayload === null + ) { + return null + } + + const version = readStringField(record, 'version') + if ( + version && + version !== ORCA_HOOK_PROTOCOL_VERSION && + !state.warnedVersions.has(version) && + state.warnedVersions.size < MAX_WARNED_KEYS + ) { + state.warnedVersions.add(version) + console.warn( + `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + + 'Reinstall agent hooks from Settings to upgrade the managed script.' + ) + } + + const clientEnv = readStringField(record, 'env') + if (clientEnv && clientEnv !== expectedEnv) { + const key = `${clientEnv}->${expectedEnv}` + if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { + state.warnedEnvs.add(key) + console.warn( + `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + + 'Likely a stale terminal from another Orca install.' + ) + } + } + + const tabId = readStringField(record, 'tabId') + const worktreeId = readStringField(record, 'worktreeId') + + const eventName = (hookPayload as Record).hook_event_name + const promptText = extractPromptText(hookPayload as Record) + const hookPayloadRecord = hookPayload as Record + // Why: exhaustive switch so adding a 7th source to AgentHookSource fails + // typecheck here instead of silently routing through OpenCode's normalizer. + let payload: ParsedAgentStatusPayload | null + switch (source) { + case 'claude': + payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'codex': + payload = normalizeCodexEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'gemini': + payload = normalizeGeminiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'opencode': + payload = normalizeOpenCodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'cursor': + payload = normalizeCursorEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + case 'pi': + payload = normalizePiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break + default: { + const _exhaustive: never = source + void _exhaustive + payload = null + } + } + + // Why: connectionId stays null at the listener layer. The local server keeps + // it null; the relay forwards null on the wire and Orca's `ingestRemote` + // stamps the real value from `mux` identity on receive. See + // docs/design/agent-status-over-ssh.md §5. + return payload ? { paneKey, tabId, worktreeId, connectionId: null, payload } : null +} + +// ─── URL routing ──────────────────────────────────────────────────── + +export const HOOK_SOURCE_BY_PATHNAME: Readonly> = Object.freeze({ + '/hook/claude': 'claude', + '/hook/codex': 'codex', + '/hook/gemini': 'gemini', + '/hook/opencode': 'opencode', + '/hook/cursor': 'cursor', + '/hook/pi': 'pi' +}) + +export function resolveHookSource(pathname: string): AgentHookSource | null { + return HOOK_SOURCE_BY_PATHNAME[pathname] ?? null +} + +// ─── Endpoint-file writing ────────────────────────────────────────── + +export function getEndpointFileName(): string { + // Why: per-platform extension lets hook scripts source the file natively + // (`. "$file"` POSIX, `call "%file%"` Windows). The OpenCode plugin's regex + // accepts both shapes already. + return process.platform === 'win32' ? 'endpoint.cmd' : 'endpoint.env' +} + +export function isShellSafeEndpointValue(value: string): boolean { + // Why: every value in the endpoint file is sourced as shell. The `+` + // quantifier rejects empty strings as defense-in-depth — a sourced empty + // `KEY=` would clear the env var in the sourcing shell. + return /^[A-Za-z0-9._:/-]+$/.test(value) +} + +export type EndpointFileFields = { + port: number + token: string + env: string + version: string +} + +/** Atomically write the endpoint file at `endpointDir/`. + * Returns true on success, false on any error (caller may fall back to PTY + * env). Mirrors `AgentHookServer.writeEndpointFile` and is shared verbatim by + * the relay's adapter. */ +export function writeEndpointFile( + endpointDir: string, + finalPath: string, + fields: EndpointFileFields +): boolean { + const tmpPath = join(endpointDir, `.endpoint-${process.pid}-${randomUUID()}.tmp`) + const prefix = process.platform === 'win32' ? 'set ' : '' + const valuesToWrite: [string, string][] = [ + ['ORCA_AGENT_HOOK_PORT', String(fields.port)], + ['ORCA_AGENT_HOOK_TOKEN', fields.token], + ['ORCA_AGENT_HOOK_ENV', fields.env], + ['ORCA_AGENT_HOOK_VERSION', fields.version] + ] + for (const [key, value] of valuesToWrite) { + if (!isShellSafeEndpointValue(value)) { + console.error( + `[agent-hooks] refusing to write endpoint file: ${key} contains ` + + 'characters unsafe for shell sourcing. Falling back to PTY env.' + ) + return false + } + } + const lines = [...valuesToWrite.map(([key, value]) => `${prefix}${key}=${value}`), ''] + let tmpWritten = false + try { + // Why: 0o700 — match the file's owner-only policy so the directory does + // not leak the existence of this Orca/relay install to other local users. + mkdirSync(endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + // Why: mkdirSync's mode only applies on creation — a pre-existing + // directory keeps its original perms. POSIX-only chmod fix. + try { + chmodSync(endpointDir, 0o700) + } catch { + // best-effort + } + } + // Why: sweep stale `.endpoint-*.tmp` orphans older than 5 min so a crash + // between writeFileSync and renameSync cannot grow the dir unboundedly. + try { + const entries = readdirSync(endpointDir) + const cutoff = Date.now() - 5 * 60 * 1000 + for (const entry of entries) { + if (!entry.startsWith('.endpoint-') || !entry.endsWith('.tmp')) { + continue + } + const entryPath = join(endpointDir, entry) + try { + if (statSync(entryPath).mtimeMs < cutoff) { + unlinkSync(entryPath) + } + } catch { + // best-effort sweep + } + } + } catch { + // readdirSync can fail on exotic filesystems + } + const separator = process.platform === 'win32' ? '\r\n' : '\n' + writeFileSync(tmpPath, lines.join(separator), { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, finalPath) + return true + } catch (err) { + console.error('[agent-hooks] failed to write endpoint file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp may already be gone + } + } + return false + } +} diff --git a/src/shared/agent-hook-relay.test.ts b/src/shared/agent-hook-relay.test.ts new file mode 100644 index 00000000000..c89631caa66 --- /dev/null +++ b/src/shared/agent-hook-relay.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD, + ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, + isRemoteAgentHooksEnabled, + type AgentHookRelayEnvelope +} from './agent-hook-relay' + +describe('agent-hook-relay wire shape', () => { + it('encodes/decodes through JSON without losing fields', () => { + const envelope: AgentHookRelayEnvelope = { + source: 'claude', + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + env: 'production', + version: '1', + payload: { + state: 'working', + prompt: 'roundtrip', + agentType: 'claude' + } + } + + const decoded = JSON.parse(JSON.stringify(envelope)) as AgentHookRelayEnvelope + expect(decoded).toEqual(envelope) + expect(decoded.connectionId).toBeNull() + expect(decoded.payload.prompt).toBe('roundtrip') + }) + + it('exposes stable JSON-RPC method names', () => { + expect(AGENT_HOOK_NOTIFICATION_METHOD).toBe('agent.hook') + expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay') + expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins') + }) +}) + +describe('isRemoteAgentHooksEnabled', () => { + it('is off when the env var is absent', () => { + expect(isRemoteAgentHooksEnabled({})).toBe(false) + }) + + it('is off for empty / "0"', () => { + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '' })).toBe(false) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '0' })).toBe(false) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: ' ' })).toBe(false) + }) + + it('is on for any other non-empty value', () => { + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: '1' })).toBe(true) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'on' })).toBe(true) + expect(isRemoteAgentHooksEnabled({ [ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]: 'true' })).toBe(true) + }) +}) diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts new file mode 100644 index 00000000000..4735ac9289c --- /dev/null +++ b/src/shared/agent-hook-relay.ts @@ -0,0 +1,83 @@ +// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification +// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which +// produces it after the shared listener parses an HTTP POST) and by +// `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`). +// +// Lives in `shared/` because the relay deliberately has no Electron dependency +// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for +// the renderer-bound IPC + installer contract; this module is the wire envelope +// between Orca's main process and the remote relay. +// +// Per the design doc: +// - The relay normalizes; Orca routes. The envelope's `payload` field has +// already been through `normalizeHookPayload` on the relay side; Orca's +// ingestRemote re-runs the canonical normalizer at the trust boundary +// (defense-in-depth) before feeding the event into the same `onAgentStatus` +// fanout the local HTTP path uses. +// - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's +// local handle on an `ssh2` connection, not a wire identity. Orca stamps the +// real value on receive from `mux` identity inside `ingestRemote`. +// - The wire `version` and `env` fields are forwarded verbatim from the agent +// CLI's POST body so Orca's existing warn-once cross-build / dev-vs-prod +// diagnostics still fire on remote-sourced events. + +import type { ParsedAgentStatusPayload } from './agent-status-types' + +// Why: the local hook server knows the discriminator from URL pathname routing +// (`/hook/`); the relay equally must tag each forwarded notification +// with the same value so Orca can attribute the event back to the right CLI. +// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it +// without dragging Electron in (the shared listener module is the only place +// that consumes it from the relay side). +export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' + +/** Wire envelope for a single hook event flowing relay → Orca. */ +export type AgentHookRelayEnvelope = { + source: AgentHookSource + paneKey: string + tabId?: string + worktreeId?: string + /** Always `null` on the wire — relay does not know Orca's local connectionId. */ + connectionId: null + /** Forwarded verbatim from the agent CLI POST body (e.g. 'production', + * 'development'). Lets Orca's warn-once env-mismatch diagnostic fire on + * remote events the same as on local. */ + env?: string + /** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once + * protocol-version diagnostic fire on remote events the same as on local. */ + version?: string + /** Pre-normalized status payload from the relay's `normalizeHookPayload`. + * Orca's `ingestRemote` re-validates via `normalizeAgentStatusPayload` at + * the trust boundary as defense-in-depth. */ + payload: ParsedAgentStatusPayload +} + +/** JSON-RPC notification method name carried over the relay control channel. */ +export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const + +/** JSON-RPC request method Orca issues after `--connect` reattach to ask the + * relay to replay its per-paneKey last-payload cache. See §5 Path 3 of the + * design doc for the race that ruled out push-on-`setWrite`. */ +export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as const + +/** JSON-RPC request method Orca issues at session-ready to ship the + * OpenCode/Pi plugin source files to the relay so it can materialize the + * per-PTY overlay dirs on the remote. */ +export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const + +/** Feature-flag env var. Read once at process start by Orca and the relay. + * Absent / empty / "0" = off; anything else = on. See §8 of the design doc + * for the gate locations. */ +export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const + +export function isRemoteAgentHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] + if (raw === undefined) { + return false + } + const trimmed = raw.trim() + if (trimmed.length === 0 || trimmed === '0') { + return false + } + return true +}