diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb46dfbb50..43095970d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 46cce9d0a3e..6d0b4d10694 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -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 diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 74b8eb3a337..55ac84121ab 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -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. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index eced49a8ffa..eb549bcba38 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -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 `. 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 diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index da4c895e2e6..2f6a3680caa 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -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; @@ -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; 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; 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(); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 18d95cae599..659b12aa909 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -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 | null { + if (shouldOmitDeviceIdentityForGatewayCall(params)) { + return null; + } try { return gatewayCallDeps.loadOrCreateDeviceIdentity(); } catch { @@ -515,7 +547,7 @@ async function executeGatewayRequestWithScopes(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>( export async function callGateway>( opts: CallGatewayOptions, ): Promise { - 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, 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 d01e7ef92af..5647339cec3 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -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");