mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
fix(config): keep write inputs immutable when using unsetPaths (#24134)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 951f8480c3
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -204,6 +204,35 @@ describe("config cli", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("path hardening", () => {
|
||||
it("rejects blocked prototype-key segments for config get", async () => {
|
||||
await expect(runConfigCommand(["config", "get", "gateway.__proto__.token"])).rejects.toThrow(
|
||||
"Invalid path segment: __proto__",
|
||||
);
|
||||
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects blocked prototype-key segments for config set", async () => {
|
||||
await expect(
|
||||
runConfigCommand(["config", "set", "tools.constructor.profile", '"sandbox"']),
|
||||
).rejects.toThrow("Invalid path segment: constructor");
|
||||
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects blocked prototype-key segments for config unset", async () => {
|
||||
await expect(
|
||||
runConfigCommand(["config", "unset", "channels.prototype.enabled"]),
|
||||
).rejects.toThrow("Invalid path segment: prototype");
|
||||
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config unset - issue #6070", () => {
|
||||
it("preserves existing config keys when unsetting a value", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import JSON5 from "json5";
|
||||
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
||||
import { redactConfigObject } from "../config/redact-snapshot.js";
|
||||
import { danger, info } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -88,6 +89,18 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function validatePathSegments(path: PathSegment[]): void {
|
||||
for (const segment of path) {
|
||||
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
|
||||
throw new Error(`Invalid path segment: ${segment}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
|
||||
let current: unknown = root;
|
||||
for (const segment of path) {
|
||||
@@ -106,7 +119,7 @@ function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?
|
||||
continue;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!(segment in record)) {
|
||||
if (!hasOwnPathKey(record, segment)) {
|
||||
return { found: false };
|
||||
}
|
||||
current = record[segment];
|
||||
@@ -136,7 +149,7 @@ function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: un
|
||||
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
const existing = record[segment];
|
||||
const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined;
|
||||
if (!existing || typeof existing !== "object") {
|
||||
record[segment] = nextIsIndex ? [] : {};
|
||||
}
|
||||
@@ -177,7 +190,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
|
||||
continue;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!(segment in record)) {
|
||||
if (!hasOwnPathKey(record, segment)) {
|
||||
return false;
|
||||
}
|
||||
current = record[segment];
|
||||
@@ -199,7 +212,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
|
||||
return false;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
if (!(last in record)) {
|
||||
if (!hasOwnPathKey(record, last)) {
|
||||
return false;
|
||||
}
|
||||
delete record[last];
|
||||
@@ -225,6 +238,7 @@ function parseRequiredPath(path: string): PathSegment[] {
|
||||
if (parsedPath.length === 0) {
|
||||
throw new Error("Path is empty.");
|
||||
}
|
||||
validatePathSegments(parsedPath);
|
||||
return parsedPath;
|
||||
}
|
||||
|
||||
@@ -322,10 +336,7 @@ export function registerConfigCli(program: Command) {
|
||||
.option("--json", "Legacy alias for --strict-json", false)
|
||||
.action(async (path: string, value: string, opts) => {
|
||||
try {
|
||||
const parsedPath = parsePath(path);
|
||||
if (parsedPath.length === 0) {
|
||||
throw new Error("Path is empty.");
|
||||
}
|
||||
const parsedPath = parseRequiredPath(path);
|
||||
const parsedValue = parseValue(value, {
|
||||
strictJson: Boolean(opts.strictJson || opts.json),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user