mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-15 14:40:47 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
2
src/tui/setup-launch-env.ts
Normal file
2
src/tui/setup-launch-env.ts
Normal 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
129
src/tui/tui-launch.test.ts
Normal 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
126
src/tui/tui-launch.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user