openclaw / src /cli /gateway-cli /run.option-collisions.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw History Blame Contribute Delete
93 kB
import fs from "node:fs/promises";
// Gateway run option collision tests cover gateway run flag registration boundaries.
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { CONFIG_AUDIT_STORE_LABEL } from "../../config/io.audit.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../../config/types.js";
import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../../daemon/constants.js";
import { createNewerSqliteSchemaVersionError } from "../../infra/sqlite-user-version.js";
import { SUPERVISOR_HINT_ENV_VARS } from "../../infra/supervisor-markers.js";
import { OpenClawDatabaseSchemaPreflightError } from "../../state/openclaw-database-preflight.js";
import { OpenClawStateDatabaseSchemaMigrationRequiredError } from "../../state/openclaw-state-db-schema-migration-required.js";
import {
captureEnv,
deleteTestEnvValue,
setTestEnvValue,
withEnvAsync,
} from "../../test-utils/env.js";
import { getFreePort } from "../../test-utils/ports.js";
import { withTempSecretFiles } from "../../test-utils/secret-file-fixture.js";
import { withMockedPlatform } from "../../test-utils/vitest-spies.js";
import { VERSION } from "../../version.js";
import { createCliRuntimeCapture } from "../test-runtime-capture.js";
import { installGatewayRunRuntimeHooks } from "./runtime-hooks.js";
const startGatewayServer = vi.fn(async (_port: number, _opts?: unknown) => ({
close: vi.fn(async () => {}),
}));
const triageAfterFailure = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../../commands/triage-failure.js", () => ({ triageAfterFailure }));
const setGatewayWsLogStyle = vi.fn((_style: string) => undefined);
const setVerbose = vi.fn((_enabled: boolean) => undefined);
const setConsoleSubsystemFilter = vi.fn((_filters: string[]) => undefined);
const forceFreePortAndWait = vi.fn(async (_port: number, _opts: unknown) => ({
killed: [],
waitedMs: 0,
escalatedToSigkill: false,
}));
const cleanStaleGatewayProcessesSync = vi.fn(
(_port?: number, _options?: { protectedPid?: number }) => [],
);
const warnAboutGatewayRestartStorm = vi.fn(
async (_env: NodeJS.ProcessEnv, _warn: (message: string) => void) => {},
);
const waitForPortBindable = vi.fn(async (_port: number, _opts?: unknown) => 0);
const findVerifiedGatewayListenerPidsOnPortSync = vi.fn((_port: number) => [] as number[]);
const formatGatewayPidList = vi.fn((pids: number[]) => pids.join(", "));
const isTerminalInteractive = vi.fn(() => true);
const offerInvalidConfigRecovery = vi.fn(async () => ({ status: "declined" as const }));
const parkCurrentLaunchAgentForMaintenance = vi.fn(async () => false);
const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
type GatewayLoopStart = (params?: { startupStartedAt?: number }) => Promise<unknown>;
type GatewayLoopParams = {
start: GatewayLoopStart;
completeBoot?: (completion: unknown) => void;
ownsProcessLifecycle?: boolean;
runtime?: unknown;
};
const runGatewayLoop = vi.fn(async ({ start }: GatewayLoopParams) => {
await start();
});
const normalizeStateDirEnv = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
const pinConfigDir = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
const pinRuntimePaths = vi.fn((_env?: NodeJS.ProcessEnv) => undefined);
const detectRespawnSupervisor = vi.fn(() => null as "systemd" | null);
type RuntimeDotEnvLoadResult = {
dotenvPresentKeys: string[];
gatewayEnvAppliedKeys: string[];
stateEnvAppliedKeys: string[];
};
const loadGlobalRuntimeDotEnvFiles = vi.fn<
(_opts?: unknown) => RuntimeDotEnvLoadResult | undefined
>(() => undefined);
const beforeRun = vi.fn(async () => {
callOrder.push("bootstrap");
});
const callOrder = vi.hoisted(() => [] as string[]);
const refreshManagedProxy = vi.fn(async () => {
callOrder.push("proxy");
});
const loadShellEnvFallback = vi.fn((_opts?: unknown) => {
callOrder.push("shell-env");
});
const clearShellEnvAppliedKeys = vi.fn((_keys: readonly string[]) => undefined);
const resolveShellEnvExpectedKeys = vi.fn((_env?: NodeJS.ProcessEnv, _config?: OpenClawConfig) => [
"OPENCLAW_GATEWAY_TOKEN",
]);
const resolveShellEnvFallbackTimeoutMs = vi.fn((_env?: NodeJS.ProcessEnv) => 15_000);
const shouldDeferShellEnvFallback = vi.fn((_env?: NodeJS.ProcessEnv) => false);
const shouldEnableShellEnvFallback = vi.fn((_env?: NodeJS.ProcessEnv) => false);
const gatewayLogMessages = vi.hoisted(() => [] as string[]);
const gatewayErrorMessages = vi.hoisted(() => [] as string[]);
const configState = vi.hoisted(() => ({
cfg: {} as Record<string, unknown>,
snapshot: { config: {}, exists: false, sourceConfig: {}, valid: true } as Record<string, unknown>,
}));
const pristineStartupMigrationPlan = vi.hoisted(() => ({
config: vi.fn(),
state: vi.fn(),
}));
const readBestEffortConfig = vi.fn(async () => configState.cfg);
type ConfigSnapshotReadOptionsStub = {
isolateEnv?: boolean;
lowerPrecedenceEnv?: Readonly<Record<string, string>>;
observe?: boolean;
};
const readConfigFileSnapshotWithPluginMetadata = vi.fn(
async (_options?: ConfigSnapshotReadOptionsStub) => ({
snapshot: configState.snapshot,
}),
);
const writeDiagnosticStabilityBundleForFailureSync = vi.fn((_reason: string, _error: unknown) => ({
status: "written" as const,
message: "wrote stability bundle: /tmp/openclaw-stability.json",
path: "/tmp/openclaw-stability.json",
}));
const bootLifecycle = vi.hoisted(() => ({
manualChannelStartHint: `Start a channel manually with: openclaw gateway call channels.start --params '{"channel":"<id>"}'`,
decisions: [] as Array<{
tripped: boolean;
uncleanBoots: number;
windowMs: number;
shouldWriteStabilityBundle: boolean;
recovered: boolean;
}>,
inspect: vi.fn(
(_env?: NodeJS.ProcessEnv, _nowMs?: number) =>
bootLifecycle.decisions.shift() ?? {
tripped: false,
uncleanBoots: 0,
windowMs: 300_000,
shouldWriteStabilityBundle: false,
recovered: false,
},
),
record: vi.fn(
(_env?: NodeJS.ProcessEnv, _nowMs?: number, _reason?: string): string | undefined => "boot-id",
),
recover: vi.fn(
(_bootId?: string, _env?: NodeJS.ProcessEnv, _nowMs?: number): string | undefined =>
"recovered-boot-id",
),
complete: vi.fn(),
}));
const netState = vi.hoisted(() => ({
autoBindHost: "127.0.0.1",
container: false,
}));
const withoutSupervisorEnv = Object.fromEntries(
SUPERVISOR_HINT_ENV_VARS.map((key) => [key, undefined]),
) as Record<string, string | undefined>;
const withoutGatewayAuthEnv = {
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
};
const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
// gateway run exports --token/--password into process.env as a side effect
// (see runGatewayCli auth wiring); snapshot and clear them so shared vitest
// workers do not leak credentials into later files' gateway connects.
const serviceEnvSnapshot = captureEnv([
"OPENCLAW_SERVICE_MARKER",
"OPENCLAW_SERVICE_KIND",
GATEWAY_SERVICE_RUNTIME_PID_ENV,
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
]);
vi.mock("../../config/config.js", () => ({
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
readBestEffortConfig: () => readBestEffortConfig(),
readConfigFileSnapshot: async () => configState.snapshot,
readConfigFileSnapshotWithPluginMetadata: (options?: ConfigSnapshotReadOptionsStub) =>
readConfigFileSnapshotWithPluginMetadata(options),
}));
vi.mock("../../commands/doctor/shared/pristine-startup-state.js", () => ({
planPristineStartupConfigMigrations: (config: unknown, env?: NodeJS.ProcessEnv) =>
pristineStartupMigrationPlan.config(config, env),
planPristineStartupStateMigrations: (env?: NodeJS.ProcessEnv) =>
pristineStartupMigrationPlan.state(env),
}));
vi.mock("../../config/paths.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../config/paths.js")>()),
CONFIG_PATH: "/tmp/openclaw-test-missing-config.json",
normalizeStateDirEnv: (env?: NodeJS.ProcessEnv) => normalizeStateDirEnv(env),
pinRuntimePaths: (env?: NodeJS.ProcessEnv) => pinRuntimePaths(env),
resolveConfigPath: () => "/tmp/openclaw-test-missing-config.json",
resolveStateDir: () => "/tmp",
resolveGatewayPort: (cfg?: { gateway?: { port?: number } }) => cfg?.gateway?.port ?? 18789,
}));
vi.mock("../../utils.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../utils.js")>()),
pinConfigDir: (env?: NodeJS.ProcessEnv) => pinConfigDir(env),
}));
vi.mock("../../infra/dotenv-global.js", () => ({
loadGlobalRuntimeDotEnvFiles: (opts?: unknown) =>
loadGlobalRuntimeDotEnvFiles(opts) ?? {
dotenvPresentKeys: [],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: [],
},
}));
vi.mock("../../config/shell-env-expected-keys.js", () => ({
resolveShellEnvExpectedKeys: (...args: Parameters<typeof resolveShellEnvExpectedKeys>) =>
resolveShellEnvExpectedKeys(...args),
}));
vi.mock("../../infra/shell-env.js", () => ({
clearShellEnvAppliedKeys: (keys: readonly string[]) => clearShellEnvAppliedKeys(keys),
loadShellEnvFallback: (opts?: unknown) => loadShellEnvFallback(opts),
resolveShellEnvFallbackTimeoutMs: (env?: NodeJS.ProcessEnv) =>
resolveShellEnvFallbackTimeoutMs(env),
shouldDeferShellEnvFallback: (env?: NodeJS.ProcessEnv) => shouldDeferShellEnvFallback(env),
shouldEnableShellEnvFallback: (env?: NodeJS.ProcessEnv) => shouldEnableShellEnvFallback(env),
}));
vi.mock("../../gateway/auth.js", () => ({
resolveGatewayAuth: (params: {
authConfig?: { mode?: string; token?: unknown; password?: unknown };
authOverride?: { mode?: string; token?: unknown; password?: unknown };
env?: NodeJS.ProcessEnv;
}) => {
const mode = params.authOverride?.mode ?? params.authConfig?.mode ?? "token";
const token =
(typeof params.authOverride?.token === "string" ? params.authOverride.token : undefined) ??
(typeof params.authConfig?.token === "string" ? params.authConfig.token : undefined) ??
params.env?.OPENCLAW_GATEWAY_TOKEN;
const password =
(typeof params.authOverride?.password === "string"
? params.authOverride.password
: undefined) ??
(typeof params.authConfig?.password === "string" ? params.authConfig.password : undefined) ??
params.env?.OPENCLAW_GATEWAY_PASSWORD;
return {
mode,
token,
password,
allowTailscale: false,
};
},
}));
vi.mock("../../gateway/net.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../gateway/net.js")>();
return {
...actual,
defaultGatewayBindMode: (tailscaleMode?: string) => {
if (tailscaleMode && tailscaleMode !== "off") {
return "loopback";
}
return netState.container ? "auto" : "loopback";
},
isContainerEnvironment: () => netState.container,
resolveGatewayBindHost: async (bind?: string, customHost?: string) => {
if (bind === "auto") {
return netState.autoBindHost;
}
if (bind === "lan") {
return "0.0.0.0";
}
if (bind === "custom") {
return customHost?.trim() || "0.0.0.0";
}
if (bind === "tailnet") {
return "100.64.0.1";
}
return "127.0.0.1";
},
};
});
vi.mock("../../infra/restart-stale-pids.js", () => ({
cleanStaleGatewayProcessesSync: (port?: number, options?: { protectedPid?: number }) =>
cleanStaleGatewayProcessesSync(port, options),
}));
vi.mock("../../daemon/restart-storm.js", () => ({
warnAboutGatewayRestartStorm: (env: NodeJS.ProcessEnv, warn: (message: string) => void) =>
warnAboutGatewayRestartStorm(env, warn),
}));
vi.mock("../../infra/gateway-processes.js", () => ({
findVerifiedGatewayListenerPidsOnPortSync: (port: number) =>
findVerifiedGatewayListenerPidsOnPortSync(port),
formatGatewayPidList: (pids: number[]) => formatGatewayPidList(pids),
}));
vi.mock("../../gateway/server.js", () => ({
startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts),
}));
vi.mock("../../daemon/launchd.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../daemon/launchd.js")>()),
parkCurrentLaunchAgentForMaintenance: () => parkCurrentLaunchAgentForMaintenance(),
}));
vi.mock("../../gateway/ws-logging.js", () => ({
setGatewayWsLogStyle: (style: string) => setGatewayWsLogStyle(style),
}));
vi.mock("../../globals.js", () => ({
setVerbose: (enabled: boolean) => setVerbose(enabled),
}));
vi.mock("../../infra/ports-inspect.js", () => ({
inspectPortUsage: async () => ({ status: "free" }),
}));
vi.mock("../../infra/ports-format.js", () => ({ formatPortDiagnostics: () => [] }));
vi.mock("../../infra/supervisor-markers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/supervisor-markers.js")>();
return {
...actual,
detectRespawnSupervisor: () => detectRespawnSupervisor(),
};
});
vi.mock("../../logging/console.js", () => ({
setConsoleSubsystemFilter: (filters: string[]) => setConsoleSubsystemFilter(filters),
setConsoleTimestampPrefix: () => undefined,
}));
vi.mock("../../logging/diagnostic-stability-bundle.js", () => ({
writeDiagnosticStabilityBundleForFailureSync: (reason: string, error: unknown) =>
writeDiagnosticStabilityBundleForFailureSync(reason, error),
}));
vi.mock("../../infra/gateway-boot-lifecycle.js", () => ({
GATEWAY_CRASH_LOOP_BREAKER_REASON: "gateway.crash_loop_breaker",
formatGatewayCrashLoopManualChannelStartHint: () => bootLifecycle.manualChannelStartHint,
GATEWAY_CRASH_LOOP_RECOVERED_REASON: "gateway.crash_loop_recovered",
inspectGatewayCrashLoopBreaker: (env?: NodeJS.ProcessEnv, nowMs?: number) =>
bootLifecycle.inspect(env, nowMs),
recordGatewayBootStart: (env?: NodeJS.ProcessEnv, nowMs?: number, reason?: string) =>
bootLifecycle.record(env, nowMs, reason),
recordGatewayCrashLoopRecovery: (bootId?: string, env?: NodeJS.ProcessEnv, nowMs?: number) =>
bootLifecycle.recover(bootId, env, nowMs),
completeGatewayBootLifecycle: (bootId: string | undefined, completion: unknown) =>
bootLifecycle.complete(bootId, completion),
}));
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({
debug: () => undefined,
info: (message: string) => {
gatewayLogMessages.push(message);
},
warn: (message: string) => {
gatewayLogMessages.push(message);
},
error: (message: string) => {
gatewayErrorMessages.push(message);
},
}),
}));
vi.mock("../../runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../runtime.js")>()),
defaultRuntime,
}));
vi.mock("../command-format.js", () => ({
formatCliCommand: (cmd: string) => cmd,
}));
vi.mock("../terminal-interactivity.js", () => ({
isTerminalInteractive: () => isTerminalInteractive(),
NON_INTERACTIVE_GATEWAY_RUN_FORCE_MESSAGE:
"Refusing to kill the operator's running gateway service from a non-interactive shell. Use an isolated dev gateway (openclaw gateway run --dev, or --profile <name> with a free port) for testing.",
}));
vi.mock("../invalid-config-recovery.js", () => ({
offerInvalidConfigRecovery: () => offerInvalidConfigRecovery(),
}));
vi.mock("../ports.js", () => ({
forceFreePortAndWait: (port: number, opts: unknown) => forceFreePortAndWait(port, opts),
waitForPortBindable: (port: number, opts?: unknown) => waitForPortBindable(port, opts),
}));
vi.mock("./dev.js", () => ({
ensureDevGatewayConfig: (opts?: unknown) => ensureDevGatewayConfig(opts),
}));
vi.mock("./run-loop.js", () => ({
runGatewayLoop: (params: { start: GatewayLoopStart }) => runGatewayLoop(params),
}));
describe("gateway run option collisions", () => {
let addGatewayRunCommand: typeof import("./run-command.js").addGatewayRunCommand;
let sharedProgram: Command;
beforeAll(async () => {
({ addGatewayRunCommand } = await import("./run-command.js"));
sharedProgram = new Command();
sharedProgram.exitOverride();
const gateway = addGatewayRunCommand(sharedProgram.command("gateway"), { beforeRun });
addGatewayRunCommand(gateway.command("run"), { beforeRun });
});
afterAll(() => {
serviceEnvSnapshot.restore();
});
beforeEach(() => {
delete process.env.OPENCLAW_SERVICE_MARKER;
delete process.env.OPENCLAW_SERVICE_KIND;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
deleteTestEnvValue(GATEWAY_SERVICE_RUNTIME_PID_ENV);
resetRuntimeCapture();
configState.cfg = {};
configState.snapshot = { config: {}, exists: false, sourceConfig: {}, valid: true };
pristineStartupMigrationPlan.config.mockReset();
pristineStartupMigrationPlan.config.mockReturnValue({
skipAllStateMigrations: false,
skipCoreStateMigrations: false,
});
pristineStartupMigrationPlan.state.mockReset();
pristineStartupMigrationPlan.state.mockReturnValue({
skipAllStateMigrations: false,
skipCoreStateMigrations: false,
});
netState.autoBindHost = "127.0.0.1";
netState.container = false;
detectRespawnSupervisor.mockReset().mockReturnValue(null);
readBestEffortConfig.mockClear();
readConfigFileSnapshotWithPluginMetadata.mockClear();
gatewayLogMessages.length = 0;
gatewayErrorMessages.length = 0;
writeDiagnosticStabilityBundleForFailureSync.mockClear();
bootLifecycle.decisions.length = 0;
bootLifecycle.inspect.mockClear();
bootLifecycle.record.mockClear();
triageAfterFailure.mockClear();
bootLifecycle.recover.mockClear();
bootLifecycle.complete.mockClear();
startGatewayServer.mockClear();
setGatewayWsLogStyle.mockClear();
setVerbose.mockClear();
setConsoleSubsystemFilter.mockClear();
forceFreePortAndWait.mockClear();
findVerifiedGatewayListenerPidsOnPortSync.mockReset();
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
formatGatewayPidList.mockClear();
isTerminalInteractive.mockReset();
isTerminalInteractive.mockReturnValue(true);
offerInvalidConfigRecovery.mockClear();
parkCurrentLaunchAgentForMaintenance.mockReset();
parkCurrentLaunchAgentForMaintenance.mockResolvedValue(false);
cleanStaleGatewayProcessesSync.mockClear();
warnAboutGatewayRestartStorm.mockReset();
waitForPortBindable.mockClear();
ensureDevGatewayConfig.mockClear();
runGatewayLoop.mockClear();
normalizeStateDirEnv.mockReset();
pinConfigDir.mockClear();
pinRuntimePaths.mockClear();
loadGlobalRuntimeDotEnvFiles.mockReset();
beforeRun.mockClear();
refreshManagedProxy.mockClear();
loadShellEnvFallback.mockClear();
clearShellEnvAppliedKeys.mockClear();
resolveShellEnvExpectedKeys.mockClear();
resolveShellEnvFallbackTimeoutMs.mockClear();
shouldDeferShellEnvFallback.mockReset();
shouldDeferShellEnvFallback.mockReturnValue(false);
shouldEnableShellEnvFallback.mockReset();
shouldEnableShellEnvFallback.mockReturnValue(false);
callOrder.length = 0;
});
async function runGatewayCli(argv: string[]) {
await sharedProgram.parseAsync(argv, { from: "user" });
}
async function prepareGatewayReset() {
const { prepareGatewayRunBootstrap } = await import("./pre-bootstrap.js");
return await prepareGatewayRunBootstrap({ opts: { reset: true }, runtime: defaultRuntime });
}
function callArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0): unknown {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`Expected mock call ${index}`);
}
return call[argIndex];
}
function gatewayStartOptions(index = 0) {
expect(startGatewayServer.mock.calls[index]?.[0]).toBe(18789);
return callArg(startGatewayServer, index, 1) as {
auth?: { mode?: string; token?: string; password?: string };
bind?: string;
channelAutostartSuppression?: { reason?: string; message?: string };
tryRecoverChannelAutostartSuppression?: () => boolean;
ambientEnvTriggers?: "allow" | "suppress";
startupConfigSnapshotRead?: { snapshot?: Record<string, unknown> };
startupStartedAt?: number;
};
}
function expectAuthOverrideMode(mode: string) {
expect(gatewayStartOptions().auth?.mode).toBe(mode);
}
it("composes gateway run registration through startup after the fast-path bootstrap", async () => {
normalizeStateDirEnv.mockImplementation((_env?: NodeJS.ProcessEnv) => {
callOrder.push("normalize");
});
startGatewayServer.mockImplementationOnce(async (_port: number, _opts?: unknown) => {
callOrder.push("start");
return { close: vi.fn(async () => {}) };
});
await runGatewayCli(["gateway", "--allow-unconfigured"]);
expect(beforeRun).toHaveBeenCalledOnce();
expect(callOrder).toEqual(["bootstrap", "normalize", "normalize", "start"]);
expect(runGatewayLoop).toHaveBeenCalledWith(
expect.objectContaining({ ownsProcessLifecycle: true, runtime: defaultRuntime }),
);
});
it("rejects invalid gateway ports before startup", async () => {
await expect(
runGatewayCli(["gateway", "--port", "0", "--token", "test-token"]),
).rejects.toThrow("__exit__:1");
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Invalid --port. Use a port number from 1 to 65535");
});
it.each([{ options: [] as string[] }, { options: ["--dev"] }])(
"suppresses ambient channel triggers by default with options %j",
async ({ options }) => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured", ...options]);
expect(gatewayStartOptions().ambientEnvTriggers).toBe("suppress");
},
);
it.each([
{
label: "the primary subcommand flag",
argv: ["gateway", "run", "--allow-unconfigured", "--ambient-channels"],
},
{
label: "the inherited primary flag",
argv: ["gateway", "--ambient-channels", "run", "--allow-unconfigured"],
},
{
label: "the deprecated alias",
argv: ["gateway", "run", "--allow-unconfigured", "--dev-ambient-channels"],
},
])("allows ambient channel triggers with $label", async ({ argv }) => {
await runGatewayCli(argv);
expect(gatewayStartOptions().ambientEnvTriggers).toBe("allow");
});
it("drops the pristine core fact when guarded config becomes stateful", async () => {
const initialConfig = {
gateway: { mode: "local" },
plugins: { load: { paths: ["/plugins/example"] } },
};
configState.snapshot = {
config: initialConfig,
exists: true,
hash: "initial",
parsed: initialConfig,
path: "/tmp/openclaw.json",
sourceConfig: initialConfig,
valid: true,
};
pristineStartupMigrationPlan.state.mockReturnValue({
skipAllStateMigrations: false,
skipCoreStateMigrations: true,
});
const {
prepareGatewayRunBootstrap,
selectGatewayRunEnvironment,
wasPreparedGatewayRunCoreStatePristine,
} = await import("./pre-bootstrap.js");
expect(await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime })).toBe(true);
const recoveredConfig = {
gateway: { mode: "local" },
session: { store: "/tmp/sessions.json" },
};
configState.snapshot = {
config: recoveredConfig,
exists: true,
hash: "recovered",
parsed: recoveredConfig,
path: "/tmp/openclaw.json",
sourceConfig: recoveredConfig,
valid: true,
};
expect(await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime })).toBe(true);
expect(wasPreparedGatewayRunCoreStatePristine()).toBe(false);
expect(pristineStartupMigrationPlan.config).toHaveBeenCalledWith(recoveredConfig, process.env);
});
it("refreshes the managed proxy from the final accepted config before gateway startup", async () => {
const finalConfig = {
gateway: { mode: "local" },
proxy: { enabled: true, proxyUrl: "http://127.0.0.1:29876" },
};
configState.snapshot = {
exists: true,
valid: true,
path: "/tmp/openclaw.json",
config: finalConfig,
parsed: finalConfig,
sourceConfig: finalConfig,
};
const uninstall = installGatewayRunRuntimeHooks({ refreshManagedProxy });
try {
await runGatewayCli(["gateway"]);
} finally {
uninstall();
}
expect(refreshManagedProxy).toHaveBeenCalledWith(finalConfig.proxy);
const refreshOrder = refreshManagedProxy.mock.invocationCallOrder[0] ?? 0;
const startOrder = startGatewayServer.mock.invocationCallOrder[0] ?? 0;
expect(startOrder).toBeGreaterThan(refreshOrder);
});
it("loads configured shell env fallback before final proxy refresh and gateway startup", async () => {
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: undefined }, async () => {
const finalConfig = {
env: {
shellEnv: { enabled: true, timeoutMs: 1234 },
vars: { OPENCLAW_GATEWAY_TOKEN: "config-token" },
},
gateway: {
auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" },
mode: "local",
},
proxy: { enabled: true, proxyUrl: "http://127.0.0.1:29876" },
};
configState.snapshot = {
exists: true,
valid: true,
path: "/tmp/openclaw.json",
config: finalConfig,
parsed: finalConfig,
sourceConfig: finalConfig,
};
readConfigFileSnapshotWithPluginMetadata
.mockImplementationOnce(async (options) => {
expect(options?.lowerPrecedenceEnv).toBeUndefined();
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
return { snapshot: configState.snapshot };
})
.mockImplementationOnce(async (options) => {
expect(options?.lowerPrecedenceEnv).toEqual({
OPENCLAW_GATEWAY_TOKEN: "shell-token",
});
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("shell-token");
return {
snapshot: {
...configState.snapshot,
config: {
...finalConfig,
gateway: {
...finalConfig.gateway,
auth: { mode: "token", token: "config-token" },
},
},
},
};
});
loadShellEnvFallback.mockImplementationOnce((opts?: unknown) => {
callOrder.push("shell-env");
(opts as { env: NodeJS.ProcessEnv }).env.OPENCLAW_GATEWAY_TOKEN = "shell-token";
});
const uninstall = installGatewayRunRuntimeHooks({ refreshManagedProxy });
try {
await runGatewayCli(["gateway"]);
} finally {
uninstall();
}
expect(loadShellEnvFallback).toHaveBeenCalledWith({
enabled: true,
env: process.env,
expectedKeys: ["OPENCLAW_GATEWAY_TOKEN"],
logger: expect.any(Object),
timeoutMs: 1234,
});
expect(resolveShellEnvExpectedKeys).toHaveBeenCalledWith(
expect.objectContaining({ OPENCLAW_GATEWAY_TOKEN: "config-token" }),
finalConfig,
);
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledWith(
expect.objectContaining({
lowerPrecedenceEnv: { OPENCLAW_GATEWAY_TOKEN: "shell-token" },
}),
);
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(2);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("config-token");
expect(clearShellEnvAppliedKeys).toHaveBeenCalledWith(["OPENCLAW_GATEWAY_TOKEN"]);
const shellEnvOrder = loadShellEnvFallback.mock.invocationCallOrder[0] ?? 0;
const initialConfigReadOrder =
readConfigFileSnapshotWithPluginMetadata.mock.invocationCallOrder[0] ?? 0;
const finalConfigReadOrder =
readConfigFileSnapshotWithPluginMetadata.mock.invocationCallOrder[1] ?? 0;
const refreshOrder = refreshManagedProxy.mock.invocationCallOrder[0] ?? 0;
const startOrder = startGatewayServer.mock.invocationCallOrder[0] ?? 0;
expect(shellEnvOrder).toBeGreaterThan(initialConfigReadOrder);
expect(finalConfigReadOrder).toBeGreaterThan(shellEnvOrder);
expect(refreshOrder).toBeGreaterThan(shellEnvOrder);
expect(startOrder).toBeGreaterThan(refreshOrder);
});
});
it("lets config env aliases replace canonical shell fallback values", async () => {
await withEnvAsync({ ZAI_API_KEY: undefined, Z_AI_API_KEY: undefined }, async () => {
const finalConfig = {
env: {
shellEnv: { enabled: true },
vars: { Z_AI_API_KEY: "config-key" },
},
gateway: { auth: { mode: "none" }, mode: "local" },
};
configState.snapshot = {
config: finalConfig,
exists: true,
parsed: finalConfig,
path: "/tmp/openclaw.json",
sourceConfig: finalConfig,
valid: true,
};
resolveShellEnvExpectedKeys
.mockReturnValueOnce(["ZAI_API_KEY"])
.mockReturnValueOnce(["ZAI_API_KEY"]);
loadShellEnvFallback.mockImplementationOnce((opts?: unknown) => {
(opts as { env: NodeJS.ProcessEnv }).env.ZAI_API_KEY = "shell-key";
});
await runGatewayCli(["gateway"]);
expect(process.env.Z_AI_API_KEY).toBe("config-key");
expect(process.env.ZAI_API_KEY).toBe("config-key");
expect(clearShellEnvAppliedKeys).toHaveBeenCalledWith(["ZAI_API_KEY"]);
});
});
it("removes shell fallback values when the final accepted config disables fallback", async () => {
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: undefined }, async () => {
const enabledConfig = {
env: { shellEnv: { enabled: true } },
gateway: { auth: { mode: "none" }, mode: "local" },
};
const disabledConfig = {
gateway: { auth: { mode: "none" }, mode: "local" },
};
const snapshot = (config: Record<string, unknown>) => ({
config,
exists: true,
parsed: config,
path: "/tmp/openclaw.json",
sourceConfig: config,
valid: true,
});
readConfigFileSnapshotWithPluginMetadata
.mockResolvedValueOnce({ snapshot: snapshot(enabledConfig) })
.mockImplementationOnce(async (options) => {
expect(options?.lowerPrecedenceEnv).toEqual({
OPENCLAW_GATEWAY_TOKEN: "shell-token",
});
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("shell-token");
return { snapshot: snapshot(disabledConfig) };
})
.mockImplementationOnce(async (options) => {
expect(options?.lowerPrecedenceEnv).toBeUndefined();
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
return { snapshot: snapshot(disabledConfig) };
});
loadShellEnvFallback.mockImplementationOnce((opts?: unknown) => {
(opts as { env: NodeJS.ProcessEnv }).env.OPENCLAW_GATEWAY_TOKEN = "shell-token";
});
await runGatewayCli(["gateway"]);
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(3);
expect(loadShellEnvFallback).toHaveBeenCalledOnce();
expect(clearShellEnvAppliedKeys).toHaveBeenCalledWith(["OPENCLAW_GATEWAY_TOKEN"]);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(startGatewayServer).toHaveBeenCalledOnce();
});
});
it("uses config env shell fallback controls without mutating the live env during planning", async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_LOAD_SHELL_ENV: undefined,
OPENCLAW_SHELL_ENV_TIMEOUT_MS: undefined,
},
async () => {
const finalConfig = {
env: {
vars: {
OPENCLAW_LOAD_SHELL_ENV: "1",
OPENCLAW_SHELL_ENV_TIMEOUT_MS: "4321",
},
},
gateway: { auth: { mode: "none" }, mode: "local" },
};
configState.snapshot = {
config: finalConfig,
exists: true,
parsed: finalConfig,
path: "/tmp/openclaw.json",
sourceConfig: finalConfig,
valid: true,
};
shouldEnableShellEnvFallback.mockImplementationOnce(
(env?: NodeJS.ProcessEnv) => env?.OPENCLAW_LOAD_SHELL_ENV === "1",
);
resolveShellEnvFallbackTimeoutMs.mockImplementationOnce((env?: NodeJS.ProcessEnv) =>
Number(env?.OPENCLAW_SHELL_ENV_TIMEOUT_MS),
);
await runGatewayCli(["gateway"]);
expect(loadShellEnvFallback).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, timeoutMs: 4321 }),
);
expect(process.env.OPENCLAW_LOAD_SHELL_ENV).toBe("1");
expect(process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS).toBe("4321");
},
);
});
it("honors config env shell fallback deferral", async () => {
await withEnvAsync(
{
OPENCLAW_DEFER_SHELL_ENV_FALLBACK: undefined,
OPENCLAW_LOAD_SHELL_ENV: undefined,
},
async () => {
const finalConfig = {
env: {
vars: {
OPENCLAW_DEFER_SHELL_ENV_FALLBACK: "1",
OPENCLAW_LOAD_SHELL_ENV: "1",
},
},
gateway: { auth: { mode: "none" }, mode: "local" },
};
configState.snapshot = {
config: finalConfig,
exists: true,
parsed: finalConfig,
path: "/tmp/openclaw.json",
sourceConfig: finalConfig,
valid: true,
};
shouldEnableShellEnvFallback.mockImplementationOnce(
(env?: NodeJS.ProcessEnv) => env?.OPENCLAW_LOAD_SHELL_ENV === "1",
);
shouldDeferShellEnvFallback.mockImplementationOnce(
(env?: NodeJS.ProcessEnv) => env?.OPENCLAW_DEFER_SHELL_ENV_FALLBACK === "1",
);
await runGatewayCli(["gateway"]);
expect(resolveShellEnvExpectedKeys).not.toHaveBeenCalled();
expect(loadShellEnvFallback).not.toHaveBeenCalled();
},
);
});
it("ignores shell fallback controls from invalid config", async () => {
const { clearGatewayRunConfigEnvironment } = await import("./pre-bootstrap.js");
clearGatewayRunConfigEnvironment();
await withEnvAsync(
{
OPENCLAW_DEFER_SHELL_ENV_FALLBACK: undefined,
OPENCLAW_LOAD_SHELL_ENV: "1",
},
async () => {
const invalidConfig = {
env: { vars: { OPENCLAW_DEFER_SHELL_ENV_FALLBACK: "1" } },
gateway: { mode: "local" },
};
configState.snapshot = {
config: invalidConfig,
exists: true,
issues: [{ path: "gateway", message: "invalid" }],
parsed: invalidConfig,
path: "/tmp/openclaw.json",
sourceConfig: invalidConfig,
valid: false,
};
shouldEnableShellEnvFallback.mockImplementation(
(env?: NodeJS.ProcessEnv) => env?.OPENCLAW_LOAD_SHELL_ENV === "1",
);
shouldDeferShellEnvFallback.mockImplementation(
(env?: NodeJS.ProcessEnv) => env?.OPENCLAW_DEFER_SHELL_ENV_FALLBACK === "1",
);
await runGatewayCli(["gateway", "--allow-unconfigured"]);
expect(loadShellEnvFallback).toHaveBeenCalledOnce();
expect(startGatewayServer).toHaveBeenCalledOnce();
},
);
});
it("admits deterministic legacy repairs to gateway preflight and rejects unrelated drift", async () => {
const selectedStateDir = "/tmp/openclaw-stable-upgrade-state";
await withEnvAsync({ OPENCLAW_STATE_DIR: undefined }, async () => {
const stableConfig = {
meta: {
lastTouchedAt: "2026-08-01T00:00:00.000Z",
lastTouchedVersion: "2026.7.1-2",
},
agents: {
defaults: { heartbeat: { skipWhenBusy: true } },
entries: { main: {} },
},
env: { vars: { OPENCLAW_STATE_DIR: selectedStateDir } },
gateway: { mode: "local" },
session: { idleMinutes: 45 },
};
configState.snapshot = {
config: stableConfig,
runtimeConfig: stableConfig,
exists: true,
issues: [
{ path: "meta", message: "retired" },
{ path: "agents.defaults.heartbeat", message: "retired" },
{ path: "session.idleMinutes", message: "retired" },
],
legacyIssues: [{ path: "", message: "retired" }],
parsed: stableConfig,
path: "/tmp/openclaw.json",
raw: JSON.stringify(stableConfig),
resolved: stableConfig,
sourceConfig: stableConfig,
valid: false,
warnings: [],
};
const {
prepareGatewayRunBootstrap,
recheckGatewayRunBootstrap,
selectGatewayRunEnvironment,
} = await import("./pre-bootstrap.js");
expect(await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime })).toBe(true);
expect(await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime })).toBe(true);
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
const repairedConfig = {
agents: { defaults: {}, entries: { main: {} } },
env: stableConfig.env,
gateway: { mode: "local" as const },
session: { reset: { mode: "idle", idleMinutes: 45 } },
meta: {
lastTouchedVersion: VERSION,
migrations: { modelPolicyAllowlist: true },
},
} satisfies ConfigFileSnapshot["sourceConfig"];
const repairedSnapshot = {
config: repairedConfig,
exists: true,
issues: [],
legacyIssues: [],
parsed: repairedConfig,
path: "/tmp/openclaw.json",
raw: JSON.stringify(repairedConfig),
resolved: repairedConfig,
runtimeConfig: repairedConfig,
sourceConfig: repairedConfig,
valid: true,
warnings: [],
} satisfies ConfigFileSnapshot;
expect(
await recheckGatewayRunBootstrap({
opts: {},
runtime: defaultRuntime,
snapshot: repairedSnapshot,
}),
).toBe(true);
await expect(
recheckGatewayRunBootstrap({
opts: {},
runtime: defaultRuntime,
snapshot: {
...repairedSnapshot,
sourceConfig: { ...repairedConfig, gateway: { mode: "remote" } },
},
}),
).rejects.toMatchObject({ code: 1 });
});
});
it("rejects an invalid final config after a prepared config selected runtime paths", async () => {
const selectedStateDir = "/tmp/openclaw-prepared-selected-state";
await withEnvAsync({ OPENCLAW_STATE_DIR: undefined }, async () => {
const selectedConfig = {
env: { vars: { OPENCLAW_STATE_DIR: selectedStateDir } },
gateway: { mode: "local" },
};
configState.snapshot = {
config: selectedConfig,
exists: true,
parsed: selectedConfig,
path: "/tmp/openclaw.json",
sourceConfig: selectedConfig,
valid: true,
};
const {
applyFinalGatewayRunConfigEnv,
prepareGatewayRunBootstrap,
selectGatewayRunEnvironment,
} = await import("./pre-bootstrap.js");
expect(await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime })).toBe(true);
expect(await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime })).toBe(true);
expect(process.env.OPENCLAW_STATE_DIR).toBe(selectedStateDir);
const invalidSnapshot = {
...configState.snapshot,
issues: [{ message: "invalid", path: "gateway" }],
valid: false,
};
await expect(
applyFinalGatewayRunConfigEnv({
runtime: defaultRuntime,
snapshot: invalidSnapshot as ConfigFileSnapshot,
}),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toContain("final config read became invalid");
expect(startGatewayServer).not.toHaveBeenCalled();
});
});
it("replaces config-derived env when the final startup snapshot changes in place", async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_PROXY_URL: undefined,
OPENCLAW_RAW_STREAM: undefined,
},
async () => {
const oldConfig = {
env: {
vars: {
OPENCLAW_GATEWAY_TOKEN: "old-token",
OPENCLAW_PROXY_URL: "http://127.0.0.1:19876",
OPENCLAW_RAW_STREAM: "1",
},
},
gateway: { mode: "local" },
};
const newConfig = {
env: { vars: { OPENCLAW_GATEWAY_TOKEN: "new-token" } },
gateway: { mode: "local" },
};
configState.snapshot = {
config: oldConfig,
exists: true,
hash: "old",
path: "/tmp/openclaw.json",
sourceConfig: oldConfig,
valid: true,
};
const { prepareGatewayRunBootstrap, selectGatewayRunEnvironment } =
await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime });
expect(pinRuntimePaths).toHaveBeenCalledWith(process.env);
expect(pinConfigDir).toHaveBeenCalledWith(process.env);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("old-token");
expect(process.env.OPENCLAW_PROXY_URL).toBe("http://127.0.0.1:19876");
configState.snapshot = {
config: newConfig,
exists: true,
hash: "new",
path: "/tmp/openclaw.json",
sourceConfig: newConfig,
valid: true,
};
readConfigFileSnapshotWithPluginMetadata.mockImplementationOnce(async () => {
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(process.env.OPENCLAW_PROXY_URL).toBeUndefined();
return { snapshot: configState.snapshot };
});
await runGatewayCli(["gateway", "--raw-stream"]);
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("new-token");
expect(process.env.OPENCLAW_PROXY_URL).toBeUndefined();
expect(process.env.OPENCLAW_RAW_STREAM).toBe("1");
},
);
});
it("forwards parent-captured options to `gateway run` subcommand", async () => {
normalizeStateDirEnv.mockImplementation((_env?: NodeJS.ProcessEnv) => {
callOrder.push("normalize");
});
startGatewayServer.mockImplementationOnce(async (_port: number, _opts?: unknown) => {
callOrder.push("start");
return { close: vi.fn(async () => {}) };
});
await runGatewayCli([
"gateway",
"run",
"--token",
"tok_run",
"--allow-unconfigured",
"--ws-log",
"full",
"--force",
]);
expect(callArg(forceFreePortAndWait, 0, 0)).toBe(18789);
expect(callArg(waitForPortBindable, 0, 0)).toBe(18789);
expect(
callArg(waitForPortBindable, 0, 1) as { intervalMs?: number; timeoutMs?: number },
).toEqual({ intervalMs: 150, timeoutMs: 3000 });
expect(setGatewayWsLogStyle).toHaveBeenCalledWith("full");
expect(gatewayStartOptions().auth?.token).toBe("tok_run");
expect(normalizeStateDirEnv).toHaveBeenCalledWith(process.env);
expect(callOrder).toEqual(["bootstrap", "normalize", "normalize", "start"]);
});
it("refuses non-interactive --force when a verified gateway appears before signaling", async () => {
isTerminalInteractive.mockReturnValue(false);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValueOnce([]).mockReturnValueOnce([4242]);
forceFreePortAndWait.mockImplementationOnce(async (_port, opts) => {
(opts as { beforeSignal?: () => void }).beforeSignal?.();
return { killed: [], waitedMs: 0, escalatedToSigkill: false };
});
await expect(
runGatewayCli(["gateway", "run", "--allow-unconfigured", "--force"]),
).rejects.toThrow("__exit__:1");
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
expect(forceFreePortAndWait).toHaveBeenCalledTimes(1);
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("openclaw gateway run --dev");
expect(runtimeErrors.join("\n")).toContain("--profile <name> with a free port");
});
it("reports forced port cleanup failures before startup", async () => {
forceFreePortAndWait.mockRejectedValueOnce(new Error("boom"));
await expect(
runGatewayCli(["gateway", "run", "--allow-unconfigured", "--force"]),
).rejects.toThrow("__exit__:1");
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Could not free port 18789: boom");
expect(runtimeErrors.join("\n")).toContain("openclaw gateway status --deep");
});
it("marks service-mode gateway descendants with the live gateway pid", async () => {
await withEnvAsync(
{
OPENCLAW_SERVICE_MARKER: "openclaw",
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: undefined,
},
async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBe(String(process.pid));
},
);
expect(normalizeStateDirEnv).toHaveBeenCalledWith(process.env);
});
it.each([
{ platform: "darwin", managed: true, warns: true },
{ platform: "darwin", managed: false, warns: false },
{ platform: "linux", managed: true, warns: false },
] as const)(
"reports restart storms before server startup only for managed macOS Gateways ($platform, managed=$managed)",
async ({ platform, managed, warns }) => {
const warning = "Gateway restart storm: inspect launchd jobs with openclaw gateway status.";
warnAboutGatewayRestartStorm.mockImplementation(async (_env, warn) => warn(warning));
startGatewayServer.mockImplementationOnce(async () => {
expect(gatewayLogMessages.includes(warning)).toBe(warns);
return { close: vi.fn(async () => {}) };
});
await withMockedPlatform(platform, () =>
withEnvAsync({ OPENCLAW_SERVICE_MARKER: managed ? "openclaw" : undefined }, async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
}),
);
expect(startGatewayServer).toHaveBeenCalledTimes(1);
},
);
it("protects the inherited service pid before replacing it", async () => {
await withEnvAsync(
{
OPENCLAW_SERVICE_MARKER: "openclaw",
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: "4242",
},
async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(18789, {
protectedPid: 4242,
});
expect(process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBe(String(process.pid));
},
);
});
it("marks descendants when the final config supplies the service marker", async () => {
await withEnvAsync(
{
OPENCLAW_SERVICE_MARKER: undefined,
[GATEWAY_SERVICE_RUNTIME_PID_ENV]: undefined,
},
async () => {
const finalConfig = {
env: { vars: { OPENCLAW_SERVICE_MARKER: "openclaw" } },
gateway: { mode: "local" },
};
configState.snapshot = {
config: finalConfig,
exists: true,
path: "/tmp/openclaw.json",
sourceConfig: finalConfig,
valid: true,
};
await runGatewayCli(["gateway"]);
expect(process.env.OPENCLAW_SERVICE_MARKER).toBe("openclaw");
expect(process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV]).toBe(String(process.pid));
},
);
});
it("rechecks future config after the final config enters service mode", async () => {
await withEnvAsync(
{
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: "1",
OPENCLAW_SERVICE_MARKER: undefined,
},
async () => {
const finalConfig = {
env: { vars: { OPENCLAW_SERVICE_MARKER: "openclaw" } },
gateway: { mode: "local" },
meta: { lastTouchedVersion: "9999.1.1" },
};
configState.cfg = finalConfig;
configState.snapshot = {
config: finalConfig,
exists: true,
path: "/tmp/openclaw.json",
sourceConfig: finalConfig,
valid: true,
};
await expect(runGatewayCli(["gateway"])).rejects.toThrow("__exit__:78");
expect(process.env.OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS).toBeUndefined();
expect(process.env.OPENCLAW_SERVICE_MARKER).toBeUndefined();
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("start the gateway service");
},
);
});
it("blocks --force port cleanup from an older binary with newer config", async () => {
configState.snapshot = {
exists: true,
valid: true,
config: { meta: { lastTouchedVersion: "9999.1.1" } },
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
};
await expect(
runGatewayCli(["gateway", "run", "--allow-unconfigured", "--force"]),
).rejects.toThrow("__exit__:1");
expect(forceFreePortAndWait).not.toHaveBeenCalled();
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Refusing to force-kill gateway port listeners");
});
it("blocks service-mode startup from an older binary with newer config", async () => {
configState.snapshot = {
exists: true,
valid: true,
config: { meta: { lastTouchedVersion: "9999.1.1" } },
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
};
const previousMarker = process.env.OPENCLAW_SERVICE_MARKER;
process.env.OPENCLAW_SERVICE_MARKER = "gateway";
try {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
} finally {
if (previousMarker === undefined) {
delete process.env.OPENCLAW_SERVICE_MARKER;
} else {
process.env.OPENCLAW_SERVICE_MARKER = previousMarker;
}
}
expect(forceFreePortAndWait).not.toHaveBeenCalled();
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Refusing to start the gateway service");
});
it("blocks dev reset from an older binary before deleting state", async () => {
configState.snapshot = {
exists: true,
valid: true,
config: { meta: { lastTouchedVersion: "9999.1.1" } },
sourceConfig: { meta: { lastTouchedVersion: "9999.1.1" } },
};
await expect(prepareGatewayReset()).rejects.toThrow("__exit__:1");
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Refusing to reset the dev gateway state");
});
it("blocks dev reset when parseable future-version metadata is schema-invalid", async () => {
configState.snapshot = {
config: {},
exists: true,
issues: [{ message: "unknown newer field", path: "gateway.newerField" }],
parsed: { gateway: { newerField: true }, meta: { lastTouchedVersion: "9999.1.1" } },
sourceConfig: {
gateway: { newerField: true },
meta: { lastTouchedVersion: "9999.1.1" },
},
valid: false,
};
await expect(prepareGatewayReset()).rejects.toThrow("__exit__:1");
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain("Refusing to reset the dev gateway state");
});
it("does not retain targets or credentials from the config deleted by dev reset", async () => {
await withEnvAsync(
{
OPENCLAW_CONFIG_PATH: undefined,
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_HOME: undefined,
OPENCLAW_PROFILE: undefined,
OPENCLAW_STATE_DIR: undefined,
OPENCLAW_WORKSPACE_DIR: undefined,
},
async () => {
configState.snapshot = {
exists: true,
valid: true,
config: { gateway: { mode: "local" } },
sourceConfig: {
env: {
vars: {
OPENCLAW_CONFIG_PATH: "/tmp/openclaw-reset/openclaw.json",
OPENCLAW_GATEWAY_TOKEN: "old-token",
OPENCLAW_HOME: "/tmp/openclaw-reset-home",
OPENCLAW_STATE_DIR: "/tmp/openclaw-reset",
},
},
gateway: { mode: "local" },
},
};
ensureDevGatewayConfig.mockImplementationOnce(async () => {
expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined();
expect(process.env.OPENCLAW_HOME).toBeUndefined();
expect(process.env.OPENCLAW_PROFILE).toBe("dev");
expect(process.env.OPENCLAW_STATE_DIR).toBeUndefined();
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(process.env.OPENCLAW_WORKSPACE_DIR).toBe("/tmp/openclaw-reset-workspace");
configState.snapshot = {
exists: true,
valid: true,
config: { gateway: { mode: "local" } },
sourceConfig: { gateway: { mode: "local" } },
};
});
loadGlobalRuntimeDotEnvFiles.mockImplementation(() => {
process.env.OPENCLAW_GATEWAY_TOKEN ??= "trusted-token";
process.env.OPENCLAW_PROFILE ??= "dev";
if (process.env.OPENCLAW_WORKSPACE_DIR === undefined) {
setTestEnvValue("OPENCLAW_WORKSPACE_DIR", "/tmp/openclaw-reset-workspace");
}
});
await prepareGatewayReset();
await runGatewayCli(["gateway", "run", "--allow-unconfigured", "--dev", "--reset"]);
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
expect(process.env.OPENCLAW_GATEWAY_TOKEN).toBe("trusted-token");
expect(loadGlobalRuntimeDotEnvFiles).toHaveBeenCalled();
},
);
});
it("refuses dev reset if trusted dotenv retargets after pre-bootstrap", async () => {
await withEnvAsync({ OPENCLAW_STATE_DIR: "/tmp/openclaw-reset-original" }, async () => {
configState.snapshot = {
config: { gateway: { mode: "local" } },
exists: true,
path: "/tmp/openclaw-reset-original/openclaw.json",
sourceConfig: { gateway: { mode: "local" } },
valid: true,
};
await prepareGatewayReset();
loadGlobalRuntimeDotEnvFiles.mockImplementation(() => {
setTestEnvValue("OPENCLAW_STATE_DIR", "/tmp/openclaw-reset-retargeted");
return {
dotenvPresentKeys: ["OPENCLAW_STATE_DIR"],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: ["OPENCLAW_STATE_DIR"],
};
});
await expect(
runGatewayCli(["gateway", "run", "--allow-unconfigured", "--dev", "--reset"]),
).rejects.toThrow("__exit__:1");
expect(ensureDevGatewayConfig).not.toHaveBeenCalled();
expect(process.env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-reset-original");
expect(runtimeErrors.join("\n")).toContain(
"selected config or state target changed during startup",
);
});
});
it.each([
"OPENCLAW_AGENT_DIR",
"OPENCLAW_INCLUDE_ROOTS",
"OPENCLAW_NIX_MODE",
"OPENCLAW_OAUTH_DIR",
"OPENCLAW_PACKAGE_DIR",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_WORKSPACE_DIR",
"PI_CODING_AGENT_DIR",
])("blocks trusted dotenv selector drift for %s after startup mutations", async (selector) => {
await withEnvAsync({ [selector]: "/tmp/openclaw-reset-value" }, async () => {
loadGlobalRuntimeDotEnvFiles.mockImplementation(() => {
setTestEnvValue(selector, "/tmp/openclaw-reset-retargeted");
});
const { reloadTrustedGatewayRunEnvironment } = await import("./pre-bootstrap.js");
await expect(reloadTrustedGatewayRunEnvironment({ runtime: defaultRuntime })).rejects.toThrow(
"__exit__:1",
);
expect(process.env[selector]).toBe("/tmp/openclaw-reset-value");
expect(runtimeErrors.join("\n")).toContain(
"trusted dotenv reload after startup mutations changed config or state selection",
);
});
});
it("blocks a final startup snapshot that changes guarded config selection", async () => {
await withEnvAsync({ OPENCLAW_STATE_DIR: undefined }, async () => {
configState.snapshot = {
exists: true,
valid: true,
config: { gateway: { mode: "local" } },
sourceConfig: {
env: { vars: { OPENCLAW_STATE_DIR: "/tmp/openclaw-late-selection" } },
gateway: { mode: "local" },
},
};
await expect(runGatewayCli(["gateway", "run"])).rejects.toThrow("__exit__:1");
expect(process.env.OPENCLAW_STATE_DIR).toBeUndefined();
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"final config read changed config or state selection",
);
});
});
it("blocks a final startup snapshot that changes an already-selected config selector", async () => {
await withEnvAsync({ OPENCLAW_STATE_DIR: undefined }, async () => {
const guardedConfig = {
env: { vars: { OPENCLAW_STATE_DIR: "/tmp/openclaw-guarded-state" } },
gateway: { mode: "local" },
};
configState.snapshot = {
config: guardedConfig,
exists: true,
hash: "guarded",
path: "/tmp/openclaw.json",
sourceConfig: guardedConfig,
valid: true,
};
const { prepareGatewayRunBootstrap, selectGatewayRunEnvironment } =
await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime });
expect(process.env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-guarded-state");
const finalConfig = {
env: { vars: { OPENCLAW_STATE_DIR: "/tmp/openclaw-final-state" } },
gateway: { mode: "local" },
};
configState.snapshot = {
config: finalConfig,
exists: true,
hash: "final",
path: "/tmp/openclaw.json",
sourceConfig: finalConfig,
valid: true,
};
await expect(runGatewayCli(["gateway", "run"])).rejects.toThrow("__exit__:1");
expect(process.env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-guarded-state");
expect(startGatewayServer).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"final config read changed config or state selection",
);
});
});
it.each([
["--cli-backend-logs", "generic flag"],
["--claude-cli-logs", "deprecated alias"],
])("enables CLI backend log filtering via %s (%s)", async (flag) => {
delete process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT;
await runGatewayCli(["gateway", "run", flag, "--allow-unconfigured"]);
expect(setConsoleSubsystemFilter).toHaveBeenCalledWith(["agent/cli-backend"]);
expect(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT).toBe("1");
});
it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => {
await withEnvAsync(withoutGatewayAuthEnv, async () => {
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
});
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1);
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledWith({
isolateEnv: true,
observe: false,
});
expect(resolveShellEnvExpectedKeys).not.toHaveBeenCalled();
expect(readBestEffortConfig).not.toHaveBeenCalled();
const options = gatewayStartOptions();
expect(options.bind).toBe("loopback");
expect(options.startupConfigSnapshotRead).toEqual({ snapshot: configState.snapshot });
});
it("allows authless auto startup when it resolves to loopback", async () => {
await withEnvAsync(withoutGatewayAuthEnv, async () => {
await runGatewayCli(["gateway", "run", "--bind", "auto", "--allow-unconfigured"]);
});
const options = gatewayStartOptions();
expect(options.bind).toBe("auto");
});
it("blocks container auto startup without explicit gateway auth", async () => {
netState.autoBindHost = "0.0.0.0";
netState.container = true;
await withEnvAsync(withoutGatewayAuthEnv, async () => {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
});
expect(runtimeErrors.join("\n")).toContain("Refusing to bind gateway to auto without auth.");
expect(startGatewayServer).not.toHaveBeenCalled();
});
it("blocks non-loopback startup without explicit gateway auth", async () => {
await withEnvAsync(withoutGatewayAuthEnv, async () => {
await expect(
runGatewayCli(["gateway", "run", "--bind", "lan", "--allow-unconfigured"]),
).rejects.toThrow("__exit__:78");
});
expect(runtimeErrors.join("\n")).toContain("Refusing to bind gateway to lan without auth.");
expect(startGatewayServer).not.toHaveBeenCalled();
});
it("allows non-loopback startup when token auth is explicit", async () => {
await runGatewayCli([
"gateway",
"run",
"--bind",
"lan",
"--token",
"tok_run",
"--allow-unconfigured",
]);
const options = gatewayStartOptions();
expect(options.bind).toBe("lan");
expect(options.auth?.token).toBe("tok_run");
});
it("uses the startup snapshot only for the first in-process gateway start", async () => {
runGatewayLoop.mockImplementationOnce(async ({ start }: { start: GatewayLoopStart }) => {
await start({ startupStartedAt: 1000 });
await start({ startupStartedAt: 2000 });
});
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(startGatewayServer).toHaveBeenCalledTimes(2);
const firstOptions = gatewayStartOptions(0);
expect(firstOptions.startupStartedAt).toBe(1000);
expect(firstOptions.startupConfigSnapshotRead).toEqual({ snapshot: configState.snapshot });
const secondOptions = gatewayStartOptions(1);
expect(secondOptions.startupConfigSnapshotRead).toBeUndefined();
expect(secondOptions.startupStartedAt).toBe(2000);
});
it("lets gateway bootstrap refresh inherited service-managed dotenv keys", async () => {
detectRespawnSupervisor.mockReturnValue("systemd");
await withMockedPlatform("linux", () =>
withEnvAsync(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY,ANTHROPIC_API_KEY",
},
async () => {
const { prepareGatewayRunBootstrap, selectGatewayRunEnvironment } =
await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime });
},
),
);
expect(loadGlobalRuntimeDotEnvFiles).toHaveBeenCalledWith(
expect.objectContaining({
overrideKeys: new Set(["OPENAI_API_KEY", "ANTHROPIC_API_KEY"]),
}),
);
});
it("limits inherited service-managed dotenv refresh to systemd launches", async () => {
const serviceManagedEnv = await import("../../daemon/service-managed-env.js");
detectRespawnSupervisor.mockReturnValueOnce("systemd");
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY",
},
"linux",
),
).toEqual(new Set(["OPENAI_API_KEY"]));
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{ OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY" },
"linux",
),
).toEqual(new Set());
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{ OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY" },
"darwin",
),
).toEqual(new Set());
expect(
serviceManagedEnv.readManagedSystemdServiceEnvKeysFromEnvironment(
{ OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "OPENAI_API_KEY" },
"win32",
),
).toEqual(new Set());
});
it("clears only missing managed keys after reading the selected config", async () => {
detectRespawnSupervisor.mockReturnValue("systemd");
configState.snapshot = {
config: {},
exists: true,
sourceConfig: {
models: {
providers: {
openai: {
apiKey: { source: "env", id: "SECRET_REF_KEY" },
},
},
},
},
valid: true,
};
loadGlobalRuntimeDotEnvFiles.mockReturnValue({
dotenvPresentKeys: [],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: [],
});
await withMockedPlatform("linux", () =>
withEnvAsync(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "REMOVED_KEY,SECRET_REF_KEY",
REMOVED_KEY: "stale-service-value",
SECRET_REF_KEY: "file-backed-value",
OPENAI_API_KEY: "operator-owned-provider-key",
OPERATOR_KEY: "operator-value",
},
async () => {
const { prepareGatewayRunBootstrap, selectGatewayRunEnvironment } =
await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
expect(process.env.REMOVED_KEY).toBeUndefined();
expect(process.env.SECRET_REF_KEY).toBe("file-backed-value");
expect(process.env.OPENAI_API_KEY).toBe("operator-owned-provider-key");
expect(process.env.OPERATOR_KEY).toBe("operator-value");
await prepareGatewayRunBootstrap({ opts: {}, runtime: defaultRuntime });
},
),
);
});
it("keeps managed keys referenced by shorthand when startup repairs the config", async () => {
detectRespawnSupervisor.mockReturnValue("systemd");
const { createConfigResolutionFacts, setConfigResolutionFacts } =
await import("../../config/resolution-facts.js");
// A repairable legacy key sends this boot through startup repair, which rebuilds sourceConfig
// as a clone. Reading the preserve set off the rebuilt object alone loses the recorded name.
const sourceConfig = {
session: { idleMinutes: 45 },
models: { providers: { minimax: { apiKey: "substituted-not-a-real-key" } } },
};
setConfigResolutionFacts(
sourceConfig,
createConfigResolutionFacts(
[],
new Map(),
"default",
new Map([["models.providers.minimax.apiKey", "SHORTHAND_KEY"]]),
),
);
configState.snapshot = {
path: "/tmp/openclaw.json",
includedPaths: [],
exists: true,
raw: JSON.stringify(sourceConfig),
parsed: sourceConfig,
config: sourceConfig,
sourceConfig,
valid: false,
issues: [{ path: "session.idleMinutes", message: "retired" }],
legacyIssues: [{ path: "", message: "retired" }],
};
loadGlobalRuntimeDotEnvFiles.mockReturnValue({
dotenvPresentKeys: [],
gatewayEnvAppliedKeys: [],
stateEnvAppliedKeys: [],
});
await withMockedPlatform("linux", () =>
withEnvAsync(
{
INVOCATION_ID: "systemd-invocation",
OPENCLAW_SERVICE_MANAGED_ENV_KEYS: "SHORTHAND_KEY,REMOVED_KEY",
SHORTHAND_KEY: "environment-file-value",
REMOVED_KEY: "stale-service-value",
},
async () => {
const { selectGatewayRunEnvironment } = await import("./pre-bootstrap.js");
await selectGatewayRunEnvironment({ opts: {}, runtime: defaultRuntime });
expect(process.env.SHORTHAND_KEY).toBe("environment-file-value");
expect(process.env.REMOVED_KEY).toBeUndefined();
},
),
);
});
it("re-inspects crash-loop breaker state for each boot iteration", async () => {
let firstBootRecovery: (() => boolean) | undefined;
bootLifecycle.record.mockReturnValueOnce("boot-1").mockReturnValueOnce("boot-2");
runGatewayLoop.mockImplementationOnce(
async ({
beginBoot,
start,
}: {
beginBoot?: (startedAtMs: number) => Promise<void> | void;
start: GatewayLoopStart;
}) => {
await beginBoot?.(1000);
await start({ startupStartedAt: 1000 });
firstBootRecovery = gatewayStartOptions(0).tryRecoverChannelAutostartSuppression;
await beginBoot?.(2000);
await start({ startupStartedAt: 2000 });
},
);
bootLifecycle.decisions.push(
{
tripped: true,
uncleanBoots: 3,
windowMs: 300_000,
shouldWriteStabilityBundle: true,
recovered: false,
},
{
tripped: false,
uncleanBoots: 0,
windowMs: 300_000,
shouldWriteStabilityBundle: false,
recovered: true,
},
);
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(bootLifecycle.inspect).toHaveBeenCalledTimes(2);
expect(bootLifecycle.inspect.mock.calls.map((call) => call[1])).toEqual([1000, 2000]);
expect(bootLifecycle.record.mock.calls.map((call) => call[2])).toEqual([
"gateway.crash_loop_breaker",
"gateway.crash_loop_recovered",
]);
expect(writeDiagnosticStabilityBundleForFailureSync).toHaveBeenCalledTimes(1);
expect(gatewayStartOptions(0).channelAutostartSuppression).toMatchObject({
reason: "crash-loop-breaker",
});
expect(gatewayStartOptions(0).channelAutostartSuppression?.message).toContain(
bootLifecycle.manualChannelStartHint,
);
expect(gatewayStartOptions(1).channelAutostartSuppression).toBeUndefined();
bootLifecycle.decisions.push({
tripped: false,
uncleanBoots: 0,
windowMs: 300_000,
shouldWriteStabilityBundle: false,
recovered: true,
});
expect(firstBootRecovery?.()).toBe(false);
expect(bootLifecycle.inspect).toHaveBeenCalledTimes(2);
expect(bootLifecycle.recover).not.toHaveBeenCalled();
expect(gatewayLogMessages.some((message) => message.includes("breaker recovered"))).toBe(true);
});
it.each([
{ supervised: false, transition: false, recorded: true, attempts: 1 },
{ supervised: false, transition: false, recorded: false, attempts: 0 },
{ supervised: true, transition: false, recorded: true, attempts: 0 },
{ supervised: true, transition: true, recorded: true, attempts: 1 },
{ supervised: true, transition: true, recorded: false, attempts: 0 },
{ supervised: false, transition: false, recorded: true, attempts: 0, cleanupFailure: "direct" },
{ supervised: true, transition: true, recorded: true, attempts: 0, cleanupFailure: "wrapped" },
])(
"triages failed starts once with supervisor transition gating: %j",
async ({ supervised, transition, recorded, attempts, cleanupFailure }) => {
triageAfterFailure.mockClear();
detectRespawnSupervisor.mockReturnValue(supervised ? "systemd" : null);
bootLifecycle.record.mockReturnValueOnce(recorded ? "boot-id" : undefined);
bootLifecycle.decisions.push({
tripped: transition,
uncleanBoots: transition ? 3 : 0,
windowMs: 300_000,
shouldWriteStabilityBundle: transition,
recovered: false,
});
let failure: Error = new Error("configured plugin crashed during startup");
if (cleanupFailure) {
const { GatewayStartupCleanupError } = await import("../../gateway/server-shutdown.js");
failure = new GatewayStartupCleanupError(
failure,
new Error("required cleanup unconfirmed"),
);
if (cleanupFailure === "wrapped") {
failure = new Error("startup wrapper failed", { cause: failure });
}
}
runGatewayLoop.mockImplementationOnce(
async (
params: GatewayLoopParams & {
beginBoot?: (now: number) => Promise<void>;
onRestartStartupFailure?: (error: unknown, signal: AbortSignal) => Promise<void>;
},
) => {
await params.beginBoot?.(1000);
// Repeated in-process failures and the terminal catch share one handoff.
await params.onRestartStartupFailure?.(failure, new AbortController().signal);
await params.onRestartStartupFailure?.(failure, new AbortController().signal);
throw failure;
},
);
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:1",
);
expect(triageAfterFailure).toHaveBeenCalledTimes(attempts);
if (attempts) {
expect(triageAfterFailure).toHaveBeenCalledWith(
defaultRuntime,
expect.objectContaining({
kind: "gateway-startup",
error: failure.message,
gateway: "verify-running",
}),
expect.any(AbortSignal),
);
}
expect(runtimeErrors.join("\n")).toContain(failure.message);
},
);
it("recovers channel autostart only after the full breaker window drains", async () => {
runGatewayLoop.mockImplementationOnce(
async ({
beginBoot,
start,
}: {
beginBoot?: (startedAtMs: number) => Promise<void> | void;
start: GatewayLoopStart;
}) => {
await beginBoot?.(1000);
await start({ startupStartedAt: 1000 });
},
);
bootLifecycle.decisions.push({
tripped: true,
uncleanBoots: 3,
windowMs: 300_000,
shouldWriteStabilityBundle: false,
recovered: false,
});
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
const recover = gatewayStartOptions().tryRecoverChannelAutostartSuppression;
expect(recover).toBeTypeOf("function");
bootLifecycle.decisions.push(
{
tripped: false,
uncleanBoots: 1,
windowMs: 300_000,
shouldWriteStabilityBundle: false,
recovered: true,
},
{
tripped: false,
uncleanBoots: 0,
windowMs: 300_000,
shouldWriteStabilityBundle: false,
recovered: true,
},
);
expect(recover?.()).toBe(false);
expect(bootLifecycle.recover).not.toHaveBeenCalled();
expect(recover?.()).toBe(true);
expect(bootLifecycle.recover).toHaveBeenCalledWith("boot-id", process.env, undefined);
expect(gatewayLogMessages.some((message) => message.includes("breaker recovered"))).toBe(true);
});
it.each(["initial", "restart", "cause", "aggregate"] as const)(
"retains the actual legacy-session refusal without triage (%s)",
async (kind) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gateway-legacy-refusal-"));
const storePath = path.join(root, "sessions.json");
const original = '{"main":{"sessionId":"legacy","updatedAt":1}}';
await fs.writeFile(storePath, original);
try {
const { assertSessionStoreMigrationComplete } =
await import("../../config/sessions/startup-migration.js");
let refusal: unknown;
try {
assertSessionStoreMigrationComplete({ cfg: {}, targets: [{ storePath }] });
} catch (error) {
refusal = error;
}
expect(refusal).toBeInstanceOf(Error);
const message = (refusal as Error).message;
expect(message).toBe(
`Legacy session store requires migration: ${storePath}. Run "openclaw doctor --fix" against the same state/config before starting OpenClaw.`,
);
const failure =
kind === "cause"
? new Error("startup wrapper", { cause: refusal })
: kind === "aggregate"
? new AggregateError([refusal], message)
: refusal;
runGatewayLoop.mockImplementationOnce(
async (
params: GatewayLoopParams & {
beginBoot?: (now: number) => Promise<void>;
onRestartStartupFailure?: (error: unknown, signal: AbortSignal) => Promise<void>;
},
) => {
await params.beginBoot?.(1000);
if (kind === "restart") {
await params.onRestartStartupFailure?.(failure, new AbortController().signal);
}
throw failure;
},
);
await withEnvAsync({ CODEX_THREAD_ID: undefined }, async () => {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
});
expect(triageAfterFailure).not.toHaveBeenCalled();
expect(parkCurrentLaunchAgentForMaintenance).toHaveBeenCalledOnce();
expect(runtimeErrors.join("\n")).toContain(message);
expect(await fs.readFile(storePath, "utf8")).toBe(original);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
},
);
it("exits 78 when the only startup blocker is legacy workspace setup state", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "gateway-workspace-refusal-"));
const source = path.join(workspaceDir, "openclaw-workspace-state.json");
const original = JSON.stringify({ version: 1, setupCompletedAt: new Date().toISOString() });
await fs.writeFile(source, original);
try {
const { assertWorkspaceStateMigrationReady } =
await import("../../agents/workspace-legacy-state.js");
startGatewayServer.mockImplementationOnce(async () => {
assertWorkspaceStateMigrationReady({ workspaceDirs: [workspaceDir] });
throw new Error("Legacy workspace setup state was unexpectedly accepted");
});
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
expect(parkCurrentLaunchAgentForMaintenance).toHaveBeenCalledOnce();
expect(triageAfterFailure).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toMatch(/gateway stop.*doctor --fix.*gateway start/s);
expect(await fs.readFile(source, "utf8")).toBe(original);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("skips failure bundles but exits nonzero for unconfirmed gateway lock conflicts", async () => {
const port = await getFreePort();
configState.snapshot = {
config: { gateway: { port } },
exists: false,
sourceConfig: {},
valid: true,
};
const err = Object.assign(new Error(`gateway already running on port ${port}`), {
name: "GatewayLockError",
});
startGatewayServer.mockRejectedValueOnce(err);
await withEnvAsync(withoutSupervisorEnv, async () => {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:1",
);
});
expect(writeDiagnosticStabilityBundleForFailureSync).not.toHaveBeenCalled();
expect(startGatewayServer).toHaveBeenCalledWith(port, expect.any(Object));
expect(runtimeErrors.join("\n")).toContain(`gateway already running on port ${port}`);
expect(runtimeErrors.join("\n")).toContain("gateway stop");
expect(triageAfterFailure).not.toHaveBeenCalled();
});
it("exits 78 and parks launchd for a repairable shared-state schema", async () => {
bootLifecycle.record.mockReturnValueOnce(undefined);
runGatewayLoop.mockImplementationOnce(async ({ start, completeBoot }: GatewayLoopParams) => {
try {
await start();
} catch (error) {
completeBoot?.({ outcome: "startup_failed", reason: "schema migration required" });
throw error;
}
});
startGatewayServer.mockRejectedValueOnce(
new OpenClawStateDatabaseSchemaMigrationRequiredError(
"agent-databases-composite-primary-key",
"/tmp/openclaw.sqlite",
),
);
parkCurrentLaunchAgentForMaintenance.mockResolvedValueOnce(true);
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
expect(parkCurrentLaunchAgentForMaintenance).toHaveBeenCalledOnce();
expect(bootLifecycle.complete).toHaveBeenCalledWith(undefined, {
outcome: "startup_failed",
reason: "schema migration required",
});
expect(triageAfterFailure).not.toHaveBeenCalled();
expect(runtimeErrors.join("\n")).toContain(
"state database schema migration required (agent-databases-composite-primary-key)",
);
});
it("does not park launchd for a nonrepairable shared-state schema", async () => {
startGatewayServer.mockRejectedValueOnce(
new Error(
"OpenClaw state database /tmp/openclaw.sqlite has a noncanonical agent database registry schema that cannot be repaired automatically.",
),
);
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:1",
);
expect(parkCurrentLaunchAgentForMaintenance).not.toHaveBeenCalled();
expect(triageAfterFailure).not.toHaveBeenCalled();
});
it.each([
{ phase: "server", kind: "state" },
{ phase: "server", kind: "agent" },
{ phase: "server", kind: "wrapped-reader" },
{ phase: "server", kind: "mixed-maintenance" },
{ phase: "bootstrap", kind: "reader" },
{ phase: "configuration", kind: "wrapped-reader" },
] as const)("stops newer-schema retries from $phase ($kind)", async ({ phase, kind }) => {
const readerError = createNewerSqliteSchemaVersionError(
"test database",
"/tmp/newer.sqlite",
999,
998,
);
const error =
kind === "state" || kind === "agent"
? new OpenClawDatabaseSchemaPreflightError([
{
kind,
path: "/tmp/newer.sqlite",
foundVersion: 999,
supportedVersion: 998,
writerAppVersion: "2026.9.4",
},
])
: kind === "reader"
? readerError
: kind === "mixed-maintenance"
? new AggregateError(
[
new OpenClawStateDatabaseSchemaMigrationRequiredError(
"audit-events-v2",
"/tmp/state.sqlite",
),
readerError,
],
"Multiple maintenance failures",
)
: new Error("Failed to open plugin state", { cause: readerError });
if (phase === "bootstrap") {
beforeRun.mockRejectedValueOnce(error);
} else if (phase === "configuration") {
refreshManagedProxy.mockRejectedValueOnce(error);
} else {
startGatewayServer.mockRejectedValueOnce(error);
}
parkCurrentLaunchAgentForMaintenance.mockResolvedValueOnce(true);
const restoreHooks = installGatewayRunRuntimeHooks({ refreshManagedProxy });
try {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
} finally {
restoreHooks();
}
expect(parkCurrentLaunchAgentForMaintenance).toHaveBeenCalledOnce();
expect(offerInvalidConfigRecovery).not.toHaveBeenCalled();
if (error instanceof OpenClawDatabaseSchemaPreflightError) {
expect(gatewayErrorMessages).toEqual([`${error.message} Parked the managed LaunchAgent.`]);
expect(gatewayErrorMessages[0]).toContain(
"uses schema 999; this build supports 998; writer build 2026.9.4",
);
expect(runtimeErrors).toEqual([`Gateway failed to start: ${error.message}`]);
} else {
expect(runtimeErrors.join("\n")).toContain("newer");
expect(runtimeErrors.join("\n")).toContain("restore your pre-update backup");
expect(runtimeErrors.join("\n")).toMatch(
/Stop the service.*then restore your pre-update backup created with openclaw backup create, then start it again/s,
);
}
expect(triageAfterFailure).not.toHaveBeenCalled();
expect(startGatewayServer).toHaveBeenCalledTimes(phase === "server" ? 1 : 0);
});
it.each([
"gateway already running (pid 4242); lock timeout after 5000ms",
"another gateway instance is already listening on ws://127.0.0.1",
])("exits 1 for unmanaged healthy-port lock conflicts: %s", async (message) => {
const healthyGateway = createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, status: "live" }));
});
await new Promise<void>((resolve) => {
healthyGateway.listen(0, "127.0.0.1", resolve);
});
const address = healthyGateway.address();
if (!address || typeof address === "string") {
throw new Error("expected TCP server address");
}
const port = address.port;
configState.snapshot = {
config: { gateway: { port } },
exists: false,
sourceConfig: {},
valid: true,
};
const err = Object.assign(new Error(`${message}:${port}`), {
name: "GatewayLockError",
});
startGatewayServer.mockRejectedValueOnce(err);
try {
await withEnvAsync(withoutSupervisorEnv, async () => {
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:1",
);
});
} finally {
await new Promise<void>((resolve, reject) => {
healthyGateway.close((closeError) => (closeError ? reject(closeError) : resolve()));
});
}
});
it("blocks startup when the observed snapshot loses gateway.mode", async () => {
configState.cfg = {
gateway: {
mode: "local",
},
};
configState.snapshot = {
exists: true,
valid: true,
config: {
update: { channel: "beta" },
},
parsed: {
update: { channel: "beta" },
},
};
await expect(runGatewayCli(["gateway", "run"])).rejects.toThrow("__exit__:78");
expect(runtimeErrors).toContain(
"Gateway start blocked: existing config is missing gateway.mode. Treat this as suspicious or clobbered config. Re-run `openclaw onboard --mode local` or `openclaw setup`, set gateway.mode=local manually, or pass --allow-unconfigured.",
);
expect(runtimeErrors).toContain(`Config write audit: ${CONFIG_AUDIT_STORE_LABEL}`);
expect(startGatewayServer).not.toHaveBeenCalled();
expect(readBestEffortConfig).not.toHaveBeenCalled();
});
it("blocks invalid startup config without automatic recovery", async () => {
configState.cfg = {};
configState.snapshot = {
exists: true,
valid: false,
path: "/tmp/openclaw-test-missing-config.json",
config: {},
parsed: null,
issues: [{ path: "<root>", message: "JSON5 parse failed" }],
legacyIssues: [],
};
await expect(runGatewayCli(["gateway", "run"])).rejects.toThrow("__exit__:78");
expect(runtimeErrors).toContain(
"Gateway start blocked: existing config is missing gateway.mode. Treat this as suspicious or clobbered config. Re-run `openclaw onboard --mode local` or `openclaw setup`, set gateway.mode=local manually, or pass --allow-unconfigured.",
);
expect(runtimeErrors).toContain(`Config write audit: ${CONFIG_AUDIT_STORE_LABEL}`);
expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledOnce();
expect(startGatewayServer).not.toHaveBeenCalled();
});
it("keeps explicit dev reset as the recovery path for invalid config", async () => {
configState.snapshot = {
exists: true,
valid: false,
path: "/tmp/openclaw-test-missing-config.json",
config: {},
parsed: null,
issues: [{ path: "<root>", message: "JSON5 parse failed" }],
legacyIssues: [],
};
await prepareGatewayReset();
await runGatewayCli(["gateway", "--dev", "--reset", "--allow-unconfigured"]);
expect(ensureDevGatewayConfig).toHaveBeenCalledWith({ reset: true });
});
it("passes invalid startup snapshot through when explicitly allowed", async () => {
configState.cfg = {};
configState.snapshot = {
exists: true,
valid: false,
path: "/tmp/openclaw-test-missing-config.json",
config: {},
parsed: null,
issues: [{ path: "<root>", message: "JSON5 parse failed" }],
legacyIssues: [],
};
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
const options = gatewayStartOptions();
expect(options.bind).toBe("loopback");
expect(options.startupConfigSnapshotRead?.snapshot?.valid).toBe(false);
});
it("does not offer doctor repair after --allow-unconfigured reaches startup", async () => {
const { createInvalidConfigError } = await import("../../config/io.invalid-config.js");
startGatewayServer.mockRejectedValueOnce(
createInvalidConfigError("/tmp/openclaw.json", "gateway.mode: invalid"),
);
await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow(
"__exit__:78",
);
expect(offerInvalidConfigRecovery).not.toHaveBeenCalled();
expect(startGatewayServer).toHaveBeenCalledOnce();
});
it.each(["none", "trusted-proxy"] as const)("accepts --auth %s override", async (mode) => {
await runGatewayCli(["gateway", "run", "--auth", mode, "--allow-unconfigured"]);
expectAuthOverrideMode(mode);
});
it("prints all supported modes on invalid --auth value", async () => {
await expect(
runGatewayCli(["gateway", "run", "--auth", "bad-mode", "--allow-unconfigured"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors).toContain(
'Invalid --auth. Use "none", "token", "password", or "trusted-proxy".',
);
});
it("accepts retired --tailscale-reset-on-exit as a no-op", async () => {
await runGatewayCli(["gateway", "run", "--tailscale-reset-on-exit", "--allow-unconfigured"]);
expect(runtimeErrors).toEqual([]);
expect(startGatewayServer).toHaveBeenCalledOnce();
});
it("allows password mode preflight when password is configured via SecretRef", async () => {
configState.cfg = {
gateway: {
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
},
secrets: {
defaults: {
env: "default",
},
},
};
configState.snapshot = {
exists: true,
valid: true,
config: configState.cfg,
parsed: configState.cfg,
};
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(gatewayStartOptions().bind).toBe("loopback");
});
it("reads gateway password from --password-file", async () => {
await withTempSecretFiles(
"openclaw-gateway-run-",
{ password: "pw_from_file\n" },
async ({ passwordFile }) => {
await runGatewayCli([
"gateway",
"run",
"--auth",
"password",
"--password-file",
passwordFile ?? "",
"--allow-unconfigured",
]);
},
);
const options = gatewayStartOptions();
expect(options.auth?.mode).toBe("password");
expect(options.auth?.password).toBe("pw_from_file"); // pragma: allowlist secret
expect(runtimeErrors).not.toContain(
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
);
});
it("warns when gateway password is passed inline", async () => {
await runGatewayCli([
"gateway",
"run",
"--auth",
"password",
"--password",
"pw_inline",
"--allow-unconfigured",
]);
expect(runtimeErrors).toContain(
"Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.",
);
});
it("rejects using both --password and --password-file", async () => {
await withTempSecretFiles(
"openclaw-gateway-run-",
{ password: "pw_from_file\n" },
async ({ passwordFile }) => {
await expect(
runGatewayCli([
"gateway",
"run",
"--password",
"pw_inline",
"--password-file",
passwordFile ?? "",
"--allow-unconfigured",
]),
).rejects.toThrow("__exit__:1");
},
);
expect(runtimeErrors[0]).toContain("Use either --password or --password-file.");
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */