diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d906b59..0a3fd8512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ Docs: https://docs.openclaw.ai - Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. - 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: auto-approve loopback `scope-upgrade` pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber. - 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. - Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS. - Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index d85e06b38..32e03a4a4 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -1166,6 +1166,72 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); }); + test("auto-approves loopback scope upgrades for control ui clients", async () => { + const { mkdtemp } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { buildDeviceAuthPayload } = await import("./device-auth.js"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = + await import("../infra/device-pairing.js"); + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-token-scope-")); + const identity = loadOrCreateDeviceIdentity(join(identityDir, "device.json")); + const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const buildDevice = (scopes: string[], nonce: string) => { + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + role: "operator", + scopes, + signedAtMs, + token: "secret", + nonce, + }); + return { + id: identity.deviceId, + publicKey: devicePublicKey, + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce, + }; + }; + const seeded = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey: devicePublicKey, + role: "operator", + scopes: ["operator.read"], + clientId: CONTROL_UI_CLIENT.id, + clientMode: CONTROL_UI_CLIENT.mode, + displayName: "loopback-control-ui-upgrade", + platform: CONTROL_UI_CLIENT.platform, + }); + await approveDevicePairing(seeded.request.requestId); + + ws.close(); + + const ws2 = await openWs(port, { origin: originForPort(port) }); + const nonce2 = await readConnectChallengeNonce(ws2); + const upgraded = await connectReq(ws2, { + token: "secret", + scopes: ["operator.admin"], + client: { ...CONTROL_UI_CLIENT }, + device: buildDevice(["operator.admin"], nonce2), + }); + expect(upgraded.ok).toBe(true); + const pending = await listDevicePairing(); + expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]); + const updated = await getPairedDevice(identity.deviceId); + expect(updated?.tokens?.operator?.scopes).toContain("operator.admin"); + + ws2.close(); + await server.close(); + restoreGatewayToken(prevToken); + }); + test("still requires node pairing while operator shared auth succeeds for the same device", async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5305e6830..e8f8659a9 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -604,7 +604,7 @@ export function attachGatewayWsMessageHandler(params: { deviceId: device.id, publicKey: devicePublicKey, ...clientAccessMetadata, - silent: isLocalClient && reason === "not-paired", + silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"), }); const context = buildRequestContext(); if (pairing.request.silent === true) {