Skip to content

Commit 01377dd

Browse files
authored
fix(cli): guard device fallback state
* fix(cli): guard device fallback state * test(agents): fix model fallback case typing
1 parent d111605 commit 01377dd

3 files changed

Lines changed: 102 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ Docs: https://docs.openclaw.ai
434434
- 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.
435435
- Memory/LanceDB: declare `apache-arrow` in the bundled memory plugin package so LanceDB installs include its runtime peer. Fixes #76910. Thanks @afiqfiles-max.
436436
- 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.
437+
- 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.
437438
- 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.
438439
- 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.
439440
- 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.

src/cli/devices-cli.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,14 +582,50 @@ describe("devices cli local fallback", () => {
582582
});
583583

584584
it("falls back to local pairing list when gateway returns a scope upgrade message on loopback", async () => {
585-
mockLocalPairingFallback("scope upgrade pending approval (requestId: req-123)");
585+
mockLocalPairingFallback("scope upgrade pending approval (requestId: req-1)");
586586

587587
await runDevicesCommand(["list"]);
588588

589589
expect(listDevicePairing).toHaveBeenCalledTimes(1);
590590
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
591591
});
592592

593+
it("refuses local fallback when the gateway request is absent from local pairing state", async () => {
594+
rejectGatewayForLocalFallback("scope upgrade pending approval (requestId: req-profile)");
595+
listDevicePairing.mockResolvedValueOnce({
596+
pending: [{ requestId: "req-default", deviceId: "device-1", publicKey: "pk", ts: 1 }],
597+
paired: [],
598+
});
599+
summarizeDeviceTokens.mockReturnValue(undefined);
600+
601+
await expect(runDevicesCommand(["list"])).rejects.toThrow(
602+
"different OPENCLAW_PROFILE or OPENCLAW_STATE_DIR",
603+
);
604+
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
605+
});
606+
607+
it("refuses local approve fallback when the gateway request is absent locally", async () => {
608+
rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)");
609+
rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)");
610+
approveDevicePairing.mockResolvedValueOnce(undefined);
611+
612+
await expect(runDevicesApprove(["req-profile"])).rejects.toThrow(
613+
"local fallback pairing state does not contain the gateway request",
614+
);
615+
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
616+
});
617+
618+
it("refuses local approve fallback before approving a different local request", async () => {
619+
rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)");
620+
rejectGatewayForLocalFallback("device pairing required (requestId: req-profile)");
621+
622+
await expect(runDevicesApprove(["req-default"])).rejects.toThrow(
623+
"local fallback pairing state does not contain the gateway request",
624+
);
625+
expect(approveDevicePairing).not.toHaveBeenCalled();
626+
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
627+
});
628+
593629
it("does not use local fallback when an explicit --url is provided", async () => {
594630
rejectGatewayForLocalFallback();
595631

src/cli/devices-cli.ts

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
33
import { ADMIN_SCOPE, PAIRING_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
44
import { isLoopbackHost } from "../gateway/net.js";
55
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js";
6-
import { readConnectPairingRequiredMessage } from "../gateway/protocol/connect-error-details.js";
6+
import {
7+
readConnectPairingRequiredMessage,
8+
type ConnectPairingRequiredDetails,
9+
} from "../gateway/protocol/connect-error-details.js";
710
import {
811
approveDevicePairing,
912
formatDevicePairingForbiddenMessage,
@@ -82,6 +85,8 @@ type DevicePairingList = {
8285

8386
const FALLBACK_NOTICE = "Direct scope access failed; using local fallback.";
8487
const DEFAULT_DEVICES_TIMEOUT_MS = 10_000;
88+
const FALLBACK_STATE_MISMATCH_MESSAGE =
89+
"Gateway requires device pairing, but local fallback pairing state does not contain the gateway request.";
8590
const OPERATOR_ROLE = "operator";
8691
const OPERATOR_SCOPE_PREFIX = "operator.";
8792
const KNOWN_NON_ADMIN_OPERATOR_SCOPES = new Set<OperatorScope>([
@@ -143,24 +148,56 @@ function isDevicePairingApprovalDenied(error: unknown): boolean {
143148
);
144149
}
145150

146-
function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean {
151+
function resolveLocalPairingFallback(
152+
opts: DevicesRpcOpts,
153+
error: unknown,
154+
): { details: ConnectPairingRequiredDetails } | null {
147155
const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error));
148-
if (!readConnectPairingRequiredMessage(message)) {
149-
return false;
156+
const details = readConnectPairingRequiredMessage(message);
157+
if (!details) {
158+
return null;
150159
}
151160
if (typeof opts.url === "string" && opts.url.trim().length > 0) {
152161
// Explicit --url might point at a remote/tunneled gateway; never silently
153162
// switch to local pairing files in that case.
154-
return false;
163+
return null;
155164
}
156165
const connection = buildGatewayConnectionDetails();
157166
if (connection.urlSource !== "local loopback") {
158-
return false;
167+
return null;
159168
}
160169
try {
161-
return isLoopbackHost(new URL(connection.url).hostname);
170+
return isLoopbackHost(new URL(connection.url).hostname) ? { details } : null;
162171
} catch {
163-
return false;
172+
return null;
173+
}
174+
}
175+
176+
function buildFallbackStateMismatchError(details: ConnectPairingRequiredDetails): Error {
177+
return new Error(
178+
[
179+
details.requestId
180+
? `${FALLBACK_STATE_MISMATCH_MESSAGE} Missing requestId: ${details.requestId}.`
181+
: FALLBACK_STATE_MISMATCH_MESSAGE,
182+
"The running gateway is probably using a different OPENCLAW_PROFILE or OPENCLAW_STATE_DIR than this CLI.",
183+
"Rerun with the same profile/state-dir as the gateway, or pass --token/--password so the CLI can approve through the gateway.",
184+
].join("\n"),
185+
);
186+
}
187+
188+
function assertLocalFallbackMatchesGatewayRequest(
189+
details: ConnectPairingRequiredDetails,
190+
list: DevicePairingList,
191+
) {
192+
const requestId = normalizeOptionalString(details.requestId);
193+
if (!requestId) {
194+
return;
195+
}
196+
const hasRequest = (list.pending ?? []).some(
197+
(request) => normalizeOptionalString(request.requestId) === requestId,
198+
);
199+
if (!hasRequest) {
200+
throw buildFallbackStateMismatchError(details);
164201
}
165202
}
166203

@@ -176,17 +213,20 @@ async function listPairingWithFallback(opts: DevicesRpcOpts): Promise<DevicePair
176213
try {
177214
return parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {}));
178215
} catch (error) {
179-
if (!shouldUseLocalPairingFallback(opts, error)) {
216+
const fallback = resolveLocalPairingFallback(opts, error);
217+
if (!fallback) {
180218
throw error;
181219
}
182-
if (opts.json !== true) {
183-
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
184-
}
185220
const local = await listDevicePairing();
186-
return {
221+
const list = {
187222
pending: local.pending as PendingDevice[],
188223
paired: local.paired.map((device) => redactLocalPairedDevice(device)),
189224
};
225+
assertLocalFallbackMatchesGatewayRequest(fallback.details, list);
226+
if (opts.json !== true) {
227+
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
228+
}
229+
return list;
190230
}
191231
}
192232

@@ -211,23 +251,31 @@ async function approvePairingWithFallback(
211251
{ scopes: [ADMIN_SCOPE] },
212252
);
213253
}
214-
if (!shouldUseLocalPairingFallback(opts, error)) {
254+
const fallback = resolveLocalPairingFallback(opts, error);
255+
if (!fallback) {
215256
throw error;
216257
}
217-
if (opts.json !== true) {
218-
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
258+
const gatewayRequestId = normalizeOptionalString(fallback.details.requestId);
259+
if (gatewayRequestId && gatewayRequestId !== requestId) {
260+
throw buildFallbackStateMismatchError(fallback.details);
219261
}
220262
const approved = await approveDevicePairing(requestId, {
221263
// Local CLI fallback already assumes direct machine access; treat it as an
222264
// explicit admin approval path instead of relying on missing caller scopes.
223265
callerScopes: ["operator.admin"],
224266
});
225267
if (!approved) {
268+
if (gatewayRequestId && gatewayRequestId === requestId) {
269+
throw buildFallbackStateMismatchError(fallback.details);
270+
}
226271
return null;
227272
}
228273
if (approved.status === "forbidden") {
229274
throw new Error(formatDevicePairingForbiddenMessage(approved), { cause: error });
230275
}
276+
if (opts.json !== true) {
277+
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
278+
}
231279
return {
232280
requestId,
233281
device: redactLocalPairedDevice(approved.device),

0 commit comments

Comments
 (0)