Console sign-in and destination recovery
Overview
The console authenticates a browser session and restores a requested supported page when the user can still access it. Agent recovery resolves an opaque key only after session admission and ends at ordinary Agent detail.
Entry Points
apps/controller/src/console/console.mts:loadPagechecks the browser session.apps/controller/src/console/console.mts:showLoginoffers available sign-in methods.apps/controller/src/console/navigation.mts:createNavigationvalidates page intents.
Flow
graph TD
A["Open console route or recovery link"] --> B["Check session"]
B -->|Null result| C["Sign in"]
C --> B
B -->|Malformed reply| R["Show Session unavailable"]
R -->|Retry| B
B -->|Valid account and binding| D["Confirm session"]
D -->|page intent| E["Authorize page reads"]
D -->|Agent key| F["Resolve authorized Agent"]
F -->|allowed| E
F -->|unavailable| G["Select Agents"]
G --> E
E --> H["Render allowed page"]Execution Trace
1. Discover sign-in and bind the session
apps/controller/src/console/console.mts:loadPage, readSession, showLogin
On a cold visit, the shell's static “Loading OpenClaw Enterprise…” status paints
before any script runs; CSS shows it only after 400 ms, so a fast load never
flashes it. The independent async observer in
apps/controller/src/console/startup-recovery.mts handles the native entry-module
load error from #console-entry in apps/controller/src/console/index.html.
If the observer arrives after the error, it checks at window load (or immediately
if the document is already complete). While the .startup-recovery marker remains,
it reports that the Console could not load and offers Reload page. Clicking
the button reloads the same URL. If the observer is unavailable, the static text
still directs the reader to browser Reload. Recovery never automatically reloads
the page or retries API requests.
The healthy console.mts entry removes the marker synchronously before session
reads, so a slow or unavailable session follows its own path below. loadPage
takes the status over in place while checking the session and announces
“Checking session…” only if still pending after 400 ms.
readSession reads GET /api/auth/session. Of successful replies, only
null confirms that the browser is signed out. A non-null reply must include
authenticated: true, a nonblank sessionKey, and nonblank account ID, email,
and name. If the reply is malformed, the console shows Session unavailable
and withholds private content and reads. Retry repeats the session check.
showLogin reads GET /api/auth/providers; a true google flag adds its Continue
with Google button, and discovery failure keeps password login. A true oidc flag adds a button
labelled from oidcSignIn.label only when oidcSignIn.authorizationUrl is https:; its start
URL must use that endpoint's origin and path, as Google's must use its fixed endpoint.
An explicit password: false makes the form recovery-only and changes the
provider-error advice from "use your password" to asking an administrator. The
console keeps the password form behind Recovery sign-in while discovery is
pending, so early focus or autofill cannot make it the default sign-in method.
Selecting recovery opens the form immediately; a later discovery reply preserves
that choice, its input, and keyboard focus. Discovery that permits passwords or
fails shows the form without requiring the recovery action. If that action has
keyboard focus, focus moves to Email when the form replaces it.
While sign-in is pending, the console ignores repeat password submissions and
provider activations. Keyboard focus stays on the control that started the
request unless the reader moves it. Generations reject late redirects. With
sessionBinding, loadPage
exchanges the button's stored attemptId once for its key. Tabs then send
their pinned x-occ-session-key, so a replaced cookie yields login.
A refused callback returns with authError and makes no exchange, so a failed
exchange (providerExchangeFailure) reports a rate limit, an outage, the
browser-origin check, a provider that the API no longer configures (403), or
an attempt that expired or was replaced (401), never a refused identity.
Password sign-in reports 429 with the Retry-After wait. The provider
budgets send no Retry-After, so provider start and the exchange say to wait a
minute. Each reports the origin check with its own text; every other password
400, 401 or 403 keeps one message. On the API side, sendAuthEndpoint
answers 429 with the wait and 503 with an outage message; every refusal
keeps the endpoint's one message.
When login interrupts a recognized console route, navigation.mts records its
page, validated Namespace ID, recognized Agent version and tab, and a Secrets page
link's validated Secret ID, without unrelated query parameters. Password
sign-in carries that intent in the current view; external sign-in stores it in
tab sessionStorage with the one-use attempt ID and a ten-minute limit. The
console consumes it only after confirming that attempt and its session, and
clears it on failure or logout. showExpiredLogin captures the validated current
route before clearing private state on an API 401 or foreground session
recheck. A null session result in loadPage also retains the requested route,
including after a failed check. It says the session expired only when this tab
had one: a mark in tab sessionStorage, set when a session resolves and cleared
by logout, tells a reload after expiry from a first visit. Sign-in reauthorizes
that destination; it never restores drafts or replays a mutation. A 403 remains
an access denial and does not start login. The login page accepts no return URL.
A native Agent recovery link can supply a 32-character lowercase hexadecimal key
in the console root fragment. loadPage removes the fragment before sign-in and
retains it across a failed session check, and resolves it only after session
confirmation with GET /native-admin/return/:key.
A valid authorized response routes to ordinary Agent detail; failed or malformed
responses open Agents, and a failed lookup says so once. The native Agent host redirects
a qualifying root or chat navigation here. Opening native chat still requires a separate user action.
authError=<provider> shows a generic, one-time error. With Google or OIDC,
account-disabled says the account is disabled and to ask
an administrator to enable it; provider-unavailable and unavailable say to try again
later. The
authentication flow
owns the server side.
apps/controller/src/auth/index.ts:requireTrustedBrowserOrigin checks Origin
before sign-in/out, even for SDK calls bypassing Better Auth middleware; headerless
CLI requests remain supported. The browser stores no credentials.
2. Authorize the destination
apps/controller/src/console/console.mts:loadPage confirms the account and
session binding before loading readable Namespaces or opening the requested
page. Agent and Namespace reads then follow their usual authorization rules.
An inaccessible resource remains unavailable. A foreground recheck of an open
Agent also confirms the session before refreshing private reads.
When the Namespace read fails after the session resolved, the page offers Sign
out beside Retry, so an account refused with 403 is not stranded.
A session or Namespace read that does not reach the API (the browser is offline,
or the read times out) tells nothing about the session. A signed-in tab keeps its
shell, says that it could not reach OpenClaw Enterprise, and moves focus to
Retry. The page and Namespace data are cleared, as for other failures.
See the parent flow.
Debugging and Verification
The focused cases in tests/browser/console.test.ts exercise the console
against controller sessions and resource reads, including a late startup observer
after a native entry failure and during a pending session read. Their external provider exchange
and recovery endpoint are modeled; they do not establish a deployed OAuth or
native-gateway integration.
The registered case “a session reply missing its binding blocks private reads
until Retry” in tests/browser/console-refocus-focus.test.ts checks that a
successful reply without a binding hides private content and sends no private
reads. After Retry, Namespace and Agent reads carry the confirmed binding.
