fix: guard config includes (#731) (thanks @pasogott)
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
||||||
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
|
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
|
||||||
- Tests: add Docker plugin loader smoke test.
|
- Tests: add Docker plugin loader smoke test.
|
||||||
|
- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott.
|
||||||
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.
|
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.
|
||||||
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
|
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
|
||||||
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
|
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ Split your config into multiple files using the `$include` directive. This is us
|
|||||||
- **Single file**: Replaces the object containing `$include`
|
- **Single file**: Replaces the object containing `$include`
|
||||||
- **Array of files**: Deep-merges files in order (later files override earlier ones)
|
- **Array of files**: Deep-merges files in order (later files override earlier ones)
|
||||||
- **With sibling keys**: Sibling keys are merged after includes (override included values)
|
- **With sibling keys**: Sibling keys are merged after includes (override included values)
|
||||||
|
- **Sibling keys + arrays/primitives**: Not supported (included content must be an object)
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
// Sibling keys override included values
|
// Sibling keys override included values
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ describe("resolveConfigIncludes", () => {
|
|||||||
expect(resolve(obj, files)).toEqual({ a: 1, b: 99 });
|
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", () => {
|
it("resolves nested includes", () => {
|
||||||
const files = {
|
const files = {
|
||||||
"/config/level1.json": { nested: { $include: "./level2.json" } },
|
"/config/level1.json": { nested: { $include: "./level2.json" } },
|
||||||
@@ -123,10 +132,12 @@ describe("resolveConfigIncludes", () => {
|
|||||||
parseJson: JSON.parse,
|
parseJson: JSON.parse,
|
||||||
};
|
};
|
||||||
const obj = { $include: "./bad.json" };
|
const obj = { $include: "./bad.json" };
|
||||||
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
|
expect(() =>
|
||||||
.toThrow(ConfigIncludeError);
|
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
|
||||||
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
|
).toThrow(ConfigIncludeError);
|
||||||
.toThrow(/Failed to parse include file/);
|
expect(() =>
|
||||||
|
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
|
||||||
|
).toThrow(/Failed to parse include file/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws CircularIncludeError for circular includes", () => {
|
it("throws CircularIncludeError for circular includes", () => {
|
||||||
@@ -143,10 +154,12 @@ describe("resolveConfigIncludes", () => {
|
|||||||
parseJson: JSON.parse,
|
parseJson: JSON.parse,
|
||||||
};
|
};
|
||||||
const obj = { $include: "./a.json" };
|
const obj = { $include: "./a.json" };
|
||||||
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
|
expect(() =>
|
||||||
.toThrow(CircularIncludeError);
|
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
|
||||||
expect(() => resolveConfigIncludes(obj, "/config/clawdbot.json", resolver))
|
).toThrow(CircularIncludeError);
|
||||||
.toThrow(/Circular include detected/);
|
expect(() =>
|
||||||
|
resolveConfigIncludes(obj, "/config/clawdbot.json", resolver),
|
||||||
|
).toThrow(/Circular include detected/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws ConfigIncludeError for invalid $include value type", () => {
|
it("throws ConfigIncludeError for invalid $include value type", () => {
|
||||||
@@ -185,7 +198,9 @@ describe("resolveConfigIncludes", () => {
|
|||||||
it("resolves parent directory references", () => {
|
it("resolves parent directory references", () => {
|
||||||
const files = { "/shared/common.json": { shared: true } };
|
const files = { "/shared/common.json": { shared: true } };
|
||||||
const obj = { $include: "../../shared/common.json" };
|
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 = {
|
const files = {
|
||||||
"/config/clients/mueller.json": {
|
"/config/clients/mueller.json": {
|
||||||
agents: [
|
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": {
|
"/config/clients/schmidt.json": {
|
||||||
agents: [
|
agents: [
|
||||||
{ id: "schmidt-screenshot", workspace: "~/clients/schmidt/screenshot" },
|
{
|
||||||
|
id: "schmidt-screenshot",
|
||||||
|
workspace: "~/clients/schmidt/screenshot",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
broadcast: { "group-schmidt": ["schmidt-screenshot"] },
|
broadcast: { "group-schmidt": ["schmidt-screenshot"] },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,9 +73,8 @@ export function deepMerge(target: unknown, source: unknown): unknown {
|
|||||||
if (isPlainObject(target) && isPlainObject(source)) {
|
if (isPlainObject(target) && isPlainObject(source)) {
|
||||||
const result: Record<string, unknown> = { ...target };
|
const result: Record<string, unknown> = { ...target };
|
||||||
for (const key of Object.keys(source)) {
|
for (const key of Object.keys(source)) {
|
||||||
result[key] = key in result
|
result[key] =
|
||||||
? deepMerge(result[key], source[key])
|
key in result ? deepMerge(result[key], source[key]) : source[key];
|
||||||
: source[key];
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -130,6 +129,13 @@ class IncludeProcessor {
|
|||||||
return included;
|
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
|
// Merge included content with sibling keys
|
||||||
const rest: Record<string, unknown> = {};
|
const rest: Record<string, unknown> = {};
|
||||||
for (const key of otherKeys) {
|
for (const key of otherKeys) {
|
||||||
@@ -163,7 +169,7 @@ class IncludeProcessor {
|
|||||||
|
|
||||||
private loadFile(includePath: string): unknown {
|
private loadFile(includePath: string): unknown {
|
||||||
const resolvedPath = this.resolvePath(includePath);
|
const resolvedPath = this.resolvePath(includePath);
|
||||||
|
|
||||||
this.checkCircular(resolvedPath);
|
this.checkCircular(resolvedPath);
|
||||||
this.checkDepth(includePath);
|
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 {
|
try {
|
||||||
return this.resolver.parseJson(raw);
|
return this.resolver.parseJson(raw);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import {
|
|||||||
applySessionDefaults,
|
applySessionDefaults,
|
||||||
applyTalkApiKey,
|
applyTalkApiKey,
|
||||||
} from "./defaults.js";
|
} from "./defaults.js";
|
||||||
import {
|
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
|
||||||
ConfigIncludeError,
|
|
||||||
resolveConfigIncludes,
|
|
||||||
} from "./includes.js";
|
|
||||||
import { findLegacyConfigIssues } from "./legacy.js";
|
import { findLegacyConfigIssues } from "./legacy.js";
|
||||||
import { resolveConfigPath, resolveStateDir } from "./paths.js";
|
import { resolveConfigPath, resolveStateDir } from "./paths.js";
|
||||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ describe("postinstall patcher", () => {
|
|||||||
fs.mkdirSync(target);
|
fs.mkdirSync(target);
|
||||||
|
|
||||||
const filePath = path.join(target, "main.js");
|
const filePath = path.join(target, "main.js");
|
||||||
const original = [
|
const original = `${[
|
||||||
"var QRCode = require('./../vendor/QRCode'),",
|
"var QRCode = require('./../vendor/QRCode'),",
|
||||||
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),",
|
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),",
|
||||||
' black = "\\033[40m \\033[0m",',
|
' black = "\\033[40m \\033[0m",',
|
||||||
' white = "\\033[47m \\033[0m",',
|
' white = "\\033[47m \\033[0m",',
|
||||||
" toCell = function (isBlack) {",
|
" toCell = function (isBlack) {",
|
||||||
].join("\n") + "\n";
|
].join("\n")}\n`;
|
||||||
fs.writeFileSync(filePath, original, "utf-8");
|
fs.writeFileSync(filePath, original, "utf-8");
|
||||||
|
|
||||||
const patchText = `diff --git a/lib/main.js b/lib/main.js
|
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");
|
const updated = fs.readFileSync(filePath, "utf-8");
|
||||||
expect(updated).toBe(
|
expect(updated).toBe(
|
||||||
[
|
`${[
|
||||||
"var QRCode = require('./../vendor/QRCode/index.js'),",
|
"var QRCode = require('./../vendor/QRCode/index.js'),",
|
||||||
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),",
|
" QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),",
|
||||||
' black = "\\033[40m \\033[0m",',
|
' black = "\\033[40m \\033[0m",',
|
||||||
' white = "\\033[47m \\033[0m",',
|
' white = "\\033[47m \\033[0m",',
|
||||||
" toCell = function (isBlack) {",
|
" toCell = function (isBlack) {",
|
||||||
].join("\n") + "\n",
|
].join("\n")}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@@ -60,7 +60,7 @@ index 0000000..1111111 100644
|
|||||||
const filePath = path.join(dir, "file.txt");
|
const filePath = path.join(dir, "file.txt");
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
filePath,
|
filePath,
|
||||||
["alpha", "beta", "gamma", "delta", "epsilon"].join("\n") + "\n",
|
`${["alpha", "beta", "gamma", "delta", "epsilon"].join("\n")}\n`,
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user