From c742963fd9dcbfa337bcf7079469df132924e332 Mon Sep 17 00:00:00 2001 From: Michael Appel Date: Fri, 3 Apr 2026 16:04:07 +0000 Subject: [PATCH] Gateway: avoid secret-ref auth disconnect churn --- src/gateway/server-methods/config.ts | 5 +- .../server.shared-auth-rotation.test.ts | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index e55a5fb99d9..dcf9e06579d 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { isDeepStrictEqual } from "node:util"; import { createConfigIO, parseConfigJson5, @@ -225,8 +226,8 @@ function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawConfig): const nextAuth = next.gateway?.auth; return ( prevAuth?.mode !== nextAuth?.mode || - prevAuth?.token !== nextAuth?.token || - prevAuth?.password !== nextAuth?.password + !isDeepStrictEqual(prevAuth?.token, nextAuth?.token) || + !isDeepStrictEqual(prevAuth?.password, nextAuth?.password) ); } diff --git a/src/gateway/server.shared-auth-rotation.test.ts b/src/gateway/server.shared-auth-rotation.test.ts index c62f904fa75..20ae9674274 100644 --- a/src/gateway/server.shared-auth-rotation.test.ts +++ b/src/gateway/server.shared-auth-rotation.test.ts @@ -15,9 +15,11 @@ import { installGatewayTestHooks({ scope: "suite" }); const ORIGINAL_GATEWAY_AUTH = testState.gatewayAuth; +const ORIGINAL_GATEWAY_TOKEN_ENV = process.env.OPENCLAW_GATEWAY_TOKEN; const OLD_TOKEN = "shared-token-old"; const NEW_TOKEN = "shared-token-new"; const TEST_METHODS = ["config.set", "config.patch", "config.apply"] as const; +const SECRET_REF_TOKEN_ID = "OPENCLAW_SHARED_AUTH_ROTATION_SECRET_REF"; let server: Awaited>; let port = 0; @@ -30,6 +32,11 @@ beforeAll(async () => { afterAll(async () => { testState.gatewayAuth = ORIGINAL_GATEWAY_AUTH; + if (ORIGINAL_GATEWAY_TOKEN_ENV === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = ORIGINAL_GATEWAY_TOKEN_ENV; + } await server.close(); }); @@ -149,3 +156,58 @@ describe("gateway shared auth rotation", () => { } }); }); + +describe("gateway shared auth rotation with unchanged SecretRefs", () => { + let secretRefServer: Awaited>; + let secretRefPort = 0; + + beforeAll(async () => { + secretRefPort = await getFreePort(); + process.env[SECRET_REF_TOKEN_ID] = OLD_TOKEN; + testState.gatewayAuth = { + mode: "token", + token: { source: "env", provider: "default", id: SECRET_REF_TOKEN_ID }, + }; + secretRefServer = await startGatewayServer(secretRefPort, { controlUiEnabled: true }); + }); + + afterAll(async () => { + delete process.env[SECRET_REF_TOKEN_ID]; + testState.gatewayAuth = ORIGINAL_GATEWAY_AUTH; + await secretRefServer.close(); + }); + + async function openSecretRefAuthenticatedWs(): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${secretRefPort}`); + trackConnectChallengeNonce(ws); + await new Promise((resolve) => ws.once("open", resolve)); + await connectOk(ws, { token: OLD_TOKEN }); + return ws; + } + + for (const method of ["config.set", "config.apply"] as const) { + it(`keeps shared-auth websocket sessions connected when ${method} reapplies an unchanged SecretRef token`, async () => { + const ws = await openSecretRefAuthenticatedWs(); + try { + const current = await rpcReq<{ + hash?: string; + config?: Record; + }>(ws, "config.get", {}); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + + const res = await rpcReq(ws, method, { + raw: JSON.stringify(current.payload?.config ?? {}, null, 2), + ...(method === "config.set" ? { baseHash: current.payload?.hash } : {}), + }); + expect(res.ok).toBe(true); + + const followUp = await rpcReq<{ hash?: string }>(ws, "config.get", {}); + expect(followUp.ok).toBe(true); + expect(typeof followUp.payload?.hash).toBe("string"); + } finally { + ws.close(); + } + }); + } +});