| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import type { OpenClawConfig } from "../config/config.js"; |
| import { captureEnv } from "../test-utils/env.js"; |
| import { |
| loadConfigMock as loadConfig, |
| pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4, |
| pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4, |
| resolveGatewayPortMock as resolveGatewayPort, |
| } from "./gateway-connection.test-mocks.js"; |
|
|
| let lastClientOptions: { |
| url?: string; |
| token?: string; |
| password?: string; |
| tlsFingerprint?: string; |
| scopes?: string[]; |
| deviceIdentity?: unknown; |
| onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>; |
| onClose?: (code: number, reason: string) => void; |
| } | null = null; |
| type StartMode = "hello" | "close" | "silent"; |
| let startMode: StartMode = "hello"; |
| let closeCode = 1006; |
| let closeReason = ""; |
| let helloMethods: string[] | undefined = ["health", "secrets.resolve"]; |
|
|
| vi.mock("./client.js", () => ({ |
| describeGatewayCloseCode: (code: number) => { |
| if (code === 1000) { |
| return "normal closure"; |
| } |
| if (code === 1006) { |
| return "abnormal closure (no close frame)"; |
| } |
| return undefined; |
| }, |
| GatewayClient: class { |
| constructor(opts: { |
| url?: string; |
| token?: string; |
| password?: string; |
| scopes?: string[]; |
| onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>; |
| onClose?: (code: number, reason: string) => void; |
| }) { |
| lastClientOptions = opts; |
| } |
| async request() { |
| return { ok: true }; |
| } |
| start() { |
| if (startMode === "hello") { |
| void lastClientOptions?.onHelloOk?.({ |
| features: { |
| methods: helloMethods, |
| }, |
| }); |
| } else if (startMode === "close") { |
| lastClientOptions?.onClose?.(closeCode, closeReason); |
| } |
| } |
| stop() {} |
| }, |
| })); |
|
|
| const { buildGatewayConnectionDetails, callGateway, callGatewayCli, callGatewayScoped } = |
| await import("./call.js"); |
|
|
| function resetGatewayCallMocks() { |
| loadConfig.mockClear(); |
| resolveGatewayPort.mockClear(); |
| pickPrimaryTailnetIPv4.mockClear(); |
| pickPrimaryLanIPv4.mockClear(); |
| lastClientOptions = null; |
| startMode = "hello"; |
| closeCode = 1006; |
| closeReason = ""; |
| helloMethods = ["health", "secrets.resolve"]; |
| } |
|
|
| function setGatewayNetworkDefaults(port = 18789) { |
| resolveGatewayPort.mockReturnValue(port); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
| } |
|
|
| function setLocalLoopbackGatewayConfig(port = 18789) { |
| loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); |
| setGatewayNetworkDefaults(port); |
| } |
|
|
| function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword = "from-config") { |
| return { |
| gateway: { |
| mode: "remote", |
| remote: { url: "wss://remote.example:18789", password: remotePassword }, |
| auth: { password: localPassword }, |
| }, |
| }; |
| } |
|
|
| describe("callGateway url resolution", () => { |
| const envSnapshot = captureEnv([ |
| "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", |
| "OPENCLAW_GATEWAY_URL", |
| "OPENCLAW_GATEWAY_TOKEN", |
| "CLAWDBOT_GATEWAY_TOKEN", |
| ]); |
|
|
| beforeEach(() => { |
| envSnapshot.restore(); |
| resetGatewayCallMocks(); |
| }); |
|
|
| afterEach(() => { |
| envSnapshot.restore(); |
| }); |
|
|
| it.each([ |
| { |
| label: "keeps loopback when local bind is auto even if tailnet is present", |
| tailnetIp: "100.64.0.1", |
| }, |
| { |
| label: "falls back to loopback when local bind is auto without tailnet IP", |
| tailnetIp: undefined, |
| }, |
| ])("local auto-bind: $label", async ({ tailnetIp }) => { |
| loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); |
| resolveGatewayPort.mockReturnValue(18800); |
| pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); |
| }); |
|
|
| it.each([ |
| { |
| label: "tailnet with TLS", |
| gateway: { mode: "local", bind: "tailnet", tls: { enabled: true } }, |
| tailnetIp: "100.64.0.1", |
| lanIp: undefined, |
| expectedUrl: "wss://127.0.0.1:18800", |
| }, |
| { |
| label: "tailnet without TLS", |
| gateway: { mode: "local", bind: "tailnet" }, |
| tailnetIp: "100.64.0.1", |
| lanIp: undefined, |
| expectedUrl: "ws://127.0.0.1:18800", |
| }, |
| { |
| label: "lan with TLS", |
| gateway: { mode: "local", bind: "lan", tls: { enabled: true } }, |
| tailnetIp: undefined, |
| lanIp: "192.168.1.42", |
| expectedUrl: "wss://127.0.0.1:18800", |
| }, |
| { |
| label: "lan without TLS", |
| gateway: { mode: "local", bind: "lan" }, |
| tailnetIp: undefined, |
| lanIp: "192.168.1.42", |
| expectedUrl: "ws://127.0.0.1:18800", |
| }, |
| { |
| label: "lan without discovered LAN IP", |
| gateway: { mode: "local", bind: "lan" }, |
| tailnetIp: undefined, |
| lanIp: undefined, |
| expectedUrl: "ws://127.0.0.1:18800", |
| }, |
| ])("uses loopback for $label", async ({ gateway, tailnetIp, lanIp, expectedUrl }) => { |
| loadConfig.mockReturnValue({ gateway }); |
| resolveGatewayPort.mockReturnValue(18800); |
| pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); |
| pickPrimaryLanIPv4.mockReturnValue(lanIp); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.url).toBe(expectedUrl); |
| }); |
|
|
| it("uses url override in remote mode even when remote url is missing", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { mode: "remote", bind: "loopback", remote: {} }, |
| }); |
| resolveGatewayPort.mockReturnValue(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
|
|
| await callGateway({ |
| method: "health", |
| url: "wss://override.example/ws", |
| token: "explicit-token", |
| }); |
|
|
| expect(lastClientOptions?.url).toBe("wss://override.example/ws"); |
| expect(lastClientOptions?.token).toBe("explicit-token"); |
| }); |
|
|
| it("does not attach device identity for local loopback shared-token auth", async () => { |
| setLocalLoopbackGatewayConfig(); |
|
|
| await callGateway({ |
| method: "health", |
| token: "explicit-token", |
| }); |
|
|
| expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); |
| expect(lastClientOptions?.token).toBe("explicit-token"); |
| expect(lastClientOptions?.deviceIdentity).toBeUndefined(); |
| }); |
|
|
| it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { mode: "remote", bind: "loopback", remote: {} }, |
| }); |
| resolveGatewayPort.mockReturnValue(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
| process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws"; |
| process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; |
|
|
| await callGateway({ |
| method: "health", |
| }); |
|
|
| expect(lastClientOptions?.url).toBe("wss://gateway-in-container.internal:9443/ws"); |
| expect(lastClientOptions?.token).toBe("env-token"); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }); |
|
|
| it("uses env URL override credentials without resolving local password SecretRefs", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| auth: { |
| mode: "password", |
| password: { source: "env", provider: "default", id: "MISSING_LOCAL_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
| resolveGatewayPort.mockReturnValue(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
| process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws"; |
| process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; |
|
|
| await callGateway({ |
| method: "health", |
| }); |
|
|
| expect(lastClientOptions?.url).toBe("wss://gateway-in-container.internal:9443/ws"); |
| expect(lastClientOptions?.token).toBe("env-token"); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }); |
|
|
| it("uses remote tlsFingerprint with env URL override", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| remote: { |
| url: "wss://remote.example:9443/ws", |
| tlsFingerprint: "remote-fingerprint", |
| }, |
| }, |
| }); |
| setGatewayNetworkDefaults(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
| process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws"; |
| process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; |
|
|
| await callGateway({ |
| method: "health", |
| }); |
|
|
| expect(lastClientOptions?.tlsFingerprint).toBe("remote-fingerprint"); |
| }); |
|
|
| it("does not apply remote tlsFingerprint for CLI url override", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| remote: { |
| url: "wss://remote.example:9443/ws", |
| tlsFingerprint: "remote-fingerprint", |
| }, |
| }, |
| }); |
| setGatewayNetworkDefaults(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
|
|
| await callGateway({ |
| method: "health", |
| url: "wss://override.example:9443/ws", |
| token: "explicit-token", |
| }); |
|
|
| expect(lastClientOptions?.tlsFingerprint).toBeUndefined(); |
| }); |
|
|
| it.each([ |
| { |
| label: "uses least-privilege scopes by default for non-CLI callers", |
| call: () => callGateway({ method: "health" }), |
| expectedScopes: ["operator.read"], |
| }, |
| { |
| label: "keeps legacy admin scopes for explicit CLI callers", |
| call: () => callGatewayCli({ method: "health" }), |
| expectedScopes: [ |
| "operator.admin", |
| "operator.read", |
| "operator.write", |
| "operator.approvals", |
| "operator.pairing", |
| ], |
| }, |
| ])("scope selection: $label", async ({ call, expectedScopes }) => { |
| setLocalLoopbackGatewayConfig(); |
| await call(); |
| expect(lastClientOptions?.scopes).toEqual(expectedScopes); |
| }); |
|
|
| it("passes explicit scopes through, including empty arrays", async () => { |
| setLocalLoopbackGatewayConfig(); |
|
|
| await callGatewayScoped({ method: "health", scopes: ["operator.read"] }); |
| expect(lastClientOptions?.scopes).toEqual(["operator.read"]); |
|
|
| await callGatewayScoped({ method: "health", scopes: [] }); |
| expect(lastClientOptions?.scopes).toEqual([]); |
| }); |
| }); |
|
|
| describe("buildGatewayConnectionDetails", () => { |
| beforeEach(() => { |
| resetGatewayCallMocks(); |
| }); |
|
|
| it("uses explicit url overrides and omits bind details", () => { |
| setLocalLoopbackGatewayConfig(18800); |
| pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.1"); |
|
|
| const details = buildGatewayConnectionDetails({ |
| url: "wss://example.com/ws", |
| }); |
|
|
| expect(details.url).toBe("wss://example.com/ws"); |
| expect(details.urlSource).toBe("cli --url"); |
| expect(details.bindDetail).toBeUndefined(); |
| expect(details.remoteFallbackNote).toBeUndefined(); |
| expect(details.message).toContain("Gateway target: wss://example.com/ws"); |
| expect(details.message).toContain("Source: cli --url"); |
| }); |
|
|
| it("emits a remote fallback note when remote url is missing", () => { |
| loadConfig.mockReturnValue({ |
| gateway: { mode: "remote", bind: "loopback", remote: {} }, |
| }); |
| resolveGatewayPort.mockReturnValue(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe("ws://127.0.0.1:18789"); |
| expect(details.urlSource).toBe("missing gateway.remote.url (fallback local)"); |
| expect(details.bindDetail).toBe("Bind: loopback"); |
| expect(details.remoteFallbackNote).toContain( |
| "gateway.mode=remote but gateway.remote.url is missing", |
| ); |
| expect(details.message).toContain("Gateway target: ws://127.0.0.1:18789"); |
| }); |
|
|
| it.each([ |
| { |
| label: "with TLS", |
| gateway: { mode: "local", bind: "lan", tls: { enabled: true } }, |
| expectedUrl: "wss://127.0.0.1:18800", |
| }, |
| { |
| label: "without TLS", |
| gateway: { mode: "local", bind: "lan" }, |
| expectedUrl: "ws://127.0.0.1:18800", |
| }, |
| ])("uses loopback URL for bind=lan $label", ({ gateway, expectedUrl }) => { |
| loadConfig.mockReturnValue({ gateway }); |
| resolveGatewayPort.mockReturnValue(18800); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
| pickPrimaryLanIPv4.mockReturnValue("10.0.0.5"); |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe(expectedUrl); |
| expect(details.urlSource).toBe("local loopback"); |
| expect(details.bindDetail).toBe("Bind: lan"); |
| }); |
|
|
| it("prefers remote url when configured", () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "tailnet", |
| remote: { url: "wss://remote.example.com/ws" }, |
| }, |
| }); |
| resolveGatewayPort.mockReturnValue(18800); |
| pickPrimaryTailnetIPv4.mockReturnValue("100.64.0.9"); |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe("wss://remote.example.com/ws"); |
| expect(details.urlSource).toBe("config gateway.remote.url"); |
| expect(details.bindDetail).toBeUndefined(); |
| expect(details.remoteFallbackNote).toBeUndefined(); |
| }); |
|
|
| it("uses env OPENCLAW_GATEWAY_URL when set", () => { |
| loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); |
| resolveGatewayPort.mockReturnValue(18800); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
| const prevUrl = process.env.OPENCLAW_GATEWAY_URL; |
| try { |
| process.env.OPENCLAW_GATEWAY_URL = "wss://browser-gateway.local:9443/ws"; |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe("wss://browser-gateway.local:9443/ws"); |
| expect(details.urlSource).toBe("env OPENCLAW_GATEWAY_URL"); |
| expect(details.bindDetail).toBeUndefined(); |
| } finally { |
| if (prevUrl === undefined) { |
| delete process.env.OPENCLAW_GATEWAY_URL; |
| } else { |
| process.env.OPENCLAW_GATEWAY_URL = prevUrl; |
| } |
| } |
| }); |
|
|
| it("throws for insecure ws:// remote URLs (CWE-319)", () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| remote: { url: "ws://remote.example.com:18789" }, |
| }, |
| }); |
| resolveGatewayPort.mockReturnValue(18789); |
| pickPrimaryTailnetIPv4.mockReturnValue(undefined); |
|
|
| let thrown: unknown; |
| try { |
| buildGatewayConnectionDetails(); |
| } catch (error) { |
| thrown = error; |
| } |
| expect(thrown).toBeInstanceOf(Error); |
| expect((thrown as Error).message).toContain("SECURITY ERROR"); |
| expect((thrown as Error).message).toContain("plaintext ws://"); |
| expect((thrown as Error).message).toContain("wss://"); |
| expect((thrown as Error).message).toContain("Tailscale Serve/Funnel"); |
| expect((thrown as Error).message).toContain("openclaw doctor --fix"); |
| }); |
|
|
| it("allows ws:// private remote URLs only when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { |
| process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| remote: { url: "ws://10.0.0.8:18789" }, |
| }, |
| }); |
| resolveGatewayPort.mockReturnValue(18789); |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe("ws://10.0.0.8:18789"); |
| expect(details.urlSource).toBe("config gateway.remote.url"); |
| }); |
|
|
| it("allows ws:// hostname remote URLs when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { |
| process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| remote: { url: "ws://openclaw-gateway.ai:18789" }, |
| }, |
| }); |
| resolveGatewayPort.mockReturnValue(18789); |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe("ws://openclaw-gateway.ai:18789"); |
| expect(details.urlSource).toBe("config gateway.remote.url"); |
| }); |
|
|
| it("allows ws:// for loopback addresses in local mode", () => { |
| setLocalLoopbackGatewayConfig(); |
|
|
| const details = buildGatewayConnectionDetails(); |
|
|
| expect(details.url).toBe("ws://127.0.0.1:18789"); |
| }); |
| }); |
|
|
| describe("callGateway error details", () => { |
| beforeEach(() => { |
| resetGatewayCallMocks(); |
| }); |
|
|
| afterEach(() => { |
| vi.useRealTimers(); |
| }); |
|
|
| it("includes connection details when the gateway closes", async () => { |
| startMode = "close"; |
| closeCode = 1006; |
| closeReason = ""; |
| setLocalLoopbackGatewayConfig(); |
|
|
| let err: Error | null = null; |
| try { |
| await callGateway({ method: "health" }); |
| } catch (caught) { |
| err = caught as Error; |
| } |
|
|
| expect(err?.message).toContain("gateway closed (1006"); |
| expect(err?.message).toContain("Gateway target: ws://127.0.0.1:18789"); |
| expect(err?.message).toContain("Source: local loopback"); |
| expect(err?.message).toContain("Bind: loopback"); |
| }); |
|
|
| it("includes connection details on timeout", async () => { |
| startMode = "silent"; |
| setLocalLoopbackGatewayConfig(); |
|
|
| vi.useFakeTimers(); |
| let errMessage = ""; |
| const promise = callGateway({ method: "health", timeoutMs: 5 }).catch((caught) => { |
| errMessage = caught instanceof Error ? caught.message : String(caught); |
| }); |
|
|
| await vi.advanceTimersByTimeAsync(5); |
| await promise; |
|
|
| expect(errMessage).toContain("gateway timeout after 5ms"); |
| expect(errMessage).toContain("Gateway target: ws://127.0.0.1:18789"); |
| expect(errMessage).toContain("Source: local loopback"); |
| expect(errMessage).toContain("Bind: loopback"); |
| }); |
|
|
| it("does not overflow very large timeout values", async () => { |
| startMode = "silent"; |
| setLocalLoopbackGatewayConfig(); |
|
|
| vi.useFakeTimers(); |
| let errMessage = ""; |
| const promise = callGateway({ method: "health", timeoutMs: 2_592_010_000 }).catch((caught) => { |
| errMessage = caught instanceof Error ? caught.message : String(caught); |
| }); |
|
|
| await vi.advanceTimersByTimeAsync(1); |
| expect(errMessage).toBe(""); |
|
|
| lastClientOptions?.onClose?.(1006, ""); |
| await promise; |
|
|
| expect(errMessage).toContain("gateway closed (1006"); |
| }); |
|
|
| it("fails fast when remote mode is missing remote url", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { mode: "remote", bind: "loopback", remote: {} }, |
| }); |
| await expect( |
| callGateway({ |
| method: "health", |
| timeoutMs: 10, |
| }), |
| ).rejects.toThrow("gateway remote mode misconfigured"); |
| }); |
|
|
| it("fails before request when a required gateway method is missing", async () => { |
| setLocalLoopbackGatewayConfig(); |
| helloMethods = ["health"]; |
| await expect( |
| callGateway({ |
| method: "secrets.resolve", |
| requiredMethods: ["secrets.resolve"], |
| }), |
| ).rejects.toThrow(/does not support required method "secrets\.resolve"/i); |
| }); |
| }); |
|
|
| describe("callGateway url override auth requirements", () => { |
| let envSnapshot: ReturnType<typeof captureEnv>; |
|
|
| beforeEach(() => { |
| envSnapshot = captureEnv([ |
| "OPENCLAW_GATEWAY_TOKEN", |
| "OPENCLAW_GATEWAY_PASSWORD", |
| "OPENCLAW_GATEWAY_URL", |
| "CLAWDBOT_GATEWAY_URL", |
| ]); |
| resetGatewayCallMocks(); |
| setGatewayNetworkDefaults(18789); |
| }); |
|
|
| afterEach(() => { |
| envSnapshot.restore(); |
| }); |
|
|
| it("throws when url override is set without explicit credentials", async () => { |
| process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; |
| process.env.OPENCLAW_GATEWAY_PASSWORD = "env-password"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| auth: { token: "local-token", password: "local-password" }, |
| }, |
| }); |
|
|
| await expect( |
| callGateway({ method: "health", url: "wss://override.example/ws" }), |
| ).rejects.toThrow("explicit credentials"); |
| }); |
|
|
| it("throws when env URL override is set without env credentials", async () => { |
| process.env.OPENCLAW_GATEWAY_URL = "wss://override.example/ws"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| auth: { token: "local-token", password: "local-password" }, |
| }, |
| }); |
|
|
| await expect(callGateway({ method: "health" })).rejects.toThrow("explicit credentials"); |
| }); |
| }); |
|
|
| describe("callGateway password resolution", () => { |
| let envSnapshot: ReturnType<typeof captureEnv>; |
| const explicitAuthCases = [ |
| { |
| label: "password", |
| authKey: "password", |
| envKey: "OPENCLAW_GATEWAY_PASSWORD", |
| envValue: "from-env", |
| configValue: "from-config", |
| explicitValue: "explicit-password", |
| }, |
| { |
| label: "token", |
| authKey: "token", |
| envKey: "OPENCLAW_GATEWAY_TOKEN", |
| envValue: "env-token", |
| configValue: "local-token", |
| explicitValue: "explicit-token", |
| }, |
| ] as const; |
|
|
| beforeEach(() => { |
| envSnapshot = captureEnv([ |
| "OPENCLAW_GATEWAY_PASSWORD", |
| "OPENCLAW_GATEWAY_TOKEN", |
| "LOCAL_REMOTE_FALLBACK_TOKEN", |
| "LOCAL_REF_PASSWORD", |
| "REMOTE_REF_TOKEN", |
| "REMOTE_REF_PASSWORD", |
| ]); |
| resetGatewayCallMocks(); |
| delete process.env.OPENCLAW_GATEWAY_PASSWORD; |
| delete process.env.OPENCLAW_GATEWAY_TOKEN; |
| delete process.env.LOCAL_REMOTE_FALLBACK_TOKEN; |
| delete process.env.LOCAL_REF_PASSWORD; |
| delete process.env.REMOTE_REF_TOKEN; |
| delete process.env.REMOTE_REF_PASSWORD; |
| setGatewayNetworkDefaults(18789); |
| }); |
|
|
| afterEach(() => { |
| envSnapshot.restore(); |
| }); |
|
|
| it.each([ |
| { |
| label: "uses local config password when env is unset", |
| envPassword: undefined, |
| config: { |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { password: "secret" }, |
| }, |
| }, |
| expectedPassword: "secret", |
| }, |
| { |
| label: "prefers env password over local config password", |
| envPassword: "from-env", |
| config: { |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { password: "from-config" }, |
| }, |
| }, |
| expectedPassword: "from-env", |
| }, |
| { |
| label: "uses remote password in remote mode when env is unset", |
| envPassword: undefined, |
| config: makeRemotePasswordGatewayConfig("remote-secret"), |
| expectedPassword: "remote-secret", |
| }, |
| { |
| label: "prefers env password over remote password in remote mode", |
| envPassword: "from-env", |
| config: makeRemotePasswordGatewayConfig("remote-secret"), |
| expectedPassword: "from-env", |
| }, |
| ])("$label", async ({ envPassword, config, expectedPassword }) => { |
| if (envPassword !== undefined) { |
| process.env.OPENCLAW_GATEWAY_PASSWORD = envPassword; |
| } |
| loadConfig.mockReturnValue(config); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.password).toBe(expectedPassword); |
| }); |
|
|
| it("resolves gateway.auth.password SecretInput refs for gateway calls", async () => { |
| process.env.LOCAL_REF_PASSWORD = "resolved-local-ref-password"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { |
| mode: "password", |
| password: { source: "env", provider: "default", id: "LOCAL_REF_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.password).toBe("resolved-local-ref-password"); |
| }); |
|
|
| it("does not resolve local password ref when env password takes precedence", async () => { |
| process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { |
| mode: "password", |
| password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.password).toBe("from-env"); |
| }); |
|
|
| it("does not resolve local password ref when token auth can win", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { |
| mode: "token", |
| token: "token-auth", |
| password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBe("token-auth"); |
| }); |
|
|
| it("resolves local password ref before unresolved local token ref can block auth", async () => { |
| process.env.LOCAL_FALLBACK_PASSWORD = "resolved-local-fallback-password"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { |
| token: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_TOKEN" }, |
| password: { source: "env", provider: "default", id: "LOCAL_FALLBACK_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBeUndefined(); |
| expect(lastClientOptions?.password).toBe("resolved-local-fallback-password"); |
| }); |
|
|
| it("fails closed when unresolved local token SecretRef would otherwise fall back to remote token", async () => { |
| process.env.LOCAL_REMOTE_FALLBACK_TOKEN = "resolved-local-remote-fallback-token"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { |
| mode: "token", |
| token: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_TOKEN" }, |
| }, |
| remote: { |
| token: { source: "env", provider: "default", id: "LOCAL_REMOTE_FALLBACK_TOKEN" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.token"); |
| }); |
|
|
| it.each(["none", "trusted-proxy"] as const)( |
| "ignores unresolved local password ref when auth mode is %s", |
| async (mode) => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { |
| mode, |
| password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBeUndefined(); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }, |
| ); |
|
|
| it("does not resolve local password ref when remote password is already configured", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| auth: { |
| mode: "password", |
| password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" }, |
| }, |
| remote: { |
| url: "wss://remote.example:18789", |
| password: "remote-secret", |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.password).toBe("remote-secret"); |
| }); |
|
|
| it("resolves gateway.remote.token SecretInput refs when remote token is required", async () => { |
| process.env.REMOTE_REF_TOKEN = "resolved-remote-ref-token"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| auth: {}, |
| remote: { |
| url: "wss://remote.example:18789", |
| token: { source: "env", provider: "default", id: "REMOTE_REF_TOKEN" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBe("resolved-remote-ref-token"); |
| }); |
|
|
| it("resolves gateway.remote.password SecretInput refs when remote password is required", async () => { |
| process.env.REMOTE_REF_PASSWORD = "resolved-remote-ref-password"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| auth: {}, |
| remote: { |
| url: "wss://remote.example:18789", |
| password: { source: "env", provider: "default", id: "REMOTE_REF_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.password).toBe("resolved-remote-ref-password"); |
| }); |
|
|
| it("does not resolve remote token ref when remote password already wins", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| auth: {}, |
| remote: { |
| url: "wss://remote.example:18789", |
| token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, |
| password: "remote-password", |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBeUndefined(); |
| expect(lastClientOptions?.password).toBe("remote-password"); |
| }); |
|
|
| it("resolves remote token ref before unresolved remote password ref can block auth", async () => { |
| process.env.REMOTE_REF_TOKEN = "resolved-remote-ref-token"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| auth: {}, |
| remote: { |
| url: "wss://remote.example:18789", |
| token: { source: "env", provider: "default", id: "REMOTE_REF_TOKEN" }, |
| password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBe("resolved-remote-ref-token"); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }); |
|
|
| it("does not resolve remote password ref when remote token already wins", async () => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "remote", |
| bind: "loopback", |
| auth: {}, |
| remote: { |
| url: "wss://remote.example:18789", |
| token: "remote-token", |
| password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBe("remote-token"); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }); |
|
|
| it("resolves remote token refs on local-mode calls when fallback token can win", async () => { |
| process.env.LOCAL_FALLBACK_REMOTE_TOKEN = "resolved-local-fallback-remote-token"; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: {}, |
| remote: { |
| token: { source: "env", provider: "default", id: "LOCAL_FALLBACK_REMOTE_TOKEN" }, |
| password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBe("resolved-local-fallback-remote-token"); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }); |
|
|
| it.each(["none", "trusted-proxy"] as const)( |
| "does not resolve remote refs on non-remote gateway calls when auth mode is %s", |
| async (mode) => { |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| bind: "loopback", |
| auth: { mode }, |
| remote: { |
| url: "wss://remote.example:18789", |
| token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, |
| password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" }, |
| }, |
| }, |
| secrets: { |
| providers: { |
| default: { source: "env" }, |
| }, |
| }, |
| } as unknown as OpenClawConfig); |
|
|
| await callGateway({ method: "health" }); |
|
|
| expect(lastClientOptions?.token).toBeUndefined(); |
| expect(lastClientOptions?.password).toBeUndefined(); |
| }, |
| ); |
|
|
| it.each(explicitAuthCases)("uses explicit $label when url override is set", async (testCase) => { |
| process.env[testCase.envKey] = testCase.envValue; |
| const auth = { [testCase.authKey]: testCase.configValue } as { |
| password?: string; |
| token?: string; |
| }; |
| loadConfig.mockReturnValue({ |
| gateway: { |
| mode: "local", |
| auth, |
| }, |
| }); |
|
|
| await callGateway({ |
| method: "health", |
| url: "wss://override.example/ws", |
| [testCase.authKey]: testCase.explicitValue, |
| }); |
|
|
| expect(lastClientOptions?.[testCase.authKey]).toBe(testCase.explicitValue); |
| }); |
| }); |
|
|