openclaw / src /cli /program /config-guard.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
18.5 kB
// 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<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 {
// 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<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" &&
// 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,
};