190 lines
5.8 KiB
TypeScript
190 lines
5.8 KiB
TypeScript
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<string, unknown>,
|
|
}));
|
|
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);
|
|
});
|
|
});
|