mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
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:
committed by
GitHub
parent
581fbea1d6
commit
7d77680d9f
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user