fix(cli): stop devices approve from misdiagnosing a superseded pairing request (#124637)

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.
This commit is contained in:
Peter Steinberger
2026-08-16 07:27:45 -07:00
committed by GitHub
parent a5f9adf483
commit a3099a572d
2 changed files with 43 additions and 20 deletions
+31 -17
View File
@@ -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;
}
+12 -3
View File
@@ -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);
});