docs(control-ui): clarify loopback gateway auth (#112746)

* docs(control-ui): clarify loopback gateway auth

* fix(gateway): preserve control ui settings auth hint
This commit is contained in:
Peter Steinberger
2026-07-22 18:45:56 -04:00
committed by GitHub
parent 62c148d7d0
commit 30c651d5bf
5 changed files with 42 additions and 14 deletions

View File

@@ -39,11 +39,13 @@ Auth is supplied during the WebSocket handshake via:
- Tailscale Serve identity headers when `gateway.auth.allowTailscale: true`
- trusted-proxy identity headers when `gateway.auth.mode: "trusted-proxy"`
The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. Onboarding usually generates a gateway token for shared-secret auth on first connect, but password auth works too when `gateway.auth.mode` is `"password"`.
Gateway auth runs before device pairing. A direct loopback connection does not bypass token or password auth. The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. After pairing, the browser can use its stored per-device token on later connections.
Onboarding usually configures a gateway token for shared-secret auth. If the Gateway starts in token mode without a configured token, it generates an ephemeral runtime token for that process instead. The runtime token is not written to config, so `openclaw config get gateway.auth.token` cannot retrieve it and a loopback browser without that token is rejected. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then paste the configured token in Control UI settings. Password auth works instead when `gateway.auth.mode` is `"password"`.
## Device pairing (first connection)
Connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`.
After gateway auth succeeds, connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`.
<Steps>
<Step title="List pending requests">
@@ -60,14 +62,15 @@ Connecting from a new browser or device usually requires a **one-time pairing ap
If the browser retries pairing with changed auth details (role/scopes/public key), the previous pending request is superseded and a new `requestId` is created; re-run `openclaw devices list` before approving.
Switching an already-paired browser from read access to write/admin access is treated as an approval upgrade, not a silent reconnect: OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly.
Switching an already-paired remote browser from read access to write/admin access is treated as an approval upgrade, not a silent reconnect: OpenClaw keeps the old approval active, blocks the broader reconnect, and asks you to approve the new scope set explicitly. A qualifying direct-loopback Control UI connection can silently approve the upgrade after it authenticates.
Once approved, the device is remembered and won't require re-approval unless you revoke it with `openclaw devices revoke --device <id> --role <role>`. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow.
<Note>
- Direct local loopback browser connections (`127.0.0.1` / `localhost`) are auto-approved.
- Direct local Control UI connections from a loopback TCP peer (`127.0.0.1` or `::1`, typically reached as `localhost`) with no forwarded/proxy headers can auto-approve device pairing only after gateway auth succeeds and the browser presents device identity. In token/password mode, the first connection still needs the configured shared secret; this auto-approval is not a token bypass.
- Direct loopback needs no shared secret only when `gateway.auth.mode: "none"` is explicitly configured. That disables gateway auth and is not the recommended Control UI setup. Tailscale Serve and trusted-proxy modes can avoid a pasted shared secret only when their respective identity checks succeed.
- Tailscale Serve can skip the pairing round trip for Control UI operator sessions when `gateway.auth.allowTailscale: true`, Tailscale identity verifies, and the browser presents its device identity. Device-less browsers and node-role connections still follow the normal device checks.
- Direct Tailnet binds, LAN browser connects, and browser profiles without device identity still require explicit approval.
- Direct Tailnet binds and LAN browser connects still require explicit approval. Browser profiles without device identity cannot use loopback auto-approval.
- Each browser profile generates a unique device ID, so switching browsers or clearing browser data requires re-pairing.
</Note>

View File

@@ -43,6 +43,7 @@ The Control UI is an **admin surface** (chat, config, exec approvals). Do not ex
- **Localhost**: open `http://127.0.0.1:18789/`.
- **Gateway TLS**: when `gateway.tls.enabled: true`, dashboard/status links use `https://` and Control UI WebSocket links use `wss://`.
- **Shared-secret token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). `openclaw dashboard` can pass it via URL fragment for one-time bootstrap; the Control UI keeps it in sessionStorage for the current tab and selected gateway URL, not localStorage.
- **Missing-config runtime token**: if startup says it generated a runtime token, that token is ephemeral and is not available through `openclaw config get gateway.auth.token`. Loopback still requires auth. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then paste the configured token in Control UI settings.
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design, to avoid exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. If the ref is unresolved in your current shell, it still prints the non-tokenized URL plus actionable auth setup guidance.
- **Shared-secret password**: use the configured `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). The dashboard does not persist passwords across reloads.
- **Identity-bearing modes**: Tailscale Serve satisfies Control UI/WebSocket auth via identity headers when `gateway.auth.allowTailscale: true`; a non-loopback identity-aware reverse proxy satisfies `gateway.auth.mode: "trusted-proxy"`. Neither needs a pasted shared secret for the WebSocket.
@@ -80,7 +81,7 @@ Non-goals for v1:
- Token: `openclaw config get gateway.auth.token`
- Password: resolve the configured `gateway.auth.password` or `OPENCLAW_GATEWAY_PASSWORD`
- SecretRef-managed token: resolve the external secret provider, or export `OPENCLAW_GATEWAY_TOKEN` in this shell and rerun `openclaw dashboard`
- No shared secret configured: `openclaw doctor --generate-gateway-token`
- Runtime token generated because no shared secret was configured: run `openclaw doctor --generate-gateway-token`, restart the Gateway, then use the configured token
- In the dashboard settings, paste the token or password into the auth field, then connect.
- The UI language picker lives in **Settings -> General -> Language**, not under Appearance.

View File

@@ -2,6 +2,11 @@
* WebSocket authentication message regression tests.
*/
import { describe, expect, it } from "vitest";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
} from "../../../../packages/gateway-protocol/src/client-info.js";
import { truncateCloseReason } from "../close-reason.js";
import { formatGatewayAuthFailureMessage } from "./auth-messages.js";
describe("formatGatewayAuthFailureMessage", () => {
@@ -14,4 +19,21 @@ describe("formatGatewayAuthFailureMessage", () => {
}),
).toBe("unauthorized: device token scope mismatch (re-pair or approve scope upgrade)");
});
it("makes a missing Control UI token actionable within the WebSocket close limit", () => {
const message = formatGatewayAuthFailureMessage({
authMode: "token",
authProvided: "none",
reason: "token_missing",
client: {
id: GATEWAY_CLIENT_IDS.CONTROL_UI,
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(message).toBe(
"unauthorized: gateway token missing (paste in Control UI settings or openclaw doctor --generate-gateway-token; restart)",
);
expect(truncateCloseReason(message)).toBe(message);
});
});

View File

@@ -23,6 +23,8 @@ export function formatGatewayAuthFailureMessage(params: {
const isControlUi = isOperatorUiClient(client);
const isWebchat = isWebchatClient(client);
const uiHint = "open the dashboard URL and paste the token in Control UI settings";
const missingUiTokenHint =
"paste in Control UI settings or openclaw doctor --generate-gateway-token; restart";
const tokenHint = isCli
? "set gateway.remote.token to match gateway.auth.token"
: isControlUi || isWebchat
@@ -35,7 +37,7 @@ export function formatGatewayAuthFailureMessage(params: {
: "provide gateway auth password";
switch (reason) {
case "token_missing":
return `unauthorized: gateway token missing (${tokenHint})`;
return `unauthorized: gateway token missing (${isControlUi || isWebchat ? missingUiTokenHint : tokenHint})`;
case "token_mismatch":
return `unauthorized: gateway token mismatch (${tokenHint})`;
case "token_missing_config":

View File

@@ -166,6 +166,12 @@ export async function authenticateGatewayConnect(
scopeCount: scopes.length,
hasDeviceIdentity: Boolean(device),
});
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: failedAuth.reason,
client: connectParams.client,
});
const authLogDecision = shouldLimitMissingCredentialAuthLog({
reason: failedAuth.reason,
authProvided,
@@ -186,15 +192,9 @@ export async function authenticateGatewayConnect(
? ` suppressed=${authLogDecision.suppressedSinceLastLog}`
: "";
logWsControl.warn(
`unauthorized conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} role=${role} scopes=${scopes.length} auth=${authProvided} device=${device ? "yes" : "no"} platform=${formatForLog(connectParams.client.platform)} instance=${formatForLog(connectParams.client.instanceId ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")} reason=${failedAuth.reason ?? "unknown"}${suppressedText}`,
`unauthorized conn=${connId} peer=${formatForLog(peerLabel)} remote=${remoteAddr ?? "?"} client=${formatForLog(clientLabel)} ${connectParams.client.mode} v${formatForLog(connectParams.client.version)} role=${role} scopes=${scopes.length} auth=${authProvided} device=${device ? "yes" : "no"} platform=${formatForLog(connectParams.client.platform)} instance=${formatForLog(connectParams.client.instanceId ?? "n/a")} host=${formatForLog(requestHost ?? "n/a")} origin=${formatForLog(requestOrigin ?? "n/a")} ua=${formatForLog(requestUserAgent ?? "n/a")} reason=${failedAuth.reason ?? "unknown"} guidance=${formatForLog(authMessage)}${suppressedText}`,
);
}
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: failedAuth.reason,
client: connectParams.client,
});
sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, authMessage, {
...(failedAuth.rateLimited === true
? {