orca/src/relay/agent-hook-integration.test.ts

321 lines
11 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 { AgentHookServer } from '../main/agent-hooks/server'
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)
}
})
})
relayFeedFn = (data: Buffer) => dispatcher.feed(data)
hookServer = new RelayAgentHookServer({
endpointDir: tmpDir,
forward: (envelope) => {
dispatcher.notify(
AGENT_HOOK_NOTIFICATION_METHOD,
envelope as unknown as Record<string, unknown>
)
}
})
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('forwards a Claude UserPromptSubmit POST through to ingestRemote', async () => {
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/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: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' }
})
})
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('claude')
})
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 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}`)
})
})