diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9d04c0351..7d089c924e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. - Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. - Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. +- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. - Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. - Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. - BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 7d0f2c895de..04b0d995e42 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -122,6 +122,26 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.operator?.scopes).toEqual(["operator.read"]); }); + test("preserves existing token scopes when approving a repair without requested scopes", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedOperatorDevice(baseDir, ["operator.admin"]); + + const repair = await requestDevicePairing( + { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + }, + baseDir, + ); + await approveDevicePairing(repair.request.requestId, baseDir); + + const paired = await getPairedDevice("device-1", baseDir); + expect(paired?.scopes).toEqual(["operator.admin"]); + expect(paired?.approvedScopes).toEqual(["operator.admin"]); + expect(paired?.tokens?.operator?.scopes).toEqual(["operator.admin"]); + }); + test("rejects scope escalation when rotating a token and leaves state unchanged", 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 1bee5d34260..8885776ac6e 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -332,8 +332,17 @@ export async function approveDevicePairing( const tokens = existing?.tokens ? { ...existing.tokens } : {}; const roleForToken = normalizeRole(pending.role); if (roleForToken) { - const nextScopes = normalizeDeviceAuthScopes(pending.scopes); const existingToken = tokens[roleForToken]; + const requestedScopes = normalizeDeviceAuthScopes(pending.scopes); + const nextScopes = + requestedScopes.length > 0 + ? requestedScopes + : normalizeDeviceAuthScopes( + existingToken?.scopes ?? + approvedScopes ?? + existing?.approvedScopes ?? + existing?.scopes, + ); const now = Date.now(); tokens[roleForToken] = { token: newToken(),