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; 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, snapshot: { config: {}, exists: false, sourceConfig: {}, valid: true } as Record, })); const pristineStartupMigrationPlan = vi.hoisted(() => ({ config: vi.fn(), state: vi.fn(), })); const readBestEffortConfig = vi.fn(async () => configState.cfg); type ConfigSnapshotReadOptionsStub = { isolateEnv?: boolean; lowerPrecedenceEnv?: Readonly>; 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":""}'`, 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; 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()), 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()), 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) => 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(); 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()), 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(); 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()), 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 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 }; 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) => ({ 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 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; 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; onRestartStartupFailure?: (error: unknown, signal: AbortSignal) => Promise; }, ) => { 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; 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; onRestartStartupFailure?: (error: unknown, signal: AbortSignal) => Promise; }, ) => { 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((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((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: "", 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: "", 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: "", 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. */