From a3099a572d18ca98ee3ce04dbea9d508136e6369 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 07:27:45 -0700 Subject: [PATCH] fix(cli): stop devices approve from misdiagnosing a superseded pairing request (#124637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the local pairing fallback could not find the gateway's requestId, the error always blamed a profile/state-dir mismatch and prescribed --token/--password — even on gateways with no shared auth, and even though a populated local pending list proves the CLI and gateway share one store and the held id was merely superseded by a re-minted request. The supersession case now names the current pending requestId with the exact approve command; the mismatch hypothesis and shared-auth flags remain only for an empty local pending list, phrased as possibilities. The fail-closed replacement-validation paths keep the mismatch wording so they never point at an incompatible pending request as a recovery step. --- src/cli/devices-cli.runtime.ts | 48 ++++++++++++++++++++++------------ src/cli/devices-cli.test.ts | 15 ++++++++--- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/cli/devices-cli.runtime.ts b/src/cli/devices-cli.runtime.ts index 992a6d20c0aa..526d1815edb5 100644 --- a/src/cli/devices-cli.runtime.ts +++ b/src/cli/devices-cli.runtime.ts @@ -330,16 +330,28 @@ function resolveLocalPairingFallback( } } -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 buildFallbackStateMismatchError( + details: ConnectPairingRequiredDetails, + pendingRequestIds: string[], +): Error { + const heading = details.requestId + ? `${FALLBACK_STATE_MISMATCH_MESSAGE} Missing requestId: ${details.requestId}.` + : FALLBACK_STATE_MISMATCH_MESSAGE; + // A populated local pending list means the CLI and gateway share this store: + // each rejected connect re-mints the request, so the held id is stale rather + // than foreign. Only an empty list suggests a genuinely different store, and + // shared-auth flags are only a fix when the gateway actually uses shared auth. + const guidance = + pendingRequestIds.length > 0 + ? [ + "That request was superseded by a newer pending request.", + `Approve the current request instead: openclaw devices approve ${pendingRequestIds[0]}`, + ] + : [ + "The running gateway may be using a different OPENCLAW_PROFILE or OPENCLAW_STATE_DIR than this CLI.", + "Rerun with the gateway's profile/state-dir; if the gateway uses shared auth, pass --token/--password to approve through it.", + ]; + return new Error([heading, ...guidance].join("\n")); } function assertLocalFallbackMatchesGatewayRequest( @@ -350,11 +362,11 @@ function assertLocalFallbackMatchesGatewayRequest( if (!requestId) { return; } - const hasRequest = (list.pending ?? []).some( - (request) => normalizeOptionalString(request.requestId) === requestId, - ); - if (!hasRequest) { - throw buildFallbackStateMismatchError(details); + const pendingRequestIds = (list.pending ?? []) + .map((request) => normalizeOptionalString(request.requestId)) + .filter((id): id is string => Boolean(id)); + if (!pendingRequestIds.includes(requestId)) { + throw buildFallbackStateMismatchError(details, pendingRequestIds); } } @@ -473,7 +485,9 @@ async function approvePairingWithFallback( if (!hasOriginalPending && !hasGatewayPending) { return null; } - throw buildFallbackStateMismatchError(fallback.details); + // Fail-closed replacement validation refused to substitute; do not point + // at the incompatible pending id as a recovery step. + throw buildFallbackStateMismatchError(fallback.details, []); } const approved = await approveDevicePairing(requestId, { // Local CLI fallback already assumes direct machine access; treat it as an @@ -482,7 +496,7 @@ async function approvePairingWithFallback( }); if (!approved) { if (gatewayRequestId && gatewayRequestId === requestId) { - throw buildFallbackStateMismatchError(fallback.details); + throw buildFallbackStateMismatchError(fallback.details, []); } return null; } diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index 13b10c590189..c40e85d12ed3 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -992,7 +992,7 @@ describe("devices cli local fallback", () => { expect(readRuntimeOutput()).toContain(fallbackNotice); }); - it("refuses local fallback when the gateway request is absent from local pairing state", async () => { + it("points at the current pending request when the gateway request id went stale", async () => { rejectGatewayForLocalFallback("scope upgrade pending approval (requestId: req-profile)"); listDevicePairing.mockResolvedValueOnce({ pending: [{ requestId: "req-default", deviceId: "device-1", publicKey: "pk", ts: 1 }], @@ -1000,9 +1000,18 @@ describe("devices cli local fallback", () => { }); summarizeDeviceTokens.mockReturnValue(undefined); - await expect(runDevicesCommand(["list"])).rejects.toThrow( - "different OPENCLAW_PROFILE or OPENCLAW_STATE_DIR", + // A populated shared pending list means supersession, not a foreign state + // dir — the recovery is the current id, never profile or shared-auth flags. + const failure = await runDevicesCommand(["list"]).then( + () => { + throw new Error("expected devices list to fail"); + }, + (error: unknown) => String(error), ); + expect(failure).toContain("superseded by a newer pending request"); + expect(failure).toContain("openclaw devices approve req-default"); + expect(failure).not.toContain("OPENCLAW_PROFILE"); + expect(failure).not.toContain("--token"); expect(readRuntimeOutput()).not.toContain(fallbackNotice); });