Skip to content

fix(windows): strip UTF-8 BOM in all settings.json readers (#3013)#3033

Open
Neclode wants to merge 1 commit into
thedotmack:mainfrom
Neclode:fix/windows-settings-bom
Open

fix(windows): strip UTF-8 BOM in all settings.json readers (#3013)#3033
Neclode wants to merge 1 commit into
thedotmack:mainfrom
Neclode:fix/windows-settings-bom

Conversation

@Neclode

@Neclode Neclode commented Jun 22, 2026

Copy link
Copy Markdown

Fixes #3013.

Problem. On Windows, external tooling (PowerShell 5.1, some editors/formatters) rewrites ~/.claude-mem/settings.json with a UTF-8 BOM (EF BB BF). Node/Bun decode it to U+FEFF at offset 0 on utf-8 read, and JSON.parse rejects it — so the worker fails to load config on every hook, eventually tripping CAPTURE_BROKEN and disabling capture entirely.

Root cause. BOM stripping was added to exactly one of seven settings readers (SettingsDefaultsManager). The other six still parsed raw and threw — including logger.getLevel(), which is the source of the Failed to load log level from settings error in the report.

Why read-side, not write-side. Our own writes use writeFileSync(..., 'utf-8'), which never emits a BOM — so a write-side fix is a no-op and wouldn't repair the already-BOM'd files sitting on users' disks. The fix has to be read-side resilience.

Changes

  • Add shared stripBom() to utils/json-utils and route readJsonSafe through it
  • Strip BOM in the six remaining settings.json readers (logger, paths, plugin-state, npx server, server-beta-bootstrap, SettingsRoutes)
  • Collapse SettingsDefaultsManager's inline BOM regex onto the shared helper
  • Drop a dead logger import so json-utils stays an import-cycle-free leaf
  • Test: readJsonSafe parses a BOM-prefixed settings.json

Verification: bun test 39/39 pass · tsc --noEmit clean.

…k#3013)

On Windows, external tooling (PowerShell 5.1, some editors/formatters)
rewrites ~/.claude-mem/settings.json with a UTF-8 BOM (EF BB BF). Node/Bun
decode it to U+FEFF at offset 0 on `utf-8` read, and JSON.parse rejects it,
so the worker fails to load config on every hook — eventually tripping
CAPTURE_BROKEN and disabling capture entirely.

BOM stripping had been added to exactly one of seven readers
(SettingsDefaultsManager). The other six still parsed raw and threw, including
logger.getLevel() — the source of the "Failed to load log level from settings"
error in the report.

Our own writes use writeFileSync(..., 'utf-8'), which never emits a BOM, so a
write-side fix is a no-op and would not repair already-BOM'd files on disk. The
fix is read-side resilience.

- add shared stripBom() to utils/json-utils and route readJsonSafe through it
- strip BOM in the six remaining settings.json readers
- collapse SettingsDefaultsManager's inline BOM regex onto the shared helper
- drop a dead logger import so json-utils stays an import-cycle-free leaf
- test: readJsonSafe parses a BOM-prefixed settings.json
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a Windows-only crash where PowerShell 5.1 (and some editors) rewrites settings.json with a UTF-8 BOM, causing JSON.parse to throw on every settings read and eventually disabling capture entirely. The fix centralises BOM stripping in a shared stripBom() helper in json-utils.ts and applies it consistently across all seven settings.json readers that were previously vulnerable.

  • src/utils/json-utils.ts: New stripBom() export using charCodeAt(0) === 0xFEFF; readJsonSafe now routes through it. The old logger import is dropped to keep json-utils an import-cycle-free leaf.
  • Six remaining readers (logger, paths, plugin-state, server, server-beta-bootstrap, SettingsRoutes): each now wraps its readFileSync result in stripBom() before JSON.parse, matching the pattern already present in SettingsDefaultsManager.
  • New test in tests/utils/json-utils.test.ts covers stripBom directly (leading, no-op, trailing) and the end-to-end readJsonSafe path with a BOM-prefixed file simulating a PowerShell-written settings.json.

Confidence Score: 5/5

Safe to merge — the change is additive, touches only the string passed to JSON.parse, and has no effect on BOM-free files.

All seven settings readers are covered, the import-cycle risk is explicitly resolved by removing the old logger import from json-utils, the implementation is a single well-understood character comparison, and the new test reproduces the exact PowerShell BOM scenario described in the bug report. The only finding is a cosmetic test constant that could be silently stripped by tooling.

tests/utils/json-utils.test.ts — the literal U+FEFF constant is worth replacing with '\uFEFF' to guard against invisible-character stripping.

Important Files Changed

Filename Overview
src/utils/json-utils.ts Adds stripBom() helper with a clean charCodeAt(0) check; removes the logger import that would have created a cycle; readJsonSafe now routes through stripBom before parsing.
src/utils/logger.ts Imports stripBom from json-utils (the reverse of the old cycle-creating direction) and applies it in getLevel() before parsing settings — fixing the root cause of Failed to load log level from settings on Windows.
src/shared/SettingsDefaultsManager.ts Replaces the inline BOM regex (/^\uFEFF/) with the shared stripBom() call; behaviour is identical, consistency improved.
src/shared/paths.ts Adds stripBom wrapping to resolveDataDir's inline readFileSync + JSON.parse call; no other logic changed.
src/shared/plugin-state.ts Adds stripBom before JSON.parse in isPluginDisabledInClaudeSettings; clean one-line change.
src/services/worker/http/routes/SettingsRoutes.ts Wraps settingsData in stripBom before JSON.parse; error handling and logging paths are unchanged.
src/services/hooks/server-beta-bootstrap.ts Applies stripBom in persistServerBetaSettings when reading the existing file before merging; catch-empty-object fallback is retained.
src/npx-cli/commands/server.ts Adds stripBom to the API-key rotation read path in runServerBetaKeysRotateCommand; minimal, correct change.
tests/utils/json-utils.test.ts New test suite covering stripBom (leading, no-op, trailing) and the end-to-end readJsonSafe BOM scenario; the BOM constant uses a literal U+FEFF character rather than '\uFEFF', which is invisible and could be stripped silently by tooling.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["settings.json on disk\n(may have UTF-8 BOM\nfrom PowerShell / editor)"] --> B["readFileSync(..., 'utf-8')"]
    B --> C["stripBom(text)\ncharCodeAt(0) === 0xFEFF\n→ slice(1) : text"]
    C --> D["JSON.parse(stripped)"]
    D --> E1["logger.getLevel()"]
    D --> E2["paths.resolveDataDir()"]
    D --> E3["plugin-state\nisPluginDisabled()"]
    D --> E4["SettingsDefaultsManager\nloadSettings()"]
    D --> E5["SettingsRoutes\nGET /settings"]
    D --> E6["server-beta-bootstrap\npersistServerBetaSettings()"]
    D --> E7["server.ts\nrunServerBetaKeysRotateCommand()"]
    D --> E8["readJsonSafe()\n(generic helper)"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["settings.json on disk\n(may have UTF-8 BOM\nfrom PowerShell / editor)"] --> B["readFileSync(..., 'utf-8')"]
    B --> C["stripBom(text)\ncharCodeAt(0) === 0xFEFF\n→ slice(1) : text"]
    C --> D["JSON.parse(stripped)"]
    D --> E1["logger.getLevel()"]
    D --> E2["paths.resolveDataDir()"]
    D --> E3["plugin-state\nisPluginDisabled()"]
    D --> E4["SettingsDefaultsManager\nloadSettings()"]
    D --> E5["SettingsRoutes\nGET /settings"]
    D --> E6["server-beta-bootstrap\npersistServerBetaSettings()"]
    D --> E7["server.ts\nrunServerBetaKeysRotateCommand()"]
    D --> E8["readJsonSafe()\n(generic helper)"]
Loading

Reviews (1): Last reviewed commit: "fix(windows): strip UTF-8 BOM in all set..." | Re-trigger Greptile

import { tmpdir } from 'os';
import { readJsonSafe, stripBom } from '../../src/utils/json-utils.js';

const BOM = '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The BOM constant is defined using a literal U+FEFF character, which is invisible in virtually every editor and can be silently stripped by formatters, linters, or copy-paste. If it disappears, stripBom tests pass vacuously — the "removes a leading BOM" case would compare '{"a":1}' to '{"a":1}' and still succeed, hiding a broken implementation. Using '\uFEFF' makes the intent explicit and immune to tool interference.

Suggested change
const BOM = '';
const BOM = '\uFEFF';

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: settings.json written with UTF-8 BOM causes JSON parse failure on every hook

1 participant