From 53ad1a606694c089068007812cc9ecbe72c0dd1e Mon Sep 17 00:00:00 2001 From: Jason Perlow Date: Wed, 22 Apr 2026 11:58:53 -0400 Subject: [PATCH] fix(gateway): allow silent metadata-upgrade pairing for loopback CLI clients (#70224) Loopback CLI clients (cli_container_local, shared_secret_loopback_local) with valid shared-secret auth previously got disconnected with 1008 pairing required whenever the paired device record's platform or deviceFamily string differed from what the CLI claimed at connect time. PR #69431 added the shared_secret_loopback_local locality but deferred the metadata-upgrade reason from the auto-approval allowlist. That deferral created an unrecoverable handshake loop in practice: every CLI connect triggers a fresh metadata-upgrade request, the Control UI has no approval surface for this reason, and non-interactive shells cannot complete pairing. This broke every non-interactive openclaw agent use case when paired device keys are replicated across hosts or installs are migrated across platforms. Extend shouldAllowSilentLocalPairing to auto-approve metadata-upgrade for cli_container_local and shared_secret_loopback_local localities only. Browser / Control-UI / remote paths retain existing approval- required behavior. Gateway still logs every metadata refresh via the existing security audit line for operator review. Add 4 unit tests covering the decision table for metadata-upgrade across all four localities. Related: #69397, #69431 --- CHANGELOG.md | 1 + .../handshake-auth-helpers.test.ts | 56 ++++++++++++++++++- .../ws-connection/handshake-auth-helpers.ts | 35 +++++++++--- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e59111a699f..2c14426799c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve `metadata-upgrade` pairing (platform / device family refresh) instead of being disconnected with `1008 pairing required`. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes. - Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path. - Gateway/pairing webchat: render `/pair qr` replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev. - Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox. 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 5dde255af58..1965b137e5c 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -541,7 +541,7 @@ describe("handshake auth helpers", () => { ).toBe("remote"); }); - it("allows silent scope-upgrade for shared_secret_loopback_local", () => { + it("allows silent scope-upgrade, role-upgrade, and metadata-upgrade for shared_secret_loopback_local", () => { expect( shouldAllowSilentLocalPairing({ locality: "shared_secret_loopback_local", @@ -560,6 +560,8 @@ describe("handshake auth helpers", () => { reason: "role-upgrade", }), ).toBe(true); + // metadata-upgrade now auto-approves for shared_secret_loopback_local + // (extended allowlist — see shouldAllowSilentLocalPairing). expect( shouldAllowSilentLocalPairing({ locality: "shared_secret_loopback_local", @@ -568,7 +570,57 @@ describe("handshake auth helpers", () => { isWebchat: false, reason: "metadata-upgrade", }), - ).toBe(false); + ).toBe(true); + }); + + describe("shouldAllowSilentLocalPairing — metadata-upgrade reason", () => { + it("allows silent metadata-upgrade for cli_container_local CLI clients", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "cli_container_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(true); + }); + + it("allows silent metadata-upgrade for shared_secret_loopback_local CLI clients", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(true); + }); + + it("still requires approval for metadata-upgrade from remote clients", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "remote", + hasBrowserOriginHeader: false, + isControlUi: false, + isWebchat: false, + reason: "metadata-upgrade", + }), + ).toBe(false); + }); + + it("still requires approval for metadata-upgrade from browser_container_local (Control UI)", () => { + expect( + shouldAllowSilentLocalPairing({ + locality: "browser_container_local", + hasBrowserOriginHeader: true, + isControlUi: true, + isWebchat: false, + 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 76980a96122..6fd8ee3556b 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -79,13 +79,34 @@ export function shouldAllowSilentLocalPairing(params: { isWebchat: boolean; reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade"; }): boolean { - return ( - params.locality !== "remote" && - (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat) && - (params.reason === "not-paired" || - params.reason === "scope-upgrade" || - params.reason === "role-upgrade") - ); + if (params.locality === "remote") { + return false; + } + if (params.hasBrowserOriginHeader && !params.isControlUi && !params.isWebchat) { + return false; + } + if ( + params.reason === "not-paired" || + params.reason === "scope-upgrade" || + params.reason === "role-upgrade" + ) { + 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. + if ( + params.reason === "metadata-upgrade" && + (params.locality === "cli_container_local" || + params.locality === "shared_secret_loopback_local") + ) { + return true; + } + return false; } function isCliContainerLocalEquivalent(params: {