feat(desktop): cloud connection mode plumbing — widen mode, portal login, discovery, silent cascade

Phase 3 (non-UI) of cloud-auto-discovery. Adds the 'cloud' connection mode and
the IPC plumbing for a single portal login that powers both agent discovery and
silent per-agent sign-in. The Phase 4 UI (cloud ModeCard + instance picker)
sits on top of these IPC methods.

Mode widening (Model A, decisions.md Q6): DesktopConnectionConfig.mode and
DesktopConnectionConfigInput.mode widen to 'local'|'remote'|'cloud'. A cloud
entry is a remote-shaped block (remoteUrl = the selected agent's dashboardUrl,
remoteAuthMode 'oauth') tagged mode 'cloud' so settings reopens into the cloud
picker. Every RESOLUTION site treats cloud as remote via the new
modeIsRemoteLike() helper (centralized in connection-config.cjs): readDesktop-
ConnectionConfig, sanitizeConnectionProfiles, sanitizeDesktopConnectionConfig,
coerceDesktopConnectionConfig, profileRemoteOverride, resolveRemoteBackend,
globalRemoteActive, testDesktopConnectionConfig, and isRemoteReauthFailure. The
live resolved HermesConnection.mode stays 'local'|'remote' — cloud never reaches
the boot path or the renderer remote-gating sites.

Cloud mechanics (main.cjs): one portal session in the persist:hermes-remote-oauth
partition does double duty — discoverCloudAgents() GETs {portal}/api/agents over
the partition-bound net (cookie-authed; NAS #542 accepts the cookie), and
cloudAgentSilentSignIn() opens a selected agent's /login in the same partition so
the portal's silent auto-approve 302s back with that agent's session cookie, no
second prompt. Portal base URL resolves via DEFAULT_NOUS_PORTAL_URL +
HERMES_PORTAL_BASE_URL/NOUS_PORTAL_BASE_URL overrides, mirroring the CLI.

IPC: hermes☁️{status,login,logout,discover,agent-sign-in} in main.cjs +
preload.cjs, typed in global.d.ts (DesktopCloudStatus/Agent/DiscoverResult/
AgentSignInResult).

Tests: modeIsRemoteLike + cloud profileRemoteOverride (node --test, 52 pass);
cloud reauth-failure cases (vitest, 16 pass). tsc clean; eslint clean. New tests
verified to fail without the source changes.

cloud-auto-discovery Phase 3 (non-discovery half + discovery/cascade plumbing).
This commit is contained in:
Ben 2026-06-30 13:45:33 +10:00
parent 3a55f66602
commit 2704e6e39c
8 changed files with 359 additions and 27 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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'

View file

@ -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)
})

View file

@ -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)

View file

@ -55,6 +55,15 @@ declare global {
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
// Hermes Cloud: one portal login powers discovery + silent per-agent
// sign-in (cloud-auto-discovery Phase 3).
cloud: {
status: () => Promise<DesktopCloudStatus>
login: () => Promise<DesktopCloudStatus & { ok: boolean }>
logout: () => Promise<DesktopCloudStatus & { ok: boolean }>
discover: () => Promise<DesktopCloudDiscoverResult>
agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult>
}
profile: {
get: () => Promise<DesktopActiveProfile>
// 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