diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index 34d3001e456f..3832a6b34789 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -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 }, diff --git a/src/gateway/server-methods/wizard.test.ts b/src/gateway/server-methods/wizard.test.ts index 93d9e62d2616..8585c1c85d9d 100644 --- a/src/gateway/server-methods/wizard.test.ts +++ b/src/gateway/server-methods/wizard.test.ts @@ -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(); + } + }, + ); +}); diff --git a/src/gateway/server-methods/wizard.ts b/src/gateway/server-methods/wizard.ts index 8116e828e287..b09ba27a121a 100644 --- a/src/gateway/server-methods/wizard.ts +++ b/src/gateway/server-methods/wizard.ts @@ -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): Promise { + 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);