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

@@ -110,6 +110,7 @@ describe("resolveGatewayConnection", () => {
"OPENCLAW_GATEWAY_URL",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_TUI_SETUP_AUTH_SOURCE",
]);
loadConfig.mockReset();
resolveGatewayPort.mockReset();
@@ -126,6 +127,7 @@ describe("resolveGatewayConnection", () => {
delete process.env.OPENCLAW_GATEWAY_URL;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.OPENCLAW_TUI_SETUP_AUTH_SOURCE;
});
afterEach(() => {
@@ -199,6 +201,74 @@ describe("resolveGatewayConnection", () => {
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 () => {
loadConfig.mockReturnValue({
gateway: {

View File

@@ -23,6 +23,7 @@ import {
} from "../gateway/protocol/index.js";
import { formatErrorMessage } from "../infra/errors.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";
export type GatewayConnectionOptions = {
@@ -317,6 +318,7 @@ export async function resolveGatewayConnection(
const env = process.env;
const gatewayAuthMode = config.gateway?.auth?.mode;
const isRemoteMode = config.gateway?.mode === "remote";
const preferConfiguredAuth = env[TUI_SETUP_AUTH_SOURCE_ENV] === TUI_SETUP_AUTH_SOURCE_CONFIG;
const urlOverride =
typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined;
@@ -394,6 +396,7 @@ export async function resolveGatewayConnection(
config,
env,
explicitAuth,
suppressEnvAuthFallback: preferConfiguredAuth,
surface: "local",
});
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();
}
});
}