From 29ad0736f44566fcbc955a1f642a84c16d20f43f Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:45:56 -0800 Subject: [PATCH] fix(gateway): tolerate legacy paired metadata in ws upgrade checks (#21447) Fixes the pairing required regression from #21236 for legacy paired devices created without roles/scopes metadata. Detects legacy paired metadata shape and skips upgrade enforcement while backfilling metadata in place on reconnect. Co-authored-by: Josh Avant <830519+joshavant@users.noreply.github.com> Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- CHANGELOG.md | 1 + src/gateway/server.auth.e2e.test.ts | 65 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 52 ++++++++------- 3 files changed, 94 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7115898c0f4..250552cc420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. - Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. - Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012. diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 7ee747abf5e..16415d0b71e 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -948,6 +948,71 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("allows legacy paired devices missing role/scope metadata", async () => { + const { resolvePairingPaths, readJsonFile } = await import("../infra/pairing-files.js"); + const { writeJsonAtomic } = await import("../infra/json-files.js"); + const { getPairedDevice } = await import("../infra/device-pairing.js"); + const { + device, + identity: { deviceId }, + } = await createSignedDevice({ + token: "secret", + scopes: ["operator.read"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + }); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + let ws2: WebSocket | undefined; + try { + const initial = await connectReq(ws, { + token: "secret", + scopes: ["operator.read"], + client: TEST_OPERATOR_CLIENT, + device, + }); + if (!initial.ok) { + await approvePendingPairingIfNeeded(); + } + + const initialPaired = await getPairedDevice(deviceId); + expect(initialPaired?.roles).toContain("operator"); + expect(initialPaired?.scopes).toContain("operator.read"); + + const { pairedPath } = resolvePairingPaths(undefined, "devices"); + const paired = + (await readJsonFile>>(pairedPath)) ?? {}; + const legacy = paired[deviceId]; + if (!legacy) { + throw new Error(`Expected paired metadata for deviceId=${deviceId}`); + } + + delete legacy.roles; + delete legacy.scopes; + await writeJsonAtomic(pairedPath, paired); + ws.close(); + + const wsReconnect = new WebSocket(`ws://127.0.0.1:${port}`); + ws2 = wsReconnect; + await new Promise((resolve) => wsReconnect.once("open", resolve)); + const reconnect = await connectReq(wsReconnect, { + token: "secret", + scopes: ["operator.read"], + client: TEST_OPERATOR_CLIENT, + device, + }); + expect(reconnect.ok).toBe(true); + + const repaired = await getPairedDevice(deviceId); + expect(repaired?.roles).toContain("operator"); + expect(repaired?.scopes).toContain("operator.read"); + } finally { + await server.close(); + restoreGatewayToken(prevToken); + ws.close(); + ws2?.close(); + } + }); + test("rejects revoked device token", async () => { const { revokeDeviceToken } = await import("../infra/device-pairing.js"); const { server, ws, port, prevToken } = await startServerWithClient("secret"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a3d5f9c29c6..43cdd94e164 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -711,43 +711,47 @@ export function attachGatewayWsMessageHandler(params: { return; } } else { + const hasLegacyPairedMetadata = + paired.roles === undefined && paired.scopes === undefined; const pairedRoles = Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : []; - const allowedRoles = new Set(pairedRoles); - if (allowedRoles.size === 0) { - logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); - const ok = await requirePairing("role-upgrade"); - if (!ok) { - return; - } - } else if (!allowedRoles.has(role)) { - logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); - const ok = await requirePairing("role-upgrade"); - if (!ok) { - return; - } - } - - const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : []; - if (scopes.length > 0) { - if (pairedScopes.length === 0) { - logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); - const ok = await requirePairing("scope-upgrade"); + if (!hasLegacyPairedMetadata) { + const allowedRoles = new Set(pairedRoles); + if (allowedRoles.size === 0) { + logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); + const ok = await requirePairing("role-upgrade"); if (!ok) { return; } - } else { - const allowedScopes = new Set(pairedScopes); - const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); - if (missingScope) { + } else if (!allowedRoles.has(role)) { + logUpgradeAudit("role-upgrade", pairedRoles, paired.scopes); + const ok = await requirePairing("role-upgrade"); + if (!ok) { + return; + } + } + + const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : []; + if (scopes.length > 0) { + if (pairedScopes.length === 0) { logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); const ok = await requirePairing("scope-upgrade"); if (!ok) { return; } + } else { + const allowedScopes = new Set(pairedScopes); + const missingScope = scopes.find((scope) => !allowedScopes.has(scope)); + if (missingScope) { + logUpgradeAudit("scope-upgrade", pairedRoles, pairedScopes); + const ok = await requirePairing("scope-upgrade"); + if (!ok) { + return; + } + } } } }