fix(config): compare size guard against canonical input (#100591)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Peter Steinberger
2026-07-06 05:47:49 +01:00
committed by GitHub
parent 82f5ac150f
commit 87d8fa7e3a
2 changed files with 144 additions and 6 deletions

View File

@@ -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<typeof console, "warn">): 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,

View File

@@ -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");