import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureFullEnv } from "../../test-utils/env.js"; import type { DaemonActionResponse } from "./response.js"; const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); const writeConfigFileMock = vi.hoisted(() => vi.fn()); const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false)); const resolveSecretInputRefMock = vi.hoisted(() => vi.fn((): { ref: unknown } => ({ ref: undefined })), ); const resolveGatewayAuthMock = vi.hoisted(() => vi.fn(() => ({ mode: "token", token: undefined, password: undefined, allowTailscale: false, })), ); const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn()); const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); const buildGatewayInstallPlanMock = vi.hoisted(() => vi.fn(async () => ({ programArguments: ["openclaw", "gateway", "run"], workingDirectory: "/tmp", environment: {}, })), ); const parsePortMock = vi.hoisted(() => vi.fn(() => null)); const isGatewayDaemonRuntimeMock = vi.hoisted(() => vi.fn(() => true)); const installDaemonServiceAndEmitMock = vi.hoisted(() => vi.fn(async () => {})); const actionState = vi.hoisted(() => ({ warnings: [] as string[], emitted: [] as DaemonActionResponse[], failed: [] as Array<{ message: string; hints?: string[] }>, })); const service = vi.hoisted(() => ({ label: "Gateway", loadedText: "loaded", notLoadedText: "not loaded", isLoaded: vi.fn(async () => false), install: vi.fn(async () => {}), uninstall: vi.fn(async () => {}), restart: vi.fn(async () => {}), stop: vi.fn(async () => {}), readCommand: vi.fn(async () => null), readRuntime: vi.fn(async () => ({ status: "stopped" as const })), })); vi.mock("../../bootstrap/node-startup-env.js", () => ({ resolveNodeStartupTlsEnvironment: resolveNodeStartupTlsEnvironmentMock, })); vi.mock("../../config/config.js", () => ({ loadConfig: loadConfigMock, readBestEffortConfig: loadConfigMock, readConfigFileSnapshot: readConfigFileSnapshotMock, resolveGatewayPort: resolveGatewayPortMock, writeConfigFile: writeConfigFileMock, })); vi.mock("../../config/paths.js", () => ({ resolveIsNixMode: resolveIsNixModeMock, })); vi.mock("../../config/types.secrets.js", () => ({ resolveSecretInputRef: resolveSecretInputRefMock, })); vi.mock("../../gateway/auth.js", () => ({ resolveGatewayAuth: resolveGatewayAuthMock, })); vi.mock("../../secrets/resolve.js", () => ({ resolveSecretRefValues: resolveSecretRefValuesMock, })); vi.mock("../../commands/onboard-helpers.js", () => ({ randomToken: randomTokenMock, })); vi.mock("../../commands/daemon-install-helpers.js", () => ({ buildGatewayInstallPlan: buildGatewayInstallPlanMock, })); vi.mock("./shared.js", () => ({ parsePort: parsePortMock, createDaemonInstallActionContext: (jsonFlag: unknown) => { const json = Boolean(jsonFlag); return { json, stdout: process.stdout, warnings: actionState.warnings, emit: (payload: DaemonActionResponse) => { actionState.emitted.push(payload); }, fail: (message: string, hints?: string[]) => { actionState.failed.push({ message, hints }); }, }; }, failIfNixDaemonInstallMode: (fail: (message: string, hints?: string[]) => void) => { if (!resolveIsNixModeMock()) { return false; } fail("Nix mode detected; service install is disabled."); return true; }, })); vi.mock("../../commands/daemon-runtime.js", () => ({ DEFAULT_GATEWAY_DAEMON_RUNTIME: "node", isGatewayDaemonRuntime: isGatewayDaemonRuntimeMock, })); vi.mock("../../daemon/service.js", () => ({ resolveGatewayService: () => service, })); vi.mock("./response.js", () => ({ buildDaemonServiceSnapshot: vi.fn(), installDaemonServiceAndEmit: installDaemonServiceAndEmitMock, })); const runtimeLogs: string[] = []; vi.mock("../../runtime.js", () => ({ defaultRuntime: { log: (message: string) => runtimeLogs.push(message), writeStdout: (value: string) => { runtimeLogs.push(value.endsWith("\n") ? value.slice(0, -1) : value); }, writeJson: (value: unknown, space = 2) => { runtimeLogs.push(JSON.stringify(value, null, space)); }, error: vi.fn(), exit: vi.fn(), }, })); function expectFirstInstallPlanCallOmitsToken() { const [firstArg] = (buildGatewayInstallPlanMock.mock.calls.at(0) as [Record] | undefined) ?? []; expect(firstArg).toBeDefined(); expect(firstArg && "token" in firstArg).toBe(false); } function mockResolvedGatewayTokenSecretRef() { resolveSecretInputRefMock.mockReturnValue({ ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, }); resolveSecretRefValuesMock.mockResolvedValue( new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]), ); } const { runDaemonInstall } = await import("./install.js"); const envSnapshot = captureFullEnv(); describe("runDaemonInstall", () => { beforeEach(() => { loadConfigMock.mockReset(); resolveNodeStartupTlsEnvironmentMock.mockReset(); readConfigFileSnapshotMock.mockReset(); resolveGatewayPortMock.mockClear(); writeConfigFileMock.mockReset(); resolveIsNixModeMock.mockReset(); resolveSecretInputRefMock.mockReset(); resolveGatewayAuthMock.mockReset(); resolveSecretRefValuesMock.mockReset(); randomTokenMock.mockReset(); buildGatewayInstallPlanMock.mockReset(); parsePortMock.mockReset(); isGatewayDaemonRuntimeMock.mockReset(); installDaemonServiceAndEmitMock.mockReset(); service.isLoaded.mockReset(); runtimeLogs.length = 0; actionState.warnings.length = 0; actionState.emitted.length = 0; actionState.failed.length = 0; loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token" } } }); readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} }); resolveGatewayPortMock.mockReturnValue(18789); resolveIsNixModeMock.mockReturnValue(false); resolveSecretInputRefMock.mockReturnValue({ ref: undefined }); resolveGatewayAuthMock.mockReturnValue({ mode: "token", token: undefined, password: undefined, allowTailscale: false, }); resolveSecretRefValuesMock.mockResolvedValue(new Map()); randomTokenMock.mockReturnValue("generated-token"); buildGatewayInstallPlanMock.mockResolvedValue({ programArguments: ["openclaw", "gateway", "run"], workingDirectory: "/tmp", environment: {}, }); parsePortMock.mockReturnValue(null); isGatewayDaemonRuntimeMock.mockReturnValue(true); installDaemonServiceAndEmitMock.mockResolvedValue(undefined); service.isLoaded.mockResolvedValue(false); service.readCommand.mockResolvedValue(null); resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ NODE_EXTRA_CA_CERTS: undefined, NODE_USE_SYSTEM_CA: undefined, }); delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN; }); afterEach(() => { envSnapshot.restore(); }); it("fails install when token auth requires an unresolved token SecretRef", async () => { resolveSecretInputRefMock.mockReturnValue({ ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, }); resolveSecretRefValuesMock.mockRejectedValue(new Error("secret unavailable")); await runDaemonInstall({ json: true }); expect(actionState.failed[0]?.message).toContain("gateway.auth.token SecretRef is configured"); expect(actionState.failed[0]?.message).toContain("unresolved"); expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled(); expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); }); it("validates token SecretRef but does not serialize resolved token into service env", async () => { mockResolvedGatewayTokenSecretRef(); await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); expectFirstInstallPlanCallOmitsToken(); expect(writeConfigFileMock).not.toHaveBeenCalled(); expect( actionState.warnings.some((warning) => warning.includes("gateway.auth.token is SecretRef-managed"), ), ).toBe(true); }); it("does not treat env-template gateway.auth.token as plaintext during install", async () => { loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } }, }); mockResolvedGatewayTokenSecretRef(); await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); expectFirstInstallPlanCallOmitsToken(); }); it("auto-mints and persists token when no source exists", async () => { randomTokenMock.mockReturnValue("minted-token"); readConfigFileSnapshotMock.mockResolvedValue({ exists: true, valid: true, config: { gateway: { auth: { mode: "token" } } }, }); await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); expect(writeConfigFileMock).toHaveBeenCalledTimes(1); const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as { gateway?: { auth?: { token?: string } }; }; expect(writtenConfig.gateway?.auth?.token).toBe("minted-token"); expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( expect.objectContaining({ port: 18789 }), ); expectFirstInstallPlanCallOmitsToken(); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); }); it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => { service.isLoaded.mockRejectedValueOnce( new Error("systemctl is-enabled unavailable: Failed to connect to bus"), ); await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); }); it("fails install when service probe reports an unrelated error", async () => { service.isLoaded.mockRejectedValueOnce( new Error("systemctl is-enabled unavailable: read-only file system"), ); await runDaemonInstall({ json: true }); expect(actionState.failed[0]?.message).toContain("Gateway service check failed"); expect(actionState.failed[0]?.message).toContain("read-only file system"); expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); }); it("returns already-installed when the service already has the expected TLS env", async () => { service.isLoaded.mockResolvedValue(true); resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt", NODE_USE_SYSTEM_CA: undefined, }); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "run"], environment: { NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt", }, } as never); await runDaemonInstall({ json: true }); expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); }); it("reinstalls when an existing service is missing the nvm TLS CA bundle", async () => { service.isLoaded.mockResolvedValue(true); resolveNodeStartupTlsEnvironmentMock.mockReturnValue({ NODE_EXTRA_CA_CERTS: "/etc/ssl/certs/ca-certificates.crt", NODE_USE_SYSTEM_CA: undefined, }); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "run"], environment: {}, } as never); await runDaemonInstall({ json: true }); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); }); it("reinstalls when the installed service still runs from nvm even if the installer runtime does not", async () => { service.isLoaded.mockResolvedValue(true); resolveNodeStartupTlsEnvironmentMock.mockImplementation(({ execPath }) => ({ NODE_EXTRA_CA_CERTS: typeof execPath === "string" && execPath.includes("/.nvm/") ? "/etc/ssl/certs/ca-certificates.crt" : undefined, NODE_USE_SYSTEM_CA: undefined, })); service.readCommand.mockResolvedValue({ programArguments: ["/home/test/.nvm/versions/node/v22.18.0/bin/node", "dist/entry.js"], environment: {}, } as never); await runDaemonInstall({ json: true }); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); expect(resolveNodeStartupTlsEnvironmentMock).toHaveBeenCalledWith( expect.objectContaining({ execPath: "/home/test/.nvm/versions/node/v22.18.0/bin/node", }), ); }); });