mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user