import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { callGatewayTool, resolveGatewayOptions } from "./gateway.js"; const callGatewayMock = vi.fn(); const configState = vi.hoisted(() => ({ value: {} as Record, })); vi.mock("../../config/config.js", () => ({ loadConfig: () => configState.value, resolveGatewayPort: () => 18789, })); vi.mock("../../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); describe("gateway tool defaults", () => { const envSnapshot = { openclaw: process.env.OPENCLAW_GATEWAY_TOKEN, clawdbot: process.env.CLAWDBOT_GATEWAY_TOKEN, }; beforeEach(() => { callGatewayMock.mockClear(); configState.value = {}; delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.CLAWDBOT_GATEWAY_TOKEN; }); afterAll(() => { if (envSnapshot.openclaw === undefined) { delete process.env.OPENCLAW_GATEWAY_TOKEN; } else { process.env.OPENCLAW_GATEWAY_TOKEN = envSnapshot.openclaw; } if (envSnapshot.clawdbot === undefined) { delete process.env.CLAWDBOT_GATEWAY_TOKEN; } else { process.env.CLAWDBOT_GATEWAY_TOKEN = envSnapshot.clawdbot; } }); it("leaves url undefined so callGateway can use config", () => { const opts = resolveGatewayOptions(); expect(opts.url).toBeUndefined(); }); it("accepts allowlisted gatewayUrl overrides (SSRF hardening)", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool( "health", { gatewayUrl: "ws://127.0.0.1:18789", gatewayToken: "t", timeoutMs: 5000 }, {}, ); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ url: "ws://127.0.0.1:18789", token: "t", timeoutMs: 5000, scopes: ["operator.read"], }), ); }); it("uses OPENCLAW_GATEWAY_TOKEN for allowlisted local overrides", () => { process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" }); expect(opts.url).toBe("ws://127.0.0.1:18789"); expect(opts.token).toBe("env-token"); }); it("falls back to config gateway.auth.token when env is unset for local overrides", () => { configState.value = { gateway: { auth: { token: "config-token" }, }, }; const opts = resolveGatewayOptions({ gatewayUrl: "ws://127.0.0.1:18789" }); expect(opts.token).toBe("config-token"); }); it("uses gateway.remote.token for allowlisted remote overrides", () => { configState.value = { gateway: { remote: { url: "wss://gateway.example", token: "remote-token", }, }, }; const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" }); expect(opts.url).toBe("wss://gateway.example"); expect(opts.token).toBe("remote-token"); }); it("does not leak local env/config tokens to remote overrides", () => { process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token"; process.env.CLAWDBOT_GATEWAY_TOKEN = "legacy-env-token"; configState.value = { gateway: { auth: { token: "local-config-token" }, remote: { url: "wss://gateway.example", }, }, }; const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" }); expect(opts.token).toBeUndefined(); }); it("ignores unresolved local token SecretRef for strict remote overrides", () => { configState.value = { gateway: { auth: { mode: "token", token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" }, }, remote: { url: "wss://gateway.example", }, }, secrets: { providers: { default: { source: "env" }, }, }, }; const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" }); expect(opts.token).toBeUndefined(); }); it("explicit gatewayToken overrides fallback token resolution", () => { process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token"; configState.value = { gateway: { remote: { url: "wss://gateway.example", token: "remote-token", }, }, }; const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example", gatewayToken: "explicit-token", }); expect(opts.token).toBe("explicit-token"); }); it("uses least-privilege write scope for write methods", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool("wake", {}, { mode: "now", text: "hi" }); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "wake", scopes: ["operator.write"], }), ); }); it("uses admin scope only for admin methods", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool("cron.add", {}, { id: "job-1" }); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "cron.add", scopes: ["operator.admin"], }), ); }); it("default-denies unknown methods by sending no scopes", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool("nonexistent.method", {}, {}); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ method: "nonexistent.method", scopes: [], }), ); }); it("rejects non-allowlisted overrides (SSRF hardening)", async () => { await expect( callGatewayTool("health", { gatewayUrl: "ws://127.0.0.1:8080", gatewayToken: "t" }, {}), ).rejects.toThrow(/gatewayUrl override rejected/i); await expect( callGatewayTool("health", { gatewayUrl: "ws://169.254.169.254", gatewayToken: "t" }, {}), ).rejects.toThrow(/gatewayUrl override rejected/i); }); });