import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const })); const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ enabled: true, required: true, fingerprintSha256: "sha256:11:22:33:44", })); const findExtraGatewayServices = vi.fn(async (_env?: unknown, _opts?: unknown) => []); const inspectPortUsage = vi.fn(async (port: number) => ({ port, status: "free" as const, listeners: [], hints: [], })); const readLastGatewayErrorLine = vi.fn(async (_env?: NodeJS.ProcessEnv) => null); const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined); const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true); const serviceReadRuntime = vi.fn(async (_env?: NodeJS.ProcessEnv) => ({ status: "running" })); const inspectGatewayRestart = vi.fn<(opts?: unknown) => Promise>( async (_opts?: unknown) => ({ runtime: { status: "running", pid: 1234 }, portUsage: { port: 19001, status: "busy", listeners: [], hints: [] }, healthy: true, staleGatewayPids: [], }), ); const serviceReadCommand = vi.fn< (env?: NodeJS.ProcessEnv) => Promise<{ programArguments: string[]; environment?: Record; }> >(async (_env?: NodeJS.ProcessEnv) => ({ programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], environment: { OPENCLAW_STATE_DIR: "/tmp/openclaw-daemon", OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon/openclaw.json", }, })); const resolveGatewayBindHost = vi.fn( async (_bindMode?: string, _customBindHost?: string) => "0.0.0.0", ); const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.9"); const resolveGatewayPort = vi.fn((_cfg?: unknown, _env?: unknown) => 18789); const resolveStateDir = vi.fn( (env: NodeJS.ProcessEnv) => env.OPENCLAW_STATE_DIR ?? "/tmp/openclaw-cli", ); const resolveConfigPath = vi.fn((env: NodeJS.ProcessEnv, stateDir: string) => { return env.OPENCLAW_CONFIG_PATH ?? `${stateDir}/openclaw.json`; }); let daemonLoadedConfig: Record = { gateway: { bind: "lan", tls: { enabled: true }, auth: { token: "daemon-token" }, }, }; let cliLoadedConfig: Record = { gateway: { bind: "loopback", }, }; vi.mock("../../config/config.js", () => ({ createConfigIO: ({ configPath }: { configPath: string }) => { const isDaemon = configPath.includes("/openclaw-daemon/"); return { readConfigFileSnapshot: async () => ({ path: configPath, exists: true, valid: true, issues: [], }), loadConfig: () => (isDaemon ? daemonLoadedConfig : cliLoadedConfig), }; }, resolveConfigPath: (env: NodeJS.ProcessEnv, stateDir: string) => resolveConfigPath(env, stateDir), resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env), resolveStateDir: (env: NodeJS.ProcessEnv) => resolveStateDir(env), })); vi.mock("../../daemon/diagnostics.js", () => ({ readLastGatewayErrorLine: (env: NodeJS.ProcessEnv) => readLastGatewayErrorLine(env), })); vi.mock("../../daemon/inspect.js", () => ({ findExtraGatewayServices: (env: unknown, opts?: unknown) => findExtraGatewayServices(env, opts), })); vi.mock("../../daemon/service-audit.js", () => ({ auditGatewayServiceConfig: (opts: unknown) => auditGatewayServiceConfig(opts), })); vi.mock("../../daemon/service.js", () => ({ resolveGatewayService: () => ({ label: "LaunchAgent", loadedText: "loaded", notLoadedText: "not loaded", isLoaded: serviceIsLoaded, readCommand: serviceReadCommand, readRuntime: serviceReadRuntime, }), })); vi.mock("../../gateway/net.js", () => ({ resolveGatewayBindHost: (bindMode: string, customBindHost?: string) => resolveGatewayBindHost(bindMode, customBindHost), })); vi.mock("../../infra/ports.js", () => ({ inspectPortUsage: (port: number) => inspectPortUsage(port), formatPortDiagnostics: () => [], })); vi.mock("../../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(), })); vi.mock("../../infra/tls/gateway.js", () => ({ loadGatewayTlsRuntime: (cfg: unknown) => loadGatewayTlsRuntime(cfg), })); vi.mock("./probe.js", () => ({ probeGatewayStatus: (opts: unknown) => callGatewayStatusProbe(opts), })); vi.mock("./restart-health.js", () => ({ inspectGatewayRestart: (opts: unknown) => inspectGatewayRestart(opts), })); const { gatherDaemonStatus } = await import("./status.gather.js"); describe("gatherDaemonStatus", () => { let envSnapshot: ReturnType; beforeEach(() => { envSnapshot = captureEnv([ "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD", "DAEMON_GATEWAY_TOKEN", "DAEMON_GATEWAY_PASSWORD", ]); process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli"; process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json"; delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.DAEMON_GATEWAY_TOKEN; delete process.env.DAEMON_GATEWAY_PASSWORD; callGatewayStatusProbe.mockClear(); loadGatewayTlsRuntime.mockClear(); inspectGatewayRestart.mockClear(); daemonLoadedConfig = { gateway: { bind: "lan", tls: { enabled: true }, auth: { token: "daemon-token" }, }, }; cliLoadedConfig = { gateway: { bind: "loopback", }, }; }); afterEach(() => { envSnapshot.restore(); }); it("uses wss probe URL and forwards TLS fingerprint when daemon TLS is enabled", async () => { const status = await gatherDaemonStatus({ rpc: {}, probe: true, deep: false, }); expect(loadGatewayTlsRuntime).toHaveBeenCalledTimes(1); expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ url: "wss://127.0.0.1:19001", tlsFingerprint: "sha256:11:22:33:44", token: "daemon-token", }), ); expect(status.gateway?.probeUrl).toBe("wss://127.0.0.1:19001"); expect(status.rpc?.url).toBe("wss://127.0.0.1:19001"); expect(status.rpc?.ok).toBe(true); }); it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => { const status = await gatherDaemonStatus({ rpc: { url: "wss://override.example:18790" }, probe: true, deep: false, }); expect(loadGatewayTlsRuntime).not.toHaveBeenCalled(); expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ url: "wss://override.example:18790", tlsFingerprint: undefined, }), ); expect(status.gateway?.probeUrl).toBe("wss://override.example:18790"); expect(status.rpc?.url).toBe("wss://override.example:18790"); }); it("reuses command environment when reading runtime status", async () => { serviceReadCommand.mockResolvedValueOnce({ programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], environment: { OPENCLAW_GATEWAY_PORT: "19001", OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon/openclaw.json", OPENCLAW_STATE_DIR: "/tmp/openclaw-daemon", } as Record, }); serviceReadRuntime.mockImplementationOnce(async (env?: NodeJS.ProcessEnv) => ({ status: env?.OPENCLAW_GATEWAY_PORT === "19001" ? "running" : "unknown", detail: env?.OPENCLAW_GATEWAY_PORT ?? "missing-port", })); const status = await gatherDaemonStatus({ rpc: {}, probe: false, deep: false, }); expect(serviceReadRuntime).toHaveBeenCalledWith( expect.objectContaining({ OPENCLAW_GATEWAY_PORT: "19001", }), ); expect(status.service.runtime).toMatchObject({ status: "running", detail: "19001", }); }); it("resolves daemon gateway auth password SecretRef values before probing", async () => { daemonLoadedConfig = { gateway: { bind: "lan", tls: { enabled: true }, auth: { password: { source: "env", provider: "default", id: "DAEMON_GATEWAY_PASSWORD" }, }, }, secrets: { providers: { default: { source: "env" }, }, }, }; process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password"; // pragma: allowlist secret await gatherDaemonStatus({ rpc: {}, probe: true, deep: false, }); expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ password: "daemon-secretref-password", // pragma: allowlist secret }), ); }); it("resolves daemon gateway auth token SecretRef values before probing", async () => { daemonLoadedConfig = { gateway: { bind: "lan", tls: { enabled: true }, auth: { mode: "token", token: "${DAEMON_GATEWAY_TOKEN}", }, }, secrets: { providers: { default: { source: "env" }, }, }, }; process.env.DAEMON_GATEWAY_TOKEN = "daemon-secretref-token"; await gatherDaemonStatus({ rpc: {}, probe: true, deep: false, }); expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ token: "daemon-secretref-token", }), ); }); it("does not resolve daemon password SecretRef when token auth is configured", async () => { daemonLoadedConfig = { gateway: { bind: "lan", tls: { enabled: true }, auth: { mode: "token", token: "daemon-token", password: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_PASSWORD" }, }, }, secrets: { providers: { default: { source: "env" }, }, }, }; await gatherDaemonStatus({ rpc: {}, probe: true, deep: false, }); expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ token: "daemon-token", password: undefined, }), ); }); it("keeps remote probe auth strict when remote token is missing", async () => { daemonLoadedConfig = { gateway: { mode: "remote", remote: { url: "wss://gateway.example", password: "remote-password", // pragma: allowlist secret }, auth: { mode: "token", token: "local-token", password: "local-password", // pragma: allowlist secret }, }, }; process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; process.env.OPENCLAW_GATEWAY_PASSWORD = "env-password"; // pragma: allowlist secret await gatherDaemonStatus({ rpc: {}, probe: true, deep: false, }); expect(callGatewayStatusProbe).toHaveBeenCalledWith( expect.objectContaining({ token: undefined, password: "env-password", // pragma: allowlist secret }), ); }); it("skips TLS runtime loading when probe is disabled", async () => { const status = await gatherDaemonStatus({ rpc: {}, probe: false, deep: false, }); expect(loadGatewayTlsRuntime).not.toHaveBeenCalled(); expect(callGatewayStatusProbe).not.toHaveBeenCalled(); expect(status.rpc).toBeUndefined(); }); it("surfaces stale gateway listener pids from restart health inspection", async () => { inspectGatewayRestart.mockResolvedValueOnce({ runtime: { status: "running", pid: 8000 }, portUsage: { port: 19001, status: "busy", listeners: [{ pid: 9000, ppid: 8999, commandLine: "openclaw-gateway" }], hints: [], }, healthy: false, staleGatewayPids: [9000], }); const status = await gatherDaemonStatus({ rpc: {}, probe: true, deep: false, }); expect(inspectGatewayRestart).toHaveBeenCalledWith( expect.objectContaining({ port: 19001, }), ); expect(status.health).toEqual({ healthy: false, staleGatewayPids: [9000], }); }); });