

























@@ -3,7 +3,10 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
33import { ADMIN_SCOPE, PAIRING_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
44import { isLoopbackHost } from "../gateway/net.js";
55import { 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";
710import {
811approveDevicePairing,
912formatDevicePairingForbiddenMessage,
@@ -82,6 +85,8 @@ type DevicePairingList = {
82858386const FALLBACK_NOTICE = "Direct scope access failed; using local fallback.";
8487const 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.";
8590const OPERATOR_ROLE = "operator";
8691const OPERATOR_SCOPE_PREFIX = "operator.";
8792const KNOWN_NON_ADMIN_OPERATOR_SCOPES = new Set<OperatorScope>([
@@ -143,24 +148,56 @@ function isDevicePairingApprovalDenied(error: unknown): boolean {
143148);
144149}
145150146-function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean {
151+function resolveLocalPairingFallback(
152+opts: DevicesRpcOpts,
153+error: unknown,
154+): { details: ConnectPairingRequiredDetails } | null {
147155const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error));
148-if (!readConnectPairingRequiredMessage(message)) {
149-return false;
156+const details = readConnectPairingRequiredMessage(message);
157+if (!details) {
158+return null;
150159}
151160if (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}
156165const connection = buildGatewayConnectionDetails();
157166if (connection.urlSource !== "local loopback") {
158-return false;
167+return null;
159168}
160169try {
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
176213try {
177214return parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {}));
178215} catch (error) {
179-if (!shouldUseLocalPairingFallback(opts, error)) {
216+const fallback = resolveLocalPairingFallback(opts, error);
217+if (!fallback) {
180218throw error;
181219}
182-if (opts.json !== true) {
183-defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
184-}
185220const local = await listDevicePairing();
186-return {
221+const list = {
187222pending: local.pending as PendingDevice[],
188223paired: 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) {
215256throw 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}
220262const 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.
223265callerScopes: ["operator.admin"],
224266});
225267if (!approved) {
268+if (gatewayRequestId && gatewayRequestId === requestId) {
269+throw buildFallbackStateMismatchError(fallback.details);
270+}
226271return null;
227272}
228273if (approved.status === "forbidden") {
229274throw new Error(formatDevicePairingForbiddenMessage(approved), { cause: error });
230275}
276+if (opts.json !== true) {
277+defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
278+}
231279return {
232280 requestId,
233281device: redactLocalPairedDevice(approved.device),
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。