gateway: harden shared auth resolution across systemd, discord, and node host

This commit is contained in:
Josh Avant
2026-03-07 18:28:32 -06:00
committed by GitHub
parent a7f6e0a921
commit 25252ab5ab
28 changed files with 1498 additions and 255 deletions

View File

@@ -26,6 +26,25 @@ const writeStore = (store: Record<string, unknown>) => {
beforeEach(() => {
writeStore({});
mockGatewayClientCtor.mockClear();
mockResolveGatewayConnectionAuth.mockReset().mockImplementation(async (params: {
config?: {
gateway?: {
auth?: {
token?: string;
password?: string;
};
};
};
env: NodeJS.ProcessEnv;
}) => {
const configToken = params.config?.gateway?.auth?.token;
const configPassword = params.config?.gateway?.auth?.password;
const envToken = params.env.OPENCLAW_GATEWAY_TOKEN ?? params.env.CLAWDBOT_GATEWAY_TOKEN;
const envPassword =
params.env.OPENCLAW_GATEWAY_PASSWORD ?? params.env.CLAWDBOT_GATEWAY_PASSWORD;
return { token: envToken ?? configToken, password: envPassword ?? configPassword };
});
});
// ─── Mocks ────────────────────────────────────────────────────────────────────
@@ -37,6 +56,8 @@ const gatewayClientStarts = vi.hoisted(() => vi.fn());
const gatewayClientStops = vi.hoisted(() => vi.fn());
const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
vi.mock("../send.shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.shared.js")>();
@@ -59,6 +80,7 @@ vi.mock("../../gateway/client.js", () => ({
constructor(params: Record<string, unknown>) {
this.params = params;
gatewayClientParams.push(params);
mockGatewayClientCtor(params);
}
start() {
gatewayClientStarts();
@@ -72,6 +94,10 @@ vi.mock("../../gateway/client.js", () => ({
},
}));
vi.mock("../../gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
}));
vi.mock("../../logger.js", () => ({
logDebug: vi.fn(),
logError: vi.fn(),
@@ -776,3 +802,74 @@ describe("DiscordExecApprovalHandler delivery routing", () => {
clearPendingTimeouts(handler);
});
});
describe("DiscordExecApprovalHandler gateway auth resolution", () => {
it("passes CLI URL overrides to shared gateway auth resolver", async () => {
mockResolveGatewayConnectionAuth.mockResolvedValue({
token: "resolved-token",
password: "resolved-password", // pragma: allowlist secret
});
const handler = new DiscordExecApprovalHandler({
token: "test-token",
accountId: "default",
gatewayUrl: "wss://override.example/ws",
config: { enabled: true, approvers: ["123"] },
cfg: { session: { store: STORE_PATH } },
});
await handler.start();
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://override.example/ws",
urlOverrideSource: "cli",
}),
);
expect(mockGatewayClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://override.example/ws",
token: "resolved-token",
password: "resolved-password", // pragma: allowlist secret
}),
);
await handler.stop();
});
it("passes env URL overrides to shared gateway auth resolver", async () => {
const previousGatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
try {
process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-from-env.example/ws";
const handler = new DiscordExecApprovalHandler({
token: "test-token",
accountId: "default",
config: { enabled: true, approvers: ["123"] },
cfg: { session: { store: STORE_PATH } },
});
await handler.start();
expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
urlOverride: "wss://gateway-from-env.example/ws",
urlOverrideSource: "env",
}),
);
expect(mockGatewayClientCtor).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://gateway-from-env.example/ws",
}),
);
await handler.stop();
} finally {
if (typeof previousGatewayUrl === "string") {
process.env.OPENCLAW_GATEWAY_URL = previousGatewayUrl;
} else {
delete process.env.OPENCLAW_GATEWAY_URL;
}
}
});
});

View File

@@ -15,7 +15,7 @@ import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import { buildGatewayConnectionDetails } from "../../gateway/call.js";
import { GatewayClient } from "../../gateway/client.js";
import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
import { resolveGatewayConnectionAuth } from "../../gateway/connection-auth.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import type {
ExecApprovalDecision,
@@ -401,18 +401,27 @@ export class DiscordExecApprovalHandler {
logDebug("discord exec approvals: starting handler");
const { url: gatewayUrl } = buildGatewayConnectionDetails({
const { url: gatewayUrl, urlSource } = buildGatewayConnectionDetails({
config: this.opts.cfg,
url: this.opts.gatewayUrl,
});
const gatewayCredentials = resolveGatewayCredentialsFromConfig({
cfg: this.opts.cfg,
const gatewayUrlOverrideSource =
urlSource === "cli --url"
? "cli"
: urlSource === "env OPENCLAW_GATEWAY_URL"
? "env"
: undefined;
const auth = await resolveGatewayConnectionAuth({
config: this.opts.cfg,
env: process.env,
urlOverride: gatewayUrlOverrideSource ? gatewayUrl : undefined,
urlOverrideSource: gatewayUrlOverrideSource,
});
this.gatewayClient = new GatewayClient({
url: gatewayUrl,
token: gatewayCredentials.token,
password: gatewayCredentials.password,
token: auth.token,
password: auth.password,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "Discord Exec Approvals",
mode: GATEWAY_CLIENT_MODES.BACKEND,