Files
openclaw/src/cli/config-cli.ts
Marcus Castro 9e8d9f114d fix(cli): use raw config instead of runtime-merged config in config set/unset
Fixes #6070

The config set/unset commands were using snapshot.config (which contains
runtime-merged defaults) instead of snapshot.parsed (the raw user config).
This caused runtime defaults like agents.defaults to leak into the written
config file when any value was set or unset.

Changed both set and unset commands to use structuredClone(snapshot.parsed)
to preserve only user-specified config values.
2026-02-13 04:41:04 +01:00

349 lines
11 KiB
TypeScript

import type { Command } from "commander";
import JSON5 from "json5";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
type PathSegment = string;
function isIndexSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
}
function parsePath(raw: string): PathSegment[] {
const trimmed = raw.trim();
if (!trimmed) {
return [];
}
const parts: string[] = [];
let current = "";
let i = 0;
while (i < trimmed.length) {
const ch = trimmed[i];
if (ch === "\\") {
const next = trimmed[i + 1];
if (next) {
current += next;
}
i += 2;
continue;
}
if (ch === ".") {
if (current) {
parts.push(current);
}
current = "";
i += 1;
continue;
}
if (ch === "[") {
if (current) {
parts.push(current);
}
current = "";
const close = trimmed.indexOf("]", i);
if (close === -1) {
throw new Error(`Invalid path (missing "]"): ${raw}`);
}
const inside = trimmed.slice(i + 1, close).trim();
if (!inside) {
throw new Error(`Invalid path (empty "[]"): ${raw}`);
}
parts.push(inside);
i = close + 1;
continue;
}
current += ch;
i += 1;
}
if (current) {
parts.push(current);
}
return parts.map((part) => part.trim()).filter(Boolean);
}
function parseValue(raw: string, opts: { json?: boolean }): unknown {
const trimmed = raw.trim();
if (opts.json) {
try {
return JSON5.parse(trimmed);
} catch (err) {
throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err });
}
}
try {
return JSON5.parse(trimmed);
} catch {
return raw;
}
}
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
let current: unknown = root;
for (const segment of path) {
if (!current || typeof current !== "object") {
return { found: false };
}
if (Array.isArray(current)) {
if (!isIndexSegment(segment)) {
return { found: false };
}
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
return { found: false };
}
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) {
return { found: false };
}
current = record[segment];
}
return { found: true, value: current };
}
function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: unknown): void {
let current: unknown = root;
for (let i = 0; i < path.length - 1; i += 1) {
const segment = path[i];
const next = path[i + 1];
const nextIsIndex = Boolean(next && isIndexSegment(next));
if (Array.isArray(current)) {
if (!isIndexSegment(segment)) {
throw new Error(`Expected numeric index for array segment "${segment}"`);
}
const index = Number.parseInt(segment, 10);
const existing = current[index];
if (!existing || typeof existing !== "object") {
current[index] = nextIsIndex ? [] : {};
}
current = current[index];
continue;
}
if (!current || typeof current !== "object") {
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
}
const record = current as Record<string, unknown>;
const existing = record[segment];
if (!existing || typeof existing !== "object") {
record[segment] = nextIsIndex ? [] : {};
}
current = record[segment];
}
const last = path[path.length - 1];
if (Array.isArray(current)) {
if (!isIndexSegment(last)) {
throw new Error(`Expected numeric index for array segment "${last}"`);
}
const index = Number.parseInt(last, 10);
current[index] = value;
return;
}
if (!current || typeof current !== "object") {
throw new Error(`Cannot set "${last}" (parent is not an object)`);
}
(current as Record<string, unknown>)[last] = value;
}
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
let current: unknown = root;
for (let i = 0; i < path.length - 1; i += 1) {
const segment = path[i];
if (!current || typeof current !== "object") {
return false;
}
if (Array.isArray(current)) {
if (!isIndexSegment(segment)) {
return false;
}
const index = Number.parseInt(segment, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
return false;
}
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) {
return false;
}
current = record[segment];
}
const last = path[path.length - 1];
if (Array.isArray(current)) {
if (!isIndexSegment(last)) {
return false;
}
const index = Number.parseInt(last, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
return false;
}
current.splice(index, 1);
return true;
}
if (!current || typeof current !== "object") {
return false;
}
const record = current as Record<string, unknown>;
if (!(last in record)) {
return false;
}
delete record[last];
return true;
}
async function loadValidConfig() {
const snapshot = await readConfigFileSnapshot();
if (snapshot.valid) {
return snapshot;
}
defaultRuntime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
for (const issue of snapshot.issues) {
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
defaultRuntime.error(`Run \`${formatCliCommand("openclaw doctor")}\` to repair, then retry.`);
defaultRuntime.exit(1);
return snapshot;
}
export function registerConfigCli(program: Command) {
const cmd = program
.command("config")
.description("Config helpers (get/set/unset). Run without subcommand for the wizard.")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.openclaw.ai/cli/config")}\n`,
)
.option(
"--section <section>",
"Configure wizard sections (repeatable). Use with no subcommand.",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.action(async (opts) => {
const { CONFIGURE_WIZARD_SECTIONS, configureCommand, configureCommandWithSections } =
await import("../commands/configure.js");
const sections: string[] = Array.isArray(opts.section)
? opts.section
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
.filter(Boolean)
: [];
if (sections.length === 0) {
await configureCommand(defaultRuntime);
return;
}
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
if (invalid.length > 0) {
defaultRuntime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
);
defaultRuntime.exit(1);
return;
}
await configureCommandWithSections(sections as never, defaultRuntime);
});
cmd
.command("get")
.description("Get a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.option("--json", "Output JSON", false)
.action(async (path: string, opts) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
const snapshot = await loadValidConfig();
const res = getAtPath(snapshot.config, parsedPath);
if (!res.found) {
defaultRuntime.error(danger(`Config path not found: ${path}`));
defaultRuntime.exit(1);
return;
}
if (opts.json) {
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
return;
}
if (
typeof res.value === "string" ||
typeof res.value === "number" ||
typeof res.value === "boolean"
) {
defaultRuntime.log(String(res.value));
return;
}
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cmd
.command("set")
.description("Set a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.argument("<value>", "Value (JSON5 or raw string)")
.option("--json", "Parse value as JSON5 (required)", false)
.action(async (path: string, value: string, opts) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
const parsedValue = parseValue(value, opts);
const snapshot = await loadValidConfig();
// Use snapshot.parsed (raw user config) instead of snapshot.config (runtime-merged with defaults)
// This prevents runtime defaults from leaking into the written config file (issue #6070)
const next = structuredClone(snapshot.parsed) as Record<string, unknown>;
setAtPath(next, parsedPath, parsedValue);
await writeConfigFile(next);
defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
cmd
.command("unset")
.description("Remove a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.action(async (path: string) => {
try {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
const snapshot = await loadValidConfig();
// Use snapshot.parsed (raw user config) instead of snapshot.config (runtime-merged with defaults)
// This prevents runtime defaults from leaking into the written config file (issue #6070)
const next = structuredClone(snapshot.parsed) as Record<string, unknown>;
const removed = unsetAtPath(next, parsedPath);
if (!removed) {
defaultRuntime.error(danger(`Config path not found: ${path}`));
defaultRuntime.exit(1);
return;
}
await writeConfigFile(next);
defaultRuntime.log(info(`Removed ${path}. Restart the gateway to apply.`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}