Files
openclaw/src/cli/config-cli.test.ts
Peter Steinberger f91de52f0d refactor: move runtime state to SQLite
* refactor: remove stale file-backed shims

* fix: harden sqlite state ci boundaries

* refactor: store matrix idb snapshots in sqlite

* fix: satisfy rebased CI guardrails

* refactor: store current conversation bindings in sqlite table

* refactor: store tui last sessions in sqlite table

* refactor: reset sqlite schema history

* refactor: drop unshipped sqlite table migration

* refactor: remove plugin index file rollback

* refactor: drop unshipped sqlite sidecar migrations

* refactor: remove runtime commitments kv migration

* refactor: preserve kysely sync result types

* refactor: drop unshipped sqlite schema migration table

* test: keep session usage coverage sqlite-backed

* refactor: keep sqlite migration doctor-only

* refactor: isolate device legacy imports

* refactor: isolate push voicewake legacy imports

* refactor: isolate remaining runtime legacy imports

* refactor: tighten sqlite migration guardrails

* test: cover sqlite persisted enum parsing

* refactor: isolate legacy update and tui imports

* refactor: tighten sqlite state ownership

* refactor: move legacy imports behind doctor

* refactor: remove legacy session row lookup

* refactor: canonicalize memory transcript locators

* refactor: drop transcript path scope fallbacks

* refactor: drop runtime legacy session delivery pruning

* refactor: store tts prefs only in sqlite

* refactor: remove cron store path runtime

* refactor: use cron sqlite store keys

* refactor: rename telegram message cache scope

* refactor: read memory dreaming status from sqlite

* refactor: rename cron status store key

* refactor: stop remembering transcript file paths

* test: use sqlite locators in agent fixtures

* refactor: remove file-shaped commitments and cron store surfaces

* refactor: keep compaction transcript handles out of session rows

* refactor: derive transcript handles from session identity

* refactor: derive runtime transcript handles

* refactor: remove gateway session locator reads

* refactor: remove transcript locator from session rows

* refactor: store raw stream diagnostics in sqlite

* refactor: remove file-shaped transcript rotation

* refactor: hide legacy trajectory paths from runtime

* refactor: remove runtime transcript file bridges

* refactor: repair database-first rebase fallout

* refactor: align tests with database-first state

* refactor: remove transcript file handoffs

* refactor: sync post-compaction memory by transcript scope

* refactor: run codex app-server sessions by id

* refactor: bind codex runtime state by session id

* refactor: pass memory transcripts by sqlite scope

* refactor: remove transcript locator cleanup leftovers

* test: remove stale transcript file fixtures

* refactor: remove transcript locator test helper

* test: make cron sqlite keys explicit

* test: remove cron runtime store paths

* test: remove stale session file fixtures

* test: use sqlite cron keys in diagnostics

* refactor: remove runtime delivery queue backfill

* test: drop fake export session file mocks

* refactor: rename acp session read failure flag

* refactor: rename acp row session key

* refactor: remove session store test seams

* refactor: move legacy session parser tests to doctor

* refactor: reindex managed memory in place

* refactor: drop stale session store wording

* refactor: rename session row helpers

* refactor: rename sqlite session entry modules

* refactor: remove transcript locator leftovers

* refactor: trim file-era audit wording

* refactor: clean managed media through sqlite

* fix: prefer explicit agent for exports

* fix: use prepared agent for session resets

* fix: canonicalize legacy codex binding import

* test: rename state cleanup helper

* docs: align backup docs with sqlite state

* refactor: drop legacy Pi usage auth fallback

* refactor: move legacy auth profile imports to doctor

* refactor: keep Pi model discovery auth in memory

* refactor: remove MSTeams legacy learning key fallback

* refactor: store model catalog config in sqlite

* refactor: use sqlite model catalog at runtime

* refactor: remove model json compatibility aliases

* refactor: store auth profiles in sqlite

* refactor: seed copied auth profiles in sqlite

* refactor: make auth profile runtime sqlite-addressed

* refactor: migrate hermes secrets into sqlite auth store

* refactor: move plugin install config migration to doctor

* refactor: rename plugin index audit checks

* test: drop auth file assumptions

* test: remove legacy transcript file assertions

* refactor: drop legacy cli session aliases

* refactor: store skill uploads in sqlite

* refactor: keep subagent attachments in sqlite vfs

* refactor: drop subagent attachment cleanup state

* refactor: move legacy session aliases to doctor

* refactor: require node 24 for sqlite state runtime

* refactor: move provider caches into sqlite state

* fix: harden virtual agent filesystem

* refactor: enforce database-first runtime state

* refactor: rename compaction transcript rotation setting

* test: clean sqlite refactor test types

* refactor: consolidate sqlite runtime state

* refactor: model session conversations in sqlite

* refactor: stop deriving cron delivery from session keys

* refactor: stop classifying sessions from key shape

* refactor: hydrate announce targets from typed delivery

* refactor: route heartbeat delivery from typed sqlite context

* refactor: tighten typed sqlite session routing

* refactor: remove session origin routing shadow

* refactor: drop session origin shadow fixtures

* perf: query sqlite vfs paths by prefix

* refactor: use typed conversation metadata for sessions

* refactor: prefer typed session routing metadata

* refactor: require typed session routing metadata

* refactor: resolve group tool policy from typed sessions

* refactor: delete dead session thread info bridge

* Show Codex subscription reset times in channel errors (#80456)

* feat(plugin-sdk): consolidate session workflow APIs

* fix(agents): allow read-only agent mount reads

* [codex] refresh plugin regression fixtures

* fix(agents): restore compaction gateway logs

* test: tighten gateway startup assertions

* Redact persisted secret-shaped payloads [AI] (#79006)

* test: tighten device pair notify assertions

* test: tighten hermes secret assertions

* test: assert matrix client error shapes

* test: assert config compat warnings

* fix(heartbeat): remap cron-run exec events to session keys (#80214)

* fix(codex): route btw through native side threads

* fix(auth): accept friendly OpenAI order for Codex profiles

* fix(codex): rotate auth profiles inside harness

* fix: keep browser status page probe within timeout

* test: assert agents add outputs

* test: pin cron read status

* fix(agents): avoid Pi resource discovery stalls

Co-authored-by: dataCenter430 <titan032000@gmail.com>

* fix: retire timed-out codex app-server clients

* test: tighten qa lab runtime assertions

* test: check security fix outputs

* test: verify extension runtime messages

* feat(wake): expose typed sessionKey on wake protocol + system event CLI

* fix(gateway): await session_end during shutdown drain and track channel + compaction lifecycle paths (#57790)

* test: guard talk consult call helper

* fix(codex): scale context engine projection (#80761)

* fix(codex): scale context engine projection

* fix: document Codex context projection scaling

* fix: document Codex context projection scaling

* fix: document Codex context projection scaling

* fix: document Codex context projection scaling

* chore: align Codex projection changelog

* chore: realign Codex projection changelog

* fix: isolate Codex projection patch

---------

Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org>
Co-authored-by: Josh Lehman <josh@martian.engineering>

* refactor: move agent runtime state toward piless

* refactor: remove cron session reaper

* refactor: move session management to sqlite

* refactor: finish database-first state migration

* chore: refresh generated sqlite db types

* refactor: remove stale file-backed shims

* test: harden kysely type coverage

# Conflicts:
#	.agents/skills/kysely-database-access/SKILL.md
#	src/infra/kysely-sync.types.test.ts
#	src/proxy-capture/store.sqlite.test.ts
#	src/state/openclaw-agent-db.test.ts
#	src/state/openclaw-state-db.test.ts

* refactor: remove cron store path runtime

* refactor: keep compaction transcript handles out of session rows

* refactor: derive embedded transcripts from sqlite identity

* refactor: remove embedded transcript locator handoff

* refactor: remove runtime transcript file bridges

* refactor: remove transcript file handoffs

* refactor: remove MSTeams legacy learning key fallback

* refactor: store model catalog config in sqlite

* refactor: use sqlite model catalog at runtime

# Conflicts:
#	docs/cli/secrets.md
#	docs/gateway/authentication.md
#	docs/gateway/secrets.md

* fix: keep oauth sibling sync sqlite-local

# Conflicts:
#	src/commands/onboard-auth.test.ts

* refactor: remove task session store maintenance

# Conflicts:
#	src/commands/tasks.ts

* refactor: keep diagnostics in state sqlite

* refactor: enforce database-first runtime state

* refactor: consolidate sqlite runtime state

* Show Codex subscription reset times in channel errors (#80456)

* fix(codex): refresh subscription limit resets

* fix(codex): format reset times for channels

* Update CHANGELOG with latest changes and fixes

Updated CHANGELOG with recent fixes and improvements.

* fix(codex): keep command load failures on codex surface

* fix(codex): format account rate limits as rows

* fix(codex): summarize account limits as usage status

* fix(codex): simplify account limit status

* test: tighten subagent announce queue assertion

* test: tighten session delete lifecycle assertions

* test: tighten cron ops assertions

* fix: track cron execution milestones

* test: tighten hermes secret assertions

* test: assert matrix sync store payloads

* test: assert config compat warnings

* fix(codex): align btw side thread semantics

* fix(codex): honor codex fallback blocking

* fix(agents): avoid Pi resource discovery stalls

* test: tighten codex event assertions

* test: tighten cron assertions

* Fix Codex app-server OAuth harness auth

* refactor: move agent runtime state toward piless

* refactor: move device and push state to sqlite

* refactor: move runtime json state imports to doctor

* refactor: finish database-first state migration

* chore: refresh generated sqlite db types

* refactor: clarify cron sqlite store keys

* refactor: remove stale file-backed shims

* refactor: bind codex runtime state by session id

* test: expect sqlite trajectory branch export

* refactor: rename session row helpers

* fix: keep legacy device identity import in doctor

* refactor: enforce database-first runtime state

* refactor: consolidate sqlite runtime state

* build: align pi contract wrappers

* chore: repair database-first rebase

* refactor: remove session file test contracts

* test: update gateway session expectations

* refactor: stop routing from session compatibility shadows

* refactor: stop persisting session route shadows

* refactor: use typed delivery context in clients

* refactor: stop echoing session route shadows

* refactor: repair embedded runner rebase imports

# Conflicts:
#	src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts

* refactor: align pi contract imports

* refactor: satisfy kysely sync helper guard

* refactor: remove file transcript bridge remnants

* refactor: remove session locator compatibility

* refactor: remove session file test contracts

* refactor: keep rebase database-first clean

* refactor: remove session file assumptions from e2e

* docs: clarify database-first goal state

* test: remove legacy store markers from sqlite runtime tests

* refactor: remove legacy store assumptions from runtime seams

* refactor: align sqlite runtime helper seams

* test: update memory recall sqlite audit mock

* refactor: align database-first runtime type seams

* test: clarify doctor cron legacy store names

* fix: preserve sqlite session route projections

* test: fix copilot token cache test syntax

* docs: update database-first proof status

* test: align database-first test fixtures

* docs: update database-first proof status

* refactor: clean extension database-first drift

* test: align agent session route proof

* test: clarify doctor legacy path fixtures

* chore: clean database-first changed checks

* chore: repair database-first rebase markers

* build: allow baileys git subdependency

* chore: repair exp-vfs rebase drift

* chore: finish exp-vfs rebase cleanup

* chore: satisfy rebase lint drift

* chore: fix qqbot rebase type seam

* chore: fix rebase drift leftovers

* fix: keep auth profile oauth secrets out of sqlite

* fix: repair rebase drift tests

* test: stabilize pairing request ordering

* test: use source manifests in plugin contract checks

* fix: restore gateway session metadata after rebase

* fix: repair database-first rebase drift

* fix: clean up database-first rebase fallout

* test: stabilize line quick reply receipt time

* fix: repair extension rebase drift

* test: keep transcript redaction tests sqlite-backed

* fix: carry injected transcript redaction through sqlite

* chore: clean database branch rebase residue

* fix: repair database branch CI drift

* fix: repair database branch CI guard drift

* fix: stabilize oauth tls preflight test

* test: align database branch fast guards

* test: repair build artifact boundary guards

* chore: clean changelog rebase markers

---------

Co-authored-by: pashpashpash <nik@vault77.ai>
Co-authored-by: Eva <eva@100yen.org>
Co-authored-by: stainlu <stainlu@newtype-ai.org>
Co-authored-by: Jason Zhou <jason.zhou.design@gmail.com>
Co-authored-by: Ruben Cuevas <hi@rubencu.com>
Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com>
Co-authored-by: Shakker <shakkerdroid@gmail.com>
Co-authored-by: Kaspre <36520309+Kaspre@users.noreply.github.com>
Co-authored-by: dataCenter430 <titan032000@gmail.com>
Co-authored-by: Kaspre <kaspre@gmail.com>
Co-authored-by: pandadev66 <nova.full.stack@outlook.com>
Co-authored-by: Eva <admin@100yen.org>
Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org>
Co-authored-by: Josh Lehman <josh@martian.engineering>
Co-authored-by: jeffjhunter <support@aipersonamethod.com>
2026-05-13 13:15:12 +01:00

2566 lines
81 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.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[][]; explicitSetPaths?: string[][] },
) => Promise<void>
>(async () => {});
const mockResolveSecretRefValue = vi.fn();
const mockReadBestEffortRuntimeConfigSchema = vi.fn();
function sourceBundledPluginTestEnv(): Record<string, string> {
return {
OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"),
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
};
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
writeConfigFile: (
cfg: OpenClawConfig,
options?: { unsetPaths?: string[][]; explicitSetPaths?: string[][] },
) => mockWriteConfigFile(cfg, options),
replaceConfigFile: (params: {
nextConfig: OpenClawConfig;
writeOptions?: { unsetPaths?: string[][]; explicitSetPaths?: string[][] };
}) => mockWriteConfigFile(params.nextConfig, params.writeOptions),
};
});
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args),
}));
vi.mock("../config/runtime-schema.js", () => ({
readBestEffortRuntimeConfigSchema: () => mockReadBestEffortRuntimeConfigSchema(),
}));
const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
const mockLog = defaultRuntime.log;
const mockError = defaultRuntime.error;
const mockExit = defaultRuntime.exit;
vi.mock("../runtime.js", async () => {
return mockRuntimeModule(
() => vi.importActual<typeof import("../runtime.js")>("../runtime.js"),
defaultRuntime,
);
});
function buildSnapshot(params: {
resolved: OpenClawConfig;
config: OpenClawConfig;
}): ConfigFileSnapshot {
return {
path: "/tmp/openclaw.json",
exists: true,
raw: JSON.stringify(params.resolved),
parsed: params.resolved,
sourceConfig: params.resolved,
resolved: params.resolved,
valid: true,
runtimeConfig: params.config,
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 writeTempJson5File(prefix: string, value: unknown): string {
const pathname = path.join(
os.tmpdir(),
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
);
fs.writeFileSync(pathname, JSON.stringify(value), "utf8");
return pathname;
}
function withRuntimeDefaults(resolved: OpenClawConfig): OpenClawConfig {
return {
...resolved,
agents: {
...resolved.agents,
defaults: {
model: "gpt-5.4",
} 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: {},
sourceConfig: {},
resolved: {},
valid: false,
runtimeConfig: {},
config: {},
issues: params.issues,
warnings: [],
legacyIssues: [],
};
}
function firstMockArg(mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } }): unknown {
const call = mock.mock.calls[0];
if (!call) {
throw new Error("expected mock to have at least one call");
}
return call[0];
}
function lastMockArg(mock: { mock: { calls: ReadonlyArray<ReadonlyArray<unknown>> } }): unknown {
const calls = mock.mock.calls;
const call = calls[calls.length - 1];
if (!call) {
throw new Error("expected mock to have at least one call");
}
return call[0];
}
function parseLastLogPayload(): unknown {
const raw = lastMockArg(mockLog);
expect(typeof raw).toBe("string");
return JSON.parse(String(raw)) as unknown;
}
async function runValidateJsonAndGetPayload() {
await expect(runConfigCommand(["config", "validate", "--json"])).rejects.toThrow("__exit__:1");
const raw = firstMockArg(mockLog);
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;
}>;
};
}
function firstWrittenConfig(): OpenClawConfig {
const written = firstMockArg(mockWriteConfigFile);
if (!written) {
throw new Error("expected written config");
}
return written as OpenClawConfig;
}
function firstWriteConfigOptions():
| { unsetPaths?: string[][]; explicitSetPaths?: string[][] }
| undefined {
return mockWriteConfigFile.mock.calls[0]?.[1];
}
function requireWriteOptions(): { unsetPaths?: string[][]; explicitSetPaths?: string[][] } {
const options = firstWriteConfigOptions();
if (!options) {
throw new Error("expected write options");
}
return options;
}
function expectLogIncludes(text: string) {
expect(mockLog.mock.calls.map((call) => String(call[0])).join("\n")).toContain(text);
}
function expectLogExcludes(text: string) {
expect(mockLog.mock.calls.map((call) => String(call[0])).join("\n")).not.toContain(text);
}
function expectErrorIncludes(text: string) {
expect(mockError.mock.calls.map((call) => String(call[0])).join("\n")).toContain(text);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be an object`);
}
return value as Record<string, unknown>;
}
function requireResolveSecretRefCall(index: number): [unknown, unknown] {
const call = mockResolveSecretRefValue.mock.calls[index];
if (!call) {
throw new Error(`expected SecretRef resolver call ${index}`);
}
return call as [unknown, unknown];
}
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(() => {
for (const [key, value] of Object.entries(sourceBundledPluginTestEnv())) {
vi.stubEnv(key, value);
}
vi.clearAllMocks();
resetRuntimeCapture();
mockReadBestEffortRuntimeConfigSchema.mockResolvedValue({
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
channels: {
type: "object",
properties: {
telegram: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
plugins: {
type: "object",
properties: {
entries: {
type: "object",
},
},
},
},
},
uiHints: {},
version: "test",
generatedAt: "2026-03-25T00:00:00.000Z",
});
mockExit.mockImplementation((code: number) => {
const errorMessages = mockError.mock.calls.map((call) => call.join(" ")).join("; ");
throw new Error(`__exit__:${code} - ${errorMessages}`);
});
mockResolveSecretRefValue.mockResolvedValue("resolved-secret");
});
afterEach(() => {
vi.unstubAllEnvs();
});
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 = firstWrittenConfig();
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("marks set paths explicit so default-equal writes persist", async () => {
const resolved: OpenClawConfig = {
channels: {
telegram: {
botToken: "tok-abc",
},
},
};
const runtimeMerged = {
...resolved,
channels: {
telegram: {
botToken: "tok-abc",
dmPolicy: "pairing",
},
},
} as OpenClawConfig;
setSnapshot(resolved, runtimeMerged);
await runConfigCommand(["config", "set", "channels.telegram.dmPolicy", "pairing"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
expect(requireWriteOptions().explicitSetPaths).toEqual([
["channels", "telegram", "dmPolicy"],
]);
});
it("marks object set paths explicit so nested default-equal writes persist", async () => {
const resolved: OpenClawConfig = {
channels: {
telegram: {
botToken: "tok-abc",
},
},
};
const runtimeMerged = {
...resolved,
channels: {
telegram: {
botToken: "tok-abc",
dmPolicy: "pairing",
},
},
} as OpenClawConfig;
setSnapshot(resolved, runtimeMerged);
await runConfigCommand([
"config",
"set",
"channels.telegram",
'{"botToken":"tok-abc","dmPolicy":"pairing"}',
"--strict-json",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
expect(requireWriteOptions().explicitSetPaths).toEqual([["channels", "telegram"]]);
});
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.4",
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 = firstWrittenConfig();
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("writes agents.defaults.videoGenerationModel.primary without disturbing sibling defaults", async () => {
const resolved: OpenClawConfig = {
agents: {
defaults: {
model: "openai/gpt-5.4",
imageGenerationModel: {
primary: "openai/gpt-image-1",
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"agents.defaults.videoGenerationModel.primary",
"qwen/wan2.6-t2v",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.agents?.defaults?.model).toBe("openai/gpt-5.4");
expect(written.agents?.defaults?.imageGenerationModel).toEqual({
primary: "openai/gpt-image-1",
});
expect(written.agents?.defaults?.videoGenerationModel).toEqual({
primary: "qwen/wan2.6-t2v",
});
});
it("normalizes retired Google Gemini model refs before writing config mutations", async () => {
const resolved: OpenClawConfig = {
agents: {
defaults: {
model: {
fallbacks: ["google/gemini-3-pro-preview"],
},
models: {
"google/gemini-3-pro-preview": { alias: "gemini" },
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"agents.defaults.model.primary",
"google/gemini-3-pro-preview",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.agents?.defaults?.model).toEqual({
primary: "google/gemini-3.1-pro-preview",
fallbacks: ["google/gemini-3.1-pro-preview"],
});
expect(written.agents?.defaults?.models).toEqual({
"google/gemini-3.1-pro-preview": { alias: "gemini" },
});
});
it("normalizes explicit model-map paths before writing config mutations", async () => {
const resolved: OpenClawConfig = {
agents: {
defaults: {
models: {
"google/gemini-3-pro-preview": {},
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"agents.defaults.models.google/gemini-3-pro-preview.alias",
"gemini",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.agents?.defaults?.models).toEqual({
"google/gemini-3.1-pro-preview": { alias: "gemini" },
});
expect(requireWriteOptions().explicitSetPaths).toEqual([
["agents", "defaults", "models", "google/gemini-3.1-pro-preview", "alias"],
]);
});
it("normalizes agent-list model refs before writing config mutations", async () => {
const resolved: OpenClawConfig = {
agents: {
list: [
{
id: "tester",
model: { primary: "google/gemini-3-pro-preview" },
models: {
"google/gemini-3-pro-preview": { alias: "gemini" },
},
},
],
},
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "gateway.port", "18790"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const agent = firstWrittenConfig().agents?.list?.[0];
expect(agent?.model).toEqual({ primary: "google/gemini-3.1-pro-preview" });
expect(agent?.models).toEqual({
"google/gemini-3.1-pro-preview": { alias: "gemini" },
});
});
it("normalizes provider catalog model refs before writing config mutations", async () => {
const resolved: OpenClawConfig = {
models: {
providers: {
google: {
api: "google-generative-ai",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
models: [
{
id: "google/gemini-3-pro-preview",
name: "Gemini 3 Pro",
contextWindow: 1_048_576,
maxTokens: 65_536,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
reasoning: true,
},
],
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "gateway.port", "18790"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
expect(firstWrittenConfig().models?.providers?.google?.models?.[0]?.id).toBe(
"google/gemini-3.1-pro-preview",
);
});
it("rejects plugin install record config updates", async () => {
await expect(
runConfigCommand([
"config",
"set",
'plugins.installs["openclaw-web-search"].spec',
'"@ollama/openclaw-web-search@0.2.2"',
"--strict-json",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("openclaw plugins install <spec>");
expectErrorIncludes("openclaw plugins update <plugin-id>");
});
it("rejects auto-managed meta.lastTouchedVersion config updates (#80849)", async () => {
await expect(
runConfigCommand([
"config",
"set",
"meta.lastTouchedVersion",
"BOGUS-NOT-A-VERSION",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("meta.lastTouchedVersion");
expectErrorIncludes("auto-managed");
});
it("rejects auto-managed meta.lastTouchedAt config updates (#80849)", async () => {
await expect(
runConfigCommand([
"config",
"set",
"meta.lastTouchedAt",
"1999-01-01T00:00:00.000Z",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("meta.lastTouchedAt");
expectErrorIncludes("auto-managed");
});
it("rejects auto-managed meta paths via config unset (#80849)", async () => {
await expect(runConfigCommand(["config", "unset", "meta.lastTouchedAt"])).rejects.toThrow(
"__exit__:1",
);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("meta.lastTouchedAt");
expectErrorIncludes("auto-managed");
});
it("rejects parent meta path mutations when payload merges an auto-managed child (#80849)", async () => {
await expect(
runConfigCommand([
"config",
"set",
"meta",
'{"lastTouchedVersion":"BOGUS-NOT-A-VERSION"}',
"--strict-json",
"--merge",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("meta.lastTouchedVersion");
expectErrorIncludes("auto-managed");
});
it("rejects parent meta path replacement that would clobber auto-managed children (#80849)", async () => {
await expect(
runConfigCommand([
"config",
"set",
"meta",
'{"lastTouchedAt":"1999-01-01T00:00:00.000Z"}',
"--strict-json",
"--replace",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("meta.lastTouchedAt");
expectErrorIncludes("auto-managed");
});
it("rejects config unset meta because deleting the parent removes auto-managed children (#80849)", async () => {
await expect(runConfigCommand(["config", "unset", "meta"])).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("meta.lastTouchedVersion");
expectErrorIncludes("auto-managed");
});
it("does not auto-managed-reject parent meta merges that leave the managed children alone (#80849)", async () => {
// The merge payload only references a non-auto-managed key; the auto-managed
// guard MUST NOT fire — otherwise a future schema-valid sibling of
// meta.lastTouched* would be collateral-rejected. Downstream layers (schema
// validator, etc.) may still legitimately reject this; we only care that the
// rejection was NOT from our auto-managed guard.
setSnapshot({}, {});
try {
await runConfigCommand([
"config",
"set",
"meta",
'{"unrelated":"x"}',
"--strict-json",
"--merge",
"--dry-run",
]);
} catch {
// Tolerated: any downstream rejection. Inspected below.
}
const errorMessages = mockError.mock.calls.map((call) => String(call[0])).join("\n");
expect(errorMessages).not.toContain("auto-managed");
});
it("rejects protected model map replacement unless explicitly requested", async () => {
const resolved: OpenClawConfig = {
agents: {
defaults: {
models: {
"openai/gpt-5.4": { alias: "GPT" },
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
},
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"agents.defaults.models",
'{"openai/gpt-5.4":{}}',
"--strict-json",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("Refusing to replace agents.defaults.models");
});
it("merges protected model map values with --merge", async () => {
const resolved: OpenClawConfig = {
agents: {
defaults: {
models: {
"openai/gpt-5.4": { alias: "GPT" },
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"agents.defaults.models",
'{"anthropic/claude-sonnet-4-6":{"alias":"Sonnet"}}',
"--strict-json",
"--merge",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.agents?.defaults?.models).toEqual({
"openai/gpt-5.4": { alias: "GPT" },
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
});
});
it("merges provider model arrays by id with --merge", async () => {
const resolved = {
models: {
providers: {
ollama: {
api: "ollama",
models: [
{ id: "llama3.2", name: "Llama 3.2", contextWindow: 131072 },
{ id: "qwen3", name: "Qwen 3" },
],
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"models.providers.ollama.models",
'[{"id":"llama3.2","name":"Llama 3.2 latest"},{"id":"gemma4","name":"Gemma 4"}]',
"--strict-json",
"--merge",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.models?.providers?.ollama?.models).toEqual([
{ id: "llama3.2", name: "Llama 3.2 latest", contextWindow: 131072 },
{ id: "qwen3", name: "Qwen 3" },
{ id: "gemma4", name: "Gemma 4" },
]);
});
it("drops gateway.auth.password when switching mode to token", async () => {
const resolved: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
token: "token-keep",
password: "password-drop", // pragma: allowlist secret
allowTailscale: true,
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "gateway.auth.mode", "token"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.gateway?.auth).toEqual({
mode: "token",
token: "token-keep",
allowTailscale: true,
});
expectLogIncludes("Removed inactive gateway.auth.password for gateway.auth.mode=token");
});
it("drops gateway.auth.token when switching mode to password", async () => {
const resolved: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "token-drop",
password: "password-keep", // pragma: allowlist secret
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "gateway.auth.mode", "password"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.gateway?.auth).toEqual({
mode: "password",
password: "password-keep", // pragma: allowlist secret
});
expectLogIncludes("Removed inactive gateway.auth.token for gateway.auth.mode=password");
});
it("applies mode-based credential cleanup using the final batch result", async () => {
const resolved: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
token: "token-keep",
password: "password-drop", // pragma: allowlist secret
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"gateway.auth.password","value":"password-updated"},{"path":"gateway.auth.mode","value":"token"}]',
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.gateway?.auth).toEqual({
mode: "token",
token: "token-keep",
});
expectLogIncludes("Removed inactive gateway.auth.password for gateway.auth.mode=token");
});
});
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();
expectLogIncludes("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");
expectErrorIncludes("config is invalid");
expectErrorIncludes("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: {},
sourceConfig: {},
valid: true,
config: {},
runtimeConfig: {},
issues: [],
warnings: [],
legacyIssues: [],
});
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
expectErrorIncludes("Config file not found:");
expect(mockLog).not.toHaveBeenCalled();
});
});
describe("config schema", () => {
it("prints the generated JSON schema as plain text", async () => {
const { computeBaseConfigSchemaResponse } = await import("../config/schema-base.js");
mockReadBestEffortRuntimeConfigSchema.mockResolvedValueOnce(
computeBaseConfigSchemaResponse({
generatedAt: "2026-03-25T00:00:00.000Z",
}),
);
await runConfigCommand(["config", "schema"]);
expect(mockExit).not.toHaveBeenCalled();
expect(mockError).not.toHaveBeenCalled();
expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1);
const payload = parseLastLogPayload() as {
properties?: Record<string, unknown>;
};
const gateway = payload.properties?.gateway as
| { properties?: Record<string, unknown> }
| undefined;
const gatewayPort = gateway?.properties?.port as
| { title?: string; description?: string }
| undefined;
expect(payload.properties?.$schema).toEqual({ type: "string" });
expect(gatewayPort?.title).toBe("Gateway Port");
expect(gatewayPort?.description).toContain("TCP port used by the gateway listener");
const channels = requireRecord(payload.properties?.channels, "schema channels");
expect(channels.title).toBe("Channels");
expect(channels.properties).toEqual({});
expect(channels.additionalProperties).toBe(true);
const plugins = requireRecord(payload.properties?.plugins, "schema plugins");
expect(plugins.title).toBe("Plugins");
expect(plugins.description).toContain("Plugin system controls");
const pluginProperties = requireRecord(plugins.properties, "schema plugin properties");
expect(requireRecord(pluginProperties.entries, "schema plugin entries").title).toBe(
"Plugin Entries",
);
});
it("falls back cleanly when best-effort schema loading returns channel-only data", async () => {
mockReadBestEffortRuntimeConfigSchema.mockResolvedValueOnce({
schema: {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: {
channels: {
type: "object",
properties: {
telegram: {
type: "object",
},
},
},
},
},
uiHints: {},
version: "test",
generatedAt: "2026-03-25T00:00:00.000Z",
});
await runConfigCommand(["config", "schema"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledTimes(1);
const payload = parseLastLogPayload() as {
properties?: Record<string, unknown>;
};
expect(payload.properties?.$schema).toEqual({ type: "string" });
const channels = requireRecord(payload.properties?.channels, "schema channels");
expect(channels.type).toBe("object");
expect(channels.properties).toEqual({ telegram: { type: "object" } });
expect(payload.properties?.plugins).toBeUndefined();
expect(mockError).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 = firstWrittenConfig();
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();
expectErrorIncludes('Could not parse "{bad" as JSON for --strict-json.');
expectErrorIncludes("For plain strings, omit --strict-json.");
});
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("rejects JSON5-only object syntax when strict parsing is enabled", async () => {
await expect(
runConfigCommand(["config", "set", "gateway.auth", "{mode:'token'}", "--strict-json"]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
});
it("accepts --strict-json with batch mode and applies batch payload", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"gateway.auth.mode","value":"token"}]',
"--strict-json",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.gateway?.auth).toEqual({ mode: "token" });
});
it("shows --strict-json and keeps --json as a legacy alias in help", () => {
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() ?? "";
const configHelpText = configCommand?.helpInformation() ?? "";
expect(configHelpText).toContain("get/set/patch/unset/file/schema/validate");
expect(configHelpText).not.toContain("get/set/apply/unset/file/schema/validate");
expect(helpText).toContain("--strict-json");
expect(helpText).toContain("--json");
expect(helpText).toContain("Legacy alias for --strict-json");
expect(helpText).toContain("Value (JSON/JSON5 or raw string)");
expect(helpText).toContain("Strict JSON parsing (error instead of");
expect(helpText).toContain("--ref-provider");
expect(helpText).toContain("--provider-source");
expect(helpText).toContain("--batch-json");
expect(helpText).toContain("--dry-run");
expect(helpText).toContain("--allow-exec");
expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json");
expect(helpText).toContain(
"openclaw config set channels.discord.token --ref-provider default --ref-source",
);
expect(helpText).toContain("--ref-id DISCORD_BOT_TOKEN");
expect(helpText).toContain(
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
);
});
});
describe("config set builders and dry-run", () => {
it("supports SecretRef builder mode without requiring a value argument", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.channels?.discord?.token).toEqual({
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
});
});
it("fails early when unsupported mutable paths are assigned SecretRef objects (builder mode)", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"HOOK_TOKEN",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("Config policy validation failed: unsupported SecretRef usage");
expectErrorIncludes("hooks.token");
});
it("fails early when parent-object writes include unsupported SecretRef objects", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks",
'{"token":{"source":"env","provider":"default","id":"HOOK_TOKEN"}}',
"--strict-json",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("Config policy validation failed: unsupported SecretRef usage");
expectErrorIncludes("hooks.token");
});
it("supports provider builder mode under secrets.providers.<alias>", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"secrets.providers.vaultfile",
"--provider-source",
"file",
"--provider-path",
"/tmp/vault.json",
"--provider-mode",
"json",
"--provider-allow-insecure-path",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.secrets?.providers?.vaultfile).toEqual({
source: "file",
path: "/tmp/vault.json",
mode: "json",
allowInsecurePath: true,
});
});
it("runs resolvability checks in builder dry-run mode without writing", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
expect(secretRef).toEqual({
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
});
expect(requireRecord(resolveOptions, "resolve options").env).toBeTypeOf("object");
});
it("requires schema validation in JSON dry-run mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"gateway.port",
'"not-a-number"',
"--strict-json",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("Dry run failed: config schema validation failed.");
});
it("fails dry-run when unsupported mutable paths receive SecretRef objects in value/json mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks.token",
'{"source":"env","provider":"default","id":"HOOK_TOKEN"}',
"--strict-json",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("Dry run failed: config schema validation failed.");
expectErrorIncludes("hooks.token");
});
it("aggregates policy failures across batch entries", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"hooks.token","ref":{"source":"env","provider":"default","id":"HOOK_TOKEN"}},{"path":"commands.ownerDisplaySecret","ref":{"source":"env","provider":"default","id":"OWNER_DISPLAY_SECRET"}}]',
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expectErrorIncludes("hooks.token");
expectErrorIncludes("commands.ownerDisplaySecret");
});
it("does not duplicate policy errors in --dry-run --json mode for parent-object writes", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks",
'{"token":{"source":"env","provider":"default","id":"HOOK_TOKEN"}}',
"--strict-json",
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
const payload = parseLastLogPayload() as {
ok: boolean;
checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean };
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
expect(payload.checks.schema).toBe(true);
const hooksTokenErrors =
payload.errors?.filter(
(entry) => entry.kind === "schema" && entry.message.includes("hooks.token"),
) ?? [];
expect(hooksTokenErrors).toHaveLength(1);
});
it("logs a dry-run note when value mode performs no validation checks", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "set", "gateway.port", "19001", "--dry-run"]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expectLogIncludes("Dry run note: value mode does not run schema/resolvability checks.");
expectLogIncludes("Dry run successful: 1 update(s) validated");
});
it("supports batch mode for refs/providers in dry-run", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]',
"--dry-run",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
});
it("skips exec SecretRef resolvability checks in dry-run by default", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "exec",
command: "/usr/bin/env",
allowInsecurePath: true,
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expectLogIncludes(
"Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec",
);
});
it("allows exec SecretRef resolvability checks in dry-run when --allow-exec is set", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "exec",
command: "/usr/bin/env",
allowInsecurePath: true,
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
"--allow-exec",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
const secretRefRecord = requireRecord(secretRef, "exec SecretRef");
expect(secretRefRecord.source).toBe("exec");
expect(secretRefRecord.provider).toBe("runner");
expect(secretRefRecord.id).toBe("openai");
expect(resolveOptions).toBeTypeOf("object");
expectLogExcludes("Dry run note: skipped 1 exec SecretRef resolvability check(s).");
});
it("rejects --allow-exec without --dry-run", async () => {
const nonexistentBatchPath = path.join(
os.tmpdir(),
`openclaw-config-batch-nonexistent-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
await expect(
runConfigCommand(["config", "set", "--batch-file", nonexistentBatchPath, "--allow-exec"]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expectErrorIncludes("config set mode error: --allow-exec requires --dry-run.");
});
it("fails dry-run when skipped exec refs use an unconfigured provider", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expectErrorIncludes('Secret provider "runner" is not configured');
});
it("fails dry-run when skipped exec refs use a provider with mismatched source", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "env",
},
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expectErrorIncludes('Secret provider "runner" has source "env" but ref requests "exec".');
});
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
channels: {
googlechat: {
enabled: true,
} as never,
} as never,
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.googlechat.serviceAccount",
"--ref-provider",
"vaultfile",
"--ref-source",
"file",
"--ref-id",
"/providers/googlechat/serviceAccount",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.channels?.googlechat?.serviceAccountRef).toEqual({
source: "file",
provider: "vaultfile",
id: "/providers/googlechat/serviceAccount",
});
expect(written.channels?.googlechat?.serviceAccount).toBeUndefined();
});
it("rejects mixing ref-builder and provider-builder flags", async () => {
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--provider-source",
"env",
]),
).rejects.toThrow("__exit__:1");
expectErrorIncludes("config set mode error: choose exactly one mode");
});
it("rejects mixing batch mode with builder flags", async () => {
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
"[]",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
]),
).rejects.toThrow("__exit__:1");
expectErrorIncludes(
"config set mode error: batch mode (--batch-json/--batch-file) cannot be combined",
);
});
it("supports batch-file mode", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-batch-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.writeFileSync(pathname, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
try {
await runConfigCommand(["config", "set", "--batch-file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.gateway?.auth).toEqual({ mode: "token" });
});
it("batch-file nested leaf updates preserve agents defaults and list siblings", async () => {
const resolved: OpenClawConfig = {
agents: {
defaults: {
models: {
"openai/gpt-5.4": { alias: "GPT" },
},
model: { primary: "openai/gpt-5.4" },
},
list: [{ id: "main" }, { id: "ops" }],
},
plugins: {
entries: {
"github-copilot": { enabled: true },
},
},
};
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-memory-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.writeFileSync(
pathname,
JSON.stringify([
{ path: "agents.defaults.memorySearch.enabled", value: true },
{ path: "agents.defaults.memorySearch.provider", value: "gemini" },
{ path: "agents.defaults.memorySearch.sources", value: ["memory"] },
]),
"utf8",
);
try {
await runConfigCommand(["config", "set", "--batch-file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.agents?.defaults?.models).toEqual(resolved.agents?.defaults?.models);
expect(written.agents?.defaults?.model).toEqual(resolved.agents?.defaults?.model);
expect(written.agents?.defaults?.memorySearch).toEqual({
enabled: true,
provider: "gemini",
sources: ["memory"],
});
expect(written.agents?.list).toEqual(resolved.agents?.list);
expect(written.plugins).toEqual(resolved.plugins);
});
it("rejects malformed batch-file payloads", async () => {
const pathname = path.join(
os.tmpdir(),
`openclaw-config-batch-invalid-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.writeFileSync(pathname, '{"path":"gateway.auth.mode","value":"token"}', "utf8");
try {
await expect(runConfigCommand(["config", "set", "--batch-file", pathname])).rejects.toThrow(
"__exit__:1",
);
} finally {
fs.rmSync(pathname, { force: true });
}
expectErrorIncludes("--batch-file must be a JSON array.");
});
it("patches config from one object in one write", async () => {
const resolved = {
secrets: {
providers: {
default: { source: "env" },
},
},
agents: {
defaults: {
models: {
"openai/gpt-5.4": { alias: "GPT 5.4" },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-patch-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
);
fs.writeFileSync(
pathname,
JSON.stringify({
channels: {
slack: {
enabled: true,
mode: "socket",
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
groupPolicy: "open",
requireMention: false,
},
discord: {
enabled: true,
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
groupPolicy: "allowlist",
},
},
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": { params: { fastMode: true } },
},
},
},
}),
"utf8",
);
try {
await runConfigCommand(["config", "patch", "--file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig() as Record<string, unknown>;
expect(
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>).models,
).toEqual({
"openai/gpt-5.4": { alias: "GPT 5.4" },
"openai/gpt-5.5": { params: { fastMode: true } },
});
expect(
(
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>)
.model as Record<string, unknown>
).primary,
).toBe("openai/gpt-5.5");
expect(
((written.channels as Record<string, unknown>).slack as Record<string, unknown>).botToken,
).toEqual({ source: "env", provider: "default", id: "SLACK_BOT_TOKEN" });
expect(
((written.channels as Record<string, unknown>).discord as Record<string, unknown>).token,
).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
});
it("preserves empty object values in config patch", async () => {
const resolved = {
agents: {
defaults: {
models: {
"openai/gpt-5.4": { alias: "GPT 5.4" },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = writeTempJson5File("openclaw-config-patch-empty-object", {
agents: {
defaults: {
models: {
"openai/gpt-5.5": {},
},
},
},
});
try {
await runConfigCommand(["config", "patch", "--file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
const written = firstWrittenConfig() as Record<string, unknown>;
expect(
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>).models,
).toEqual({
"openai/gpt-5.4": { alias: "GPT 5.4" },
"openai/gpt-5.5": {},
});
});
it("treats empty object config patches as recursive merges", async () => {
const resolved = {
channels: {
slack: {
enabled: true,
mode: "socket",
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = writeTempJson5File("openclaw-config-patch-empty-merge", {
channels: {
slack: {},
},
});
try {
await runConfigCommand(["config", "patch", "--file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
const written = firstWrittenConfig() as Record<string, unknown>;
expect((written.channels as Record<string, unknown>).slack).toEqual({
enabled: true,
mode: "socket",
});
});
it("dry-runs config patch and resolves changed SecretRefs", async () => {
const resolved = {
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-patch-dry-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
);
fs.writeFileSync(
pathname,
JSON.stringify({
channels: {
discord: {
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
}),
"utf8",
);
try {
await runConfigCommand(["config", "patch", "--file", pathname, "--dry-run"]);
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
expect(secretRef).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
expect(resolveOptions).toBeTypeOf("object");
});
it("schema-validates SecretRef-only config patch operations", async () => {
const resolved = {
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-patch-ref-schema-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}.json5`,
);
fs.writeFileSync(
pathname,
JSON.stringify({
gateway: {
typo: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
}),
"utf8",
);
try {
await expect(
runConfigCommand(["config", "patch", "--file", pathname, "--dry-run"]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expectErrorIncludes("Dry run failed: config schema validation failed.");
expectErrorIncludes("gateway");
expectErrorIncludes('"typo"');
});
it("dry-runs nested SecretRefs inside config patch replacements", async () => {
const resolved = {
secrets: {
providers: {
default: { source: "env" },
},
},
channels: {
slack: {
enabled: false,
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
const pathname = path.join(
os.tmpdir(),
`openclaw-config-patch-nested-ref-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}.json5`,
);
fs.writeFileSync(
pathname,
JSON.stringify({
channels: {
slack: {
enabled: true,
mode: "socket",
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
},
},
}),
"utf8",
);
try {
await expect(
runConfigCommand([
"config",
"patch",
"--file",
pathname,
"--replace-path",
"channels.slack",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(2);
expectErrorIncludes("Dry run failed: 2 SecretRef assignment(s) could not be resolved.");
});
it("rejects config patch --json without dry-run", async () => {
await expect(runConfigCommand(["config", "patch", "--stdin", "--json"])).rejects.toThrow(
"__exit__:1",
);
expectErrorIncludes("config patch mode error: --json requires --dry-run.");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("supports replace-path and null deletes in config patch", async () => {
const resolved = {
channels: {
slack: {
appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" },
},
discord: {
guilds: {
guild: {
channels: {
old: { enabled: true },
},
},
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-patch-replace-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
);
fs.writeFileSync(
pathname,
JSON.stringify({
channels: {
slack: {
appToken: null,
},
discord: {
guilds: {
guild: {
channels: {
maintainers: { enabled: true, requireMention: true },
},
},
},
},
},
}),
"utf8",
);
try {
await runConfigCommand([
"config",
"patch",
"--file",
pathname,
"--replace-path",
"channels.discord.guilds.guild.channels",
]);
} finally {
fs.rmSync(pathname, { force: true });
}
const written = firstWrittenConfig() as Record<string, unknown>;
const channels = (written.channels as Record<string, unknown>).discord as Record<
string,
unknown
>;
expect(
((channels.guilds as Record<string, unknown>).guild as Record<string, unknown>)
.channels as Record<string, unknown>,
).toEqual({ maintainers: { enabled: true, requireMention: true } });
expect((written.channels as Record<string, unknown>).slack).not.toHaveProperty("appToken");
expect(requireWriteOptions().unsetPaths).toEqual([["channels", "slack", "appToken"]]);
});
it("rejects unused config patch replace paths", async () => {
const pathname = path.join(
os.tmpdir(),
`openclaw-config-patch-unused-replace-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}.json5`,
);
fs.writeFileSync(
pathname,
JSON.stringify({
channels: {
discord: {
enabled: true,
},
},
}),
"utf8",
);
try {
await expect(
runConfigCommand([
"config",
"patch",
"--file",
pathname,
"--replace-path",
"channels.discord.guilds",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(pathname, { force: true });
}
expectErrorIncludes(
"config patch mode error: --replace-path channels.discord.guilds did not match any value in the input patch.",
);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("rejects malformed batch entries with mixed operation keys", async () => {
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"channels.discord.token","value":"x","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
]),
).rejects.toThrow("__exit__:1");
expectErrorIncludes("must include exactly one of: value, ref, provider");
});
it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expectErrorIncludes("Dry run failed: 1 SecretRef assignment(s) could not be resolved.");
});
it("emits structured JSON for --dry-run --json success", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
"--json",
]);
const payload = parseLastLogPayload() as {
ok: boolean;
checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean };
refsChecked: number;
skippedExecRefs: number;
operations: number;
};
expect(payload.ok).toBe(true);
expect(payload.operations).toBe(1);
expect(payload.refsChecked).toBe(1);
expect(payload.skippedExecRefs).toBe(0);
expect(payload.checks).toEqual({
schema: false,
resolvability: true,
resolvabilityComplete: true,
});
});
it("emits skipped exec metadata for --dry-run --json success", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "exec",
command: "/usr/bin/env",
allowInsecurePath: true,
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
"--json",
]);
const payload = parseLastLogPayload() as {
ok: boolean;
checks: { resolvability: boolean; resolvabilityComplete: boolean };
refsChecked: number;
skippedExecRefs: number;
};
expect(payload.ok).toBe(true);
expect(payload.checks.resolvability).toBe(true);
expect(payload.checks.resolvabilityComplete).toBe(false);
expect(payload.refsChecked).toBe(0);
expect(payload.skippedExecRefs).toBe(1);
});
it("emits structured JSON for --dry-run --json failure", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
const payload = parseLastLogPayload() as {
ok: boolean;
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
const errorKinds = (payload.errors ?? []).map((entry) => entry.kind);
expect(errorKinds).toContain("resolvability");
const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? "");
expect(errorRefs).toContain("env:default:DISCORD_BOT_TOKEN");
});
it("keeps distinct resolvability failures when messages are identical but refs differ", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"channels.discord.token","ref":{"source":"exec","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"channels.telegram.botToken","ref":{"source":"exec","provider":"default","id":"TELEGRAM_BOT_TOKEN"}}]',
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
const payload = parseLastLogPayload() as {
ok: boolean;
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
const resolvabilityErrors =
payload.errors?.filter((entry) => entry.kind === "resolvability") ?? [];
expect(resolvabilityErrors).toHaveLength(2);
expect(
resolvabilityErrors.some((entry) => entry.ref === "exec:default:DISCORD_BOT_TOKEN"),
).toBe(true);
expect(
resolvabilityErrors.some((entry) => entry.ref === "exec:default:TELEGRAM_BOT_TOKEN"),
).toBe(true);
});
it("aggregates schema and resolvability failures in --dry-run --json mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"gateway.port","value":"not-a-number"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
const payload = parseLastLogPayload() as {
ok: boolean;
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
const errorKinds = (payload.errors ?? []).map((entry) => entry.kind);
expect(errorKinds).toContain("schema");
expect(errorKinds).toContain("resolvability");
const errorRefs = (payload.errors ?? []).map((entry) => entry.ref ?? "");
expect(errorRefs).toContain("env:default:DISCORD_BOT_TOKEN");
});
it("fails dry-run when provider updates make existing refs unresolvable", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" },
},
},
tools: {
web: {
search: {
enabled: true,
apiKey: {
source: "file",
provider: "vaultfile",
id: "/providers/search/apiKey",
},
},
},
} as never,
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockImplementationOnce(async () => {
throw new Error("provider mismatch");
});
await expect(
runConfigCommand([
"config",
"set",
"secrets.providers.vaultfile",
"--provider-source",
"env",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expectErrorIncludes("Dry run failed: 1 SecretRef assignment(s) could not be resolved.");
expectErrorIncludes("provider mismatch");
});
it("fails dry-run for nested provider edits that make existing refs unresolvable", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" },
},
},
tools: {
web: {
search: {
enabled: true,
apiKey: {
source: "file",
provider: "vaultfile",
id: "/providers/search/apiKey",
},
},
},
} as never,
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockImplementationOnce(async () => {
throw new Error("provider mismatch");
});
await expect(
runConfigCommand([
"config",
"set",
"secrets.providers.vaultfile.path",
'"/tmp/other-secrets.json"',
"--strict-json",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
const [secretRef, resolveOptions] = requireResolveSecretRefCall(0);
const secretRefRecord = requireRecord(secretRef, "existing SecretRef");
expect(secretRefRecord.provider).toBe("vaultfile");
expect(secretRefRecord.id).toBe("/providers/search/apiKey");
expect(resolveOptions).toBeTypeOf("object");
expectErrorIncludes("Dry run failed: 1 SecretRef assignment(s) could not be resolved.");
expectErrorIncludes("provider mismatch");
});
});
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 = firstWrittenConfig();
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(firstWriteConfigOptions()).toEqual({
unsetPaths: [["tools", "alsoAllow"]],
});
});
it("removes only the specified array element", async () => {
const resolved: OpenClawConfig = {
agents: {
list: [{ id: "agent-a" }, { id: "agent-b" }, { id: "agent-c" }],
},
};
const runtimeMerged: OpenClawConfig = {
...withRuntimeDefaults(resolved),
};
setSnapshot(resolved, runtimeMerged);
await runConfigCommand(["config", "unset", "agents.list[1]"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig();
expect(written.agents?.list).toEqual([{ id: "agent-a" }, { id: "agent-c" }]);
expect(firstWriteConfigOptions()).toBeUndefined();
});
it("preserves write-level unset handling for numeric object keys", async () => {
const resolved: OpenClawConfig = {
channels: {
discord: {
guilds: {
"123": { channels: ["general"] },
"456": { channels: ["alerts"] },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "unset", "channels.discord.guilds.123"]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig() as {
channels?: { discord?: { guilds?: Record<string, unknown> } };
};
expect(written.channels?.discord?.guilds).toEqual({
"456": { channels: ["alerts"] },
});
expect(firstWriteConfigOptions()).toEqual({
unsetPaths: [["channels", "discord", "guilds", "123"]],
});
});
});
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");
});
});
});