From 44cccb8cd903f68cf1ed887c62f6b1969f06b9ef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:35:57 -0700 Subject: [PATCH] fix(agent-hooks): accept BOM-prefixed hook configs (#13383) --- src/main/agent-hooks/hooks-json-read.ts | 18 ++++++++++++------ .../installer-utils-remote.test.ts | 9 +++++++++ .../agent-hooks/installer-utils-remote.ts | 10 +++------- src/main/agent-hooks/installer-utils.test.ts | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/main/agent-hooks/hooks-json-read.ts b/src/main/agent-hooks/hooks-json-read.ts index c8536fad0..e2c8743ca 100644 --- a/src/main/agent-hooks/hooks-json-read.ts +++ b/src/main/agent-hooks/hooks-json-read.ts @@ -11,6 +11,17 @@ export type HooksJsonSnapshot = { config: HooksConfig | null } +export function parseHooksJsonText(raw: string): HooksConfig | null { + // Why: JSON.parse rejects a decoded UTF-8 BOM; strip only the leading marker. + const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw + try { + const parsed = JSON.parse(content) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + // Why: generation guards abort a mutation when the file no longer matches the // bytes it was derived from; the raw snapshot and the parse must come from one // read or a concurrent save can slip between them unnoticed. @@ -24,12 +35,7 @@ export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot { } catch { return { raw: null, config: null } } - try { - const parsed = JSON.parse(raw) - return { raw, config: isPlainObject(parsed) ? parsed : null } - } catch { - return { raw, config: null } - } + return { raw, config: parseHooksJsonText(raw) } } export function readHooksJson(configPath: string): HooksConfig | null { diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts index 75f9c9682..b848883d2 100644 --- a/src/main/agent-hooks/installer-utils-remote.test.ts +++ b/src/main/agent-hooks/installer-utils-remote.test.ts @@ -151,6 +151,15 @@ describe('installer-utils-remote', () => { expect(result).toBeNull() }) + it('parses settings.json with one leading BOM', async () => { + const { sftp, fs } = createFakeSftp() + fs.files.set('/home/u/.cursor/hooks.json', '\uFEFF{"version":1,"hooks":{}}') + + const result = await readHooksJsonRemote(sftp, '/home/u/.cursor/hooks.json') + + expect(result).toEqual({ version: 1, hooks: {} }) + }) + it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => { const sftp = { readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => { diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index c8edd5e80..aa7e47d25 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -14,7 +14,8 @@ import { randomUUID } from 'node:crypto' import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' -import { isPlainObject, type HooksConfig } from './installer-utils' +import type { HooksConfig } from './installer-utils' +import { parseHooksJsonText } from './hooks-json-read' const DEFAULT_REMOTE_CONFIG_MODE = 0o600 const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000 @@ -38,12 +39,7 @@ export async function readHooksJsonRemote( } throw err } - try { - const parsed = JSON.parse(body) - return isPlainObject(parsed) ? parsed : null - } catch { - return null - } + return parseHooksJsonText(body) } /** Atomically write a JSON config to the remote — write to a tmp path then diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 487a58195..75aabc6fc 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -56,6 +56,25 @@ describe('readHooksJsonWithRaw', () => { }) }) + it('parses one leading BOM while preserving the exact raw contents', () => { + const contents = '\uFEFF{"hooks": {"Stop": []}, "custom": 1}\n' + writeFileSync(configPath, contents, 'utf-8') + + expect(readHooksJsonWithRaw(configPath)).toEqual({ + raw: contents, + config: { hooks: { Stop: [] }, custom: 1 } + }) + }) + + it('rejects multiple or misplaced BOM characters', () => { + const body = '{"hooks": {"Stop": []}}' + for (const contents of [`\uFEFF\uFEFF${body}`, ` \uFEFF${body}`, `{\uFEFF"hooks": {}}`]) { + writeFileSync(configPath, contents, 'utf-8') + + expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: contents, config: null }) + } + }) + it('reports a missing file as an empty config with no raw bytes', () => { expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: null, config: {} }) })