fix(gateway): allow mobile OS metadata refresh (#83490)

Merged via squash.

Prepared head SHA: 5fae3757e9
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
Nimrod Gutman 2026-05-18 15:23:55 +03:00 committed by GitHub
parent 384ddae86f
commit a7ab09fa4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 113 additions and 8 deletions

View file

@ -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

View file

@ -3962,7 +3962,7 @@ extension NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case .gateway:
case .gateway, .dashboard:
break
}
}

View file

@ -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",
});
});
});

View file

@ -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 }
: {}),
});
}
}

View file

@ -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"]);

View file

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