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 <clawsweeper-repair@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-30 20:45:51 +01:00
committed by GitHub
parent 581fbea1d6
commit 7d77680d9f
4 changed files with 108 additions and 2 deletions

View File

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

View File

@@ -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<string, unknown>;
@@ -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";

View File

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

View File

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