diff --git a/CHANGELOG.md b/CHANGELOG.md index aadfd82e5de..b346ee32922 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ Docs: https://docs.openclaw.ai ### Fixes - Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. Thanks @jadewon and @vincentkoc. +- Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf. ## 2026.4.1-beta.1 + - Plugins/runtime: stop ambient core helper and setup paths from loading non-selected bundled plugins, keep channel-setup snapshot scoping safe for custom channel plugins, and honor env-scoped plugin auth paths. (#59136) Thanks @vincentkoc. - Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras. - Gateway/pairing: prefer explicit QR bootstrap auth over earlier Tailscale auth classification so iOS `/pair qr` silent bootstrap pairing does not fall through to `pairing required`. (#59232) Thanks @ngutman. diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index d8b18a89a95..9f0ff1b6552 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -65,7 +65,7 @@ describe("handshake auth helpers", () => { }); }); - it("allows silent local pairing only for not-paired and scope upgrades", () => { + it("allows silent local pairing for not-paired, scope-upgrade and role-upgrade", () => { expect( shouldAllowSilentLocalPairing({ isLocalClient: true, @@ -75,6 +75,24 @@ describe("handshake auth helpers", () => { reason: "not-paired", }), ).toBe(true); + expect( + shouldAllowSilentLocalPairing({ + isLocalClient: true, + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "role-upgrade", + }), + ).toBe(true); + expect( + shouldAllowSilentLocalPairing({ + isLocalClient: true, + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "scope-upgrade", + }), + ).toBe(true); expect( shouldAllowSilentLocalPairing({ isLocalClient: true, @@ -85,4 +103,16 @@ describe("handshake auth helpers", () => { }), ).toBe(false); }); + + it("rejects silent role-upgrade for remote clients", () => { + expect( + shouldAllowSilentLocalPairing({ + isLocalClient: false, + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "role-upgrade", + }), + ).toBe(false); + }); }); diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 72793fb325d..262fd64f743 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -55,7 +55,9 @@ export function shouldAllowSilentLocalPairing(params: { return ( params.isLocalClient && (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) && - (params.reason === "not-paired" || params.reason === "scope-upgrade") + (params.reason === "not-paired" || + params.reason === "scope-upgrade" || + params.reason === "role-upgrade") ); } diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 758c2308d98..6d7e9d5d5b0 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -593,6 +593,21 @@ describe("device pairing tokens", () => { expect(hasEffectivePairedDeviceRole(paired, "node")).toBe(false); }); + test("falls back to legacy role fields when tokens map is empty", async () => { + const device: PairedDevice = { + deviceId: "device-fallback", + publicKey: "pk-fallback", + role: "node", + roles: ["node", "operator"], + tokens: {}, + createdAtMs: Date.now(), + approvedAtMs: Date.now(), + }; + expect(listEffectivePairedDeviceRoles(device)).toEqual(["node", "operator"]); + expect(hasEffectivePairedDeviceRole(device, "node")).toBe(true); + expect(hasEffectivePairedDeviceRole(device, "operator")).toBe(true); + }); + test("removes paired devices by device id", async () => { const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); await setupPairedOperatorDevice(baseDir, ["operator.read"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index d894d202081..26d342a2937 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -170,8 +170,15 @@ export function listEffectivePairedDeviceRoles( device: Pick, ): string[] { const activeTokenRoles = listActiveTokenRoles(device.tokens); - if (device.tokens) { - return activeTokenRoles ?? []; + if (activeTokenRoles && activeTokenRoles.length > 0) { + return activeTokenRoles; + } + // Only fall back to legacy role fields when the tokens map is absent + // or has no entries at all (empty object from a fresh pairing record). + // When token entries exist but are all revoked, the revocation is + // authoritative — do not re-grant roles from sticky historical fields. + if (device.tokens && Object.keys(device.tokens).length > 0) { + return []; } return mergeRoles(device.roles, device.role) ?? []; }