From a7ab09fa4e79fdbaa6359d2b05e8038db810cf47 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Mon, 18 May 2026 15:23:55 +0300 Subject: [PATCH] fix(gateway): allow mobile OS metadata refresh (#83490) Merged via squash. Prepared head SHA: 5fae3757e95150b07cd2e864e96512547a4f1775 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- CHANGELOG.md | 1 + apps/ios/Sources/Model/NodeAppModel.swift | 2 +- ...essage-handler.post-connect-health.test.ts | 64 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 39 +++++++++-- src/infra/device-pairing.test.ts | 4 +- src/infra/device-pairing.ts | 11 +++- 6 files changed, 113 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0757c19f1..518aea4874a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23. - Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng. - Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg. +- Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman. ## 2026.5.17 diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 60748dacc33..badccc01a84 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -3962,7 +3962,7 @@ extension NodeAppModel { switch route { case let .agent(link): await self.handleAgentDeepLink(link, originalURL: url) - case .gateway: + case .gateway, .dashboard: break } } diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 0d9ef880c21..70f3d9498eb 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -444,4 +444,68 @@ describe("resolvePinnedClientMetadata", () => { }); }, ); + + it.each([ + ["openclaw-ios", "iOS 26.5.0", "iOS 26.4.2", "iPhone"], + ["openclaw-ios", "iPadOS 26.5.0", "iPadOS 26.4.2", "iPad"], + ["openclaw-ios", "iPadOS 26.5.0", "iOS 26.4.2", "iPad"], + ["openclaw-android", "Android 16", "Android 15", "Android"], + ])( + "allows %s platform version refresh without metadata-upgrade approval", + (clientId, claimedPlatform, pairedPlatform, deviceFamily) => { + expect( + __testing.resolvePinnedClientMetadata({ + clientId, + clientMode: "node", + claimedPlatform, + claimedDeviceFamily: deviceFamily, + pairedPlatform, + pairedDeviceFamily: deviceFamily, + }), + ).toEqual({ + platformMismatch: false, + deviceFamilyMismatch: false, + pinnedPlatform: claimedPlatform, + pinnedDeviceFamily: deviceFamily, + refreshPairedPlatform: claimedPlatform, + }); + }, + ); + + it("still requires approval when an iOS device family changes", () => { + expect( + __testing.resolvePinnedClientMetadata({ + clientId: "openclaw-ios", + clientMode: "node", + claimedPlatform: "iOS 26.5.0", + claimedDeviceFamily: "iPad", + pairedPlatform: "iOS 26.4.2", + pairedDeviceFamily: "iPhone", + }), + ).toEqual({ + platformMismatch: false, + deviceFamilyMismatch: true, + pinnedPlatform: "iOS 26.5.0", + pinnedDeviceFamily: "iPhone", + refreshPairedPlatform: "iOS 26.5.0", + }); + }); + + it("keeps non-mobile platform version changes approval-bound", () => { + expect( + __testing.resolvePinnedClientMetadata({ + clientId: "node-host", + clientMode: "node", + claimedPlatform: "linux 6.9", + claimedDeviceFamily: "Linux", + pairedPlatform: "linux 6.8", + pairedDeviceFamily: "Linux", + }), + ).toEqual({ + platformMismatch: true, + deviceFamilyMismatch: false, + pinnedPlatform: undefined, + pinnedDeviceFamily: "Linux", + }); + }); }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index c7fb25a0323..8b2631176f6 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -186,6 +186,7 @@ function resolvePinnedClientMetadata(params: { deviceFamilyMismatch: boolean; pinnedPlatform?: string; pinnedDeviceFamily?: string; + refreshPairedPlatform?: string; } { function normalizeLegacyNodeHostPlatformPin(value: string): string { switch (value) { @@ -200,6 +201,16 @@ function resolvePinnedClientMetadata(params: { } } + function normalizeMobileAppPlatformPin(clientId: string | undefined, value: string): string { + if (clientId === GATEWAY_CLIENT_IDS.IOS_APP && /^(?:ios|ipados)(?:\s|$)/.test(value)) { + return "ios-family"; + } + if (clientId === GATEWAY_CLIENT_IDS.ANDROID_APP && /^android(?:\s|$)/.test(value)) { + return "android"; + } + return value; + } + const claimedPlatform = normalizeDeviceMetadataForAuth(params.claimedPlatform); const claimedDeviceFamily = normalizeDeviceMetadataForAuth(params.claimedDeviceFamily); const pairedPlatform = normalizeDeviceMetadataForAuth(params.pairedPlatform); @@ -213,20 +224,32 @@ function resolvePinnedClientMetadata(params: { claimedPlatform !== "" && normalizeLegacyNodeHostPlatformPin(claimedPlatform) === normalizeLegacyNodeHostPlatformPin(pairedPlatform); + const isMobileAppPlatformVersionRefresh = + hasPinnedPlatform && + claimedPlatform !== "" && + claimedPlatform !== pairedPlatform && + normalizeMobileAppPlatformPin(params.clientId, claimedPlatform) === + normalizeMobileAppPlatformPin(params.clientId, pairedPlatform); const platformMismatch = - hasPinnedPlatform && claimedPlatform !== pairedPlatform && !isLegacyNodeHostPlatformPin; + hasPinnedPlatform && + claimedPlatform !== pairedPlatform && + !isLegacyNodeHostPlatformPin && + !isMobileAppPlatformVersionRefresh; const deviceFamilyMismatch = hasPinnedDeviceFamily && claimedDeviceFamily !== pairedDeviceFamily; const pinnedPlatform = claimedPlatform === pairedPlatform ? params.pairedPlatform : isLegacyNodeHostPlatformPin ? normalizeLegacyNodeHostPlatformPin(pairedPlatform) - : undefined; + : isMobileAppPlatformVersionRefresh + ? params.claimedPlatform + : undefined; return { platformMismatch, deviceFamilyMismatch, pinnedPlatform: hasPinnedPlatform ? pinnedPlatform : undefined, pinnedDeviceFamily: hasPinnedDeviceFamily ? params.pairedDeviceFamily : undefined, + ...(isMobileAppPlatformVersionRefresh ? { refreshPairedPlatform: params.claimedPlatform } : {}), }; } @@ -1284,9 +1307,15 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar } } - // Metadata pinning is approval-bound. Reconnects can update access metadata, - // but platform/device family must stay on the approved pairing record. - await updatePairedDeviceMetadata(device.id, clientAccessMetadata); + // Metadata pinning is approval-bound. Reconnects can update access metadata + // and same-family mobile OS version labels, but real platform/device-family + // changes must stay on the approved pairing record. + await updatePairedDeviceMetadata(device.id, { + ...clientAccessMetadata, + ...(metadataPinning.refreshPairedPlatform + ? { platform: metadataPinning.refreshPairedPlatform } + : {}), + }); } } diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index bc856ccf8c3..f9c3cdc58e5 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -714,7 +714,7 @@ describe("device pairing tokens", () => { }); }); - test("metadata refresh cannot mutate approved role and scope fields", async () => { + test("metadata refresh can update display metadata but not approved role and scope fields", async () => { const baseDir = await makeDevicePairingDir(); await setupPairedNodeDevice(baseDir); @@ -722,6 +722,7 @@ describe("device pairing tokens", () => { "node-1", { displayName: "renamed-node", + platform: "iOS 26.5.0", role: "operator", roles: ["operator"], scopes: ["operator.admin"], @@ -734,6 +735,7 @@ describe("device pairing tokens", () => { const paired = await getPairedDevice("node-1", baseDir); expect(paired?.displayName).toBe("renamed-node"); + expect(paired?.platform).toBe("iOS 26.5.0"); expect(paired?.publicKey).toBe("public-key-node-1"); expect(paired?.role).toBe("node"); expect(paired?.roles).toEqual(["node"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 1e3ee274bb7..579269cfb90 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -97,7 +97,13 @@ export type PairedDevice = { export type PairedDeviceMetadataPatch = Pick< PairedDevice, - "displayName" | "clientId" | "clientMode" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason" + | "displayName" + | "platform" + | "clientId" + | "clientMode" + | "remoteIp" + | "lastSeenAtMs" + | "lastSeenReason" >; export type DevicePairingList = { @@ -855,6 +861,9 @@ export async function updatePairedDeviceMetadata( if ("displayName" in patch) { next.displayName = patch.displayName; } + if ("platform" in patch) { + next.platform = patch.platform; + } if ("clientId" in patch) { next.clientId = patch.clientId; }