fix(cli): offer config repair after invalid startup (#110533)

* fix(cli): offer doctor recovery for invalid config

* fix(cli): preserve invalid config overrides

* fix(cli): keep invalid config recovery controlled
This commit is contained in:
Peter Steinberger
2026-07-18 09:30:27 +01:00
committed by GitHub
parent ccf55a43cd
commit 1710313596
12 changed files with 537 additions and 28 deletions
+1
View File
@@ -33,6 +33,7 @@ openclaw gateway run # equivalent, explicit form
<AccordionGroup>
<Accordion title="Startup behavior">
- Refuses to start unless `gateway.mode=local` is set in `~/.openclaw/openclaw.json`. Use `--allow-unconfigured` for ad-hoc/dev runs; it bypasses the guard without writing or repairing config.
- When startup finds a repairable invalid config, an interactive terminal offers to run `openclaw doctor --fix` and retries startup once after consent. Non-interactive runs never repair automatically; they print the command instead. If the repaired config is still invalid, startup remains stopped.
- `openclaw onboard --mode local` and `openclaw setup` write `gateway.mode=local`. If the config file exists but `gateway.mode` is missing, that is treated as damaged/clobbered config and the Gateway refuses to guess `local` for you — re-run onboarding, set the key manually, or pass `--allow-unconfigured`.
- Binding beyond loopback without auth is blocked.
- `--bind` values `lan`, `tailnet`, and `custom` resolve over IPv4-only paths today; IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway.
@@ -34,6 +34,7 @@ const waitForPortBindable = vi.fn(async (_port: number, _opts?: unknown) => 0);
const findVerifiedGatewayListenerPidsOnPortSync = vi.fn((_port: number) => [] as number[]);
const formatGatewayPidList = vi.fn((pids: number[]) => pids.join(", "));
const isTerminalInteractive = vi.fn(() => true);
const offerInvalidConfigRecovery = vi.fn(async () => ({ status: "declined" as const }));
const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
type GatewayLoopStart = (params?: { startupStartedAt?: number }) => Promise<unknown>;
const runGatewayLoop = vi.fn(async ({ start }: { start: GatewayLoopStart }) => {
@@ -331,6 +332,10 @@ vi.mock("../terminal-interactivity.js", () => ({
"Refusing to kill the operator's running gateway service from a non-interactive shell. Use an isolated dev gateway (openclaw gateway run --dev, or --profile <name> with a free port) for testing.",
}));
vi.mock("../invalid-config-recovery.js", () => ({
offerInvalidConfigRecovery: () => offerInvalidConfigRecovery(),
}));
vi.mock("../ports.js", () => ({
forceFreePortAndWait: (port: number, opts: unknown) => forceFreePortAndWait(port, opts),
waitForPortBindable: (port: number, opts?: unknown) => waitForPortBindable(port, opts),
@@ -399,6 +404,7 @@ describe("gateway run option collisions", () => {
formatGatewayPidList.mockClear();
isTerminalInteractive.mockReset();
isTerminalInteractive.mockReturnValue(true);
offerInvalidConfigRecovery.mockClear();
cleanStaleGatewayProcessesSync.mockClear();
waitForPortBindable.mockClear();
ensureDevGatewayConfig.mockClear();
@@ -1650,6 +1656,20 @@ describe("gateway run option collisions", () => {
expect(options.startupConfigSnapshotRead?.snapshot?.valid).toBe(false);
});
it("does not offer doctor repair after --allow-unconfigured reaches startup", async () => {
const { createInvalidConfigError } = await import("../../config/io.invalid-config.js");
startGatewayServer.mockRejectedValueOnce(
createInvalidConfigError("/tmp/openclaw.json", "gateway.mode: invalid"),
);
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
expect(offerInvalidConfigRecovery).not.toHaveBeenCalled();
expect(startGatewayServer).toHaveBeenCalledOnce();
});
it.each(["none", "trusted-proxy"] as const)("accepts --auth %s override", async (mode) => {
await runGatewayCli(["gateway", "run", "--auth", mode, "--allow-unconfigured"]);
+39 -2
View File
@@ -17,7 +17,10 @@ import type {
ReadConfigFileSnapshotWithPluginMetadataResult,
} from "../../config/config.js";
import { ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV } from "../../config/future-version-guard.js";
import { isInvalidConfigError } from "../../config/io.invalid-config.js";
import {
isDoctorRecoverableInvalidConfigError,
isInvalidConfigError,
} from "../../config/io.invalid-config.js";
import {
CONFIG_PATH,
normalizeStateDirEnv,
@@ -62,6 +65,7 @@ import { defaultRuntime } from "../../runtime.js";
import { printClawBanner, type ClawBannerResult } from "../claw-banner.js";
import { formatCliCommand } from "../command-format.js";
import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js";
import type { InvalidConfigRecoveryDeps } from "../invalid-config-recovery.js";
import { withProgress } from "../progress.js";
import { parsePort } from "../shared/parse-port.js";
import {
@@ -648,7 +652,7 @@ async function maybeWriteGatewayStartupFailureBundle(
}
}
export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunRuntimeHooks = {}) {
async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRuntimeHooks = {}) {
// Reparenting can hide the running service from the ancestor walk.
// Preserve its inherited PID before config env rebuilding overwrites it.
const inheritedGatewayServicePid = parseStrictPositiveInteger(
@@ -1195,6 +1199,9 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR
);
return;
}
if (isInvalidConfigError(err)) {
throw err;
}
await maybeWriteGatewayStartupFailureBundle(err);
defaultRuntime.error(
`Gateway failed to start: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep")} for diagnostics.`,
@@ -1203,6 +1210,36 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR
}
}
/** Run foreground Gateway startup with one consent-gated invalid-config repair attempt. */
export async function runGatewayCommand(
opts: GatewayRunOpts,
hooks: GatewayRunRuntimeHooks = {},
recoveryDeps?: InvalidConfigRecoveryDeps,
) {
try {
await runGatewayCommandOnce(opts, hooks);
} catch (error) {
if (!isInvalidConfigError(error)) {
throw error;
}
defaultRuntime.error(`Gateway failed to start: ${formatErrorMessage(error)}`);
if (opts.allowUnconfigured || !isDoctorRecoverableInvalidConfigError(error)) {
defaultRuntime.exit(EXIT_CONFIG_ERROR);
return;
}
const { offerInvalidConfigRecovery } = await import("../invalid-config-recovery.js");
const recovery = await offerInvalidConfigRecovery({
runtime: defaultRuntime,
deps: recoveryDeps,
retry: async () => await runGatewayCommandOnce(opts, hooks),
});
if (recovery.status === "recovered") {
return;
}
defaultRuntime.exit(EXIT_CONFIG_ERROR);
}
}
const testing = {
isGatewayHealthzResponse,
normalizeGatewayHealthProbeHost,
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it, vi } from "vitest";
import { createInvalidConfigError } from "../config/io.invalid-config.js";
import { ExitError, type RuntimeEnv } from "../runtime.js";
import { offerInvalidConfigRecovery } from "./invalid-config-recovery.js";
function createRuntime(): RuntimeEnv {
return {
error: vi.fn(),
exit: vi.fn(),
log: vi.fn(),
};
}
describe("offerInvalidConfigRecovery", () => {
it("runs doctor and retries once after interactive consent", async () => {
const runtime = createRuntime();
const runDoctor = vi.fn(async () => {});
const retry = vi.fn(async () => "started");
await expect(
offerInvalidConfigRecovery({
runtime,
retry,
deps: {
confirm: vi.fn(async () => true),
isInteractive: () => true,
runDoctor,
},
}),
).resolves.toEqual({ status: "recovered", value: "started" });
expect(runDoctor).toHaveBeenCalledOnce();
expect(retry).toHaveBeenCalledOnce();
});
it("prints the command without running doctor when consent is declined", async () => {
const runtime = createRuntime();
const runDoctor = vi.fn(async () => {});
const retry = vi.fn(async () => {});
await expect(
offerInvalidConfigRecovery({
runtime,
retry,
deps: {
confirm: vi.fn(async () => false),
isInteractive: () => true,
runDoctor,
},
}),
).resolves.toEqual({ status: "declined" });
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("openclaw doctor --fix"));
expect(runDoctor).not.toHaveBeenCalled();
expect(retry).not.toHaveBeenCalled();
});
it("prints only the command in non-interactive mode", async () => {
const runtime = createRuntime();
const confirm = vi.fn(async () => true);
const runDoctor = vi.fn(async () => {});
const retry = vi.fn(async () => {});
await expect(
offerInvalidConfigRecovery({
runtime,
retry,
deps: { confirm, isInteractive: () => false, runDoctor },
}),
).resolves.toEqual({ status: "declined" });
expect(runtime.error).toHaveBeenCalledTimes(1);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("openclaw doctor --fix"));
expect(confirm).not.toHaveBeenCalled();
expect(runDoctor).not.toHaveBeenCalled();
expect(retry).not.toHaveBeenCalled();
});
it("reports one failed retry without running doctor again", async () => {
const runtime = createRuntime();
const runDoctor = vi.fn(async () => {});
const retry = vi.fn(async () => {
throw createInvalidConfigError("/tmp/openclaw.json", "- gateway.port: invalid");
});
await expect(
offerInvalidConfigRecovery({
runtime,
retry,
deps: {
confirm: vi.fn(async () => true),
isInteractive: () => true,
runDoctor,
},
}),
).resolves.toEqual({ status: "retry-failed" });
expect(runDoctor).toHaveBeenCalledOnce();
expect(retry).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Config is still invalid"));
});
it("reports doctor failures without retrying the command", async () => {
const runtime = createRuntime();
const retry = vi.fn(async () => "started");
await expect(
offerInvalidConfigRecovery({
runtime,
retry,
deps: {
confirm: vi.fn(async () => true),
isInteractive: () => true,
runDoctor: vi.fn(async () => {
throw new Error("repair unavailable");
}),
},
}),
).resolves.toEqual({ status: "retry-failed" });
expect(retry).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("repair unavailable"));
});
it("preserves intentional doctor exits", async () => {
const runtime = createRuntime();
await expect(
offerInvalidConfigRecovery({
runtime,
retry: vi.fn(async () => "started"),
deps: {
confirm: vi.fn(async () => true),
isInteractive: () => true,
runDoctor: vi.fn(async () => {
throw new ExitError(2);
}),
},
}),
).rejects.toMatchObject({ code: 2 });
});
});
+71
View File
@@ -0,0 +1,71 @@
import { formatErrorMessage } from "../infra/errors.js";
import { ExitError, type RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "./command-format.js";
import { isTerminalInteractive } from "./terminal-interactivity.js";
type InvalidConfigRecoveryResult<T> =
| { status: "declined" }
| { status: "recovered"; value: T }
| { status: "retry-failed" };
export type InvalidConfigRecoveryDeps = {
confirm?: (question: string, defaultYes: boolean) => Promise<boolean>;
isInteractive?: () => boolean;
runDoctor?: (runtime: RuntimeEnv) => Promise<void>;
};
/** Offer a consent-gated doctor repair, then retry the failed operation once. */
export async function offerInvalidConfigRecovery<T>(params: {
runtime: RuntimeEnv;
retry: () => Promise<T>;
deps?: InvalidConfigRecoveryDeps;
}): Promise<InvalidConfigRecoveryResult<T>> {
const command = formatCliCommand("openclaw doctor --fix");
const printCommand = () => {
params.runtime.error(`Run "${command}" to repair the config, then retry.`);
};
const isInteractive = params.deps?.isInteractive ?? isTerminalInteractive;
if (!isInteractive()) {
printCommand();
return { status: "declined" };
}
const confirm =
params.deps?.confirm ??
(async (question: string, defaultYes: boolean) => {
const { promptYesNo } = await import("./prompt.js");
return await promptYesNo(question, defaultYes);
});
if (!(await confirm(`Run "${command}" now?`, true))) {
printCommand();
return { status: "declined" };
}
const runDoctor =
params.deps?.runDoctor ??
(async (runtime: RuntimeEnv) => {
const { doctorCommand } = await import("../commands/doctor.js");
await doctorCommand(runtime, { repair: true });
});
try {
await runDoctor(params.runtime);
} catch (error) {
if (error instanceof ExitError) {
throw error;
}
params.runtime.error(`Failed to run "${command}": ${formatErrorMessage(error)}`);
return { status: "retry-failed" };
}
try {
return { status: "recovered", value: await params.retry() };
} catch (error) {
const { isInvalidConfigError } = await import("../config/io.invalid-config.js");
if (!isInvalidConfigError(error)) {
throw error;
}
params.runtime.error(`Config is still invalid after "${command}":`);
params.runtime.error(formatErrorMessage(error));
return { status: "retry-failed" };
}
}
+114 -5
View File
@@ -96,12 +96,14 @@ describe("ensureConfigReady", () => {
snapshot,
baseConfig: {},
});
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_PROFILE");
deleteTestEnvValue("OPENCLAW_STATE_DIR");
return root;
@@ -127,7 +129,13 @@ describe("ensureConfigReady", () => {
}
beforeEach(() => {
envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR"]);
envSnapshot = captureEnv([
"HOME",
"OPENCLAW_HOME",
"OPENCLAW_NIX_MODE",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
]);
vi.clearAllMocks();
resetConfigGuardStateForTests();
for (const root of tempRoots.splice(0)) {
@@ -505,13 +513,87 @@ describe("ensureConfigReady", () => {
"Problem:",
" - channels.quietchat: invalid",
"",
`Fix: ${formatCliCommand("openclaw doctor --fix")}`,
`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("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("keeps invalid Nix-managed config on the manual recovery path", async () => {
setInvalidSnapshot();
setTestEnvValue("OPENCLAW_NIX_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("Config is managed by Nix");
expect(runtime.exit).toHaveBeenCalledWith(78);
});
it("replaces doctor fix advice for plugin packaging-only invalid config", async () => {
setInvalidSnapshot({
issues: [
@@ -534,9 +616,12 @@ describe("ensureConfigReady", () => {
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("does not exit for invalid config on allowlisted commands", async () => {
it("allows read-only invalid-config commands but blocks gateway startup", async () => {
setInvalidSnapshot({
issues: [{ path: "agents.defaults", message: 'Unrecognized key: "agentRuntime"' }],
});
@@ -547,10 +632,16 @@ describe("ensureConfigReady", () => {
expect(auditRuntime.exit).not.toHaveBeenCalled();
const bareGatewayRuntime = await runEnsureConfigReady(["gateway"]);
expect(bareGatewayRuntime.exit).not.toHaveBeenCalled();
expect(bareGatewayRuntime.exit).toHaveBeenCalledWith(78);
const gatewayRunRuntime = await runEnsureConfigReady(["gateway", "run"]);
expect(gatewayRunRuntime.exit).not.toHaveBeenCalled();
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();
@@ -583,6 +674,24 @@ describe("ensureConfigReady", () => {
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();
});
it("runs doctor migration flow only once per module instance", async () => {
writeLegacyTaskSidecarMarker(useTempOpenClawHome());
const runtimeA = makeRuntime();
+93 -16
View File
@@ -4,11 +4,18 @@ import os from "node:os";
import path from "node:path";
import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js";
import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
import { createInvalidConfigError } from "../../config/io.invalid-config.js";
import {
resolveIsNixMode,
resolveLegacyStateDirs,
resolveOAuthDir,
resolveStateDir,
} from "../../config/paths.js";
import type { ConfigFileSnapshot } from "../../config/types.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { ExitError, type RuntimeEnv } from "../../runtime.js";
import { shouldMigrateStateFromPath } from "../argv.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([
@@ -167,6 +174,17 @@ function shouldRequireStartupMigrationCheckpoint(commandPath: string[]): boolean
);
}
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 }) {
if (options?.observe === false) {
return readConfigFileSnapshot(options);
@@ -187,15 +205,18 @@ async function getConfigSnapshot(options?: { observe: false }) {
return configSnapshotPromise;
}
export async function ensureConfigReady(params: {
runtime: RuntimeEnv;
commandPath?: string[];
suppressDoctorStdout?: boolean;
allowInvalid?: boolean;
beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise<boolean>;
skipPristineCoreStateMigrations?: boolean;
skipPristineStartupStateMigrations?: boolean;
}): Promise<void> {
export async function ensureConfigReady(
params: {
runtime: RuntimeEnv;
commandPath?: string[];
suppressDoctorStdout?: boolean;
allowInvalid?: boolean;
beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise<boolean>;
skipPristineCoreStateMigrations?: boolean;
skipPristineStartupStateMigrations?: boolean;
},
recoveryDeps?: InvalidConfigRecoveryDeps,
): Promise<void> {
const commandPath = params.commandPath ?? [];
const commandName = commandPath[0];
const subcommandName = commandPath[1];
@@ -319,10 +340,22 @@ export async function ensureConfigReady(params: {
params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n"));
}
params.runtime.error("");
const fixHint = isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
? formatPluginPackagingRuntimeOutputRecoveryHint()
: commandText(formatCliCommand("openclaw doctor --fix"));
params.runtime.error(`${muted("Fix:")} ${fixHint}`);
const isPluginPackagingFailure = isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot);
const isNixManagedConfig = resolveIsNixMode();
const isGatewayStartup = isGatewayStartupCommand(commandPath);
const mustBlockInvalid = !allowInvalid || (isGatewayStartup && params.allowInvalid !== true);
const shouldOfferRecovery =
mustBlockInvalid && !params.suppressDoctorStdout && !isNixManagedConfig;
if (isPluginPackagingFailure || isNixManagedConfig || !shouldOfferRecovery) {
const fixHint = isPluginPackagingFailure
? formatPluginPackagingRuntimeOutputRecoveryHint()
: isNixManagedConfig
? new (await import("../../config/nix-mode-write-guard.js")).NixModeConfigMutationError({
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"))}`,
);
@@ -331,8 +364,52 @@ export async function ensureConfigReady(params: {
"Audit, status, health, logs, tasks list/audit, and doctor commands still run with invalid config.",
),
);
if (!allowInvalid) {
params.runtime.exit(1);
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,
...configSnapshotOptions,
})
).snapshot;
if (retrySnapshot.exists && !retrySnapshot.valid) {
const retryIssues = formatConfigIssueLines(retrySnapshot.issues, "-", {
normalizeRoot: true,
});
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);
}
}
+23
View File
@@ -11,6 +11,7 @@ const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord");
const setVerboseMock = vi.fn();
const emitCliBannerMock = vi.fn();
type EnsureConfigReadyOptions = {
allowInvalid?: boolean;
beforeStateMigrations?: () => Promise<boolean>;
commandPath?: string[];
requireConfig?: boolean;
@@ -167,11 +168,13 @@ describe("registerPreActionHooks", () => {
.action(() => {});
const gateway = programLocal
.command("gateway")
.option("--allow-unconfigured")
.option("--force")
.option("--reset")
.action(() => {});
gateway
.command("run")
.option("--allow-unconfigured")
.option("--force")
.option("--reset")
.action(() => {});
@@ -340,6 +343,26 @@ describe("registerPreActionHooks", () => {
});
});
it("passes --allow-unconfigured through as an invalid-config override", async () => {
const gatewayRunCommand = resolveActionCommand(["gateway", "run"]);
gatewayRunCommand.setOptionValueWithSource("allowUnconfigured", true, "cli");
try {
await runPreAction({
parseArgv: ["gateway", "run"],
processArgv: ["node", "openclaw", "gateway", "run", "--allow-unconfigured"],
});
} finally {
gatewayRunCommand.setOptionValueWithSource("allowUnconfigured", false, "default");
}
expect(ensureConfigReadyMock).toHaveBeenCalledWith(
expect.objectContaining({
allowInvalid: true,
commandPath: ["gateway", "run"],
}),
);
});
it("loads plugins for text local agent runs", async () => {
await runPreAction({
parseArgv: ["agent"],
+3 -1
View File
@@ -144,6 +144,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
let beforeStateMigrations: ((snapshot?: ConfigFileSnapshot) => Promise<boolean>) | undefined;
let skipPristineStartupStateMigrations = false;
let skipPristineCoreStateMigrations = false;
let allowInvalid = shouldAllowInvalidConfigForAction(actionCommand, commandPath);
if (isGatewayRunAction(actionCommand)) {
const {
prepareGatewayRunBootstrap,
@@ -153,6 +154,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
} = await import("../gateway-cli/pre-bootstrap.js");
const { resolveGatewayRunOptions } = await import("../gateway-cli/run-options.js");
const resolvedOptions = resolveGatewayRunOptions(actionCommand.opts(), actionCommand);
allowInvalid ||= resolvedOptions.allowUnconfigured === true;
const opts = {
force: resolvedOptions.force === true,
reset: resolvedOptions.reset === true,
@@ -174,7 +176,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
runtime: defaultRuntime,
commandPath,
startupPolicy,
allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath),
allowInvalid,
...(beforeStateMigrations ? { beforeStateMigrations } : {}),
...(skipPristineStartupStateMigrations ? { skipPristineStartupStateMigrations: true } : {}),
...(skipPristineCoreStateMigrations ? { skipPristineCoreStateMigrations: true } : {}),
+9
View File
@@ -4,6 +4,7 @@ import { createDedupeCache } from "../infra/dedupe.js";
import {
createInvalidConfigError,
formatInvalidConfigDetails,
isDoctorRecoverableInvalidConfigError,
isInvalidConfigError,
throwInvalidConfig,
} from "./io.invalid-config.js";
@@ -38,6 +39,14 @@ describe("config io invalid config formatting", () => {
expect(err.code).toBe("INVALID_CONFIG");
expect(err.details).toBe("- gateway.port: bad");
expect(isInvalidConfigError(err)).toBe(true);
expect(isDoctorRecoverableInvalidConfigError(err)).toBe(true);
expect(
isDoctorRecoverableInvalidConfigError(
createInvalidConfigError("/tmp/openclaw.json", "manual repair", {
recovery: "manual",
}),
),
).toBe(false);
expect(
isInvalidConfigError(Object.assign(new Error(err.message), { code: "INVALID_CONFIG" })),
).toBe(true);
+18 -3
View File
@@ -43,22 +43,37 @@ function logInvalidConfigOnce(params: {
}
/** Creates the tagged error shape used by callers that need details after catch. */
export function createInvalidConfigError(configPath: string, details: string): Error {
export function createInvalidConfigError(
configPath: string,
details: string,
options: { recovery?: "doctor" | "manual" } = {},
): Error {
const error = new Error(`Invalid config at ${configPath}:\n${details}`);
// Keep metadata non-class-based so cross-module callers can inspect plain Error instances.
error.name = "InvalidConfigError";
(error as { code?: "INVALID_CONFIG"; details?: string }).code = "INVALID_CONFIG";
(error as { code?: "INVALID_CONFIG"; details?: string }).details = details;
const tagged = error as {
code?: "INVALID_CONFIG";
details?: string;
recovery?: "doctor" | "manual";
};
tagged.code = "INVALID_CONFIG";
tagged.details = details;
tagged.recovery = options.recovery ?? "doctor";
return error;
}
export function isInvalidConfigError(err: unknown): err is Error & {
code: "INVALID_CONFIG";
details?: string;
recovery?: "doctor" | "manual";
} {
return extractErrorCode(err) === "INVALID_CONFIG";
}
export function isDoctorRecoverableInvalidConfigError(err: unknown): boolean {
return isInvalidConfigError(err) && err.recovery !== "manual";
}
/** Logs and throws the standard invalid-config error for a validation result. */
export function throwInvalidConfig(params: {
configPath: string;
+4 -1
View File
@@ -123,6 +123,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
throw createInvalidConfigError(
configSnapshot.path,
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
{ recovery: "manual" },
);
}
if (configSnapshot.exists) {
@@ -438,7 +439,9 @@ function assertValidGatewayStartupConfigSnapshot(
: options.includeDoctorHint
? `\n${formatInvalidConfigRecoveryHint()}`
: "";
throw createInvalidConfigError(snapshot.path, `${issues}${recoveryHint}`);
throw createInvalidConfigError(snapshot.path, `${issues}${recoveryHint}`, {
recovery: isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) ? "manual" : "doctor",
});
}
/** Prepare the effective Gateway startup config after auth, overrides, and secrets activation. */