Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import type { Server } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startHookServer, type HookServerHandlers, chooseHookAgent } from './hookServer.js'
import { env } from './config/env.js'
import { readHookCredential } from './lib/hookAuth.js'
let server: Server | null = null
afterEach(async () => {
if (!server) return
await new Promise<void>((resolve) => server!.close(() => resolve()))
server = null
})
async function start(overrides: Partial<HookServerHandlers> = {}) {
const handlers: HookServerHandlers = {
onRegistered: vi.fn(),
onSessionEnd: vi.fn(),
...overrides,
}
const started = await startHookServer(0, handlers)
server = started.server
const credential = readHookCredential(env.ADAPTER_DATA_DIR)
if (!credential) throw new Error('hook credential was not created')
return {
handlers,
base: `http://127.0.0.1:${started.port}`,
headers: { 'content-type': 'application/json', 'x-harness-hook-token': credential },
}
}
describe('process-owned hook server', () => {
it('runs targeted resolution and rejects a hook without a matching pane engine process', async () => {
const resolveHookAgent = vi.fn(async () => null)
const { handlers, base, headers } = await start({ resolveHookAgent })
const response = await fetch(`${base}/api/hook/session-start`, {
method: 'POST',
headers,
body: JSON.stringify({
engine: 'codex',
tmuxPane: '%41',
sessionId: '019fea92-e31a-7692-9c35-f616e9d458b7',
cwd: '/work/demo',
}),
})
expect(await response.json()).toEqual({ ignored: true, reason: 'no_matching_engine_process' })
expect(resolveHookAgent).toHaveBeenCalledWith({
engine: 'codex', tmuxPane: '%41', runtimeHints: [{ backend: 'tmux', paneId: '%41' }], callerPid: undefined,
})
expect(handlers.onRegistered).not.toHaveBeenCalled()
})
it('rejects hooks outside configured terminal contexts before attempting process resolution', async () => {
const resolveHookAgent = vi.fn(async () => null)
const { handlers, base, headers } = await start({ resolveHookAgent })
const response = await fetch(`${base}/api/hook/session-start`, {
method: 'POST',
headers,
body: JSON.stringify({ engine: 'claude', sessionId: 'session-1', cwd: '/work/demo' }),
})
expect(await response.json()).toEqual({ ignored: true, reason: 'not_in_terminal' })
expect(resolveHookAgent).not.toHaveBeenCalled()
expect(handlers.onRegistered).not.toHaveBeenCalled()
})
it('treats SessionEnd as a reconciliation hint and exposes no launcher websocket endpoint', async () => {
const onSessionEnd = vi.fn()
const resolveHookAgent = vi.fn(async () => ({
engine: 'claude', sessionId: 'session-1', agentId: 'agent-1',
} as never))
const { base, headers } = await start({ onSessionEnd, resolveHookAgent })
const ended = await fetch(`${base}/api/hook/session-end`, {
method: 'POST',
headers,
body: JSON.stringify({
engine: 'claude', sessionId: 'session-1', reason: 'clear', tmuxPane: '%1', callerPid: 123,
}),
})
expect(await ended.json()).toEqual({ ok: true })
expect(onSessionEnd).toHaveBeenCalledWith('session-1', 'clear')
const legacy = await fetch(`${base}/api/machine-ws`)
expect(legacy.status).toBe(404)
})
it('rejects every unauthenticated mutating hook request', async () => {
const { base, handlers } = await start()
const response = await fetch(`${base}/api/hook/session-end`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: 'session-1' }),
})
expect(response.status).toBe(401)
expect(await response.json()).toEqual({ error: 'UNAUTHORIZED' })
expect(handlers.onSessionEnd).not.toHaveBeenCalled()
})
it.each([
[{ engine: 'claude', sessionId: 'session-1', tmuxPane: '%1', unknown: true }],
[{ engine: 'claude', sessionId: 'session-1', runtimeHints: [{ backend: 'tmux', paneId: '%1', extra: true }] }],
[{ engine: 'claude', sessionId: 'session-1', tmuxPane: '%1', source: { forged: true } }],
[{ engine: 'claude', sessionId: 'session-1', tmuxPane: '%1', input: 'x'.repeat(128 * 1024 + 1) }],
[null],
])('rejects malformed or unknown hook fields before process resolution', async (body) => {
const resolveHookAgent = vi.fn(async () => null)
const { base, headers } = await start({ resolveHookAgent })
const response = await fetch(`${base}/api/hook/session-start`, {
method: 'POST', headers, body: JSON.stringify(body),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'invalid hook body' })
expect(resolveHookAgent).not.toHaveBeenCalled()
})
it.each([
['/api/hook/session-end', { reason: 'clear' }, 'onSessionEnd'],
['/api/hook/turn-start', {}, 'onTurnStart'],
['/api/hook/tool-start', { toolUseId: 'tool-1', toolName: 'Task' }, 'onToolStart'],
['/api/hook/turn-stop', { status: 'error' }, 'onTurnStop'],
] as const)('rejects a forged bound-session mutation on %s', async (path, extra, handlerName) => {
const handler = vi.fn()
const resolveHookAgent = vi.fn(async () => ({
engine: 'claude', sessionId: 'real-session', agentId: 'agent-1',
} as never))
const { base, headers } = await start({ [handlerName]: handler, resolveHookAgent })
const response = await fetch(`${base}${path}`, {
method: 'POST',
headers,
body: JSON.stringify({
engine: 'claude', sessionId: 'forged-session', tmuxPane: '%1', callerPid: 123, ...extra,
}),
})
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'UNBOUND_HOOK' })
expect(handler).not.toHaveBeenCalled()
})
it('answers a proxied control-plane read whose handler throws, instead of hanging it', async () => {
// The handler is a void-discarded async: a throw used to be an unhandledRejection and a request
// with no response, which the desktop app reported 30s later as its own receive timeout.
const { base } = await start({ onMachinesList: async () => { throw new TypeError('fetch failed') } })
const response = await fetch(`${base}/api/machines`)
expect(response.status).toBe(502)
expect(await response.json()).toEqual({ success: false, error: { code: 'PROXY_FAILED', message: 'fetch failed' } })
})
it('forwards a proxied answer verbatim, status and body alike', async () => {
const { base } = await start({
onAuthMe: async () => ({ status: 504, body: { success: false, error: { code: 'BACKEND_TIMEOUT', message: 'slow' } } }),
})
const response = await fetch(`${base}/api/auth/me`)
expect(response.status).toBe(504)
expect(await response.json()).toEqual({ success: false, error: { code: 'BACKEND_TIMEOUT', message: 'slow' } })
})
})
describe('the Harness Store proxy', () => {
it('forwards a store read with its path and query, and a store write only with the local header', async () => {
const calls: Array<[string, string, unknown]> = []
const { base } = await start({
onStore: async (method, path, body) => { calls.push([method, path, body]); return { status: 200, body: { success: true, data: { ok: method } } } },
})
const read = await fetch(`${base}/api/store/harnesses/autonomous/marp/reviews?limit=5`)
expect(read.status).toBe(200)
expect(await read.json()).toEqual({ success: true, data: { ok: 'GET' } })
const crossOrigin = await fetch(`${base}/api/store/harnesses/autonomous/marp/review`, { method: 'PUT', body: JSON.stringify({ rating: 5 }) })
expect(crossOrigin.status).toBe(403)
const write = await fetch(`${base}/api/store/harnesses/autonomous/marp/review`, {
method: 'PUT', headers: { 'x-adapter-local': '1', 'content-type': 'application/json' }, body: JSON.stringify({ rating: 5, title: 'Keynote' }),
})
expect(write.status).toBe(200)
const gone = await fetch(`${base}/api/store/harnesses/autonomous/marp/review`, { method: 'DELETE', headers: { 'x-adapter-local': '1' } })
expect(gone.status).toBe(200)
expect(calls).toEqual([
['GET', '/api/store/harnesses/autonomous/marp/reviews?limit=5', undefined],
['PUT', '/api/store/harnesses/autonomous/marp/review', { rating: 5, title: 'Keynote' }],
['DELETE', '/api/store/harnesses/autonomous/marp/review', undefined],
])
})
it('refuses a store path with anything but id characters in it', async () => {
const { base } = await start({ onStore: async () => ({ status: 200, body: {} }) })
expect((await fetch(`${base}/api/store/harnesses/a%20b/reviews`)).status).toBe(400)
expect((await fetch(`${base}/api/store/ratings`, { method: 'POST', headers: { 'x-adapter-local': '1' } })).status).toBe(405)
})
})
describe('chooseHookAgent', () => {
it('prefers caller ancestry, the evidence that cannot be guessed at', () => {
expect(chooseHookAgent(['strong'], ['weak'])).toEqual({ agent: 'strong', reason: 'ancestry' })
})
it('accepts the runtime alone when ancestry is unavailable and the pane is unambiguous', () => {
// Cursor posts its hooks from outside the pane's process tree — on tmux and on Herdr alike — so
// demanding ancestry rejected every hook it ever sent and no session bound. The pane is the proof:
// the hook named a runtime, and that runtime carries exactly one agent of this engine.
expect(chooseHookAgent([], ['only-agent-on-that-pane'])).toEqual({
agent: 'only-agent-on-that-pane', reason: 'runtime',
})
})
it('answers nothing rather than guessing', () => {
expect(chooseHookAgent([], [])).toEqual({ agent: null, reason: 'none' })
expect(chooseHookAgent([], ['a', 'b'])).toEqual({ agent: null, reason: 'ambiguous' })
expect(chooseHookAgent(['a', 'b'], ['c'])).toEqual({ agent: null, reason: 'ambiguous' })
})
})
describe('/api/status', () => {
it('serves whatever the daemon reports — including the pid the desktop uses to tell daemons apart', async () => {
const { base } = await start({
onStatus: () => ({ pid: process.pid, connected: false, restarting: false, discoveryReady: true }),
})
const response = await fetch(`${base}/api/status`)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ pid: process.pid, connected: false, restarting: false, discoveryReady: true })
})
})
7fd606699This file is pinned to the selected release. Download the original to open it in your design tool.