diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 77ba70d8e8e2..dab56cd0d2fc 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -524,11 +524,11 @@ automatically when the Gateway returns. Live controls and slash commands remain offline, except that **Stop** can queue an exact local run ID for replay. A session-only stop is not replayed because newer work may start in that session before the connection returns. -When this browser already holds credentials (a configured token/password or an approved device -token), first opens and reloads show a small animated OpenClaw mark while the connection is -established instead of flashing the login gate. The login gate only appears when no credentials -are stored yet or when the Gateway actively rejects them (bad token/password, revoked pairing) — -states that need your input rather than waiting. +First opens and reloads show a small animated OpenClaw mark while the Gateway resolves the initial +connection, including when authentication comes from a trusted proxy or Tailscale instead of a +browser-stored credential. The login gate appears only after the initial connection fails or the +Gateway actively rejects authentication (bad token/password, missing trusted identity, revoked +pairing) — states that need your input rather than waiting. ## PWA install and web push diff --git a/ui/src/api/gateway-browser-auth.ts b/ui/src/api/gateway-browser-auth.ts index 13115341688f..ebc416f2dfc1 100644 --- a/ui/src/api/gateway-browser-auth.ts +++ b/ui/src/api/gateway-browser-auth.ts @@ -1,5 +1,4 @@ import type { GatewayConnectAuthSelection } from "@openclaw/gateway-client/browser"; -import { loadDeviceAuthToken, peekStoredDeviceIdentityId } from "../lib/nodes/index.ts"; const CONTROL_UI_OPERATOR_ROLE = "operator"; @@ -12,33 +11,6 @@ export function storedDeviceTokenScopesAllowRead(role: string, scopes: readonly ); } -/** True when the next browser connect would present a usable stored credential. */ -export function hasStoredGatewayAuth(params: { - gatewayUrl: string; - token?: string; - password?: string; -}): boolean { - if (params.token?.trim() || params.password?.trim()) { - return true; - } - // Insecure contexts skip device identity, so their stored token is unusable. - if (typeof crypto === "undefined" || !crypto.subtle) { - return false; - } - const deviceId = peekStoredDeviceIdentityId(); - if (!deviceId) { - return false; - } - const storedEntry = loadDeviceAuthToken({ - deviceId, - gatewayUrl: params.gatewayUrl, - role: CONTROL_UI_OPERATOR_ROLE, - }); - return Boolean( - storedEntry && storedDeviceTokenScopesAllowRead(CONTROL_UI_OPERATOR_ROLE, storedEntry.scopes), - ); -} - export function gatewayRecoveryScopeMaterial( selected: GatewayConnectAuthSelection, ): string | undefined { diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 1d9811e3d29e..2339a5c29cf8 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -55,8 +55,6 @@ import { } from "./gateway-browser-auth.ts"; import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts"; -export { hasStoredGatewayAuth } from "./gateway-browser-auth.ts"; - export type GatewayEventFrame = EventFrame; type GatewayErrorInfo = ErrorShape; diff --git a/ui/src/app/app-root.ts b/ui/src/app/app-root.ts index 417995e062e4..b4d5298db892 100644 --- a/ui/src/app/app-root.ts +++ b/ui/src/app/app-root.ts @@ -1,7 +1,7 @@ import { ContextProvider } from "@lit/context"; import { html, nothing } from "lit"; import { state } from "lit/decorators.js"; -import { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { RouteId } from "../app-routes.ts"; import "../components/gateway-url-confirmation.ts"; import "../components/github-link-hovercard-registration.ts"; @@ -76,10 +76,6 @@ export class OpenClawApp extends OpenClawLightDomElement { @state() private onboarding = resolveOnboardingMode(globalThis.location?.search ?? ""); private readonly terminalOnly = isTerminalOnlyView(); - // Fixed at page load: whether this browser held credentials (token, - // password, or stored device token) before the first connect attempt. - // Later manual gate submissions are covered by loginGatePinned instead. - private initialAuthPresent = false; private runtime: ApplicationRuntime | undefined; private readonly contextProvider = new ContextProvider(this, { context: applicationContext, @@ -118,7 +114,6 @@ export class OpenClawApp extends OpenClawLightDomElement { preloadOptionalElement(this, APPROVAL_PAGE_ELEMENT); } const context = this.runtime.context; - this.initialAuthPresent = hasStoredGatewayAuth(context.gateway.connection); this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null; // Context identity changes only across a full app-tree connection epoch; // descendants reconnect and rebuild their controller-owned state afterward. @@ -228,19 +223,15 @@ export class OpenClawApp extends OpenClawLightDomElement { : nothing} `; } - // Transport drops after an established session keep the shell mounted - // (offline presentation + client auto-retry); the login gate is reserved for - // credential-less first connects, credential rejections, and manual gate - // submissions. A first connect backed by stored credentials paints the - // connecting splash instead of flashing the login gate; the gate returns - // the moment the attempt fails (lastError set on every close). + // In the normal Control UI document, the Gateway lifecycle owns unresolved + // first-connect state across every auth mode. Failures publish lastError + // before the gate returns; reconnects keep the shell mounted, and + // loginGatePinned protects manual submissions. const initialConnectPending = - this.initialAuthPresent && - !gatewayConnected && - gatewaySnapshot.phase !== "reconnecting" && + runtime.documentMode === null && + gatewaySnapshot.phase === "connecting" && !this.loginGatePinned && - gatewaySnapshot.lastError === null && - gatewaySnapshot.client !== null; + gatewaySnapshot.lastError === null; if (initialConnectPending) { return html` diff --git a/ui/src/e2e/initial-connect-splash.e2e.test.ts b/ui/src/e2e/initial-connect-splash.e2e.test.ts index e5ab013b1b6f..8ae3839ac934 100644 --- a/ui/src/e2e/initial-connect-splash.e2e.test.ts +++ b/ui/src/e2e/initial-connect-splash.e2e.test.ts @@ -1,5 +1,5 @@ // Control UI tests cover the initial-connect splash shown instead of the -// login gate while a first connect backed by stored credentials is in flight. +// login gate while the Gateway resolves its first connection attempt. import { mkdir } from "node:fs/promises"; import path from "node:path"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; @@ -45,6 +45,38 @@ async function captureProof(page: Page, name: string): Promise { await page.screenshot({ fullPage: true, path: path.join(artifactDir, `${name}.png`) }); } +async function traceLoginGateMounts(page: Page): Promise<() => Promise> { + await page.addInitScript(() => { + const trace = { mounted: false }; + ( + window as Window & { + openclawLoginGateMountTrace?: typeof trace; + } + ).openclawLoginGateMountTrace = trace; + new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof Element && + (node.localName === "openclaw-login-gate" || node.querySelector("openclaw-login-gate")) + ) { + trace.mounted = true; + } + } + } + }).observe(document, { childList: true, subtree: true }); + }); + return () => + page.evaluate( + () => + ( + window as Window & { + openclawLoginGateMountTrace?: { mounted: boolean }; + } + ).openclawLoginGateMountTrace?.mounted ?? false, + ); +} + describeControlUiE2e("Control UI initial connect splash E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -69,6 +101,7 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { it("shows the splash instead of the login gate while a configured token connects", async () => { const page = await createPage(); + const loginGateMounted = await traceLoginGateMounts(page); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); await page.goto(`${server.baseUrl}#token=e2e-shared-token`); @@ -93,6 +126,7 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { await gateway.resolveDeferred("connect"); await page.locator("openclaw-app-shell").waitFor(); expect(await page.locator(".connect-splash").count()).toBe(0); + expect(await loginGateMounted()).toBe(false); await captureProof(page, "02-connected-content"); }); @@ -159,14 +193,22 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { } }); - it("keeps the login gate for first connects without stored credentials", async () => { + it("shows the splash while a credential-less first connection resolves", async () => { const page = await createPage(); + const loginGateMounted = await traceLoginGateMounts(page); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); await page.goto(server.baseUrl); await gateway.waitForRequest("connect"); - await page.locator("openclaw-login-gate").waitFor(); + await page.locator(".connect-splash").waitFor(); + expect(await page.locator("openclaw-login-gate").count()).toBe(0); + expect(await loginGateMounted()).toBe(false); + await captureProof(page, "05-credentialless-connecting-mascot"); + + await gateway.resolveDeferred("connect"); + await page.locator("openclaw-app-shell").waitFor(); expect(await page.locator(".connect-splash").count()).toBe(0); + expect(await loginGateMounted()).toBe(false); }); it("falls back to the login gate when stored credentials are rejected", async () => { @@ -190,10 +232,10 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { const page = await createPage(); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); - // First visit has no credentials: the login gate owns the pending connect. + // First visit has no credentials, but the Gateway still owns the pending attempt. await page.goto(server.baseUrl); await gateway.waitForRequest("connect"); - await page.locator("openclaw-login-gate").waitFor(); + await page.locator(".connect-splash").waitFor(); await gateway.resolveDeferred("connect"); await page.locator("openclaw-app-shell").waitFor();