mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix(gateway): decouple backend RPC from CLI pairing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user