fix(agent-hooks): accept BOM-prefixed hook configs (#13383)

This commit is contained in:
Brennan Benson 2026-08-09 16:35:57 -07:00 committed by GitHub
parent 7c05c8c72e
commit 44cccb8cd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 43 additions and 13 deletions

View File

@ -11,6 +11,17 @@ export type HooksJsonSnapshot = {
config: HooksConfig | null 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 // 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 // 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. // read or a concurrent save can slip between them unnoticed.
@ -24,12 +35,7 @@ export function readHooksJsonWithRaw(configPath: string): HooksJsonSnapshot {
} catch { } catch {
return { raw: null, config: null } return { raw: null, config: null }
} }
try { return { raw, config: parseHooksJsonText(raw) }
const parsed = JSON.parse(raw)
return { raw, config: isPlainObject(parsed) ? parsed : null }
} catch {
return { raw, config: null }
}
} }
export function readHooksJson(configPath: string): HooksConfig | null { export function readHooksJson(configPath: string): HooksConfig | null {

View File

@ -151,6 +151,15 @@ describe('installer-utils-remote', () => {
expect(result).toBeNull() 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 () => { it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => {
const sftp = { const sftp = {
readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => { readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => {

View File

@ -14,7 +14,8 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { SFTPWrapper, FileEntryWithStats } from 'ssh2' 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 DEFAULT_REMOTE_CONFIG_MODE = 0o600
const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000 const REMOTE_SFTP_OPERATION_TIMEOUT_MS = 10_000
@ -38,12 +39,7 @@ export async function readHooksJsonRemote(
} }
throw err throw err
} }
try { return parseHooksJsonText(body)
const parsed = JSON.parse(body)
return isPlainObject(parsed) ? parsed : null
} catch {
return null
}
} }
/** Atomically write a JSON config to the remote write to a tmp path then /** Atomically write a JSON config to the remote write to a tmp path then

View File

@ -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', () => { it('reports a missing file as an empty config with no raw bytes', () => {
expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: null, config: {} }) expect(readHooksJsonWithRaw(configPath)).toEqual({ raw: null, config: {} })
}) })