Skip to content

Commit 6ea907c

Browse files
fix(cli): recover replaced device approvals (#85342)
Summary: - The PR teaches `openclaw devices approve <requestId>` to approve a compatible same-device replacement request during local fallback and adds focused CLI, infra, and changelog coverage. - Reproducibility: yes. Source inspection shows current main rejects the gateway's replacement requestId as a ... adds focused infra and CLI tests for the churn path; I did not run tests because this review is read-only. Automerge notes: - PR branch already contained follow-up commit before automerge: docs: note device approval recovery Validation: - ClawSweeper review passed for head 1d2f2e9. - Required merge gates passed before the squash merge. Prepared head SHA: 1d2f2e9 Review: #85342 (comment) Co-authored-by: masonxhuang <masonxhuang@tencent.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: hxy91819 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
1 parent 0def3e2 commit 6ea907c

4 files changed

Lines changed: 1073 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
5252
- Doctor/Codex: point native Codex asset warnings at the canonical `openclaw migrate plan codex` preview command. Fixes #84948. Thanks @markoa.
5353
- CLI/models: make `capability model auth logout --agent` remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.
5454
- CLI/status: suppress systemd user-service setup hints when `openclaw status --deep` can already reach a running Gateway RPC service. Fixes #85094. Thanks @islandpreneur007.
55+
- CLI/devices: recover local approval when a same-device repair request replaces the request ID being approved.
5556
- CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded `openclaw agent` execution.
5657
- CLI/update: keep managed Gateway service stop/restart status lines out of `openclaw update --json` stdout so package-update automation can parse the JSON payload.
5758
- Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.

src/cli/devices-cli.runtime.ts

Lines changed: 189 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ type PendingDevice = {
6161
deviceId: string;
6262
publicKey?: string;
6363
displayName?: string;
64+
clientId?: string;
65+
clientMode?: string;
6466
role?: string;
6567
roles?: string[];
6668
scopes?: string[];
@@ -86,6 +88,11 @@ type DevicePairingList = {
8688
paired?: PairedDevice[];
8789
};
8890

91+
type ApprovePairingGatewayContext = {
92+
originalRequest: PendingDevice | null;
93+
scopes?: OperatorScope[];
94+
};
95+
8996
const FALLBACK_NOTICE = "Direct scope access failed; using local fallback.";
9097
const DEFAULT_DEVICES_TIMEOUT_MS = 10_000;
9198
const FALLBACK_STATE_MISMATCH_MESSAGE =
@@ -225,7 +232,7 @@ async function approvePairingWithFallback(
225232
opts: DevicesRpcOpts,
226233
requestId: string,
227234
): Promise<Record<string, unknown> | null> {
228-
const scopes = await resolveApprovePairingGatewayScopes(opts, requestId);
235+
const { scopes, originalRequest } = await resolveApprovePairingGatewayContext(opts, requestId);
229236
try {
230237
return await callGatewayCli(
231238
"device.pair.approve",
@@ -248,6 +255,53 @@ async function approvePairingWithFallback(
248255
}
249256
const gatewayRequestId = normalizeOptionalString(fallback.details.requestId);
250257
if (gatewayRequestId && gatewayRequestId !== requestId) {
258+
const local = await listDevicePairing();
259+
const localList = {
260+
pending: local.pending as PendingDevice[],
261+
paired: local.paired.map((device) => redactLocalPairedDevice(device)),
262+
};
263+
const replacement = findSameDeviceReplacementRequest({
264+
originalRequest,
265+
originalRequestId: requestId,
266+
gatewayRequestId,
267+
pending: localList.pending,
268+
paired: localList.paired,
269+
});
270+
if (replacement) {
271+
const approved = await approveDevicePairing(replacement.requestId, {
272+
callerScopes: ["operator.admin"],
273+
});
274+
if (!approved) {
275+
return null;
276+
}
277+
if (approved.status === "forbidden") {
278+
throw new Error(formatDevicePairingForbiddenMessage(approved), { cause: error });
279+
}
280+
if (opts.json !== true) {
281+
defaultRuntime.log(
282+
theme.warn(
283+
`Pending request ${sanitizeForLog(requestId)} was replaced by same-device repair ${sanitizeForLog(replacement.requestId)}; approving latest compatible request.`,
284+
),
285+
);
286+
defaultRuntime.log(theme.warn(FALLBACK_NOTICE));
287+
}
288+
return {
289+
requestId: replacement.requestId,
290+
resolved: {
291+
kind: "same-device-replacement",
292+
requestedRequestId: requestId,
293+
approvedRequestId: replacement.requestId,
294+
},
295+
device: redactLocalPairedDevice(approved.device),
296+
};
297+
}
298+
const hasOriginalPending = Boolean(findPendingRequestById(localList.pending, requestId));
299+
const hasGatewayPending = Boolean(
300+
findPendingRequestById(localList.pending, gatewayRequestId),
301+
);
302+
if (!hasOriginalPending && !hasGatewayPending) {
303+
return null;
304+
}
251305
throw buildFallbackStateMismatchError(fallback.details);
252306
}
253307
const approved = await approveDevicePairing(requestId, {
@@ -303,6 +357,122 @@ function normalizeOperatorScopes(scopes: string[] | undefined): string[] {
303357
);
304358
}
305359

360+
function findPendingRequestById(
361+
pending: PendingDevice[] | undefined,
362+
requestId: string | null | undefined,
363+
): PendingDevice | null {
364+
const normalizedRequestId = normalizeOptionalString(requestId);
365+
if (!normalizedRequestId) {
366+
return null;
367+
}
368+
return (
369+
pending?.find(
370+
(request) => normalizeOptionalString(request.requestId) === normalizedRequestId,
371+
) ?? null
372+
);
373+
}
374+
375+
function hasExactRoleMatch(original: PendingDevice, replacement: PendingDevice): boolean {
376+
const originalRoles = normalizeDeviceRoles(original);
377+
const replacementRoles = normalizeDeviceRoles(replacement);
378+
if (originalRoles.length !== replacementRoles.length) {
379+
return false;
380+
}
381+
const replacementRoleSet = new Set(replacementRoles);
382+
return originalRoles.every((role) => replacementRoleSet.has(role));
383+
}
384+
385+
function hasCompatibleClientMetadata(original: PendingDevice, replacement: PendingDevice): boolean {
386+
const originalClientId = normalizeOptionalString(original.clientId);
387+
const replacementClientId = normalizeOptionalString(replacement.clientId);
388+
if (originalClientId && replacementClientId && originalClientId !== replacementClientId) {
389+
return false;
390+
}
391+
const originalClientMode = normalizeOptionalString(original.clientMode);
392+
const replacementClientMode = normalizeOptionalString(replacement.clientMode);
393+
return !(
394+
originalClientMode &&
395+
replacementClientMode &&
396+
originalClientMode !== replacementClientMode
397+
);
398+
}
399+
400+
function resolveOriginalReplacementScopes(
401+
original: PendingDevice,
402+
paired: PairedDevice | undefined,
403+
): string[] {
404+
const requestedScopes = normalizeDeviceAuthScopes(original.scopes);
405+
const inferredOperatorScopes = resolvePendingOperatorApprovalScopes(original, paired);
406+
return [...new Set([...requestedScopes, ...inferredOperatorScopes])];
407+
}
408+
409+
function replacementScopesCoverOriginal(
410+
original: PendingDevice,
411+
replacement: PendingDevice,
412+
paired: PairedDevice | undefined,
413+
): boolean {
414+
const originalScopes = resolveOriginalReplacementScopes(original, paired);
415+
const replacementScopes = normalizeDeviceAuthScopes(replacement.scopes);
416+
const replacementScopeSet = new Set(replacementScopes);
417+
if (!originalScopes.every((scope) => replacementScopeSet.has(scope))) {
418+
return false;
419+
}
420+
// Same-device repair reconnects can supersede a stale request with a combined
421+
// request that appends the pairing scope required for the repaired session to
422+
// reconnect and complete approval.
423+
return replacementScopes.every(
424+
(scope) => originalScopes.includes(scope) || scope === PAIRING_SCOPE,
425+
);
426+
}
427+
428+
function findSameDeviceReplacementRequest(params: {
429+
originalRequest: PendingDevice | null;
430+
originalRequestId: string;
431+
gatewayRequestId: string;
432+
pending: PendingDevice[] | undefined;
433+
paired: PairedDevice[] | undefined;
434+
}): PendingDevice | null {
435+
const originalRequestId = normalizeOptionalString(params.originalRequestId);
436+
if (!params.originalRequest || !originalRequestId) {
437+
// Without the pre-approve snapshot we cannot prove that the gateway's newer
438+
// request is the same-device repair contract the operator intended to approve.
439+
return null;
440+
}
441+
if (normalizeOptionalString(params.originalRequest.requestId) !== originalRequestId) {
442+
return null;
443+
}
444+
const replacement = findPendingRequestById(params.pending, params.gatewayRequestId);
445+
if (!replacement) {
446+
return null;
447+
}
448+
const originalDeviceId = normalizeOptionalString(params.originalRequest.deviceId);
449+
const replacementDeviceId = normalizeOptionalString(replacement.deviceId);
450+
if (!originalDeviceId || originalDeviceId !== replacementDeviceId) {
451+
return null;
452+
}
453+
const originalPublicKey = normalizeOptionalString(params.originalRequest.publicKey);
454+
const replacementPublicKey = normalizeOptionalString(replacement.publicKey);
455+
if (!originalPublicKey || !replacementPublicKey || originalPublicKey !== replacementPublicKey) {
456+
return null;
457+
}
458+
if (!hasExactRoleMatch(params.originalRequest, replacement)) {
459+
return null;
460+
}
461+
if (!hasCompatibleClientMetadata(params.originalRequest, replacement)) {
462+
return null;
463+
}
464+
const pairedByDeviceId = indexPairedDevices(params.paired);
465+
const originalPaired = lookupPairedDevice(pairedByDeviceId, params.originalRequest);
466+
const replacementPaired = lookupPairedDevice(pairedByDeviceId, replacement);
467+
if (!replacementScopesCoverOriginal(params.originalRequest, replacement, originalPaired)) {
468+
return null;
469+
}
470+
if (replacement.isRepair !== true && (!originalPaired || !replacementPaired)) {
471+
return null;
472+
}
473+
return replacement;
474+
}
475+
306476
function resolvePairedOperatorScopes(paired: PairedDevice | undefined): string[] {
307477
const operatorToken = paired?.tokens?.find((token) => {
308478
const role = normalizeOptionalString(token.role);
@@ -347,22 +517,25 @@ function resolveApprovePairingScopesForRequest(
347517
return [...out];
348518
}
349519

350-
async function resolveApprovePairingGatewayScopes(
520+
async function resolveApprovePairingGatewayContext(
351521
opts: DevicesRpcOpts,
352522
requestId: string,
353-
): Promise<OperatorScope[] | undefined> {
523+
): Promise<ApprovePairingGatewayContext> {
354524
try {
355525
const list = await listPairingWithFallback(opts);
356-
const request = list.pending?.find((pending) => pending.requestId === requestId);
526+
const request = findPendingRequestById(list.pending, requestId);
357527
if (!request) {
358-
return undefined;
528+
return { originalRequest: null, scopes: undefined };
359529
}
360-
return resolveApprovePairingScopesForRequest(
361-
request,
362-
lookupPairedDevice(indexPairedDevices(list.paired), request),
363-
);
530+
return {
531+
originalRequest: request,
532+
scopes: resolveApprovePairingScopesForRequest(
533+
request,
534+
lookupPairedDevice(indexPairedDevices(list.paired), request),
535+
),
536+
};
364537
} catch {
365-
return undefined;
538+
return { originalRequest: null, scopes: undefined };
366539
}
367540
}
368541

@@ -753,9 +926,14 @@ export async function runDevicesApproveCommand(
753926
defaultRuntime.writeJson(result);
754927
return;
755928
}
929+
const resultRequestId = (result as { requestId?: unknown })?.requestId;
930+
const approvedRequestId =
931+
typeof resultRequestId === "string" && resultRequestId.trim().length > 0
932+
? resultRequestId
933+
: resolvedRequestId;
756934
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
757935
defaultRuntime.log(
758-
`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")} ${theme.muted(`(${resolvedRequestId})`)}`,
936+
`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")} ${theme.muted(`(${approvedRequestId})`)}`,
759937
);
760938
}
761939

0 commit comments

Comments
 (0)