← Back to PRs

#23277: fix(gateway): preserve scopes for localhost token-auth without device identity

by dashed open 2026-02-22 05:06 View on GitHub →
gateway agents size: M
## Summary Preserve self-declared scopes for localhost token-authenticated WebSocket connections that have no device identity, and add a `skipDeviceIdentity` option to `GatewayClient`/`callGateway()` so programmatic callers (e.g. cron announce) can opt out of automatic device-identity loading. ## Problem Three layered bugs caused internal gateway calls (cron announce, `gateway call`) to fail with "missing scope: operator.write" or "pairing required": 1. **Unconditional scope stripping** — `clearUnboundScopes()` in `message-handler.ts` set `scopes = []` for ANY connection without device identity, even trusted localhost connections with valid token auth. This is the root cause behind 10+ open upstream issues. 2. **Unavoidable device-identity auto-loading** — `GatewayClient` constructor used `opts.deviceIdentity ?? loadOrCreateDeviceIdentity()`, which auto-loaded a device identity from disk even when callers explicitly passed `undefined`. There was no way to opt out. 3. **Scope-upgrade triggering** — The auto-loaded device identity had scopes that didn't include `operator.write`, triggering the scope-upgrade path → pairing required → delivery failure for cron announce. ## Solution **Part 1: Scope preservation** (`message-handler.ts`) Skip `clearUnboundScopes()` when the connection has valid shared-secret auth (`sharedAuthOk`) AND is from localhost (`isLocalClient`). This preserves self-declared scopes for trusted internal callers without weakening security for remote or unauthenticated connections. ```diff -if (!device) { +if (!device && !(sharedAuthOk && isLocalClient)) { clearUnboundScopes(); } ``` **Part 2: `skipDeviceIdentity` flag** (`client.ts`, `call.ts`) Add `skipDeviceIdentity?: boolean` to `GatewayClientOptions` and `CallGatewayBaseOptions`. When set, the `GatewayClient` constructor skips the `loadOrCreateDeviceIdentity()` fallback entirely, allowing connections to proceed without device identity. Applied in `subagent-announce.ts` for all cron announce `callGateway()` calls. ## Files changed | File | Change | |------|--------| | `src/gateway/server/ws-connection/message-handler.ts` | Core fix: skip scope stripping for authenticated localhost | | `src/gateway/client.ts` | Add `skipDeviceIdentity` option to constructor | | `src/gateway/call.ts` | Pass through `skipDeviceIdentity` to `GatewayClient` | | `src/agents/subagent-announce.ts` | Set `skipDeviceIdentity: true` on all announce calls + debug logging | | `src/gateway/server.scope-localhost.e2e.test.ts` | **New**: 2 E2E tests for scope preservation | | `src/gateway/client.test.ts` | 4 unit tests for `skipDeviceIdentity` constructor behavior | | `src/gateway/call.test.ts` | 1 unit test for passthrough | | `src/gateway/test-helpers.e2e.ts` | Add `skipDeviceIdentity` to test helper | ## Test plan - [x] `npx tsc --noEmit` — clean - [x] `npx oxlint --type-aware` — clean - [x] `npx vitest run` — 41 unit tests pass (including 5 new) - [x] `npx vitest run --config vitest.e2e.config.ts src/gateway/server.scope-localhost.e2e.test.ts` — 2 E2E tests pass - [x] Production verification: cron announce delivery succeeds with `consecutiveErrors: 0`, `lastDelivered: true` **Note on E2E test design**: Tests use `skipDeviceIdentity: true` to prevent `GatewayClient` from auto-loading a device identity. Without this flag, the client always sends a device identity, making `hasDeviceIdentity=true` on the server side and trivially bypassing the `clearUnboundScopes()` path — which would make the test a false positive. ## Security analysis ### Verdict: This PR does NOT violate the OpenClaw security model A three-part security audit was performed covering the auth flow, the `skipDeviceIdentity` flag, and upstream intent. ### Part 1: Scope preservation is safe (`sharedAuthOk && isLocalClient`) Both conditions required for the bypass are individually strong and non-spoofable: | Check | What it proves | Spoofable? | |-------|---------------|------------| | `sharedAuthOk` | Client knows the gateway token — verified via `safeEqualSecret()` (constant-time comparison) against configured token/password | No | | `isLocalClient` | TCP connection originates from `127.0.0.1` or `::1` — checked via `req.socket.remoteAddress` (kernel TCP stack) | No — proxy headers are detected and rejected via `hasUntrustedProxyHeaders`; behind trusted proxies, `resolveClientIp()` walks `X-Forwarded-For` to find the real client IP | A process satisfying both conditions is on the same machine and knows the admin token. It already has equivalent access to `~/.openclaw` config files, device keys, and the gateway token on disk. Requiring device identity for such callers adds complexity with zero security benefit. Additionally, method-level scope enforcement (`authorizeGatewayMethod()` in `server-methods.ts`) independently validates scopes on every RPC call, providing belt-and-suspenders protection. ### Part 2: `skipDeviceIdentity` has zero external attack surface - **Client-side only**: The flag is a `GatewayClient` constructor parameter. It is NOT part of the WebSocket protocol schema (`ConnectParamsSchema` has `additionalProperties: false`). The server never sees this flag. - **Server enforces independently**: When a client connects without device identity, the server applies its own policy via `evaluateMissingDeviceIdentity()` — remote clients without device identity are rejected with code 1008 regardless of what the client-side flag says. - **Internal-only callers**: Only 3 call sites exist, all in `subagent-announce.ts` (internal gateway-to-gateway cron/announce delivery). Zero usage in CLI, UI, or any external-facing code path. - **No privilege escalation**: External clients can already omit the `device` field from `ConnectParams`. The server handles this case independently — `skipDeviceIdentity` just prevents the client from auto-loading one it doesn't need. ### Part 3: Upstream intent confirms this is a bug fix, not a security bypass - **`clearUnboundScopes()` is a sequencing bug**, not a deliberate security boundary. It runs BEFORE `evaluateMissingDeviceIdentity()`, which already ALLOWS operator + shared-secret auth connections to skip device identity via `roleCanSkipDeviceIdentity()`. Scopes get stripped from connections the evaluator subsequently allows. - **Upstream SECURITY.md** states: "The host where OpenClaw runs is within a trusted OS/admin boundary" and "Anyone who can modify `~/.openclaw` state/config is effectively a trusted operator." - **Merged upstream PR [#22996](https://github.com/openclaw/openclaw/pull/22996)** explicitly accepts localhost as a valid trust boundary exception for device identity. Greptile automated security review: 5/5 confidence, "Safe to merge with no security concerns." - **10+ open upstream issues** report the same scope-stripping bug. Zero maintainer comments defend it as intentional security behavior. - **This PR is more conservative** than upstream proposals — issue [#18560](https://github.com/openclaw/openclaw/issues/18560) proposes preserving scopes for ANY token-auth connection (regardless of locality), while this PR restricts the bypass to `localhost + valid token` only. ## Related upstream issues This PR addresses the root cause behind a cluster of 10+ open issues in `openclaw/openclaw`: | Issue | Title | Relation | |-------|-------|----------| | [#18560](https://github.com/openclaw/openclaw/issues/18560) | Token-auth WS connections have all scopes stripped when no paired device | **Exact same bug** — proposes the same fix | | [#17095](https://github.com/openclaw/openclaw/issues/17095) | Dashboard shows "missing scope: operator.read" with token-only auth | Same root cause in `message-handler.ts` | | [#17153](https://github.com/openclaw/openclaw/issues/17153) | `dangerouslyDisableDeviceAuth` strips all scopes | Same `clearUnboundScopes()` code path | | [#19858](https://github.com/openclaw/openclaw/issues/19858) | Third-party clients fail even with `dangerouslyDisableDeviceAuth` | Upstream partial fix only covers Control UI client ID | | [#17750](https://github.com/openclaw/openclaw/issues/17750) | Control UI unusable over HTTP: missing scopes | Comprehensive root cause analysis | | [#8529](https://github.com/openclaw/openclaw/issues/8529) | disconnected (1008): device identity required | Original device-identity gate issue | | [#17570](https://github.com/openclaw/openclaw/issues/17570) | Connect response should warn when scopes are silently stripped | Feature request for observability | ## Related upstream PRs Several upstream PRs attempt narrower fixes for the same root cause. Each targets a specific client type or config flag, whereas this PR addresses the general case (any authenticated localhost connection): | PR | Title | Scope | |----|-------|-------| | [#17605](https://github.com/openclaw/openclaw/pull/17605) | Preserve scopes when `disableControlUiDeviceAuth` is enabled | Control UI only | | [#20089](https://github.com/openclaw/openclaw/pull/20089) | Preserve control-ui scopes when `dangerouslyDisableDeviceAuth` is set | Control UI + specific flag | | [#17572](https://github.com/openclaw/openclaw/pull/17572) | Make `dangerouslyDisableDeviceAuth` bypass device identity checks | Control UI + specific flag | | [#22996](https://github.com/openclaw/openclaw/pull/22996) | Restore localhost Control UI pairing with `allowInsecureAuth` | **Merged** — adjacent fix in same code area | | [#21658](https://github.com/openclaw/openclaw/pull/21658) | Allow node-role clients to call chat methods | Workaround via method allowlist | | [#22253](https://github.com/openclaw/openclaw/pull/22253) | Auto-approve local loopback pairing for role/scope upgrades | Pairing flow, not scope stripping | <!-- greptile_comment --> <h3>Greptile Summary</h3> Fixes a scope-stripping bug where `clearUnboundScopes()` in `message-handler.ts` unconditionally cleared all scopes for connections without device identity — even trusted localhost connect...

Most Similar PRs