openclaw / src /cli /program /config-guard.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
38.3 kB
// Config guard tests cover program-level config checks before command execution.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { note } from "../../../packages/terminal-core/src/note.js";
import type { ConfigSnapshotReadMeasure } from "../../config/io.js";
import { getGatewayPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-state.js";
import {
adoptProcessPluginCache,
createPluginCache,
getProcessPluginCache,
withPluginCache,
} from "../../plugins/plugin-cache.js";
import { createPluginMetadataSnapshotFixture } from "../../plugins/plugin-metadata.test-support.js";
import { ExitError } from "../../runtime.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
import { VERSION } from "../../version.js";
import { formatCliCommand } from "../command-format.js";
import { ensureConfigReady, testApi } from "./config-guard.js";
const pluginPackagingRecoveryHint = [
"This is a plugin packaging issue, not a local config problem.",
"Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.",
].join("\n");
const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const setRuntimeConfigSnapshotMock = vi.hoisted(() => vi.fn());
vi.mock("../../commands/doctor-config-preflight.js", () => ({
runDoctorConfigPreflight: loadAndMaybeMigrateDoctorConfigMock,
}));
vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
setRuntimeConfigSnapshot: setRuntimeConfigSnapshotMock,
}));
type ConfigIssue = { path: string; pathSegments?: Array<string | number>; message: string };
function makeSnapshot() {
return {
exists: false,
valid: true,
raw: null as string | null,
parsed: {},
sourceConfig: {},
issues: [] as ConfigIssue[],
warnings: [] as ConfigIssue[],
legacyIssues: [] as ConfigIssue[],
path: "/tmp/openclaw.json",
};
}
function makeRuntime() {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
function plainErrorCalls(runtime: ReturnType<typeof makeRuntime>): string[] {
const ansiPattern = new RegExp(String.raw`\u001b\[[0-9;]*m`, "g");
return runtime.error.mock.calls.map((call) => String(call[0]).replace(ansiPattern, ""));
}
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
const writes: string[] = [];
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((
chunk: unknown,
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
) => {
writes.push(String(chunk));
const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
done?.();
return true;
}) as typeof process.stdout.write);
try {
await run();
return writes.join("");
} finally {
writeSpy.mockRestore();
}
}
describe("ensureConfigReady", () => {
const resetConfigGuardStateForTests = testApi.resetConfigGuardStateForTests;
const tempRoots: string[] = [];
let envSnapshot: ReturnType<typeof captureEnv> | undefined;
let processCache: ReturnType<typeof getProcessPluginCache>;
let preflightCache: ReturnType<typeof createPluginCache>;
let preflightMetadata: ReturnType<typeof createPluginMetadataSnapshotFixture>;
async function runEnsureConfigReady(commandPath: string[], suppressDoctorStdout = false) {
const runtime = makeRuntime();
await ensureConfigReady({ runtime: runtime as never, commandPath, suppressDoctorStdout });
return runtime;
}
function setInvalidSnapshot(overrides?: Partial<ReturnType<typeof makeSnapshot>>) {
const snapshot = {
...makeSnapshot(),
exists: true,
valid: false,
issues: [{ path: "channels.quietchat", message: "invalid" }],
...overrides,
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
snapshot,
baseConfig: {},
pluginMetadataSnapshot: preflightMetadata,
});
return snapshot;
}
function useTempOpenClawHome(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-"));
tempRoots.push(root);
setTestEnvValue("OPENCLAW_HOME", root);
deleteTestEnvValue("OPENCLAW_NIX_MODE");
deleteTestEnvValue("OPENCLAW_CONFIG_READONLY");
deleteTestEnvValue("OPENCLAW_PROFILE");
deleteTestEnvValue("OPENCLAW_STATE_DIR");
return root;
}
function writeLegacyTaskSidecarMarker(root: string): void {
const markerPath = path.join(root, ".openclaw", "tasks", "runs.sqlite");
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, "");
}
function writePendingTaskSidecarArchiveMarker(root: string): void {
const markerPath = path.join(root, ".openclaw", "tasks", "runs.sqlite");
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(`${markerPath}.migrated`, "");
fs.writeFileSync(`${markerPath}-wal`, "");
}
function writeStateMarker(root: string, relativePath: string): void {
const markerPath = path.join(root, ".openclaw", relativePath);
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
fs.writeFileSync(markerPath, "{}");
}
beforeEach(() => {
processCache = getProcessPluginCache();
preflightCache = createPluginCache();
preflightMetadata = withPluginCache(preflightCache, createPluginMetadataSnapshotFixture);
envSnapshot = captureEnv([
"HOME",
"OPENCLAW_HOME",
"OPENCLAW_NIX_MODE",
"OPENCLAW_CONFIG_READONLY",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
]);
vi.clearAllMocks();
resetConfigGuardStateForTests();
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
useTempOpenClawHome();
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => ({
snapshot: makeSnapshot(),
baseConfig: {},
pluginMetadataSnapshot: preflightMetadata,
}));
});
afterEach(() => {
adoptProcessPluginCache(processCache);
envSnapshot?.restore();
envSnapshot = undefined;
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
it.each([
["skips doctor flow for status task reads without legacy state", ["status"], 0],
["skips doctor flow for update status", ["update", "status"], 0],
["skips doctor flow for health", ["health"], 0],
["skips doctor flow for logs", ["logs"], 0],
["skips doctor flow for sessions", ["sessions"], 0],
["skips doctor flow for remote gateway calls", ["gateway", "call"], 0],
["skips doctor flow for gateway restart control", ["gateway", "restart"], 0],
["skips doctor flow for legacy daemon restart control", ["daemon", "restart"], 0],
["skips doctor flow for config set", ["config", "set"], 0],
["skips doctor flow for config patch", ["config", "patch"], 0],
["skips doctor flow for config get", ["config", "get"], 0],
["skips doctor flow for config unset", ["config", "unset"], 0],
["skips doctor flow for agent without legacy state", ["agent"], 0],
["skips doctor flow for plugin listing without legacy state", ["plugins", "list"], 0],
["runs doctor flow for commands that may mutate state without legacy state", ["message"], 1],
["runs doctor flow for unknown commands", ["unknown-command"], 1],
["runs doctor flow when the command path is empty", [], 1],
])("%s", async (_name, commandPath, expectedDoctorCalls) => {
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
expect(getProcessPluginCache()).toBe(processCache);
if (expectedDoctorCalls > 0) {
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStateMigrationCheckpoint: true,
});
}
});
it("keeps status config guard reads non-observing", async () => {
await runEnsureConfigReady(["status"]);
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({ observe: false });
});
it("keeps logs config guard reads non-observing and independent of plugin state", async () => {
await runEnsureConfigReady(["logs"]);
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({
observe: false,
pluginValidation: "core-only",
});
});
it("validates config without observing health, plugins, or startup migrations", async () => {
await ensureConfigReady({
runtime: makeRuntime() as never,
commandPath: ["nodes", "approve"],
validateConfigOnly: true,
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({
observe: false,
pluginValidation: "core-only",
});
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
});
it("keeps remote gateway call config reads non-observing", async () => {
await runEnsureConfigReady(["gateway", "call"]);
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({ observe: false });
});
it("runs doctor flow when lightweight startup detection finds legacy state", async () => {
const root = useTempOpenClawHome();
writeLegacyTaskSidecarMarker(root);
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
requireStateMigrationCheckpoint: true,
});
});
it("keeps remote gateway calls from migrating existing local legacy state", async () => {
const root = useTempOpenClawHome();
writeLegacyTaskSidecarMarker(root);
await runEnsureConfigReady(["gateway", "call"]);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
});
it.each([
["gateway", "restart"],
["daemon", "restart"],
])("keeps %s control from migrating existing local legacy state", async (command, action) => {
const root = useTempOpenClawHome();
writeLegacyTaskSidecarMarker(root);
await runEnsureConfigReady([command, action]);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({ observe: false });
});
it("keeps logs from migrating existing local legacy state", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "cron/runs/legacy-job.jsonl");
await runEnsureConfigReady(["logs"]);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(root, ".openclaw", "cron/runs/legacy-job.jsonl"))).toBe(true);
});
it.each(["restart-sentinel.json", "restart-sentinel.json.doctor-importing"])(
"runs doctor flow when lightweight startup detection finds %s",
async (relativePath) => {
const root = useTempOpenClawHome();
writeStateMarker(root, relativePath);
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
requireStateMigrationCheckpoint: true,
});
},
);
it("runs doctor flow when lightweight startup detection finds a pending SQLite archive", async () => {
const root = useTempOpenClawHome();
writePendingTaskSidecarArchiveMarker(root);
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
requireStateMigrationCheckpoint: true,
});
});
it.each([
[["gateway"], false],
[["gateway"], true],
[["gateway", "run"], false],
[["gateway", "run"], true],
])(
"retains accepted startup facts for %j (suppressed: %s)",
async (commandPath, suppressDoctorStdout) => {
await runEnsureConfigReady(commandPath, suppressDoctorStdout);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStartupMigrationCheckpoint: true,
validateStartupConfig: expect.any(Function),
});
expect(getProcessPluginCache() === preflightCache).toBe(true);
// Cache reuse must not freeze the Gateway inventory before its final config read.
expect(getGatewayPluginMetadataSnapshot()).toBeUndefined();
},
);
it("keeps the process owner when accepted config preparation fails", async () => {
const error = new Error("runtime config preparation failed");
setRuntimeConfigSnapshotMock.mockImplementationOnce(() => {
throw error;
});
await expect(runEnsureConfigReady(["gateway", "run"])).rejects.toThrow(error);
expect(getProcessPluginCache()).toBe(processCache);
});
it("honors a deferred migration exit after preflight resources unwind", async () => {
let preflightUnwound = false;
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
try {
throw new ExitError(78);
} finally {
preflightUnwound = true;
}
});
const runtime = makeRuntime();
runtime.exit.mockImplementation(() => {
expect(preflightUnwound).toBe(true);
});
await expect(
ensureConfigReady({ runtime: runtime as never, commandPath: ["gateway"] }),
).rejects.toMatchObject({ name: "ExitError", code: 78 });
expect(runtime.exit).toHaveBeenCalledWith(78);
expect(getProcessPluginCache()).toBe(processCache);
});
it("uses only the state migration checkpoint for gateway probes", async () => {
await runEnsureConfigReady(["gateway", "health"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStateMigrationCheckpoint: true,
});
expect(getProcessPluginCache()).toBe(processCache);
});
it("runs doctor flow for legacy sessions without task sidecars", async () => {
const root = useTempOpenClawHome();
fs.mkdirSync(path.join(root, ".openclaw", "sessions"), { recursive: true });
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("runs doctor flow before agent commands when the legacy plugin install index exists", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "plugins/installs.json");
await runEnsureConfigReady(["agent"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStateMigrationCheckpoint: true,
});
});
it("checkpoints migration discovery for established canonical agent state", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "agents/main/sessions/sessions.json");
await runEnsureConfigReady(["agent"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStateMigrationCheckpoint: true,
});
});
it("preserves plugin listing migrations when the legacy plugin install index exists", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "plugins/installs.json");
const migratedSnapshot = {
...makeSnapshot(),
config: { plugins: { entries: { legacy: { enabled: true } } } },
runtimeConfig: { plugins: { entries: { legacy: { enabled: true } } } },
sourceConfig: { plugins: { entries: { legacy: { enabled: true } } } },
};
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
snapshot: migratedSnapshot,
baseConfig: {},
});
await runEnsureConfigReady(["plugins", "list"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
requireStateMigrationCheckpoint: true,
});
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
migratedSnapshot.runtimeConfig,
migratedSnapshot.sourceConfig,
);
});
it("preserves plugin listing migrations when the shared state database exists", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "state/openclaw.sqlite");
await runEnsureConfigReady(["plugins", "list"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it.each([
[["agent"], "exec-approvals.json"],
[["status"], "plugin-binding-approvals.json"],
[["plugins", "list"], "exec-approvals.json"],
[["tasks", "list"], "plugin-binding-approvals.json"],
])("while %j uses custom state, ignores default-state %s", async (commandPath, source) => {
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
writeStateMarker(root, source);
const sourcePath = path.join(root, ".openclaw", source);
const sourceRaw = fs.readFileSync(sourcePath, "utf8");
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(false);
});
it("keeps named profiles isolated from default-profile approval migrations", async () => {
const root = useTempOpenClawHome();
setTestEnvValue("OPENCLAW_PROFILE", "work");
setTestEnvValue("OPENCLAW_STATE_DIR", path.join(root, ".openclaw-work"));
writeStateMarker(root, "exec-approvals.json");
writeStateMarker(root, "plugin-binding-approvals.json");
await runEnsureConfigReady(["agent"]);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
});
it.each([
["Discord model picker preferences", "discord/model-picker-preferences.json"],
["Discord thread bindings", "discord/thread-bindings.json"],
["Telegram bot info cache", "telegram/bot-info-default.json"],
["Telegram update offset", "telegram/update-offset-default.json"],
["Telegram sticker cache", "telegram/sticker-cache.json"],
["Telegram thread bindings", "telegram/thread-bindings-default.json"],
["Telegram pairing allowFrom", "credentials/telegram-allowFrom.json"],
["iMessage reply short-id cache", "imessage/reply-cache.jsonl"],
["iMessage sent echo cache", "imessage/sent-echoes.jsonl"],
["iMessage catchup cursor", "imessage/catchup/default__37a8eec1ce19.json"],
["WhatsApp root auth", "credentials/creds.json"],
])("runs doctor flow for bundled channel legacy state: %s", async (_label, relativePath) => {
const root = useTempOpenClawHome();
writeStateMarker(root, relativePath);
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it("uses shared tilde expansion for OPENCLAW_HOME in the startup detector", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-guard-home-"));
tempRoots.push(root);
setTestEnvValue("HOME", root);
setTestEnvValue("OPENCLAW_HOME", "~/svc");
deleteTestEnvValue("OPENCLAW_STATE_DIR");
writeLegacyTaskSidecarMarker(path.join(root, "svc"));
await runEnsureConfigReady(["status"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
});
it.each([
["status", ["status"]],
["plugin listing", ["plugins", "list"]],
])(
"runs doctor flow for %s with configured custom session stores",
async (_name, commandPath) => {
const root = useTempOpenClawHome();
const customStore = path.join(root, "sessions", "sessions.json");
const snapshot = {
...makeSnapshot(),
config: { session: { store: customStore } },
runtimeConfig: { session: { store: customStore } },
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
snapshot,
baseConfig: {},
});
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
},
);
it("pins a valid preflight snapshot for command code reuse", async () => {
const snapshot = {
...makeSnapshot(),
config: { runtime: true },
runtimeConfig: { runtime: true, materialized: true },
sourceConfig: { source: true },
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
await runEnsureConfigReady(["health"]);
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
snapshot.runtimeConfig,
snapshot.sourceConfig,
);
});
it("forwards config snapshot phase measurement", async () => {
const snapshot = makeSnapshot();
const measuredStages: string[] = [];
const measure: ConfigSnapshotReadMeasure = async (stage, run) => {
measuredStages.push(stage);
return await run();
};
readConfigFileSnapshotMock.mockImplementationOnce(
async (options?: { measure?: ConfigSnapshotReadMeasure }) => {
await options?.measure?.("config.snapshot.read.validate", async () => undefined);
return snapshot;
},
);
await ensureConfigReady({
runtime: makeRuntime() as never,
commandPath: ["health"],
measure,
});
expect(measuredStages).toEqual(["config.snapshot.read.validate"]);
});
it("forwards config snapshot phase measurement through doctor preflight", async () => {
const root = useTempOpenClawHome();
writeStateMarker(root, "plugins/installs.json");
const measuredStages: string[] = [];
const measure: ConfigSnapshotReadMeasure = async (stage, run) => {
measuredStages.push(stage);
return await run();
};
loadAndMaybeMigrateDoctorConfigMock.mockImplementationOnce(
async (options?: { measure?: ConfigSnapshotReadMeasure }) => {
await options?.measure?.("config.snapshot.read.validate", async () => undefined);
return { snapshot: makeSnapshot(), baseConfig: {} };
},
);
await ensureConfigReady({
runtime: makeRuntime() as never,
commandPath: ["agent"],
measure,
});
expect(measuredStages).toEqual(["config.snapshot.read.validate"]);
});
it("pins plugin listing config without loading state migration runtime", async () => {
const snapshot = {
...makeSnapshot(),
config: { plugins: { entries: { alpha: { enabled: true } } } },
runtimeConfig: { plugins: { entries: { alpha: { enabled: true } } } },
sourceConfig: { plugins: { entries: { alpha: { enabled: true } } } },
};
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
await runEnsureConfigReady(["plugins", "list"]);
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
snapshot.runtimeConfig,
snapshot.sourceConfig,
);
});
it("retries the cached config snapshot after a read rejection", async () => {
const transientError = new Error("temporary config read failure");
const recoveredSnapshot = makeSnapshot();
readConfigFileSnapshotMock
.mockRejectedValueOnce(transientError)
.mockResolvedValueOnce(recoveredSnapshot);
await expect(runEnsureConfigReady(["health"])).rejects.toThrow(transientError);
await expect(runEnsureConfigReady(["health"])).resolves.toBeDefined();
await expect(runEnsureConfigReady(["health"])).resolves.toBeDefined();
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(2);
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(undefined, {});
});
it("exits for invalid config on non-allowlisted commands", async () => {
setInvalidSnapshot();
const runtime = await runEnsureConfigReady(["message"]);
expect(plainErrorCalls(runtime)).toEqual([
"OpenClaw config is invalid",
"File: /tmp/openclaw.json",
"Problem:",
" - channels.quietchat: invalid",
"",
`Inspect: ${formatCliCommand("openclaw config validate")}`,
"Audit, status, health, logs, tasks list/audit, and doctor commands still run with invalid config.",
`Run "${formatCliCommand("openclaw doctor --fix")}" to repair the config, then retry.`,
]);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("renders unknown keys and received values with the shared source diagnostics", async () => {
setInvalidSnapshot({
raw: '{\n "meta": { "migrations": { "futureMarker": true } },\n "gateway": { "port": "nope" }\n}',
parsed: {
meta: { migrations: { futureMarker: true } },
gateway: { port: "nope" },
},
sourceConfig: {
meta: { migrations: { futureMarker: true } },
gateway: { port: "nope" },
},
issues: [
{
path: "meta",
pathSegments: ["meta"],
message: 'Unrecognized key: "migrations"',
},
{
path: "gateway.port",
pathSegments: ["gateway", "port"],
message: "Invalid input: expected number",
},
],
});
const runtime = await runEnsureConfigReady(["message"]);
const output = plainErrorCalls(runtime).join("\n");
expect(output).toContain(' - openclaw.json:2 — meta: Unrecognized key: "migrations"');
expect(output).toContain(
' - openclaw.json:3 — gateway.port: Invalid input: expected number, got: "nope"',
);
});
it.each([
["9999.1.1", true],
[VERSION, false],
])(
"shows a config version-skew hint only for newer writers (%s)",
async (touchedVersion, expected) => {
setInvalidSnapshot({ sourceConfig: { meta: { lastTouchedVersion: touchedVersion } } });
const runtime = await runEnsureConfigReady(["message"]);
const output = plainErrorCalls(runtime).join("\n");
const hint = `Config was last written by OpenClaw ${touchedVersion}, but you are running ${VERSION} — upgrade or re-run setup.`;
expect(output.includes(hint)).toBe(expected);
},
);
it("runs doctor and retries the config guard once after consent", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
const invalidSnapshot = setInvalidSnapshot();
const validSnapshot = {
...makeSnapshot(),
config: { gateway: { mode: "local" } },
sourceConfig: { gateway: { mode: "local" } },
};
loadAndMaybeMigrateDoctorConfigMock
.mockResolvedValueOnce({ snapshot: invalidSnapshot, baseConfig: {} })
.mockResolvedValueOnce({ snapshot: validSnapshot, baseConfig: validSnapshot.config });
readConfigFileSnapshotMock.mockResolvedValue(validSnapshot);
const runtime = makeRuntime();
const confirm = vi.fn(async () => true);
const runDoctor = vi.fn(async () => {});
await ensureConfigReady(
{ runtime: runtime as never, commandPath: ["message"] },
{ confirm, isInteractive: () => true, runDoctor },
);
expect(confirm).toHaveBeenCalledWith(
`Run "${formatCliCommand("openclaw doctor --fix")}" now?`,
true,
);
expect(runDoctor).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(2);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenLastCalledWith({
migrateState: false,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
expect(readConfigFileSnapshotMock).not.toHaveBeenCalled();
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
validSnapshot.config,
validSnapshot.sourceConfig,
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("does not prompt for repair when stdout belongs to a machine-readable command", async () => {
setInvalidSnapshot();
const runtime = makeRuntime();
const confirm = vi.fn(async () => true);
await ensureConfigReady(
{
runtime: runtime as never,
commandPath: ["agents", "list"],
suppressDoctorStdout: true,
},
{ confirm, isInteractive: () => true },
);
expect(confirm).not.toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it.each([
["blocked JSON commands", ["onboard"], ["node", "openclaw", "onboard", "--json"], 1, true],
["protocol-owned stdout", ["mcp", "serve"], ["node", "openclaw", "mcp", "serve"], 1, false],
[
"allowed read-only JSON diagnostics",
["status"],
["node", "openclaw", "status", "--json"],
undefined,
false,
],
[
"blocked JSON gateway startup",
["gateway", "run"],
["node", "openclaw", "gateway", "run", "--json"],
78,
true,
],
])(
"preserves output ownership for %s",
async (_name, commandPath, argv, exitCode, writesJson) => {
setInvalidSnapshot();
const runtime = makeRuntime();
const originalArgv = process.argv;
process.argv = argv;
try {
await ensureConfigReady({
runtime,
commandPath,
suppressDoctorStdout: true,
});
} finally {
process.argv = originalArgv;
}
if (writesJson) {
expect(runtime.log).toHaveBeenCalledOnce();
expect(JSON.parse(String(runtime.log.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
type: "cli_error",
message: "OpenClaw config is invalid: /tmp/openclaw.json",
},
issues: [{ path: "channels.quietchat", message: "invalid" }],
});
} else {
expect(runtime.log).not.toHaveBeenCalled();
}
if (exitCode === undefined) {
expect(runtime.exit).not.toHaveBeenCalled();
} else {
expect(runtime.exit).toHaveBeenCalledWith(exitCode);
}
},
);
it.each(["OPENCLAW_NIX_MODE", "OPENCLAW_CONFIG_READONLY"])(
"keeps invalid %s config on the manual recovery path",
async (mode) => {
setInvalidSnapshot();
setTestEnvValue(mode, "1");
const runtime = makeRuntime();
const confirm = vi.fn(async () => true);
await ensureConfigReady(
{ runtime: runtime as never, commandPath: ["gateway", "run"] },
{ confirm, isInteractive: () => true },
);
expect(confirm).not.toHaveBeenCalled();
expect(plainErrorCalls(runtime).join("\n")).toContain(`${mode}=1`);
expect(runtime.exit).toHaveBeenCalledWith(78);
},
);
it("replaces doctor fix advice for plugin packaging-only invalid config", async () => {
setInvalidSnapshot({
issues: [
{
path: "plugins.slots.memory",
message: "plugin not found: source-only-pack",
},
],
warnings: [
{
path: "plugins",
message:
"plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.",
},
],
});
const runtime = await runEnsureConfigReady(["message"]);
const calls = plainErrorCalls(runtime);
expect(calls).toContain(`Fix: ${pluginPackagingRecoveryHint}`);
expect(calls).not.toContain(`Fix: ${formatCliCommand("openclaw doctor --fix")}`);
expect(runtime.exit).toHaveBeenCalledWith(1);
const gatewayRuntime = await runEnsureConfigReady(["gateway", "start"]);
expect(gatewayRuntime.exit).toHaveBeenCalledWith(78);
});
it("allows read-only invalid-config commands but blocks gateway startup", async () => {
setInvalidSnapshot({
issues: [{ path: "agents.defaults", message: 'Unrecognized key: "agentRuntime"' }],
});
const statusRuntime = await runEnsureConfigReady(["status"]);
expect(statusRuntime.exit).not.toHaveBeenCalled();
const auditRuntime = await runEnsureConfigReady(["audit"]);
expect(auditRuntime.exit).not.toHaveBeenCalled();
const bareGatewayRuntime = await runEnsureConfigReady(["gateway"]);
expect(bareGatewayRuntime.exit).toHaveBeenCalledWith(78);
const gatewayRunRuntime = await runEnsureConfigReady(["gateway", "run"]);
expect(gatewayRunRuntime.exit).toHaveBeenCalledWith(78);
const gatewayStartRuntime = await runEnsureConfigReady(["gateway", "start"]);
expect(gatewayStartRuntime.exit).toHaveBeenCalledWith(78);
const gatewayRestartRuntime = await runEnsureConfigReady(["gateway", "restart"]);
expect(gatewayRestartRuntime.exit).toHaveBeenCalledWith(78);
const gatewayRuntime = await runEnsureConfigReady(["gateway", "health"]);
expect(gatewayRuntime.exit).not.toHaveBeenCalled();
const tasksListRuntime = await runEnsureConfigReady(["tasks", "list"]);
expect(tasksListRuntime.exit).not.toHaveBeenCalled();
const tasksParentRuntime = await runEnsureConfigReady(["tasks"]);
expect(tasksParentRuntime.exit).not.toHaveBeenCalled();
const tasksAuditRuntime = await runEnsureConfigReady(["tasks", "audit"]);
expect(tasksAuditRuntime.exit).not.toHaveBeenCalled();
const tasksRunRuntime = await runEnsureConfigReady(["tasks", "run"]);
expect(tasksRunRuntime.exit).toHaveBeenCalledWith(1);
const doctorRuntime = await runEnsureConfigReady(["doctor", "fix"]);
expect(doctorRuntime.exit).not.toHaveBeenCalled();
expect(doctorRuntime.error).toHaveBeenCalledWith(expect.stringContaining("agentRuntime"));
expect(getProcessPluginCache()).toBe(processCache);
});
it("allows an explicit invalid-config override", async () => {
setInvalidSnapshot();
const runtime = makeRuntime();
await ensureConfigReady({
runtime: runtime as never,
commandPath: ["plugins", "install"],
allowInvalid: true,
});
expect(runtime.exit).not.toHaveBeenCalled();
});
it("does not offer repair for an explicitly allowed gateway startup", async () => {
setInvalidSnapshot();
const runtime = makeRuntime();
const confirm = vi.fn(async () => true);
await ensureConfigReady(
{
runtime: runtime as never,
commandPath: ["gateway", "run"],
allowInvalid: true,
},
{ confirm, isInteractive: () => true },
);
expect(confirm).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
expect(getProcessPluginCache()).toBe(processCache);
});
it("runs doctor migration flow only once per module instance", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
const runtimeA = makeRuntime();
const runtimeB = makeRuntime();
await ensureConfigReady({ runtime: runtimeA as never, commandPath: ["message"] });
await ensureConfigReady({ runtime: runtimeB as never, commandPath: ["message"] });
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
});
it("still runs doctor flow when stdout suppression is enabled", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
await runEnsureConfigReady(["message"], true);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1);
});
it("prevents preflight note noise when suppression is enabled", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
note("Doctor warnings", "Config warnings");
return {
snapshot: makeSnapshot(),
baseConfig: {},
};
});
const output = await withCapturedStdout(async () => {
await runEnsureConfigReady(["message"], true);
});
expect(output).not.toContain("Doctor warnings");
});
it("allows preflight note noise when suppression is not enabled", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
note("Doctor warnings", "Config warnings");
return {
snapshot: makeSnapshot(),
baseConfig: {},
};
});
const output = await withCapturedStdout(async () => {
await runEnsureConfigReady(["message"], false);
});
expect(output).toContain("Doctor warnings");
});
it("does not suppress unrelated concurrent stdout writes while suppressing preflight notes", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
let releasePreflight: (() => void) | undefined;
let preflightStarted: (() => void) | undefined;
const preflightStartedPromise = new Promise<void>((resolve) => {
preflightStarted = resolve;
});
const releasePreflightPromise = new Promise<void>((resolve) => {
releasePreflight = resolve;
});
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
note("Doctor warnings", "Config warnings");
preflightStarted?.();
await releasePreflightPromise;
return {
snapshot: makeSnapshot(),
baseConfig: {},
};
});
let callbackCalled = false;
const output = await withCapturedStdout(async () => {
const ready = runEnsureConfigReady(["message"], true);
await preflightStartedPromise;
process.stdout.write("Concurrent output\n", () => {
callbackCalled = true;
});
releasePreflight?.();
await ready;
});
expect(output).toContain("Concurrent output");
expect(output).not.toContain("Doctor warnings");
expect(callbackCalled).toBe(true);
});
});