diff --git a/qa/scenarios/ui/tui-local-runtime-recovery-pty.yaml b/qa/scenarios/ui/tui-local-runtime-recovery-pty.yaml new file mode 100644 index 000000000000..6fa269a5dbab --- /dev/null +++ b/qa/scenarios/ui/tui-local-runtime-recovery-pty.yaml @@ -0,0 +1,39 @@ +title: TUI local runtime recovery PTY contracts +scenario: + id: tui-local-runtime-recovery-pty + surface: tui + category: tui.runtime-modes + coverage: + primary: + - tui.embedded-local-chat + - tui.config-repair-loop + - tui.gateway-free-recovery + - tui.local-auth-flow + risk: high + objective: Prove built embedded-local chat, config repair, validation-abort recovery, and provider auth through a real PTY without a Gateway. + successCriteria: + - The existing embedded-local chat assertion reaches the mock provider and renders its response. + - An approved built-CLI config repair updates and validates the isolated config before a successful local turn. + - A local validation abort returns to a usable prompt that completes another mock-provider turn. + - A manifest-discovered API-key auth flow masks input, persists to canonical SQLite, resumes the TUI, and preserves the active model. + codeRefs: [src/tui/tui-pty-local.e2e.test.ts] + execution: + kind: script + path: test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts + timeoutMs: 720000 + args: [--artifact-base, "${outputDir}", --scenario-id, "${scenarioId}"] + config: + requireBuiltCli: true + tuiPtyCases: + - coverageId: tui.embedded-local-chat + testFile: src/tui/tui-pty-local.e2e.test.ts + testNamePattern: ^TUI PTY real backends drives and steers the real local backend with a mocked model endpoint$ + - coverageId: tui.config-repair-loop + testFile: src/tui/tui-pty-local.e2e.test.ts + testNamePattern: ^TUI PTY real backends repairs isolated config through the approved built CLI and resumes local chat$ + - coverageId: tui.gateway-free-recovery + testFile: src/tui/tui-pty-local.e2e.test.ts + testNamePattern: ^TUI PTY real backends renders safe validation-loop abort diagnostics through the real local backend$ + - coverageId: tui.local-auth-flow + testFile: src/tui/tui-pty-local.e2e.test.ts + testNamePattern: ^TUI PTY real backends authenticates a manifest-discovered provider and resumes the unchanged local model$ diff --git a/src/auto-reply/reply/commands-openclaw-cli.test.ts b/src/auto-reply/reply/commands-openclaw-cli.test.ts index f539bdbced52..4a133ae8a116 100644 --- a/src/auto-reply/reply/commands-openclaw-cli.test.ts +++ b/src/auto-reply/reply/commands-openclaw-cli.test.ts @@ -1,88 +1,18 @@ -import { createRequire } from "node:module"; // Verifies chat-facing CLI snippets execute the OpenClaw CLI even from harness-hosted gateways. -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildCurrentOpenClawCliArgv, + buildCurrentOpenClawCliCommand, buildCurrentOpenClawCliExecEnv, } from "./commands-openclaw-cli.js"; -const requireFromHere = createRequire(import.meta.url); -const originalArgv = [...process.argv]; -const repoSourceEntry = path.join(process.cwd(), "src", "entry.ts"); -const trustedTsxLoader = requireFromHere.resolve("tsx", { paths: [process.cwd()] }); - -function setArgv1(value: string): void { - process.argv.splice(0, process.argv.length, process.execPath, value); -} - describe("buildCurrentOpenClawCliArgv", () => { - afterEach(() => { - process.argv.splice(0, process.argv.length, ...originalArgv); - }); - - it("falls back to the package CLI entry when hosted by a test harness", () => { - setArgv1(path.join(process.cwd(), "scripts", "test-live.mjs")); - - expect(buildCurrentOpenClawCliArgv(["sessions", "export-trajectory"])).toEqual([ - process.execPath, - "--import", - trustedTsxLoader, - repoSourceEntry, - "sessions", - "export-trajectory", - ]); - }); - - it("preserves a real OpenClaw launcher entry", () => { - setArgv1("/opt/openclaw/openclaw.mjs"); - - expect(buildCurrentOpenClawCliArgv(["sessions", "export-trajectory"])).toEqual([ - process.execPath, - ...process.execArgv, - "/opt/openclaw/openclaw.mjs", - "sessions", - "export-trajectory", - ]); - }); - - it("preserves OpenClaw dist entries from the package root", () => { - const distEntry = path.join(process.cwd(), "dist", "entry.js"); - setArgv1(distEntry); - - expect(buildCurrentOpenClawCliArgv(["sessions", "export-trajectory"])).toEqual([ - process.execPath, - ...process.execArgv, - distEntry, - "sessions", - "export-trajectory", - ]); - }); - - it("preserves OpenClaw source entries from the package root", () => { - const sourceEntry = path.join(process.cwd(), "src", "entry.ts"); - setArgv1(sourceEntry); - - expect(buildCurrentOpenClawCliArgv(["sessions", "export-trajectory"])).toEqual([ - process.execPath, - ...process.execArgv, - sourceEntry, - "sessions", - "export-trajectory", - ]); - }); - - it("does not treat foreign dist entries as OpenClaw launchers", () => { - setArgv1("/app/dist/index.js"); - - expect(buildCurrentOpenClawCliArgv(["sessions", "export-trajectory"])).toEqual([ - process.execPath, - "--import", - trustedTsxLoader, - repoSourceEntry, - "sessions", - "export-trajectory", - ]); + it("delegates launch policy while keeping shell rendering local", () => { + const args = ["sessions", "export-trajectory"]; + const argv = buildCurrentOpenClawCliArgv(args); + expect(argv.at(-2)).toBe("sessions"); + expect(argv.at(-1)).toBe("export-trajectory"); + expect(buildCurrentOpenClawCliCommand(args)).toBe(argv.map((value) => `'${value}'`).join(" ")); }); it("clears inherited Vitest runner environment for CLI child processes", () => { diff --git a/src/auto-reply/reply/commands-openclaw-cli.ts b/src/auto-reply/reply/commands-openclaw-cli.ts index d646ae066c7d..99c62e39eb72 100644 --- a/src/auto-reply/reply/commands-openclaw-cli.ts +++ b/src/auto-reply/reply/commands-openclaw-cli.ts @@ -1,19 +1,6 @@ // Formats OpenClaw CLI command snippets for chat-facing command responses. -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { isBunRuntime } from "../../daemon/runtime-binary.js"; -import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; +import { resolveCurrentOpenClawCliInvocation } from "../../infra/openclaw-cli-invocation.js"; -const requireFromHere = createRequire(import.meta.url); -const OPENCLAW_CLI_ENTRY_BASENAMES = new Set(["openclaw", "openclaw.mjs"]); -const OPENCLAW_PACKAGE_ENTRY_PATHS = new Set([ - path.join("dist", "entry.js"), - path.join("dist", "entry.mjs"), - path.join("dist", "index.js"), - path.join("dist", "index.mjs"), - path.join("src", "entry.ts"), -]); const TEST_RUNNER_ENV_PREFIXES = ["VITEST_", "OPENCLAW_VITEST_"]; function quoteShellArg(value: string): string { @@ -23,67 +10,10 @@ function quoteShellArg(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } -function isOpenClawCliLauncherEntry(entry: string): boolean { - return OPENCLAW_CLI_ENTRY_BASENAMES.has(path.basename(entry)); -} - -function isOpenClawPackageEntry(entry: string, packageRoot: string): boolean { - const relativeEntry = path.relative(path.resolve(packageRoot), path.resolve(entry)); - return OPENCLAW_PACKAGE_ENTRY_PATHS.has(relativeEntry); -} - -function safeCwd(): string | undefined { - try { - return process.cwd(); - } catch { - return undefined; - } -} - -function buildPackageRootCliArgvPrefix(packageRoot: string): string[] { - const sourceEntry = path.join(packageRoot, "src", "entry.ts"); - if (fs.existsSync(sourceEntry)) { - const tsxLoader = resolveTrustedTsxLoader(packageRoot); - return isBunRuntime(process.execPath) - ? [process.execPath, sourceEntry] - : tsxLoader - ? [process.execPath, "--import", tsxLoader, sourceEntry] - : [process.execPath, path.join(packageRoot, "openclaw.mjs")]; - } - return [process.execPath, path.join(packageRoot, "openclaw.mjs")]; -} - -function resolveTrustedTsxLoader(packageRoot: string): string | null { - try { - return requireFromHere.resolve("tsx", { paths: [packageRoot] }); - } catch { - return null; - } -} - -function resolveCurrentOpenClawCliArgvPrefix(): string[] { - const entry = process.argv[1]?.trim(); - if (entry && entry !== process.execPath && isOpenClawCliLauncherEntry(entry)) { - return [process.execPath, ...process.execArgv, entry]; - } - const entryPackageRoot = entry ? resolveOpenClawPackageRootSync({ argv1: entry }) : null; - if (entry && entryPackageRoot && isOpenClawPackageEntry(entry, entryPackageRoot)) { - return [process.execPath, ...process.execArgv, entry]; - } - const packageRoot = resolveOpenClawPackageRootSync({ - argv1: entry, - cwd: safeCwd(), - moduleUrl: import.meta.url, - }); - if (packageRoot) { - return buildPackageRootCliArgvPrefix(packageRoot); - } - return entry && entry !== process.execPath ? [process.execPath, entry] : [process.execPath]; -} - /** Reconstructs the current OpenClaw CLI invocation with extra args. */ export function buildCurrentOpenClawCliArgv(args: string[]): string[] { - return [...resolveCurrentOpenClawCliArgvPrefix(), ...args]; + const invocation = resolveCurrentOpenClawCliInvocation(args); + return [invocation.command, ...invocation.args]; } /** Clears test-runner env inherited by harness-hosted gateways before spawning the CLI. */ diff --git a/src/infra/openclaw-cli-invocation.test.ts b/src/infra/openclaw-cli-invocation.test.ts new file mode 100644 index 000000000000..e256fc4403e5 --- /dev/null +++ b/src/infra/openclaw-cli-invocation.test.ts @@ -0,0 +1,123 @@ +import { writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { resolveCurrentOpenClawCliInvocation } from "./openclaw-cli-invocation.js"; + +const requireFromHere = createRequire(import.meta.url); +const repoRoot = process.cwd(); +const repoSourceEntry = path.join(repoRoot, "src", "entry.ts"); +const trustedTsxLoader = requireFromHere.resolve("tsx", { paths: [repoRoot] }); +const commandArgs = ["sessions", "export-trajectory"]; + +describe("resolveCurrentOpenClawCliInvocation", () => { + it("uses the source entry for a Node-hosted checkout harness", () => { + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: path.join(repoRoot, "scripts", "test-live.mjs"), + cwd: repoRoot, + execArgv: [], + execPath: "/usr/bin/node", + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["--import", trustedTsxLoader, repoSourceEntry, ...commandArgs], + cwd: repoRoot, + }); + }); + + it("uses the source entry directly under Bun", () => { + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: path.join(repoRoot, "scripts", "test-live.mjs"), + cwd: repoRoot, + execPath: "/usr/local/bin/bun", + }), + ).toEqual({ + command: "/usr/local/bin/bun", + args: [repoSourceEntry, ...commandArgs], + cwd: repoRoot, + }); + }); + + it("preserves launcher argv and execArgv from the current checkout", () => { + const launcher = path.join(repoRoot, "openclaw.mjs"); + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: launcher, + cwd: path.join(repoRoot, "src"), + execArgv: ["--trace-warnings"], + execPath: "/usr/bin/node", + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["--trace-warnings", launcher, ...commandArgs], + cwd: repoRoot, + }); + }); + + it("preserves package entry argv from the current checkout", () => { + const distEntry = path.join(repoRoot, "dist", "entry.js"); + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: distEntry, + cwd: repoRoot, + execArgv: ["--enable-source-maps"], + execPath: "/usr/bin/node", + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["--enable-source-maps", distEntry, ...commandArgs], + cwd: repoRoot, + }); + }); + + it("uses the installed wrapper and canonical package cwd", async () => { + await withTempDir("openclaw-cli-invocation-", async (packageRoot) => { + await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "tui", "index.js")).href; + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: path.join(packageRoot, "bin", "host.mjs"), + cwd: path.join(packageRoot, "state"), + execPath: "/usr/bin/node", + moduleUrl, + }), + ).toEqual({ + command: "/usr/bin/node", + args: [path.join(packageRoot, "openclaw.mjs"), ...commandArgs], + cwd: packageRoot, + }); + }); + }); + + it("does not preserve a foreign package entry", () => { + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: "/app/dist/index.js", + cwd: repoRoot, + execPath: "/usr/bin/node", + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["--import", trustedTsxLoader, repoSourceEntry, ...commandArgs], + cwd: repoRoot, + }); + }); + + it("does not preserve a foreign launcher basename", () => { + expect( + resolveCurrentOpenClawCliInvocation(commandArgs, { + argv1: "/other/openclaw.mjs", + cwd: repoRoot, + execPath: "/usr/bin/node", + }), + ).toEqual({ + command: "/usr/bin/node", + args: ["--import", trustedTsxLoader, repoSourceEntry, ...commandArgs], + cwd: repoRoot, + }); + }); +}); diff --git a/src/infra/openclaw-cli-invocation.ts b/src/infra/openclaw-cli-invocation.ts new file mode 100644 index 000000000000..f22c3bc37b2c --- /dev/null +++ b/src/infra/openclaw-cli-invocation.ts @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { isBunRuntime } from "../daemon/runtime-binary.js"; +import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; +import { tryProcessCwd } from "./safe-cwd.js"; + +const requireFromHere = createRequire(import.meta.url); +const OPENCLAW_CLI_ENTRY_BASENAMES = new Set(["openclaw", "openclaw.mjs"]); +const OPENCLAW_PACKAGE_ENTRY_PATHS = new Set([ + path.join("dist", "entry.js"), + path.join("dist", "entry.mjs"), + path.join("dist", "index.js"), + path.join("dist", "index.mjs"), + path.join("src", "entry.ts"), +]); + +type OpenClawCliInvocation = Readonly<{ + command: string; + args: string[]; + cwd: string; +}>; + +function resolveTrustedTsxLoader(packageRoot: string): string | null { + try { + return requireFromHere.resolve("tsx", { paths: [packageRoot] }); + } catch { + return null; + } +} + +function buildPackageRootCliArgs(packageRoot: string, execPath: string): string[] { + const sourceEntry = path.join(packageRoot, "src", "entry.ts"); + if (fs.existsSync(sourceEntry)) { + const tsxLoader = resolveTrustedTsxLoader(packageRoot); + return isBunRuntime(execPath) + ? [sourceEntry] + : tsxLoader + ? ["--import", tsxLoader, sourceEntry] + : [path.join(packageRoot, "openclaw.mjs")]; + } + return [path.join(packageRoot, "openclaw.mjs")]; +} + +export function resolveCurrentOpenClawCliInvocation( + args: readonly string[], + options: { + argv1?: string; + cwd?: string; + execArgv?: readonly string[]; + execPath?: string; + moduleUrl?: string; + } = {}, +): OpenClawCliInvocation { + const execPath = options.execPath ?? process.execPath; + const execArgv = options.execArgv ?? process.execArgv; + const entry = (options.argv1 ?? process.argv[1])?.trim(); + const cwd = options.cwd ?? tryProcessCwd(); + const entryPackageRoot = entry ? resolveOpenClawPackageRootSync({ argv1: entry }) : null; + const packageRoot = + entryPackageRoot ?? + resolveOpenClawPackageRootSync({ + argv1: entry, + cwd, + moduleUrl: options.moduleUrl ?? import.meta.url, + }); + const invocationCwd = + packageRoot ?? cwd ?? (entry ? path.dirname(path.resolve(entry)) : path.dirname(execPath)); + + if ( + entry && + entry !== execPath && + entryPackageRoot && + (OPENCLAW_CLI_ENTRY_BASENAMES.has(path.basename(entry)) || + OPENCLAW_PACKAGE_ENTRY_PATHS.has( + path.relative(path.resolve(entryPackageRoot), path.resolve(entry)), + )) + ) { + return { command: execPath, args: [...execArgv, entry, ...args], cwd: invocationCwd }; + } + if (packageRoot) { + return { + command: execPath, + args: [...buildPackageRootCliArgs(packageRoot, execPath), ...args], + cwd: invocationCwd, + }; + } + return { + command: execPath, + args: [...(entry && entry !== execPath ? [entry] : []), ...args], + cwd: invocationCwd, + }; +} diff --git a/src/tui/tui-exec-argv.ts b/src/tui/tui-exec-argv.ts new file mode 100644 index 000000000000..de34d0e835b1 --- /dev/null +++ b/src/tui/tui-exec-argv.ts @@ -0,0 +1,34 @@ +export function filterTuiExecArgv(execArgv: readonly string[]): string[] { + const filtered: string[] = []; + for (let index = 0; index < execArgv.length; index += 1) { + const arg = execArgv[index] ?? ""; + // Strip inspector flags so TUI-owned children cannot contend with or pause beneath + // the parent debugger. + 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; +} diff --git a/src/tui/tui-launch.ts b/src/tui/tui-launch.ts index 1e34bbfc8a82..1c3efaad7490 100644 --- a/src/tui/tui-launch.ts +++ b/src/tui/tui-launch.ts @@ -4,6 +4,7 @@ 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 { filterTuiExecArgv } from "./tui-exec-argv.js"; import type { TuiOptions } from "./tui.js"; // Relaunch helper used when setup wants to hand control to an inherited-stdio TUI process. @@ -19,40 +20,6 @@ function appendOption(args: string[], flag: string, value: string | number | und 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] ?? ""; - // Strip inspector flags so a relaunched TUI does not fight the parent debug port. - 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) { diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index 8ccd9f9cd573..421cfe5b7985 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -1,5 +1,6 @@ // Exercises slower TUI PTY paths against real local and Gateway backends. -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -9,9 +10,12 @@ import { createOpenClawTestInstance, type OpenClawTestInstance, } from "../../test/helpers/openclaw-test-instance.js"; +import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { connectGatewayClient } from "../gateway/test-helpers.e2e.js"; +import { runExec } from "../process/exec.js"; import { createDeferred } from "../test-utils/deferred.js"; import { GatewayChatClient } from "./gateway-chat.js"; import { synchronizedFrameRows } from "./tui-pty-harness-assertion-test-support.js"; @@ -27,6 +31,7 @@ type MockModelServer = { baseUrl: string; requests: (modelId?: string) => MockModelRequest[]; rejectedRequests: () => MockModelRequest[]; + allowValidResponses: (modelId: string) => void; releaseFirstResponse: (modelId: string) => void; stop: () => Promise; }; @@ -369,6 +374,12 @@ async function startRoutedMockModelServer( baseUrl: `http://127.0.0.1:${address.port}`, requests: (modelId) => (modelId ? (requestsByModel.get(modelId) ?? []) : requests), rejectedRequests: () => rejectedRequests, + allowValidResponses: (modelId) => { + const behavior = behaviors[modelId]; + if (behavior) { + behavior.invalidEditLoop = false; + } + }, releaseFirstResponse: (modelId) => { firstResponseGates.get(modelId)?.resolve(); }, @@ -514,6 +525,11 @@ async function startLocalModeTui( holdFirstResponse?: boolean; followupReplyText?: string; replyText?: string; + prepareConfig?: (params: { + config: OpenClawConfig; + tempDir: string; + stateDir: string; + }) => Promise | OpenClawConfig; } = {}, ) { const replyText = opts.replyText ?? "LOCAL_PTY_RESPONSE"; @@ -525,18 +541,39 @@ async function startLocalModeTui( const xdgDataHome = path.join(tempDir, "xdg-data"); const xdgCacheHome = path.join(tempDir, "xdg-cache"); const configPath = path.join(tempDir, "openclaw.json"); + const env: NodeJS.ProcessEnv = { + HOME: homeDir, + OPENCLAW_HOME: homeDir, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS: "500", + OPENCLAW_AGENT_DIR: undefined, + OPENCLAW_SKIP_PROVIDERS: undefined, + XDG_CONFIG_HOME: xdgConfigHome, + XDG_DATA_HOME: xdgDataHome, + XDG_CACHE_HOME: xdgCacheHome, + OPENCLAW_THEME: "dark", + OPENCLAW_CODEX_DISCOVERY_LIVE: "0", + NO_COLOR: undefined, + }; const mockModel = await startMockModelServer(replyText, { invalidEditLoop: opts.invalidEditLoop, holdFirstResponse: opts.holdFirstResponse, followupReplyText: opts.followupReplyText, }); - const config = buildLocalModeConfig({ + let config: OpenClawConfig = buildLocalModeConfig({ workspaceDir, providerBaseUrl: mockModel.baseUrl, toolsProfile: opts.invalidEditLoop ? "coding" : "minimal", }); let run: PtyRun; try { + config = + (await opts.prepareConfig?.({ + config, + tempDir, + stateDir, + })) ?? config; await Promise.all([ mkdir(workspaceDir, { recursive: true }), mkdir(homeDir, { recursive: true }), @@ -549,21 +586,7 @@ async function startLocalModeTui( run = startPty(process.execPath, buildTuiProcessArgs(opts.cliArgs ?? ["tui", "--local"]), { cwd: process.cwd(), - env: { - HOME: homeDir, - OPENCLAW_HOME: homeDir, - OPENCLAW_CONFIG_PATH: configPath, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_TUI_LOCAL_RUN_SHUTDOWN_GRACE_MS: "500", - OPENCLAW_AGENT_DIR: undefined, - OPENCLAW_SKIP_PROVIDERS: undefined, - XDG_CONFIG_HOME: xdgConfigHome, - XDG_DATA_HOME: xdgDataHome, - XDG_CACHE_HOME: xdgCacheHome, - OPENCLAW_THEME: "dark", - OPENCLAW_CODEX_DISCOVERY_LIVE: "0", - NO_COLOR: undefined, - }, + env, exitTimeoutMs: LOCAL_EXIT_TIMEOUT_MS, outputTimeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, }); @@ -596,6 +619,9 @@ async function startLocalModeTui( kind: "local" as const, run, mockModel, + configPath, + env, + stateDir, cleanup, }; } @@ -1197,6 +1223,214 @@ describe("TUI PTY real backends", () => { LOCAL_TEST_TIMEOUT_MS, ); + it( + "repairs isolated config through the approved built CLI and resumes local chat", + async ({ onTestFinished }) => { + const fixture = await startLocalModeTui(onTestFinished, { + prepareConfig: ({ config }) => ({ + ...config, + tools: { ...config.tools, profile: "coding" }, + }), + }); + try { + await fixture.run.waitForOutput("local ready", LOCAL_STARTUP_TIMEOUT_MS); + const cliPath = path.join(process.cwd(), "openclaw.mjs"); + const cli = `${JSON.stringify(process.execPath)} ${JSON.stringify(cliPath)}`; + await fixture.run.write(`!${cli} config set tools.profile minimal\r`); + await fixture.run.waitForOutput("Allow local shell commands for this session?"); + await fixture.run.write("\u001b[B\r", { delay: false }); + await fixture.run.waitForOutput("local shell: enabled for this session"); + await fixture.run.waitForOutput("[local] exit 0"); + + const repaired = JSON.parse(await readFile(fixture.configPath, "utf8")) as OpenClawConfig; + expect(repaired.tools?.profile).toBe("minimal"); + + const { stdout } = await runExec( + process.execPath, + [cliPath, "config", "validate", "--json"], + { + cwd: process.cwd(), + env: { ...fixture.env, OPENCLAW_TEST_RUNTIME_LOG: "1" }, + logOutput: false, + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ valid: true }); + + await fixture.run.write("prompt after config repair\r"); + await waitFor({ + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + read: () => (fixture.mockModel.requests().length === 1 ? true : null), + onTimeout: () => new Error("post-repair prompt did not reach the mock provider"), + }); + expect(JSON.stringify(fixture.mockModel.requests()[0]?.body)).toContain( + "prompt after config repair", + ); + await fixture.run.waitForOutput("LOCAL_PTY_RESPONSE", LOCAL_OUTPUT_TIMEOUT_MS); + + await fixture.run.write("/exit\r", { delay: false }); + expect((await fixture.run.waitForExit()).exitCode).toBe(0); + } finally { + await fixture.cleanup(); + } + }, + LOCAL_TEST_TIMEOUT_MS, + ); + + it( + "authenticates a manifest-discovered provider and resumes the unchanged local model", + async ({ onTestFinished }) => { + const pluginId = "t05-local-auth-fixture"; + const providerId = "t05-local-auth-provider"; + const profileId = `${providerId}:default`; + const sentinel = `t05-${randomUUID()}`; + const expectedDigest = createHash("sha256").update(sentinel).digest("hex"); + const fixture = await startLocalModeTui(onTestFinished, { + replyText: "LOCAL_AUTH_RESPONSE", + prepareConfig: async ({ config, tempDir }) => { + const pluginDir = path.join(tempDir, "auth-plugin"); + await mkdir(pluginDir, { recursive: true }); + await Promise.all([ + writeFile( + path.join(pluginDir, "package.json"), + `${JSON.stringify( + { + name: "@openclaw/t05-local-auth-fixture", + version: "0.0.0", + type: "module", + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, + "utf8", + ), + writeFile( + path.join(pluginDir, "openclaw.plugin.json"), + `${JSON.stringify( + { + id: pluginId, + name: "T05 Local Auth Fixture", + providers: [providerId], + setup: { + providers: [{ id: providerId, envVars: ["T05_LOCAL_AUTH_API_KEY"] }], + }, + providerAuthChoices: [ + { + provider: providerId, + method: "api-key", + choiceId: `${providerId}-api-key`, + choiceLabel: "T05 local auth API key", + groupId: providerId, + groupLabel: "T05 local auth", + optionKey: "t05LocalAuthApiKey", + cliFlag: "--t05-local-auth-api-key", + cliOption: "--t05-local-auth-api-key ", + onboardingScopes: ["text-inference"], + }, + ], + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + )}\n`, + "utf8", + ), + writeFile( + path.join(pluginDir, "index.js"), + `const providerId = ${JSON.stringify(providerId)}; +export default { + id: ${JSON.stringify(pluginId)}, + name: "T05 Local Auth Fixture", + register(api) { + api.registerProvider({ + id: providerId, + label: "T05 local auth", + envVars: ["T05_LOCAL_AUTH_API_KEY"], + auth: [{ + id: "api-key", + kind: "api_key", + label: "T05 local auth API key", + run: async (ctx) => { + const key = await ctx.prompter.text({ + message: "Enter T05 local auth API key", + sensitive: true, + }); + return { + profiles: [{ + profileId: providerId + ":default", + credential: { type: "api_key", provider: providerId, key }, + }], + }; + }, + }], + }); + }, +}; +`, + "utf8", + ), + ]); + return { + ...config, + plugins: { + enabled: true, + slots: { memory: "none" }, + load: { paths: [pluginDir] }, + allow: [pluginId], + entries: { [pluginId]: { enabled: true } }, + }, + }; + }, + }); + try { + await fixture.run.waitForOutput("local ready", LOCAL_STARTUP_TIMEOUT_MS); + await fixture.run.write(`/auth ${providerId}\r`, { delay: false }); + await fixture.run.waitForOutput(`opening auth flow for ${providerId}`); + await fixture.run.waitForOutput("Enter T05 local auth API key"); + await fixture.run.write(`${sentinel}\r`, { delay: false }); + await fixture.run.waitForOutput(`auth flow finished for ${providerId}`); + expect(fixture.run.output().includes(sentinel)).toBe(false); + + const agentDir = path.join(fixture.stateDir, "agents", "main", "agent"); + const sqlitePath = path.join(agentDir, "openclaw-agent.sqlite"); + expect(await stat(sqlitePath).then((entry) => entry.isFile())).toBe(true); + const store = loadPersistedAuthProfileStore(agentDir); + const profile = store?.profiles[profileId]; + expect(profile?.type === "api_key").toBe(true); + expect(profile?.provider === providerId).toBe(true); + const persistedDigest = + profile?.type === "api_key" && profile.key + ? createHash("sha256").update(profile.key).digest("hex") + : ""; + expect(persistedDigest).toBe(expectedDigest); + + const config = JSON.parse(await readFile(fixture.configPath, "utf8")) as OpenClawConfig; + expect(resolveAgentModelPrimaryValue(config.agents?.defaults?.model)).toBe( + "tui-pty-mock/gpt-5.5", + ); + await fixture.run.write("prompt after local auth\r"); + await waitFor({ + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + read: () => (fixture.mockModel.requests().length === 1 ? true : null), + onTimeout: () => new Error("post-auth prompt did not reach the mock provider"), + }); + expect(fixture.mockModel.requests()[0]?.body.model).toBe("gpt-5.5"); + await fixture.run.waitForOutput("LOCAL_AUTH_RESPONSE", LOCAL_OUTPUT_TIMEOUT_MS); + + await fixture.run.write("/exit\r", { delay: false }); + expect((await fixture.run.waitForExit()).exitCode).toBe(0); + } finally { + await fixture.cleanup(); + } + }, + LOCAL_TEST_TIMEOUT_MS, + ); + function registerValidationLoopTest(mode: "gateway" | "local") { it( `renders safe validation-loop abort diagnostics through the real ${mode} backend`, @@ -1286,6 +1520,18 @@ describe("TUI PTY real backends", () => { expect(caseOutput).not.toContain("Received arguments"); if (fixture.kind === "local") { + fixture.mockModel.allowValidResponses("gpt-5.5"); + const abortedRequestCount = fixture.mockModel.requests().length; + await fixture.run.write("prompt after local validation abort\r"); + await waitFor({ + timeoutMs: LOCAL_OUTPUT_TIMEOUT_MS, + read: () => (fixture.mockModel.requests().length > abortedRequestCount ? true : null), + onTimeout: () => new Error("post-abort prompt did not reach the mock provider"), + }); + expect(JSON.stringify(fixture.mockModel.requests().at(-1)?.body)).toContain( + "prompt after local validation abort", + ); + await fixture.run.waitForOutput("LOCAL_PTY_RESPONSE", LOCAL_OUTPUT_TIMEOUT_MS); await fixture.run.write("/exit\r", { delay: false }); expect((await fixture.run.waitForExit()).exitCode).toBe(0); } @@ -1298,6 +1544,8 @@ describe("TUI PTY real backends", () => { ); } + registerValidationLoopTest("local"); + // Register every Gateway case inside the nested suite so targeted runs retain // the fixture's separate startup timeout. const gatewayTestRegistrations: Array<() => void> = []; diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index e1cc8d893b11..86dc834f39de 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,5 +1,6 @@ // Covers core TUI state transitions and backend event rendering. import { EventEmitter } from "node:events"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { MAX_TIMER_TIMEOUT_MS } from "../infra/parse-finite-number.js"; @@ -22,10 +23,9 @@ import { resolveInitialTuiAgentId, resolveTuiToolsToggleActivityStatus, isTuiBusyActivityStatus, - resolveLocalAuthCliInvocation, - resolveLocalAuthSpawnCwd, resolveLocalAuthSpawnInvocation, resolveTuiCtrlCAction, + resolveTuiLocalAuthCliInvocation, resolveTuiShutdownHardExitMs, resolveTuiSessionKey, scheduleProcessExitAfterTuiReturn, @@ -67,6 +67,44 @@ describe("resolveFinalAssistantText", () => { }); }); +describe("resolveTuiLocalAuthCliInvocation", () => { + it("filters inspector flags while preserving the current CLI runtime context", () => { + const originalArgv = [...process.argv]; + try { + const cliEntry = path.resolve("openclaw.mjs"); + process.argv[1] = cliEntry; + + expect( + resolveTuiLocalAuthCliInvocation({ + provider: "test-provider", + execArgv: [ + "--import", + "/repo/node_modules/tsx/dist/loader.mjs", + "--inspect-brk=0", + "--trace-warnings", + ], + }), + ).toStrictEqual({ + command: process.execPath, + args: [ + "--import", + "/repo/node_modules/tsx/dist/loader.mjs", + "--trace-warnings", + cliEntry, + "models", + "auth", + "login", + "--provider", + "test-provider", + ], + cwd: path.resolve("."), + }); + } finally { + process.argv = originalArgv; + } + }); +}); + describe("tui slash commands", () => { it("treats /elev as an alias for /elevated", () => { expect(parseCommand("/elev on")).toEqual({ name: "elevated", args: "on" }); @@ -935,38 +973,6 @@ describe("resolveCodexCliBin", () => { }); }); -describe("resolveLocalAuthCliInvocation", () => { - it("uses the source runner when dist is unavailable", () => { - expect( - resolveLocalAuthCliInvocation({ - execPath: "/usr/bin/node", - wrapperPath: "/repo/openclaw.mjs", - runNodePath: "/repo/scripts/run-node.mjs", - hasDistEntry: false, - hasRunNodeScript: true, - }), - ).toEqual({ - command: "/usr/bin/node", - args: ["/repo/scripts/run-node.mjs", "models", "auth", "login"], - }); - }); - - it("uses the packaged wrapper when dist is available", () => { - expect( - resolveLocalAuthCliInvocation({ - execPath: "/usr/bin/node", - wrapperPath: "/repo/openclaw.mjs", - runNodePath: "/repo/scripts/run-node.mjs", - hasDistEntry: true, - hasRunNodeScript: true, - }), - ).toEqual({ - command: "/usr/bin/node", - args: ["/repo/openclaw.mjs", "models", "auth", "login"], - }); - }); -}); - describe("resolveLocalAuthSpawnInvocation", () => { it("wraps Windows cmd shims through cmd.exe", () => { expect( @@ -1013,32 +1019,3 @@ describe("resolveLocalAuthSpawnInvocation", () => { ).toStrictEqual({ command: "C:\\tools\\codex.exe", args: ["login"], options: {} }); }); }); - -describe("resolveLocalAuthSpawnCwd", () => { - it("runs the packaged wrapper from the repo root", () => { - expect( - resolveLocalAuthSpawnCwd({ - args: ["/repo/openclaw.mjs", "models", "auth", "login"], - defaultCwd: "/worktree/subdir", - }), - ).toBe("/repo"); - }); - - it("runs the source fallback helper from the repo root", () => { - expect( - resolveLocalAuthSpawnCwd({ - args: ["/repo/scripts/run-node.mjs", "models", "auth", "login"], - defaultCwd: "/worktree/subdir", - }), - ).toBe("/repo"); - }); - - it("keeps the caller cwd for direct codex exec", () => { - expect( - resolveLocalAuthSpawnCwd({ - args: ["login"], - defaultCwd: "/worktree/subdir", - }), - ).toBe("/worktree/subdir"); - }); -}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 7f19f718ab01..169489d4134d 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1,9 +1,6 @@ // Runs the interactive TUI loop and coordinates backend, input, and rendering. import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; import { CombinedAutocompleteProvider, Container, @@ -17,6 +14,7 @@ import type { CommandEntry } from "../../packages/gateway-protocol/src/index.js" import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js"; import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; +import { resolveCurrentOpenClawCliInvocation } from "../infra/openclaw-cli-invocation.js"; import { tryProcessCwd } from "../infra/safe-cwd.js"; import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js"; import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js"; @@ -44,6 +42,7 @@ import { sanitizeAutocompleteProvider } from "./tui-autocomplete.js"; import type { TuiBackend } from "./tui-backend.js"; import { createCommandHandlers } from "./tui-command-handlers.js"; import { createEventHandlers } from "./tui-event-handlers.js"; +import { filterTuiExecArgv } from "./tui-exec-argv.js"; import { formatTuiErrorMessage, formatTuiFooter, @@ -85,13 +84,6 @@ export { shouldEnableWindowsGitBashPasteFallback, } from "./tui-submit.js"; -const OPENCLAW_CLI_WRAPPER_PATH = fileURLToPath(new URL("../../openclaw.mjs", import.meta.url)); -const OPENCLAW_RUN_NODE_SCRIPT_PATH = fileURLToPath( - new URL("../../scripts/run-node.mjs", import.meta.url), -); -const DIST_ENTRY_JS_PATH = fileURLToPath(new URL("../../dist/entry.js", import.meta.url)); -const DIST_ENTRY_MJS_PATH = fileURLToPath(new URL("../../dist/entry.mjs", import.meta.url)); - const OPENAI_CODEX_PROVIDER = "openai"; const CODEX_CLI_LOOKUP_TIMEOUT_MS = 5_000; const SESSION_SUBSCRIPTION_MAX_ATTEMPTS = 5; @@ -133,27 +125,6 @@ export async function resolveCodexCliBin(): Promise { } } -export function resolveLocalAuthCliInvocation(params?: { - execPath?: string; - wrapperPath?: string; - runNodePath?: string; - hasDistEntry?: boolean; - hasRunNodeScript?: boolean; -}): { command: string; args: string[] } { - const hasDistEntry = - params?.hasDistEntry ?? (existsSync(DIST_ENTRY_JS_PATH) || existsSync(DIST_ENTRY_MJS_PATH)); - const hasRunNodeScript = params?.hasRunNodeScript ?? existsSync(OPENCLAW_RUN_NODE_SCRIPT_PATH); - const command = params?.execPath ?? process.execPath; - const wrapperPath = params?.wrapperPath ?? OPENCLAW_CLI_WRAPPER_PATH; - const runNodePath = params?.runNodePath ?? OPENCLAW_RUN_NODE_SCRIPT_PATH; - - // Prefer the packaged wrapper when build output exists, but keep source-tree - // auth working in unbuilt checkouts that only have scripts/run-node.mjs. - return hasDistEntry || !hasRunNodeScript - ? { command, args: [wrapperPath, "models", "auth", "login"] } - : { command, args: [runNodePath, "models", "auth", "login"] }; -} - export function resolveLocalAuthSpawnInvocation(params: { command: string; args: string[]; @@ -174,21 +145,17 @@ export function resolveLocalAuthSpawnInvocation(params: { }; } -export function resolveLocalAuthSpawnCwd(params: { args: string[]; defaultCwd?: string }): string { - const defaultCwd = - params.defaultCwd ?? tryProcessCwd() ?? path.dirname(OPENCLAW_CLI_WRAPPER_PATH); - const entryArg = params.args[0]?.trim(); - if (!entryArg) { - return defaultCwd; - } - const entryBase = path.basename(entryArg).toLowerCase(); - if (entryBase === "openclaw.mjs") { - return path.dirname(entryArg); - } - if (entryBase === "run-node.mjs") { - return path.dirname(path.dirname(entryArg)); - } - return defaultCwd; +export function resolveTuiLocalAuthCliInvocation(params: { + provider?: string; + execArgv?: readonly string[]; +}) { + const provider = params.provider?.trim(); + return resolveCurrentOpenClawCliInvocation( + ["models", "auth", "login", ...(provider ? ["--provider", provider] : [])], + { + execArgv: filterTuiExecArgv(params.execArgv ?? process.execArgv), + }, + ); } export function resolveTuiSessionKey(params: { @@ -606,8 +573,8 @@ function resolveEmptySessionInfoDefaults(config: OpenClawConfig): SessionInfo { export async function runTui(opts: RunTuiOptions): Promise { const isLocalMode = opts.local === true || opts.backend !== undefined; const config = opts.config ?? getRuntimeConfig({ skipPluginValidation: !isLocalMode }); - const fallbackCwd = path.dirname(OPENCLAW_CLI_WRAPPER_PATH); - const resolveUsableCwd = () => tryProcessCwd() ?? fallbackCwd; + const cliInvocation = resolveCurrentOpenClawCliInvocation([]); + const resolveUsableCwd = () => tryProcessCwd() ?? cliInvocation.cwd; const emptySessionInfoDefaults = resolveEmptySessionInfoDefaults(config); const initialSessionInput = (opts.session ?? "").trim(); const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope; @@ -1198,19 +1165,19 @@ export async function runTui(opts: RunTuiOptions): Promise { (resolve, reject) => { let command: string; let args: string[]; + let cwd: string; if (codexBin) { command = codexBin; args = ["login"]; + cwd = resolveUsableCwd(); } else { - ({ command, args } = resolveLocalAuthCliInvocation()); - if (provider) { - args.push("--provider", provider); - } + const invocation = resolveTuiLocalAuthCliInvocation({ provider }); + ({ command, args, cwd } = invocation); } const invocation = resolveLocalAuthSpawnInvocation({ command, args }); const child = spawn(invocation.command, invocation.args, { - cwd: resolveLocalAuthSpawnCwd({ args, defaultCwd: resolveUsableCwd() }), + cwd, env: process.env, stdio: "inherit", ...invocation.options,