-
-
Notifications
You must be signed in to change notification settings - Fork 39.9k
Description
Summary
Commit d8a2c80cd ("fix(gateway): prefer explicit token over stored auth") flipped the client-side token priority in src/gateway/client.ts from storedToken ?? this.opts.token to this.opts.token ?? storedToken and removed the canFallbackToShared self-healing mechanism. This causes any client that has both a stored device token and a config/env token to send the wrong token, breaking device-token auth for non-localhost connections.
This appears to be the shared root cause behind #16820, #16862, #17223, and #17233.
Steps to reproduce
- On v2026.2.13, configure
gateway.bind=lanand pair a device (Node, CLI, or browser) - Verify the connection works
- Upgrade to v2026.2.14
- Device connection fails with unauthorized: device token mismatch
- Downgrade to v2026.2.13 - connection works again immediately
Expected behavior
Previously paired devices should continue to authenticate via their stored device tokens after upgrading.
Actual behavior
All non-localhost device-authenticated connections fail with:
unauthorized: device token mismatch (rotate/reissue device token)
OpenClaw version
v2026.2.14
Operating system
Ubuntu 24.04 LTS (Hyper-V VM), but OS-independent - also reported on macOS (#17233), PopOS (#16820), and Linux arm64 (#17223).
Install method
npm global
Logs, screenshots, and evidence
The commit diff
d8a2c80cd changed src/gateway/client.ts in two ways:
Change 1 — Token priority flip (line ~191):
// v2026.2.13 — device token takes priority
const authToken = storedToken ?? this.opts.token ?? undefined;
const canFallbackToShared = Boolean(storedToken && this.opts.token);
// v2026.2.14 — config/env token takes priority
const authToken = this.opts.token ?? storedToken ?? undefined;
// canFallbackToShared removed
Change 2 — Self-healing fallback removed (lines ~273-278):
// v2026.2.13 — .catch() handler cleared stale device tokens
if (canFallbackToShared && this.opts.deviceIdentity) {
clearDeviceAuthToken({
deviceId: this.opts.deviceIdentity.deviceId,
role,
});
}
// v2026.2.14 — entire block deleted
// companion commit 00b7ab7db removed the now-unused clearDeviceAuthToken import
Token flow trace
How this.opts.token is populated - all common GatewayClient call sites resolve it from the shared config token:
call.ts (line 208-220)
• token resolves to: CLI --token OR OPENCLAW_GATEWAY_TOKEN env OR gateway.auth.token from config
node-host/runner.ts (line 94-96)
• token resolves to: OPENCLAW_GATEWAY_TOKEN env OR gateway.auth.token from config
tui/gateway-chat.ts (line 247-258)
• token resolves to: Same pattern
acp/server.ts (line 25-29)
• token resolves to: Same pattern
In the common local/LAN case (no explicit --token), this.opts.token = the shared config token. This is a different value from the device-specific token in device-auth.json.
How storedToken is populated:
const storedToken = this.opts.deviceIdentity
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
: null;
Reads from ~/.openclaw/identity/device-auth.json — the device-specific token issued by the server during previous successful connect.
v2026.2.13 server-side flow (working):
1. Client sends storedToken (device token) as connectParams.auth.token
2. authorizeGatewayConnect() compares it against server's shared secret → doesn't match → authOk = false
3. Fallback: verifyDeviceToken({ token: connectParams.auth.token }) → matches device token in pairing DB → authOk = true
v2026.2.14 server-side flow (broken):
1. Client sends this.opts.token (shared config token) as connectParams.auth.token
2. authorizeGatewayConnect() compares it against server's shared secret → result varies by setup:
• Same machine, matching config: succeeds → device check skipped → bug masked
• LAN/remote, config drift, stale env var, password mode, trusted-proxy: fails → authOk = false
3. Fallback: verifyDeviceToken({ token: connectParams.auth.token }) → receives config token, not the device token → mismatch → rejected
isLocalDirectRequest() is not involvedImpact and severity
Impact and severity: High - breaks all non-localhost device auth.
Affects:
- LAN-bound gateways with Nodes, CLI, or Web UI from other hosts
- Systemd installs where OPENCLAW_GATEWAY_TOKEN env var diverges from config ([Bug]: OPENCLAW_GATEWAY_TOKEN in systemd service file not updated on config change or update, causing device_token_mismatch #17223)
- Browser clients with cached device tokens ([Bug]:Safari web chat device token requires macOS logout after 2026.2.14 update #17233 — Safari; Chrome recovers by re-pairing)
- Password or trusted-proxy auth modes where token-based shared auth doesn't apply
Workaround: downgrade to v2026.2.13.
Additional information
The commit's intent was valid but the implementation is too broad. The inline comment says:
Prefer explicitly provided credentials (e.g. CLI --token) over any persisted device-auth tokens.
This fixes the real case where a user passes --token my-new-token but a stale stored device token takes priority. However, this.opts.token doesn't distinguish between an explicit CLI --token and a passive config fallback — both arrive as the same field.
Suggested fixes:
- Option A (safest): Full revert — restore storedToken ?? this.opts.token priority and the canFallbackToShared cleanup. The self-healing mechanism handled stale device tokens acceptably (clear on first failure, succeed on retry).
- Option B (cleanest): Add an explicitToken field to GatewayClient options. Priority: explicitToken ?? storedToken ?? opts.token. Only callers receiving --token set explicitToken; config/env fallbacks use opts.token.
No test coverage: Zero unit tests cover the token priority logic in GatewayClient.sendConnect().