diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index edab2cb88..b91c109df 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -152,6 +152,45 @@ describe('shared agent-hook-listener', () => { expect(event!.payload.agentType).toBe('claude') }) + it('normalizes a BOM-prefixed Cursor hook payload to a working state', () => { + const event = normalizeHookPayload( + state, + 'cursor', + { + paneKey: PANE_KEY, + payload: '\uFEFF{"hook_event_name":"beforeSubmitPrompt","prompt":"Synthetic Cursor prompt"}' + }, + 'production' + ) + + expect(event?.payload).toMatchObject({ + agentType: 'cursor', + state: 'working', + prompt: 'Synthetic Cursor prompt' + }) + expect(event?.hookEventName).toBe('beforeSubmitPrompt') + }) + + // Why: pins the allowance to exactly one leading U+FEFF, so nobody widens it into a trim. + it('still rejects a hook payload that is malformed once the BOM is removed', () => { + const bom = '\uFEFF' + const body = '{"hook_event_name":"beforeSubmitPrompt"}' + for (const payload of [ + `${bom}${bom}${body}`, + `${bom}not json`, + ` ${bom}${body}`, + `{"hook_event_name"${bom}:"beforeSubmitPrompt"}` + ]) { + const event = normalizeHookPayload( + state, + 'cursor', + { paneKey: PANE_KEY, payload }, + 'production' + ) + expect(event).toBeNull() + } + }) + it('normalizes Gemini BeforeTool to working with tool fields', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 9e133aa68..f2a455ecb 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -85,8 +85,12 @@ const AGENT_HOOK_JSON_STRUCTURE_LIMITS = { } as const function parseAgentHookJson(content: string): unknown { - assertJsonTextStructureWithinLimits(content, AGENT_HOOK_JSON_STRUCTURE_LIMITS) - return JSON.parse(content) as unknown + // Why: Cursor on Windows writes UTF-8-with-BOM to the hook's stdin and `JSON.parse` rejects U+FEFF, + // so the whole event was dropped. Strip exactly one leading BOM — not a trim — to keep every other + // malformed payload rejected as before. + const normalizedContent = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content + assertJsonTextStructureWithinLimits(normalizedContent, AGENT_HOOK_JSON_STRUCTURE_LIMITS) + return JSON.parse(normalizedContent) as unknown } /** Bound the warn-once Sets so a client varying `version`/`env` per request can't grow them unbounded. */