fix(cli): guard device fallback state

* fix(cli): guard device fallback state

* test(agents): fix model fallback case typing
This commit is contained in:
Vincent Koc
2026-05-05 17:12:58 -07:00
committed by GitHub
parent d111605453
commit 01377ddbe2
3 changed files with 102 additions and 17 deletions

View File

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

View File

@@ -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();

View File

@@ -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<OperatorScope>([
@@ -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<DevicePair
try {
return parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {}));
} catch (error) {
if (!shouldUseLocalPairingFallback(opts, error)) {
const fallback = resolveLocalPairingFallback(opts, error);
if (!fallback) {
throw error;
}
if (opts.json !== true) {
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
}
const local = await listDevicePairing();
return {
const list = {
pending: local.pending as PendingDevice[],
paired: local.paired.map((device) => 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),