Merge pull request #61912 from NousResearch/bb/salvage-55402-desktop-cloud-mode

feat(desktop): Hermes Cloud connection mode (salvage of #55402)
This commit is contained in:
brooklyn! 2026-07-10 03:15:59 -05:00 committed by GitHub
commit 79a104d037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1301 additions and 125 deletions

View file

@ -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', () => {

View file

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

View file

@ -42,7 +42,9 @@ import {
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveLiveSession,
cookiesHavePrivySession,
cookiesHaveSession,
modeIsRemoteLike,
normalizeRemoteBaseUrl,
normAuthMode,
pathWithGlobalRemoteProfile,
@ -5094,12 +5096,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.'))
@ -5118,6 +5133,7 @@ function openOauthLoginWindow(baseUrl) {
let settled = false
let win = null
let pollTimer = null
let revealTimer = null
const finish = err => {
if (settled) {
@ -5129,6 +5145,10 @@ function openOauthLoginWindow(baseUrl) {
clearInterval(pollTimer)
}
if (revealTimer) {
clearTimeout(revealTimer)
}
try {
if (win && !win.isDestroyed()) {
win.destroy()
@ -5158,8 +5178,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,
@ -5182,6 +5208,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.'))
@ -5191,7 +5234,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)))
})
@ -5353,6 +5400,311 @@ 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)))
})
})
}
// A "portal session is gone, re-login" error the renderer flips to signed-out
// on (via the `needsCloudLogin` tag). `cause` chains the underlying 401 when
// the session lapsed mid-flight.
function cloudLoginError(message, cause?) {
const err = new Error(message) as any
err.needsCloudLogin = true
if (cause) {
err.cause = cause
}
return err
}
// 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())) {
throw cloudLoginError(
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
)
}
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) {
throw cloudLoginError('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.', error)
}
// 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: <json body>".)
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 org row ({ id, slug, name, isPersonal, role }) to the trimmed
// shape the renderer consumes/persists, or null when absent/malformed. Shared
// by the success-body echo (trimCloudOrg) and the 409 org list, so the two can
// never drift.
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: <raw json>" (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.map(trimCloudOrg).filter(Boolean)
}
// 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())) {
throw cloudLoginError('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.')
}
await openOauthLoginWindow(baseUrl, { silent: true })
return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
}
function encryptDesktopSecret(value) {
return encryptDesktopSecretStrict(value, safeStorage)
}
@ -5398,8 +5750,14 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
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()
@ -5413,6 +5771,16 @@ function sanitizeConnectionProfiles(raw: Record<string, any>) {
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
}
@ -5448,7 +5816,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
@ -5519,7 +5887,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
@ -5542,6 +5914,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
@ -5553,24 +5928,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
@ -5580,23 +5986,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 || {} }
@ -5707,8 +6117,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
}
@ -5733,14 +6143,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.
@ -5832,7 +6243,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
@ -7210,6 +7621,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)

View file

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

View file

@ -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 | string>(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<DesktopCloudAgent[]>([])
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(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<DesktopCloudOrg[]>([])
const [cloudOrg, setCloudOrgState] = useState<null | string>(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 | string>(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,22 @@ 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.
// The saved cloud URL was stored via the main-side normalizeRemoteBaseUrl
// (which lowercases the host through URL.toString()), but a discovered agent's
// dashboardUrl arrives raw from NAS — so normalize both sides the same way
// (trim, drop trailing slash, lowercase) or a host-casing difference would
// silently break the connected-highlight.
const normalizeCloudUrl = (url: string) => url.trim().replace(/\/+$/, '').toLowerCase()
const connectedCloudUrl = state.mode === 'cloud' ? normalizeCloudUrl(state.remoteUrl) : ''
const isConnectedAgent = (agent: DesktopCloudAgent) =>
Boolean(connectedCloudUrl && agent.dashboardUrl && normalizeCloudUrl(agent.dashboardUrl) === connectedCloudUrl)
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
@ -379,6 +426,238 @@ export function GatewaySettings() {
}
}
// --- Hermes Cloud handlers ---
// A rejected cloud IPC call tagged `needsCloudLogin` means the portal session
// lapsed — treat it as signed-out so the panel falls back to the sign-in view.
const cloudLoginLapsed = (err: unknown) => Boolean(err && typeof err === 'object' && 'needsCloudLogin' in err)
// 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 (cloudLoginLapsed(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 (cloudLoginLapsed(err)) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudConnectFailed)
} finally {
setCloudConnectingId(null)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
@ -465,7 +744,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@ -474,6 +753,14 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
@ -484,112 +771,276 @@ export function GatewaySettings() {
/>
</div>
<div className="mt-5 grid gap-1">
<ListRow
action={
<Input
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => 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' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.probing}
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : 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 ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
oauthConnected ? (
cloudSignedIn ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.signedIn}
<Check className="size-3" /> {g.cloudSignedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{g.cloudSignIn}
</Button>
)
}
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.
<div className="mt-3">
<div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudOrgPickerTitle}
</div>
<div className="grid gap-1">
{cloudOrgs.map(orgEntry => (
<ListRow
action={
<Button onClick={() => selectCloudOrg(orgEntry)} size="sm">
{g.cloudOrgSelect}
</Button>
}
description={g.cloudOrgRole(orgEntry.role)}
key={orgEntry.id}
title={orgEntry.name}
/>
))}
</div>
</div>
) : (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudAgentsTitle}
</div>
<div className="flex items-center gap-2">
{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).
<Button onClick={() => changeCloudOrg()} size="sm" variant="text">
{g.cloudOrgChange}
</Button>
) : null}
<Button
disabled={cloudDiscover === 'loading'}
onClick={() => void discoverCloud(cloudOrg ?? undefined)}
size="sm"
variant="text"
>
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{g.cloudRefresh}
</Button>
</div>
</div>
{cloudDiscover === 'loading' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.cloudLoadingAgents}
</div>
) : cloudAgents.length === 0 ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>
{g.cloudNoAgents.before}
<ExternalLink
href="https://portal.nousresearch.com/cloud?setup=instance"
showExternalIcon={false}
>
{g.cloudNoAgents.linkText}
</ExternalLink>
{g.cloudNoAgents.after}
</span>
</div>
) : (
<div className="grid gap-1">
{cloudAgents.map(agent => {
const connected = isConnectedAgent(agent)
return (
<div
className={cn('rounded-md px-2', connected && 'bg-primary/5 ring-1 ring-primary/25')}
key={agent.id}
>
<ListRow
action={
connected ? (
<Pill tone="primary">
<Check className="mr-1 inline size-3" />
{g.cloudConnectedPill}
</Pill>
) : (
<Button
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
onClick={() => void connectCloudAgent(agent)}
size="sm"
>
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
{agent.dashboardUrl
? cloudConnectingId === agent.id
? g.cloudConnecting
: g.cloudConnect
: g.cloudAgentProvisioning}
</Button>
)
}
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
title={agent.name}
/>
</div>
)
})}
</div>
)}
</div>
)
) : null}
</div>
) : null}
{state.mode === 'remote' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
className={cn('h-8', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => 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}
</div>
{state.mode === 'remote' && probeStatus === 'probing' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.probing}
</div>
) : null}
{state.mode === 'remote' && probeStatus === 'error' ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
{g.probeError}
</div>
) : null}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
oauthConnected ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
)
}
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' ? (
<ListRow
action={
<Input
autoComplete="off"
className={cn('h-8 font-mono', CONTROL_TEXT)}
disabled={state.envOverride}
onChange={event => 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}
</div>
) : null}
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
{/* 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' ? (
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
{state.mode === 'remote' ? (
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
) : null}
<Button
disabled={state.envOverride || saving}
onClick={() => void save(false)}
size="sm"
variant="textStrong"
>
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
) : null}
<div className="mt-6 grid gap-1">
<ListRow

View file

@ -14,6 +14,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): 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)
})

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: (org?: string) => Promise<DesktopCloudDiscoverResult>
agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult>
}
profile: {
get: () => Promise<DesktopActiveProfile>
// 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,55 @@ 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 Nous portal (Privy) session — the
// portal authenticates via Privy, so this reflects the privy-token cookie, NOT
// the hermes gateway session cookies. See cookiesHavePrivySession.
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

View file

@ -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 ',
linkText: 'Hermes Cloud',
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…',

View file

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

View file

@ -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: 'Hermes Cloud',
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: '正在检查此网关的认证方式…',

View file

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