fix(agent-hooks): accept BOM-prefixed hook configs (#13383)
This commit is contained in:
parent
7c05c8c72e
commit
44cccb8cd9
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: {} })
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue