diff --git a/CHANGELOG.md b/CHANGELOG.md index fe8868f5126..e8f4b3cc92a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index 6ffd2559642..398f8aacf9c 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -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, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f46e6307691..d799d9e63ec 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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, diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index fb6435a8235..366d31ed20c 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -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> | 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> | 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(); } }); }); diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 74880180348..64c7f503ab9 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -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, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 078db4643ae..0c4d6dcb38d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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;