mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
762f04da5a
commit
8fb462f84c
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user