import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureFullEnv } from "../../test-utils/env.js"; import type { DaemonActionResponse } from "./response.js"; 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("../../config/config.js", () => ({ loadConfig: 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, })); 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(), createDaemonActionContext: vi.fn(() => ({ stdout: process.stdout, warnings: actionState.warnings, emit: (payload: DaemonActionResponse) => { actionState.emitted.push(payload); }, fail: (message: string, hints?: string[]) => { actionState.failed.push({ message, hints }); }, })), installDaemonServiceAndEmit: installDaemonServiceAndEmitMock, })); const runtimeLogs: string[] = []; vi.mock("../../runtime.js", () => ({ defaultRuntime: { log: (message: string) => runtimeLogs.push(message), error: vi.fn(), exit: vi.fn(), }, })); const { runDaemonInstall } = await import("./install.js"); const envSnapshot = captureFullEnv(); describe("runDaemonInstall", () => { beforeEach(() => { loadConfigMock.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); 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 () => { resolveSecretInputRefMock.mockReturnValue({ ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, }); resolveSecretRefValuesMock.mockResolvedValue( new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]), ); await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false); 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}" } }, }); resolveSecretInputRefMock.mockReturnValue({ ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, }); resolveSecretRefValuesMock.mockResolvedValue( new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]), ); await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false); }); 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 }), ); expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false); expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1); expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); }); });