From 3669babf8f572efc6f02df4de4fdea70062525ba Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 21 Apr 2026 02:40:22 +0100 Subject: [PATCH] fix: harden setup tui handoff --- src/tui/tui-launch.test.ts | 83 +++++++++++++++++++++++++++++++ src/tui/tui-launch.ts | 31 +++++++++++- src/wizard/setup.finalize.test.ts | 48 +++++++++++++++++- src/wizard/setup.finalize.ts | 21 ++++---- 4 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/tui/tui-launch.test.ts diff --git a/src/tui/tui-launch.test.ts b/src/tui/tui-launch.test.ts new file mode 100644 index 00000000000..fba342399cb --- /dev/null +++ b/src/tui/tui-launch.test.ts @@ -0,0 +1,83 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.hoisted(() => vi.fn()); +const detachMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +vi.mock("../process/child-process-bridge.js", () => ({ + attachChildProcessBridge: vi.fn(() => ({ detach: detachMock })), +})); + +import { launchTuiCli } from "./tui-launch.js"; + +const originalArgv = [...process.argv]; +const originalExecArgv = [...process.execArgv]; + +function createChildProcess(): ChildProcess { + return new EventEmitter() as ChildProcess; +} + +describe("launchTuiCli", () => { + beforeEach(() => { + process.argv = [...originalArgv]; + process.argv[1] = "/repo/openclaw.mjs"; + process.execArgv.length = 0; + spawnMock.mockReset(); + detachMock.mockReset(); + vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, "isPaused").mockReturnValue(false); + }); + + afterEach(() => { + process.argv = [...originalArgv]; + process.execArgv.length = 0; + process.execArgv.push(...originalExecArgv); + vi.restoreAllMocks(); + }); + + it("filters inherited inspector flags when relaunching TUI", async () => { + process.execArgv.push( + "--import", + "tsx", + "--inspect=127.0.0.1:9229", + "--inspect-brk", + "--inspect-wait=0", + "--inspect-port", + "9230", + "--no-warnings", + ); + const child = createChildProcess(); + spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => { + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }); + + await launchTuiCli({ + url: "ws://127.0.0.1:18789", + token: "test-token", + deliver: false, + }); + + expect(spawnMock).toHaveBeenCalledWith( + process.execPath, + [ + "--import", + "tsx", + "--no-warnings", + "/repo/openclaw.mjs", + "tui", + "--url", + "ws://127.0.0.1:18789", + "--token", + "test-token", + ], + expect.objectContaining({ stdio: "inherit" }), + ); + }); +}); diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts index 16ba6b8caa9..b6b36bfe1a2 100644 --- a/src/tui/tui-launch.ts +++ b/src/tui/tui-launch.ts @@ -10,13 +10,42 @@ function appendOption(args: string[], flag: string, value: string | number | und args.push(flag, String(value)); } +function filterTuiExecArgv(execArgv: readonly string[]): string[] { + const filtered: string[] = []; + for (let index = 0; index < execArgv.length; index += 1) { + const arg = execArgv[index] ?? ""; + if ( + arg === "--inspect" || + arg.startsWith("--inspect=") || + arg === "--inspect-brk" || + arg.startsWith("--inspect-brk=") || + arg === "--inspect-wait" || + arg.startsWith("--inspect-wait=") + ) { + continue; + } + if (arg === "--inspect-port") { + const next = execArgv[index + 1]; + if (typeof next === "string" && !next.startsWith("-")) { + index += 1; + } + continue; + } + if (arg.startsWith("--inspect-port=")) { + continue; + } + filtered.push(arg); + } + return filtered; +} + function buildTuiCliArgs(opts: TuiOptions): string[] { const entry = process.argv[1]?.trim(); if (!entry) { throw new Error("unable to relaunch TUI: current CLI entry path is unavailable"); } - const args = [...process.execArgv, entry, "tui"]; + const args = [...filterTuiExecArgv(process.execArgv), entry, "tui"]; appendOption(args, "--url", opts.url); appendOption(args, "--token", opts.token); appendOption(args, "--password", opts.password); diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index b1dd617d510..ee502309120 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -5,6 +5,7 @@ import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; const launchTuiCli = vi.hoisted(() => vi.fn(async () => {})); +const restoreTerminalState = vi.hoisted(() => vi.fn()); const probeGatewayReachable = vi.hoisted(() => vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })), ); @@ -134,7 +135,7 @@ vi.mock("../infra/control-ui-assets.js", () => ({ })); vi.mock("../terminal/restore.js", () => ({ - restoreTerminalState: vi.fn(), + restoreTerminalState, })); vi.mock("../tui/tui-launch.js", () => ({ @@ -237,6 +238,7 @@ function createAdvancedFinalizeArgs(params: AdvancedFinalizeArgs = {}) { describe("finalizeSetupWizard", () => { beforeEach(() => { launchTuiCli.mockClear(); + restoreTerminalState.mockClear(); probeGatewayReachable.mockClear(); waitForGatewayReachable.mockReset(); waitForGatewayReachable.mockResolvedValue({ ok: true }); @@ -345,6 +347,50 @@ describe("finalizeSetupWizard", () => { ); }); + it("restores terminal state after failed TUI hatch", async () => { + launchTuiCli.mockRejectedValueOnce(new Error("TUI exited with code 1")); + const select = vi.fn(async (params: { message: string }) => { + if (params.message === "How do you want to hatch your bot?") { + return "tui"; + } + return "later"; + }); + const prompter = buildWizardPrompter({ select: select as never }); + + await expect( + finalizeSetupWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: false, + }, + baseConfig: {}, + nextConfig: {}, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: "test-token", + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }), + ).rejects.toThrow("TUI exited with code 1"); + + expect(restoreTerminalState).toHaveBeenCalledWith("pre-setup tui", { + resumeStdinIfPaused: true, + }); + expect(restoreTerminalState).toHaveBeenCalledWith("post-setup tui", { + resumeStdinIfPaused: true, + }); + }); + it("does not persist resolved SecretRef token in daemon install plan", async () => { const prompter = buildWizardPrompter({ select: vi.fn(async () => "later") as never, diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index e7461a877e4..66192b10269 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -423,15 +423,18 @@ export async function finalizeSetupWizard( if (hatchChoice === "tui") { restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true }); - await launchTuiCli({ - url: links.wsUrl, - token: settings.authMode === "token" ? settings.gatewayToken : undefined, - password: settings.authMode === "password" ? resolvedGatewayPassword : "", - // Safety: setup TUI should not auto-deliver to lastProvider/lastTo. - deliver: false, - message: hasBootstrap ? "Wake up, my friend!" : undefined, - }); - restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true }); + try { + await launchTuiCli({ + url: links.wsUrl, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, + password: settings.authMode === "password" ? resolvedGatewayPassword : "", + // Safety: setup TUI should not auto-deliver to lastProvider/lastTo. + deliver: false, + message: hasBootstrap ? "Wake up, my friend!" : undefined, + }); + } finally { + restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true }); + } launchedTui = true; } else if (hatchChoice === "web") { const browserSupport = await detectBrowserOpenSupport();