diff --git a/src/config/io.ts b/src/config/io.ts index 73fa35296b7d..581c6f43747b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -510,7 +510,7 @@ function resolveConfigStatMetadata( function resolveConfigWriteSuspiciousReasons(params: { existsBefore: boolean; unreadableBefore: boolean; - previousBytes: number | null; + sizeBaselineBytes: number | null; nextBytes: number | null; hasMetaBefore: boolean; gatewayModeBefore: string | null; @@ -524,12 +524,12 @@ function resolveConfigWriteSuspiciousReasons(params: { reasons.push("unreadable-config-before-write"); } if ( - typeof params.previousBytes === "number" && + typeof params.sizeBaselineBytes === "number" && typeof params.nextBytes === "number" && - params.previousBytes >= 512 && - params.nextBytes < Math.floor(params.previousBytes * 0.5) + params.sizeBaselineBytes >= 512 && + params.nextBytes < Math.floor(params.sizeBaselineBytes * 0.5) ) { - reasons.push(`size-drop:${params.previousBytes}->${params.nextBytes}`); + reasons.push(`size-drop:${params.sizeBaselineBytes}->${params.nextBytes}`); } if (!params.hasMetaBefore) { reasons.push("missing-meta-before-write"); @@ -991,6 +991,29 @@ function stampConfigVersion(cfg: OpenClawConfig, version?: string): OpenClawConf return stampConfigWriteMetadata(cfg, new Date().toISOString(), version); } +function resolveConfigSizeBaselineBytes(params: { + raw: string | null; + json5: { parse: (value: string) => unknown }; + lastTouchedVersionOverride?: string; +}): number | null { + if (params.raw === null) { + return null; + } + const rawBytes = Buffer.byteLength(params.raw, "utf-8"); + const parsed = parseConfigJson5(params.raw, params.json5); + if (!parsed.ok || !isRecord(parsed.parsed)) { + return rawBytes; + } + const canonical = JSON.stringify( + stampConfigVersion(parsed.parsed as OpenClawConfig, params.lastTouchedVersionOverride), + null, + 2, + ) + .trimEnd() + .concat("\n"); + return Buffer.byteLength(canonical, "utf-8"); +} + function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick): void { const touched = cfg.meta?.lastTouchedVersion; if (!touched) { @@ -2499,6 +2522,13 @@ export function createConfigIO( const changedPathCount = changedPaths?.size; const previousBytes = typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null; + // Formatting is not data. Keep malformed/non-object files on the raw-byte + // baseline, but compare parseable authored config in its canonical form. + const sizeBaselineBytes = resolveConfigSizeBaselineBytes({ + raw: snapshot.raw, + json5: deps.json5, + lastTouchedVersionOverride: options.lastTouchedVersionOverride, + }); const nextBytes = Buffer.byteLength(json, "utf-8"); const previousStat = snapshot.exists ? await deps.fs.promises.stat(configPath).catch(() => null) @@ -2510,7 +2540,7 @@ export function createConfigIO( const suspiciousReasons = resolveConfigWriteSuspiciousReasons({ existsBefore: snapshot.exists, unreadableBefore: snapshot.readError != null, - previousBytes, + sizeBaselineBytes, nextBytes, hasMetaBefore, gatewayModeBefore, diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 1ae0831c1adf..85db12f013d2 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -1178,6 +1178,114 @@ describe("config io write", () => { }); }); + it("ignores verbose BOM formatting but still rejects a destructive size drop", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const original = { + meta: { lastTouchedVersion: "2026.4.23" }, + gateway: { mode: "local" }, + channels: { + telegram: { + enabled: true, + allowFrom: Array.from({ length: 80 }, (_, index) => `telegram:${index}`), + }, + }, + } satisfies ConfigFileSnapshot["config"]; + const powerShellRaw = `\uFEFF${JSON.stringify(original, null, 12)}\n`; + await fs.writeFile(configPath, powerShellRaw, "utf-8"); + const io = createConfigIO({ + env: { VITEST: "true" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.valid).toBe(true); + await expectConfigWriteRejected( + io.writeConfigFile( + { meta: original.meta, gateway: { mode: "local" } }, + { baseSnapshot: snapshot }, + ), + ); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(powerShellRaw); + + await io.writeConfigFile( + { + ...original, + gateway: { mode: "local", port: 18789 }, + }, + { baseSnapshot: snapshot }, + ); + const canonicalRaw = await fs.readFile(configPath, "utf-8"); + expect(Buffer.byteLength(powerShellRaw, "utf-8")).toBeGreaterThan( + Buffer.byteLength(canonicalRaw, "utf-8") * 2, + ); + }); + }); + + it("canonicalizes parseable schema-invalid config for the size baseline", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const channels = { + telegram: { + enabled: true, + allowFrom: Array.from({ length: 60 }, (_, index) => `telegram:${index}`), + }, + }; + const invalid = { + gateway: { mode: "local" }, + channels, + agents: { list: "not-an-array" }, + }; + const invalidRaw = `\uFEFF${JSON.stringify(invalid, null, 12)}\n`; + await fs.writeFile(configPath, invalidRaw, "utf-8"); + const io = createConfigIO({ + env: { VITEST: "true" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + const snapshot = { + path: configPath, + exists: true, + raw: invalidRaw, + parsed: invalid, + sourceConfig: {}, + resolved: {}, + valid: false, + runtimeConfig: {}, + config: {}, + issues: [], + warnings: [], + legacyIssues: [], + } satisfies ConfigFileSnapshot; + await expect( + io.writeConfigFile( + { gateway: { mode: "local", port: 18789 }, channels }, + { baseSnapshot: snapshot, skipPluginValidation: true }, + ), + ).resolves.toBeDefined(); + }); + }); + + it("keeps the raw-byte size baseline for malformed config", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const malformedRaw = `not-json\n${"x".repeat(2048)}\n`; + await fs.writeFile(configPath, malformedRaw, "utf-8"); + const io = createConfigIO({ + env: { VITEST: "true" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + await expectConfigWriteRejected(io.writeConfigFile({ gateway: { mode: "local" } })); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(malformedRaw); + }); + }); + it("allows intentional size-drop writes without disabling gateway-mode protection", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json");