// CLI config readiness guard, legacy-state migration routing, and invalid-config allowances. import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js"; import type { DoctorConfigPreflightResult } from "../../commands/doctor/shared/config-migration-result.js"; import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; import { createInvalidConfigError } from "../../config/io.invalid-config.js"; import type { ConfigSnapshotReadMeasure } from "../../config/io.js"; import { resolveIsConfigReadOnly, resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir, } from "../../config/paths.js"; import type { ConfigFileSnapshot } from "../../config/types.js"; import { resolveExecApprovalsPath } from "../../infra/exec-approvals-config.js"; import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { adoptProcessPluginCache, getPluginMetadataSnapshotCache, } from "../../plugins/plugin-cache.js"; import { ExitError, type RuntimeEnv } from "../../runtime.js"; import type { InvalidConfigRecoveryDeps } from "../invalid-config-recovery.js"; const ALLOWED_INVALID_COMMANDS = new Set(["audit", "doctor", "logs", "health", "help", "status"]); const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([ "run", "status", "probe", "health", "discover", "call", "install", "uninstall", "start", "stop", "restart", ]); const ALLOWED_INVALID_TASK_SUBCOMMANDS = new Set(["list", "audit"]); let didRunDoctorConfigFlow = false; let configSnapshotPromise: Promise>> | null = null; function resetConfigGuardStateForTests() { didRunDoctorConfigFlow = false; configSnapshotPromise = null; } function fileOrDirExists(pathname: string): boolean { try { return fs.existsSync(pathname); } catch { return false; } } function dirHasFile(dir: string, predicate: (name: string) => boolean): boolean { try { return fs .readdirSync(dir, { withFileTypes: true }) .some((entry) => entry.isFile() && predicate(entry.name)); } catch { return false; } } function isLegacyWhatsAppAuthFile(name: string): boolean { if (name === "creds.json" || name === "creds.json.bak") { return true; } return name.endsWith(".json") && /^(app-state-sync|session|sender-key|pre-key)-/.test(name); } function isLegacyTelegramStateFile(name: string): boolean { return ( (name.startsWith("bot-info-") && name.endsWith(".json")) || (name.startsWith("update-offset-") && name.endsWith(".json")) || name === "sticker-cache.json" || (name.startsWith("thread-bindings-") && name.endsWith(".json")) ); } function hasLegacyIMessageStateFiles(stateDir: string): boolean { return ( fileOrDirExists(path.join(stateDir, "imessage", "reply-cache.jsonl")) || fileOrDirExists(path.join(stateDir, "imessage", "sent-echoes.jsonl")) || dirHasFile(path.join(stateDir, "imessage", "catchup"), (name) => name.endsWith(".json")) ); } function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir: string): boolean { if ( fileOrDirExists(path.join(stateDir, "discord", "model-picker-preferences.json")) || fileOrDirExists(path.join(stateDir, "discord", "thread-bindings.json")) ) { return true; } if (hasLegacyIMessageStateFiles(stateDir)) { return true; } if ( fileOrDirExists(path.join(oauthDir, "telegram-allowFrom.json")) || dirHasFile(path.join(stateDir, "telegram"), isLegacyTelegramStateFile) ) { return true; } return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile); } function hasPendingSqliteSidecarArchive(sourcePath: string): boolean { return ( fileOrDirExists(`${sourcePath}.migrated`) && ["-shm", "-wal", "-journal"].some((suffix) => fileOrDirExists(`${sourcePath}${suffix}`)) ); } function hasLegacyStateMigrationInputs(): boolean { // Only run migration prompts when old state actually exists in known legacy locations. const stateDir = resolveStateDir(process.env, os.homedir); const oauthDir = resolveOAuthDir(process.env, stateDir); if ( !process.env.OPENCLAW_STATE_DIR?.trim() && resolveLegacyStateDirs(() => resolveRequiredHomeDir(process.env, os.homedir)).some( fileOrDirExists, ) ) { return true; } const sqliteSidecarPaths = [ path.join(stateDir, "flows", "registry.sqlite"), path.join(stateDir, "plugin-state", "state.sqlite"), path.join(stateDir, "tasks", "runs.sqlite"), ]; const legacyExecApprovalsPath = resolveExecApprovalsPath(process.env); return ( [ path.join(stateDir, "agent"), path.join(stateDir, "agents"), legacyExecApprovalsPath, `${legacyExecApprovalsPath}.doctor-importing`, path.join(stateDir, "plugins", "installs.json"), path.join(stateDir, "restart-sentinel.json"), path.join(stateDir, "restart-sentinel.json.doctor-importing"), path.join(stateDir, "sessions"), path.join(stateDir, "state", "openclaw.sqlite"), ].some(fileOrDirExists) || sqliteSidecarPaths.some( (sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath), ) || hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ); } function shouldRunStateMigrationOnlyWithLegacyInputs(commandPath: string[]): boolean { const commandName = commandPath[0]; const subcommandName = commandPath[1]; // Metadata-only plugin listing still migrates known legacy inputs, but an empty // state must not cold-load doctor and bundled channel runtime graphs. return ( commandName === "agent" || commandName === "status" || (commandName === "plugins" && subcommandName === "list") || (commandName === "tasks" && (subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName))) ); } function snapshotHasConfiguredSessionStore( snapshot: Awaited>, ): boolean { const cfg = snapshot.runtimeConfig ?? snapshot.config; const store = cfg?.session?.store; return typeof store === "string" && store.trim().length > 0; } function shouldRequireStartupMigrationCheckpoint(commandPath: string[]): boolean { const commandName = commandPath[0]; const subcommandName = commandPath[1]; return ( commandName === "gateway" && (subcommandName === undefined || subcommandName === "run" || subcommandName.trim() === "") ); } function isGatewayStartupCommand(commandPath: string[]): boolean { const [commandName, subcommandName] = commandPath; return ( commandName === "gateway" && (subcommandName === undefined || subcommandName === "run" || subcommandName === "start" || subcommandName === "restart") ); } async function getConfigSnapshot( options?: { observe: false; pluginValidation?: "skip" | "core-only" }, measure?: ConfigSnapshotReadMeasure, ) { if (options?.observe === false) { return readConfigFileSnapshot({ ...options, ...(measure ? { measure } : {}), }); } if (!configSnapshotPromise) { const pendingSnapshot = readConfigFileSnapshot(measure ? { measure } : undefined); configSnapshotPromise = pendingSnapshot; pendingSnapshot.catch(() => { if (configSnapshotPromise === pendingSnapshot) { configSnapshotPromise = null; } }); } return configSnapshotPromise; } export async function ensureConfigReady( params: { runtime: RuntimeEnv; commandPath?: string[]; suppressDoctorStdout?: boolean; allowInvalid?: boolean; beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise; measure?: ConfigSnapshotReadMeasure; skipPristineCoreStateMigrations?: boolean; skipPristineStartupStateMigrations?: boolean; validateConfigOnly?: boolean; }, recoveryDeps?: InvalidConfigRecoveryDeps, ): Promise { const commandPath = params.commandPath ?? []; const commandName = commandPath[0]; const subcommandName = commandPath[1]; const isRestartController = (commandName === "gateway" || commandName === "daemon") && subcommandName === "restart"; let preflightResult: DoctorConfigPreflightResult | null = null; const shouldConsiderStateMigration = !params.validateConfigOnly && commandName !== "config" && commandName !== "health" && commandName !== "logs" && commandName !== "sessions" && // Remote RPC clients must not migrate state owned by the running gateway. !(commandName === "gateway" && subcommandName === "call") && // A newer restart client may be controlling an older live Gateway. Validate // config without advancing the persistent schema owned by that process. !isRestartController && !(commandName === "update" && subcommandName === "status"); const requiresLegacyStateInput = shouldRunStateMigrationOnlyWithLegacyInputs(commandPath); const runStateMigrationPreflight = async () => { didRunDoctorConfigFlow = true; const runDoctorConfigPreflight = async () => (await import("../../commands/doctor-config-preflight.js")).runDoctorConfigPreflight({ migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, ...(params.measure ? { measure: params.measure } : {}), ...(commandName === "status" ? { observe: false } : {}), ...(shouldRequireStartupMigrationCheckpoint(commandPath) ? { requireStartupMigrationCheckpoint: true, validateStartupConfig: async (snapshot) => { const { getGatewayStartGuardErrors } = await import("../gateway-cli/pre-bootstrap.js"); const errors = getGatewayStartGuardErrors({ allowUnconfigured: params.allowInvalid, configExists: snapshot.exists, mode: snapshot.config.gateway?.mode, }); if (errors.length > 0) { throw new Error(errors.join("\n")); } }, } : { requireStateMigrationCheckpoint: true }), ...(params.beforeStateMigrations ? { beforeStateMigrations: params.beforeStateMigrations } : {}), ...(params.skipPristineStartupStateMigrations ? { skipPristineStartupStateMigrations: true } : {}), ...(params.skipPristineCoreStateMigrations ? { skipPristineCoreStateMigrations: true } : {}), }); try { return !params.suppressDoctorStdout ? await runDoctorConfigPreflight() : await withSuppressedNotes(runDoctorConfigPreflight); } catch (error) { if (shouldRequireStartupMigrationCheckpoint(commandPath)) { await ( await import("../gateway-cli/startup-maintenance.js") ).handleGatewayStartupMaintenance(error); } if (error instanceof ExitError) { // The migration owner has unwound its lease and heartbeat before this handoff. params.runtime.exit(error.code); } throw error; } }; if ( !didRunDoctorConfigFlow && shouldConsiderStateMigration && (!requiresLegacyStateInput || hasLegacyStateMigrationInputs()) ) { preflightResult = await runStateMigrationPreflight(); } // Read-only diagnostics must not record config health. Core-only validation // also skips plugin metadata discovery, whose state reads create SQLite sidecars. const configSnapshotOptions = params.validateConfigOnly ? ({ observe: false, pluginValidation: "core-only" } as const) : commandName === "logs" ? ({ observe: false, pluginValidation: "core-only" } as const) : commandName === "status" || (commandName === "gateway" && subcommandName === "call") || isRestartController ? ({ observe: false } as const) : undefined; let snapshot = preflightResult?.snapshot ?? (await getConfigSnapshot(configSnapshotOptions, params.measure)); if ( !preflightResult && !didRunDoctorConfigFlow && shouldConsiderStateMigration && requiresLegacyStateInput && snapshot.valid && snapshotHasConfiguredSessionStore(snapshot) ) { preflightResult = await runStateMigrationPreflight(); snapshot = preflightResult.snapshot; } const isBareGatewayForegroundRun = commandName === "gateway" && (subcommandName === undefined || subcommandName.trim() === ""); const isReadOnlyTaskStateCommand = commandName === "tasks" && (subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName)); const allowInvalid = commandName ? params.allowInvalid === true || ALLOWED_INVALID_COMMANDS.has(commandName) || isReadOnlyTaskStateCommand || isBareGatewayForegroundRun || (commandName === "gateway" && subcommandName && ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName)) : false; const [{ formatConfigIssueLines }, { renderConfigValidationIssueLines }] = await Promise.all([ import("../../config/issue-format.js"), import("../../config/issue-location.js"), ]); const issues = snapshot.exists && !snapshot.valid ? renderConfigValidationIssueLines(snapshot) : []; const legacyIssues = snapshot.legacyIssues.length > 0 ? formatConfigIssueLines(snapshot.legacyIssues, "-") : []; const invalid = snapshot.exists && !snapshot.valid; if (!invalid) { setRuntimeConfigSnapshot(snapshot.runtimeConfig ?? snapshot.config, snapshot.sourceConfig); if ( shouldRequireStartupMigrationCheckpoint(commandPath) && preflightResult?.pluginMetadataSnapshot ) { // Carry verified package facts into the final config reread without publishing Gateway policy. adoptProcessPluginCache( getPluginMetadataSnapshotCache(preflightResult.pluginMetadataSnapshot), ); } return; } const [ { colorize, isRich, theme }, { shortenHomePath }, { formatCliCommand }, { isPluginPackagingRuntimeOutputInvalidConfigSnapshot }, { formatPluginPackagingRuntimeOutputRecoveryHint }, ] = await Promise.all([ import("../../../packages/terminal-core/src/theme.js"), import("../../utils.js"), import("../command-format.js"), import("../../config/recovery-policy.js"), import("../config-recovery-hints.js"), ]); const rich = isRich(); const muted = (value: string) => colorize(rich, theme.muted, value); const error = (value: string) => colorize(rich, theme.error, value); const heading = (value: string) => colorize(rich, theme.heading, value); const commandText = (value: string) => colorize(rich, theme.command, value); params.runtime.error(heading("OpenClaw config is invalid")); params.runtime.error(`${muted("File:")} ${muted(shortenHomePath(snapshot.path))}`); if (issues.length > 0) { params.runtime.error(muted("Problem:")); params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n")); } if (legacyIssues.length > 0) { params.runtime.error(muted("Legacy config keys detected:")); params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n")); } params.runtime.error(""); const isPluginPackagingFailure = isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot); const isReadOnlyConfig = resolveIsConfigReadOnly(); const isGatewayStartup = isGatewayStartupCommand(commandPath); const mustBlockInvalid = !allowInvalid || (isGatewayStartup && params.allowInvalid !== true); const shouldOfferRecovery = mustBlockInvalid && !params.suppressDoctorStdout && !isReadOnlyConfig; if (isPluginPackagingFailure || isReadOnlyConfig || !shouldOfferRecovery) { const fixHint = isPluginPackagingFailure ? formatPluginPackagingRuntimeOutputRecoveryHint() : isReadOnlyConfig ? (await import("../../config/config-write-guard.js")).createConfigMutationError({ configPath: snapshot.path, }).message : commandText(formatCliCommand("openclaw doctor --fix")); params.runtime.error(`${muted("Fix:")} ${fixHint}`); } params.runtime.error( `${muted("Inspect:")} ${commandText(formatCliCommand("openclaw config validate"))}`, ); params.runtime.error( muted( "Audit, status, health, logs, tasks list/audit, and doctor commands still run with invalid config.", ), ); if ( mustBlockInvalid && (await import("../json-output-mode.js")).isJsonOutputModeActive(process.argv) ) { const { writeInvalidConfigCliJson } = await import("../config-validation-output.js"); writeInvalidConfigCliJson(params.runtime, snapshot); } if (isPluginPackagingFailure && isGatewayStartup) { params.runtime.exit(78); return; } if (shouldOfferRecovery && !isPluginPackagingFailure) { const { offerInvalidConfigRecovery } = await import("../invalid-config-recovery.js"); const recovery = await offerInvalidConfigRecovery({ runtime: params.runtime, deps: recoveryDeps, retry: async () => { // Doctor may rewrite config; retry the same legacy/plugin-aware validation without // rerunning startup state migrations. configSnapshotPromise = null; const { runDoctorConfigPreflight } = await import("../../commands/doctor-config-preflight.js"); const retrySnapshot = ( await runDoctorConfigPreflight({ migrateState: false, migrateLegacyConfig: false, invalidConfigNote: false, ...(params.measure ? { measure: params.measure } : {}), ...configSnapshotOptions, }) ).snapshot; if (retrySnapshot.exists && !retrySnapshot.valid) { const retryIssues = renderConfigValidationIssueLines(retrySnapshot); throw createInvalidConfigError( retrySnapshot.path, retryIssues.join("\n") || "Unknown validation issue.", ); } setRuntimeConfigSnapshot( retrySnapshot.runtimeConfig ?? retrySnapshot.config, retrySnapshot.sourceConfig, ); }, }); if (recovery.status === "recovered") { return; } params.runtime.exit(isGatewayStartup ? 78 : 1); return; } if (mustBlockInvalid) { params.runtime.exit(isGatewayStartup ? 78 : 1); } } export const testApi = { resetConfigGuardStateForTests, };