From 883fa67b7ece1f41620f20f8eb3eadcd0556e226 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 1 Jul 2026 12:08:20 +1000 Subject: [PATCH] fix(desktop): make the per-agent cloud cascade actually silent (load protected root, not /login) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent per-agent sign-in (decisions.md Q5) was prompting a SECOND interactive login after portal sign-in → org → dashboard selection (Ben's screencast). Root cause: cloudAgentSilentSignIn → openOauthLoginWindow loaded the agent gateway's /login, but /login is a PUBLIC route (dashboard-auth middleware allowlist), so the gate's _auto_sso_response never runs there — it only fires on an unauthenticated load of a PROTECTED page. The window therefore rendered the interactive 'Log in with X' chooser every time, instead of the silent 302 cascade. (Auto-SSO is correctly configured on hosted agents: exactly one 'nous' session provider, client_id agent:{id}, so it would have fired silently if triggered.) Fix: openOauthLoginWindow(baseUrl, { silent }). The cascade passes silent:true, which loads the PROTECTED root '/' instead of '/login'. The gate then runs auto-SSO — single provider + a live partition portal session → 302 through /auth/login → portal /oauth/authorize (auto-approves org members) → /auth/callback sets the gateway session cookie with NO prompt. In silent mode the window also starts HIDDEN and only reveals after 2.5s if the cascade hasn't completed (graceful fallback to interactive, e.g. the portal session lapsed). The interactive remote-gateway login (settings UI) keeps silent:false → /login chooser, behavior unchanged. Verified live end-to-end on Ben's host: portal sign-in → org picker → select agent → Connect now completes with no second login prompt. cloud-auto-discovery Phase 3 follow-up (decisions.md Q9). --- apps/desktop/electron/main.cjs | 53 +++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 82536b64f81..d47e2403eaa 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -4400,7 +4400,23 @@ async function clearOauthSession(baseUrl) { // reject if the user closes the window first. The window navigates through the // IDP and back to /auth/callback, which sets the session cookies on the // partition; we poll the cookie jar rather than try to read the HttpOnly value. -function openOauthLoginWindow(baseUrl) { +// Open a gateway login window in the OAuth session partition, resolving once +// the access-token cookie appears (login done) or rejecting if the user closes +// the window first. +// +// `silent` selects the URL the window loads, which decides interactive-vs-silent: +// - silent=false (default): load ``/login`` — the public interstitial that +// renders the "Log in with X" provider chooser. This is the interactive +// remote-gateway login the settings UI drives. +// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public +// route, so loading it NEVER triggers the gate's auto-SSO and always shows +// the chooser. Loading a protected page with no session cookie makes the +// gate run ``_auto_sso_response``: single registered provider + a live +// portal session in this partition → a silent 302 through +// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members) +// → ``/auth/callback``, which sets the gateway cookie with NO interactive +// prompt. This is the per-agent cloud cascade (decisions.md Q5). +function openOauthLoginWindow(baseUrl, { silent = false } = {}) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) @@ -4415,11 +4431,13 @@ function openOauthLoginWindow(baseUrl) { let settled = false let win = null let pollTimer = null + let revealTimer = null const finish = err => { if (settled) return settled = true if (pollTimer) clearInterval(pollTimer) + if (revealTimer) clearTimeout(revealTimer) try { if (win && !win.isDestroyed()) win.destroy() } catch { @@ -4438,8 +4456,14 @@ function openOauthLoginWindow(baseUrl) { win = new BrowserWindow({ width: 520, height: 720, - title: 'Sign in to Hermes gateway', + title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', autoHideMenuBar: true, + // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in + // well under a second, so the window normally never needs to show. We + // only reveal it as a fallback if the cascade DOESN'T complete quickly + // (e.g. the portal session lapsed and the gate fell through to the + // interactive chooser) — see the reveal timer below. + show: !silent, webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -4461,6 +4485,23 @@ function openOauthLoginWindow(baseUrl) { win.webContents.on('did-frame-navigate', () => void checkCookie()) pollTimer = setInterval(() => void checkCookie(), 750) + // Silent-mode reveal fallback: if the cascade hasn't settled shortly, the + // auto-SSO didn't go through silently (no portal session, multi-provider, + // loop-guard tripped, etc.) and the window is now showing an interactive + // page. Reveal it so the user can complete sign-in manually rather than + // staring at nothing. Cleared on finish(). + if (silent && win) { + revealTimer = setTimeout(() => { + try { + if (!settled && win && !win.isDestroyed() && !win.isVisible()) { + win.show() + } + } catch { + // window torn down + } + }, 2500) + } + win.on('closed', () => { if (!settled) finish(new Error('Login window closed before authentication completed.')) }) @@ -4468,7 +4509,11 @@ function openOauthLoginWindow(baseUrl) { // ``next`` is intentionally omitted: the gateway lands on ``/`` after // login, which is a valid authenticated page that sets the cookies. We // only care that the cookie jar is populated. - const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login` + // + // silent=true loads the protected root so the gate auto-SSOs (no chooser); + // silent=false loads the public ``/login`` chooser for interactive sign-in. + const normalizedBase = normalizeRemoteBaseUrl(baseUrl) + const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login` win.loadURL(loginUrl).catch(error => { finish(error instanceof Error ? error : new Error(String(error))) }) @@ -4832,7 +4877,7 @@ async function cloudAgentSilentSignIn(dashboardUrl) { err.needsCloudLogin = true throw err } - await openOauthLoginWindow(baseUrl) + await openOauthLoginWindow(baseUrl, { silent: true }) return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } }