From c101207b99239f77395bddc185cfd53b2e3fa0c1 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 10 Jul 2026 01:37:43 -0500 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Hermes=20Cloud=20connection=20?= =?UTF-8?q?mode=20=E2=80=94=20one=20sign-in,=20agent=20discovery,=20silent?= =?UTF-8?q?=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third "Hermes Cloud" gateway mode to the desktop app: one portal sign-in auto-discovers the agents on your account and connects to any of them with no second interactive prompt. - Electron: widen connection mode to 'local' | 'remote' | 'cloud', routed through a centralized modeIsRemoteLike() so every resolution site treats cloud exactly like remote; portal discovery (GET /api/agents over the OAuth partition), Privy-cookie liveness, multi-org picker (NAS 409), and a silent per-agent /oauth cascade (load protected root, not /login). - Persist a cloudOrg on the cloud block; unselect cloud on mode switch. - Renderer: Hermes Cloud ModeCard + agent picker (signed-out/loading/empty/ list), org picker, Change-org, connected-highlight + Connected pill. - i18n (en + zh full; ja/zh-hant inherit via defineLocale), Cloud icon. - IPC: hermes:cloud:{status,login,logout,discover,agent-sign-in}. Salvage of #55402 onto current main: the original branch predates the desktop electron .cjs -> .ts migration (39d09453f), so the electron half was re-authored against the .ts files. Authorship preserved. cloud-auto-discovery Phases 3 + 4. --- .../electron/connection-config.test.ts | 59 ++ apps/desktop/electron/connection-config.ts | 45 +- apps/desktop/electron/main.ts | 501 +++++++++++++- apps/desktop/electron/preload.ts | 9 + .../src/app/settings/gateway-settings.tsx | 614 +++++++++++++++--- .../components/boot-failure-reauth.test.ts | 11 + .../src/components/boot-failure-reauth.ts | 6 +- apps/desktop/src/global.d.ts | 75 ++- apps/desktop/src/i18n/en.ts | 32 + apps/desktop/src/i18n/types.ts | 27 + apps/desktop/src/i18n/zh.ts | 31 + apps/desktop/src/lib/icons.ts | 2 + 12 files changed, 1287 insertions(+), 125 deletions(-) diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 34492bbd53f..518d49805df 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -20,7 +20,9 @@ import { buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, + cookiesHavePrivySession, cookiesHaveSession, + modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, @@ -47,6 +49,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => { assert.equal(normAuthMode('weird'), 'token') }) +// --- modeIsRemoteLike --- + +test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => { + // cloud resolves to a remote backend under the hood (Q6), so every resolution + // site treats it like remote. + assert.equal(modeIsRemoteLike('remote'), true) + assert.equal(modeIsRemoteLike('cloud'), true) + assert.equal(modeIsRemoteLike('local'), false) + assert.equal(modeIsRemoteLike(undefined), false) + assert.equal(modeIsRemoteLike(null), false) + assert.equal(modeIsRemoteLike('weird'), false) +}) + // --- profileRemoteOverride --- test('profileRemoteOverride returns null when no profile is given', () => { @@ -86,6 +101,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => { assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth') }) +test('profileRemoteOverride treats a cloud entry as a remote override', () => { + // A 'cloud' per-profile entry resolves to the same remote backend a 'remote' + // entry would (Q6) — the override must be returned, not dropped. + const config = { + profiles: { + coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' } + } + } + assert.deepEqual(profileRemoteOverride(config, 'coder'), { + url: 'https://agent-1.agents.nousresearch.com', + authMode: 'oauth', + token: undefined + }) +}) + test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride({}, 'coder'), null) assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null) @@ -332,6 +362,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', () assert.equal(cookiesHaveLiveSession([]), false) }) +// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) --- + +test('cookiesHavePrivySession detects the privy-token access cookie', () => { + assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true) +}) + +test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => { + assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true) + assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true) + assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true) +}) + +test('cookiesHavePrivySession is false for an empty value', () => { + assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false) +}) + +test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => { + // The whole point of Q7: a gateway session cookie is NOT a portal sign-in. + assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false) + assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false) +}) + +test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => { + assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false) + assert.equal(cookiesHavePrivySession(null), false) + assert.equal(cookiesHavePrivySession(undefined), false) + assert.equal(cookiesHavePrivySession([]), false) +}) + // --- tokenPreview --- test('tokenPreview returns null for empty', () => { diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 5d84972dfc1..569f9cc0726 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -37,6 +37,15 @@ const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at'] const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt'] +// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a +// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the +// `privy-token` access-token cookie (with `privy-id-token` alongside), which is +// also exactly what the `/api/agents` cookie-auth path validates. So portal +// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway +// cookies above. `privy-token` is the access token (the required signal); +// variants cover the secured-prefix forms and the older `privy-session` name. +const PRIVY_SESSION_COOKIE_VARIANTS = ['__Host-privy-token', '__Secure-privy-token', 'privy-token', 'privy-session'] + function normalizeRemoteBaseUrl(rawUrl) { const value = String(rawUrl || '').trim() @@ -150,20 +159,31 @@ function normAuthMode(mode) { return mode === 'oauth' ? 'oauth' : 'token' } +// True for connection modes that resolve to a REMOTE backend. 'cloud' is a +// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a +// remote-shaped block and reuses the entire remote connect/probe/reconnect +// path, so every resolution site treats it exactly like 'remote'. The only +// places that distinguish cloud from remote are the settings UI (which card to +// show) and config persistence (remembering the provenance). Centralized here +// so no resolution site forgets the third arm. +function modeIsRemoteLike(mode) { + return mode === 'remote' || mode === 'cloud' +} + /** * Select a profile's explicit remote override from a connection config, or null * when it has none (so the caller falls back to env → global remote → local). * * The config may carry a `profiles` map keyed by name; an entry counts as an - * override only with `mode === 'remote'` and a non-empty `url`. Pure: `token` - * is the raw stored secret; main.ts decrypts it. Returns + * override only with a remote-like `mode` (remote or cloud) and a non-empty + * `url`. Pure: `token` is the raw stored secret; main.ts decrypts it. Returns * `{ url, authMode, token } | null`. */ function profileRemoteOverride(config, profile) { const key = connectionScopeKey(profile) const entry = key ? config?.profiles?.[key] : null - if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') { + if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) { return null } @@ -292,6 +312,22 @@ function cookiesHaveLiveSession(cookies) { return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name))) } +/** + * True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty + * `privy-token` (access-token) cookie, or a variant. This is the portal + * analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not + * the Hermes gateway session cookies, so cloud sign-in / discovery liveness + * must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents` + * cookie path both key off `privy-token`.) + */ +function cookiesHavePrivySession(cookies) { + if (!Array.isArray(cookies)) { + return false + } + + return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name)) +} + export { AT_COOKIE_VARIANTS, authModeFromStatus, @@ -299,10 +335,13 @@ export { buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, + cookiesHavePrivySession, cookiesHaveSession, + modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, + PRIVY_SESSION_COOKIE_VARIANTS, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b8c2d03da38..da40595bfbc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -42,7 +42,9 @@ import { buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, + cookiesHavePrivySession, cookiesHaveSession, + modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, pathWithGlobalRemoteProfile, @@ -5088,12 +5090,25 @@ async function clearOauthSession(baseUrl) { } } -// Open the gateway's /login page in a visible window using the OAuth session -// partition, and resolve once the access-token cookie appears (login done) or -// 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. 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. +// +// `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.')) @@ -5112,6 +5127,7 @@ function openOauthLoginWindow(baseUrl) { let settled = false let win = null let pollTimer = null + let revealTimer = null const finish = err => { if (settled) { @@ -5123,6 +5139,10 @@ function openOauthLoginWindow(baseUrl) { clearInterval(pollTimer) } + if (revealTimer) { + clearTimeout(revealTimer) + } + try { if (win && !win.isDestroyed()) { win.destroy() @@ -5152,8 +5172,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, @@ -5176,6 +5202,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.')) @@ -5185,7 +5228,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))) }) @@ -5347,6 +5394,310 @@ async function freshGatewayWsUrl(profile) { return connection.wsUrl } +// --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery +// Phase 3) --------------------------------------------------------------- +// +// The "cloud" connection mode lets a user sign in to the Nous portal ONCE in +// the OAuth session partition, then (a) discover their hosted agents and (b) +// connect to any of them with no second interactive sign-in. Both ride the one +// portal session cookie living in `persist:hermes-remote-oauth`: +// - discovery → GET {portal}/api/agents over the partition-bound net; the +// portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie). +// - cascade → opening an agent's own /login in the same partition hits the +// portal's silent auto-approve (org member, existing session) and 302s back +// with that agent's session cookie — no prompt. Each agent still completes +// its own PKCE exchange; SSO removes the human click, not a security check. + +// Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI +// convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names) +// so a single override flips every Hermes surface to the same portal. +const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com' + +function resolvePortalBaseUrl() { + const raw = process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL + + return String(raw).trim().replace(/\/+$/, '') +} + +// Whether the OAuth partition currently holds a live Nous portal session — the +// credential that powers both discovery and the silent cascade. The portal +// authenticates via PRIVY, not the Hermes gateway session cookies, so this +// checks for the `privy-token` cookie on the portal host (NOT +// hasLiveOauthSession, which looks for hermes_session_at/rt that the portal +// never sets). See connection-config.ts cookiesHavePrivySession. +async function hasLivePortalSession() { + const sess = getOauthSession() + + if (!sess) { + return false + } + + const portalBaseUrl = resolvePortalBaseUrl() + const parsed = new URL(portalBaseUrl) + + try { + const cookies = await sess.cookies.get({ url: portalBaseUrl }) + + return cookiesHavePrivySession(cookies) + } catch { + try { + const cookies = await sess.cookies.get({ domain: parsed.hostname }) + + return cookiesHavePrivySession(cookies) + } catch { + return false + } + } +} + +// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike +// openOauthLoginWindow (which targets a gateway's /login), this lands on the +// portal itself so the resulting session cookie is portal-scoped — the cookie +// that authenticates discovery AND is reused for every silent per-agent +// cascade. Resolves once the portal session cookie appears. +function openPortalLoginWindow() { + const portalBaseUrl = resolvePortalBaseUrl() + + return new Promise((resolve, reject) => { + if (!app.isReady()) { + reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.')) + + return + } + + const sess = getOauthSession() + + if (!sess) { + reject(new Error('OAuth session partition is unavailable.')) + + return + } + + let settled = false + let win = null + let pollTimer = null + + const finish = err => { + if (settled) { + return + } + settled = true + + if (pollTimer) { + clearInterval(pollTimer) + } + + try { + if (win && !win.isDestroyed()) { + win.destroy() + } + } catch { + // window already torn down + } + + if (err) { + reject(err) + } else { + resolve({ portalBaseUrl, ok: true }) + } + } + + const checkCookie = async () => { + if (settled) { + return + } + + // A live portal (Privy) session cookie means sign-in completed. + if (await hasLivePortalSession()) { + finish(null) + } + } + + try { + win = new BrowserWindow({ + width: 520, + height: 720, + title: 'Sign in to Hermes Cloud', + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + session: sess, + webSecurity: true + } + }) + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))) + + return + } + + win.webContents.on('did-navigate', () => void checkCookie()) + win.webContents.on('did-redirect-navigation', () => void checkCookie()) + win.webContents.on('did-frame-navigate', () => void checkCookie()) + pollTimer = setInterval(() => void checkCookie(), 750) + + win.on('closed', () => { + if (!settled) { + finish(new Error('Sign-in window closed before authentication completed.')) + } + }) + + // Land on the portal root; any authenticated portal page sets the session + // cookie. We only care that the partition cookie jar is populated. + win.loadURL(portalBaseUrl).catch(error => { + finish(error instanceof Error ? error : new Error(String(error))) + }) + }) +} + +// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls +// the NAS trimmed-summary endpoint over the partition-bound net, so the portal +// session cookie is attached automatically (no bearer needed — NAS accepts the +// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs } +// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409 +// org_selection_required). Pass `org` (a slug/id from a prior org list) to +// scope discovery to that org. Throws a needsCloudLogin-tagged error when no +// portal session is present. +async function discoverCloudAgents(org?: string) { + const portalBaseUrl = resolvePortalBaseUrl() + + if (!(await hasLivePortalSession())) { + const err = new Error( + 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' + ) as any + err.needsCloudLogin = true + throw err + } + + const orgQuery = org ? `?org=${encodeURIComponent(org)}` : '' + let body + + try { + body = (await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, { + method: 'GET', + timeoutMs: 15_000 + })) as any + } catch (error) { + // A 401 means the portal session lapsed between the liveness check and the + // call — surface it as a re-login, not a generic failure. + if (error && error.statusCode === 401) { + const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.') as any + err.needsCloudLogin = true + err.cause = error + throw err + } + + // A 409 means we're a multi-org user who hasn't picked an org. The body + // carries the user's org list; surface it so the renderer shows a picker + // and re-calls discovery with the chosen org. (fetchJsonViaOauthSession + // throws on >=400 with err.statusCode + err.message "409: ".) + if (error && error.statusCode === 409) { + const orgs = parseOrgSelectionError(error) + + if (orgs) { + return { needsOrgSelection: true, orgs } + } + } + + throw error + } + + return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) } +} + +// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed +// shape the renderer persists, or null when absent/malformed. +function trimCloudOrg(org) { + if (!org || typeof org !== 'object' || typeof org.id !== 'string') { + return null + } + + return { + id: org.id, + slug: typeof org.slug === 'string' ? org.slug : null, + name: typeof org.name === 'string' ? org.name : org.id, + isPersonal: Boolean(org.isPersonal), + role: typeof org.role === 'string' ? org.role : 'MEMBER' + } +} + +// Extract the org list from a 409 org_selection_required error body. The error +// message is "409: " (see fetchJsonViaOauthSession); parse defensively +// and return null if it isn't the shape we expect (caller then rethrows). +function parseOrgSelectionError(error) { + const msg = String(error?.message || '') + const jsonStart = msg.indexOf('{') + + if (jsonStart < 0) { + return null + } + + let parsed + + try { + parsed = JSON.parse(msg.slice(jsonStart)) + } catch { + return null + } + + if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) { + return null + } + + return parsed.orgs + .filter(o => o && typeof o === 'object' && typeof o.id === 'string') + .map(o => ({ + id: o.id, + slug: typeof o.slug === 'string' ? o.slug : null, + name: typeof o.name === 'string' ? o.name : o.id, + isPersonal: Boolean(o.isPersonal), + role: typeof o.role === 'string' ? o.role : 'MEMBER' + })) +} + +// Project NAS's agent rows to the trimmed DTO the renderer consumes. +function trimCloudAgents(body) { + const agents = Array.isArray(body?.agents) ? body.agents : [] + + return agents + .filter(a => a && typeof a === 'object' && typeof a.id === 'string') + .map(a => ({ + id: a.id, + name: typeof a.name === 'string' ? a.name : a.id, + status: typeof a.status === 'string' ? a.status : 'unknown', + dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null, + dashboardGatewayState: typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown' + })) +} + +// Silent per-agent sign-in: open the selected agent dashboard's /login in the +// SAME OAuth partition. Because the user already holds a live portal session +// there, the agent's /oauth/authorize auto-approves (org member) and 302s back, +// setting that agent's gateway session cookie WITHOUT a second interactive +// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the +// agent's session cookie lands (a silent flow finishes in well under a second; +// if the portal session were absent it would fall through to an interactive +// login, which the discovery gate already prevents). Returns once the agent's +// gateway session cookie is present. +async function cloudAgentSilentSignIn(dashboardUrl) { + const baseUrl = normalizeRemoteBaseUrl(dashboardUrl) + + // Pre-req: a live portal session must exist, or this would surface an + // interactive prompt rather than a silent cascade. Discovery already gates on + // this, but a selection can arrive after the session lapsed. + if (!(await hasLivePortalSession())) { + const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.') as any + err.needsCloudLogin = true + throw err + } + + await openOauthLoginWindow(baseUrl, { silent: true }) + + return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } +} + function encryptDesktopSecret(value) { return encryptDesktopSecretStrict(value, safeStorage) } @@ -5392,8 +5743,14 @@ function sanitizeConnectionProfiles(raw: Record) { continue } - const cleaned: { mode: 'remote' | 'local'; url?: string; authMode?: string; token?: object } = { - mode: entry.mode === 'remote' ? 'remote' : 'local' + const cleaned: { + mode: 'remote' | 'local' | 'cloud' + url?: string + authMode?: string + token?: object + org?: string + } = { + mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } const url = String(entry.url || '').trim() @@ -5407,6 +5764,16 @@ function sanitizeConnectionProfiles(raw: Record) { cleaned.token = entry.token } + // Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can + // reopen into the same org for a per-profile cloud connection. + if (cleaned.mode === 'cloud') { + const org = String(entry.org || '').trim() + + if (org) { + cleaned.org = org + } + } + out[name] = cleaned } @@ -5442,7 +5809,7 @@ function readDesktopConnectionConfig() { // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { - mode: parsed.mode === 'remote' ? 'remote' : 'local', + mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so @@ -5513,7 +5880,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const remoteToken = decryptDesktopSecret(block.token) const authMode = normAuthMode(block.authMode) const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') - const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local' + // The env override forces a plain remote connection. Otherwise reflect the + // saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI + // reopens into the cloud picker; any non-remote-like value collapses to local. + const savedMode = key ? scoped?.mode : config.mode + const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local' let remoteOauthConnected = false @@ -5536,6 +5907,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon remoteAuthMode: authMode, remoteOauthConnected, remoteUrl, + // The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for + // remote/local. Lets Settings → Gateway reopen into the same org. + cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), // The env override only forces the global/primary connection; a per-profile @@ -5547,24 +5921,55 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // Build + validate a `{ url, authMode, token }` remote block. OAuth gateways // authenticate via the login-window session cookie (verified at connect time in // resolveRemoteBackend), so only token-auth remotes require a saved token. -function buildRemoteBlock(remoteUrl, authMode, token) { +// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered +// under — persisted so Settings can reopen into the same org; omitted from the +// block when empty so plain remote connections stay unchanged. +function buildRemoteBlock(remoteUrl, authMode, token, org?: string) { if (authMode !== 'oauth' && !decryptDesktopSecret(token)) { throw new Error('Remote gateway session token is required.') } - return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token } + const block: { url: string; authMode: string; token: object; org?: string } = { + url: normalizeRemoteBaseUrl(remoteUrl), + authMode, + token + } + const orgValue = typeof org === 'string' ? org.trim() : '' + + if (orgValue) { + block.org = orgValue + } + + return block } function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopConnectionConfig(), options: any = {}) { const persistToken = options.persistToken !== false const key = connectionScopeKey(input.profile) - const mode = input.mode === 'remote' ? 'remote' : 'local' + // 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is + // remembered as its own provenance (Q6) and resolves to remote downstream. + // Anything else collapses to local. + const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local' + const remoteLike = modeIsRemoteLike(mode) // The block being edited: a per-profile entry or the global remote block. - const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {} + // Leaving a CLOUD connection unselects it: a cloud block's url/org/token + // describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway, + // so switching to local or remote must NOT inherit them (otherwise the stale + // cloud URL lingers and re-selecting Cloud looks "already connected"). When the + // saved block was cloud and the new mode is not cloud, start from an empty + // block. (remote↔local toggles still preserve a real remote URL as before.) + const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode + const leavingCloud = existingMode === 'cloud' && mode !== 'cloud' + const existingBlock = leavingCloud ? {} : rawExistingBlock const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim() // authMode: explicit input wins; otherwise inherit the saved value, default 'token'. const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode) + // Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise + // inherit the saved org. A plain 'remote' connection never carries an org + // (switching cloud→remote drops it), so it stays unset unless mode is cloud. + const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : '' const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' const nextToken = incomingToken @@ -5574,23 +5979,27 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo : existingBlock.token if (key) { - // Per-profile scope: a remote entry pins this profile to its own backend; a - // local entry clears the override so the profile inherits the default. + // Per-profile scope: a remote/cloud entry pins this profile to its own + // backend; a local entry clears the override so the profile inherits the + // default. The mode tag (remote vs cloud) is preserved on the entry. const profiles = { ...(existing.profiles || {}) } - if (mode === 'remote') { - profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) } + if (remoteLike) { + profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) } } else { delete profiles[key] } - return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles } + return { + mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local', + remote: existing.remote || {}, + profiles + } } - const nextRemote = - mode === 'remote' - ? buildRemoteBlock(remoteUrl, authMode, nextToken) - : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } + const nextRemote = remoteLike + ? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } @@ -5701,8 +6110,8 @@ async function resolveRemoteBackend(profile) { return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env') } - // 3. Global remote. - if (config.mode !== 'remote') { + // 3. Global remote (or cloud — cloud resolves to a remote backend, Q6). + if (!modeIsRemoteLike(config.mode)) { return null } @@ -5727,14 +6136,15 @@ function configuredRemoteProfileNames() { } // True when the app is in app-global remote mode (Settings → "All profiles" → -// Remote, or the env override): a SINGLE remote backend serves every profile via -// ?profile=. Distinct from per-profile overrides — here there's one host for all. +// Remote/Cloud, or the env override): a SINGLE remote backend serves every +// profile via ?profile=. Cloud counts — it resolves to a remote backend (Q6). +// Distinct from per-profile overrides — here there's one host for all. function globalRemoteActive() { if (process.env.HERMES_DESKTOP_REMOTE_URL) { return true } - return readDesktopConnectionConfig().mode === 'remote' + return modeIsRemoteLike(readDesktopConnectionConfig().mode) } // GET a profile's resolved backend (remote pool or local primary), parsed JSON. @@ -5826,7 +6236,7 @@ async function testDesktopConnectionConfig(input: any = {}) { const block = key ? config.profiles?.[key] || null : config.remote const wantRemote = - block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + modeIsRemoteLike(block?.mode) || (!key && modeIsRemoteLike(config.mode)) || (modeIsRemoteLike(input.mode) && block) // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only @@ -7190,6 +7600,35 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) = // as still-connected rather than silently signed-out. return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false } }) + +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- +// One portal login in the OAuth partition powers both discovery and the silent +// per-agent cascade. See the discovery/cascade helpers above. +ipcMain.handle('hermes:cloud:status', async () => ({ + portalBaseUrl: resolvePortalBaseUrl(), + signedIn: await hasLivePortalSession() +})) +ipcMain.handle('hermes:cloud:login', async () => { + await openPortalLoginWindow() + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:logout', async () => { + await clearOauthSession(resolvePortalBaseUrl()) + + return { ok: true, signedIn: await hasLivePortalSession() } +}) +ipcMain.handle('hermes:cloud:discover', async (_event, org) => { + // Returns { agents } or { needsOrgSelection: true, orgs }. `org` (optional) + // scopes discovery to a chosen org for multi-org users. + return discoverCloudAgents(typeof org === 'string' && org ? org : undefined) +}) +ipcMain.handle('hermes:cloud:agent-sign-in', async (_event, dashboardUrl) => { + // Silent per-agent sign-in via the shared portal session. Returns the agent's + // gateway baseUrl + whether its session cookie landed; the renderer then + // saves a cloud-mode connection pointed at this dashboardUrl. + return cloudAgentSilentSignIn(dashboardUrl) +}) ipcMain.handle('hermes:connection-config:save', async (_event, payload) => { const config = coerceDesktopConnectionConfig(payload) writeDesktopConnectionConfig(config) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 3a74b1c3ebc..519df08dcac 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -43,6 +43,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', { probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + // Hermes Cloud: one portal login powers discovery + silent per-agent sign-in + // (cloud-auto-discovery Phase 3). + cloud: { + status: () => ipcRenderer.invoke('hermes:cloud:status'), + login: () => ipcRenderer.invoke('hermes:cloud:login'), + logout: () => ipcRenderer.invoke('hermes:cloud:logout'), + discover: org => ipcRenderer.invoke('hermes:cloud:discover', org), + agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl) + }, profile: { get: () => ipcRenderer.invoke('hermes:profile:get'), set: name => ipcRenderer.invoke('hermes:profile:set', name) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index aae1c75efe2..2fdeadcbd16 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,9 +3,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global' +import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' import { useI18n } from '@/i18n' -import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons' +import { ExternalLink } from '@/lib/external-link' +import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' @@ -13,9 +14,11 @@ import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' -type Mode = 'local' | 'remote' +type Mode = 'local' | 'remote' | 'cloud' type AuthMode = 'oauth' | 'token' type ProbeStatus = 'idle' | 'probing' | 'done' | 'error' +// Hermes Cloud discovery lifecycle for the cloud-mode panel. +type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error' interface GatewaySettingsState { envOverride: boolean @@ -25,6 +28,7 @@ interface GatewaySettingsState { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + cloudOrg: string } const EMPTY_STATE: GatewaySettingsState = { @@ -34,7 +38,8 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, - remoteUrl: '' + remoteUrl: '', + cloudOrg: '' } function ModeCard({ @@ -105,6 +110,32 @@ export function GatewaySettings() { const [remoteToken, setRemoteToken] = useState('') const [lastTest, setLastTest] = useState(null) + // --- Hermes Cloud (cloud mode) state --- + // One portal session powers discovery + the silent per-agent cascade. These + // track the cloud panel: whether we're signed in, the discovered agent list, + // and which agent is mid-connect. + const [cloudSignedIn, setCloudSignedIn] = useState(false) + const [cloudSigningIn, setCloudSigningIn] = useState(false) + const [cloudAgents, setCloudAgents] = useState([]) + const [cloudDiscover, setCloudDiscover] = useState('idle') + const [cloudConnectingId, setCloudConnectingId] = useState(null) + // Multi-org users: when discovery returns needsOrgSelection, we hold the org + // list here and show a picker. `cloudOrg` is the chosen org slug/id (null = + // not yet chosen / single-org user). + const [cloudOrgs, setCloudOrgs] = useState([]) + const [cloudOrg, setCloudOrgState] = useState(null) + // Mirror the selected org into a ref so connect reads the CURRENT value, not a + // value captured in a stale render closure. discoverCloud() resolves the org + // asynchronously (from the NAS response) and a user can click Connect in the + // same render tick; without the ref, connectCloudAgent could persist a null + // org even though discovery just resolved one. Always set both together. + const cloudOrgRef = useRef(null) + + const setCloudOrg = (value: null | string) => { + cloudOrgRef.current = value + setCloudOrgState(value) + } + // Connection scope: null = the global/default connection (the original // behavior); a profile name = that profile's per-profile remote override, so // each profile can point at its own backend. @@ -163,6 +194,18 @@ export function GatewaySettings() { // OAuth login button or the session-token entry box. The effective auth mode // prefers a fresh probe result over the saved value. const trimmedUrl = state.remoteUrl.trim() + + // The dashboardUrl of the currently-connected cloud instance (the saved + // cloud connection's remoteUrl), normalized for comparison against each + // discovered agent's dashboardUrl so we can highlight the active one and hide + // its Connect button. Empty unless the saved connection is a cloud one. + const connectedCloudUrl = state.mode === 'cloud' ? state.remoteUrl.trim().replace(/\/+$/, '') : '' + + const isConnectedAgent = (agent: DesktopCloudAgent) => + Boolean( + connectedCloudUrl && agent.dashboardUrl && agent.dashboardUrl.trim().replace(/\/+$/, '') === connectedCloudUrl + ) + useEffect(() => { if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) { setProbeStatus('idle') @@ -379,6 +422,234 @@ export function GatewaySettings() { } } + // --- Hermes Cloud handlers --- + + // Pull the discovered agent list over the shared portal session. Tolerant of + // a lapsed session: a needsCloudLogin error flips us back to signed-out. + // `org` scopes discovery for multi-org users; when discovery comes back with + // needsOrgSelection we surface the org list and show a picker instead. + const discoverCloud = async (org?: string) => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudDiscover('loading') + + try { + const result = await desktop.cloud.discover(org) + + if ('needsOrgSelection' in result && result.needsOrgSelection) { + // Multi-org user with no org chosen yet: show the picker. Don't clear a + // previously-chosen org list on a refresh. + setCloudOrgs(result.orgs) + setCloudAgents([]) + setCloudDiscover('done') + + return + } + + // Single org (or org now chosen): we have agents. + setCloudAgents('agents' in result ? result.agents : []) + + // Record the org AUTHORITATIVELY from the response (NAS echoes the org the + // list was scoped to), falling back to the org we requested. This is what + // gets persisted on connect, so it must be set even on single-membership + // auto-resolve where no picker ran and no `org` arg was passed. + const resolvedOrgRef = 'org' in result && result.org ? (result.org.slug ?? result.org.id) : null + + if (resolvedOrgRef) { + setCloudOrg(resolvedOrgRef) + } else if (org) { + setCloudOrg(org) + } + + setCloudDiscover('done') + } catch (err) { + setCloudAgents([]) + setCloudDiscover('error') + + // A lapsed/absent portal session means we're effectively signed out. + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudDiscoverFailed) + } + } + + // User picked an org from the multi-org picker: remember it and re-run + // discovery scoped to it. + const selectCloudOrg = (org: DesktopCloudOrg) => { + const ref = org.slug ?? org.id + setCloudOrg(ref) + void discoverCloud(ref) + } + + // "Change org": clear the selected org and re-discover with no org arg. A + // multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves + // back to their one org. Also clear the agent list so the current org's + // agents don't linger under the picker while discovery re-runs. + const changeCloudOrg = () => { + setCloudOrg(null) + setCloudAgents([]) + void discoverCloud() + } + + // On entering cloud mode (or scope change), read the portal session status and + // auto-discover when already signed in, so the picker is populated on open. + useEffect(() => { + if (state.mode !== 'cloud') { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + let cancelled = false + desktop.cloud + .status() + .then(status => { + if (cancelled) { + return + } + + setCloudSignedIn(status.signedIn) + + if (status.signedIn) { + // Restore the persisted org (if any) so we reopen straight into that + // org's agent list instead of the picker; discoverCloud(org) also + // records it as the selected org. Empty → normal discovery (single-org + // resolves automatically; multi-org shows the picker). + const savedOrg = state.cloudOrg || '' + + if (savedOrg) { + setCloudOrg(savedOrg) + } + + void discoverCloud(savedOrg || undefined) + } else { + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + } + }) + .catch(() => { + if (!cancelled) { + setCloudSignedIn(false) + } + }) + + return () => void (cancelled = true) + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only + }, [state.mode, scope]) + + const cloudSignIn = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + const result = await desktop.cloud.login() + setCloudSignedIn(result.signedIn) + + if (result.signedIn) { + await discoverCloud() + } + } catch (err) { + notifyError(err, g.cloudSignInFailed) + } finally { + setCloudSigningIn(false) + } + } + + const cloudSignOut = async () => { + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudSigningIn(true) + + try { + await desktop.cloud.logout() + setCloudSignedIn(false) + setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) + setCloudDiscover('idle') + notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) + } catch (err) { + notifyError(err, g.signOutFailed) + } finally { + setCloudSigningIn(false) + } + } + + // Select a discovered agent: drive the silent per-agent cascade (no second + // prompt — the shared portal session auto-approves), then persist a cloud-mode + // connection pointed at its dashboardUrl and apply it (reconnects the window). + const connectCloudAgent = async (agent: DesktopCloudAgent) => { + if (!agent.dashboardUrl) { + return + } + + const desktop = window.hermesDesktop + + if (!desktop?.cloud) { + return + } + + setCloudConnectingId(agent.id) + + try { + const result = await desktop.cloud.agentSignIn(agent.dashboardUrl) + + if (!result.connected) { + notify({ + kind: 'warning', + title: t.boot.failure.signInIncompleteTitle, + message: t.boot.failure.signInIncompleteMessage + }) + + return + } + + // Persist a cloud-mode connection (remote-shaped, oauth) and reconnect. + // Include the selected org so Settings reopens into the same org + instance. + // Read the REF (not the cloudOrg state) so a just-resolved org from + // discovery in this same render tick is captured, not a stale null. + const next = await desktop.applyConnectionConfig({ + mode: 'cloud', + profile: scope ?? undefined, + remoteAuthMode: 'oauth', + remoteUrl: agent.dashboardUrl, + cloudOrg: cloudOrgRef.current ?? undefined + }) + + setState(next) + notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) }) + } catch (err) { + if (err && typeof err === 'object' && 'needsCloudLogin' in err) { + setCloudSignedIn(false) + } + + notifyError(err, g.cloudConnectFailed) + } finally { + setCloudConnectingId(null) + } + } + const testRemote = async () => { if (!canUseRemote) { notify({ @@ -465,7 +736,7 @@ export function GatewaySettings() { ) : null} -
+
setState(current => ({ ...current, mode: 'local' }))} title={g.localTitle} /> + setState(current => ({ ...current, mode: 'cloud' }))} + title={g.cloudTitle} + />
-
- setState(current => ({ ...current, remoteUrl: event.target.value }))} - placeholder="https://gateway.example.com/hermes" - value={state.remoteUrl} - /> - } - description={g.remoteUrlDesc} - title={g.remoteUrlTitle} - /> - - {state.mode === 'remote' && probeStatus === 'probing' ? ( -
- - {g.probing} -
- ) : null} - - {state.mode === 'remote' && probeStatus === 'error' ? ( -
- - {g.probeError} -
- ) : null} - - {/* OAuth / password gateways: present a sign-in button + connection status. */} - {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + {/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker + whose selection drives the silent per-agent cascade + a cloud + connection. Replaces the URL/token form while in cloud mode. */} + {state.mode === 'cloud' && !state.envOverride ? ( +
- {g.signedIn} + {g.cloudSignedIn} -
) : ( - ) } - description={ - oauthConnected - ? isPasswordProvider - ? g.authSignedInPassword - : g.authSignedInOauth - : isPasswordProvider - ? g.authNeedsPassword - : g.authNeedsOauth(providerLabel) - } - title={g.authTitle} + description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn} + title={g.cloudSignInTitle} /> - ) : null} - {/* Session-token gateways: keep the existing token entry box. */} - {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + {cloudSignedIn ? ( + cloudOrgs.length > 0 && !cloudOrg ? ( + // Multi-org user who hasn't picked an org yet: show the org picker + // instead of the agent list. Selecting one re-runs discovery + // scoped to it. +
+
+ {g.cloudOrgPickerTitle} +
+
+ {cloudOrgs.map(orgEntry => ( + selectCloudOrg(orgEntry)} size="sm"> + {g.cloudOrgSelect} + + } + description={g.cloudOrgRole(orgEntry.role)} + key={orgEntry.id} + title={orgEntry.name} + /> + ))} +
+
+ ) : ( +
+
+
+ {g.cloudAgentsTitle} +
+
+ {cloudOrg ? ( + // Let the user switch orgs. Gating on cloudOrgs.length would + // hide this after a restore-open (which discovers straight + // into the saved org and never populates the org list). So + // show it whenever an org is selected: clicking clears the + // org and re-runs discovery with no org arg — a multi-org + // user gets the picker (NAS 409), a single-org user simply + // auto-resolves back to their one org (harmless). + + ) : null} + +
+
+ + {cloudDiscover === 'loading' ? ( +
+ + {g.cloudLoadingAgents} +
+ ) : cloudAgents.length === 0 ? ( +
+ + + {g.cloudNoAgents.before} + + {g.cloudNoAgents.linkText} + + {g.cloudNoAgents.after} + +
+ ) : ( +
+ {cloudAgents.map(agent => { + const connected = isConnectedAgent(agent) + + return ( +
+ + + {g.cloudConnectedPill} + + ) : ( + + ) + } + description={g.cloudStatusLabel(agent.dashboardGatewayState)} + title={agent.name} + /> +
+ ) + })} +
+ )} +
+ ) + ) : null} +
+ ) : null} + + {state.mode === 'remote' && !state.envOverride ? ( +
setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) : g.pasteSessionToken - } - type="password" - value={remoteToken} + onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))} + placeholder="https://gateway.example.com/hermes" + value={state.remoteUrl} /> } - description={g.tokenDesc} - title={g.tokenTitle} + description={g.remoteUrlDesc} + title={g.remoteUrlTitle} /> - ) : null} -
+ + {state.mode === 'remote' && probeStatus === 'probing' ? ( +
+ + {g.probing} +
+ ) : null} + + {state.mode === 'remote' && probeStatus === 'error' ? ( +
+ + {g.probeError} +
+ ) : null} + + {/* OAuth / password gateways: present a sign-in button + connection status. */} + {state.mode === 'remote' && authResolved && authMode === 'oauth' ? ( + + + {g.signedIn} + + +
+ ) : ( + + ) + } + description={ + oauthConnected + ? isPasswordProvider + ? g.authSignedInPassword + : g.authSignedInOauth + : isPasswordProvider + ? g.authNeedsPassword + : g.authNeedsOauth(providerLabel) + } + title={g.authTitle} + /> + ) : null} + + {/* Session-token gateways: keep the existing token entry box. */} + {state.mode === 'remote' && authResolved && authMode === 'token' ? ( + setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet + ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) + : g.pasteSessionToken + } + type="password" + value={remoteToken} + /> + } + description={g.tokenDesc} + title={g.tokenTitle} + /> + ) : null} + + ) : null} {lastTest ?
{lastTest}
: null} -
- - - -
+ {/* Test/Save apply to local + remote. Cloud connects via the agent picker + above (which applies a cloud connection on select), so its only + bottom-row action would be redundant — hidden in cloud mode. */} + {state.mode !== 'cloud' ? ( +
+ {state.mode === 'remote' ? ( + + ) : null} + + +
+ ) : null}
= {}): DesktopConnec remoteTokenPreview: null, remoteTokenSet: false, remoteUrl: 'https://box:9119', + cloudOrg: '', ...overrides } } @@ -31,6 +32,16 @@ describe('isRemoteReauthFailure', () => { expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false) }) + it('true for a cloud connection with a lapsed session (cloud resolves to remote oauth)', () => { + // A 'cloud' connection is a remote oauth backend under the hood (Q6), so a + // lapsed cloud session is the same reauth failure as a lapsed remote one. + expect(isRemoteReauthFailure(config({ mode: 'cloud' }))).toBe(true) + }) + + it('false for a connected cloud session', () => { + expect(isRemoteReauthFailure(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(false) + }) + it('false for a token (non-gated) remote gateway', () => { expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false) }) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 3aeae7846e4..fd2e01ffcb6 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -31,14 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = { // dashboard restarted) and the local-recovery buttons (Retry/Repair) can't // fix it — only re-establishing the remote session can. A connected oauth // session, or a token/local gateway, boots for some other reason the -// local-recovery buttons address, so those return false here. +// local-recovery buttons address, so those return false here. 'cloud' counts +// as remote here — it resolves to a remote oauth backend (cloud-auto-discovery +// Q6), so a lapsed cloud session is the same reauth failure. export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { if (!config) { return false } return ( - config.mode === 'remote' && + (config.mode === 'remote' || config.mode === 'cloud') && config.remoteAuthMode === 'oauth' && !config.remoteOauthConnected && Boolean(config.remoteUrl) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 3c2e8fe8da2..9e820109fad 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -55,6 +55,15 @@ declare global { probeConnectionConfig: (remoteUrl: string) => Promise oauthLoginConnectionConfig: (remoteUrl: string) => Promise oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise + // Hermes Cloud: one portal login powers discovery + silent per-agent + // sign-in (cloud-auto-discovery Phase 3). + cloud: { + status: () => Promise + login: () => Promise + logout: () => Promise + discover: (org?: string) => Promise + agentSignIn: (dashboardUrl: string) => Promise + } profile: { get: () => Promise // Persists the desktop's profile choice and relaunches the local @@ -359,6 +368,9 @@ export interface DesktopUpdateProgress { export interface HermesConnection { baseUrl: string isFullscreen: boolean + // The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a + // 'cloud' saved-config entry resolves to a 'remote' connection under the hood + // (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'. mode?: 'local' | 'remote' authMode?: 'oauth' | 'token' nativeOverlayWidth: number @@ -391,7 +403,12 @@ export interface DesktopActiveProfile { export interface DesktopConnectionConfig { envOverride: boolean - mode: 'local' | 'remote' + // The saved connection mode. 'cloud' is a Hermes Cloud connection: it carries + // a remote-shaped block (remoteUrl = the selected agent's dashboardUrl, + // remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into + // the cloud picker. Resolution treats cloud exactly as remote + // (cloud-auto-discovery Q3/Q6). + mode: 'local' | 'remote' | 'cloud' // The profile this config describes, or null for the global/default // connection. Per-profile entries let a profile point at its own backend. profile: null | string @@ -400,16 +417,23 @@ export interface DesktopConnectionConfig { remoteTokenPreview: string | null remoteTokenSet: boolean remoteUrl: string + // For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the + // connected instance was discovered under, so Settings → Gateway can reopen + // into that org. Empty string for remote/local. + cloudOrg: string } export interface DesktopConnectionConfigInput { - mode: 'local' | 'remote' + mode: 'local' | 'remote' | 'cloud' // When set, the save/apply/test targets this profile's per-profile remote // override instead of the global connection. profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string remoteUrl?: string + // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to + // persist so Settings can reopen into it. Ignored for remote/local modes. + cloudOrg?: string } export interface DesktopConnectionTestResult { @@ -448,6 +472,53 @@ export interface DesktopOauthLogoutResult { connected: boolean } +// --- Hermes Cloud (cloud-auto-discovery Phase 3) --- + +export interface DesktopCloudStatus { + // The portal base URL the desktop talks to (default or env-overridden). + portalBaseUrl: string + // Whether the OAuth partition holds a live portal session (AT-or-RT). + signedIn: boolean +} + +// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents. +export interface DesktopCloudAgent { + id: string + name: string + status: string + // null until the agent has a provisioned dashboard (show "provisioning…"). + dashboardUrl: string | null + // "active" | "degraded" | "down" | "unknown". + dashboardGatewayState: string +} + +// An org the signed-in user belongs to — for the org picker shown when a +// multi-org user's discovery call needs disambiguation (NAS 409). +export interface DesktopCloudOrg { + id: string + slug: string | null + name: string + isPersonal: boolean + // "OWNER" | "MEMBER". + role: string +} + +// Discovery result: either the agent list, OR a request to pick an org first +// (multi-org user, no org chosen yet). The renderer shows a picker on the +// latter and re-calls discover(org). On the agents branch, `org` echoes the +// authoritatively-resolved org the list was scoped to (from NAS), so the +// desktop persists it without relying on transient picker state. +export type DesktopCloudDiscoverResult = + | { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false } + | { needsOrgSelection: true; orgs: DesktopCloudOrg[] } + +export interface DesktopCloudAgentSignInResult { + // The agent gateway base URL the silent sign-in targeted. + baseUrl: string + // Whether the agent's gateway session cookie landed (silent cascade done). + connected: boolean +} + export interface DesktopBootProgress { error: string | null fakeMode: boolean diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0ed3e0ff881..ac278dc9a08 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -535,6 +535,38 @@ export const en: Translations = { remoteTitle: 'Remote gateway', remoteDesc: 'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.', + cloudTitle: 'Hermes Cloud', + cloudDesc: + 'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Sign in to Hermes Cloud', + cloudSignedIn: 'Signed in to Hermes Cloud', + cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.', + cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.', + cloudAgentsTitle: 'Your agents', + cloudOrgPickerTitle: 'Choose an organization', + cloudOrgSelect: 'Select', + cloudOrgChange: 'Change org', + cloudOrgRole: role => `Role: ${role}`, + cloudLoadingAgents: 'Loading your agents…', + cloudNoAgents: { + before: 'No agents found on this account. Create one in the ', + linkText: 'Nous portal', + after: ', then refresh.' + }, + cloudRefresh: 'Refresh', + cloudConnect: 'Connect', + cloudConnecting: 'Connecting…', + cloudDiscoverFailed: 'Could not load your Hermes Cloud agents', + cloudConnectFailed: 'Could not connect to that agent', + cloudSignInFailed: 'Hermes Cloud sign-in failed', + cloudSignedOutTitle: 'Signed out of Hermes Cloud', + cloudSignedOutMessage: 'Cleared the Hermes Cloud session.', + cloudConnectedTitle: 'Connected', + cloudConnectedPill: 'Connected', + cloudConnectedTo: name => `Connected to ${name}.`, + cloudAgentProvisioning: 'Provisioning…', + cloudStatusLabel: status => `Status: ${status}`, remoteUrlTitle: 'Remote URL', remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.', probing: 'Checking how this gateway authenticates…', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5c10c3d6873..20e41ee97e2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -446,6 +446,33 @@ export interface Translations { localDesc: string remoteTitle: string remoteDesc: string + cloudTitle: string + cloudDesc: string + cloudSignInTitle: string + cloudSignIn: string + cloudSignedIn: string + cloudNeedsSignIn: string + cloudSignedInDesc: string + cloudAgentsTitle: string + cloudOrgPickerTitle: string + cloudOrgSelect: string + cloudOrgChange: string + cloudOrgRole: (role: string) => string + cloudLoadingAgents: string + cloudNoAgents: { before: string; linkText: string; after: string } + cloudRefresh: string + cloudConnect: string + cloudConnecting: string + cloudDiscoverFailed: string + cloudConnectFailed: string + cloudSignInFailed: string + cloudSignedOutTitle: string + cloudSignedOutMessage: string + cloudConnectedTitle: string + cloudConnectedPill: string + cloudConnectedTo: (name: string) => string + cloudAgentProvisioning: string + cloudStatusLabel: (status: string) => string remoteUrlTitle: string remoteUrlDesc: string probing: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 84b9f54e281..56a7cf059c4 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -725,6 +725,37 @@ export const zh: Translations = { remoteTitle: '远程网关', remoteDesc: '将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。', + cloudTitle: 'Hermes Cloud', + cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: '登录 Hermes Cloud', + cloudSignedIn: '已登录 Hermes Cloud', + cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。', + cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。', + cloudAgentsTitle: '你的智能体', + cloudOrgPickerTitle: '选择一个组织', + cloudOrgSelect: '选择', + cloudOrgChange: '切换组织', + cloudOrgRole: role => `角色:${role}`, + cloudLoadingAgents: '正在加载你的智能体…', + cloudNoAgents: { + before: '此账户下未找到智能体。请在', + linkText: 'Nous 门户', + after: '中创建一个,然后刷新。' + }, + cloudRefresh: '刷新', + cloudConnect: '连接', + cloudConnecting: '正在连接…', + cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体', + cloudConnectFailed: '无法连接到该智能体', + cloudSignInFailed: 'Hermes Cloud 登录失败', + cloudSignedOutTitle: '已退出 Hermes Cloud', + cloudSignedOutMessage: '已清除 Hermes Cloud 会话。', + cloudConnectedTitle: '已连接', + cloudConnectedPill: '已连接', + cloudConnectedTo: name => `已连接到 ${name}。`, + cloudAgentProvisioning: '正在配置…', + cloudStatusLabel: status => `状态:${status}`, remoteUrlTitle: '远程 URL', remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。', probing: '正在检查此网关的认证方式…', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e863aa39280..42073f02510 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -27,6 +27,7 @@ import { IconCircle as CircleIcon, IconClipboard as Clipboard, IconClock as Clock, + IconCloud as Cloud, IconCommand as Command, IconCopy as Copy, IconCopy as CopyIcon, @@ -142,6 +143,7 @@ export { CircleIcon, Clipboard, Clock, + Cloud, Command, Copy, CopyIcon,