From c356394bfa127832350c0535d32f5511bc86523c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:48:52 +0200 Subject: [PATCH] Load config.json saved with a UTF-8 byte order mark (#5315) * Load config.json saved with a UTF-8 byte order mark Windows Notepad saves UTF-8 with a BOM. Both config readers passed the text straight to JSON.parse, so the daemon refused to start and CLI config commands failed with "Unexpected token". Closes #4683 * Strip the config BOM with an anchored replace --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/persisted-config.test.ts | 29 +++++++++++++++++++ .../server/src/server/persisted-config.ts | 11 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 2ab987eebf..cb98d050a4 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest"; import { loadPersistedConfig, PersistedConfigSchema, + readPersistedConfig, savePersistedConfig, } from "./persisted-config.js"; import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; @@ -736,6 +737,34 @@ describe("loadPersistedConfig", () => { }); }); +describe("config.json saved with a UTF-8 byte order mark", () => { + // Windows Notepad writes this shape: a BOM, then CRLF line endings. + const notepadConfig = + '{\r\n "version": 1,\r\n "daemon": { "listen": "127.0.0.1:6767" }\r\n}\r\n'; + + test("loadPersistedConfig reads it", () => { + const home = createTempHome(); + try { + writeFileSync(path.join(home, "config.json"), notepadConfig); + + expect(loadPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("readPersistedConfig reads it", () => { + const home = createTempHome(); + try { + writeFileSync(path.join(home, "config.json"), notepadConfig); + + expect(readPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { test("initializes config.json with private permissions", () => { const home = createTempHome(); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 953dde62d2..ad78ee1b2c 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -438,7 +438,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per let parsed: unknown; try { - parsed = JSON.parse(raw); + parsed = parseConfigText(raw); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, { @@ -472,7 +472,14 @@ export function readPersistedConfig( return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; throw error; } - return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig; + return PersistedConfigSchema.parse( + stripRemovedConfigFields(parseConfigText(raw)), + ) as PersistedConfig; +} + +/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */ +function parseConfigText(raw: string): unknown { + return JSON.parse(raw.replace(/^\uFEFF/, "")); } function configPathParts(field: string): string[] {