Files
openclaw/src/cli/config-cli.test.ts
Vincent Koc e4d80ed556 CI: restore main detect-secrets scan (#38438)
* Tests: stabilize detect-secrets fixtures

* Tests: fix rebased detect-secrets false positives

* Docs: keep snippets valid under detect-secrets

* Tests: finalize detect-secrets false-positive fixes

* Tests: reduce detect-secrets false positives

* Tests: keep detect-secrets pragmas inline

* Tests: remediate next detect-secrets batch

* Tests: tighten detect-secrets allowlists

* Tests: stabilize detect-secrets formatter drift
2026-03-07 10:06:35 -08:00

446 lines
15 KiB
TypeScript

import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
/**
* Test for issue #6070:
* `openclaw config set/unset` must update snapshot.resolved (user config after $include/${ENV},
* but before runtime defaults), so runtime defaults don't leak into the written config.
*/
const mockReadConfigFileSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
const mockWriteConfigFile = vi.fn<
(cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise<void>
>(async () => {});
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
writeConfigFile: (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) =>
mockWriteConfigFile(cfg, options),
}));
const mockLog = vi.fn();
const mockError = vi.fn();
const mockExit = vi.fn((code: number) => {
const errorMessages = mockError.mock.calls.map((c) => c.join(" ")).join("; ");
throw new Error(`__exit__:${code} - ${errorMessages}`);
});
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: (...args: unknown[]) => mockLog(...args),
error: (...args: unknown[]) => mockError(...args),
exit: (code: number) => mockExit(code),
},
}));
function buildSnapshot(params: {
resolved: OpenClawConfig;
config: OpenClawConfig;
}): ConfigFileSnapshot {
return {
path: "/tmp/openclaw.json",
exists: true,
raw: JSON.stringify(params.resolved),
parsed: params.resolved,
resolved: params.resolved,
valid: true,
config: params.config,
issues: [],
warnings: [],
legacyIssues: [],
};
}
function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) {
mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config }));
}
function setSnapshotOnce(snapshot: ConfigFileSnapshot) {
mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
}
function withRuntimeDefaults(resolved: OpenClawConfig): OpenClawConfig {
return {
...resolved,
agents: {
...resolved.agents,
defaults: {
model: "gpt-5.2",
} as never,
} as never,
};
}
function makeInvalidSnapshot(params: {
issues: ConfigFileSnapshot["issues"];
path?: string;
}): ConfigFileSnapshot {
return {
path: params.path ?? "/tmp/custom-openclaw.json",
exists: true,
raw: "{}",
parsed: {},
resolved: {},
valid: false,
config: {},
issues: params.issues,
warnings: [],
legacyIssues: [],
};
}
async function runValidateJsonAndGetPayload() {
await expect(runConfigCommand(["config", "validate", "--json"])).rejects.toThrow("__exit__:1");
const raw = mockLog.mock.calls.at(0)?.[0];
expect(typeof raw).toBe("string");
return JSON.parse(String(raw)) as {
valid: boolean;
path: string;
issues: Array<{
path: string;
message: string;
allowedValues?: string[];
allowedValuesHiddenCount?: number;
}>;
};
}
let registerConfigCli: typeof import("./config-cli.js").registerConfigCli;
let sharedProgram: Command;
async function runConfigCommand(args: string[]) {
await sharedProgram.parseAsync(args, { from: "user" });
}
describe("config cli", () => {
beforeAll(async () => {
({ registerConfigCli } = await import("./config-cli.js"));
sharedProgram = new Command();
sharedProgram.exitOverride();
registerConfigCli(sharedProgram);
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("config set - issue #6070", () => {
it("preserves existing config keys when setting a new value", async () => {
const resolved: OpenClawConfig = {
agents: {
list: [{ id: "main" }, { id: "oracle", workspace: "~/oracle-workspace" }],
},
gateway: { port: 18789 },
tools: { allow: ["group:fs"] },
logging: { level: "debug" },
};
const runtimeMerged: OpenClawConfig = {
...withRuntimeDefaults(resolved),
};
setSnapshot(resolved, runtimeMerged);
await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.gateway?.auth).toEqual({ mode: "token" });
expect(written.gateway?.port).toBe(18789);
expect(written.agents).toEqual(resolved.agents);
expect(written.tools).toEqual(resolved.tools);
expect(written.logging).toEqual(resolved.logging);
expect(written.agents).not.toHaveProperty("defaults");
});
it("does not inject runtime defaults into the written config", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
const runtimeMerged = {
...resolved,
agents: {
defaults: {
model: "gpt-5.2",
contextWindow: 128_000,
maxTokens: 16_000,
},
} as never,
messages: { ackReaction: "✅" } as never,
sessions: { persistence: { enabled: true } } as never,
} as unknown as OpenClawConfig;
setSnapshot(resolved, runtimeMerged);
await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written).not.toHaveProperty("agents.defaults.model");
expect(written).not.toHaveProperty("agents.defaults.contextWindow");
expect(written).not.toHaveProperty("agents.defaults.maxTokens");
expect(written).not.toHaveProperty("messages.ackReaction");
expect(written).not.toHaveProperty("sessions.persistence");
expect(written.gateway?.port).toBe(18789);
expect(written.gateway?.auth).toEqual({ mode: "token" });
});
it("auto-seeds a valid Ollama provider when setting only models.providers.ollama.apiKey", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "models.providers.ollama.apiKey", '"ollama-local"']);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.models?.providers?.ollama).toEqual({
baseUrl: "http://127.0.0.1:11434",
api: "ollama",
models: [],
apiKey: "ollama-local", // pragma: allowlist secret
});
});
});
describe("config get", () => {
it("redacts sensitive values", async () => {
const resolved: OpenClawConfig = {
gateway: {
auth: {
token: "super-secret-token",
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "get", "gateway.auth.token"]);
expect(mockLog).toHaveBeenCalledWith("__OPENCLAW_REDACTED__");
});
});
describe("config validate", () => {
it("prints success and exits 0 when config is valid", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "validate"]);
expect(mockExit).not.toHaveBeenCalled();
expect(mockError).not.toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Config valid:"));
});
it("prints issues and exits 1 when config is invalid", async () => {
setSnapshotOnce(
makeInvalidSnapshot({
issues: [
{
path: "agents.defaults.suppressToolErrorWarnings",
message: "Unrecognized key(s) in object",
},
],
}),
);
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config invalid at"));
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("agents.defaults.suppressToolErrorWarnings"),
);
expect(mockLog).not.toHaveBeenCalled();
});
it("returns machine-readable JSON with --json for invalid config", async () => {
setSnapshotOnce(
makeInvalidSnapshot({
issues: [{ path: "gateway.bind", message: "Invalid enum value" }],
}),
);
const payload = await runValidateJsonAndGetPayload();
expect(payload.valid).toBe(false);
expect(payload.path).toBe("/tmp/custom-openclaw.json");
expect(payload.issues).toEqual([{ path: "gateway.bind", message: "Invalid enum value" }]);
expect(mockError).not.toHaveBeenCalled();
});
it("preserves allowed-values metadata in --json output", async () => {
setSnapshotOnce(
makeInvalidSnapshot({
issues: [
{
path: "update.channel",
message: 'Invalid input (allowed: "stable", "beta", "dev")',
allowedValues: ["stable", "beta", "dev"],
allowedValuesHiddenCount: 0,
},
],
}),
);
const payload = await runValidateJsonAndGetPayload();
expect(payload.valid).toBe(false);
expect(payload.path).toBe("/tmp/custom-openclaw.json");
expect(payload.issues).toEqual([
{
path: "update.channel",
message: 'Invalid input (allowed: "stable", "beta", "dev")',
allowedValues: ["stable", "beta", "dev"],
},
]);
expect(mockError).not.toHaveBeenCalled();
});
it("prints file-not-found and exits 1 when config file is missing", async () => {
setSnapshotOnce({
path: "/tmp/openclaw.json",
exists: false,
raw: null,
parsed: {},
resolved: {},
valid: true,
config: {},
issues: [],
warnings: [],
legacyIssues: [],
});
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config file not found:"));
expect(mockLog).not.toHaveBeenCalled();
});
});
describe("config set parsing flags", () => {
it("falls back to raw string when parsing fails and strict mode is off", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "gateway.auth.mode", "{bad"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.gateway?.auth).toEqual({ mode: "{bad" });
});
it("throws when strict parsing is enabled via --strict-json", async () => {
await expect(
runConfigCommand(["config", "set", "gateway.auth.mode", "{bad", "--strict-json"]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
});
it("keeps --json as a strict parsing alias", async () => {
await expect(
runConfigCommand(["config", "set", "gateway.auth.mode", "{bad", "--json"]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
});
it("shows --strict-json and keeps --json as a legacy alias in help", async () => {
const program = new Command();
registerConfigCli(program);
const configCommand = program.commands.find((command) => command.name() === "config");
const setCommand = configCommand?.commands.find((command) => command.name() === "set");
const helpText = setCommand?.helpInformation() ?? "";
expect(helpText).toContain("--strict-json");
expect(helpText).toContain("--json");
expect(helpText).toContain("Legacy alias for --strict-json");
});
});
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 = {
agents: { list: [{ id: "main" }] },
gateway: { port: 18789 },
tools: {
profile: "coding",
alsoAllow: ["agents_list"],
},
logging: { level: "debug" },
};
const runtimeMerged: OpenClawConfig = {
...withRuntimeDefaults(resolved),
};
setSnapshot(resolved, runtimeMerged);
await runConfigCommand(["config", "unset", "tools.alsoAllow"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.tools).not.toHaveProperty("alsoAllow");
expect(written.agents).not.toHaveProperty("defaults");
expect(written.agents?.list).toEqual(resolved.agents?.list);
expect(written.gateway).toEqual(resolved.gateway);
expect(written.tools?.profile).toBe("coding");
expect(written.logging).toEqual(resolved.logging);
expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({
unsetPaths: [["tools", "alsoAllow"]],
});
});
});
describe("config file", () => {
it("prints the active config file path", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "file"]);
expect(mockLog).toHaveBeenCalledWith("/tmp/openclaw.json");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("handles config file path with home directory", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
const snapshot = buildSnapshot({ resolved, config: resolved });
snapshot.path = "/home/user/.openclaw/openclaw.json";
mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
await runConfigCommand(["config", "file"]);
expect(mockLog).toHaveBeenCalledWith("/home/user/.openclaw/openclaw.json");
});
});
});