diff --git a/src/gateway/auth-surface-resolution.ts b/src/gateway/auth-surface-resolution.ts index 3ba60d5cff5..2154ee3be7f 100644 --- a/src/gateway/auth-surface-resolution.ts +++ b/src/gateway/auth-surface-resolution.ts @@ -145,6 +145,7 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; explicitAuth?: ExplicitGatewayAuth; + suppressEnvAuthFallback?: boolean; surface: "local" | "remote"; }): Promise<{ token?: string; @@ -155,8 +156,12 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: { const diagnostics: string[] = []; const explicitToken = trimToUndefined(params.explicitAuth?.token); const explicitPassword = trimToUndefined(params.explicitAuth?.password); - const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN); - const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD); + const envToken = params.suppressEnvAuthFallback + ? undefined + : trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN); + const envPassword = params.suppressEnvAuthFallback + ? undefined + : trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD); if (params.surface === "remote") { const remoteToken = explicitToken diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 494623dd545..e6c12c426b0 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -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: { diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 00cea9f728f..e489a28b92f 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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) { diff --git a/src/tui/setup-launch-env.ts b/src/tui/setup-launch-env.ts new file mode 100644 index 00000000000..12c1af51019 --- /dev/null +++ b/src/tui/setup-launch-env.ts @@ -0,0 +1,2 @@ +export const TUI_SETUP_AUTH_SOURCE_ENV = "OPENCLAW_TUI_SETUP_AUTH_SOURCE"; +export const TUI_SETUP_AUTH_SOURCE_CONFIG = "config"; diff --git a/src/tui/tui-launch.test.ts b/src/tui/tui-launch.test.ts new file mode 100644 index 00000000000..27058f9b56f --- /dev/null +++ b/src/tui/tui-launch.test.ts @@ -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", + }), + }), + ); + }); +}); diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts new file mode 100644 index 00000000000..157e8a2b647 --- /dev/null +++ b/src/tui/tui-launch.ts @@ -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 { + 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((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(); + } + }); +} diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index dbb0f08db01..1fc11f1f779 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -4,7 +4,8 @@ import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.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(() => 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", () => ({ - restoreTerminalState: vi.fn(), + restoreTerminalState, })); -vi.mock("../tui/tui.js", () => ({ - runTui, +vi.mock("../tui/tui-launch.js", () => ({ + launchTuiCli, })); vi.mock("./setup.secret-input.js", () => ({ @@ -236,7 +237,8 @@ function createAdvancedFinalizeArgs(params: AdvancedFinalizeArgs = {}) { describe("finalizeSetupWizard", () => { beforeEach(() => { - runTui.mockClear(); + launchTuiCli.mockClear(); + restoreTerminalState.mockClear(); probeGatewayReachable.mockClear(); waitForGatewayReachable.mockReset(); waitForGatewayReachable.mockResolvedValue({ ok: true }); @@ -265,7 +267,7 @@ describe("finalizeSetupWizard", () => { 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; process.env.OPENCLAW_GATEWAY_PASSWORD = "resolved-gateway-password"; // pragma: allowlist secret resolveSetupSecretInputString.mockResolvedValueOnce("resolved-gateway-password"); @@ -337,14 +339,62 @@ describe("finalizeSetupWizard", () => { password: "resolved-gateway-password", // pragma: allowlist secret }), ); - expect(runTui).toHaveBeenCalledWith( - expect.objectContaining({ - url: "ws://127.0.0.1:18789", - password: "resolved-gateway-password", // pragma: allowlist secret - }), + expect(launchTuiCli).toHaveBeenCalledWith( + { + deliver: false, + 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 () => { const prompter = buildWizardPrompter({ select: vi.fn(async () => "later") as never, diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index fc1fdc61509..7f930411b8a 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -30,7 +30,7 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RuntimeEnv } from "../runtime.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 { listConfiguredWebSearchProviders } from "../web-search/runtime.js"; import type { WizardPrompter } from "./prompts.js"; @@ -423,14 +423,21 @@ export async function finalizeSetupWizard( if (hatchChoice === "tui") { restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: true }); - await runTui({ - 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, - }); + try { + await launchTuiCli( + { + // Safety: setup TUI should not auto-deliver to lastProvider/lastTo. + deliver: false, + message: hasBootstrap ? "Wake up, my friend!" : undefined, + }, + { + authSource: "config", + gatewayUrl: links.wsUrl, + }, + ); + } finally { + restoreTerminalState("post-setup tui", { resumeStdinIfPaused: true }); + } launchedTui = true; } else if (hatchChoice === "web") { const browserSupport = await detectBrowserOpenSupport();