// 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; 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): 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): Promise { 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 | undefined; let processCache: ReturnType; let preflightCache: ReturnType; let preflightMetadata: ReturnType; async function runEnsureConfigReady(commandPath: string[], suppressDoctorStdout = false) { const runtime = makeRuntime(); await ensureConfigReady({ runtime: runtime as never, commandPath, suppressDoctorStdout }); return runtime; } function setInvalidSnapshot(overrides?: Partial>) { 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((resolve) => { preflightStarted = resolve; }); const releasePreflightPromise = new Promise((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); }); });