Fix setup TUI hatch terminal handoff (#69524)

* fix: relaunch setup tui in a fresh process

* fix: harden setup tui handoff

* fix: preserve tui hatch exit flow

* Revert "fix: preserve tui hatch exit flow"

This reverts commit f4f119a5a3.

* fix: let setup tui resolve gateway auth

* fix: support packaged tui relaunch

* fix: pin setup tui gateway target

* fix: preserve setup tui auth source
This commit is contained in:
Shakker
2026-04-21 03:45:57 +01:00
committed by GitHub
parent bed2472121
commit aae4b1b29d
8 changed files with 414 additions and 22 deletions

View File

@@ -145,6 +145,7 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: {
config: OpenClawConfig; config: OpenClawConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
explicitAuth?: ExplicitGatewayAuth; explicitAuth?: ExplicitGatewayAuth;
suppressEnvAuthFallback?: boolean;
surface: "local" | "remote"; surface: "local" | "remote";
}): Promise<{ }): Promise<{
token?: string; token?: string;
@@ -155,8 +156,12 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: {
const diagnostics: string[] = []; const diagnostics: string[] = [];
const explicitToken = trimToUndefined(params.explicitAuth?.token); const explicitToken = trimToUndefined(params.explicitAuth?.token);
const explicitPassword = trimToUndefined(params.explicitAuth?.password); const explicitPassword = trimToUndefined(params.explicitAuth?.password);
const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN); const envToken = params.suppressEnvAuthFallback
const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD); ? undefined
: trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
const envPassword = params.suppressEnvAuthFallback
? undefined
: trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
if (params.surface === "remote") { if (params.surface === "remote") {
const remoteToken = explicitToken const remoteToken = explicitToken

View File

@@ -110,6 +110,7 @@ describe("resolveGatewayConnection", () => {
"OPENCLAW_GATEWAY_URL", "OPENCLAW_GATEWAY_URL",
"OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD", "OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_TUI_SETUP_AUTH_SOURCE",
]); ]);
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
@@ -126,6 +127,7 @@ describe("resolveGatewayConnection", () => {
delete process.env.OPENCLAW_GATEWAY_URL; delete process.env.OPENCLAW_GATEWAY_URL;
delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.OPENCLAW_TUI_SETUP_AUTH_SOURCE;
}); });
afterEach(() => { afterEach(() => {
@@ -199,6 +201,74 @@ describe("resolveGatewayConnection", () => {
expect(result.token).toBeUndefined(); expect(result.token).toBeUndefined();
}); });
it("keeps normal TUI local password mode env precedence by default", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
auth: {
mode: "password",
password: "config-password", // pragma: allowlist secret
},
},
});
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-password" }, async () => {
const result = await resolveGatewayConnection({});
expect(result.password).toBe("env-password");
});
});
it("uses configured local password for setup-launched TUI despite stale gateway password env", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
auth: {
mode: "password",
password: "config-password", // pragma: allowlist secret
},
},
});
await withEnvAsync(
{
OPENCLAW_GATEWAY_PASSWORD: "stale-env-password", // pragma: allowlist secret
OPENCLAW_TUI_SETUP_AUTH_SOURCE: "config",
},
async () => {
const result = await resolveGatewayConnection({});
expect(result.password).toBe("config-password");
},
);
});
it("still resolves env SecretRefs for setup-launched TUI config auth", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
},
});
await withEnvAsync(
{
OPENCLAW_GATEWAY_PASSWORD: "resolved-ref-password", // pragma: allowlist secret
OPENCLAW_TUI_SETUP_AUTH_SOURCE: "config",
},
async () => {
const result = await resolveGatewayConnection({});
expect(result.password).toBe("resolved-ref-password");
},
);
});
it("fails when both local token and password are configured but gateway.auth.mode is unset", async () => { it("fails when both local token and password are configured but gateway.auth.mode is unset", async () => {
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
gateway: { gateway: {

View File

@@ -23,6 +23,7 @@ import {
} from "../gateway/protocol/index.js"; } from "../gateway/protocol/index.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js";
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
export type GatewayConnectionOptions = { export type GatewayConnectionOptions = {
@@ -317,6 +318,7 @@ export async function resolveGatewayConnection(
const env = process.env; const env = process.env;
const gatewayAuthMode = config.gateway?.auth?.mode; const gatewayAuthMode = config.gateway?.auth?.mode;
const isRemoteMode = config.gateway?.mode === "remote"; const isRemoteMode = config.gateway?.mode === "remote";
const preferConfiguredAuth = env[TUI_SETUP_AUTH_SOURCE_ENV] === TUI_SETUP_AUTH_SOURCE_CONFIG;
const urlOverride = const urlOverride =
typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined;
@@ -394,6 +396,7 @@ export async function resolveGatewayConnection(
config, config,
env, env,
explicitAuth, explicitAuth,
suppressEnvAuthFallback: preferConfiguredAuth,
surface: "local", surface: "local",
}); });
if (resolved.failureReason) { if (resolved.failureReason) {

View File

@@ -0,0 +1,2 @@
export const TUI_SETUP_AUTH_SOURCE_ENV = "OPENCLAW_TUI_SETUP_AUTH_SOURCE";
export const TUI_SETUP_AUTH_SOURCE_CONFIG = "config";

129
src/tui/tui-launch.test.ts Normal file
View File

@@ -0,0 +1,129 @@
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:9231",
"--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",
password: "test-password",
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",
"--password",
"test-password",
],
expect.objectContaining({ stdio: "inherit" }),
);
});
it("launches compiled CLI shapes without repeating the current command", async () => {
process.argv[1] = "setup";
const child = createChildProcess();
spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => {
queueMicrotask(() => child.emit("exit", 0, null));
return child;
});
await launchTuiCli({ deliver: false });
expect(spawnMock).toHaveBeenCalledWith(
process.execPath,
["tui"],
expect.objectContaining({ stdio: "inherit" }),
);
});
it("pins the child gateway URL and config auth source through env without adding url argv", async () => {
const child = createChildProcess();
spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => {
queueMicrotask(() => child.emit("exit", 0, null));
return child;
});
await launchTuiCli(
{ deliver: false },
{ authSource: "config", gatewayUrl: "ws://127.0.0.1:18789" },
);
expect(spawnMock).toHaveBeenCalledWith(
process.execPath,
["/repo/openclaw.mjs", "tui"],
expect.objectContaining({
env: expect.objectContaining({
OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789",
OPENCLAW_TUI_SETUP_AUTH_SOURCE: "config",
}),
}),
);
});
});

126
src/tui/tui-launch.ts Normal file
View File

@@ -0,0 +1,126 @@
import { spawn } from "node:child_process";
import path from "node:path";
import { formatErrorMessage } from "../infra/errors.js";
import { attachChildProcessBridge } from "../process/child-process-bridge.js";
import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js";
import type { TuiOptions } from "./tui.js";
type TuiLaunchOptions = {
authSource?: "config";
gatewayUrl?: string;
};
function appendOption(args: string[], flag: string, value: string | number | undefined): void {
if (value === undefined) {
return;
}
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=")
) {
const next = execArgv[index + 1];
if (!arg.includes("=") && typeof next === "string" && !next.startsWith("-")) {
index += 1;
}
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 buildCurrentCliEntryArgs(): string[] {
const entry = process.argv[1]?.trim();
if (!entry) {
throw new Error("unable to relaunch TUI: current CLI entry path is unavailable");
}
return path.isAbsolute(entry) ? [entry] : [];
}
function buildTuiCliArgs(opts: TuiOptions): string[] {
const args = [...filterTuiExecArgv(process.execArgv), ...buildCurrentCliEntryArgs(), "tui"];
appendOption(args, "--url", opts.url);
appendOption(args, "--token", opts.token);
appendOption(args, "--password", opts.password);
appendOption(args, "--session", opts.session);
appendOption(args, "--thinking", opts.thinking);
appendOption(args, "--message", opts.message);
appendOption(args, "--timeout-ms", opts.timeoutMs);
appendOption(args, "--history-limit", opts.historyLimit);
if (opts.deliver) {
args.push("--deliver");
}
return args;
}
export async function launchTuiCli(
opts: TuiOptions,
launchOptions: TuiLaunchOptions = {},
): Promise<void> {
const args = buildTuiCliArgs(opts);
const env =
launchOptions.gatewayUrl || launchOptions.authSource
? {
...process.env,
...(launchOptions.gatewayUrl ? { OPENCLAW_GATEWAY_URL: launchOptions.gatewayUrl } : {}),
...(launchOptions.authSource === "config"
? { [TUI_SETUP_AUTH_SOURCE_ENV]: TUI_SETUP_AUTH_SOURCE_CONFIG }
: {}),
}
: process.env;
const stdinWasPaused =
typeof process.stdin.isPaused === "function" ? process.stdin.isPaused() : false;
process.stdin.pause();
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, args, {
stdio: "inherit",
env,
});
const { detach } = attachChildProcessBridge(child);
child.once("error", (error) => {
detach();
reject(new Error(`failed to launch TUI: ${formatErrorMessage(error)}`));
});
child.once("exit", (code, signal) => {
detach();
if (signal) {
reject(new Error(`TUI exited from signal ${signal}`));
return;
}
if ((code ?? 0) !== 0) {
reject(new Error(`TUI exited with code ${code ?? 1}`));
return;
}
resolve();
});
}).finally(() => {
if (!stdinWasPaused) {
process.stdin.resume();
}
});
}

View File

@@ -4,7 +4,8 @@ import type { OpenClawConfig } from "../config/config.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
const runTui = vi.hoisted(() => vi.fn(async () => {})); const launchTuiCli = vi.hoisted(() => vi.fn(async () => {}));
const restoreTerminalState = vi.hoisted(() => vi.fn());
const probeGatewayReachable = vi.hoisted(() => const probeGatewayReachable = vi.hoisted(() =>
vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })), vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })),
); );
@@ -134,11 +135,11 @@ vi.mock("../infra/control-ui-assets.js", () => ({
})); }));
vi.mock("../terminal/restore.js", () => ({ vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: vi.fn(), restoreTerminalState,
})); }));
vi.mock("../tui/tui.js", () => ({ vi.mock("../tui/tui-launch.js", () => ({
runTui, launchTuiCli,
})); }));
vi.mock("./setup.secret-input.js", () => ({ vi.mock("./setup.secret-input.js", () => ({
@@ -236,7 +237,8 @@ function createAdvancedFinalizeArgs(params: AdvancedFinalizeArgs = {}) {
describe("finalizeSetupWizard", () => { describe("finalizeSetupWizard", () => {
beforeEach(() => { beforeEach(() => {
runTui.mockClear(); launchTuiCli.mockClear();
restoreTerminalState.mockClear();
probeGatewayReachable.mockClear(); probeGatewayReachable.mockClear();
waitForGatewayReachable.mockReset(); waitForGatewayReachable.mockReset();
waitForGatewayReachable.mockResolvedValue({ ok: true }); waitForGatewayReachable.mockResolvedValue({ ok: true });
@@ -265,7 +267,7 @@ describe("finalizeSetupWizard", () => {
listConfiguredWebSearchProviders.mockReturnValue([]); listConfiguredWebSearchProviders.mockReturnValue([]);
}); });
it("resolves gateway password SecretRef for probe and TUI", async () => { it("resolves gateway password SecretRef for probe but omits auth from TUI hatch", async () => {
const previous = process.env.OPENCLAW_GATEWAY_PASSWORD; const previous = process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-gateway-password"; // pragma: allowlist secret process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-gateway-password"; // pragma: allowlist secret
resolveSetupSecretInputString.mockResolvedValueOnce("resolved-gateway-password"); resolveSetupSecretInputString.mockResolvedValueOnce("resolved-gateway-password");
@@ -337,14 +339,62 @@ describe("finalizeSetupWizard", () => {
password: "resolved-gateway-password", // pragma: allowlist secret password: "resolved-gateway-password", // pragma: allowlist secret
}), }),
); );
expect(runTui).toHaveBeenCalledWith( expect(launchTuiCli).toHaveBeenCalledWith(
expect.objectContaining({ {
url: "ws://127.0.0.1:18789", deliver: false,
password: "resolved-gateway-password", // pragma: allowlist secret message: undefined,
}), },
{
authSource: "config",
gatewayUrl: "ws://127.0.0.1:18789",
},
); );
}); });
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 () => { it("does not persist resolved SecretRef token in daemon install plan", async () => {
const prompter = buildWizardPrompter({ const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never, select: vi.fn(async () => "later") as never,

View File

@@ -30,7 +30,7 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { restoreTerminalState } from "../terminal/restore.js"; import { restoreTerminalState } from "../terminal/restore.js";
import { runTui } from "../tui/tui.js"; import { launchTuiCli } from "../tui/tui-launch.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { listConfiguredWebSearchProviders } from "../web-search/runtime.js"; import { listConfiguredWebSearchProviders } from "../web-search/runtime.js";
import type { WizardPrompter } from "./prompts.js"; import type { WizardPrompter } from "./prompts.js";
@@ -423,14 +423,21 @@ export async function finalizeSetupWizard(
if (hatchChoice === "tui") { if (hatchChoice === "tui") {
restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true }); restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true });
await runTui({ try {
url: links.wsUrl, await launchTuiCli(
token: settings.authMode === "token" ? settings.gatewayToken : undefined, {
password: settings.authMode === "password" ? resolvedGatewayPassword : "", // Safety: setup TUI should not auto-deliver to lastProvider/lastTo.
// Safety: setup TUI should not auto-deliver to lastProvider/lastTo. deliver: false,
deliver: false, message: hasBootstrap ? "Wake up, my friend!" : undefined,
message: hasBootstrap ? "Wake up, my friend!" : undefined, },
}); {
authSource: "config",
gatewayUrl: links.wsUrl,
},
);
} finally {
restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true });
}
launchedTui = true; launchedTui = true;
} else if (hatchChoice === "web") { } else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();