Files
openclaw/src/cli/run-main.exit.test.ts
dorukardahan 9344bb8b48 fix(cli): exit after model inspection output (#77904)
* fix(cli): exit after model inspection output

* fix(cli): drain stderr before one-shot exit

* fix(cli): exit after model inspection output

* fix(clownfish): address review for live-pr-inventory-20260706T090631-001 (1)

* fix(cli): defer model command exit until teardown

* test(cli): isolate one-shot exit spies

* fix(cli): defer models status check exit

* fix(cli): satisfy lint for Vitest markers

* fix(cli): always wait for stream flush callbacks

* test(cli): wait for deferred exit callback

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-07-06 06:16:59 -07:00

3124 lines
115 KiB
TypeScript

// Run main exit tests cover process exit behavior for CLI failures.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { CommanderError } from "commander";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js";
import { loggingState } from "../logging/state.js";
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
import { getGatewayRunRuntimeHooks } from "./gateway-cli/runtime-hooks.js";
import type { RootHelpRenderOptions } from "./program/root-help.js";
import { runCli, shouldStartProxyForCli } from "./run-main.js";
import { registerSignalExitBarrier } from "./signal-exit-barrier.js";
type ConfigSnapshotStub = {
exists: boolean;
hash?: string;
path?: string;
raw?: string | null;
valid: boolean;
sourceConfig: Record<string, unknown>;
};
type ConfigSnapshotReadOptionsStub = {
isolateEnv?: boolean;
observe?: boolean;
recoverSuspicious?: boolean;
allowSuspiciousRecovery?: (
candidate: Record<string, unknown>,
current: Record<string, unknown>,
) => boolean | Promise<boolean>;
};
const tryRouteCliMock = vi.hoisted(() => vi.fn());
const loadDotEnvMock = vi.hoisted(() => vi.fn());
const normalizeEnvMock = vi.hoisted(() => vi.fn());
const pinConfigDirMock = vi.hoisted(() => vi.fn());
const pinRuntimePathsMock = vi.hoisted(() => vi.fn());
const ensurePathMock = vi.hoisted(() => vi.fn());
const assertRuntimeMock = vi.hoisted(() => vi.fn());
const closeActiveMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const hasMemoryRuntimeMock = vi.hoisted(() => vi.fn(() => false));
const listRegisteredAgentHarnessesMock = vi.hoisted(() => vi.fn((): unknown[] => []));
const disposeRegisteredAgentHarnessesMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureTaskRegistryReadyMock = vi.hoisted(() => vi.fn());
const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn());
const outputRootHelpMock = vi.hoisted(() => vi.fn());
const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false));
const outputPrecomputedBrowserHelpTextMock = vi.hoisted(() => vi.fn(() => false));
const outputPrecomputedSecretsHelpTextMock = vi.hoisted(() => vi.fn(() => false));
const outputPrecomputedNodesHelpTextMock = vi.hoisted(() => vi.fn(() => false));
const outputPrecomputedSubcommandHelpTextMock = vi.hoisted(() => vi.fn(() => false));
const loadRootHelpRenderOptionsForConfigSensitivePluginsMock = vi.hoisted(() =>
vi.fn<() => Promise<RootHelpRenderOptions | null>>(async () => null),
);
const tryOutputSetupOnboardConfigureHelpMock = vi.hoisted(() => vi.fn(async () => true));
const buildProgramMock = vi.hoisted(() => vi.fn());
const getProgramContextMock = vi.hoisted(() => vi.fn(() => null));
const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => ({})));
const resolvePluginCliRootOwnerIdsMock = vi.hoisted(() => vi.fn());
const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestCliCommandSurfaceOwnerMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() =>
vi.fn<(options?: ConfigSnapshotReadOptionsStub) => Promise<ConfigSnapshotStub>>(async () => ({
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
})),
);
const setupWizardCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const runCrestodianMock = vi.hoisted(() =>
vi.fn<(options?: unknown) => Promise<void>>(async () => {}),
);
const launchTuiCliMock = vi.hoisted(() =>
vi.fn<(opts: unknown, launchOptions?: unknown) => Promise<void>>(async () => {}),
);
const probeGatewayReachableMock = vi.hoisted(() =>
vi.fn<() => Promise<{ ok: boolean; detail?: string }>>(async () => ({ ok: true })),
);
const resolveControlUiLinksMock = vi.hoisted(() =>
vi.fn(() => ({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
})),
);
const commanderParseAsyncMock = vi.hoisted(() => vi.fn(async () => {}));
type GatewayRunCommandHooks = {
beforeRun?: (opts: { reset?: boolean }) => Promise<void>;
};
type CliExecutionBootstrapOptions = {
beforeStateMigrations?: () => Promise<boolean>;
};
const addGatewayRunCommandMock = vi.hoisted(() =>
vi.fn<(command: unknown, hooks?: GatewayRunCommandHooks) => unknown>((command) => command),
);
const ensureCliExecutionBootstrapMock = vi.hoisted(() =>
vi.fn<(_opts: CliExecutionBootstrapOptions) => Promise<void>>(async () => {}),
);
const emitCliBannerMock = vi.hoisted(() => vi.fn());
const enableConsoleCaptureMock = vi.hoisted(() => vi.fn());
const progressDoneMock = vi.hoisted(() => vi.fn());
const createCliProgressMock = vi.hoisted(() =>
vi.fn(() => ({
done: progressDoneMock,
})),
);
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({})));
const startProxyMock = vi.hoisted(() =>
vi.fn<(config: unknown) => Promise<unknown>>(async () => null),
);
const stopProxyMock = vi.hoisted(() => vi.fn<(handle: unknown) => Promise<void>>(async () => {}));
const flushExitAfterOneShotOutputMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
>((argv: string[]) => ({ handled: false, argv })),
);
const serviceEnvSnapshot = captureEnv([
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_KIND",
GATEWAY_SERVICE_RUNTIME_PID_ENV,
]);
function requireRunCrestodianOptions(index = 0): { onReady?: unknown } {
const call = runCrestodianMock.mock.calls[index];
if (!call) {
throw new Error(`expected runCrestodian call ${index}`);
}
expect(typeof call[0]).toBe("object");
if (typeof call[0] !== "object" || call[0] === null) {
throw new Error(`expected runCrestodian call ${index} to receive options`);
}
return call[0] as { onReady?: unknown };
}
vi.mock("commander", () => {
class MockCommanderError extends Error {
exitCode: number;
code: string;
constructor(exitCode: number, code: string, message: string) {
super(message);
this.exitCode = exitCode;
this.code = code;
}
}
class MockCommand {
name = vi.fn(() => this);
enablePositionalOptions = vi.fn(() => this);
option = vi.fn(() => this);
exitOverride = vi.fn(() => this);
description = vi.fn(() => this);
command = vi.fn(() => new MockCommand());
parseAsync = commanderParseAsyncMock;
}
return {
Command: MockCommand,
CommanderError: MockCommanderError,
};
});
vi.mock("./route.js", () => ({
tryRouteCli: tryRouteCliMock,
}));
vi.mock("./gateway-cli/run-command.js", () => ({
addGatewayRunCommand: addGatewayRunCommandMock,
}));
vi.mock("./command-execution-startup.js", () => ({
ensureCliExecutionBootstrap: ensureCliExecutionBootstrapMock,
}));
vi.mock("../version.js", () => ({
VERSION: "9.9.9-test",
}));
vi.mock("./banner.js", () => ({
emitCliBanner: emitCliBannerMock,
}));
vi.mock("../logging.js", async () => ({
...(await vi.importActual<typeof import("../logging.js")>("../logging.js")),
enableConsoleCapture: enableConsoleCaptureMock,
}));
vi.mock("./container-target.js", () => ({
maybeRunCliInContainer: maybeRunCliInContainerMock,
parseCliContainerArgs: (argv: string[]) => ({ ok: true, container: null, argv }),
}));
vi.mock("./dotenv.js", () => ({
loadCliDotEnv: loadDotEnvMock,
}));
vi.mock("./one-shot-exit.js", () => ({
flushExitAfterOneShotOutput: flushExitAfterOneShotOutputMock,
}));
vi.mock("../infra/env.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../infra/env.js")>()),
isTruthyEnvValue: (value?: string) =>
typeof value === "string" && ["1", "on", "true", "yes"].includes(value.trim().toLowerCase()),
normalizeEnv: normalizeEnvMock,
}));
vi.mock("../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/paths.js")>()),
pinRuntimePaths: pinRuntimePathsMock,
}));
vi.mock("../gateway/control-ui-links.js", () => ({
resolveControlUiLinks: resolveControlUiLinksMock,
}));
vi.mock("../utils.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils.js")>()),
pinConfigDir: pinConfigDirMock,
}));
vi.mock("../infra/path-env.js", () => ({
ensureOpenClawCliOnPath: ensurePathMock,
}));
vi.mock("../infra/runtime-guard.js", () => ({
assertSupportedRuntime: assertRuntimeMock,
}));
vi.mock("../plugins/memory-runtime.js", () => ({
closeActiveMemorySearchManagers: closeActiveMemorySearchManagersMock,
}));
vi.mock("../plugins/memory-state.js", () => ({
hasMemoryRuntime: hasMemoryRuntimeMock,
}));
vi.mock("../agents/harness/registry.js", () => ({
listRegisteredAgentHarnesses: listRegisteredAgentHarnessesMock,
disposeRegisteredAgentHarnesses: disposeRegisteredAgentHarnessesMock,
}));
vi.mock("../tasks/task-registry.js", () => ({
ensureTaskRegistryReady: ensureTaskRegistryReadyMock,
}));
vi.mock("../tasks/task-registry.maintenance.js", () => ({
startTaskRegistryMaintenance: startTaskRegistryMaintenanceMock,
}));
vi.mock("./program/root-help.js", () => ({
outputRootHelp: outputRootHelpMock,
}));
vi.mock("./root-help-metadata.js", () => ({
outputPrecomputedBrowserHelpText: outputPrecomputedBrowserHelpTextMock,
outputPrecomputedNodesHelpText: outputPrecomputedNodesHelpTextMock,
outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock,
outputPrecomputedSecretsHelpText: outputPrecomputedSecretsHelpTextMock,
outputPrecomputedSubcommandHelpText: outputPrecomputedSubcommandHelpTextMock,
}));
vi.mock("./root-help-live-config.js", () => ({
loadRootHelpRenderOptionsForConfigSensitivePlugins:
loadRootHelpRenderOptionsForConfigSensitivePluginsMock,
}));
vi.mock("./setup-onboard-configure-help-fast-path.js", () => ({
tryOutputSetupOnboardConfigureHelp: tryOutputSetupOnboardConfigureHelpMock,
}));
vi.mock("./program.js", () => ({
buildProgram: buildProgramMock,
}));
vi.mock("./program/program-context.js", () => ({
getProgramContext: getProgramContextMock,
}));
vi.mock("./program/command-registry.js", () => ({
registerCoreCliByName: registerCoreCliByNameMock,
}));
vi.mock("./program/register.subclis.js", () => ({
registerSubCliByName: registerSubCliByNameMock,
}));
vi.mock("../plugins/cli.js", () => ({
registerPluginCliCommandsFromValidatedConfig: registerPluginCliCommandsFromValidatedConfigMock,
}));
vi.mock("../plugins/cli-registry-loader.js", () => ({
resolvePluginCliRootOwnerIds: resolvePluginCliRootOwnerIdsMock,
}));
vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({
resolveManifestCliCommandSurfaceOwner: resolveManifestCliCommandSurfaceOwnerMock,
resolveManifestCommandAliasOwner: resolveManifestCommandAliasOwnerMock,
resolveManifestToolOwner: resolveManifestToolOwnerMock,
}));
vi.mock("../../packages/terminal-core/src/restore.js", () => ({
restoreTerminalState: restoreTerminalStateMock,
}));
vi.mock("../infra/net/proxy-env.js", () => ({
hasEnvHttpProxyAgentConfigured: hasEnvHttpProxyAgentConfiguredMock,
}));
vi.mock("../infra/net/undici-global-dispatcher.js", () => ({
ensureGlobalUndiciEnvProxyDispatcher: ensureGlobalUndiciEnvProxyDispatcherMock,
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
}));
vi.mock("../commands/onboard.js", () => ({
setupWizardCommand: setupWizardCommandMock,
}));
vi.mock("../commands/onboard-helpers.js", () => ({
probeGatewayReachable: probeGatewayReachableMock,
}));
vi.mock("../tui/tui-launch.js", () => ({
launchTuiCli: launchTuiCliMock,
}));
vi.mock("../crestodian/crestodian.js", () => ({
runCrestodian: runCrestodianMock,
}));
vi.mock("./progress.js", () => ({
createCliProgress: createCliProgressMock,
}));
vi.mock("../config/io.js", () => ({
readBestEffortConfig: loadConfigMock,
}));
vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({
startProxy: startProxyMock,
stopProxy: stopProxyMock,
}));
function makeProxyHandle() {
return {
proxyUrl: "http://127.0.0.1:19876",
stop: vi.fn(async () => {}),
kill: vi.fn(),
};
}
async function withInteractiveTty(fn: () => Promise<void>): Promise<void> {
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
await fn();
} finally {
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
}
describe("runCli exit behavior", () => {
afterAll(() => {
serviceEnvSnapshot.restore();
});
beforeEach(() => {
delete process.env.OPENCLAW_SERVICE_MARKER;
delete process.env.OPENCLAW_SERVICE_KIND;
// Sibling CLI suites run `gateway run --token/--password`, which exports
// credentials into process.env; leaked values change gateway preflight
// auth in shared vitest workers.
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
});
probeGatewayReachableMock.mockResolvedValue({ ok: true });
resolveControlUiLinksMock.mockReturnValue({
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
hasMemoryRuntimeMock.mockReturnValue(false);
listRegisteredAgentHarnessesMock.mockReturnValue([]);
outputPrecomputedBrowserHelpTextMock.mockReturnValue(false);
outputPrecomputedNodesHelpTextMock.mockReturnValue(false);
outputPrecomputedRootHelpTextMock.mockReturnValue(false);
outputPrecomputedSecretsHelpTextMock.mockReturnValue(false);
outputPrecomputedSubcommandHelpTextMock.mockReturnValue(false);
loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValue(null);
tryOutputSetupOnboardConfigureHelpMock.mockResolvedValue(true);
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(false);
loadConfigMock.mockReturnValue({});
startProxyMock.mockResolvedValue(null);
stopProxyMock.mockResolvedValue(undefined);
getProgramContextMock.mockReturnValue(null);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "googlemeet" ? ["google-meet"] : [],
);
resolveManifestCommandAliasOwnerMock.mockReturnValue(undefined);
resolveManifestToolOwnerMock.mockReturnValue(undefined);
resolveManifestCliCommandSurfaceOwnerMock.mockReturnValue(undefined);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
delete process.env.OPENCLAW_HIDE_BANNER;
loggingState.forceConsoleToStderr = false;
});
it("does not force process.exit after successful routed command", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`unexpected process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "status"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
expect(disposeRegisteredAgentHarnessesMock).not.toHaveBeenCalled();
expect(ensureTaskRegistryReadyMock).not.toHaveBeenCalled();
expect(startTaskRegistryMaintenanceMock).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it("disposes registered harnesses after full CLI command completion", async () => {
listRegisteredAgentHarnessesMock.mockReturnValueOnce([{ harness: { id: "codex" } }]);
tryRouteCliMock.mockResolvedValueOnce(false);
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "agent", aliases: () => [] }],
parseAsync,
});
await runCli(["node", "openclaw", "agent", "--local"]);
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "agent", "--local"]);
expect(disposeRegisteredAgentHarnessesMock).toHaveBeenCalledTimes(1);
});
it("flushes requested one-shot exits after asynchronous teardown", async () => {
const order: string[] = [];
listRegisteredAgentHarnessesMock.mockReturnValueOnce([{ harness: { id: "copilot" } }]);
disposeRegisteredAgentHarnessesMock.mockImplementationOnce(async () => {
order.push("harnesses");
});
hasMemoryRuntimeMock.mockReturnValueOnce(true);
closeActiveMemorySearchManagersMock.mockImplementationOnce(async () => {
order.push("memory");
});
flushExitAfterOneShotOutputMock.mockImplementationOnce(() => {
order.push("exit");
});
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "models", "status", "--probe"]);
expect(order).toEqual(["harnesses", "memory", "exit"]);
});
it("shows the standard spinner while loading the full CLI", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "config", aliases: () => [] }],
parseAsync,
});
await runCli(["node", "openclaw", "config"]);
expect(createCliProgressMock).toHaveBeenCalledWith({
label: "Loading OpenClaw CLI…",
indeterminate: true,
delayMs: 0,
});
expect(progressDoneMock).toHaveBeenCalledTimes(1);
});
it("suppresses startup progress for json output commands before full CLI parsing", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "sessions", aliases: () => [] }],
parseAsync,
});
await runCli(["node", "openclaw", "sessions", "--json", "--limit", "all"]);
expect(createCliProgressMock).toHaveBeenCalledWith({
label: "Loading OpenClaw CLI…",
indeterminate: true,
delayMs: 0,
enabled: false,
});
expect(parseAsync).toHaveBeenCalledWith([
"node",
"openclaw",
"sessions",
"--json",
"--limit",
"all",
]);
expect(progressDoneMock).toHaveBeenCalledTimes(1);
});
it("pauses non-tty stdin after full CLI command completion", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "channels", aliases: () => [] }],
parseAsync,
});
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
const pauseSpy = vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin);
try {
await runCli(["node", "openclaw", "channels"]);
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "channels"]);
expect(pauseSpy).toHaveBeenCalledTimes(1);
} finally {
pauseSpy.mockRestore();
if (stdinTty) {
Object.defineProperty(process.stdin, "isTTY", stdinTty);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
}
});
it("emits the startup banner before gateway foreground fast-path startup", async () => {
await runCli(["node", "openclaw", "gateway", "--force"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(emitCliBannerMock).toHaveBeenCalledWith("9.9.9-test", {
argv: ["node", "openclaw", "gateway", "--force"],
});
expect(addGatewayRunCommandMock).toHaveBeenCalledTimes(2);
expect(commanderParseAsyncMock).toHaveBeenCalledWith([
"node",
"openclaw",
"gateway",
"--force",
]);
});
it("installs console capture before parsing the gateway foreground fast path", async () => {
await runCli(["node", "openclaw", "gateway", "--force"]);
expect(enableConsoleCaptureMock).toHaveBeenCalledTimes(1);
expect(commanderParseAsyncMock).toHaveBeenCalledTimes(1);
const captureOrder = enableConsoleCaptureMock.mock.invocationCallOrder[0] ?? 0;
const parseOrder = commanderParseAsyncMock.mock.invocationCallOrder[0] ?? 0;
expect(captureOrder).toBeGreaterThan(0);
expect(parseOrder).toBeGreaterThan(captureOrder);
});
it("configures the gateway foreground fast path with the standard CLI bootstrap", async () => {
await runCli(["node", "openclaw", "gateway", "--force"]);
expect(readConfigFileSnapshotMock.mock.calls).toEqual([[{ isolateEnv: true, observe: false }]]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(ensureCliExecutionBootstrapMock).toHaveBeenCalledWith(
expect.objectContaining({
beforeStateMigrations: expect.any(Function),
commandPath: ["gateway"],
loadPlugins: false,
}),
);
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({
isolateEnv: true,
recoverSuspicious: true,
allowSuspiciousRecovery: expect.any(Function),
});
const recoveryOrder = readConfigFileSnapshotMock.mock.invocationCallOrder[2] ?? 0;
const bootstrapOrder = ensureCliExecutionBootstrapMock.mock.invocationCallOrder[0] ?? 0;
expect(recoveryOrder).toBeGreaterThan(0);
expect(bootstrapOrder).toBeGreaterThan(recoveryOrder);
});
it("rechecks the effective guarded config before automatic startup migrations", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
hash: "guarded",
path: "/tmp/openclaw.json",
raw: "{}",
valid: true,
sourceConfig: {
cron: { store: "/tmp/included-a.json" },
gateway: { mode: "local" },
},
});
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
const beforeStateMigrations = (
ensureCliExecutionBootstrapMock.mock.calls[0]?.[0] as
| { beforeStateMigrations?: () => Promise<boolean> }
| undefined
)?.beforeStateMigrations;
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
hash: "guarded",
path: "/tmp/openclaw.json",
raw: "{}",
valid: true,
sourceConfig: {
cron: { store: "/tmp/included-b.json" },
gateway: { mode: "local" },
},
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await expect(beforeStateMigrations?.()).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("changed during startup"));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it.each([
{
name: "automatic startup migrations",
flags: [],
marker: undefined,
override: undefined,
expectedAction: "run automatic gateway startup migrations",
expectedExitCode: 1,
},
{
name: "service-mode startup",
flags: [],
marker: "gateway",
override: "1",
expectedAction: "start the gateway service",
expectedExitCode: 78,
},
{
name: "forced port cleanup",
flags: ["--force"],
marker: undefined,
override: undefined,
expectedAction: "force-kill gateway port listeners",
expectedExitCode: 1,
},
{
name: "dev reset",
flags: ["--dev", "--reset"],
marker: undefined,
override: undefined,
expectedAction: "reset the dev gateway state",
expectedExitCode: 1,
},
{
name: "forced dev reset",
flags: ["--dev", "--reset", "--force"],
marker: undefined,
override: undefined,
expectedAction: "reset the dev gateway state",
expectedExitCode: 1,
},
])("blocks future-config $name before gateway bootstrap", async (params) => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
});
const previousMarker = process.env.OPENCLAW_SERVICE_MARKER;
const previousOverride = process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS;
if (params.marker) {
process.env.OPENCLAW_SERVICE_MARKER = params.marker;
} else {
delete process.env.OPENCLAW_SERVICE_MARKER;
}
if (params.override) {
process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = params.override;
} else {
delete process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS;
}
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await expect(runCli(["node", "openclaw", "gateway", ...params.flags])).rejects.toThrow(
`exit:${params.expectedExitCode}`,
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(params.expectedAction));
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
expect(readConfigFileSnapshotMock.mock.calls).toEqual([
[{ isolateEnv: true, observe: false }],
]);
if (params.marker) {
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined();
}
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
if (previousMarker === undefined) {
delete process.env.OPENCLAW_SERVICE_MARKER;
} else {
process.env.OPENCLAW_SERVICE_MARKER = previousMarker;
}
if (previousOverride === undefined) {
delete process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS;
} else {
process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = previousOverride;
}
}
});
it("blocks and revokes the destructive override when selected config declares service mode", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_SERVICE_MARKER: "gateway" } },
meta: { lastTouchedVersion: "9999.1.1" },
},
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await withEnvAsync(
{
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: "1",
OPENCLAW_SERVICE_MARKER: undefined,
},
async () => {
await expect(runCli(["node", "openclaw", "gateway"])).rejects.toThrow("exit:78");
expect(process.env.OPENCLAW_SERVICE_MARKER).toBeUndefined();
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined();
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
},
);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("ignores service mode declared by an invalid selected config", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: false,
sourceConfig: {
env: { vars: { OPENCLAW_SERVICE_MARKER: "gateway" } },
meta: { lastTouchedVersion: "9999.1.1" },
},
});
await withEnvAsync(
{
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: "1",
OPENCLAW_SERVICE_MARKER: undefined,
},
async () => {
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_SERVICE_MARKER).toBeUndefined();
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBe("1");
},
);
});
it("guards the config selected by trusted global dotenv before the default config", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-global-selection-"));
const stateDir = path.join(homeDir, ".openclaw");
const selectedConfigPath = path.join(stateDir, "selected.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(
path.join(stateDir, ".env"),
[
`OPENCLAW_CONFIG_PATH=${selectedConfigPath}`,
"OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1",
"",
].join("\n"),
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: undefined,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
readConfigFileSnapshotMock.mockImplementation(async () =>
process.env.OPENCLAW_CONFIG_PATH === selectedConfigPath
? {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
}
: {
exists: true,
valid: true,
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
},
);
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_CONFIG_PATH).toBe(selectedConfigPath);
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined();
expect(readConfigFileSnapshotMock).toHaveBeenCalledOnce();
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("loads state dotenv before a custom config-root fallback", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-config-env-"));
const stateDir = path.join(homeDir, ".openclaw");
const configDir = path.join(homeDir, "profile");
const configPath = path.join(configDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(path.join(stateDir, ".env"), "OPENCLAW_GATEWAY_TOKEN=state-token\n");
await fs.writeFile(
path.join(configDir, ".env"),
[
"OPENCLAW_GATEWAY_PASSWORD=config-root-password",
"OPENCLAW_GATEWAY_TOKEN=config-root-token",
"",
].join("\n"),
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_GATEWAY_PASSWORD: undefined,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("state-token");
expect(process.env.OPENCLAW_GATEWAY_PASSWORD).toBe("config-root-password");
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("loads and repins a legacy state dotenv after automatic state migration", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-legacy-env-"));
const legacyStateDir = path.join(homeDir, ".clawdbot");
const newStateDir = path.join(homeDir, ".openclaw");
await fs.mkdir(legacyStateDir, { recursive: true });
await fs.writeFile(path.join(legacyStateDir, ".env"), "OPENCLAW_GATEWAY_TOKEN=legacy-token\n");
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
OPENCLAW_TEST_FAST: undefined,
},
async () => {
ensureCliExecutionBootstrapMock.mockImplementationOnce(async () => {
await fs.rename(legacyStateDir, newStateDir);
});
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("legacy-token");
await expect(fs.access(path.join(newStateDir, ".env"))).resolves.toBeUndefined();
const bootstrapOrder = ensureCliExecutionBootstrapMock.mock.invocationCallOrder[0] ?? 0;
const finalPinOrder = pinRuntimePathsMock.mock.invocationCallOrder.at(-1) ?? 0;
expect(finalPinOrder).toBeGreaterThan(bootstrapOrder);
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("blocks a future-config recovery candidate before destructive gateway reset", async () => {
const currentSnapshot = {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
};
readConfigFileSnapshotMock.mockImplementation(async (options) => {
if (options?.recoverSuspicious) {
await options?.allowSuspiciousRecovery?.(
{
meta: { lastTouchedVersion: "9999.1.1" },
gateway: { mode: "local" },
},
currentSnapshot.sourceConfig,
);
}
return currentSnapshot;
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await runCli(["node", "openclaw", "gateway", "--dev", "--reset"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await expect(hooks?.beforeRun?.({ reset: true })).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Refusing to reset the dev gateway state"),
);
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("blocks a future current config before pre-bootstrap suspicious recovery", async () => {
const currentSnapshot = {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
};
readConfigFileSnapshotMock.mockImplementation(async (options) => {
if (options?.recoverSuspicious) {
await options.allowSuspiciousRecovery?.(
{ gateway: { mode: "local" } },
{
meta: { lastTouchedVersion: "9999.1.1" },
gateway: { mode: "local" },
},
);
}
return currentSnapshot;
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await expect(hooks?.beforeRun?.({})).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("run automatic gateway startup migrations"),
);
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("blocks a future service-mode candidate before pre-bootstrap suspicious recovery", async () => {
const currentSnapshot = {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
};
readConfigFileSnapshotMock.mockImplementation(async (options) => {
if (options?.recoverSuspicious) {
await options.allowSuspiciousRecovery?.(
{
env: { vars: { OPENCLAW_SERVICE_MARKER: "gateway" } },
gateway: { mode: "local" },
meta: { lastTouchedVersion: "9999.1.1" },
},
{ gateway: { mode: "local" } },
);
}
return currentSnapshot;
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await withEnvAsync(
{
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: "1",
OPENCLAW_SERVICE_MARKER: undefined,
},
async () => {
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await expect(hooks?.beforeRun?.({})).rejects.toThrow("exit:78");
},
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("start the gateway service"));
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
it("re-guards config env path selection until the gateway config is stable", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-selection-"));
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
readConfigFileSnapshotMock.mockImplementation(async () => {
if (process.env.OPENCLAW_CONFIG_PATH === "/tmp/openclaw-chain-c.json") {
return {
exists: true,
valid: true,
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
};
}
if (process.env.OPENCLAW_STATE_DIR === "/tmp/openclaw-chain-b") {
return {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_CONFIG_PATH: "/tmp/openclaw-chain-c.json" } },
gateway: { mode: "local" },
},
};
}
return {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_STATE_DIR: "/tmp/openclaw-chain-b" } },
gateway: { mode: "local" },
},
};
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await expect(runCli(["node", "openclaw", "gateway"])).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("run automatic gateway startup migrations"),
);
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(3);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("re-guards config changes to Termux home selectors", async () => {
await withEnvAsync({ ANDROID_DATA: undefined, PREFIX: undefined }, async () => {
readConfigFileSnapshotMock.mockImplementation(async () =>
process.env.ANDROID_DATA === "/data" &&
process.env.PREFIX === "/data/data/com.termux/files/usr"
? {
exists: true,
valid: true,
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
}
: {
exists: true,
valid: true,
sourceConfig: {
env: {
vars: {
ANDROID_DATA: "/data",
PREFIX: "/data/data/com.termux/files/usr",
},
},
gateway: { mode: "local" },
},
},
);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await expect(runCli(["node", "openclaw", "gateway"])).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("run automatic gateway startup migrations"),
);
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(2);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
it("drops credentials from configs superseded during state selection", async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
readConfigFileSnapshotMock.mockImplementation(async () =>
process.env.OPENCLAW_STATE_DIR === "/tmp/openclaw-selected-state"
? {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_GATEWAY_TOKEN: "selected-token" } },
gateway: { mode: "local" },
},
}
: {
exists: true,
valid: true,
sourceConfig: {
env: {
vars: {
OPENCLAW_GATEWAY_TOKEN: "superseded-token",
OPENCLAW_STATE_DIR: "/tmp/openclaw-selected-state",
},
},
gateway: { mode: "local" },
},
},
);
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(process.env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-selected-state");
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("selected-token");
expect(ensureCliExecutionBootstrapMock).toHaveBeenCalledOnce();
},
);
});
it("re-guards config selection from a newly selected state dotenv", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-dotenv-"));
const futureConfigPath = path.join(stateDir, "future.json");
await fs.writeFile(
path.join(stateDir, ".env"),
[
`OPENCLAW_CONFIG_PATH=${futureConfigPath}`,
"OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1",
"",
].join("\n"),
);
try {
await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_STATE_DIR: undefined,
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: undefined,
},
async () => {
readConfigFileSnapshotMock.mockImplementation(async () => {
if (process.env.OPENCLAW_CONFIG_PATH === futureConfigPath) {
return {
exists: true,
valid: true,
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
};
}
return {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_STATE_DIR: stateDir } },
gateway: { mode: "local" },
},
};
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await expect(runCli(["node", "openclaw", "gateway"])).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("run automatic gateway startup migrations"),
);
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(2);
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined();
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
},
);
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("re-inspects recovery after recovery changes config selection", async () => {
await withEnvAsync({ OPENCLAW_CONFIG_PATH: undefined }, async () => {
const selectedConfigPath = "/tmp/openclaw-recovered-selection.json";
const currentSnapshot = {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
};
let recoveryReads = 0;
readConfigFileSnapshotMock.mockImplementation(async (options) => {
if (!options?.recoverSuspicious) {
return currentSnapshot;
}
recoveryReads += 1;
if (recoveryReads === 1) {
const recoveredSnapshot = {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_CONFIG_PATH: selectedConfigPath } },
gateway: { mode: "local" },
},
};
await options.allowSuspiciousRecovery?.(
recoveredSnapshot.sourceConfig,
currentSnapshot.sourceConfig,
);
return recoveredSnapshot;
}
await options.allowSuspiciousRecovery?.(
{
meta: { lastTouchedVersion: "9999.1.1" },
gateway: { mode: "local" },
},
currentSnapshot.sourceConfig,
);
return currentSnapshot;
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await expect(hooks?.beforeRun?.({})).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("run automatic gateway startup migrations"),
);
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
expect(recoveryReads).toBe(2);
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
it("discards env from a config replaced by suspicious recovery", async () => {
await withEnvAsync(
{ OPENCLAW_GATEWAY_TOKEN: undefined, OPENCLAW_PROXY_ACTIVE: undefined },
async () => {
const clobberedSnapshot = {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_GATEWAY_TOKEN: "discarded-token" } },
gateway: { mode: "local" },
},
hash: "clobbered",
path: "/tmp/openclaw.json",
};
const recoveredSnapshot = {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
hash: "recovered",
path: "/tmp/openclaw.json",
};
const initialSnapshot = {
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
hash: "initial",
path: "/tmp/openclaw.json",
};
let currentSnapshot = initialSnapshot;
let recovered = false;
readConfigFileSnapshotMock.mockImplementation(async (options) => {
if (!options?.recoverSuspicious) {
return recovered ? recoveredSnapshot : currentSnapshot;
}
recovered = true;
await options.allowSuspiciousRecovery?.(
recoveredSnapshot.sourceConfig,
currentSnapshot.sourceConfig,
);
return recoveredSnapshot;
});
await runCli(["node", "openclaw", "gateway"]);
currentSnapshot = clobberedSnapshot;
process.env.OPENCLAW_PROXY_ACTIVE = "1";
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(process.env.OPENCLAW_PROXY_ACTIVE).toBe("1");
expect(ensureCliExecutionBootstrapMock).toHaveBeenCalledOnce();
},
);
});
it("does not apply environment variables from invalid config snapshots", async () => {
await withEnvAsync({ OPENCLAW_INCLUDE_ROOTS: undefined }, async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: false,
sourceConfig: {
env: { vars: { OPENCLAW_INCLUDE_ROOTS: "/tmp/openclaw-includes" } },
gateway: { mode: "local" },
},
});
await runCli(["node", "openclaw", "gateway"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(process.env.OPENCLAW_INCLUDE_ROOTS).toBeUndefined();
expect(readConfigFileSnapshotMock.mock.calls).toEqual([
[{ isolateEnv: true, observe: false }],
[{ isolateEnv: true, observe: false }],
]);
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
});
});
it("loads selected state dotenv before config env and environment normalization", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-selected-env-"));
const stateDir = path.join(homeDir, "state");
await fs.mkdir(stateDir, { recursive: true });
await fs.writeFile(path.join(stateDir, ".env"), "OPENCLAW_GATEWAY_TOKEN=state-token\n");
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: {
env: {
vars: {
OPENCLAW_GATEWAY_TOKEN: "config-token",
OPENCLAW_STATE_DIR: stateDir,
},
},
gateway: { mode: "local" },
},
});
let tokenAtNormalize: string | undefined;
normalizeEnvMock.mockImplementation(() => {
tokenAtNormalize = process.env.OPENCLAW_GATEWAY_TOKEN;
});
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("state-token");
expect(tokenAtNormalize).toBe("state-token");
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("drops credentials from a trusted dotenv superseded by state selection", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-dotenv-hop-"));
const defaultStateDir = path.join(homeDir, ".openclaw");
const selectedStateDir = path.join(homeDir, "selected-state");
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.mkdir(selectedStateDir, { recursive: true });
await fs.writeFile(
path.join(defaultStateDir, ".env"),
[
`OPENCLAW_STATE_DIR=${selectedStateDir}`,
"OPENCLAW_GATEWAY_TOKEN=superseded-token",
"",
].join("\n"),
);
await fs.writeFile(
path.join(selectedStateDir, ".env"),
"OPENCLAW_GATEWAY_TOKEN=selected-token\n",
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("selected-token");
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("drops gateway.env selectors when the default state dotenv selects a custom state", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-fallback-hop-"));
const defaultStateDir = path.join(homeDir, ".openclaw");
const selectedStateDir = path.join(homeDir, "selected-state");
const gatewayEnvDir = path.join(homeDir, ".config", "openclaw");
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.mkdir(selectedStateDir, { recursive: true });
await fs.mkdir(gatewayEnvDir, { recursive: true });
await fs.writeFile(
path.join(defaultStateDir, ".env"),
`OPENCLAW_STATE_DIR=${selectedStateDir}\n`,
);
await fs.writeFile(
path.join(gatewayEnvDir, "gateway.env"),
[
"OPENCLAW_CONFIG_PATH=/tmp/wrong-openclaw.json",
"OPENCLAW_GATEWAY_TOKEN=fallback-token",
"",
].join("\n"),
);
await fs.writeFile(
path.join(selectedStateDir, ".env"),
[
"OPENCLAW_GATEWAY_TOKEN=selected-token",
"OPENCLAW_INCLUDE_ROOTS=/tmp/untrusted-include-root",
"NODE_OPTIONS=--require /tmp/untrusted.js",
"",
].join("\n"),
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
NODE_OPTIONS: undefined,
},
async () => {
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("selected-token");
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("preserves gateway.env selectors when the compatibility fallback selects the target", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-fallback-select-"));
const selectedStateDir = path.join(homeDir, "selected-state");
const gatewayEnvDir = path.join(homeDir, ".config", "openclaw");
await fs.mkdir(selectedStateDir, { recursive: true });
await fs.mkdir(gatewayEnvDir, { recursive: true });
await fs.writeFile(
path.join(gatewayEnvDir, "gateway.env"),
[`OPENCLAW_STATE_DIR=${selectedStateDir}`, "OPENCLAW_GATEWAY_TOKEN=fallback-token", ""].join(
"\n",
),
);
await fs.writeFile(
path.join(selectedStateDir, ".env"),
"OPENCLAW_GATEWAY_TOKEN=selected-token\n",
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_INCLUDE_ROOTS: undefined,
OPENCLAW_STATE_DIR: undefined,
NODE_OPTIONS: undefined,
},
async () => {
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("selected-token");
expect(process.env.OPENCLAW_INCLUDE_ROOTS).toBeUndefined();
expect(process.env.NODE_OPTIONS).toBeUndefined();
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("drops old state dotenv credentials when config selects another state", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-config-state-hop-"));
const defaultStateDir = path.join(homeDir, ".openclaw");
const selectedStateDir = path.join(homeDir, "selected-state");
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.mkdir(selectedStateDir, { recursive: true });
await fs.writeFile(
path.join(defaultStateDir, ".env"),
"OPENCLAW_GATEWAY_TOKEN=superseded-token\n",
);
await fs.writeFile(
path.join(selectedStateDir, ".env"),
"OPENCLAW_GATEWAY_TOKEN=selected-token\n",
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
readConfigFileSnapshotMock.mockImplementation(async () => ({
exists: true,
valid: true,
sourceConfig:
process.env.OPENCLAW_STATE_DIR === selectedStateDir
? { gateway: { mode: "local" } }
: {
env: { vars: { OPENCLAW_STATE_DIR: selectedStateDir } },
gateway: { mode: "local" },
},
}));
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("selected-token");
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("drops early target credentials when a later guard selects another state", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-late-state-hop-"));
const defaultStateDir = path.join(homeDir, ".openclaw");
const selectedStateDir = path.join(homeDir, "selected-state");
await fs.mkdir(defaultStateDir, { recursive: true });
await fs.mkdir(selectedStateDir, { recursive: true });
await fs.writeFile(path.join(defaultStateDir, ".env"), "OPENCLAW_GATEWAY_TOKEN=early-token\n");
await fs.writeFile(
path.join(selectedStateDir, ".env"),
"OPENCLAW_GATEWAY_TOKEN=selected-token\n",
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
let selectLateState = false;
readConfigFileSnapshotMock.mockImplementation(async () => ({
exists: true,
valid: true,
sourceConfig:
selectLateState && process.env.OPENCLAW_STATE_DIR !== selectedStateDir
? {
env: { vars: { OPENCLAW_STATE_DIR: selectedStateDir } },
gateway: { mode: "local" },
}
: { gateway: { mode: "local" } },
}));
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("early-token");
selectLateState = true;
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("selected-token");
expect(ensureCliExecutionBootstrapMock).toHaveBeenCalledOnce();
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("drops normalized credentials from an early config replaced by a later guard", async () => {
await withEnvAsync({ ZAI_API_KEY: undefined, Z_AI_API_KEY: undefined }, async () => {
let useReplacement = false;
readConfigFileSnapshotMock.mockImplementation(async () => ({
exists: true,
valid: true,
sourceConfig: {
env: {
vars: {
Z_AI_API_KEY: useReplacement ? "replacement-key" : "superseded-key",
},
},
gateway: { mode: "local" },
},
}));
normalizeEnvMock.mockImplementation(() => {
if (!process.env.ZAI_API_KEY?.trim() && process.env.Z_AI_API_KEY?.trim()) {
process.env.ZAI_API_KEY = process.env.Z_AI_API_KEY;
}
});
await runCli(["node", "openclaw", "gateway"]);
expect(process.env.ZAI_API_KEY).toBe("superseded-key");
useReplacement = true;
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(process.env.Z_AI_API_KEY).toBe("replacement-key");
expect(process.env.ZAI_API_KEY).toBe("replacement-key");
});
});
it("does not let gateway.env authorize automatic mutations of a selected future config", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-global-env-"));
const gatewayEnvDir = path.join(homeDir, ".config", "openclaw");
const futureConfigPath = path.join(homeDir, "future.json");
await fs.mkdir(gatewayEnvDir, { recursive: true });
await fs.writeFile(
path.join(gatewayEnvDir, "gateway.env"),
"OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1\n",
);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: undefined,
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_HOME: homeDir,
OPENCLAW_STATE_DIR: undefined,
},
async () => {
readConfigFileSnapshotMock.mockImplementation(async () =>
process.env.OPENCLAW_CONFIG_PATH === futureConfigPath
? {
exists: true,
valid: true,
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
}
: {
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_CONFIG_PATH: futureConfigPath } },
gateway: { mode: "local" },
},
},
);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${String(code)}`);
}) as typeof process.exit);
try {
await expect(runCli(["node", "openclaw", "gateway"])).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("run automatic gateway startup migrations"),
);
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined();
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
},
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("does not treat gateway option values as bootstrap command paths", async () => {
await runCli(["node", "openclaw", "gateway", "--raw-stream-path", "status"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
expect(ensureCliExecutionBootstrapMock).toHaveBeenCalledWith(
expect.objectContaining({
commandPath: ["gateway"],
loadPlugins: false,
}),
);
});
it("guards then skips state migration before destructive gateway dev resets", async () => {
await runCli(["node", "openclaw", "gateway", "--dev", "--reset"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({ reset: true });
expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({ isolateEnv: true, observe: false });
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
});
it("retains selected config paths and invocation reset targets", async () => {
await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: "/tmp/openclaw-invocation/openclaw.json",
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: "/tmp/openclaw-invocation-home",
OPENCLAW_INCLUDE_ROOTS: undefined,
OPENCLAW_PROFILE: undefined,
OPENCLAW_STATE_DIR: "/tmp/openclaw-invocation-state",
OPENCLAW_TEST_FAST: "1",
OPENCLAW_WORKSPACE_DIR: "/tmp/openclaw-invocation-workspace",
},
async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: {
env: {
vars: {
OPENCLAW_CONFIG_PATH: "/tmp/openclaw-reset/openclaw.json",
OPENCLAW_GATEWAY_TOKEN: "old-token",
OPENCLAW_HOME: "/tmp/openclaw-reset-home",
OPENCLAW_INCLUDE_ROOTS: "/tmp/openclaw-reset-includes",
OPENCLAW_PROFILE: "config-dev",
OPENCLAW_STATE_DIR: "/tmp/openclaw-reset",
OPENCLAW_TEST_FAST: "0",
OPENCLAW_WORKSPACE_DIR: "/tmp/openclaw-reset-workspace",
},
},
gateway: { mode: "local" },
},
});
await runCli(["node", "openclaw", "gateway", "--dev", "--reset"]);
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { reset?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({ reset: true });
expect(process.env.OPENCLAW_CONFIG_PATH).toBe("/tmp/openclaw-invocation/openclaw.json");
expect(process.env.OPENCLAW_HOME).toBe("/tmp/openclaw-invocation-home");
expect(process.env.OPENCLAW_PROFILE).toBeUndefined();
expect(process.env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-invocation-state");
expect(process.env.OPENCLAW_TEST_FAST).toBe("1");
expect(process.env.OPENCLAW_WORKSPACE_DIR).toBe("/tmp/openclaw-invocation-workspace");
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(process.env.OPENCLAW_INCLUDE_ROOTS).toBeUndefined();
expect(ensureCliExecutionBootstrapMock).not.toHaveBeenCalled();
},
);
});
it("does not let config env authorize or retarget an explicit reset", async () => {
await withEnvAsync(
{ OPENCLAW_PROFILE: undefined, OPENCLAW_WORKSPACE_DIR: undefined },
async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: {
env: {
vars: {
OPENCLAW_PROFILE: "dev",
OPENCLAW_WORKSPACE_DIR: "/tmp/openclaw-config-workspace",
},
},
gateway: { mode: "local" },
},
});
await runCli(["node", "openclaw", "gateway", "--reset"]);
expect(process.env.OPENCLAW_PROFILE).toBeUndefined();
expect(process.env.OPENCLAW_WORKSPACE_DIR).toBeUndefined();
},
);
});
it("honors banner suppression on the gateway foreground fast path", async () => {
process.env.OPENCLAW_HIDE_BANNER = "1";
await runCli(["node", "openclaw", "gateway"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(emitCliBannerMock).not.toHaveBeenCalled();
expect(commanderParseAsyncMock).toHaveBeenCalledWith(["node", "openclaw", "gateway"]);
});
it("renders browser help from startup metadata without building the full program", async () => {
outputPrecomputedBrowserHelpTextMock.mockReturnValueOnce(true);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`unexpected process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "browser", "--help"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith([
"node",
"openclaw",
"browser",
"--help",
]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputPrecomputedBrowserHelpTextMock).toHaveBeenCalledTimes(1);
expect(outputRootHelpMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it("renders secrets help from startup metadata without building the full program", async () => {
outputPrecomputedSecretsHelpTextMock.mockReturnValueOnce(true);
await runCli(["node", "openclaw", "secrets", "--help"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputPrecomputedSecretsHelpTextMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerSubCliByNameMock).not.toHaveBeenCalled();
});
it("renders nodes help from startup metadata without building the full program", async () => {
outputPrecomputedNodesHelpTextMock.mockReturnValueOnce(true);
await runCli(["node", "openclaw", "nodes", "--help"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputPrecomputedNodesHelpTextMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerSubCliByNameMock).not.toHaveBeenCalled();
});
it("defers nodes help startup metadata when plugin config can change command metadata", async () => {
const argv = ["node", "openclaw", "nodes", "--help"];
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
const program = {
commands: [{ name: () => "nodes", aliases: () => [] }],
parseAsync,
};
loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValueOnce({ env: {} });
outputPrecomputedNodesHelpTextMock.mockReturnValueOnce(true);
buildProgramMock.mockReturnValueOnce(program);
await runCli(argv);
expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1);
expect(outputPrecomputedNodesHelpTextMock).not.toHaveBeenCalled();
expect(registerSubCliByNameMock.mock.calls).toEqual([[program, "nodes", argv]]);
expect(parseAsync).toHaveBeenCalledWith(argv);
});
it("renders selected subcommand help from startup metadata without building the full program", async () => {
outputPrecomputedSubcommandHelpTextMock.mockReturnValueOnce(true);
await runCli(["node", "openclaw", "doctor", "--help"]);
expect(outputPrecomputedSubcommandHelpTextMock).toHaveBeenCalledWith("doctor");
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
});
it("keeps root help on the precomputed path without proxy bootstrap", async () => {
outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true);
await runCli(["node", "openclaw", "--help"]);
expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1);
expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1);
expect(hasEnvHttpProxyAgentConfiguredMock).not.toHaveBeenCalled();
expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled();
expect(runCrestodianMock).not.toHaveBeenCalled();
});
it("renders setup/onboard/configure help without building the full program", async () => {
await runCli(["node", "openclaw", "setup", "--help"]);
expect(tryOutputSetupOnboardConfigureHelpMock).toHaveBeenCalledWith([
"node",
"openclaw",
"setup",
"--help",
]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("renders root help without building the full program", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`unexpected process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "--help"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "--help"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1);
expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1);
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it("renders config-sensitive root help live instead of precomputed metadata", async () => {
const liveOptions: RootHelpRenderOptions = {
config: {
plugins: {
slots: {
memory: "memory-lancedb",
},
},
},
env: process.env,
};
loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValueOnce(liveOptions);
outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true);
await runCli(["node", "openclaw", "--help"]);
expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1);
expect(outputPrecomputedRootHelpTextMock).not.toHaveBeenCalled();
expect(outputRootHelpMock).toHaveBeenCalledWith(liveOptions);
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("does not start the managed proxy for local gateway client commands", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "status"]);
expect(startProxyMock).not.toHaveBeenCalled();
expect(stopProxyMock).not.toHaveBeenCalled();
});
it.each([
["gateway runtime", ["node", "openclaw", "gateway", "run"]],
["bare gateway runtime", ["node", "openclaw", "gateway"]],
["node runtime", ["node", "openclaw", "node", "run"]],
["local agent runtime", ["node", "openclaw", "agent", "--local"]],
["provider inference", ["node", "openclaw", "infer", "web", "fetch", "https://example.com"]],
["model command", ["node", "openclaw", "models", "auth", "login", "openai"]],
["plugin command", ["node", "openclaw", "plugins", "marketplace", "list"]],
["skill command", ["node", "openclaw", "skills", "search", "browser"]],
["update command", ["node", "openclaw", "update", "check"]],
["channel probe", ["node", "openclaw", "channels", "status", "--probe"]],
["channel capabilities probe", ["node", "openclaw", "channels", "capabilities"]],
["directory plugin command", ["node", "openclaw", "directory", "peers", "list"]],
["message plugin command", ["node", "openclaw", "message", "send", "--to", "demo"]],
["metadata-owned plugin command", ["node", "openclaw", "googlemeet", "login"]],
])("starts managed proxy routing for %s", (_name, argv) => {
expect(shouldStartProxyForCli(argv)).toBe(true);
});
it.each([
["root help", ["node", "openclaw", "--help"]],
["root version", ["node", "openclaw", "--version"]],
["gateway help", ["node", "openclaw", "gateway", "--help"]],
["gateway run help", ["node", "openclaw", "gateway", "run", "--help"]],
["status", ["node", "openclaw", "status"]],
["health", ["node", "openclaw", "health"]],
["gateway status", ["node", "openclaw", "gateway", "status"]],
["gateway health", ["node", "openclaw", "gateway", "health"]],
["remote agent control-plane", ["node", "openclaw", "agent", "run"]],
["chat control-plane", ["node", "openclaw", "chat"]],
["terminal control-plane", ["node", "openclaw", "terminal"]],
["config", ["node", "openclaw", "config", "get", "proxy.enabled"]],
["channels parent help", ["node", "openclaw", "channels"]],
["completion", ["node", "openclaw", "completion", "zsh"]],
["debug proxy cli", ["node", "openclaw", "proxy", "start"]],
["agents list", ["node", "openclaw", "agents", "list"]],
["models list", ["node", "openclaw", "models", "list"]],
["models status without live probe", ["node", "openclaw", "models", "status"]],
["skills check", ["node", "openclaw", "skills", "check"]],
["skills info", ["node", "openclaw", "skills", "info", "weather"]],
["skills list", ["node", "openclaw", "skills", "list"]],
["tasks list", ["node", "openclaw", "tasks", "list"]],
["legacy singular tool namespace", ["node", "openclaw", "tool", "image_generate"]],
["gateway tools namespace typo", ["node", "openclaw", "tools", "effective"]],
["migrate", ["node", "openclaw", "migrate"]],
])("skips managed proxy routing for %s", (_name, argv) => {
expect(shouldStartProxyForCli(argv)).toBe(false);
});
it("starts the managed proxy for network-capable commands by default", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "plugins", "marketplace", "list"]);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it.each([
["fast path", ["node", "openclaw", "gateway", "run"]],
[
"full Commander path with root options",
["node", "openclaw", "--log-level", "debug", "gateway", "run"],
],
])("loads trusted dotenv and isolates %s gateway proxy config reads", async (_name, argv) => {
if (_name === "full Commander path with root options") {
tryRouteCliMock.mockResolvedValueOnce(true);
}
await runCli(argv);
expect(loadDotEnvMock).toHaveBeenCalledWith({ loadGlobalEnv: false, quiet: true });
expect(loadConfigMock).toHaveBeenCalledWith({ isolateEnv: true, observe: false });
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it("validates the runtime before selecting gateway config", async () => {
await runCli(["node", "openclaw", "gateway", "run"]);
const runtimeGuardOrder = assertRuntimeMock.mock.invocationCallOrder[0] ?? 0;
const configReadOrder = readConfigFileSnapshotMock.mock.invocationCallOrder[0] ?? 0;
expect(runtimeGuardOrder).toBeGreaterThan(0);
expect(configReadOrder).toBeGreaterThan(runtimeGuardOrder);
});
it("re-pins runtime paths after selecting gateway config", async () => {
await runCli(["node", "openclaw", "gateway", "run"]);
expect(pinRuntimePathsMock).toHaveBeenCalledWith(process.env);
expect(pinConfigDirMock).toHaveBeenCalledWith(process.env);
const configReadOrder = readConfigFileSnapshotMock.mock.invocationCallOrder[0] ?? 0;
const pinOrder = pinRuntimePathsMock.mock.invocationCallOrder[0] ?? 0;
expect(pinOrder).toBeGreaterThan(configReadOrder);
});
it("selects gateway config env before starting its managed proxy", async () => {
await withEnvAsync({ OPENCLAW_TEST_PROXY_SELECTION: undefined }, async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: {
env: { vars: { OPENCLAW_TEST_PROXY_SELECTION: "selected" } },
gateway: { mode: "local" },
},
});
loadConfigMock.mockImplementationOnce(() => ({
proxy: { selected: process.env.OPENCLAW_TEST_PROXY_SELECTION },
}));
await runCli(["node", "openclaw", "gateway", "run"]);
expect(startProxyMock).toHaveBeenCalledWith({ selected: "selected" });
});
});
it("replaces the early managed proxy with the final accepted gateway config", async () => {
const earlyHandle = makeProxyHandle();
const finalHandle = makeProxyHandle();
const earlyProxy = { enabled: true, proxyUrl: "http://127.0.0.1:19876" };
const finalProxy = { enabled: true, proxyUrl: "http://127.0.0.1:29876" };
loadConfigMock.mockReturnValueOnce({ proxy: earlyProxy });
startProxyMock.mockResolvedValueOnce(earlyHandle).mockResolvedValueOnce(finalHandle);
commanderParseAsyncMock.mockImplementationOnce(async () => {
const hooks = addGatewayRunCommandMock.mock.calls[0]?.[1] as
| { beforeRun?: (opts: { force?: boolean }) => Promise<void> }
| undefined;
await hooks?.beforeRun?.({});
await getGatewayRunRuntimeHooks().refreshManagedProxy?.(finalProxy);
});
await runCli(["node", "openclaw", "gateway", "run"]);
expect(startProxyMock).toHaveBeenNthCalledWith(1, earlyProxy);
expect(startProxyMock).toHaveBeenNthCalledWith(2, finalProxy);
expect(stopProxyMock).toHaveBeenNthCalledWith(1, earlyHandle);
expect(stopProxyMock).toHaveBeenNthCalledWith(2, finalHandle);
const earlyStopOrder = stopProxyMock.mock.invocationCallOrder[0] ?? 0;
const finalEnvironmentReadOrder = readConfigFileSnapshotMock.mock.invocationCallOrder[1] ?? 0;
const finalStartOrder = startProxyMock.mock.invocationCallOrder[1] ?? 0;
expect(finalEnvironmentReadOrder).toBeGreaterThan(earlyStopOrder);
expect(finalStartOrder).toBeGreaterThan(earlyStopOrder);
});
it("removes early proxy signal handlers when the final config disables the proxy", async () => {
const earlyHandle = makeProxyHandle();
const earlyProxy = { enabled: true, proxyUrl: "http://127.0.0.1:19876" };
const finalProxy = { enabled: false };
loadConfigMock.mockReturnValueOnce({ proxy: earlyProxy });
startProxyMock.mockResolvedValueOnce(earlyHandle).mockResolvedValueOnce(null);
const processOnceSpy = vi.spyOn(process, "once");
const processOffSpy = vi.spyOn(process, "off");
commanderParseAsyncMock.mockImplementationOnce(async () => {
const sigtermHandler = processOnceSpy.mock.calls.find(([event]) => event === "SIGTERM")?.[1];
const sigintHandler = processOnceSpy.mock.calls.find(([event]) => event === "SIGINT")?.[1];
const exitHandler = processOnceSpy.mock.calls.find(([event]) => event === "exit")?.[1];
await getGatewayRunRuntimeHooks().refreshManagedProxy?.(finalProxy);
expect(processOffSpy).toHaveBeenCalledWith("SIGTERM", sigtermHandler);
expect(processOffSpy).toHaveBeenCalledWith("SIGINT", sigintHandler);
expect(processOffSpy).toHaveBeenCalledWith("exit", exitHandler);
});
try {
await runCli(["node", "openclaw", "gateway", "run"]);
} finally {
processOffSpy.mockRestore();
processOnceSpy.mockRestore();
}
expect(startProxyMock).toHaveBeenNthCalledWith(1, earlyProxy);
expect(startProxyMock).toHaveBeenNthCalledWith(2, finalProxy);
expect(stopProxyMock).toHaveBeenCalledOnce();
expect(stopProxyMock).toHaveBeenCalledWith(earlyHandle);
});
it("starts the managed proxy for metadata-owned plugin commands by default", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "googlemeet", "login"]);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it("rejects unowned command roots before proxy and plugin runtime registration", async () => {
await expect(runCli(["node", "openclaw", "foo"])).rejects.toThrow(
'No built-in command or plugin CLI metadata owns "foo"',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("suggests close known commands for unowned command roots before proxy startup", async () => {
await expect(runCli(["node", "openclaw", "upate"])).rejects.toThrow(
"Did you mean this?\n openclaw update",
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("keeps suggestions out of plugin-policy diagnostics", async () => {
resolveManifestCommandAliasOwnerMock.mockReturnValueOnce({
pluginId: "codex",
kind: "runtime-slash",
cliCommand: "plugins",
});
let error: unknown;
try {
await runCli(["node", "openclaw", "codex"]);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("runtime slash command");
expect((error as Error).message).toContain("/codex");
expect((error as Error).message).not.toContain("Did you mean this?");
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("rejects unowned command roots even when --help is appended (regression for #81077)", async () => {
await expect(runCli(["node", "openclaw", "foo", "--help"])).rejects.toThrow(
'No built-in command or plugin CLI metadata owns "foo"',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("rejects unowned command roots even when --version is appended", async () => {
await expect(runCli(["node", "openclaw", "foo", "--version"])).rejects.toThrow(
'No built-in command or plugin CLI metadata owns "foo"',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
});
it("does not suggest plugins.allow for unknown command roots before proxy startup", async () => {
loadConfigMock.mockReturnValueOnce({
plugins: {
allow: ["browser"],
},
});
let error: unknown;
try {
await runCli(["node", "openclaw", "totally-unknown"]);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
'No built-in command or plugin CLI metadata owns "totally-unknown"',
);
expect((error as Error).message).not.toContain("plugins.allow");
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("preserves plugins.allow diagnostics for roots owned only by CLI metadata", async () => {
loadConfigMock.mockReturnValueOnce({
plugins: {
allow: ["browser"],
},
});
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({
cfg,
primaryCommand,
}: {
cfg?: { plugins?: { allow?: string[] } };
primaryCommand?: string;
}) => (primaryCommand === "qa" && cfg?.plugins?.allow?.length === 0 ? ["qa-lab"] : []),
);
await expect(runCli(["node", "openclaw", "qa"])).rejects.toThrow(
'Add "qa-lab" to `plugins.allow` instead of "qa"',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("reports plugin tool command mistakes before proxy startup", async () => {
resolveManifestToolOwnerMock.mockReturnValueOnce({
toolName: "lcm_recent",
pluginId: "lossless-claw",
availability: "loaded",
});
await expect(runCli(["node", "openclaw", "lcm_recent"])).rejects.toThrow(
'"lcm_recent" is an agent tool available from the "lossless-claw" plugin',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("does not install the env proxy dispatcher for bypassed skills inspection commands", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "skills", "check"]);
expect(hasEnvHttpProxyAgentConfiguredMock).not.toHaveBeenCalled();
expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled();
});
it.each([
["auth", ["node", "openclaw", "auth", "--help"]],
["tool", ["node", "openclaw", "tool", "image_generate"]],
["tools", ["node", "openclaw", "tools", "effective"]],
])("keeps reserved %s command roots out of plugin command discovery", async (_name, argv) => {
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
const program = {
commands: [],
parseAsync,
};
buildProgramMock.mockReturnValueOnce(program);
await runCli(argv);
expect(startProxyMock).not.toHaveBeenCalled();
expect(registerSubCliByNameMock.mock.calls).toEqual([[program, argv[2], argv]]);
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
expect(parseAsync).toHaveBeenCalledWith(argv);
});
it("routes lazy plugin registration logs to stderr only during --json registration", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "memory" ? ["memory"] : [],
);
let stderrDuringPluginRegistration = false;
let stderrDuringParse = true;
registerPluginCliCommandsFromValidatedConfigMock.mockImplementationOnce(async () => {
stderrDuringPluginRegistration = loggingState.forceConsoleToStderr;
return {};
});
const parseAsync = vi.fn().mockImplementationOnce(async () => {
stderrDuringParse = loggingState.forceConsoleToStderr;
});
buildProgramMock.mockReturnValueOnce({
commands: [],
parseAsync,
});
await runCli(["node", "openclaw", "memory", "search", "query", "--json"]);
expect(registerPluginCliCommandsFromValidatedConfigMock).toHaveBeenCalledWith(
expect.anything(),
undefined,
undefined,
{ mode: "lazy", primary: "memory" },
);
expect(stderrDuringPluginRegistration).toBe(true);
expect(stderrDuringParse).toBe(false);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("does not route lazy plugin registration logs for pass-through --json after terminator", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "memory" ? ["memory"] : [],
);
let stderrDuringPluginRegistration = true;
registerPluginCliCommandsFromValidatedConfigMock.mockImplementationOnce(async () => {
stderrDuringPluginRegistration = loggingState.forceConsoleToStderr;
return {};
});
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [],
parseAsync,
});
await runCli(["node", "openclaw", "memory", "--", "--json"]);
expect(registerPluginCliCommandsFromValidatedConfigMock).toHaveBeenCalledWith(
expect.anything(),
undefined,
undefined,
{ mode: "lazy", primary: "memory" },
);
expect(stderrDuringPluginRegistration).toBe(false);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("fails protected commands when managed proxy activation fails", async () => {
startProxyMock.mockRejectedValueOnce(new Error("proxy: enabled but no HTTP proxy URL"));
await expect(runCli(["node", "openclaw", "gateway", "run"])).rejects.toThrow(
"proxy: enabled but no HTTP proxy URL",
);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(stopProxyMock).not.toHaveBeenCalled();
});
it("fails protected commands when config cannot be loaded for managed proxy startup", async () => {
loadConfigMock.mockImplementationOnce(() => {
throw new Error("config parse failed");
});
await expect(runCli(["node", "openclaw", "gateway", "run"])).rejects.toThrow(
"config parse failed",
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
});
it("stops the managed proxy after normal gateway runtime completion", async () => {
const handle = makeProxyHandle();
startProxyMock.mockResolvedValueOnce(handle);
await runCli(["node", "openclaw", "gateway", "run"]);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
expect(stopProxyMock).toHaveBeenCalledOnce();
expect(stopProxyMock).toHaveBeenCalledWith(handle);
});
it("stops the managed proxy and exits after SIGINT", async () => {
const handle = makeProxyHandle();
startProxyMock.mockResolvedValueOnce(handle);
let resolveRoute: (value: boolean) => void = () => {};
tryRouteCliMock.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveRoute = resolve;
}),
);
const processOnceSpy = vi.spyOn(process, "once");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string) => {
void code;
return undefined as never;
}) as typeof process.exit);
let finishCompanionCleanup: (() => void) | undefined;
const unregisterCompanionCleanup = registerSignalExitBarrier(
() =>
new Promise<void>((resolve) => {
finishCompanionCleanup = resolve;
}),
);
try {
const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]);
await vi.waitFor(() => {
expect(
processOnceSpy.mock.calls.some(
([event, listener]) => event === "SIGINT" && typeof listener === "function",
),
).toBe(true);
});
const sigintHandler = processOnceSpy.mock.calls.find(([event]) => event === "SIGINT")?.[1];
if (typeof sigintHandler !== "function") {
throw new Error("SIGINT handler was not registered");
}
sigintHandler();
await vi.waitFor(() => {
expect(stopProxyMock).toHaveBeenCalledWith(handle);
});
expect(exitSpy).not.toHaveBeenCalled();
if (!finishCompanionCleanup) {
throw new Error("companion signal cleanup did not start");
}
finishCompanionCleanup();
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalledWith(130);
});
resolveRoute(true);
await runPromise;
expect(stopProxyMock).toHaveBeenCalledTimes(1);
} finally {
unregisterCompanionCleanup();
exitSpy.mockRestore();
processOnceSpy.mockRestore();
}
});
it("synchronously kills the managed proxy during hard process exit", async () => {
const handle = makeProxyHandle();
startProxyMock.mockResolvedValueOnce(handle);
let resolveRoute: (value: boolean) => void = () => {};
tryRouteCliMock.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveRoute = resolve;
}),
);
const processOnceSpy = vi.spyOn(process, "once");
try {
const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]);
await vi.waitFor(() => {
expect(
processOnceSpy.mock.calls.reduce(
(count, [event]) => count + (event === "exit" ? 1 : 0),
0,
),
).toBe(2);
});
const exitHandler = processOnceSpy.mock.calls.find(([event]) => event === "exit")?.[1];
if (typeof exitHandler !== "function") {
throw new Error("exit handler was not registered");
}
exitHandler(0 as never);
expect(handle.kill).toHaveBeenCalledWith("SIGTERM");
resolveRoute(true);
await runPromise;
expect(stopProxyMock).not.toHaveBeenCalledWith(handle);
} finally {
processOnceSpy.mockRestore();
}
});
it("starts onboarding for bare root invocations before config exists", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: false,
valid: true,
sourceConfig: {},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("starts onboarding for bare root invocations when config is empty", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("starts onboarding for bare root invocations when config only has metadata", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
$schema: "https://openclaw.ai/config.json",
meta: { updatedBy: "fixture" },
},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("points noninteractive fresh bare root invocations to onboarding automation", async () => {
const previousExitCode = process.exitCode;
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: false,
valid: true,
sourceConfig: {},
});
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
try {
await runCli(["node", "openclaw"]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
});
it("starts the gateway-backed TUI for bare root invocations when config already exists", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
auth: {
mode: "password",
password: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_PASSWORD",
},
},
},
},
});
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "gateway-ref-password" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(resolveControlUiLinksMock).toHaveBeenCalledWith({
port: 18789,
bind: "loopback",
basePath: undefined,
tlsEnabled: false,
});
expect(probeGatewayReachableMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:18789",
password: "gateway-ref-password",
});
expect(launchTuiCliMock).toHaveBeenCalledWith(
{ deliver: false },
{ gatewayUrl: "ws://127.0.0.1:18789", authSource: "config" },
);
expect(runCrestodianMock).not.toHaveBeenCalled();
});
it("uses gateway env credentials for bare root gateway preflight", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
auth: { mode: "token" },
},
},
});
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
});
expect(probeGatewayReachableMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:18789",
token: "env-token",
});
expect(launchTuiCliMock).toHaveBeenCalledWith(
{ deliver: false },
{ gatewayUrl: "ws://127.0.0.1:18789" },
);
});
it("probes local gateways over loopback even when the gateway advertises a LAN bind", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
bind: "lan",
auth: {
mode: "token",
token: "local-token",
},
},
},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(resolveControlUiLinksMock).toHaveBeenCalledWith({
port: 18789,
bind: "loopback",
basePath: undefined,
tlsEnabled: false,
});
expect(probeGatewayReachableMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:18789",
token: "local-token",
});
expect(launchTuiCliMock).toHaveBeenCalledWith(
{ deliver: false },
{ gatewayUrl: "ws://127.0.0.1:18789", authSource: "config" },
);
});
it("falls back to the configured local tailnet gateway URL when loopback is unavailable", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
bind: "tailnet",
auth: {
mode: "token",
token: "local-token",
},
},
},
});
resolveControlUiLinksMock.mockImplementation(({ bind }: { bind?: string } = {}) =>
bind === "tailnet"
? {
httpUrl: "http://100.64.0.10:18789/",
wsUrl: "ws://100.64.0.10:18789",
}
: {
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
},
);
probeGatewayReachableMock
.mockResolvedValueOnce({ ok: false, detail: "loopback offline" })
.mockResolvedValueOnce({ ok: true });
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(probeGatewayReachableMock).toHaveBeenNthCalledWith(1, {
url: "ws://127.0.0.1:18789",
token: "local-token",
});
expect(probeGatewayReachableMock).toHaveBeenNthCalledWith(2, {
url: "ws://100.64.0.10:18789",
token: "local-token",
});
expect(launchTuiCliMock).toHaveBeenCalledWith(
{ deliver: false },
{ gatewayUrl: "ws://100.64.0.10:18789", authSource: "config" },
);
});
it("starts the local TUI for bare root invocations when the gateway is unavailable", async () => {
probeGatewayReachableMock.mockResolvedValueOnce({ ok: false, detail: "offline" });
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(launchTuiCliMock).toHaveBeenCalledWith({ deliver: false, local: true }, {});
expect(runCrestodianMock).not.toHaveBeenCalled();
});
it("does not probe unsafe remote gateway URLs with configured credentials", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url: "ws://192.168.1.10:18789",
token: "remote-token",
},
},
},
});
await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
});
expect(probeGatewayReachableMock).not.toHaveBeenCalled();
expect(launchTuiCliMock).toHaveBeenCalledWith({ deliver: false, local: true }, {});
expect(runCrestodianMock).not.toHaveBeenCalled();
});
it("rejects configured bare root TUI startup without an interactive TTY", async () => {
const previousExitCode = process.exitCode;
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
try {
await runCli(["node", "openclaw"]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
"OpenClaw TUI needs an interactive TTY. Use `openclaw agent --local ...` for automation.",
);
expect(launchTuiCliMock).not.toHaveBeenCalled();
expect(runCrestodianMock).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
});
it("keeps invalid configured bare root invocations on Crestodian", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: false,
sourceConfig: { gateway: { mode: "local" } },
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(launchTuiCliMock).not.toHaveBeenCalled();
expect(runCrestodianMock).toHaveBeenCalledOnce();
const crestodianOptions = requireRunCrestodianOptions();
expect(crestodianOptions).toEqual({ onReady: crestodianOptions.onReady });
expect(crestodianOptions.onReady).toBeTypeOf("function");
});
it("bootstraps env proxy before bare TUI startup", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
await runCli(["node", "openclaw"]);
} finally {
if (stdinTty) {
Object.defineProperty(process.stdin, "isTTY", stdinTty);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
if (stdoutTty) {
Object.defineProperty(process.stdout, "isTTY", stdoutTty);
} else {
delete (process.stdout as { isTTY?: boolean }).isTTY;
}
}
expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1);
expect(launchTuiCliMock).toHaveBeenCalledOnce();
expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan(
probeGatewayReachableMock.mock.invocationCallOrder[0],
);
expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan(
launchTuiCliMock.mock.invocationCallOrder[0],
);
});
it("bootstraps env proxy before modern onboard Crestodian startup", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
await runCli(["node", "openclaw", "onboard", "--modern", "--json"]);
expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1);
expect(runCrestodianMock).toHaveBeenCalledWith({
message: undefined,
yes: false,
json: true,
interactive: true,
});
expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan(
runCrestodianMock.mock.invocationCallOrder[0],
);
});
it("closes memory managers when a runtime was registered", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
hasMemoryRuntimeMock.mockReturnValue(true);
await runCli(["node", "openclaw", "status"]);
expect(closeActiveMemorySearchManagersMock).toHaveBeenCalledTimes(1);
});
it("does not fail the command when memory cleanup is unavailable", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
hasMemoryRuntimeMock.mockImplementationOnce(() => {
throw new Error("stale memory-state chunk");
});
await expect(runCli(["node", "openclaw", "status"])).resolves.toBeUndefined();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
});
it("returns after a handled container-target invocation", async () => {
maybeRunCliInContainerMock.mockReturnValueOnce({ handled: true, exitCode: 0 });
await runCli(["node", "openclaw", "--container", "demo", "status"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith([
"node",
"openclaw",
"--container",
"demo",
"status",
]);
expect(loadDotEnvMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
});
it("propagates a handled container-target exit code", async () => {
const exitCode = process.exitCode;
maybeRunCliInContainerMock.mockReturnValueOnce({ handled: true, exitCode: 7 });
await runCli(["node", "openclaw", "--container", "demo", "status"]);
expect(process.exitCode).toBe(7);
process.exitCode = exitCode;
});
it("swallows Commander parse exits after recording the exit code", async () => {
const exitCode = process.exitCode;
const program = {
commands: [{ name: () => "status" }],
parseAsync: vi
.fn()
.mockRejectedValueOnce(
new CommanderError(1, "commander.excessArguments", "too many arguments for 'status'"),
),
};
buildProgramMock.mockReturnValueOnce(program);
await expect(runCli(["node", "openclaw", "status"])).resolves.toBeUndefined();
expect(registerSubCliByNameMock.mock.calls).toEqual([
[program, "status", ["node", "openclaw", "status"]],
]);
expect(process.exitCode).toBe(1);
process.exitCode = exitCode;
});
it("loads the real primary command before rendering command help", async () => {
const program = {
commands: [{ name: () => "doctor" }],
parseAsync: vi.fn().mockResolvedValueOnce(undefined),
};
buildProgramMock.mockReturnValueOnce(program);
const ctx = { programVersion: "0.0.0-test" };
getProgramContextMock.mockReturnValueOnce(ctx as never);
await runCli(["node", "openclaw", "doctor", "--help"]);
expect(registerCoreCliByNameMock.mock.calls).toEqual([
[program, ctx, "doctor", ["node", "openclaw", "doctor", "--help"]],
]);
expect(registerSubCliByNameMock.mock.calls).toEqual([
[program, "doctor", ["node", "openclaw", "doctor", "--help"]],
]);
});
it("restores terminal state before uncaught CLI exits", async () => {
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "status" }],
parseAsync: vi.fn().mockResolvedValueOnce(undefined),
});
const processOnSpy = vi.spyOn(process, "on");
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "status"]);
const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1];
if (typeof handler !== "function") {
throw new Error("uncaughtException handler was not registered");
}
try {
expect(() => handler(new Error("boom"))).toThrow("process.exit(1)");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[openclaw] OpenClaw hit an unexpected runtime error.",
);
expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom");
expect(restoreTerminalStateMock).toHaveBeenCalledWith("uncaught exception", {
resumeStdinIfPaused: false,
});
} finally {
if (typeof handler === "function") {
process.off("uncaughtException", handler);
}
consoleErrorSpy.mockRestore();
exitSpy.mockRestore();
processOnSpy.mockRestore();
}
});
it("does not exit for transient uncaught CLI exceptions", async () => {
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "status" }],
parseAsync: vi.fn().mockResolvedValueOnce(undefined),
});
const processOnSpy = vi.spyOn(process, "on");
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "status"]);
const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1];
if (typeof handler !== "function") {
throw new Error("uncaughtException handler was not registered");
}
try {
const hostUnreachable = Object.assign(new Error("connect EHOSTUNREACH 149.154.167.220:443"), {
code: "EHOSTUNREACH",
});
expect(handler(hostUnreachable)).toBeUndefined();
expect(consoleWarnSpy.mock.calls).toEqual([
["[openclaw] Non-fatal uncaught exception (continuing):", hostUnreachable.stack],
]);
expect(restoreTerminalStateMock).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
} finally {
if (typeof handler === "function") {
process.off("uncaughtException", handler);
}
consoleWarnSpy.mockRestore();
exitSpy.mockRestore();
processOnSpy.mockRestore();
}
});
});