From 6e1017d88aa70def04e9b9d5aee28507a98478f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 21:00:25 +0100 Subject: [PATCH] fix: allow native app metadata reconnects --- CHANGELOG.md | 4 ++ docs/gateway/pairing.md | 11 +++-- .../handshake-auth-helpers.test.ts | 46 +++++++++++++++++++ .../ws-connection/handshake-auth-helpers.ts | 19 ++++---- .../server/ws-connection/message-handler.ts | 23 ++++++++-- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1412553de..2a6a6efd866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -615,6 +615,10 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Gateway pairing: silently accept same-host native app + `metadata-upgrade` reconnects, so macOS patch-version changes update paired + metadata instead of spamming security audit warnings and `pairing required` + disconnects. Thanks @steipete. - CLI/Gateway: wait for one-shot gateway RPC clients to finish WebSocket teardown before the CLI process exits, reducing hangs where commands like `openclaw status` or `openclaw version` could finish their work but stay alive until an external timeout killed them (#70691). Thanks @Takhoffman. - Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime (#70601). Thanks @Takhoffman. - Gateway/model pricing: extend OpenRouter and LiteLLM catalog fetch timeouts to 60 seconds, reducing noisy timeout warnings during slow upstream responses. Thanks @steipete. diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 4029912905f..73efed1157d 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -148,11 +148,12 @@ Security boundary: When an already paired device reconnects with only non-sensitive metadata changes (for example, display name or client platform hints), OpenClaw treats that as a `metadata-upgrade`. Silent auto-approval is narrow: it applies only -to trusted local CLI/helper reconnects that already proved possession of the -shared token or password over loopback. Browser/Control UI clients and remote -clients still use the explicit re-approval flow. Scope upgrades (read to -write/admin) and public key changes are **not** eligible for metadata-upgrade -auto-approval — they stay as explicit re-approval requests. +to trusted non-browser local reconnects that already proved possession of local +or shared credentials, including same-host native app reconnects after OS +version metadata changes. Browser/Control UI clients and remote clients still +use the explicit re-approval flow. Scope upgrades (read to write/admin) and +public key changes are **not** eligible for metadata-upgrade auto-approval — +they stay as explicit re-approval requests. ## QR pairing helpers diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index 1965b137e5c..0cd73f16f1f 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -574,6 +574,31 @@ describe("handshake auth helpers", () => { }); describe("shouldAllowSilentLocalPairing — metadata-upgrade reason", () => { + it("allows silent metadata-upgrade for direct local native app clients without browser origin", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "direct_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + isNativeAppUi: true, + reason: "metadata-upgrade", + }), + ).toBe(true); + }); + + it("still requires approval for direct local node metadata-upgrade", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "direct_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(false); + }); + it("allows silent metadata-upgrade for cli_container_local CLI clients", () => { expect( shouldAllowSilentLocalPairing({ @@ -621,6 +646,27 @@ describe("handshake auth helpers", () => { }), ).toBe(false); }); + + it("still requires approval for direct local Browser or Control UI metadata-upgrade", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "direct_local", + hasBrowserOriginHeader: true, + isControlUi: true, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(false); + expect( + shouldAllowSilentLocalPairing({ + locality: "direct_local", + hasBrowserOriginHeader: true, + isControlUi: false, + isWebchat: true, + reason: "metadata-upgrade", + }), + ).toBe(false); + }); }); it("prefers cli_container_local over shared_secret_loopback_local for CLI clients", () => { diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 6fd8ee3556b..8f51193af46 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -77,6 +77,7 @@ export function shouldAllowSilentLocalPairing(params: { hasBrowserOriginHeader: boolean; isControlUi: boolean; isWebchat: boolean; + isNativeAppUi?: boolean; reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade"; }): boolean { if (params.locality === "remote") { @@ -92,16 +93,18 @@ export function shouldAllowSilentLocalPairing(params: { ) { return true; } - // metadata-upgrade auto-approves only for shared-secret loopback CLI clients. - // On those paths the connection has already proved possession of a token or - // password over loopback, so allowing the pinned platform/deviceFamily to be - // refreshed on reconnect matches the "Reconnects can update access metadata" - // comment in message-handler.ts. Browser / Control-UI clients keep the - // existing approval-required flow — metadata pinning there is a real - // anti-tampering surface. + // metadata-upgrade auto-approves only for non-browser local reconnects that + // already proved possession of local/shared credentials. Direct-local + // metadata refresh is limited to first-party native app UI clients, covering + // same-host app reconnects after OS version metadata changes while keeping + // node-host, Browser, and Control-UI metadata pinning on the explicit approval path. if ( params.reason === "metadata-upgrade" && - (params.locality === "cli_container_local" || + !params.hasBrowserOriginHeader && + !params.isControlUi && + !params.isWebchat && + ((params.locality === "direct_local" && params.isNativeAppUi === true) || + params.locality === "cli_container_local" || params.locality === "shared_secret_loopback_local") ) { return true; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 11b8e10ab28..b5f24fff948 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -72,6 +72,7 @@ import { shouldAutoApproveNodePairingFromTrustedCidrs, } from "../../node-pairing-auto-approve.js"; import { checkBrowserOrigin } from "../../origin-check.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; import { buildPairingConnectCloseReason, buildPairingConnectErrorDetails, @@ -480,6 +481,11 @@ export function attachGatewayWsMessageHandler(params: { const isControlUi = isOperatorUiClient(connectParams.client); const isBrowserOperatorUi = isBrowserOperatorUiClient(connectParams.client); const isWebchat = isWebchatConnect(connectParams); + const isNativeAppUi = + connectParams.client.mode === GATEWAY_CLIENT_MODES.UI && + (connectParams.client.id === GATEWAY_CLIENT_IDS.MACOS_APP || + connectParams.client.id === GATEWAY_CLIENT_IDS.IOS_APP || + connectParams.client.id === GATEWAY_CLIENT_IDS.ANDROID_APP); if (enforceOriginCheckForAnyClient || isBrowserOperatorUi || isWebchat) { const hostHeaderOriginFallbackEnabled = configSnapshot.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; @@ -931,6 +937,7 @@ export function attachGatewayWsMessageHandler(params: { hasBrowserOriginHeader, isControlUi, isWebchat, + isNativeAppUi, reason, }); const allowSilentTrustedCidrsNodePairing = shouldAutoApproveNodePairingFromTrustedCidrs( @@ -1111,9 +1118,19 @@ export function attachGatewayWsMessageHandler(params: { }); const { platformMismatch, deviceFamilyMismatch } = metadataPinning; if (platformMismatch || deviceFamilyMismatch) { - logGateway.warn( - `security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? ""} pinnedPlatform=${pairedPlatform ?? ""} claimedDeviceFamily=${claimedDeviceFamily ?? ""} pinnedDeviceFamily=${pairedDeviceFamily ?? ""} client=${connectParams.client.id} conn=${connId}`, - ); + const allowSilentMetadataUpgrade = shouldAllowSilentLocalPairing({ + locality: pairingLocality, + hasBrowserOriginHeader, + isControlUi, + isWebchat, + isNativeAppUi, + reason: "metadata-upgrade", + }); + if (!allowSilentMetadataUpgrade) { + logGateway.warn( + `security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? ""} pinnedPlatform=${pairedPlatform ?? ""} claimedDeviceFamily=${claimedDeviceFamily ?? ""} pinnedDeviceFamily=${pairedDeviceFamily ?? ""} client=${connectParams.client.id} conn=${connId}`, + ); + } const ok = await requirePairing("metadata-upgrade", paired); if (!ok) { return;