refactor(gateway): unify credential precedence across entrypoints

This commit is contained in:
Peter Steinberger
2026-02-22 18:54:58 +01:00
parent 98427453ba
commit 08431da5d5
15 changed files with 636 additions and 96 deletions

View File

@@ -1,9 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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: () => ({}),
loadConfig: () => configState.value,
resolveGatewayPort: () => 18789,
}));
vi.mock("../../gateway/call.js", () => ({
@@ -11,8 +14,29 @@ vi.mock("../../gateway/call.js", () => ({
}));
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", () => {
@@ -37,6 +61,69 @@ describe("gateway tool defaults", () => {
);
});
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("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" });

View File

@@ -1,5 +1,6 @@
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js";
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { readStringParam } from "./common.js";
@@ -12,6 +13,8 @@ export type GatewayCallOptions = {
timeoutMs?: number;
};
type GatewayOverrideTarget = "local" | "remote";
export function readGatewayCallOptions(params: Record<string, unknown>): GatewayCallOptions {
return {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
@@ -50,10 +53,13 @@ function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: strin
return { origin, key };
}
function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string {
const cfg = loadConfig();
function validateGatewayUrlOverrideForAgentTools(params: {
cfg: ReturnType<typeof loadConfig>;
urlOverride: string;
}): { url: string; target: GatewayOverrideTarget } {
const { cfg } = params;
const port = resolveGatewayPort(cfg);
const allowed = new Set<string>([
const localAllowed = new Set<string>([
`ws://127.0.0.1:${port}`,
`wss://127.0.0.1:${port}`,
`ws://localhost:${port}`,
@@ -62,45 +68,73 @@ function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string {
`wss://[::1]:${port}`,
]);
let remoteKey: string | undefined;
const remoteUrl =
typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : "";
if (remoteUrl) {
try {
const remote = canonicalizeToolGatewayWsUrl(remoteUrl);
allowed.add(remote.key);
remoteKey = remote.key;
} catch {
// ignore: misconfigured remote url; tools should fall back to default resolution.
}
}
const parsed = canonicalizeToolGatewayWsUrl(urlOverride);
if (!allowed.has(parsed.key)) {
throw new Error(
[
"gatewayUrl override rejected.",
`Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`,
"Or: configure gateway.remote.url and omit gatewayUrl to use the configured remote gateway.",
].join(" "),
);
const parsed = canonicalizeToolGatewayWsUrl(params.urlOverride);
if (localAllowed.has(parsed.key)) {
return { url: parsed.origin, target: "local" };
}
return parsed.origin;
if (remoteKey && parsed.key === remoteKey) {
return { url: parsed.origin, target: "remote" };
}
throw new Error(
[
"gatewayUrl override rejected.",
`Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`,
"Or: configure gateway.remote.url and omit gatewayUrl to use the configured remote gateway.",
].join(" "),
);
}
function resolveGatewayOverrideToken(params: {
cfg: ReturnType<typeof loadConfig>;
target: GatewayOverrideTarget;
explicitToken?: string;
}): string | undefined {
if (params.explicitToken) {
return params.explicitToken;
}
return resolveGatewayCredentialsFromConfig({
cfg: params.cfg,
env: process.env,
modeOverride: params.target,
remoteTokenFallback: params.target === "remote" ? "remote-only" : "remote-env-local",
remotePasswordFallback: params.target === "remote" ? "remote-only" : "remote-env-local",
}).token;
}
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
// Prefer an explicit override; otherwise let callGateway choose based on config.
const url =
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
? validateGatewayUrlOverrideForAgentTools(opts.gatewayUrl)
: undefined;
const token =
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
? opts.gatewayToken.trim()
const cfg = loadConfig();
const validatedOverride =
trimToUndefined(opts?.gatewayUrl) !== undefined
? validateGatewayUrlOverrideForAgentTools({
cfg,
urlOverride: String(opts?.gatewayUrl),
})
: undefined;
const explicitToken = trimToUndefined(opts?.gatewayToken);
const token = validatedOverride
? resolveGatewayOverrideToken({
cfg,
target: validatedOverride.target,
explicitToken,
})
: explicitToken;
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: 30_000;
return { url, token, timeoutMs };
return { url: validatedOverride?.url, token, timeoutMs };
}
export async function callGatewayTool<T = Record<string, unknown>>(