Gateway: preserve token scopes on scope-less repair approvals

This commit is contained in:
Vignesh Natarajan 2026-02-21 19:37:15 -08:00
parent 55d492b4cd
commit 483c464b62
3 changed files with 31 additions and 1 deletions

View File

@ -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:<id>]]`, `[[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.

View File

@ -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"]);

View File

@ -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(),