diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 561447808a9e..d00ada59032a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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`. @@ -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 --role `. See [Devices CLI](/cli/devices) for token rotation, revocation, and the Paperclip / `openclaw_gateway` first-run approval flow. -- 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. diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index d0489d762a2c..ad3e6be8eb2c 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -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. diff --git a/src/gateway/server/ws-connection/auth-messages.test.ts b/src/gateway/server/ws-connection/auth-messages.test.ts index 7df17d369ae1..50be4dc2189f 100644 --- a/src/gateway/server/ws-connection/auth-messages.test.ts +++ b/src/gateway/server/ws-connection/auth-messages.test.ts @@ -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); + }); }); diff --git a/src/gateway/server/ws-connection/auth-messages.ts b/src/gateway/server/ws-connection/auth-messages.ts index ac96d96c5454..0705153a1708 100644 --- a/src/gateway/server/ws-connection/auth-messages.ts +++ b/src/gateway/server/ws-connection/auth-messages.ts @@ -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": diff --git a/src/gateway/server/ws-connection/connect-auth.ts b/src/gateway/server/ws-connection/connect-auth.ts index 08ee9af84d6d..566d65bc68d6 100644 --- a/src/gateway/server/ws-connection/connect-auth.ts +++ b/src/gateway/server/ws-connection/connect-auth.ts @@ -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 ? {