


























@@ -61,6 +61,8 @@ type PendingDevice = {
6161deviceId: string;
6262publicKey?: string;
6363displayName?: string;
64+clientId?: string;
65+clientMode?: string;
6466role?: string;
6567roles?: string[];
6668scopes?: string[];
@@ -86,6 +88,11 @@ type DevicePairingList = {
8688paired?: PairedDevice[];
8789};
889091+type ApprovePairingGatewayContext = {
92+originalRequest: PendingDevice | null;
93+scopes?: OperatorScope[];
94+};
95+8996const FALLBACK_NOTICE = "Direct scope access failed; using local fallback.";
9097const DEFAULT_DEVICES_TIMEOUT_MS = 10_000;
9198const FALLBACK_STATE_MISMATCH_MESSAGE =
@@ -225,7 +232,7 @@ async function approvePairingWithFallback(
225232opts: DevicesRpcOpts,
226233requestId: string,
227234): Promise<Record<string, unknown> | null> {
228-const scopes = await resolveApprovePairingGatewayScopes(opts, requestId);
235+const { scopes, originalRequest } = await resolveApprovePairingGatewayContext(opts, requestId);
229236try {
230237return await callGatewayCli(
231238"device.pair.approve",
@@ -248,6 +255,53 @@ async function approvePairingWithFallback(
248255}
249256const gatewayRequestId = normalizeOptionalString(fallback.details.requestId);
250257if (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+}
251305throw buildFallbackStateMismatchError(fallback.details);
252306}
253307const approved = await approveDevicePairing(requestId, {
@@ -303,6 +357,122 @@ function normalizeOperatorScopes(scopes: string[] | undefined): string[] {
303357);
304358}
305359360+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+306476function resolvePairedOperatorScopes(paired: PairedDevice | undefined): string[] {
307477const operatorToken = paired?.tokens?.find((token) => {
308478const role = normalizeOptionalString(token.role);
@@ -347,22 +517,25 @@ function resolveApprovePairingScopesForRequest(
347517return [...out];
348518}
349519350-async function resolveApprovePairingGatewayScopes(
520+async function resolveApprovePairingGatewayContext(
351521opts: DevicesRpcOpts,
352522requestId: string,
353-): Promise<OperatorScope[] | undefined> {
523+): Promise<ApprovePairingGatewayContext> {
354524try {
355525const list = await listPairingWithFallback(opts);
356-const request = list.pending?.find((pending) => pending.requestId === requestId);
526+const request = findPendingRequestById(list.pending, requestId);
357527if (!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(
753926defaultRuntime.writeJson(result);
754927return;
755928}
929+const resultRequestId = (result as { requestId?: unknown })?.requestId;
930+const approvedRequestId =
931+typeof resultRequestId === "string" && resultRequestId.trim().length > 0
932+ ? resultRequestId
933+ : resolvedRequestId;
756934const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
757935defaultRuntime.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此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。