fix: allow native app metadata reconnects

This commit is contained in:
Peter Steinberger
2026-04-25 21:00:25 +01:00
parent 89c52988c5
commit 6e1017d88a
5 changed files with 87 additions and 16 deletions

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -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 ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} 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 ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
);
}
const ok = await requirePairing("metadata-upgrade", paired);
if (!ok) {
return;