#23277: fix(gateway): preserve scopes for localhost token-auth without device identity
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
#23361: Gateway: reject scope assertions without identity binding
by bmendonca3 · 2026-02-22
81.2%
#20089: fix(gateway): preserve control-ui scopes when dangerouslyDisableDev...
by vashkartik · 2026-02-18
80.4%
#17572: fix: make dangerouslyDisableDeviceAuth bypass device identity checks
by gitwithuli · 2026-02-15
80.3%
#17605: fix: preserve scopes when disableControlUiDeviceAuth is enabled
by MisterGuy420 · 2026-02-16
79.6%
#17379: fix: restore device token priority in device-auth mode
by Limitless2023 · 2026-02-15
78.1%
#17425: fix(gateway): auto-approve scope/role upgrades for already-paired d...
by sauerdaniel · 2026-02-15
77.1%
#21622: fix(gateway): include read/write in CLI default operator scopes
by zerone0x · 2026-02-20
76.8%
#19389: Fix #2248: Allow insecure auth bypass when device signature validat...
by cedillarack · 2026-02-17
76.3%
#16310: fix(ws-connection): skip device pairing when client authenticates w...
by nawinsharma · 2026-02-14
76.3%
#12802: fix(gateway): default unscoped operator connections to read-only
by yubrew · 2026-02-09
76.2%