fix: gateway restart no longer takes the service down into a known-fatal auth migration (#114715)

* fix(gateway): refuse to restart the service into a known-fatal auth migration

* refactor(cli): keep service action preflight types module-local
This commit is contained in:
Peter Steinberger
2026-07-27 16:18:43 -04:00
committed by GitHub
parent 762f04da5a
commit 8fb462f84c
6 changed files with 241 additions and 61 deletions
@@ -0,0 +1,65 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetConfigRuntimeState } from "../../config/config.js";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import { getServiceActionPreflightFailure } from "./lifecycle-action-preflight.js";
afterEach(() => {
resetConfigRuntimeState();
vi.unstubAllEnvs();
});
async function withIsolatedLifecycleState(
run: (params: { agentDir: string }) => Promise<void>,
): Promise<void> {
await withTempDir({ prefix: "openclaw-lifecycle-action-preflight-" }, async (root) => {
const stateDir = path.join(root, "state");
const configPath = path.join(root, "openclaw.json");
const agentDir = path.join(stateDir, "agents", "main", "agent");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(configPath, "{}\n");
vi.stubEnv("HOME", root);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
resetConfigRuntimeState();
await run({ agentDir });
});
}
describe("getServiceActionPreflightFailure", () => {
it.each(["start", "restart"] as const)(
"blocks %s when a legacy credential file exists",
async (action) => {
await withIsolatedLifecycleState(async ({ agentDir }) => {
await fs.writeFile(path.join(agentDir, "auth-profiles.json"), "{}\n");
await expect(getServiceActionPreflightFailure(action)).resolves.toEqual({
message:
"Auth profile store ~/state/agents/main/agent/openclaw-agent.sqlite requires legacy credential migration.",
hints: ["Run `openclaw doctor --fix`, then retry this command."],
});
});
},
);
it.each(["stop", "uninstall"] as const)(
"allows %s with the same pending migration",
async (action) => {
await withIsolatedLifecycleState(async ({ agentDir }) => {
await fs.writeFile(path.join(agentDir, "auth-profiles.json"), "{}\n");
await expect(getServiceActionPreflightFailure(action)).resolves.toBeNull();
});
},
);
it.each(["start", "restart"] as const)(
"allows %s when no legacy credential files exist",
async (action) => {
await withIsolatedLifecycleState(async () => {
await expect(getServiceActionPreflightFailure(action)).resolves.toBeNull();
});
},
);
});
@@ -0,0 +1,96 @@
import {
AUTH_PROFILE_MIGRATION_COMMAND,
listAuthProfileStoresRequiringMigration,
} from "../../agents/auth-profiles/legacy-source-diagnostic.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../../config/recovery-policy.js";
import type { ConfigFileSnapshot } from "../../config/types.openclaw.js";
import { collectCandidateAgentDirs } from "../../secrets/runtime-fast-path.js";
import { formatPluginPackagingRuntimeOutputRecoveryHint } from "../config-recovery-hints.js";
/** Service lifecycle actions; only start/restart bring the gateway up. */
type DaemonServiceAction = "start" | "restart" | "stop" | "uninstall";
type ServiceActionPreflightFailure = {
message: string;
hints?: string[];
};
const ACTION_PROSE: Record<DaemonServiceAction, string> = {
start: "start the gateway service",
restart: "restart the gateway service",
stop: "stop the gateway service",
uninstall: "uninstall the gateway service",
};
const GATEWAY_LAUNCHING_ACTIONS = new Set<DaemonServiceAction>(["start", "restart"]);
function formatPluginPackagingRuntimeOutputRecoveryHints(): string[] {
return formatPluginPackagingRuntimeOutputRecoveryHint().split("\n");
}
/**
* Retired credential files make the gateway throw AuthProfileMigrationRequiredError during boot.
* Blocking here keeps the running service up instead of taking it down into a known-fatal state.
* Only launching actions are gated; stop/uninstall never read the auth store.
*/
function resolveAuthProfileMigrationBlock(
action: DaemonServiceAction,
snapshot: ConfigFileSnapshot,
): ServiceActionPreflightFailure | null {
if (!GATEWAY_LAUNCHING_ACTIONS.has(action) || !snapshot.valid) {
return null;
}
let stores: string[];
try {
stores = listAuthProfileStoresRequiringMigration({
agentDirs: collectCandidateAgentDirs(snapshot.runtimeConfig, process.env),
env: process.env,
});
} catch {
// A preflight must never be the reason a lifecycle command fails.
return null;
}
if (stores.length === 0) {
return null;
}
return {
message:
stores.length === 1
? `Auth profile store ${stores[0]} requires legacy credential migration.`
: `Auth profile stores ${stores.join(", ")} require legacy credential migration.`,
hints: [`Run \`${AUTH_PROFILE_MIGRATION_COMMAND}\`, then retry this command.`],
};
}
/** Best-effort validation before a service action mutates runtime state. */
export async function getServiceActionPreflightFailure(
action: DaemonServiceAction,
): Promise<ServiceActionPreflightFailure | null> {
let snapshot: ConfigFileSnapshot;
try {
snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const message =
snapshot.issues.length > 0
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
: "Unknown validation issue.";
return {
message,
...(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
? { hints: formatPluginPackagingRuntimeOutputRecoveryHints() }
: {}),
};
}
} catch {
return null;
}
const futureBlock = resolveFutureConfigActionBlock({ action: ACTION_PROSE[action], snapshot });
if (futureBlock) {
return { message: futureBlock.message, hints: futureBlock.hints };
}
return resolveAuthProfileMigrationBlock(action, snapshot);
}
@@ -1,46 +0,0 @@
import { readConfigFileSnapshot } from "../../config/config.js";
import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../../config/recovery-policy.js";
import { formatPluginPackagingRuntimeOutputRecoveryHint } from "../config-recovery-hints.js";
type ConfigActionPreflightFailure = {
message: string;
hints?: string[];
};
function formatPluginPackagingRuntimeOutputRecoveryHints(): string[] {
return formatPluginPackagingRuntimeOutputRecoveryHint().split("\n");
}
/** Best-effort validation before a service action mutates runtime state. */
export async function getConfigActionPreflightFailure(
action: string,
): Promise<ConfigActionPreflightFailure | null> {
let snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
try {
snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const message =
snapshot.issues.length > 0
? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n")
: "Unknown validation issue.";
return {
message,
...(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
? { hints: formatPluginPackagingRuntimeOutputRecoveryHints() }
: {}),
};
}
} catch {
return null;
}
const futureBlock = resolveFutureConfigActionBlock({ action, snapshot });
return futureBlock
? {
message: futureBlock.message,
hints: futureBlock.hints,
}
: null;
}
+5 -5
View File
@@ -24,11 +24,11 @@ import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
import { formatInvalidConfigRecoveryHint } from "../config-recovery-hints.js";
import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js";
import { getServiceActionPreflightFailure } from "./lifecycle-action-preflight.js";
import {
appendServiceLifecycleRepairAudit,
createServiceLifecycleMutationAudit,
} from "./lifecycle-audit.js";
import { getConfigActionPreflightFailure } from "./lifecycle-config-preflight.js";
import {
buildDaemonServiceSnapshot,
createDaemonActionContext,
@@ -150,7 +150,7 @@ export async function runServiceUninstall(params: {
}
{
const preflight = await getConfigActionPreflightFailure("uninstall the gateway service");
const preflight = await getServiceActionPreflightFailure("uninstall");
if (preflight) {
fail(`${params.serviceNoun} uninstall blocked: ${preflight.message}`, preflight.hints);
return;
@@ -218,7 +218,7 @@ export async function runServiceStart(params: {
// Pre-flight config validation (#35862) — run for both loaded and not-loaded
// to prevent launching from invalid config in any start path.
{
const preflight = await getConfigActionPreflightFailure("start the gateway service");
const preflight = await getServiceActionPreflightFailure("start");
if (preflight) {
fail(
preflight.hints
@@ -371,7 +371,7 @@ export async function runServiceStop(params: {
return;
}
{
const preflight = await getConfigActionPreflightFailure("stop the gateway service");
const preflight = await getServiceActionPreflightFailure("stop");
if (preflight) {
fail(`${params.serviceNoun} stop blocked: ${preflight.message}`, preflight.hints);
return;
@@ -521,7 +521,7 @@ export async function runServiceRestart(params: {
// Pre-flight config validation: check before any restart action (including
// onNotLoaded which may send SIGUSR1 to an unmanaged process). (#35862)
{
const preflight = await getConfigActionPreflightFailure("restart the gateway service");
const preflight = await getServiceActionPreflightFailure("restart");
if (preflight) {
fail(
preflight.hints