Files
orca/src/relay/agent-hook-integration.test.ts
T
Neilandbbingz 14087c8e32 Accept repeated leading BOMs in agent hooks (#22414)
Adapted from the investigation and proposal by @bbingz.

Co-authored-by: bbingz <bbingz@users.noreply.github.com>
2026-09-25 20:50:15 -07:00

481 lines
16 KiB
TypeScript

/**
* End-to-end agent-status-over-SSH integration test.
*
* Wires Orca's main-side SshChannelMultiplexer to the relay-side
* RelayDispatcher through an in-memory pipe and starts a real
* RelayAgentHookServer. POSTs a hook event to the relay's loopback HTTP
* receiver and asserts the parsed payload arrives in `agentHookServer`'s
* onAgentStatus listener through the `agent.hook` JSON-RPC notification path.
*
* This is the test the design doc (§9 step 5/6) calls "the SSH provider gets
* at least one integration test that round-trips a hook event from a remote
* PTY back to AgentStatus state."
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
SshChannelMultiplexer,
type MultiplexerTransport
} from '../main/ssh/ssh-channel-multiplexer'
import { RelayDispatcher } from './dispatcher'
import { RelayAgentHookServer } from './agent-hook-server'
import {
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../shared/agent-hook-relay'
import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH } from '../shared/agent-status-types'
import { AgentHookServer } from '../main/agent-hooks/server'
import { publishAgentHookEnvelope } from './agent-hook-envelope-publication'
const LEAF_7 = '77777777-7777-4777-8777-777777777777'
const LEAF_9 = '99999999-9999-4999-8999-999999999999'
describe('Integration: relay hook server → mux → AgentHookServer.ingestRemote', () => {
let tmpDir: string
let mux: SshChannelMultiplexer
let dispatcher: RelayDispatcher
let hookServer: RelayAgentHookServer
let orcaServer: AgentHookServer
beforeEach(async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'agent-hook-e2e-'))
let relayFeedFn: ((data: Buffer) => void) | undefined
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const clientTransport: MultiplexerTransport = {
write: (data: Buffer) => {
setImmediate(() => relayFeedFn?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
// Why: MultiplexerTransport.onClose is a required field; the test never
// simulates a transport close, so register a no-op rather than tracking
// callbacks that nothing invokes.
onClose: () => {}
}
dispatcher = new RelayDispatcher(
(data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
},
{
writableHighWaterMark: () => 4096,
writableLength: () => 0
}
)
relayFeedFn = (data: Buffer) => dispatcher.feed(data)
hookServer = new RelayAgentHookServer({
endpointDir: tmpDir,
forward: (envelope) => {
publishAgentHookEnvelope(dispatcher, envelope)
}
})
await hookServer.start()
dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => {
const replayed = hookServer.replayCachedPayloadsForPanes()
return { replayed }
})
mux = new SshChannelMultiplexer(clientTransport)
orcaServer = new AgentHookServer()
// Why: Orca-side never starts an HTTP server in this test — `ingestRemote`
// is the entry point we exercise. setListener registers the IPC fanout
// sink we assert against. Server is otherwise inert.
mux.onNotification((method, params) => {
if (method === AGENT_HOOK_NOTIFICATION_METHOD) {
// Why: `connectionId` is normally derived from the mux identity at
// the call site. For the in-memory test we use a fixed string.
orcaServer.ingestRemote(
params as unknown as {
paneKey: string
tabId?: string
worktreeId?: string
payload: unknown
},
'conn-test'
)
}
})
})
afterEach(async () => {
mux.dispose()
dispatcher.dispose()
hookServer.stop()
orcaServer.stop()
rmSync(tmpDir, { recursive: true, force: true })
})
it.each([
{ agent: 'claude', input: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' } },
{
agent: 'opencode2',
input: { hook_event_name: 'MessagePart', role: 'user', text: 'roundtrip' }
}
])('forwards a $agent prompt through the relay to ingestRemote', async ({ agent, input }) => {
const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = []
orcaServer.setListener((event) => {
events.push({
paneKey: event.paneKey,
payload: event.payload,
connectionId: event.connectionId
})
})
const { port, token } = hookServer.getCoordinates()
const res = await fetch(`http://127.0.0.1:${port}/hook/${agent}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: `tab-7:${LEAF_7}`,
tabId: 'tab-7',
worktreeId: 'wt-7',
env: 'remote',
version: '1',
payload: input
})
})
expect(res.status).toBe(204)
// Why: dispatcher → transport setImmediate → mux feed → handler all run
// on the next tick(s); spin until our sink captures the event or we
// hit a generous timeout.
const start = Date.now()
while (events.length === 0 && Date.now() - start < 1500) {
await new Promise((r) => setImmediate(r))
}
expect(events).toHaveLength(1)
expect(events[0].paneKey).toBe(`tab-7:${LEAF_7}`)
expect(events[0].connectionId).toBe('conn-test')
const payload = events[0].payload as { state: string; prompt: string; agentType: string }
expect(payload.state).toBe('working')
expect(payload.prompt).toBe('roundtrip')
expect(payload.agentType).toBe(agent)
})
it.each([0, 1, 2, 3])(
'delivers Cursor form payloads with %i BOMs to the host-owned status store',
async (count) => {
const { port, token } = hookServer.getCoordinates()
for (const [hookEventName, state] of [
['beforeSubmitPrompt', 'working'],
['stop', 'done']
]) {
const payload = Buffer.concat([
...Array.from({ length: count }, () => Buffer.from([0xef, 0xbb, 0xbf])),
Buffer.from(
JSON.stringify({
hook_event_name: hookEventName,
prompt: 'Synthetic café 😀',
status: 'completed'
})
)
])
const response = await fetch(`http://127.0.0.1:${port}/hook/cursor`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Orca-Agent-Hook-Token': token
},
body: new URLSearchParams({
paneKey: `tab-7:${LEAF_7}`,
worktreeId: 'folder:synthetic-cursor',
payload: payload.toString('utf8')
}).toString()
})
expect(response.status).toBe(204)
await expect
.poll(() => orcaServer.getStatusSnapshot())
.toEqual([
expect.objectContaining({
paneKey: `tab-7:${LEAF_7}`,
worktreeId: 'folder:synthetic-cursor',
connectionId: 'conn-test',
state,
agentType: 'cursor',
prompt: 'Synthetic café 😀'
})
])
}
}
)
it('acknowledges malformed Cursor form payloads without publishing status', async () => {
const { port, token } = hookServer.getCoordinates()
for (const payload of [
'',
'\uFEFF\uFEFFnot json',
' \uFEFF{}',
'{\uFEFF"hook_event_name":"stop"}',
'\uFEFF\uFEFF{"hook_event_name":"unknown"}'
]) {
const response = await fetch(`http://127.0.0.1:${port}/hook/cursor`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Orca-Agent-Hook-Token': token
},
body: new URLSearchParams({ paneKey: `tab-7:${LEAF_7}`, payload }).toString()
})
expect(response.status).toBe(204)
}
await new Promise((resolve) => setImmediate(resolve))
expect(hookServer.replayCachedPayloadsForPanes()).toBe(0)
expect(orcaServer.getStatusSnapshot()).toEqual([])
})
it('sheds an oversized assistant message through the production publication path', async () => {
const events: { payload: { state: string; lastAssistantMessage?: string } }[] = []
orcaServer.setListener((event) => {
events.push({ payload: event.payload })
})
const { port, token } = hookServer.getCoordinates()
const post = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({ paneKey: `tab-7:${LEAF_7}`, payload })
})
await expect(
post({ hook_event_name: 'UserPromptSubmit', prompt: 'bounded publication' })
).resolves.toMatchObject({ status: 204 })
await expect(
post({
hook_event_name: 'Stop',
last_assistant_message: 'a'.repeat(AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH)
})
).resolves.toMatchObject({ status: 204 })
const start = Date.now()
while (events.length < 2 && Date.now() - start < 1500) {
await new Promise((resolve) => setImmediate(resolve))
}
expect(events).toHaveLength(2)
expect(events[1].payload.state).toBe('done')
expect(events[1].payload.lastAssistantMessage).toBeUndefined()
expect(dispatcher.activeClientIds()).toHaveLength(1)
})
it('clears remote Claude permission when the approved tool starts with matching identity', async () => {
const { port, token } = hookServer.getCoordinates()
const postClaude = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: `tab-7:${LEAF_7}`,
tabId: 'tab-7',
worktreeId: 'wt-7',
env: 'remote',
version: '1',
payload
})
})
await expect(
postClaude({
hook_event_name: 'PermissionRequest',
agent_id: 'agent-subagent-a',
agent_type: 'Review',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' }
})
).resolves.toMatchObject({ status: 204 })
await expect(
postClaude({
hook_event_name: 'PreToolUse',
agent_id: 'agent-subagent-a',
agent_type: 'Review',
tool_name: 'Bash',
tool_input: { command: 'pnpm test' },
tool_use_id: 'toolu-approved-remote'
})
).resolves.toMatchObject({ status: 204 })
const start = Date.now()
while (orcaServer.getStatusSnapshot()[0]?.state !== 'working' && Date.now() - start < 1500) {
await new Promise((r) => setImmediate(r))
}
expect(orcaServer.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: `tab-7:${LEAF_7}`,
connectionId: 'conn-test',
state: 'working',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'pnpm test'
})
])
})
it('clears remote Claude permission when its teammate idles', async () => {
const { port, token } = hookServer.getCoordinates()
const postClaude = (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: `tab-9:${LEAF_9}`,
tabId: 'tab-9',
worktreeId: 'wt-9',
env: 'remote',
version: '1',
payload
})
})
await postClaude({
hook_event_name: 'PermissionRequest',
agent_id: 'areviewer-6d3cb5b5',
agent_type: 'reviewer',
tool_name: 'Bash',
tool_input: { command: 'false' }
})
await postClaude({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' })
const start = Date.now()
while (orcaServer.getStatusSnapshot()[0]?.state !== 'working' && Date.now() - start < 1500) {
await new Promise((resolve) => setImmediate(resolve))
}
expect(orcaServer.getStatusSnapshot()[0]).toMatchObject({
paneKey: `tab-9:${LEAF_9}`,
connectionId: 'conn-test',
state: 'working',
agentType: 'claude',
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })]
})
expect(orcaServer.getStatusSnapshot()[0]?.toolName).toBeUndefined()
})
it('clears remote Claude permission when approved PostToolUse matches the preceding tool use id', async () => {
const { port, token } = hookServer.getCoordinates()
const postClaude = async (payload: Record<string, unknown>): Promise<Response> =>
fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: `tab-7:${LEAF_7}`,
tabId: 'tab-7',
worktreeId: 'wt-7',
env: 'remote',
version: '1',
payload
})
})
await expect(
postClaude({
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' },
tool_use_id: 'toolu-approved-remote-post'
})
).resolves.toMatchObject({ status: 204 })
await expect(
postClaude({
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' }
})
).resolves.toMatchObject({ status: 204 })
await expect(
postClaude({
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/orca-2824-permission-target' },
tool_use_id: 'toolu-approved-remote-post'
})
).resolves.toMatchObject({ status: 204 })
const start = Date.now()
while (orcaServer.getStatusSnapshot()[0]?.state !== 'working' && Date.now() - start < 1500) {
await new Promise((r) => setImmediate(r))
}
expect(orcaServer.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: `tab-7:${LEAF_7}`,
connectionId: 'conn-test',
state: 'working',
agentType: 'claude',
toolName: 'Bash',
toolInput: 'rm -rf /tmp/orca-2824-permission-target'
})
])
})
it('replays the cached last-status on agent_hook.requestReplay', async () => {
// Why: register the listener BEFORE the initial POST so live notifications
// are observed. setListener on a non-empty cache replays cached entries
// synchronously; if we set it AFTER the POST drains, the assertion below
// would pass without the relay's replay actually crossing the wire.
const events: { paneKey: string; payload: unknown }[] = []
orcaServer.setListener((event) => {
events.push({ paneKey: event.paneKey, payload: event.payload })
})
const { port, token } = hookServer.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-9:${LEAF_9}`,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cached' }
})
})
// Why: spin until the live notification arrives so the replay request
// below produces a strictly-second event in `events`.
const liveStart = Date.now()
while (events.length === 0 && Date.now() - liveStart < 1500) {
await new Promise((r) => setImmediate(r))
}
expect(events).toHaveLength(1)
const result = (await mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD)) as {
replayed: number
}
expect(result.replayed).toBe(1)
// Why: relay-side replay produces a fresh notification; spin until it
// arrives so the assertion below proves the round-trip (relay → wire →
// mux → ingestRemote → listener) rather than just the relay-side count.
const replayStart = Date.now()
while (events.length < 2 && Date.now() - replayStart < 1500) {
await new Promise((r) => setImmediate(r))
}
expect(events).toHaveLength(2)
expect(events[1].paneKey).toBe(`tab-9:${LEAF_9}`)
})
})