From 7d77680d9f4d1c947a1e83f356a28aab2cd50278 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 20:45:51 +0100 Subject: [PATCH] fix(gateway): keep native approvals off stale pairing baselines (#74472) * fix(gateway): keep native approvals off stale pairing baselines * fix(gateway): keep native approvals off stale pairing baselines * docs: defer maintainer-only changelog credit * docs: keep gateway approval changelog entry --------- Co-authored-by: clawsweeper-repair --- CHANGELOG.md | 1 + src/gateway/operator-approvals-client.test.ts | 41 ++++++++++++++++++- src/gateway/operator-approvals-client.ts | 27 ++++++++++++ ...silent-scope-upgrade-reconnect.poc.test.ts | 41 +++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e11a13f5fe5..b0a8eaafaea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Web search: describe `web_search` as using the configured provider instead of hard-coding Brave when DuckDuckGo or another provider is active. Fixes #75088. Thanks @sun-rongyang. - Infra/tmp: tolerate concurrent temp-dir permission repairs by rechecking directories that another process already tightened, so parallel ACP subprocess startup no longer throws `Unsafe fallback OpenClaw temp dir`. Fixes #66867. Thanks @Kane808-AI and @jarvisz8. - Agents/compaction: add an opt-in `agents.defaults.compaction.midTurnPrecheck` mid-turn precheck that detects tool-loop context pressure and triggers compaction before the next tool call instead of waiting for end-of-turn. (#73499) Thanks @marchpure and @haoxingjun. +- Gateway/approvals: let loopback token/password-backed native approval clients resolve exec approvals without attaching stale paired Gateway identities, while remote and unauthenticated approval clients keep normal device identity behavior. (#74472) ## 2026.4.29 diff --git a/src/gateway/operator-approvals-client.test.ts b/src/gateway/operator-approvals-client.test.ts index dc2b23fdfe5..3fa56d8d79f 100644 --- a/src/gateway/operator-approvals-client.test.ts +++ b/src/gateway/operator-approvals-client.test.ts @@ -9,6 +9,11 @@ const clientState = vi.hoisted(() => ({ stopAndWaitSpy: vi.fn(async () => undefined), })); +const bootstrapState = vi.hoisted(() => ({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" as string | undefined, password: undefined as string | undefined }, +})); + class MockGatewayClient { private readonly opts: Record; @@ -50,8 +55,8 @@ class MockGatewayClient { vi.mock("./client-bootstrap.js", () => ({ resolveGatewayClientBootstrap: vi.fn(async () => ({ - url: "ws://127.0.0.1:18789", - auth: { token: "secret", password: undefined }, + url: bootstrapState.url, + auth: bootstrapState.auth, })), })); @@ -69,6 +74,8 @@ describe("withOperatorApprovalsGatewayClient", () => { clientState.requestSpy.mockReset().mockResolvedValue(undefined); clientState.stopSpy.mockReset(); clientState.stopAndWaitSpy.mockReset().mockResolvedValue(undefined); + bootstrapState.url = "ws://127.0.0.1:18789"; + bootstrapState.auth = { token: "secret", password: undefined }; }); it("waits for hello before running the callback and stops cleanly", async () => { @@ -86,6 +93,7 @@ describe("withOperatorApprovalsGatewayClient", () => { ); expect(clientState.options?.scopes).toEqual(["operator.approvals"]); + expect(clientState.options?.deviceIdentity).toBeNull(); expect(clientState.requestSpy).toHaveBeenCalledWith("exec.approval.resolve", { id: "req-123", decision: "allow-once", @@ -93,6 +101,35 @@ describe("withOperatorApprovalsGatewayClient", () => { expect(clientState.stopAndWaitSpy).toHaveBeenCalledTimes(1); }); + it("keeps device identity for remote shared-auth approval clients", async () => { + bootstrapState.url = "wss://gateway.example/ws"; + + await withOperatorApprovalsGatewayClient( + { + config: {} as never, + clientDisplayName: "Matrix approval (@owner:example.org)", + }, + async () => undefined, + ); + + expect(clientState.options).not.toHaveProperty("deviceIdentity", null); + expect(clientState.options?.deviceIdentity).toBeUndefined(); + }); + + it("keeps device identity for loopback approval clients without shared auth", async () => { + bootstrapState.auth = { token: undefined, password: undefined }; + + await withOperatorApprovalsGatewayClient( + { + config: {} as never, + clientDisplayName: "Matrix approval (@owner:example.org)", + }, + async () => undefined, + ); + + expect(clientState.options?.deviceIdentity).toBeUndefined(); + }); + it("surfaces close failures before hello", async () => { clientState.startMode = "close"; diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts index 0fca7bd43d2..5e83f96ac0b 100644 --- a/src/gateway/operator-approvals-client.ts +++ b/src/gateway/operator-approvals-client.ts @@ -1,9 +1,29 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isLoopbackIpAddress } from "../shared/net/ip.js"; import { resolveGatewayClientBootstrap } from "./client-bootstrap.js"; import { startGatewayClientWhenEventLoopReady } from "./client-start-readiness.js"; import { GatewayClient, type GatewayClientOptions } from "./client.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "./protocol/client-info.js"; +function isLoopbackGatewayUrl(rawUrl: string): boolean { + try { + const hostname = new URL(rawUrl).hostname.toLowerCase(); + const unbracketed = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + return unbracketed === "localhost" || isLoopbackIpAddress(unbracketed); + } catch { + return false; + } +} + +function shouldOmitOperatorApprovalDeviceIdentity(params: { + url: string; + token?: string; + password?: string; +}): boolean { + return Boolean((params.token || params.password) && isLoopbackGatewayUrl(params.url)); +} + export async function createOperatorApprovalsGatewayClient( params: Pick< GatewayClientOptions, @@ -33,6 +53,13 @@ export async function createOperatorApprovalsGatewayClient( clientDisplayName: params.clientDisplayName, mode: GATEWAY_CLIENT_MODES.BACKEND, scopes: ["operator.approvals"], + deviceIdentity: shouldOmitOperatorApprovalDeviceIdentity({ + url: bootstrap.url, + token: bootstrap.auth.token, + password: bootstrap.auth.password, + }) + ? null + : undefined, onEvent: params.onEvent, onHelloOk: params.onHelloOk, onConnectError: params.onConnectError, diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index 5647339cec3..d18918cba14 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -17,6 +17,7 @@ import { loadDeviceIdentity, openTrackedWs, } from "./device-authz.test-helpers.js"; +import { withOperatorApprovalsGatewayClient } from "./operator-approvals-client.js"; import { connectOk, connectReq, @@ -268,6 +269,46 @@ describe("gateway silent scope-upgrade reconnect", () => { } }); + test("keeps local native approval clients off stale paired gateway-client baseline", async () => { + const started = await startServerWithClient("secret"); + const identity = loadOrCreateDeviceIdentity(); + const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); + const request = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey, + role: "operator", + scopes: ["operator.read"], + clientId: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientMode: GATEWAY_CLIENT_MODES.BACKEND, + }); + await approveDevicePairing(request.request.requestId, { + callerScopes: ["operator.read"], + }); + + try { + await expect( + withOperatorApprovalsGatewayClient( + { + config: { + gateway: { port: started.port, auth: { mode: "token", token: "secret" } }, + } as never, + clientDisplayName: "test native approvals", + }, + async () => undefined, + ), + ).resolves.toBeUndefined(); + + const pending = await devicePairingModule.listDevicePairing(); + expect(pending.pending).toHaveLength(0); + const paired = await getPairedDevice(identity.deviceId); + expect(paired?.approvedScopes).toEqual(["operator.read"]); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + test("accepts local silent reconnect when pairing was concurrently approved", async () => { const started = await startServerWithClient("secret"); const loaded = loadDeviceIdentity("silent-reconnect-race");