diff --git a/CHANGELOG.md b/CHANGELOG.md index 267849c5c54..39b09f92781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: let trusted official `@openclaw/*` catalog installs recover when npm `latest` points at a prerelease by falling back to the newest stable version, or by selecting the newest exact prerelease for prerelease-only launch packages with a warning instead of making beta/development plugin sweeps fail at install time. Thanks @vincentkoc. - Google Meet: grant Chrome media permissions against the actual Meet tab, start the local realtime audio bridge only after Meet joins, expose realtime transcripts in status/logs, and force explicit audio responses with current OpenAI realtime output-audio events so BlackHole capture does not keep the OpenClaw participant muted or silent. - Memory/LanceDB: declare `apache-arrow` in the bundled memory plugin package so LanceDB installs include its runtime peer. Fixes #76910. Thanks @afiqfiles-max. +- CLI/devices: retry explicit device-pair approval with `operator.admin` after a pairing-scope ownership denial, so existing admin-capable paired-device tokens can recover new Control UI/browser pairing after upgrades instead of requiring manual JSON edits. Fixes #76956. Thanks @neo19482. - Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted. - Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health. - Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear. diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index 50284c72a2d..81b5e22f23a 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -142,6 +142,13 @@ openclaw devices approve openclaw devices reject ``` +When an explicit approval is denied because the approving paired-device session +was opened with pairing-only scope, the CLI retries the same request with +`operator.admin`. This lets an existing admin-capable paired device recover a new +Control UI/browser pairing without editing `devices/paired.json` by hand. The +Gateway still validates the retried connection; tokens that cannot authenticate +with `operator.admin` remain blocked. + If the same device retries with different auth details (for example different role/scopes/public key), the previous pending request is superseded and a new `requestId` is created. diff --git a/docs/gateway/operator-scopes.md b/docs/gateway/operator-scopes.md index 83c1a08f4a1..95d341e5110 100644 --- a/docs/gateway/operator-scopes.md +++ b/docs/gateway/operator-scopes.md @@ -78,8 +78,9 @@ When approving a device request: `operator.admin`. For paired-device token sessions, management is self-scoped unless the caller -also has `operator.admin`: non-admin callers can rotate, revoke, or remove only -their own device entry. +also has `operator.admin`: non-admin callers see only their own pairing entries, +can approve or reject only their own pending request, and can rotate, revoke, or +remove only their own device entry. ## Node pairing approvals diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index 5069526ff34..cf4ea329acb 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -171,6 +171,36 @@ describe("devices cli approve", () => { ); }); + it("retries explicit approval with admin scope when a paired-device session is ownership-denied", async () => { + callGateway + .mockResolvedValueOnce({ + pending: [], + paired: [], + }) + .mockRejectedValueOnce(new Error("GatewayClientRequestError: device pairing approval denied")) + .mockResolvedValueOnce({ device: { deviceId: "device-2" } }); + + await runDevicesApprove(["req-cross-device"]); + + expect(callGateway).toHaveBeenCalledTimes(3); + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-cross-device" }, + scopes: undefined, + }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-cross-device" }, + scopes: ["operator.admin"], + }), + ); + }); + it("uses admin scope when a repair approval would inherit an admin token", async () => { callGateway .mockResolvedValueOnce({ diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 1ca700f2fb4..aef38deca54 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -137,6 +137,12 @@ function normalizeErrorMessage(error: unknown): string { return String(error); } +function isDevicePairingApprovalDenied(error: unknown): boolean { + return normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)).includes( + "device pairing approval denied", + ); +} + function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean { const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)); if (!readConnectPairingRequiredMessage(message)) { @@ -197,6 +203,14 @@ async function approvePairingWithFallback( scopes ? { scopes } : undefined, ); } catch (error) { + if (isDevicePairingApprovalDenied(error) && !scopes?.includes(ADMIN_SCOPE)) { + return await callGatewayCli( + "device.pair.approve", + opts, + { requestId }, + { scopes: [ADMIN_SCOPE] }, + ); + } if (!shouldUseLocalPairingFallback(opts, error)) { throw error; } diff --git a/src/gateway/server-methods/devices.test.ts b/src/gateway/server-methods/devices.test.ts index 98eb0e7e436..ef1d6e89835 100644 --- a/src/gateway/server-methods/devices.test.ts +++ b/src/gateway/server-methods/devices.test.ts @@ -612,6 +612,49 @@ describe("deviceHandlers", () => { ); }); + it("allows admins to approve another device", async () => { + approveDevicePairingMock.mockResolvedValue({ + status: "approved", + requestId: "req-2", + device: { + deviceId: "device-2", + publicKey: "pk-2", + approvedAtMs: 200, + createdAtMs: 150, + }, + }); + const opts = createOptions( + "device.pair.approve", + { requestId: "req-2" }, + { + client: createClient(["operator.admin"], "device-1", { + isDeviceTokenAuth: true, + }), + }, + ); + + await deviceHandlers["device.pair.approve"](opts); + + expect(getPendingDevicePairingMock).not.toHaveBeenCalled(); + expect(approveDevicePairingMock).toHaveBeenCalledWith("req-2", { + callerScopes: ["operator.admin"], + }); + expect(opts.respond).toHaveBeenCalledWith( + true, + { + requestId: "req-2", + device: { + deviceId: "device-2", + publicKey: "pk-2", + approvedAtMs: 200, + createdAtMs: 150, + tokens: undefined, + }, + }, + undefined, + ); + }); + it("allows approving the caller device from a non-admin device session", async () => { getPendingDevicePairingMock.mockResolvedValue({ requestId: "req-1",