mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(cli): guard device fallback state
* fix(cli): guard device fallback state * test(agents): fix model fallback case typing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user