mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 09:43:57 +00:00
fix(config): compare size guard against canonical input (#100591)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
committed by
GitHub
parent
82f5ac150f
commit
87d8fa7e3a
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user