mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:20:45 +00:00
fix(cli): retry admin device approval after ownership denial
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user