| |
| 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<Awaited<ReturnType<typeof readConfigFileSnapshot>>> | 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 { |
| |
| 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]; |
| |
| |
| return ( |
| commandName === "agent" || |
| commandName === "status" || |
| (commandName === "plugins" && subcommandName === "list") || |
| (commandName === "tasks" && |
| (subcommandName === undefined || ALLOWED_INVALID_TASK_SUBCOMMANDS.has(subcommandName))) |
| ); |
| } |
|
|
| function snapshotHasConfiguredSessionStore( |
| snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>, |
| ): 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<boolean>; |
| measure?: ConfigSnapshotReadMeasure; |
| skipPristineCoreStateMigrations?: boolean; |
| skipPristineStartupStateMigrations?: boolean; |
| validateConfigOnly?: boolean; |
| }, |
| recoveryDeps?: InvalidConfigRecoveryDeps, |
| ): Promise<void> { |
| 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" && |
| |
| !(commandName === "gateway" && subcommandName === "call") && |
| |
| |
| !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) { |
| |
| params.runtime.exit(error.code); |
| } |
| throw error; |
| } |
| }; |
| if ( |
| !didRunDoctorConfigFlow && |
| shouldConsiderStateMigration && |
| (!requiresLegacyStateInput || hasLegacyStateMigrationInputs()) |
| ) { |
| preflightResult = await runStateMigrationPreflight(); |
| } |
|
|
| |
| |
| 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 |
| ) { |
| |
| 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 () => { |
| |
| |
| 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, |
| }; |
|
|