fix: preserve Gateway when setup Reinstall is cancelled (#130617)

Keep replacement preparation ahead of service mutation in configure and onboarding. Let platform installers own replacement, and report surviving Gateway reachability independently of failed setup.
This commit is contained in:
Peter Steinberger
2026-08-26 20:07:50 -07:00
committed by GitHub
parent a872abc978
commit 682ddcb28c
8 changed files with 206 additions and 73 deletions
+1
View File
@@ -55,6 +55,7 @@ When configure starts from a provider auth choice, the default-model and model-p
- After local config writes, configure installs selected downloadable plugins when the chosen setup path requires them. Remote gateway config does not install local plugin packages.
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
- Choosing **Reinstall** keeps the existing Gateway service in place while you select its runtime and configure validates authentication and prepares the replacement. Cancelling or failing during preparation leaves the existing service installed.
- If you run the daemon install step, token auth requires a token. If `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata; if the SecretRef is unresolved, configure blocks daemon install with actionable remediation guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, configure blocks daemon install until you set the mode explicitly.
+71 -11
View File
@@ -1,5 +1,7 @@
// Configure daemon tests cover daemon install prompts, progress labels, and runtime install calls.
import { PassThrough } from "node:stream";
import { select as clackSelect } from "@clack/prompts";
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { maybeInstallDaemon } from "./configure.daemon.js";
@@ -14,13 +16,14 @@ const buildGatewayInstallPlan = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());
const serviceIsLoaded = vi.hoisted(() => vi.fn(async () => false));
const serviceInstall = vi.hoisted(() => vi.fn(async () => {}));
const serviceUninstall = vi.hoisted(() => vi.fn(async () => {}));
const serviceRestart = vi.hoisted(() =>
vi.fn<() => Promise<{ outcome: "completed" } | { outcome: "scheduled" }>>(async () => ({
outcome: "completed",
})),
);
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
const select = vi.hoisted(() => vi.fn(async () => "node"));
const select = vi.hoisted(() => vi.fn<() => Promise<string | symbol>>(async () => "node"));
vi.mock("../cli/progress.js", () => ({
withProgress,
@@ -49,11 +52,6 @@ vi.mock("./configure.shared.js", () => ({
select,
}));
vi.mock("./daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }],
}));
vi.mock("../daemon/service.js", async () => {
const actual =
await vi.importActual<typeof import("../daemon/service.js")>("../daemon/service.js");
@@ -62,15 +60,12 @@ vi.mock("../daemon/service.js", async () => {
resolveGatewayService: vi.fn(() => ({
isLoaded: serviceIsLoaded,
install: serviceInstall,
uninstall: serviceUninstall,
restart: serviceRestart,
})),
};
});
vi.mock("./onboard-helpers.js", () => ({
guardCancel: (value: unknown) => value,
}));
vi.mock("./systemd-linger.js", () => ({
ensureSystemdUserLingerInteractive,
}));
@@ -81,6 +76,9 @@ describe("maybeInstallDaemon", () => {
progressSetLabel.mockReset();
serviceIsLoaded.mockResolvedValue(false);
serviceInstall.mockResolvedValue(undefined);
serviceUninstall.mockReset();
select.mockReset();
select.mockResolvedValue("node");
serviceRestart.mockResolvedValue({ outcome: "completed" });
loadConfig.mockReturnValue({});
resolveGatewayInstallToken.mockResolvedValue({
@@ -113,7 +111,11 @@ describe("maybeInstallDaemon", () => {
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("blocks install when token SecretRef is unresolved", async () => {
it.each([false, true])("blocks install with unresolved auth (reinstall=%s)", async (loaded) => {
serviceIsLoaded.mockResolvedValue(loaded);
if (loaded) {
select.mockResolvedValueOnce("reinstall");
}
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
@@ -133,6 +135,64 @@ describe("maybeInstallDaemon", () => {
);
expect(buildGatewayInstallPlan).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
expect(serviceUninstall).not.toHaveBeenCalled();
});
it("keeps the installed service when runtime selection is cancelled", async () => {
serviceIsLoaded.mockResolvedValue(true);
const cancelled = await clackSelect({
message: "Runtime",
options: [{ value: "node", label: "Node" }],
signal: AbortSignal.abort(),
input: new PassThrough(),
output: new PassThrough(),
});
select.mockResolvedValueOnce("reinstall").mockResolvedValueOnce(cancelled);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("setup cancelled");
}),
};
await expect(maybeInstallDaemon({ runtime, port: 18789 })).rejects.toThrow("setup cancelled");
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(serviceUninstall).not.toHaveBeenCalled();
expect(resolveGatewayInstallToken).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
});
it("keeps the installed service when replacement planning fails", async () => {
serviceIsLoaded.mockResolvedValue(true);
select.mockResolvedValueOnce("reinstall");
buildGatewayInstallPlan.mockRejectedValueOnce(new Error("replacement plan failed"));
await expect(
maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
}),
).rejects.toThrow("replacement plan failed");
expect(serviceUninstall).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
});
it("hands the existing service to the replacement installer", async () => {
serviceIsLoaded.mockResolvedValue(true);
select.mockResolvedValueOnce("reinstall");
const outcome = await maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
});
expect(outcome).toBe("succeeded");
expect(serviceInstall).toHaveBeenCalledOnce();
expect(serviceUninstall).not.toHaveBeenCalled();
expect(serviceRestart).not.toHaveBeenCalled();
});
it("continues daemon install flow when service status probe throws", async () => {
+1 -10
View File
@@ -71,19 +71,10 @@ export async function maybeInstallDaemon(params: {
if (action === "skip") {
return "skipped";
}
if (action === "reinstall") {
await withProgress(
{ label: "Gateway service", indeterminate: true, delayMs: 0 },
async (progress) => {
progress.setLabel("Uninstalling Gateway service…");
await service.uninstall({ env: process.env, stdout: process.stdout });
progress.setLabel("Gateway service uninstalled.");
},
);
}
}
if (shouldInstall) {
// Keep the old service until preparation succeeds; install owns replacement.
let installError: string | null = null;
if (!params.daemonRuntime) {
if (GATEWAY_DAEMON_RUNTIME_OPTIONS.length === 1) {
-2
View File
@@ -1140,8 +1140,6 @@ export const en = {
gatewayServiceRestarted: "Gateway service restarted.",
gatewayServiceRestarting: "Restarting Gateway service...",
gatewayServiceRestartScheduled: "Gateway service restart scheduled.",
gatewayServiceUninstalled: "Gateway service uninstalled.",
gatewayServiceUninstalling: "Uninstalling Gateway service...",
gatewayTokenGenerate: "Generate token: {command}",
gatewayTokenShared: "Gateway token: shared auth for the Gateway + Control UI.",
gatewayTokenStored:
-2
View File
@@ -1103,8 +1103,6 @@ export const zh_CN = {
gatewayServiceRestarted: "Gateway 服务已重启。",
gatewayServiceRestarting: "正在重启 Gateway 服务...",
gatewayServiceRestartScheduled: "Gateway 服务重启已排队。",
gatewayServiceUninstalled: "Gateway 服务已卸载。",
gatewayServiceUninstalling: "正在卸载 Gateway 服务...",
gatewayTokenGenerate: "生成令牌:{command}",
gatewayTokenShared: "Gateway 令牌:Gateway 和 Control UI 的共享认证。",
gatewayTokenStored:
-2
View File
@@ -1104,8 +1104,6 @@ export const zh_TW = {
gatewayServiceRestarted: "Gateway 服務已重新啟動。",
gatewayServiceRestarting: "正在重新啟動 Gateway 服務...",
gatewayServiceRestartScheduled: "Gateway 服務重新啟動已排程。",
gatewayServiceUninstalled: "Gateway 服務已解除安裝。",
gatewayServiceUninstalling: "正在解除安裝 Gateway 服務...",
gatewayTokenGenerate: "產生權杖:{command}",
gatewayTokenShared: "Gateway 權杖:Gateway 和 Control UI 的共享認證。",
gatewayTokenStored:
+100 -2
View File
@@ -1111,6 +1111,37 @@ describe("finalizeSetupWizard", () => {
expect(gatewayServiceInstall).not.toHaveBeenCalled();
});
it.each([false, true])(
"detects the surviving gateway after failed reinstall (skipHealth=%s)",
async (skipHealth) => {
gatewayServiceIsLoaded.mockResolvedValue(true);
buildGatewayInstallPlan.mockRejectedValueOnce(new Error("replacement plan failed"));
probeGatewayReachable.mockResolvedValue({ ok: true });
const prompter = buildWizardPrompter({
select: vi.fn().mockResolvedValueOnce("reinstall").mockResolvedValueOnce("tui"),
});
await finalizeSetupWizard(
createFinalizeArgs("quickstart", {
opts: { installDaemon: true, skipHealth },
prompter,
}),
);
expect(gatewayServiceUninstall).not.toHaveBeenCalled();
expect(gatewayServiceInstall).not.toHaveBeenCalled();
expect(waitForGatewayReachable).not.toHaveBeenCalled();
expect(probeGatewayReachable).toHaveBeenCalledOnce();
expect(healthCommand).toHaveBeenCalledTimes(skipHealth ? 0 : 1);
expect(runTui).toHaveBeenCalledWith(
expect.objectContaining({ boundGateway: { url: "ws://127.0.0.1:18789" } }),
);
expectNoteContains(prompter, "replacement plan failed", "Gateway");
expectNoteNotContains(prompter, "Gateway: not detected");
expect(prompter.outro).toHaveBeenCalledWith(expect.stringContaining("setup failed"));
},
);
it("reports gateway installation failure without waiting for impossible health", async () => {
gatewayServiceInstall.mockRejectedValueOnce(new Error("service install exploded"));
const prompter = createLaterPrompter();
@@ -1124,10 +1155,10 @@ describe("finalizeSetupWizard", () => {
);
expect(waitForGatewayReachable).not.toHaveBeenCalled();
expect(probeGatewayReachable).not.toHaveBeenCalled();
expect(probeGatewayReachable).toHaveBeenCalledOnce();
expect(runtime.error).toHaveBeenCalledWith("health failed");
expectNoteContains(prompter, "service install exploded", "Gateway");
expectNoteContains(prompter, "Gateway: not detected (service install exploded)", "Control UI");
expectNoteContains(prompter, "Gateway: not detected (offline)", "Control UI");
expect(prompter.outro).toHaveBeenCalledWith(
expect.stringContaining("managed Mock Platform Service setup failed"),
);
@@ -1442,6 +1473,73 @@ describe("finalizeSetupWizard", () => {
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
});
it.each(["auth", "planning"])(
"preserves the installed service when reinstall %s fails",
async (failure) => {
let installed = true;
gatewayServiceIsLoaded.mockImplementation(async () => installed);
gatewayServiceUninstall.mockImplementationOnce(async () => {
installed = false;
});
if (failure === "auth") {
resolveGatewayInstallToken.mockImplementationOnce(async () => ({
token: undefined,
tokenRefConfigured: true,
warnings: [],
unavailableReason: "replacement auth unavailable",
}));
} else {
buildGatewayInstallPlan.mockRejectedValueOnce(new Error("replacement plan failed"));
}
const prompter = buildWizardPrompter({ select: vi.fn(async () => "reinstall") as never });
const result = await ensureGatewayServiceForOnboarding(
createFinalizeArgs("quickstart", { opts: { installDaemon: true }, prompter }),
);
expect(result.gateway.status).toBe("failed");
expect(installed).toBe(true);
expect(gatewayServiceInstall).not.toHaveBeenCalled();
},
);
it("passes the existing service intact to the reinstall owner", async () => {
let installed = true;
gatewayServiceIsLoaded.mockImplementation(async () => installed);
gatewayServiceUninstall.mockImplementationOnce(async () => {
installed = false;
});
gatewayServiceInstall.mockImplementationOnce(async () => {
expect(installed).toBe(true);
});
const prompter = buildWizardPrompter({ select: vi.fn(async () => "reinstall") as never });
const result = await ensureGatewayServiceForOnboarding(
createFinalizeArgs("quickstart", { opts: { installDaemon: true }, prompter }),
);
expect(result.gateway).toEqual({ status: "ready", action: "installed" });
expect(gatewayServiceInstall).toHaveBeenCalledOnce();
expect(gatewayServiceUninstall).not.toHaveBeenCalled();
});
it.each(["skip", "restart"])("does not turn %s into an implicit reinstall", async (action) => {
gatewayServiceIsLoaded.mockResolvedValueOnce(true).mockResolvedValue(false);
const prompter = buildWizardPrompter({ select: vi.fn(async () => action) as never });
const result = await ensureGatewayServiceForOnboarding(
createFinalizeArgs("quickstart", { opts: { installDaemon: true }, prompter }),
);
expect(result.gateway).toEqual({
status: "ready",
action: action === "restart" ? "restarted" : "reused",
});
expect(gatewayServiceInstall).not.toHaveBeenCalled();
expect(gatewayServiceUninstall).not.toHaveBeenCalled();
expect(gatewayServiceRestart).toHaveBeenCalledTimes(action === "restart" ? 1 : 0);
});
it("localizes finalize non-prompt notes", async () => {
const previousLocale = process.env.OPENCLAW_LOCALE;
process.env.OPENCLAW_LOCALE = "zh-CN";
+33 -44
View File
@@ -182,7 +182,7 @@ function buildGatewayRecoveryProjection(params: {
} {
const { gateway } = params;
const notDetected = t("wizard.finalize.gatewayNotDetected");
if (params.reachable) {
if (params.reachable && gateway.status !== "failed") {
return { detail: t("wizard.finalize.gatewayReachable"), summary: t("wizard.guided.complete") };
}
if (gateway.status === "ready") {
@@ -202,7 +202,10 @@ function buildGatewayRecoveryProjection(params: {
statusCommand: formatCliCommand("openclaw gateway status --deep"),
recoveryCommand: formatCliCommand("openclaw gateway install --force"),
});
return { detail, summary: `${notDetected} ${detail.replaceAll("\n", " ")}` };
return {
detail,
summary: `${params.reachable ? "" : `${notDetected} `}${detail.replaceAll("\n", " ")}`,
};
}
const startGuidance =
@@ -388,7 +391,7 @@ export async function ensureGatewayServiceForOnboarding(params: {
}
}
const loaded = await service.isLoaded({ env: process.env });
let restartWasScheduled = false;
let shouldInstall = !loaded;
if (loaded) {
const action =
(params.loadedAction === "restart" ? params.loadedAction : undefined) ??
@@ -415,7 +418,6 @@ export async function ensureGatewayServiceForOnboarding(params: {
restartDoneMessage = restartStatus.scheduled
? t("wizard.finalize.gatewayServiceRestartScheduled")
: t("wizard.finalize.gatewayServiceRestarted");
restartWasScheduled = restartStatus.scheduled;
gateway = {
status: "ready",
action: restartStatus.scheduled ? "restart-scheduled" : "restarted",
@@ -423,21 +425,12 @@ export async function ensureGatewayServiceForOnboarding(params: {
},
);
} else if (action === "reinstall") {
await withWizardProgress(
t("wizard.finalize.gatewayService"),
{ doneMessage: t("wizard.finalize.gatewayServiceUninstalled") },
async (progress) => {
progress.update(t("wizard.finalize.gatewayServiceUninstalling"));
await service.uninstall({ env: process.env, stdout: process.stdout });
},
);
// Preserve the old definition so the install owner can replace or restore it.
shouldInstall = true;
}
}
if (
!loaded ||
(!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env })))
) {
if (shouldInstall) {
const progress = prompter.progress(t("wizard.finalize.gatewayService"));
let installError: string | null = null;
const installWarnings: Array<{ message: string; title?: string }> = [];
@@ -531,9 +524,6 @@ export async function finalizeSetupWizard(
prompter,
runtime,
});
if (gateway.status === "failed") {
gatewayProbe = { ok: false, detail: gateway.error };
}
if (settings.authMode === "password") {
try {
@@ -573,17 +563,17 @@ export async function finalizeSetupWizard(
basePath: undefined,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
// A failed installation cannot become healthy; preserve its authoritative
// error instead of masking it behind a slow, guaranteed-to-fail probe.
if (gateway.status !== "failed") {
// Install/restart can briefly flap the WS; wait before checking health.
gatewayProbe = await waitForGatewayReachable({
url: probeLinks.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : undefined,
deadlineMs: 15_000,
});
}
const probeOptions = {
url: probeLinks.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
password: settings.authMode === "password" ? resolvedGatewayPassword : undefined,
};
// A failed replacement may leave the old Gateway alive. Observe it once;
// only successful install/restart needs the startup grace period.
gatewayProbe =
gateway.status === "failed"
? await probeGatewayReachable(probeOptions)
: await waitForGatewayReachable({ ...probeOptions, deadlineMs: 15_000 });
if (gatewayProbe.ok) {
try {
const healthConfig: OpenClawConfig =
@@ -684,7 +674,7 @@ export async function finalizeSetupWizard(
basePath: controlUiBasePath,
tlsEnabled: nextConfig.gateway?.tls?.enabled === true,
});
if (gateway.status !== "failed" && (opts.skipHealth || !gatewayProbe.ok)) {
if (opts.skipHealth || (!gatewayProbe.ok && gateway.status !== "failed")) {
gatewayProbe = await probeGatewayReachable({
url: probeLinks.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@@ -993,12 +983,17 @@ export async function finalizeSetupWizard(
await prompter.note(t("wizard.finalize.whatNow"), t("wizard.finalize.whatNowTitle"));
await prompter.outro(
gatewayProbe.ok && gatewayHealthCheckFailed
? t("wizard.finalize.outroHealthCheckFailed", {
command: formatCliCommand("openclaw health"),
})
: gatewayProbe.ok
? dashboardReady
!gatewayProbe.ok || gateway.status === "failed"
? buildGatewayRecoveryProjection({
gateway,
reachable: gatewayProbe.ok,
serviceLabel: gateway.status === "skipped" ? undefined : resolveGatewayService().label,
}).summary
: gatewayHealthCheckFailed
? t("wizard.finalize.outroHealthCheckFailed", {
command: formatCliCommand("openclaw health"),
})
: dashboardReady
? t("wizard.finalize.outroDashboardLink")
: controlUiEnabled
? [
@@ -1007,13 +1002,7 @@ export async function finalizeSetupWizard(
command: formatCliCommand("openclaw dashboard"),
}),
].join(" ")
: t("wizard.guided.complete")
: buildGatewayRecoveryProjection({
gateway,
reachable: false,
serviceLabel:
gateway.status === "skipped" ? undefined : resolveGatewayService().label,
}).summary,
: t("wizard.guided.complete"),
);
if (shouldLaunchTui) {