fix: harden setup tui handoff

This commit is contained in:
Shakker
2026-04-21 02:40:22 +01:00
parent 4aeb81eeeb
commit 3669babf8f
4 changed files with 172 additions and 11 deletions

View File

@@ -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" }),
);
});
});

View File

@@ -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);

View File

@@ -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,

View File

@@ -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();