mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(gateway): hosted setup exits no longer stop the gateway (#115671)
Keep successful and failed setup/channel wizard exits inside their owning session. Preserve the shared Gateway and existing protocol; prove both flows with authenticated WebSocket E2E. Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: TheAngryPit <16145902+TheAngryPit@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
56e488a30f
commit
1dd28f1bca
@@ -3,7 +3,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearConfigCache,
|
||||
clearRuntimeConfigSnapshot,
|
||||
@@ -841,6 +841,79 @@ module.exports = {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ flow: "setup", exitCode: 0, status: "done" },
|
||||
{ flow: "setup", exitCode: 23, status: "error" },
|
||||
{ flow: "channels", exitCode: 0, status: "done" },
|
||||
{ flow: "channels", exitCode: 23, status: "error" },
|
||||
] as const)(
|
||||
"keeps the authenticated Gateway alive after a $flow wizard exits $exitCode",
|
||||
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
|
||||
async ({ flow, exitCode, status }) => {
|
||||
const { envSnapshot, tempHome } = await setupGatewayTempHome({
|
||||
prefix: `openclaw-wizard-${flow}-exit-home-`,
|
||||
minimalGateway: true,
|
||||
});
|
||||
const wizardToken = nextGatewayId("wiz-contained-exit");
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token: wizardToken },
|
||||
controlUiEnabled: false,
|
||||
wizardRunner: async (_opts, runtime, prompter) => {
|
||||
await prompter.outro("wizard complete");
|
||||
runtime.exit(exitCode);
|
||||
},
|
||||
channelWizardRunner: async (_opts, runtime, prompter) => {
|
||||
await prompter.outro("channel wizard complete");
|
||||
runtime.exit(exitCode);
|
||||
},
|
||||
});
|
||||
const client = await connectGatewayClient({
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
token: wizardToken,
|
||||
clientDisplayName: "vitest-wizard-contained-exit",
|
||||
});
|
||||
// Intercept an actual host exit so the fail-first Gateway test cannot
|
||||
// terminate its Vitest worker before reporting the regression.
|
||||
const processExit = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Gateway process exit ${code}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const start = await client.request<{
|
||||
sessionId: string;
|
||||
done: boolean;
|
||||
status: "running" | "done" | "cancelled" | "error";
|
||||
step?: { id: string };
|
||||
}>("wizard.start", flow === "channels" ? { flow } : { mode: "local" });
|
||||
expect(start).toMatchObject({ done: false, status: "running" });
|
||||
expect(start.step?.id).toBeTruthy();
|
||||
|
||||
const result = await client.request<{
|
||||
done: boolean;
|
||||
status: "running" | "done" | "cancelled" | "error";
|
||||
error?: string;
|
||||
}>("wizard.next", {
|
||||
sessionId: start.sessionId,
|
||||
answer: { stepId: start.step?.id, value: null },
|
||||
});
|
||||
expect(result).toMatchObject({ done: true, status });
|
||||
if (exitCode !== 0) {
|
||||
expect(result.error).toContain(String(exitCode));
|
||||
}
|
||||
expect(processExit).not.toHaveBeenCalled();
|
||||
await expect(client.request("health", {})).resolves.toBeDefined();
|
||||
} finally {
|
||||
processExit.mockRestore();
|
||||
await disconnectGatewayClient(client);
|
||||
await server.close({ reason: "wizard runtime isolation E2E complete" });
|
||||
await removeGatewayTempHome(tempHome);
|
||||
envSnapshot.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"routes wizard.start flow channels to the channel wizard runner",
|
||||
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Wizard server-method tests cover stable lifecycle errors for process-local sessions.
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import { createWizardSessionTracker } from "../server-wizard-sessions.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
import { wizardHandlers } from "./wizard.js";
|
||||
|
||||
@@ -33,3 +36,71 @@ describe("wizard session lookup", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosted wizard runtime isolation", () => {
|
||||
it.each([
|
||||
{ flow: "setup", exitCode: 0, status: "done" },
|
||||
{ flow: "setup", exitCode: 23, status: "error" },
|
||||
{ flow: "channels", exitCode: 0, status: "done" },
|
||||
{ flow: "channels", exitCode: 23, status: "error" },
|
||||
] as const)(
|
||||
"contains a $flow wizard exit $exitCode without exiting the Gateway",
|
||||
async ({ flow, exitCode, status }) => {
|
||||
const processExit = vi.spyOn(process, "exit").mockImplementation((code) => {
|
||||
throw new Error(`Gateway process exit ${code}`);
|
||||
});
|
||||
const tracker = createWizardSessionTracker();
|
||||
const runner = async (runtime: RuntimeEnv, prompter: WizardPrompter) => {
|
||||
await prompter.outro("wizard complete");
|
||||
runtime.exit(exitCode);
|
||||
};
|
||||
const context = {
|
||||
...tracker,
|
||||
wizardRunner: async (_opts: unknown, runtime: RuntimeEnv, prompter: WizardPrompter) =>
|
||||
runner(runtime, prompter),
|
||||
channelWizardRunner: async (
|
||||
_opts: unknown,
|
||||
runtime: RuntimeEnv,
|
||||
prompter: WizardPrompter,
|
||||
) => runner(runtime, prompter),
|
||||
};
|
||||
|
||||
try {
|
||||
const startRespond = vi.fn();
|
||||
await expectDefined(
|
||||
wizardHandlers["wizard.start"],
|
||||
"wizard.start test invariant",
|
||||
)({
|
||||
params: flow === "channels" ? { flow } : { mode: "local" },
|
||||
respond: startRespond,
|
||||
context,
|
||||
} as never);
|
||||
expect(startRespond).toHaveBeenCalledOnce();
|
||||
const [, start] = startRespond.mock.calls[0] ?? [];
|
||||
expect(start).toMatchObject({ done: false, status: "running" });
|
||||
|
||||
const nextRespond = vi.fn();
|
||||
await expectDefined(
|
||||
wizardHandlers["wizard.next"],
|
||||
"wizard.next test invariant",
|
||||
)({
|
||||
params: {
|
||||
sessionId: start.sessionId,
|
||||
answer: { stepId: start.step.id, value: null },
|
||||
},
|
||||
respond: nextRespond,
|
||||
context,
|
||||
} as never);
|
||||
expect(nextRespond).toHaveBeenCalledOnce();
|
||||
const [, result] = nextRespond.mock.calls[0] ?? [];
|
||||
expect(result).toMatchObject({ done: true, status });
|
||||
if (exitCode !== 0) {
|
||||
expect(result.error).toContain(String(exitCode));
|
||||
}
|
||||
expect(processExit).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
processExit.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
validateWizardStatusParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { OnboardOptions } from "../../commands/onboard-types.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createNonExitingRuntime, ExitError, type RuntimeEnv } from "../../runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import { WizardSession } from "../../wizard/session.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
@@ -45,6 +45,19 @@ export const runDefaultChannelSetupWizard: ChannelSetupWizardRunner = async (...
|
||||
return runChannelsSetupWizard(...args);
|
||||
};
|
||||
|
||||
async function runHostedWizard(run: (runtime: RuntimeEnv) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await run(createNonExitingRuntime());
|
||||
} catch (error) {
|
||||
// Hosted wizards share the Gateway process; a successful CLI-style exit
|
||||
// must complete only its session, while failures remain session errors.
|
||||
if (error instanceof ExitError && error.code === 0) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readWizardStatus(session: WizardSession) {
|
||||
return {
|
||||
status: session.getStatus(),
|
||||
@@ -88,26 +101,30 @@ export const wizardHandlers: GatewayRequestHandlers = {
|
||||
const session =
|
||||
flow === "channels"
|
||||
? new WizardSession((prompter, _signal, wizardSession) =>
|
||||
context.channelWizardRunner(
|
||||
{
|
||||
channel: readStringValue(params.channel),
|
||||
onConfigured: (accounts) => wizardSession.setConfiguredAccounts(accounts),
|
||||
// Durable effects (plugin installs, config commit) must finish
|
||||
// even if the client cancels mid-write.
|
||||
beforePersistentEffect: async () => wizardSession.lockCancellation(),
|
||||
},
|
||||
defaultRuntime,
|
||||
prompter,
|
||||
runHostedWizard((runtime) =>
|
||||
context.channelWizardRunner(
|
||||
{
|
||||
channel: readStringValue(params.channel),
|
||||
onConfigured: (accounts) => wizardSession.setConfiguredAccounts(accounts),
|
||||
// Durable effects (plugin installs, config commit) must finish
|
||||
// even if the client cancels mid-write.
|
||||
beforePersistentEffect: async () => wizardSession.lockCancellation(),
|
||||
},
|
||||
runtime,
|
||||
prompter,
|
||||
),
|
||||
),
|
||||
)
|
||||
: new WizardSession((prompter) =>
|
||||
context.wizardRunner(
|
||||
{
|
||||
mode: params.mode,
|
||||
workspace: readStringValue(params.workspace),
|
||||
},
|
||||
defaultRuntime,
|
||||
prompter,
|
||||
runHostedWizard((runtime) =>
|
||||
context.wizardRunner(
|
||||
{
|
||||
mode: params.mode,
|
||||
workspace: readStringValue(params.workspace),
|
||||
},
|
||||
runtime,
|
||||
prompter,
|
||||
),
|
||||
),
|
||||
);
|
||||
context.wizardSessions.set(sessionId, session);
|
||||
|
||||
Reference in New Issue
Block a user