Files
openclaw/src/cli/run-main.ts
2026-05-09 08:05:05 +08:00

725 lines
23 KiB
TypeScript

import { existsSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
import { isMainModule } from "../infra/is-main.js";
import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import {
isReservedNonPluginCommandRoot,
shouldRegisterPrimaryCommandOnly,
shouldSkipPluginCommandRegistration,
} from "./command-registration-policy.js";
import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js";
import {
consumeGatewayFastPathRootOptionToken,
consumeGatewayRunOptionToken,
} from "./gateway-run-argv.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { getCoreCliCommandNames } from "./program/core-command-descriptors.js";
import { getSubCliEntries } from "./program/subcli-descriptors.js";
import {
resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy,
rewriteUpdateFlagArgv,
shouldEnsureCliPath,
shouldStartCrestodianForBareRoot,
shouldStartCrestodianForModernOnboard,
shouldStartProxyForCli,
shouldUseBrowserHelpFastPath,
shouldUseRootHelpFastPath,
} from "./run-main-policy.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
export {
rewriteUpdateFlagArgv,
shouldEnsureCliPath,
shouldStartCrestodianForBareRoot,
shouldStartCrestodianForModernOnboard,
shouldStartProxyForCli,
shouldUseBrowserHelpFastPath,
shouldUseRootHelpFastPath,
} from "./run-main-policy.js";
type Awaitable<T> = T | Promise<T>;
const CLI_PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] as const;
function createGatewayCliMainStartupTrace(argv: string[]) {
const enabled =
isTruthyEnvValue(process.env.OPENCLAW_GATEWAY_STARTUP_TRACE) &&
argv.slice(2).includes("gateway");
const started = performance.now();
let last = started;
const emit = (name: string, durationMs: number, totalMs: number) => {
if (!enabled) {
return;
}
process.stderr.write(
`[gateway] startup trace: cli.main.${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms\n`,
);
};
return {
mark(name: string) {
const now = performance.now();
emit(name, now - last, now - started);
last = now;
},
async measure<T>(name: string, run: () => Awaitable<T>): Promise<T> {
const before = performance.now();
try {
return await run();
} finally {
const now = performance.now();
emit(name, now - before, now - started);
last = now;
}
},
};
}
export function isGatewayRunFastPathArgv(argv: string[]): boolean {
const invocation = resolveCliArgvInvocation(argv);
if (invocation.hasHelpOrVersion) {
return false;
}
const args = argv.slice(2);
let sawGateway = false;
let sawRun = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
return false;
}
if (!sawGateway) {
const consumed = consumeGatewayFastPathRootOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (arg !== "gateway") {
return false;
}
sawGateway = true;
continue;
}
const consumed = consumeGatewayRunOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (!sawRun && arg === "run") {
sawRun = true;
continue;
}
return false;
}
return sawGateway;
}
function hasJsonOutputFlag(argv: string[]): boolean {
return argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
}
async function tryRunGatewayRunFastPath(
argv: string[],
startupTrace: ReturnType<typeof createGatewayCliMainStartupTrace>,
): Promise<boolean> {
if (!isGatewayRunFastPathArgv(argv)) {
return false;
}
const [
{ Command },
{ addGatewayRunCommand },
{ VERSION },
{ emitCliBanner },
{ resolveCliStartupPolicy },
] = await startupTrace.measure("gateway-run-imports", () =>
Promise.all([
import("commander"),
import("./gateway-cli/run.js"),
import("../version.js"),
import("./banner.js"),
import("./command-startup-policy.js"),
]),
);
const invocation = resolveCliArgvInvocation(argv);
const startupPolicy = resolveCliStartupPolicy({
commandPath: invocation.commandPath,
jsonOutputMode: hasJsonOutputFlag(argv),
routeMode: true,
});
if (!startupPolicy.hideBanner) {
emitCliBanner(VERSION, { argv });
}
const program = new Command();
program.name("openclaw");
program.enablePositionalOptions();
program.option("--no-color", "Disable ANSI colors", false);
program.exitOverride((err) => {
process.exitCode = typeof err.exitCode === "number" ? err.exitCode : 1;
throw err;
});
const gateway = addGatewayRunCommand(
program.command("gateway").description("Run, inspect, and query the WebSocket Gateway"),
);
addGatewayRunCommand(
gateway.command("run").description("Run the WebSocket Gateway (foreground)"),
);
try {
await startupTrace.measure("gateway-run-parse", () => program.parseAsync(argv));
} catch (error) {
if (!isCommanderParseExit(error)) {
throw error;
}
process.exitCode = error.exitCode;
}
return true;
}
async function closeCliMemoryManagers(): Promise<void> {
try {
const { hasMemoryRuntime } = await import("../plugins/memory-state.js");
if (!hasMemoryRuntime()) {
return;
}
const { closeActiveMemorySearchManagers } = await import("../plugins/memory-runtime.js");
await closeActiveMemorySearchManagers();
} catch {
// Best-effort teardown for short-lived CLI processes. Package updates can
// replace hashed chunks before this finalizer runs.
}
}
async function disposeCliAgentHarnesses(): Promise<void> {
try {
const { listAgentHarnessIds, disposeRegisteredAgentHarnesses } =
await import("../agents/harness/registry.js");
if (listAgentHarnessIds().length === 0) {
return;
}
await disposeRegisteredAgentHarnesses();
} catch {
// Best-effort teardown for short-lived CLI commands. Harness plugins may
// own subprocesses, but cleanup must not hide the command's real outcome.
}
}
function pauseNonTtyStdinForCliExit(): void {
const stdin = process.stdin;
if (stdin.isTTY) {
return;
}
try {
stdin.pause();
} catch {
// Best-effort cleanup for command paths that only inspected stdin.
}
}
export function resolveMissingPluginCommandMessage(
pluginId: string,
config?: OpenClawConfig,
options?: { registry?: PluginManifestCommandAliasRegistry },
): string | null {
return resolveMissingPluginCommandMessageFromPolicy(
pluginId,
config,
options?.registry ? { registry: options.registry } : undefined,
);
}
function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean {
if (existsSync(path.join(process.cwd(), ".env"))) {
return true;
}
return existsSync(path.join(resolveStateDir(env), ".env"));
}
function isCommanderParseExit(error: unknown): error is { exitCode: number } {
if (!error || typeof error !== "object") {
return false;
}
const candidate = error as { code?: unknown; exitCode?: unknown };
return (
typeof candidate.exitCode === "number" &&
Number.isInteger(candidate.exitCode) &&
typeof candidate.code === "string" &&
candidate.code.startsWith("commander.")
);
}
async function ensureCliEnvProxyDispatcher(): Promise<void> {
try {
const { hasEnvHttpProxyAgentConfigured } = await import("../infra/net/proxy-env.js");
if (!hasEnvHttpProxyAgentConfigured()) {
return;
}
const { ensureGlobalUndiciEnvProxyDispatcher } =
await import("../infra/net/undici-global-dispatcher.js");
ensureGlobalUndiciEnvProxyDispatcher();
} catch {
// Best-effort proxy bootstrap; CLI startup should continue without it.
}
}
function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process.env): boolean {
if (
isTruthyEnvValue(env.OPENCLAW_DEBUG_PROXY_ENABLED) ||
isTruthyEnvValue(env.OPENCLAW_DEBUG_PROXY_REQUIRE)
) {
return true;
}
return CLI_PROXY_ENV_KEYS.some((key) => {
const value = env[key];
return typeof value === "string" && value.trim().length > 0;
});
}
function isKnownBuiltInCommandRoot(primary: string): boolean {
return (
getCoreCliCommandNames().includes(primary) ||
getSubCliEntries().some((entry) => entry.name === primary)
);
}
async function isPluginCliRoot(params: {
primary: string;
config: OpenClawConfig;
}): Promise<boolean | null> {
try {
const { resolvePluginCliRootOwnerIds } = await import("../plugins/cli-registry-loader.js");
const ownerIds = await resolvePluginCliRootOwnerIds({
cfg: params.config,
env: process.env,
primaryCommand: params.primary,
});
return ownerIds === null ? null : ownerIds.length > 0;
} catch {
return null;
}
}
async function resolveUnownedCliPrimary(params: {
argv: string[];
config: OpenClawConfig;
}): Promise<string | null> {
const invocation = resolveCliArgvInvocation(rewriteUpdateFlagArgv(params.argv));
const { primary } = invocation;
if (
invocation.hasHelpOrVersion ||
!primary ||
primary === "help" ||
isReservedNonPluginCommandRoot(primary) ||
isKnownBuiltInCommandRoot(primary)
) {
return null;
}
const pluginRoot = await isPluginCliRoot({ primary, config: params.config });
if (pluginRoot !== false) {
return null;
}
return primary;
}
async function bootstrapCliProxyCaptureAndDispatcher(
startupTrace: ReturnType<typeof createGatewayCliMainStartupTrace>,
options: { ensureDispatcher?: boolean } = {},
): Promise<void> {
const [
{ initializeDebugProxyCapture, finalizeDebugProxyCapture },
{ maybeWarnAboutDebugProxyCoverage },
] = await startupTrace.measure("proxy-imports", () =>
Promise.all([import("../proxy-capture/runtime.js"), import("../proxy-capture/coverage.js")]),
);
initializeDebugProxyCapture("cli");
process.once("exit", () => {
finalizeDebugProxyCapture();
});
if (options.ensureDispatcher !== false) {
await startupTrace.measure("proxy-dispatcher", () => ensureCliEnvProxyDispatcher());
}
maybeWarnAboutDebugProxyCoverage();
}
export async function runCli(argv: string[] = process.argv) {
const originalArgv = normalizeWindowsArgv(argv);
const startupTrace = createGatewayCliMainStartupTrace(originalArgv);
const parsedContainer = parseCliContainerArgs(originalArgv);
if (!parsedContainer.ok) {
throw new Error(parsedContainer.error);
}
const parsedProfile = parseCliProfileArgs(parsedContainer.argv);
if (!parsedProfile.ok) {
throw new Error(parsedProfile.error);
}
if (parsedProfile.profile) {
applyCliProfileEnv({ profile: parsedProfile.profile });
}
const containerTargetName =
parsedContainer.container ?? normalizeOptionalString(process.env.OPENCLAW_CONTAINER) ?? null;
if (containerTargetName && parsedProfile.profile) {
throw new Error("--container cannot be combined with --profile/--dev");
}
const containerTarget = maybeRunCliInContainer(originalArgv);
if (containerTarget.handled) {
if (containerTarget.exitCode !== 0) {
process.exitCode = containerTarget.exitCode;
}
return;
}
let normalizedArgv = parsedProfile.argv;
startupTrace.mark("argv");
if (shouldLoadCliDotEnv()) {
await startupTrace.measure("dotenv", async () => {
const { loadCliDotEnv } = await import("./dotenv.js");
loadCliDotEnv({ quiet: true });
});
}
normalizeEnv();
if (shouldEnsureCliPath(normalizedArgv)) {
ensureOpenClawCliOnPath();
}
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
// Activate operator-managed proxy routing for network-capable commands.
// Local Gateway/control-plane commands keep direct loopback access while
// runtime, provider, plugin, update, and manifest/metadata-owned plugin commands route egress.
let proxyHandle: ProxyHandle | null = null;
let bestEffortConfigPromise: Promise<OpenClawConfig> | null = null;
const readBestEffortCliConfig = async (): Promise<OpenClawConfig> => {
if (!bestEffortConfigPromise) {
bestEffortConfigPromise = import("../config/io.js").then(({ readBestEffortConfig }) =>
readBestEffortConfig(),
);
}
return await bestEffortConfigPromise;
};
const stopStartedProxy = async () => {
const handle = proxyHandle;
proxyHandle = null;
if (handle) {
const { stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
await stopProxy(handle);
}
};
const killStartedProxy = () => {
const handle = proxyHandle;
proxyHandle = null;
handle?.kill("SIGTERM");
};
if (shouldStartProxyForCli(normalizedArgv)) {
const config = await readBestEffortCliConfig();
const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config });
if (unownedPrimary) {
throw new Error(
`Unknown command: openclaw ${unownedPrimary}. No built-in command or plugin CLI metadata owns "${unownedPrimary}".`,
);
}
const { startProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
proxyHandle = await startProxy(config?.proxy ?? undefined);
}
let onSigterm: (() => void) | null = null;
let onSigint: (() => void) | null = null;
let onExit: (() => void) | null = null;
if (proxyHandle) {
const shutdown = (exitCode: number) => {
if (onSigterm) {
process.off("SIGTERM", onSigterm);
}
if (onSigint) {
process.off("SIGINT", onSigint);
}
void stopStartedProxy().finally(() => {
process.exit(exitCode);
});
};
onSigterm = () => shutdown(143);
onSigint = () => shutdown(130);
onExit = () => killStartedProxy();
process.once("SIGTERM", onSigterm);
process.once("SIGINT", onSigint);
process.once("exit", onExit);
}
try {
if (shouldUseRootHelpFastPath(normalizedArgv)) {
const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js");
if (!outputPrecomputedRootHelpText()) {
const { outputRootHelp } = await import("./program/root-help.js");
await outputRootHelp();
}
return;
}
if (shouldUseBrowserHelpFastPath(normalizedArgv)) {
const { outputPrecomputedBrowserHelpText } = await import("./root-help-metadata.js");
if (outputPrecomputedBrowserHelpText()) {
return;
}
}
const shouldRunBareRootCrestodian = shouldStartCrestodianForBareRoot(normalizedArgv);
const shouldRunModernOnboardCrestodian = shouldStartCrestodianForModernOnboard(normalizedArgv);
if (shouldRunBareRootCrestodian || shouldRunModernOnboardCrestodian) {
await ensureCliEnvProxyDispatcher();
}
if (shouldRunBareRootCrestodian) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error(
'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.',
);
process.exitCode = 1;
return;
}
const { runCrestodian } = await import("../crestodian/crestodian.js");
const { createCliProgress } = await import("./progress.js");
const progress = createCliProgress({
label: "Starting Crestodian…",
indeterminate: true,
delayMs: 0,
fallback: "none",
});
let progressStopped = false;
const stopProgress = () => {
if (progressStopped) {
return;
}
progressStopped = true;
progress.done();
};
try {
await runCrestodian({ onReady: stopProgress });
} finally {
stopProgress();
}
return;
}
if (shouldRunModernOnboardCrestodian) {
const { runCrestodian } = await import("../crestodian/crestodian.js");
const nonInteractive = normalizedArgv.includes("--non-interactive");
await runCrestodian({
message: nonInteractive ? "overview" : undefined,
yes: false,
json: normalizedArgv.includes("--json"),
interactive: !nonInteractive,
});
return;
}
const shouldUseCliEnvProxy = shouldStartProxyForCli(normalizedArgv);
const bootstrapProxyBeforeFastPath =
shouldUseCliEnvProxy && shouldBootstrapCliProxyBeforeFastPath();
if (
!bootstrapProxyBeforeFastPath &&
(await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))
) {
return;
}
await bootstrapCliProxyCaptureAndDispatcher(startupTrace, {
ensureDispatcher: shouldUseCliEnvProxy,
});
if (
bootstrapProxyBeforeFastPath &&
(await tryRunGatewayRunFastPath(normalizedArgv, startupTrace))
) {
return;
}
const { tryRouteCli } = await startupTrace.measure("route-import", () => import("./route.js"));
if (await startupTrace.measure("route", () => tryRouteCli(normalizedArgv))) {
return;
}
const { createCliProgress } = await import("./progress.js");
const startupProgress = createCliProgress({
label: "Loading OpenClaw CLI…",
indeterminate: true,
delayMs: 0,
fallback: "none",
});
let startupProgressStopped = false;
const stopStartupProgress = () => {
if (startupProgressStopped) {
return;
}
startupProgressStopped = true;
startupProgress.done();
};
try {
// Capture all console output into structured logs while keeping stdout/stderr behavior.
const { enableConsoleCapture } = await import("../logging.js");
enableConsoleCapture();
const [
{ buildProgram },
{ formatUncaughtError },
{ formatCliFailureLines },
{ runFatalErrorHooks },
{
installUnhandledRejectionHandler,
isBenignUncaughtExceptionError,
isUncaughtExceptionHandled,
},
{ restoreTerminalState },
] = await startupTrace.measure("core-imports", () =>
Promise.all([
import("./program.js"),
import("../infra/errors.js"),
import("./failure-output.js"),
import("../infra/fatal-error-hooks.js"),
import("../infra/unhandled-rejections.js"),
import("../terminal/restore.js"),
]),
);
const program = await startupTrace.measure("build-program", () => buildProgram());
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
if (isUncaughtExceptionHandled(error)) {
return;
}
if (isBenignUncaughtExceptionError(error)) {
console.warn(
"[openclaw] Non-fatal uncaught exception (continuing):",
formatUncaughtError(error),
);
return;
}
for (const line of formatCliFailureLines({
title: "OpenClaw hit an unexpected runtime error.",
error,
argv: normalizedArgv,
})) {
console.error(line);
}
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
console.error("[openclaw]", message);
}
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
process.exit(1);
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
const invocation = resolveCliArgvInvocation(parseArgv);
// Register the primary command (builtin or subcli) so help and command parsing
// are correct even with lazy command registration.
const { primary } = invocation;
if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) {
await startupTrace.measure("register-primary", async () => {
const { getProgramContext } = await import("./program/program-context.js");
const ctx = getProgramContext(program);
if (ctx) {
const { registerCoreCliByName } = await import("./program/command-registry.js");
await registerCoreCliByName(program, ctx, primary, parseArgv);
}
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary, parseArgv);
});
}
const hasBuiltinPrimary =
primary !== null &&
program.commands.some(
(command) => command.name() === primary || command.aliases().includes(primary),
);
const shouldSkipPluginRegistration = shouldSkipPluginCommandRegistration({
argv: parseArgv,
primary,
hasBuiltinPrimary,
});
if (!shouldSkipPluginRegistration) {
const config = await startupTrace.measure("register-plugin-commands", async () => {
const { registerPluginCliCommandsFromValidatedConfig } =
await import("../plugins/cli.js");
return await registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, {
mode: "lazy",
primary,
});
});
if (config) {
if (
primary &&
!program.commands.some(
(command) => command.name() === primary || command.aliases().includes(primary),
)
) {
const { resolveManifestCommandAliasOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy(
primary,
config,
{
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
},
);
if (missingPluginCommandMessage) {
throw new Error(missingPluginCommandMessage);
}
}
}
}
stopStartupProgress();
try {
await startupTrace.measure("parse", () => program.parseAsync(parseArgv));
} catch (error) {
if (!isCommanderParseExit(error)) {
throw error;
}
process.exitCode = error.exitCode;
}
} finally {
stopStartupProgress();
}
} finally {
if (onSigterm) {
process.off("SIGTERM", onSigterm);
}
if (onSigint) {
process.off("SIGINT", onSigint);
}
if (onExit) {
process.off("exit", onExit);
}
await stopStartedProxy();
await disposeCliAgentHarnesses();
await closeCliMemoryManagers();
pauseNonTtyStdinForCliExit();
}
}
export function isCliMainModule(): boolean {
return isMainModule({ currentFile: fileURLToPath(import.meta.url) });
}