fix(cli): retry admin device approval after ownership denial

This commit is contained in:
Peter Steinberger
2026-05-04 00:40:59 +01:00
parent baadd74b6b
commit c3f5c20f2c
6 changed files with 98 additions and 2 deletions

View File

@@ -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. - 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. - 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. - 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: 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. - 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. - 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.

View File

@@ -142,6 +142,13 @@ openclaw devices approve <requestId>
openclaw devices reject <requestId> openclaw devices reject <requestId>
``` ```
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 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 role/scopes/public key), the previous pending request is superseded and a new
`requestId` is created. `requestId` is created.

View File

@@ -78,8 +78,9 @@ When approving a device request:
`operator.admin`. `operator.admin`.
For paired-device token sessions, management is self-scoped unless the caller 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 also has `operator.admin`: non-admin callers see only their own pairing entries,
their own device entry. can approve or reject only their own pending request, and can rotate, revoke, or
remove only their own device entry.
## Node pairing approvals ## Node pairing approvals

View File

@@ -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 () => { it("uses admin scope when a repair approval would inherit an admin token", async () => {
callGateway callGateway
.mockResolvedValueOnce({ .mockResolvedValueOnce({

View File

@@ -137,6 +137,12 @@ function normalizeErrorMessage(error: unknown): string {
return String(error); return String(error);
} }
function isDevicePairingApprovalDenied(error: unknown): boolean {
return normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)).includes(
"device pairing approval denied",
);
}
function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean { function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean {
const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)); const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error));
if (!readConnectPairingRequiredMessage(message)) { if (!readConnectPairingRequiredMessage(message)) {
@@ -197,6 +203,14 @@ async function approvePairingWithFallback(
scopes ? { scopes } : undefined, scopes ? { scopes } : undefined,
); );
} catch (error) { } catch (error) {
if (isDevicePairingApprovalDenied(error) && !scopes?.includes(ADMIN_SCOPE)) {
return await callGatewayCli(
"device.pair.approve",
opts,
{ requestId },
{ scopes: [ADMIN_SCOPE] },
);
}
if (!shouldUseLocalPairingFallback(opts, error)) { if (!shouldUseLocalPairingFallback(opts, error)) {
throw error; throw error;
} }

View File

@@ -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 () => { it("allows approving the caller device from a non-admin device session", async () => {
getPendingDevicePairingMock.mockResolvedValue({ getPendingDevicePairingMock.mockResolvedValue({
requestId: "req-1", requestId: "req-1",