mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
ccf55a43cd
commit
1710313596
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user