feat(agent-hooks): shared listener + relay adapter (PR 1/N for SSH agent status) (#1678)

* feat(agent-hooks): introduce relay wire envelope + connectionId stamping

Adds the shared `agent-hook-relay.ts` module with the `agent.hook` JSON-RPC
notification envelope, the `agent_hook.requestReplay` /
`agent_hook.installPlugins` method names, and the
`ORCA_FEATURE_REMOTE_AGENT_HOOKS` flag helper. Promotes `AgentHookSource` to
`shared/` so the relay can import it without dragging Electron in.

Threads a `connectionId: string | null` field through `AgentHookEventPayload`,
the `agentStatus:set` IPC contract, and the renderer-bound preload listener.
Local hook posts stamp `null`; the relay-forwarded path will stamp from `mux`
identity in a later commit. Renderer uses the stamp for stale-event filtering
when an SSH connection tears down with notifications still in flight.

See docs/design/agent-status-over-ssh.md §1, §5, §8 (commit #1).

Co-authored-by: Orca <help@stably.ai>

* refactor(agent-hooks): extract shared listener; add relay-side adapter

Extracts the listener internals (request parsing, payload normalization,
endpoint-file writing, per-CLI extractors, warn-once Sets, slowloris timer
helper, request size cap, paneKey caches) from `src/main/agent-hooks/server.ts`
into a new transport-agnostic `src/shared/agent-hook-listener.ts`. The shared
module uses only Node builtins (no Electron) so it is safe to import from
`src/relay/`.

Adds `src/relay/agent-hook-server.ts` — a thin HTTP-loopback adapter that
wires the shared listener to a `forward(envelope)` callback so `relay.ts` can
re-emit each parsed payload as an `agent.hook` JSON-RPC notification on the
existing SshChannelMultiplexer. The adapter owns:

- 127.0.0.1:0 socket + bearer-token auth, identical shape to the local server
- per-paneKey last-payload cache + replayCachedPayloadsForPanes() for the
  request-driven replay path used after `--connect` reattach (see §5 Path 3)
- clearPaneState(paneKey) for PTY-exit eviction (symmetric with local server)
- buildPtyEnv() / endpoint-file writing for relay-spawned PTYs

Orca's `AgentHookServer` is now a ~200-LoC adapter over the shared listener
that owns the IPC fanout, listener replay, and `ingestRemote(envelope, connId)`
entry point that bypasses the HTTP path for relay-forwarded events.

See docs/design/agent-status-over-ssh.md §3, §8 (commit #2).

Co-authored-by: Orca <help@stably.ai>

* fix(preload): expose connectionId on agentStatus.onSet type

src/preload/index.ts already passes through `connectionId?: string | null`
from main, but the PreloadApi declaration in api-types.ts was missing the
field. Align the type with the runtime contract so renderer call sites
can read connectionId without an `as` cast.

Co-authored-by: Orca <help@stably.ai>

* fix(agent-hooks): harden ingestRemote + relay replay; review-driven cleanup

- ingestRemote: re-run normalizeAgentStatusPayload at trust boundary;
  trim+validate connectionId/paneKey/tabId/worktreeId
- relay: preserve source/env/version through replay via sidecar map;
  drop sourceFromAgentType fallback that mis-tagged unknown agents
- shared listener: exhaustive switch+never on AgentHookSource dispatch
  chains; extractPromptText returns trimmed values; export MAX_PANE_KEY_LEN
- preload: tighten connectionId from optional to required (always sent)
- main IPC: reorder spread so explicit envelope fields win on collision

Co-authored-by: Orca <help@stably.ai>

* chore(docs): drop agent-status-over-ssh design doc from PR

The design RFC was useful for authoring this PR series but doesn't belong
in-tree — keeping it here would freeze line-number references and design
prose against future churn. Folding it into the PR description instead.

Co-authored-by: Orca <help@stably.ai>

* chore(agent-hooks): widen ingestRemote type for env/version (PR2 prep)

Declares `env?: string` and `version?: string` on the `ingestRemote` envelope
parameter so PR2 only needs to add the `warnOnHookEnvOrVersionMismatch`
callsite, not also widen the type. The fields are forwarded verbatim from
the agent CLI POST body on the remote and let Orca's warn-once cross-build
/ dev-vs-prod diagnostics fire identically on remote-sourced events.

Type-only addition; no runtime consumer in this PR.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-10 21:57:48 -07:00
committed by GitHub
co-authored by Orca
parent 908bc18234
commit 1041ab4f7c
11 changed files with 2203 additions and 1386 deletions
+157
View File
@@ -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)
})
})
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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
+5
View File
@@ -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
+6
View File
@@ -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
+155
View File
@@ -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()
}
})
})
+266
View File
@@ -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/<source> 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<typeof createServer> | 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<void> {
if (this.server) {
return
}
this.token = randomUUID()
this.endpointFileWritten = false
this.server = createServer((req, res) => this.handleRequest(req, res))
await new Promise<void>((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<string, string> {
if (this.port <= 0 || !this.token) {
return {}
}
const env: Record<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
}
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<void> {
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<string, unknown>).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<string, unknown>).version
return typeof v === 'string' && v.length > 0 ? v : undefined
}
}
+162
View File
@@ -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)
})
})
})
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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)
})
})
+83
View File
@@ -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/<source>`); 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
}