diff --git a/CHANGELOG.md b/CHANGELOG.md index a04c0019f09..1f4f95954c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -434,6 +434,7 @@ Docs: https://docs.openclaw.ai - 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. +- CLI/devices: stop local pairing fallback when the active Gateway names a pending request that is absent from the local pairing store, so profile or state-dir mismatches no longer make `openclaw devices list/approve` inspect the wrong store while a real device stays blocked. Thanks @vincentkoc. - 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/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index cf4ea329acb..e68077097b5 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -582,7 +582,7 @@ describe("devices cli local fallback", () => { }); it("falls back to local pairing list when gateway returns a scope upgrade message on loopback", async () => { - mockLocalPairingFallback("scope upgrade pending approval (requestId: req-123)"); + mockLocalPairingFallback("scope upgrade pending approval (requestId: req-1)"); await runDevicesCommand(["list"]); @@ -590,6 +590,42 @@ describe("devices cli local fallback", () => { expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); }); + it("refuses local fallback when the gateway request is absent from local pairing state", async () => { + rejectGatewayForLocalFallback("scope upgrade pending approval (requestId: req-profile)"); + listDevicePairing.mockResolvedValueOnce({ + pending: [{ requestId: "req-default", deviceId: "device-1", publicKey: "pk", ts: 1 }], + paired: [], + }); + summarizeDeviceTokens.mockReturnValue(undefined); + + await expect(runDevicesCommand(["list"])).rejects.toThrow( + "different OPENCLAW_PROFILE or OPENCLAW_STATE_DIR", + ); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); + }); + + it("refuses local approve fallback when the gateway request is absent locally", async () => { + rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)"); + rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)"); + approveDevicePairing.mockResolvedValueOnce(undefined); + + await expect(runDevicesApprove(["req-profile"])).rejects.toThrow( + "local fallback pairing state does not contain the gateway request", + ); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); + }); + + it("refuses local approve fallback before approving a different local request", async () => { + rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)"); + rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)"); + + await expect(runDevicesApprove(["req-default"])).rejects.toThrow( + "local fallback pairing state does not contain the gateway request", + ); + expect(approveDevicePairing).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); + }); + it("does not use local fallback when an explicit --url is provided", async () => { rejectGatewayForLocalFallback(); diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index aef38deca54..e1a13a8a4c0 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -3,7 +3,10 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { ADMIN_SCOPE, PAIRING_SCOPE, type OperatorScope } from "../gateway/method-scopes.js"; import { isLoopbackHost } from "../gateway/net.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; -import { readConnectPairingRequiredMessage } from "../gateway/protocol/connect-error-details.js"; +import { + readConnectPairingRequiredMessage, + type ConnectPairingRequiredDetails, +} from "../gateway/protocol/connect-error-details.js"; import { approveDevicePairing, formatDevicePairingForbiddenMessage, @@ -82,6 +85,8 @@ type DevicePairingList = { const FALLBACK_NOTICE = "Direct scope access failed; using local fallback."; const DEFAULT_DEVICES_TIMEOUT_MS = 10_000; +const FALLBACK_STATE_MISMATCH_MESSAGE = + "Gateway requires device pairing, but local fallback pairing state does not contain the gateway request."; const OPERATOR_ROLE = "operator"; const OPERATOR_SCOPE_PREFIX = "operator."; const KNOWN_NON_ADMIN_OPERATOR_SCOPES = new Set([ @@ -143,24 +148,56 @@ function isDevicePairingApprovalDenied(error: unknown): boolean { ); } -function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean { +function resolveLocalPairingFallback( + opts: DevicesRpcOpts, + error: unknown, +): { details: ConnectPairingRequiredDetails } | null { const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)); - if (!readConnectPairingRequiredMessage(message)) { - return false; + const details = readConnectPairingRequiredMessage(message); + if (!details) { + return null; } if (typeof opts.url === "string" && opts.url.trim().length > 0) { // Explicit --url might point at a remote/tunneled gateway; never silently // switch to local pairing files in that case. - return false; + return null; } const connection = buildGatewayConnectionDetails(); if (connection.urlSource !== "local loopback") { - return false; + return null; } try { - return isLoopbackHost(new URL(connection.url).hostname); + return isLoopbackHost(new URL(connection.url).hostname) ? { details } : null; } catch { - return false; + return null; + } +} + +function buildFallbackStateMismatchError(details: ConnectPairingRequiredDetails): Error { + return new Error( + [ + details.requestId + ? `${FALLBACK_STATE_MISMATCH_MESSAGE} Missing requestId: ${details.requestId}.` + : FALLBACK_STATE_MISMATCH_MESSAGE, + "The running gateway is probably using a different OPENCLAW_PROFILE or OPENCLAW_STATE_DIR than this CLI.", + "Rerun with the same profile/state-dir as the gateway, or pass --token/--password so the CLI can approve through the gateway.", + ].join("\n"), + ); +} + +function assertLocalFallbackMatchesGatewayRequest( + details: ConnectPairingRequiredDetails, + list: DevicePairingList, +) { + const requestId = normalizeOptionalString(details.requestId); + if (!requestId) { + return; + } + const hasRequest = (list.pending ?? []).some( + (request) => normalizeOptionalString(request.requestId) === requestId, + ); + if (!hasRequest) { + throw buildFallbackStateMismatchError(details); } } @@ -176,17 +213,20 @@ async function listPairingWithFallback(opts: DevicesRpcOpts): Promise redactLocalPairedDevice(device)), }; + assertLocalFallbackMatchesGatewayRequest(fallback.details, list); + if (opts.json !== true) { + defaultRuntime.log(theme.warn(FALLBACK_NOTICE)); + } + return list; } } @@ -211,11 +251,13 @@ async function approvePairingWithFallback( { scopes: [ADMIN_SCOPE] }, ); } - if (!shouldUseLocalPairingFallback(opts, error)) { + const fallback = resolveLocalPairingFallback(opts, error); + if (!fallback) { throw error; } - if (opts.json !== true) { - defaultRuntime.log(theme.warn(FALLBACK_NOTICE)); + const gatewayRequestId = normalizeOptionalString(fallback.details.requestId); + if (gatewayRequestId && gatewayRequestId !== requestId) { + throw buildFallbackStateMismatchError(fallback.details); } const approved = await approveDevicePairing(requestId, { // Local CLI fallback already assumes direct machine access; treat it as an @@ -223,11 +265,17 @@ async function approvePairingWithFallback( callerScopes: ["operator.admin"], }); if (!approved) { + if (gatewayRequestId && gatewayRequestId === requestId) { + throw buildFallbackStateMismatchError(fallback.details); + } return null; } if (approved.status === "forbidden") { throw new Error(formatDevicePairingForbiddenMessage(approved), { cause: error }); } + if (opts.json !== true) { + defaultRuntime.log(theme.warn(FALLBACK_NOTICE)); + } return { requestId, device: redactLocalPairedDevice(approved.device),