fix(gateway): decouple backend RPC from CLI pairing

This commit is contained in:
Peter Steinberger
2026-04-25 23:22:08 +01:00
parent 91adb69c57
commit e640c0a95f
7 changed files with 175 additions and 12 deletions

View File

@@ -61,6 +61,11 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/subagents: keep direct-loopback backend RPCs authenticated with the
shared gateway token/password off stale CLI paired-device scope baselines, so
internal calls no longer hit `scope-upgrade` pairing prompts while remote,
browser, node, device-token, and explicit-device paths still require normal
pairing approval. Fixes #63548.
- CLI/gateway: keep diagnostic probes from creating first-time read-only device
pairings, while still reusing cached device tokens for detailed read probes.
Fixes #71766. Thanks @SunboZ.

View File

@@ -110,6 +110,14 @@ permissions:
}
```
Trusted same-process backend clients (`client.id: "gateway-client"`,
`client.mode: "backend"`) may omit `device` on direct loopback connections when
they authenticate with the shared gateway token/password. This path is reserved
for internal control-plane RPCs and keeps stale CLI/device pairing baselines from
blocking local backend work such as subagent session updates. Remote clients,
browser-origin clients, node clients, and explicit device-token/device-identity
clients still use the normal pairing and scope-upgrade checks.
When a device token is issued, `hello-ok` also includes:
```json

View File

@@ -92,6 +92,11 @@ Treat Gateway and node as one operator trust domain, with different roles:
- **Gateway** is the control plane and policy surface (`gateway.auth`, tool policy, routing).
- **Node** is remote execution surface paired to that Gateway (commands, device actions, host-local capabilities).
- A caller authenticated to the Gateway is trusted at Gateway scope. After pairing, node actions are trusted operator actions on that node.
- Direct loopback backend clients authenticated with the shared gateway
token/password can make internal control-plane RPCs without presenting a user
device identity. This is not a remote or browser pairing bypass: network
clients, node clients, device-token clients, and explicit device identities
still go through pairing and scope-upgrade enforcement.
- `sessionKey` is routing/context selection, not per-user auth.
- Exec approvals (allowlist + ask) are guardrails for operator intent, not hostile multi-tenant isolation.
- OpenClaw's product default for trusted single-operator setups is that host exec on `gateway`/`node` is allowed without approval prompts (`security="full"`, `ask="off"` unless you tighten it). That default is intentional UX, not a vulnerability by itself.

View File

@@ -200,6 +200,12 @@ Use `error.details.code` from the failed `connect` response to pick the next act
| `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. |
| `PAIRING_REQUIRED` | Device identity needs approval. Check `error.details.reason` for `not-paired`, `scope-upgrade`, `role-upgrade`, or `metadata-upgrade`, and use `requestId` / `remediationHint` when present. | Approve pending request: `openclaw devices list` then `openclaw devices approve <requestId>`. Scope/role upgrades use the same flow after you review the requested access. |
Direct loopback backend RPCs authenticated with the shared gateway
token/password should not depend on the CLI's paired-device scope baseline. If
subagents or other internal calls still fail with `scope-upgrade`, verify the
caller is using `client.id: "gateway-client"` and `client.mode: "backend"` and
is not forcing an explicit `deviceIdentity` or device token.
Device auth v2 migration check:
```bash

View File

@@ -27,7 +27,9 @@ let lastClientOptions: {
token?: string;
password?: string;
tlsFingerprint?: string;
clientName?: string;
clientDisplayName?: string;
mode?: string;
scopes?: string[];
deviceIdentity?: unknown;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
@@ -59,7 +61,9 @@ vi.mock("./client.js", () => ({
url?: string;
token?: string;
password?: string;
clientName?: string;
clientDisplayName?: string;
mode?: string;
scopes?: string[];
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
@@ -97,7 +101,9 @@ class StubGatewayClient {
url?: string;
token?: string;
password?: string;
clientName?: string;
clientDisplayName?: string;
mode?: string;
scopes?: string[];
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
@@ -303,7 +309,7 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.token).toBe("test-token");
});
it("keeps device identity enabled for local loopback shared-token auth", async () => {
it("keeps direct-local backend shared-token auth independent of paired device state", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
@@ -311,6 +317,23 @@ describe("callGateway url resolution", () => {
token: "explicit-token",
});
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT);
expect(lastClientOptions?.mode).toBe(GATEWAY_CLIENT_MODES.BACKEND);
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("keeps device identity enabled for explicit CLI loopback shared-token auth", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
method: "health",
token: "explicit-token",
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
});
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
@@ -331,6 +354,22 @@ describe("callGateway url resolution", () => {
expect(lastRequestOptions?.method).toBe("health");
});
it("keeps backend device identity enabled for remote shared-token auth", async () => {
loadConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-password"));
setGatewayNetworkDefaults();
await callGateway({
method: "health",
token: "explicit-token",
});
expect(lastClientOptions?.url).toBe("wss://remote.example:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT);
expect(lastClientOptions?.mode).toBe(GATEWAY_CLIENT_MODES.BACKEND);
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("honors an explicit null device identity override", async () => {
setLocalLoopbackGatewayConfig();
@@ -470,6 +509,22 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.scopes).toEqual([]);
});
it("uses backend client metadata for explicit scoped default calls", async () => {
setLocalLoopbackGatewayConfig();
await callGateway({
method: "sessions.delete",
scopes: ["operator.admin"],
token: "explicit-token",
});
expect(lastClientOptions?.clientName).toBe(GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT);
expect(lastClientOptions?.mode).toBe(GATEWAY_CLIENT_MODES.BACKEND);
expect(lastClientOptions?.clientDisplayName).toBe("gateway:sessions.delete");
expect(lastClientOptions?.scopes).toEqual(["operator.admin"]);
expect(lastClientOptions?.deviceIdentity).toBeNull();
});
it("labels default backend calls with the requested method", async () => {
setLocalLoopbackGatewayConfig();

View File

@@ -8,6 +8,7 @@ import {
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { isLoopbackIpAddress } from "../shared/net/ip.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
GATEWAY_CLIENT_MODES,
@@ -194,12 +195,43 @@ export const __testing = {
},
};
function resolveDeviceIdentityForGatewayCall(): ReturnType<
typeof loadOrCreateDeviceIdentity
> | null {
// Shared-auth local calls should still stay device-bound so operator scopes
// remain available for detail RPCs such as status / system-presence /
// last-heartbeat.
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 shouldOmitDeviceIdentityForGatewayCall(params: {
opts: CallGatewayBaseOptions;
url: string;
token?: string;
password?: string;
}): boolean {
const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI;
const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI;
const hasSharedAuth = Boolean(params.token || params.password);
return (
mode === GATEWAY_CLIENT_MODES.BACKEND &&
clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT &&
hasSharedAuth &&
isLoopbackGatewayUrl(params.url)
);
}
function resolveDeviceIdentityForGatewayCall(params: {
opts: CallGatewayBaseOptions;
url: string;
token?: string;
password?: string;
}): ReturnType<typeof loadOrCreateDeviceIdentity> | null {
if (shouldOmitDeviceIdentityForGatewayCall(params)) {
return null;
}
try {
return gatewayCallDeps.loadOrCreateDeviceIdentity();
} catch {
@@ -515,7 +547,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
scopes,
deviceIdentity:
opts.deviceIdentity === undefined
? resolveDeviceIdentityForGatewayCall()
? resolveDeviceIdentityForGatewayCall({ opts, url, token, password })
: opts.deviceIdentity,
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
@@ -616,14 +648,21 @@ export async function callGatewayLeastPrivilege<T = Record<string, unknown>>(
export async function callGateway<T = Record<string, unknown>>(
opts: CallGatewayOptions,
): Promise<T> {
if (Array.isArray(opts.scopes)) {
return await callGatewayWithScopes(opts, opts.scopes);
}
const callerMode = opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND;
const callerName = opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT;
if (callerMode === GATEWAY_CLIENT_MODES.CLI || callerName === GATEWAY_CLIENT_NAMES.CLI) {
return await callGatewayCli(opts);
}
if (Array.isArray(opts.scopes)) {
return await callGatewayWithScopes(
{
...opts,
mode: callerMode,
clientName: callerName,
},
opts.scopes,
);
}
return await callGatewayLeastPrivilege({
...opts,
mode: callerMode,

View File

@@ -1,8 +1,17 @@
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import * as devicePairingModule from "../infra/device-pairing.js";
import { getPairedDevice } from "../infra/device-pairing.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { callGateway } from "./call.js";
import {
issueOperatorToken,
loadDeviceIdentity,
@@ -223,6 +232,42 @@ describe("gateway silent scope-upgrade reconnect", () => {
}
});
test("keeps direct-local backend callGateway scoped calls off stale paired CLI 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.CLI,
clientMode: GATEWAY_CLIENT_MODES.CLI,
});
await approveDevicePairing(request.request.requestId, {
callerScopes: ["operator.read"],
});
try {
await expect(
callGateway({
url: `ws://127.0.0.1:${started.port}`,
token: "secret",
method: "health",
scopes: ["operator.admin"],
timeoutMs: 2_000,
}),
).resolves.toMatchObject({ ok: true });
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");