diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 0edf11fe1ac..cb3a7511c18 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -140,6 +140,10 @@ describe('shared agent-hook-listener', () => { version: undefined, payload: rawBody }) + expect(normalizeHookPayload(state, 'claude', merged, 'production')).toMatchObject({ + paneKey: PANE_KEY, + payload: { state: 'working', prompt: 'hello' } + }) }) it('releases request parser listeners after rejecting an oversized body', async () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 1bebc086470..71ef358203f 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -78,6 +78,7 @@ import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 +const HEADER_MERGED_HOOK_ENVELOPE = Symbol('headerMergedHookEnvelope') const HOOK_REQUEST_INITIAL_BUFFER_BYTES = 4 * 1024 const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { structuralTokens: 128 * 1024, @@ -537,7 +538,7 @@ export function mergeAgentHookRequestHeaders(body: unknown, headers: IncomingHtt if (!paneKey) { return body } - return { + const envelope = { paneKey, tabId: readHookHeader(headers, 'x-orca-tab-id'), launchToken: readHookHeader(headers, 'x-orca-launch-token'), @@ -546,6 +547,10 @@ export function mergeAgentHookRequestHeaders(body: unknown, headers: IncomingHtt version: readHookHeader(headers, 'x-orca-agent-hook-version'), payload: body } + Object.defineProperty(envelope, HEADER_MERGED_HOOK_ENVELOPE, { + value: true + }) + return envelope } function ignoreSettledRequestError(): void {} @@ -1300,6 +1305,41 @@ function parseHookBodyPayloadRecord(body: unknown): Record | nu : null } +function readHookBodyEnvelope(body: unknown): Record | null { + if (typeof body !== 'object' || body === null) { + return null + } + const record = body as Record + if (record[HEADER_MERGED_HOOK_ENVELOPE] === true) { + return body as Record + } + // Why: direct callers and already-installed form hooks still send this legacy envelope shape. + const legacyEnvelope = body as Record + if ( + typeof legacyEnvelope.paneKey !== 'string' || + !Object.prototype.hasOwnProperty.call(legacyEnvelope, 'payload') + ) { + return null + } + return legacyEnvelope +} + +function parseHookEnvelopePayload(body: unknown): unknown { + const envelope = readHookBodyEnvelope(body) + if (!envelope) { + return null + } + const rawPayload = envelope.payload + if (typeof rawPayload !== 'string') { + return rawPayload + } + try { + return parseAgentHookJson(rawPayload) + } catch { + return null + } +} + function readBoundedString( record: Record, keys: readonly string[], @@ -4128,20 +4168,13 @@ export function normalizeHookPayload( return null } - const record = body as Record + const record = readHookBodyEnvelope(body) + if (!record) { + return null + } const paneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' const parsedPaneKey = parsePaneKey(paneKey) - const rawPayload = record.payload - const hookPayload = - typeof rawPayload === 'string' - ? (() => { - try { - return parseAgentHookJson(rawPayload) - } catch { - return null - } - })() - : rawPayload + const hookPayload = parseHookEnvelopePayload(body) if ( !paneKey || paneKey.length > MAX_PANE_KEY_LEN ||