fix: guard config includes (#731) (thanks @pasogott)

This commit is contained in:
Peter Steinberger
2026-01-12 00:12:03 +00:00
parent 53d3134fe8
commit e3e3498a4b
6 changed files with 62 additions and 27 deletions

View File

@@ -100,6 +100,15 @@ describe("resolveConfigIncludes", () => {
expect(resolve(obj, files)).toEqual({ a: 1, b: 99 });
});
it("throws when sibling keys are used with non-object includes", () => {
const files = { "/config/list.json": ["a", "b"] };
const obj = { $include: "./list.json", extra: true };
expect(() => resolve(obj, files)).toThrow(ConfigIncludeError);
expect(() => resolve(obj, files)).toThrow(
/Sibling keys require included content to be an object/,
);
});
it("resolves nested includes", () => {
const files = {
"/config/level1.json": { nested: { $include: "./level2.json" } },
@@ -123,10 +132,12 @@ describe("resolveConfigIncludes", () => {
parseJson: JSON.parse,
};
const obj = { $include: "./bad.json" };
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
.toThrow(ConfigIncludeError);
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
.toThrow(/Failed to parse include file/);
expect(() =>
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
).toThrow(ConfigIncludeError);
expect(() =>
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
).toThrow(/Failed to parse include file/);
});
it("throws CircularIncludeError for circular includes", () => {
@@ -143,10 +154,12 @@ describe("resolveConfigIncludes", () => {
parseJson: JSON.parse,
};
const obj = { $include: "./a.json" };
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
.toThrow(CircularIncludeError);
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
.toThrow(/Circular include detected/);
expect(() =>
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
).toThrow(CircularIncludeError);
expect(() =>
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
).toThrow(/Circular include detected/);
});
it("throws ConfigIncludeError for invalid $include value type", () => {
@@ -185,7 +198,9 @@ describe("resolveConfigIncludes", () => {
it("resolves parent directory references", () => {
const files = { "/shared/common.json": { shared: true } };
const obj = { $include: "../../shared/common.json" };
expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({ shared: true });
expect(resolve(obj, files, "/config/sub/clawdbot.json")).toEqual({
shared: true,
});
});
});
@@ -194,14 +209,25 @@ describe("real-world config patterns", () => {
const files = {
"/config/clients/mueller.json": {
agents: [
{ id: "mueller-screenshot", workspace: "~/clients/mueller/screenshot" },
{ id: "mueller-transcribe", workspace: "~/clients/mueller/transcribe" },
{
id: "mueller-screenshot",
workspace: "~/clients/mueller/screenshot",
},
{
id: "mueller-transcribe",
workspace: "~/clients/mueller/transcribe",
},
],
broadcast: { "group-mueller": ["mueller-screenshot", "mueller-transcribe"] },
broadcast: {
"group-mueller": ["mueller-screenshot", "mueller-transcribe"],
},
},
"/config/clients/schmidt.json": {
agents: [
{ id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" },
{
id: "schmidt-screenshot",
workspace: "~/clients/schmidt/screenshot",
},
],
broadcast: { "group-schmidt": ["schmidt-screenshot"] },
},

View File

@@ -73,9 +73,8 @@ export function deepMerge(target: unknown, source: unknown): unknown {
if (isPlainObject(target) && isPlainObject(source)) {
const result: Record<string, unknown> = { ...target };
for (const key of Object.keys(source)) {
result[key] = key in result
? deepMerge(result[key], source[key])
: source[key];
result[key] =
key in result ? deepMerge(result[key], source[key]) : source[key];
}
return result;
}
@@ -130,6 +129,13 @@ class IncludeProcessor {
return included;
}
if (!isPlainObject(included)) {
throw new ConfigIncludeError(
"Sibling keys require included content to be an object",
typeof includeValue === "string" ? includeValue : INCLUDE_KEY,
);
}
// Merge included content with sibling keys
const rest: Record<string, unknown> = {};
for (const key of otherKeys) {
@@ -163,7 +169,7 @@ class IncludeProcessor {
private loadFile(includePath: string): unknown {
const resolvedPath = this.resolvePath(includePath);
this.checkCircular(resolvedPath);
this.checkDepth(includePath);
@@ -207,7 +213,11 @@ class IncludeProcessor {
}
}
private parseFile(includePath: string, resolvedPath: string, raw: string): unknown {
private parseFile(
includePath: string,
resolvedPath: string,
raw: string,
): unknown {
try {
return this.resolver.parseJson(raw);
} catch (err) {

View File

@@ -22,10 +22,7 @@ import {
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
import {
ConfigIncludeError,
resolveConfigIncludes,
} from "./includes.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { findLegacyConfigIssues } from "./legacy.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";

View File

@@ -16,13 +16,13 @@ describe("postinstall patcher", () => {
fs.mkdirSync(target);
const filePath = path.join(target, "main.js");
const original = [
const original = `${[
"var QRCode = require('./../vendor/QRCode'),",
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),",
' black = "\\033[40m \\033[0m",',
' white = "\\033[47m \\033[0m",',
" toCell = function (isBlack) {",
].join("\n") + "\n";
].join("\n")}\n`;
fs.writeFileSync(filePath, original, "utf-8");
const patchText = `diff --git a/lib/main.js b/lib/main.js
@@ -43,13 +43,13 @@ index 0000000..1111111 100644
const updated = fs.readFileSync(filePath, "utf-8");
expect(updated).toBe(
[
`${[
"var QRCode = require('./../vendor/QRCode/index.js'),",
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),",
' black = "\\033[40m \\033[0m",',
' white = "\\033[47m \\033[0m",',
" toCell = function (isBlack) {",
].join("\n") + "\n",
].join("\n")}\n`,
);
fs.rmSync(dir, { recursive: true, force: true });
@@ -60,7 +60,7 @@ index 0000000..1111111 100644
const filePath = path.join(dir, "file.txt");
fs.writeFileSync(
filePath,
["alpha", "beta", "gamma", "delta", "epsilon"].join("\n") + "\n",
`${["alpha", "beta", "gamma", "delta", "epsilon"].join("\n")}\n`,
"utf-8",
);