diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 12f7859640d..83a351a1dc9 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -142,19 +142,30 @@ 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.cjs 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.cjs 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 } @@ -273,6 +284,7 @@ module.exports = { connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + modeIsRemoteLike, normAuthMode, normalizeRemoteBaseUrl, pathWithGlobalRemoteProfile, diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 1c7330e78d0..845a3743d2f 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -22,6 +22,7 @@ const { connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + modeIsRemoteLike, normAuthMode, normalizeRemoteBaseUrl, pathWithGlobalRemoteProfile, @@ -47,6 +48,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', () => { @@ -85,6 +99,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) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index e800034500b..6d3395d04e3 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -102,6 +102,7 @@ const { connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + modeIsRemoteLike, normAuthMode, normalizeRemoteBaseUrl, pathWithGlobalRemoteProfile, @@ -4595,6 +4596,186 @@ 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. Reuses the +// same AT-or-RT liveness notion as the per-gateway check. +async function hasLivePortalSession() { + return hasLiveOauthSession(resolvePortalBaseUrl()) +} + +// 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 session (AT or RT cookie) means sign-in completed. + if (await hasLiveOauthSession(portalBaseUrl)) 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 Phase 2.5 +// accepts the cookie). Returns the trimmed agent list; throws a +// needsCloudLogin-tagged error when no portal session is present. +async function discoverCloudAgents() { + const portalBaseUrl = resolvePortalBaseUrl() + if (!(await hasLiveOauthSession(portalBaseUrl))) { + const err = new Error( + 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' + ) + err.needsCloudLogin = true + throw err + } + + let body + try { + body = await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents`, { + method: 'GET', + timeoutMs: 15_000 + }) + } 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.') + err.needsCloudLogin = true + err.cause = error + throw err + } + throw error + } + + const agents = Array.isArray(body?.agents) ? body.agents : [] + // Pass the trimmed DTO straight through; the renderer renders name/status/ + // health and uses dashboardUrl to resolve a connection on selection. + 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.') + err.needsCloudLogin = true + throw err + } + await openOauthLoginWindow(baseUrl) + return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } +} + function encryptDesktopSecret(value) { return encryptDesktopSecretStrict(value, safeStorage) } @@ -4638,7 +4819,7 @@ function sanitizeConnectionProfiles(raw) { continue } - const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' } + const cleaned = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } const url = String(entry.url || '').trim() if (url) { cleaned.url = url @@ -4681,7 +4862,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 @@ -4752,7 +4933,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 if (authMode === 'oauth' && remoteUrl) { @@ -4795,7 +4980,11 @@ function buildRemoteBlock(remoteUrl, authMode, token) { function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) { 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 || {} @@ -4810,21 +4999,25 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect : 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) } } 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) + : { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken } // Preserve per-profile overrides when saving the global connection. return { mode, remote: nextRemote, profiles: existing.profiles || {} } @@ -4928,8 +5121,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 } const authMode = normAuthMode(config.remote?.authMode) @@ -4951,13 +5144,14 @@ 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. @@ -5045,7 +5239,9 @@ async function testDesktopConnectionConfig(input = {}) { // already normalized the URL and resolved token inheritance for the scope. 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 // need a base URL. For a remote config we normalize the URL from the input; @@ -6273,6 +6469,32 @@ 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 () => { + const agents = await discoverCloudAgents() + return { agents } +}) +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.cjs b/apps/desktop/electron/preload.cjs index aa8bcc16128..b9890b2fb2c 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -41,6 +41,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: () => ipcRenderer.invoke('hermes:cloud:discover'), + 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..22de3c3e349 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -13,7 +13,7 @@ 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' diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 613b43f6535..cb55cd6772c 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -31,6 +31,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 870d3311837..9e7dabba8dc 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: () => Promise + agentSignIn: (dashboardUrl: string) => Promise + } profile: { get: () => Promise // Persists the desktop's profile choice and relaunches the local @@ -354,6 +363,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 @@ -386,7 +398,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 @@ -398,7 +415,7 @@ export interface DesktopConnectionConfig { } 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 @@ -443,6 +460,37 @@ 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 +} + +export interface DesktopCloudDiscoverResult { + agents: DesktopCloudAgent[] +} + +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