diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 8794b57e03df..be6220e69c41 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -33,6 +33,7 @@ openclaw gateway run # equivalent, explicit form - 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. diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 076f38d8dca9..8f191b242aa5 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -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; 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 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"]); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 6afdffdcf701..dcb0d19f72b8 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -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, diff --git a/src/cli/invalid-config-recovery.test.ts b/src/cli/invalid-config-recovery.test.ts new file mode 100644 index 000000000000..ba681acb903a --- /dev/null +++ b/src/cli/invalid-config-recovery.test.ts @@ -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 }); + }); +}); diff --git a/src/cli/invalid-config-recovery.ts b/src/cli/invalid-config-recovery.ts new file mode 100644 index 000000000000..e76b0e46948c --- /dev/null +++ b/src/cli/invalid-config-recovery.ts @@ -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 = + | { status: "declined" } + | { status: "recovered"; value: T } + | { status: "retry-failed" }; + +export type InvalidConfigRecoveryDeps = { + confirm?: (question: string, defaultYes: boolean) => Promise; + isInteractive?: () => boolean; + runDoctor?: (runtime: RuntimeEnv) => Promise; +}; + +/** Offer a consent-gated doctor repair, then retry the failed operation once. */ +export async function offerInvalidConfigRecovery(params: { + runtime: RuntimeEnv; + retry: () => Promise; + deps?: InvalidConfigRecoveryDeps; +}): Promise> { + 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" }; + } +} diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 57de193caeba..ded6a409c1d0 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -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(); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 8978a55a29c6..c91aa91f602b 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -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; - skipPristineCoreStateMigrations?: boolean; - skipPristineStartupStateMigrations?: boolean; -}): Promise { +export async function ensureConfigReady( + params: { + runtime: RuntimeEnv; + commandPath?: string[]; + suppressDoctorStdout?: boolean; + allowInvalid?: boolean; + beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise; + skipPristineCoreStateMigrations?: boolean; + skipPristineStartupStateMigrations?: boolean; + }, + recoveryDeps?: InvalidConfigRecoveryDeps, +): Promise { 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); } } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index f13361f03680..f5b3eba29b2f 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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; 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"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index e593896a2884..4b760741362e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -144,6 +144,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) let beforeStateMigrations: ((snapshot?: ConfigFileSnapshot) => Promise) | 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 } : {}), diff --git a/src/config/io.invalid-config.test.ts b/src/config/io.invalid-config.test.ts index a294624d2fc6..276f7d1a796a 100644 --- a/src/config/io.invalid-config.test.ts +++ b/src/config/io.invalid-config.test.ts @@ -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); diff --git a/src/config/io.invalid-config.ts b/src/config/io.invalid-config.ts index d2d75453e5cf..c505dd075392 100644 --- a/src/config/io.invalid-config.ts +++ b/src/config/io.invalid-config.ts @@ -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; diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 37c9c43c0647..b22e112616af 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -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. */