fix(tui): restore provider auth in installed local mode (#119283)

* test(tui): cover local recovery and auth flows

* fix(tui): restore installed local auth

* test(qa): allow TUI scenario teardown margin

Punchcard-Session: ember-workshop-lantern-bs

* fix(tui): constrain local CLI launcher ownership

Punchcard-Session: ember-workshop-lantern-bs

* fix(tui): satisfy local auth validation gates

Punchcard-Session: ember-workshop-lantern-bs

* fix(tui): strip inspector flags from auth child

Punchcard-Session: ember-workshop-lantern-bs
This commit is contained in:
Vincent Koc
2026-08-05 05:40:13 +08:00
committed by GitHub
parent 8fc4458593
commit bfcce12e6b
10 changed files with 626 additions and 318 deletions
@@ -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$
@@ -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", () => {
+3 -73
View File
@@ -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. */
+123
View File
@@ -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,
});
});
});
+93
View File
@@ -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,
};
}
+34
View File
@@ -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;
}
+1 -34
View File
@@ -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) {
+265 -17
View File
@@ -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<void>;
};
@@ -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> | 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 <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> = [];
+40 -63
View File
@@ -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");
});
});
+20 -53
View File
@@ -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<string | null> {
}
}
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<TuiResult> {
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<TuiResult> {
(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,