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); });