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,39 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { withTempDir } from "../../test-helpers/temp-dir.js";
import {
assertAuthProfileMigrationReady,
clearAuthProfileMigrationDiagnostics,
listAuthProfileStoresRequiringMigration,
} from "./legacy-source-diagnostic.js";
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
afterEach(() => {
clearAuthProfileMigrationDiagnostics();
});
describe("listAuthProfileStoresRequiringMigration", () => {
it("reports only credential sources without marking runtime migration state", async () => {
await withTempDir({ prefix: "openclaw-auth-migration-diagnostic-" }, async (root) => {
const credentialAgentDir = path.join(root, "credential-agent");
const authStateAgentDir = path.join(root, "auth-state-agent");
const env = { OPENCLAW_STATE_DIR: path.join(root, "state") };
await fs.mkdir(credentialAgentDir, { recursive: true });
await fs.mkdir(authStateAgentDir, { recursive: true });
const credentialPath = path.join(credentialAgentDir, "auth-profiles.json");
await fs.writeFile(credentialPath, "{}\n");
await fs.writeFile(path.join(authStateAgentDir, "auth-state.json"), "{}\n");
expect(
listAuthProfileStoresRequiringMigration({
agentDirs: [authStateAgentDir, credentialAgentDir, credentialAgentDir],
env,
}),
).toEqual([resolveAuthProfileDatabasePath(credentialAgentDir)]);
await fs.rm(credentialPath);
expect(() => assertAuthProfileMigrationReady(credentialAgentDir)).not.toThrow();
});
});
});
@@ -7,7 +7,7 @@ import { resolveSharedMainAuthAgentDir } from "./shared-main-dir.js";
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
const AUTH_PROFILE_MIGRATION_REQUIRED_CODE = "AUTH_PROFILE_MIGRATION_REQUIRED" as const;
const AUTH_PROFILE_MIGRATION_COMMAND = "openclaw doctor --fix" as const;
export const AUTH_PROFILE_MIGRATION_COMMAND = "openclaw doctor --fix" as const;
const log = createSubsystemLogger("auth-profiles/persistence");
type LegacyAuthProfileSourceKind = "auth-profiles" | "auth-state" | "legacy-auth" | "legacy-oauth";
@@ -17,6 +17,10 @@ type LegacyAuthProfileSource = {
path: string;
};
function isCredentialSource(source: LegacyAuthProfileSource): boolean {
return source.kind !== "auth-state";
}
export function resolveLegacyOAuthPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveOAuthDir(env), "oauth.json");
}
@@ -79,20 +83,33 @@ export function listLegacyAuthProfileArchives(params: {
}
export function hasLegacyAuthProfileCredentialSource(agentDir?: string): boolean {
return listLegacyAuthProfileSources({ agentDir }).some((source) => source.kind !== "auth-state");
return listLegacyAuthProfileSources({ agentDir }).some(isCredentialSource);
}
function listStartupLegacyAuthProfileSources(params: {
agentDirs: readonly string[];
env?: NodeJS.ProcessEnv;
}): Array<{
agentDir: string;
sources: LegacyAuthProfileSource[];
credentialSources: LegacyAuthProfileSource[];
}> {
const sharedMainDir = resolveSharedMainAuthAgentDir(params.env);
return [...new Set([...params.agentDirs, sharedMainDir])].map((agentDir) => {
const sources = listLegacyAuthProfileSources({ agentDir, env: params.env });
return { agentDir, sources, credentialSources: sources.filter(isCredentialSource) };
});
}
export function hasLegacyAuthProfileSourcesForStartup(params: {
agentDirs: readonly string[];
env?: NodeJS.ProcessEnv;
}): boolean {
const sharedMainDir = resolveSharedMainAuthAgentDir(params.env);
const candidates = new Set([...params.agentDirs, sharedMainDir]);
let detected = false;
for (const agentDir of candidates) {
const sources = listLegacyAuthProfileSources({ agentDir, env: params.env });
for (const { agentDir, sources, credentialSources } of listStartupLegacyAuthProfileSources(
params,
)) {
detected ||= sources.length > 0;
const credentialSources = sources.filter((source) => source.kind !== "auth-state");
if (credentialSources.length > 0) {
markAuthProfileMigrationRequired(
agentDir,
@@ -103,6 +120,17 @@ export function hasLegacyAuthProfileSourcesForStartup(params: {
return detected;
}
/** Agent auth stores whose retired credential files make gateway startup fail until Doctor migrates them. */
export function listAuthProfileStoresRequiringMigration(params: {
agentDirs: readonly string[];
env?: NodeJS.ProcessEnv;
}): string[] {
const owners = listStartupLegacyAuthProfileSources(params)
.filter(({ credentialSources }) => credentialSources.length > 0)
.map(({ agentDir }) => shortenHomePath(resolveAuthProfileDatabasePath(agentDir)));
return [...new Set(owners)].toSorted();
}
export class AuthProfileMigrationRequiredError extends Error {
readonly code = AUTH_PROFILE_MIGRATION_REQUIRED_CODE;
readonly action = AUTH_PROFILE_MIGRATION_COMMAND;
@@ -179,9 +207,7 @@ export function assertAuthProfileMigrationReady(agentDir?: string): void {
}
// Older shipped processes and restores can recreate these three fixed files
// after startup, so this credential boundary deliberately rechecks their names.
const sources = listLegacyAuthProfileSources({ agentDir }).filter(
(source) => source.kind !== "auth-state",
);
const sources = listLegacyAuthProfileSources({ agentDir }).filter(isCredentialSource);
if (sources.length > 0) {
const migrationError = new AuthProfileMigrationRequiredError({ agentDir, sources });
markAuthProfileMigrationRequired(agentDir, migrationError);
@@ -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