Gateway: refresh websocket auth after secrets reload (#60323)

* Gateway: refresh websocket auth after secrets reload

* Gateway: always restore auth reload test globals

* chore: add changelog for websocket auth reload

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
mappel-nv
2026-04-03 16:35:31 -04:00
committed by GitHub
parent f3a6d13965
commit 21e53aea9e
6 changed files with 133 additions and 11 deletions

View File

@@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
- Slack/app manifest: set `bot_user.always_online` to `true` in the onboarding and example Slack app manifest so the Slack app appears ready to respond.
- Gateway/websocket auth: refresh auth on new websocket connects after secrets reload so rotated gateway tokens take effect immediately without requiring a restart. (#60323) Thanks @mappel-nv.
## 2026.4.2

View File

@@ -31,6 +31,7 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) {
canvasHostEnabled: params.canvasHostEnabled,
canvasHostServerPort: params.canvasHostServerPort,
resolvedAuth: params.resolvedAuth,
getResolvedAuth: params.getResolvedAuth,
rateLimiter: params.rateLimiter,
browserRateLimiter: params.browserRateLimiter,
gatewayMethods: params.gatewayMethods,

View File

@@ -80,6 +80,7 @@ import {
} from "../tasks/task-registry.maintenance.js";
import { runSetupWizard } from "../wizard/setup.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { resolveGatewayAuth } from "./auth.js";
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
import { startGatewayConfigReloader } from "./config-reload.js";
import type { ControlUiRootState } from "./control-ui.js";
@@ -633,6 +634,13 @@ export async function startGatewayServer(
tailscaleConfig,
tailscaleMode,
} = runtimeConfig;
const getResolvedAuth = () =>
resolveGatewayAuth({
authConfig: getRuntimeConfig().gateway?.auth,
authOverride: opts.auth,
env: process.env,
tailscaleMode,
});
let hooksConfig = runtimeConfig.hooksConfig;
let hookClientIpConfig = resolveHookClientIpConfig(cfgAtStart);
const canvasHostEnabled = runtimeConfig.canvasHostEnabled;
@@ -1311,6 +1319,7 @@ export async function startGatewayServer(
canvasHostEnabled: Boolean(canvasHost),
canvasHostServerPort,
resolvedAuth,
getResolvedAuth,
rateLimiter: authRateLimiter,
browserRateLimiter: browserAuthRateLimiter,
gatewayMethods,

View File

@@ -3,7 +3,9 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMainSessionKeyFromConfig } from "../config/sessions.js";
import { drainSystemEvents } from "../infra/system-events.js";
import { openTrackedWs } from "./device-authz.test-helpers.js";
import {
connectReq,
connectOk,
installGatewayTestHooks,
rpcReq,
@@ -848,12 +850,13 @@ process.stdin.on("end", () => {
const previousGatewayAuth = testState.gatewayAuth;
const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN;
testState.gatewayAuth = undefined;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
const started = await startServerWithClient();
const { server, ws, envSnapshot } = started;
let started: Awaited<ReturnType<typeof startServerWithClient>> | undefined;
try {
testState.gatewayAuth = undefined;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
started = await startServerWithClient();
const { ws } = started;
await connectOk(ws, {
token: tokenValue,
});
@@ -889,9 +892,114 @@ process.stdin.on("end", () => {
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv;
}
envSnapshot.restore();
ws.close();
await server.close();
started?.envSnapshot.restore();
started?.ws.close();
await started?.server.close();
}
});
it("uses refreshed gateway auth for new websocket connects after secrets reload", async () => {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("OPENCLAW_STATE_DIR is not set");
}
const resolverScriptPath = path.join(stateDir, "gateway-auth-refresh-resolver.cjs");
const tokenPath = path.join(stateDir, "gateway-auth-refresh-token.txt");
await fs.mkdir(path.dirname(resolverScriptPath), { recursive: true });
await fs.writeFile(
resolverScriptPath,
`const fs = require("node:fs");
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += chunk;
});
process.stdin.on("end", () => {
const tokenPath = process.argv[2];
const token = fs.readFileSync(tokenPath, "utf8").trim();
let ids = ["gateway/token"];
try {
const parsed = JSON.parse(input || "{}");
if (Array.isArray(parsed.ids) && parsed.ids.length > 0) {
ids = parsed.ids.map((entry) => String(entry));
}
} catch {}
const values = {};
for (const id of ids) {
values[id] = token;
}
process.stdout.write(JSON.stringify({ protocolVersion: 1, values }) + "\\n");
});
`,
"utf8",
);
await fs.writeFile(tokenPath, "token-before-reload\n", "utf8");
await writeConfigFile({
gateway: {
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
secrets: {
providers: {
vault: {
source: "exec",
command: process.execPath,
allowSymlinkCommand: true,
args: [resolverScriptPath, tokenPath],
},
},
},
});
const previousGatewayAuth = testState.gatewayAuth;
const previousGatewayTokenEnv = process.env.OPENCLAW_GATEWAY_TOKEN;
let started: Awaited<ReturnType<typeof startServerWithClient>> | undefined;
try {
testState.gatewayAuth = undefined;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
started = await startServerWithClient();
const { ws, port } = started;
await connectOk(ws, { token: "token-before-reload" });
await fs.writeFile(tokenPath, "token-after-reload\n", "utf8");
const reload = await rpcReq<{ warningCount?: number }>(ws, "secrets.reload", {});
expect(reload.ok).toBe(true);
const staleWs = await openTrackedWs(port);
try {
const staleConnect = await connectReq(staleWs, {
token: "token-before-reload",
skipDefaultAuth: true,
});
expect(staleConnect.ok).toBe(false);
expect(staleConnect.error?.message ?? "").toContain("gateway token mismatch");
} finally {
staleWs.close();
}
const freshWs = await openTrackedWs(port);
try {
await connectOk(freshWs, {
token: "token-after-reload",
skipDefaultAuth: true,
});
} finally {
freshWs.close();
}
} finally {
testState.gatewayAuth = previousGatewayAuth;
if (previousGatewayTokenEnv === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayTokenEnv;
}
started?.envSnapshot.restore();
started?.ws.close();
await started?.server.close();
}
});
});

View File

@@ -68,6 +68,7 @@ export type GatewayWsSharedHandlerParams = {
canvasHostEnabled: boolean;
canvasHostServerPort?: number;
resolvedAuth: ResolvedGatewayAuth;
getResolvedAuth?: () => ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
@@ -102,6 +103,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
canvasHostEnabled,
canvasHostServerPort,
resolvedAuth,
getResolvedAuth = () => resolvedAuth,
rateLimiter,
browserRateLimiter,
gatewayMethods,
@@ -313,7 +315,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
requestUserAgent,
canvasHostUrl,
connectNonce,
resolvedAuth,
getResolvedAuth,
rateLimiter,
browserRateLimiter,
gatewayMethods,

View File

@@ -159,7 +159,7 @@ export function attachGatewayWsMessageHandler(params: {
requestUserAgent?: string;
canvasHostUrl?: string;
connectNonce: string;
resolvedAuth: ResolvedGatewayAuth;
getResolvedAuth: () => ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
@@ -194,7 +194,7 @@ export function attachGatewayWsMessageHandler(params: {
requestUserAgent,
canvasHostUrl,
connectNonce,
resolvedAuth,
getResolvedAuth,
rateLimiter,
browserRateLimiter,
gatewayMethods,
@@ -433,6 +433,7 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = isOperatorUiClient(connectParams.client);
const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client);
const isWebchat = isWebchatConnect(connectParams);
const resolvedAuth = getResolvedAuth();
if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) {
const hostHeaderOriginFallbackEnabled =
configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;