Skip to content

Commit aae4b1b

Browse files
authored
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 f4f119a. * fix: let setup tui resolve gateway auth * fix: support packaged tui relaunch * fix: pin setup tui gateway target * fix: preserve setup tui auth source
1 parent bed2472 commit aae4b1b

8 files changed

Lines changed: 414 additions & 22 deletions

src/gateway/auth-surface-resolution.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: {
145145
config: OpenClawConfig;
146146
env?: NodeJS.ProcessEnv;
147147
explicitAuth?: ExplicitGatewayAuth;
148+
suppressEnvAuthFallback?: boolean;
148149
surface: "local" | "remote";
149150
}): Promise<{
150151
token?: string;
@@ -155,8 +156,12 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: {
155156
const diagnostics: string[] = [];
156157
const explicitToken = trimToUndefined(params.explicitAuth?.token);
157158
const explicitPassword = trimToUndefined(params.explicitAuth?.password);
158-
const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
159-
const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
159+
const envToken = params.suppressEnvAuthFallback
160+
? undefined
161+
: trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN);
162+
const envPassword = params.suppressEnvAuthFallback
163+
? undefined
164+
: trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD);
160165

161166
if (params.surface === "remote") {
162167
const remoteToken = explicitToken

src/tui/gateway-chat.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ describe("resolveGatewayConnection", () => {
110110
"OPENCLAW_GATEWAY_URL",
111111
"OPENCLAW_GATEWAY_TOKEN",
112112
"OPENCLAW_GATEWAY_PASSWORD",
113+
"OPENCLAW_TUI_SETUP_AUTH_SOURCE",
113114
]);
114115
loadConfig.mockReset();
115116
resolveGatewayPort.mockReset();
@@ -126,6 +127,7 @@ describe("resolveGatewayConnection", () => {
126127
delete process.env.OPENCLAW_GATEWAY_URL;
127128
delete process.env.OPENCLAW_GATEWAY_TOKEN;
128129
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
130+
delete process.env.OPENCLAW_TUI_SETUP_AUTH_SOURCE;
129131
});
130132

131133
afterEach(() => {
@@ -199,6 +201,74 @@ describe("resolveGatewayConnection", () => {
199201
expect(result.token).toBeUndefined();
200202
});
201203

204+
it("keeps normal TUI local password mode env precedence by default", async () => {
205+
loadConfig.mockReturnValue({
206+
gateway: {
207+
mode: "local",
208+
auth: {
209+
mode: "password",
210+
password: "config-password", // pragma: allowlist secret
211+
},
212+
},
213+
});
214+
215+
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-password" }, async () => {
216+
const result = await resolveGatewayConnection({});
217+
expect(result.password).toBe("env-password");
218+
});
219+
});
220+
221+
it("uses configured local password for setup-launched TUI despite stale gateway password env", async () => {
222+
loadConfig.mockReturnValue({
223+
gateway: {
224+
mode: "local",
225+
auth: {
226+
mode: "password",
227+
password: "config-password", // pragma: allowlist secret
228+
},
229+
},
230+
});
231+
232+
await withEnvAsync(
233+
{
234+
OPENCLAW_GATEWAY_PASSWORD: "stale-env-password", // pragma: allowlist secret
235+
OPENCLAW_TUI_SETUP_AUTH_SOURCE: "config",
236+
},
237+
async () => {
238+
const result = await resolveGatewayConnection({});
239+
expect(result.password).toBe("config-password");
240+
},
241+
);
242+
});
243+
244+
it("still resolves env SecretRefs for setup-launched TUI config auth", async () => {
245+
loadConfig.mockReturnValue({
246+
secrets: {
247+
providers: {
248+
default: { source: "env" },
249+
},
250+
},
251+
gateway: {
252+
mode: "local",
253+
auth: {
254+
mode: "password",
255+
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
256+
},
257+
},
258+
});
259+
260+
await withEnvAsync(
261+
{
262+
OPENCLAW_GATEWAY_PASSWORD: "resolved-ref-password", // pragma: allowlist secret
263+
OPENCLAW_TUI_SETUP_AUTH_SOURCE: "config",
264+
},
265+
async () => {
266+
const result = await resolveGatewayConnection({});
267+
expect(result.password).toBe("resolved-ref-password");
268+
},
269+
);
270+
});
271+
202272
it("fails when both local token and password are configured but gateway.auth.mode is unset", async () => {
203273
loadConfig.mockReturnValue({
204274
gateway: {

src/tui/gateway-chat.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "../gateway/protocol/index.js";
2424
import { formatErrorMessage } from "../infra/errors.js";
2525
import { VERSION } from "../version.js";
26+
import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js";
2627
import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js";
2728

2829
export type GatewayConnectionOptions = {
@@ -317,6 +318,7 @@ export async function resolveGatewayConnection(
317318
const env = process.env;
318319
const gatewayAuthMode = config.gateway?.auth?.mode;
319320
const isRemoteMode = config.gateway?.mode === "remote";
321+
const preferConfiguredAuth = env[TUI_SETUP_AUTH_SOURCE_ENV] === TUI_SETUP_AUTH_SOURCE_CONFIG;
320322

321323
const urlOverride =
322324
typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined;
@@ -394,6 +396,7 @@ export async function resolveGatewayConnection(
394396
config,
395397
env,
396398
explicitAuth,
399+
suppressEnvAuthFallback: preferConfiguredAuth,
397400
surface: "local",
398401
});
399402
if (resolved.failureReason) {

src/tui/setup-launch-env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const TUI_SETUP_AUTH_SOURCE_ENV = "OPENCLAW_TUI_SETUP_AUTH_SOURCE";
2+
export const TUI_SETUP_AUTH_SOURCE_CONFIG = "config";

src/tui/tui-launch.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { ChildProcess, SpawnOptions } from "node:child_process";
2+
import { EventEmitter } from "node:events";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const spawnMock = vi.hoisted(() => vi.fn());
6+
const detachMock = vi.hoisted(() => vi.fn());
7+
8+
vi.mock("node:child_process", () => ({
9+
spawn: spawnMock,
10+
}));
11+
12+
vi.mock("../process/child-process-bridge.js", () => ({
13+
attachChildProcessBridge: vi.fn(() => ({ detach: detachMock })),
14+
}));
15+
16+
import { launchTuiCli } from "./tui-launch.js";
17+
18+
const originalArgv = [...process.argv];
19+
const originalExecArgv = [...process.execArgv];
20+
21+
function createChildProcess(): ChildProcess {
22+
return new EventEmitter() as ChildProcess;
23+
}
24+
25+
describe("launchTuiCli", () => {
26+
beforeEach(() => {
27+
process.argv = [...originalArgv];
28+
process.argv[1] = "/repo/openclaw.mjs";
29+
process.execArgv.length = 0;
30+
spawnMock.mockReset();
31+
detachMock.mockReset();
32+
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin);
33+
vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin);
34+
vi.spyOn(process.stdin, "isPaused").mockReturnValue(false);
35+
});
36+
37+
afterEach(() => {
38+
process.argv = [...originalArgv];
39+
process.execArgv.length = 0;
40+
process.execArgv.push(...originalExecArgv);
41+
vi.restoreAllMocks();
42+
});
43+
44+
it("filters inherited inspector flags when relaunching TUI", async () => {
45+
process.execArgv.push(
46+
"--import",
47+
"tsx",
48+
"--inspect",
49+
"127.0.0.1:9231",
50+
"--inspect=127.0.0.1:9229",
51+
"--inspect-brk",
52+
"--inspect-wait=0",
53+
"--inspect-port",
54+
"9230",
55+
"--no-warnings",
56+
);
57+
const child = createChildProcess();
58+
spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => {
59+
queueMicrotask(() => child.emit("exit", 0, null));
60+
return child;
61+
});
62+
63+
await launchTuiCli({
64+
url: "ws://127.0.0.1:18789",
65+
token: "test-token",
66+
password: "test-password",
67+
deliver: false,
68+
});
69+
70+
expect(spawnMock).toHaveBeenCalledWith(
71+
process.execPath,
72+
[
73+
"--import",
74+
"tsx",
75+
"--no-warnings",
76+
"/repo/openclaw.mjs",
77+
"tui",
78+
"--url",
79+
"ws://127.0.0.1:18789",
80+
"--token",
81+
"test-token",
82+
"--password",
83+
"test-password",
84+
],
85+
expect.objectContaining({ stdio: "inherit" }),
86+
);
87+
});
88+
89+
it("launches compiled CLI shapes without repeating the current command", async () => {
90+
process.argv[1] = "setup";
91+
const child = createChildProcess();
92+
spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => {
93+
queueMicrotask(() => child.emit("exit", 0, null));
94+
return child;
95+
});
96+
97+
await launchTuiCli({ deliver: false });
98+
99+
expect(spawnMock).toHaveBeenCalledWith(
100+
process.execPath,
101+
["tui"],
102+
expect.objectContaining({ stdio: "inherit" }),
103+
);
104+
});
105+
106+
it("pins the child gateway URL and config auth source through env without adding url argv", async () => {
107+
const child = createChildProcess();
108+
spawnMock.mockImplementation((_cmd: string, _args: string[], _opts: SpawnOptions) => {
109+
queueMicrotask(() => child.emit("exit", 0, null));
110+
return child;
111+
});
112+
113+
await launchTuiCli(
114+
{ deliver: false },
115+
{ authSource: "config", gatewayUrl: "ws://127.0.0.1:18789" },
116+
);
117+
118+
expect(spawnMock).toHaveBeenCalledWith(
119+
process.execPath,
120+
["/repo/openclaw.mjs", "tui"],
121+
expect.objectContaining({
122+
env: expect.objectContaining({
123+
OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789",
124+
OPENCLAW_TUI_SETUP_AUTH_SOURCE: "config",
125+
}),
126+
}),
127+
);
128+
});
129+
});

src/tui/tui-launch.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { spawn } from "node:child_process";
2+
import path from "node:path";
3+
import { formatErrorMessage } from "../infra/errors.js";
4+
import { attachChildProcessBridge } from "../process/child-process-bridge.js";
5+
import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js";
6+
import type { TuiOptions } from "./tui.js";
7+
8+
type TuiLaunchOptions = {
9+
authSource?: "config";
10+
gatewayUrl?: string;
11+
};
12+
13+
function appendOption(args: string[], flag: string, value: string | number | undefined): void {
14+
if (value === undefined) {
15+
return;
16+
}
17+
args.push(flag, String(value));
18+
}
19+
20+
function filterTuiExecArgv(execArgv: readonly string[]): string[] {
21+
const filtered: string[] = [];
22+
for (let index = 0; index < execArgv.length; index += 1) {
23+
const arg = execArgv[index] ?? "";
24+
if (
25+
arg === "--inspect" ||
26+
arg.startsWith("--inspect=") ||
27+
arg === "--inspect-brk" ||
28+
arg.startsWith("--inspect-brk=") ||
29+
arg === "--inspect-wait" ||
30+
arg.startsWith("--inspect-wait=")
31+
) {
32+
const next = execArgv[index + 1];
33+
if (!arg.includes("=") && typeof next === "string" && !next.startsWith("-")) {
34+
index += 1;
35+
}
36+
continue;
37+
}
38+
if (arg === "--inspect-port") {
39+
const next = execArgv[index + 1];
40+
if (typeof next === "string" && !next.startsWith("-")) {
41+
index += 1;
42+
}
43+
continue;
44+
}
45+
if (arg.startsWith("--inspect-port=")) {
46+
continue;
47+
}
48+
filtered.push(arg);
49+
}
50+
return filtered;
51+
}
52+
53+
function buildCurrentCliEntryArgs(): string[] {
54+
const entry = process.argv[1]?.trim();
55+
if (!entry) {
56+
throw new Error("unable to relaunch TUI: current CLI entry path is unavailable");
57+
}
58+
return path.isAbsolute(entry) ? [entry] : [];
59+
}
60+
61+
function buildTuiCliArgs(opts: TuiOptions): string[] {
62+
const args = [...filterTuiExecArgv(process.execArgv), ...buildCurrentCliEntryArgs(), "tui"];
63+
appendOption(args, "--url", opts.url);
64+
appendOption(args, "--token", opts.token);
65+
appendOption(args, "--password", opts.password);
66+
appendOption(args, "--session", opts.session);
67+
appendOption(args, "--thinking", opts.thinking);
68+
appendOption(args, "--message", opts.message);
69+
appendOption(args, "--timeout-ms", opts.timeoutMs);
70+
appendOption(args, "--history-limit", opts.historyLimit);
71+
if (opts.deliver) {
72+
args.push("--deliver");
73+
}
74+
return args;
75+
}
76+
77+
export async function launchTuiCli(
78+
opts: TuiOptions,
79+
launchOptions: TuiLaunchOptions = {},
80+
): Promise<void> {
81+
const args = buildTuiCliArgs(opts);
82+
const env =
83+
launchOptions.gatewayUrl || launchOptions.authSource
84+
? {
85+
...process.env,
86+
...(launchOptions.gatewayUrl ? { OPENCLAW_GATEWAY_URL: launchOptions.gatewayUrl } : {}),
87+
...(launchOptions.authSource === "config"
88+
? { [TUI_SETUP_AUTH_SOURCE_ENV]: TUI_SETUP_AUTH_SOURCE_CONFIG }
89+
: {}),
90+
}
91+
: process.env;
92+
const stdinWasPaused =
93+
typeof process.stdin.isPaused === "function" ? process.stdin.isPaused() : false;
94+
95+
process.stdin.pause();
96+
97+
await new Promise<void>((resolve, reject) => {
98+
const child = spawn(process.execPath, args, {
99+
stdio: "inherit",
100+
env,
101+
});
102+
const { detach } = attachChildProcessBridge(child);
103+
104+
child.once("error", (error) => {
105+
detach();
106+
reject(new Error(`failed to launch TUI: ${formatErrorMessage(error)}`));
107+
});
108+
109+
child.once("exit", (code, signal) => {
110+
detach();
111+
if (signal) {
112+
reject(new Error(`TUI exited from signal ${signal}`));
113+
return;
114+
}
115+
if ((code ?? 0) !== 0) {
116+
reject(new Error(`TUI exited with code ${code ?? 1}`));
117+
return;
118+
}
119+
resolve();
120+
});
121+
}).finally(() => {
122+
if (!stdinWasPaused) {
123+
process.stdin.resume();
124+
}
125+
});
126+
}

0 commit comments

Comments
 (0)