Compare commits

...

8 Commits

Author SHA1 Message Date
Ben
1b54d38e75 fix(desktop): reliably persist cloud org, unselect cloud on mode switch, keep Change-org button after restore
Three fixes from live testing the org persist/restore flow:

1. Org not persisting (stale closure). discoverCloud() resolves the org
   asynchronously from the NAS response and setCloudOrg() is a React state
   update, but connectCloudAgent read the cloudOrg value captured in its render
   closure — often still null when the user clicked Connect in the same tick, so
   no org was saved. Mirror the org into a ref (cloudOrgRef) updated
   synchronously alongside state; connect reads cloudOrgRef.current.

2. Cloud connection lingered after switching away. coerceDesktopConnectionConfig
   inherits existingBlock.url across mode switches (correct for remote↔local),
   so switching cloud→local/remote kept the cloud instance URL in the remote
   block — re-selecting Cloud then looked 'already connected' with no way to
   re-pick. Added a leavingCloud rule: when the saved block was cloud and the new
   mode isn't cloud, start from an empty block (drop the cloud url/org/token),
   cleanly unselecting the cloud gateway. remote↔local toggles still preserve a
   real remote URL.

3. Change-org button vanished after restore-open. It was gated on
   cloudOrgs.length > 1, but the restore path discovers straight into the saved
   org and never populates cloudOrgs. Gate on cloudOrg being set instead, via a
   new changeCloudOrg() that clears the org + agent list and re-discovers with no
   org arg (multi-org → NAS 409 picker; single-org → auto-resolve back).

Depends on NAS #550 (echo resolved org), merged + live on prod (0dc86d0b).

tsc + eslint clean; 57 node --test + 16 vitest pass; all three verified live on
Ben's host (org persists + restores, cloud unselects on switch, Change-org shows
after reopen). The benign 'Session not found' 404 on backend switch is left as-is
(already handled by isSessionGoneError → fresh draft; dev-log noise only).

cloud-auto-discovery Phase 3/4 follow-up.
2026-07-01 13:28:42 +10:00
Ben
34d7d3fe2e feat(desktop): gate the Hermes Cloud gateway selector behind a BETA env flag
The Hermes Cloud ModeCard in Settings → Gateway now only appears when the BETA
env var is truthy (1/true/yes/on, case-insensitive); absent/empty/false/0 hides
it. While the feature is in beta, non-beta users see only Local + Remote.

- main.cjs: betaFeaturesEnabled() reads process.env.BETA; exposed via new IPC
  hermes:cloud:beta-enabled (the sandboxed renderer can't read process.env, and
  runtime IPC means the same build honors BETA per-launch with no rebuild).
- preload.cjs / global.d.ts: cloud.betaEnabled() bridge + type.
- gateway-settings.tsx: fetch the flag on mount (default false so it never
  flashes in for non-beta users), conditionally render the Cloud ModeCard, and
  flip the grid sm:grid-cols-3 → sm:grid-cols-2 when hidden.

Gates the SELECTOR only — an already-saved cloud connection keeps working if
BETA is later turned off; only newly selecting cloud is hidden.

tsc + eslint clean; 57 node --test + 16 vitest pass; env parsing unit-checked
across 9 cases; gate verified in the packaged bundle.

cloud-auto-discovery beta gating.
2026-07-01 13:07:46 +10:00
Ben
7e1e9d62c4 feat(desktop): persist the selected Hermes Cloud org + instance; restore on reopen
Settings → Gateway remembered 'cloud' mode but not WHICH org/instance, so
reopening dropped multi-org users back to the org picker, hiding the connected
agent (reported live).

- Persist a cloudOrg on the saved cloud connection (rides the remote block:
  coerce reads input.cloudOrg / inherits saved; buildRemoteBlock + profile
  sanitizer carry it; sanitize echoes it back as config.cloudOrg). Only for
  mode:'cloud'; plain remote is unchanged. The instance was already persisted as
  remoteUrl (the dashboardUrl).
- discoverCloudAgents now returns the org NAS echoes in the response
  (trimCloudOrg), and the renderer records cloudOrg AUTHORITATIVELY from
  result.org — so it's set even on single-membership auto-resolve where no
  picker ran (the exact case that left the org unpersisted). Requires NAS #550
  (echo resolved org in /api/agents); before that deploys, falls back to the
  requested org.
- On open, the cloud-status effect seeds cloudOrg from the persisted
  config.cloudOrg and discovers scoped to it, so Settings reopens straight into
  that org's agent list instead of the picker.
- connectCloudAgent passes cloudOrg when saving so the choice sticks.
- The connected instance is highlighted (primary tint + ring) and shows a
  'Connected' pill instead of a Connect button (compares saved remoteUrl to each
  agent's dashboardUrl, normalized).

tsc + eslint clean; 57 node --test + 16 vitest pass. Connected-pill verified live;
org-restore pending NAS #550 deploy for the authoritative echo.

cloud-auto-discovery Phase 3/4 follow-up.
2026-07-01 12:46:01 +10:00
Ben
d8c00b8ce2 feat(desktop): linkify 'Nous portal' in the cloud no-agents message
When Hermes Cloud discovery returns zero agents, the empty-state message now
renders 'Nous portal' as a hyperlink to https://portal.nousresearch.com/agents
(opened via the app's ExternalLink → shell.openExternal), so the user can jump
straight to creating an agent instead of finding the portal manually.

The cloudNoAgents i18n string becomes { before, linkText, after } (en + zh) so
each locale controls link placement; ja/zh-hant fall back to en via defineLocale.
No external-link icon on this inline link to keep the sentence clean.

tsc + eslint clean; link verified present in the packaged renderer bundle.
2026-07-01 12:15:55 +10:00
Ben
883fa67b7e fix(desktop): make the per-agent cloud cascade actually silent (load protected root, not /login)
The silent per-agent sign-in (decisions.md Q5) was prompting a SECOND interactive
login after portal sign-in → org → dashboard selection (Ben's screencast). Root
cause: cloudAgentSilentSignIn → openOauthLoginWindow loaded the agent gateway's
/login, but /login is a PUBLIC route (dashboard-auth middleware allowlist), so the
gate's _auto_sso_response never runs there — it only fires on an unauthenticated
load of a PROTECTED page. The window therefore rendered the interactive
'Log in with X' chooser every time, instead of the silent 302 cascade. (Auto-SSO
is correctly configured on hosted agents: exactly one 'nous' session provider,
client_id agent:{id}, so it would have fired silently if triggered.)

Fix: openOauthLoginWindow(baseUrl, { silent }). The cascade passes silent:true,
which loads the PROTECTED root '/' instead of '/login'. The gate then runs
auto-SSO — single provider + a live partition portal session → 302 through
/auth/login → portal /oauth/authorize (auto-approves org members) → /auth/callback
sets the gateway session cookie with NO prompt. In silent mode the window also
starts HIDDEN and only reveals after 2.5s if the cascade hasn't completed
(graceful fallback to interactive, e.g. the portal session lapsed). The
interactive remote-gateway login (settings UI) keeps silent:false → /login
chooser, behavior unchanged.

Verified live end-to-end on Ben's host: portal sign-in → org picker → select agent
→ Connect now completes with no second login prompt.

cloud-auto-discovery Phase 3 follow-up (decisions.md Q9).
2026-07-01 12:08:20 +10:00
Ben
7e06e61fc8 fix(desktop): Hermes Cloud sign-in uses Privy session + multi-org org picker
Two fixes surfaced by the first live end-to-end test of cloud sign-in (both
would have shipped broken — green units + code review did not catch them).

1. Portal session is PRIVY, not Hermes-gateway cookies (Q7). Phase 3 polled for
   hermes_session_at/rt on the portal host, but the Nous portal (NAS) is a
   Privy-authed Next.js app — it sets privy-token (which NAS auth() and the
   /api/agents cookie path both read). The sign-in window therefore never
   detected success and hung. Fix: cookiesHavePrivySession (privy-token + __Host/
   __Secure/legacy privy-session variants) in connection-config.cjs, and
   hasLivePortalSession now checks the Privy cookie on the portal host. The
   per-agent silent cascade still uses the gateway-cookie check (each agent IS a
   Hermes gateway).

2. Multi-org discovery needs an org picker (Q8). A portal session carries no org
   pin, so a user in >1 org got a dead-end 403. Paired with NAS #545 (merged):
   /api/agents now returns 409 org_selection_required + the user's org list, and
   accepts a membership-validated ?org=. discoverCloudAgents(org) appends ?org=,
   and on 409 returns { needsOrgSelection, orgs } instead of throwing; the cloud
   panel shows a 'Choose an organization' picker, then re-runs discovery scoped
   to the chosen org (with a 'Change org' affordance for multi-org users).

Also reverts the ERR_NETWORK_CHANGED retry helper from the prior commit: the
IPv6-churn aborts on Ben's Arch host are a host/network-layer issue, and a
client reload can't safely drive Privy's single-use-code redirect chain
(disable IPv6 for the session is the workaround). Kept out of this feature PR.

Tests: connection-config.test.cjs (57, +5 Privy-cookie cases, proven to fail
without the helper); boot-failure-reauth (16). tsc + eslint clean. Verified live
end-to-end against prod portal: sign-in → org picker → scoped agent list →
silent per-agent connect.

cloud-auto-discovery Phases 3+4 follow-up (decisions.md Q7, Q8).
2026-07-01 09:44:23 +10:00
Ben
318910ce80 feat(desktop): Hermes Cloud mode card + agent picker in Gateway settings
Phase 4 of cloud-auto-discovery — the UI on top of the Phase 3 cloud plumbing.

Adds a third 'Hermes Cloud' ModeCard alongside Local/Remote in gateway-settings.
Selecting it reveals the cloud panel instead of the URL/token form:
- signed-out → 'Sign in to Hermes Cloud' (one portal login in the OAuth partition)
- signed-in  → a discovered-agent picker (loading / empty / list states) with a
  Refresh control. Selecting an agent drives the silent per-agent cascade
  (cloud.agentSignIn) then applies a mode:'cloud' connection pointed at its
  dashboardUrl — no second sign-in prompt.
Cloud auto-discovers on entering the mode when a portal session already exists.
Test/Save bottom-row actions are hidden in cloud mode (selection applies the
connection); the remote URL/token form is now gated to remote mode only.

Wires the renderer to the Phase 3 IPC (window.hermesDesktop.cloud.*). i18n
strings added to en + zh (full) and the Translations type; ja/zh-hant inherit via
defineLocale fallback. New 'Cloud' icon (IconCloud) exported from lib/icons.

Validated: tsc clean, eslint clean, vite renderer build succeeds, 52 electron +
16 vitest tests pass.

cloud-auto-discovery Phase 4.
2026-06-30 13:55:34 +10:00
Ben
2704e6e39c 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).
2026-06-30 13:45:33 +10:00
12 changed files with 1204 additions and 56 deletions

View File

@@ -37,6 +37,20 @@
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()
@@ -142,19 +156,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
}
@@ -264,15 +289,31 @@ 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))
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
PRIVY_SESSION_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
cookiesHavePrivySession,
modeIsRemoteLike,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,

View File

@@ -22,6 +22,8 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
cookiesHavePrivySession,
modeIsRemoteLike,
normAuthMode,
normalizeRemoteBaseUrl,
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', () => {
@@ -85,6 +100,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)
@@ -331,6 +361,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

@@ -102,6 +102,8 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
cookiesHavePrivySession,
modeIsRemoteLike,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
@@ -4398,7 +4400,23 @@ async function clearOauthSession(baseUrl) {
// reject if the user closes the window first. The window navigates through the
// IDP and back to /auth/callback, which sets the session cookies on the
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
function openOauthLoginWindow(baseUrl) {
// Open a gateway login window in the OAuth session partition, resolving once
// the access-token cookie appears (login done) or rejecting if the user closes
// the window first.
//
// `silent` selects the URL the window loads, which decides interactive-vs-silent:
// - silent=false (default): load ``/login`` — the public interstitial that
// renders the "Log in with X" provider chooser. This is the interactive
// remote-gateway login the settings UI drives.
// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public
// route, so loading it NEVER triggers the gate's auto-SSO and always shows
// the chooser. Loading a protected page with no session cookie makes the
// gate run ``_auto_sso_response``: single registered provider + a live
// portal session in this partition → a silent 302 through
// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members)
// → ``/auth/callback``, which sets the gateway cookie with NO interactive
// prompt. This is the per-agent cloud cascade (decisions.md Q5).
function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
return new Promise((resolve, reject) => {
if (!app.isReady()) {
reject(new Error('Desktop is not ready to start an OAuth login.'))
@@ -4413,11 +4431,13 @@ function openOauthLoginWindow(baseUrl) {
let settled = false
let win = null
let pollTimer = null
let revealTimer = null
const finish = err => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
if (revealTimer) clearTimeout(revealTimer)
try {
if (win && !win.isDestroyed()) win.destroy()
} catch {
@@ -4436,8 +4456,14 @@ function openOauthLoginWindow(baseUrl) {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes gateway',
title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway',
autoHideMenuBar: true,
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
// well under a second, so the window normally never needs to show. We
// only reveal it as a fallback if the cascade DOESN'T complete quickly
// (e.g. the portal session lapsed and the gate fell through to the
// interactive chooser) — see the reveal timer below.
show: !silent,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@@ -4459,6 +4485,23 @@ function openOauthLoginWindow(baseUrl) {
win.webContents.on('did-frame-navigate', () => void checkCookie())
pollTimer = setInterval(() => void checkCookie(), 750)
// Silent-mode reveal fallback: if the cascade hasn't settled shortly, the
// auto-SSO didn't go through silently (no portal session, multi-provider,
// loop-guard tripped, etc.) and the window is now showing an interactive
// page. Reveal it so the user can complete sign-in manually rather than
// staring at nothing. Cleared on finish().
if (silent && win) {
revealTimer = setTimeout(() => {
try {
if (!settled && win && !win.isDestroyed() && !win.isVisible()) {
win.show()
}
} catch {
// window torn down
}
}, 2500)
}
win.on('closed', () => {
if (!settled) finish(new Error('Login window closed before authentication completed.'))
})
@@ -4466,7 +4509,11 @@ function openOauthLoginWindow(baseUrl) {
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
// login, which is a valid authenticated page that sets the cookies. We
// only care that the cookie jar is populated.
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
//
// silent=true loads the protected root so the gate auto-SSOs (no chooser);
// silent=false loads the public ``/login`` chooser for interactive sign-in.
const normalizedBase = normalizeRemoteBaseUrl(baseUrl)
const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login`
win.loadURL(loginUrl).catch(error => {
finish(error instanceof Error ? error : new Error(String(error)))
})
@@ -4595,6 +4642,258 @@ 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.cjs cookiesHavePrivySession.
async function hasLivePortalSession() {
const sess = getOauthSession()
if (!sess) return false
const portalBaseUrl = resolvePortalBaseUrl()
const parsed = new URL(portalBaseUrl)
try {
const cookies = await sess.cookies.get({ url: portalBaseUrl })
return cookiesHavePrivySession(cookies)
} catch {
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHavePrivySession(cookies)
} catch {
return false
}
}
}
// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike
// openOauthLoginWindow (which targets a gateway's /login), this lands on the
// portal itself so the resulting session cookie is portal-scoped — the cookie
// that authenticates discovery AND is reused for every silent per-agent
// cascade. Resolves once the portal session cookie appears.
function openPortalLoginWindow() {
const portalBaseUrl = resolvePortalBaseUrl()
return new Promise((resolve, reject) => {
if (!app.isReady()) {
reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.'))
return
}
const sess = getOauthSession()
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
return
}
let settled = false
let win = null
let pollTimer = null
const finish = err => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
try {
if (win && !win.isDestroyed()) win.destroy()
} catch {
// window already torn down
}
if (err) reject(err)
else resolve({ portalBaseUrl, ok: true })
}
const checkCookie = async () => {
if (settled) return
// A live portal (Privy) session cookie means sign-in completed.
if (await hasLivePortalSession()) finish(null)
}
try {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes Cloud',
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
session: sess,
webSecurity: true
}
})
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
win.webContents.on('did-navigate', () => void checkCookie())
win.webContents.on('did-redirect-navigation', () => void checkCookie())
win.webContents.on('did-frame-navigate', () => void checkCookie())
pollTimer = setInterval(() => void checkCookie(), 750)
win.on('closed', () => {
if (!settled) finish(new Error('Sign-in window closed before authentication completed.'))
})
// Land on the portal root; any authenticated portal page sets the session
// cookie. We only care that the partition cookie jar is populated.
win.loadURL(portalBaseUrl).catch(error => {
finish(error instanceof Error ? error : new Error(String(error)))
})
})
}
// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls
// the NAS trimmed-summary endpoint over the partition-bound net, so the portal
// session cookie is attached automatically (no bearer needed — NAS accepts the
// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs }
// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409
// org_selection_required). Pass `org` (a slug/id from a prior org list) to
// scope discovery to that org. Throws a needsCloudLogin-tagged error when no
// portal session is present.
async function discoverCloudAgents(org) {
const portalBaseUrl = resolvePortalBaseUrl()
if (!(await hasLivePortalSession())) {
const err = new Error(
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
)
err.needsCloudLogin = true
throw err
}
const orgQuery = org ? `?org=${encodeURIComponent(org)}` : ''
let body
try {
body = await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, {
method: 'GET',
timeoutMs: 15_000
})
} 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
}
// 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 response org ({ id, slug, name, isPersonal }) to the trimmed
// shape the renderer persists, or null when absent/malformed.
function trimCloudOrg(org) {
if (!org || typeof org !== 'object' || typeof org.id !== 'string') return null
return {
id: org.id,
slug: typeof org.slug === 'string' ? org.slug : null,
name: typeof org.name === 'string' ? org.name : org.id,
isPersonal: Boolean(org.isPersonal),
role: typeof org.role === 'string' ? org.role : 'MEMBER'
}
}
// Extract the org list from a 409 org_selection_required error body. The error
// message is "409: <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
.filter(o => o && typeof o === 'object' && typeof o.id === 'string')
.map(o => ({
id: o.id,
slug: typeof o.slug === 'string' ? o.slug : null,
name: typeof o.name === 'string' ? o.name : o.id,
isPersonal: Boolean(o.isPersonal),
role: typeof o.role === 'string' ? o.role : 'MEMBER'
}))
}
// Project NAS's agent rows to the trimmed DTO the renderer consumes.
function trimCloudAgents(body) {
const agents = Array.isArray(body?.agents) ? body.agents : []
return agents
.filter(a => a && typeof a === 'object' && typeof a.id === 'string')
.map(a => ({
id: a.id,
name: typeof a.name === 'string' ? a.name : a.id,
status: typeof a.status === 'string' ? a.status : 'unknown',
dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null,
dashboardGatewayState:
typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown'
}))
}
// Silent per-agent sign-in: open the selected agent dashboard's /login in the
// SAME OAuth partition. Because the user already holds a live portal session
// there, the agent's /oauth/authorize auto-approves (org member) and 302s back,
// setting that agent's gateway session cookie WITHOUT a second interactive
// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the
// agent's session cookie lands (a silent flow finishes in well under a second;
// if the portal session were absent it would fall through to an interactive
// login, which the discovery gate already prevents). Returns once the agent's
// gateway session cookie is present.
async function cloudAgentSilentSignIn(dashboardUrl) {
const baseUrl = normalizeRemoteBaseUrl(dashboardUrl)
// Pre-req: a live portal session must exist, or this would surface an
// interactive prompt rather than a silent cascade. Discovery already gates on
// this, but a selection can arrive after the session lapsed.
if (!(await hasLivePortalSession())) {
const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.')
err.needsCloudLogin = true
throw err
}
await openOauthLoginWindow(baseUrl, { silent: true })
return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
}
function encryptDesktopSecret(value) {
return encryptDesktopSecretStrict(value, safeStorage)
}
@@ -4638,7 +4937,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
@@ -4647,6 +4946,14 @@ function sanitizeConnectionProfiles(raw) {
if (entry.token && typeof entry.token === 'object') {
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
}
@@ -4681,7 +4988,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 +5059,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) {
@@ -4774,6 +5085,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
@@ -4785,23 +5099,49 @@ 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) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
const block = { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
const orgValue = typeof org === 'string' ? org.trim() : ''
if (orgValue) {
block.org = orgValue
}
return block
}
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 || {}
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
? persistToken
@@ -4810,21 +5150,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, 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 || {} }
@@ -4928,8 +5272,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 +5295,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 +5390,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 +6620,44 @@ 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()
}))
// Whether the Hermes Cloud gateway selector is enabled — gated behind a BETA
// env flag while the feature is in beta. Truthy values: '1', 'true', 'yes',
// 'on' (case-insensitive). Absent, empty, 'false', '0', etc. → disabled, so the
// renderer hides the Cloud ModeCard entirely.
function betaFeaturesEnabled() {
const raw = String(process.env.BETA ?? '').trim().toLowerCase()
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'
}
ipcMain.handle('hermes:cloud:beta-enabled', async () => betaFeaturesEnabled())
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

@@ -41,6 +41,16 @@ 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'),
betaEnabled: () => ipcRenderer.invoke('hermes:cloud:beta-enabled'),
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,15 @@ 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 +19,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 +33,7 @@ interface GatewaySettingsState {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
cloudOrg: string
}
const EMPTY_STATE: GatewaySettingsState = {
@@ -34,7 +43,8 @@ const EMPTY_STATE: GatewaySettingsState = {
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
remoteUrl: '',
cloudOrg: ''
}
function ModeCard({
@@ -105,6 +115,60 @@ 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)
}
// Hermes Cloud is beta-gated: the selector ModeCard only appears when the main
// process reports the BETA env flag is enabled. Default hidden until the async
// check resolves, so it never flashes in for non-beta users.
const [cloudBetaEnabled, setCloudBetaEnabled] = useState(false)
useEffect(() => {
const desktop = window.hermesDesktop
if (!desktop?.cloud?.betaEnabled) {
return
}
let cancelled = false
desktop.cloud
.betaEnabled()
.then(enabled => {
if (!cancelled) {
setCloudBetaEnabled(Boolean(enabled))
}
})
.catch(() => {
if (!cancelled) {
setCloudBetaEnabled(false)
}
})
return () => void (cancelled = true)
}, [])
// 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 +227,21 @@ export function GatewaySettings() {
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
// The dashboardUrl of the currently-connected cloud instance (the saved
// cloud connection's remoteUrl), normalized for comparison against each
// discovered agent's dashboardUrl so we can highlight the active one and hide
// its Connect button. Empty unless the saved connection is a cloud one.
const connectedCloudUrl =
state.mode === 'cloud' ? state.remoteUrl.trim().replace(/\/+$/, '') : ''
const isConnectedAgent = (agent: DesktopCloudAgent) =>
Boolean(
connectedCloudUrl &&
agent.dashboardUrl &&
agent.dashboardUrl.trim().replace(/\/+$/, '') === connectedCloudUrl
)
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
@@ -379,6 +458,235 @@ export function GatewaySettings() {
}
}
// --- Hermes Cloud handlers ---
// Pull the discovered agent list over the shared portal session. Tolerant of
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
// `org` scopes discovery for multi-org users; when discovery comes back with
// needsOrgSelection we surface the org list and show a picker instead.
const discoverCloud = async (org?: string) => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudDiscover('loading')
try {
const result = await desktop.cloud.discover(org)
if ('needsOrgSelection' in result && result.needsOrgSelection) {
// Multi-org user with no org chosen yet: show the picker. Don't clear a
// previously-chosen org list on a refresh.
setCloudOrgs(result.orgs)
setCloudAgents([])
setCloudDiscover('done')
return
}
// Single org (or org now chosen): we have agents.
setCloudAgents('agents' in result ? result.agents : [])
// Record the org AUTHORITATIVELY from the response (NAS echoes the org the
// list was scoped to), falling back to the org we requested. This is what
// gets persisted on connect, so it must be set even on single-membership
// auto-resolve where no picker ran and no `org` arg was passed.
const resolvedOrgRef =
'org' in result && result.org ? (result.org.slug ?? result.org.id) : null
if (resolvedOrgRef) {
setCloudOrg(resolvedOrgRef)
} else if (org) {
setCloudOrg(org)
}
setCloudDiscover('done')
} catch (err) {
setCloudAgents([])
setCloudDiscover('error')
// A lapsed/absent portal session means we're effectively signed out.
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudDiscoverFailed)
}
}
// User picked an org from the multi-org picker: remember it and re-run
// discovery scoped to it.
const selectCloudOrg = (org: DesktopCloudOrg) => {
const ref = org.slug ?? org.id
setCloudOrg(ref)
void discoverCloud(ref)
}
// "Change org": clear the selected org and re-discover with no org arg. A
// multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves
// back to their one org. Also clear the agent list so the current org's
// agents don't linger under the picker while discovery re-runs.
const changeCloudOrg = () => {
setCloudOrg(null)
setCloudAgents([])
void discoverCloud()
}
// On entering cloud mode (or scope change), read the portal session status and
// auto-discover when already signed in, so the picker is populated on open.
useEffect(() => {
if (state.mode !== 'cloud') {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
let cancelled = false
desktop.cloud
.status()
.then(status => {
if (cancelled) {
return
}
setCloudSignedIn(status.signedIn)
if (status.signedIn) {
// Restore the persisted org (if any) so we reopen straight into that
// org's agent list instead of the picker; discoverCloud(org) also
// records it as the selected org. Empty → normal discovery (single-org
// resolves automatically; multi-org shows the picker).
const savedOrg = state.cloudOrg || ''
if (savedOrg) {
setCloudOrg(savedOrg)
}
void discoverCloud(savedOrg || undefined)
} else {
setCloudAgents([])
setCloudOrgs([])
setCloudOrg(null)
setCloudDiscover('idle')
}
})
.catch(() => {
if (!cancelled) {
setCloudSignedIn(false)
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
}, [state.mode, scope])
const cloudSignIn = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
const result = await desktop.cloud.login()
setCloudSignedIn(result.signedIn)
if (result.signedIn) {
await discoverCloud()
}
} catch (err) {
notifyError(err, g.cloudSignInFailed)
} finally {
setCloudSigningIn(false)
}
}
const cloudSignOut = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
await desktop.cloud.logout()
setCloudSignedIn(false)
setCloudAgents([])
setCloudOrgs([])
setCloudOrg(null)
setCloudDiscover('idle')
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
} catch (err) {
notifyError(err, g.signOutFailed)
} finally {
setCloudSigningIn(false)
}
}
// Select a discovered agent: drive the silent per-agent cascade (no second
// prompt — the shared portal session auto-approves), then persist a cloud-mode
// connection pointed at its dashboardUrl and apply it (reconnects the window).
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
if (!agent.dashboardUrl) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudConnectingId(agent.id)
try {
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
if (!result.connected) {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
return
}
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
// Include the selected org so Settings reopens into the same org + instance.
// Read the REF (not the cloudOrg state) so a just-resolved org from
// discovery in this same render tick is captured, not a stale null.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl,
cloudOrg: cloudOrgRef.current ?? undefined
})
setState(next)
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
} catch (err) {
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudConnectFailed)
} finally {
setCloudConnectingId(null)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
@@ -465,7 +773,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className={cn('grid gap-3', cloudBetaEnabled ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@@ -474,6 +782,16 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
{cloudBetaEnabled ? (
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
) : null}
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
@@ -484,6 +802,155 @@ export function GatewaySettings() {
/>
</div>
{/* 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={
cloudSignedIn ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.cloudSignedIn}
</Pill>
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{g.cloudSignIn}
</Button>
)
}
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
title={g.cloudSignInTitle}
/>
{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/agents" 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={
@@ -568,28 +1035,36 @@ export function GatewaySettings() {
/>
) : 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,17 @@ 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>
// Whether the BETA env flag enables the Hermes Cloud gateway selector.
betaEnabled: () => Promise<boolean>
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
@@ -354,6 +365,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 +400,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
@@ -395,16 +414,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 {
@@ -443,6 +469,53 @@ export interface DesktopOauthLogoutResult {
connected: boolean
}
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
export interface DesktopCloudStatus {
// The portal base URL the desktop talks to (default or env-overridden).
portalBaseUrl: string
// Whether the OAuth partition holds a live portal session (AT-or-RT).
signedIn: boolean
}
// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents.
export interface DesktopCloudAgent {
id: string
name: string
status: string
// null until the agent has a provisioned dashboard (show "provisioning…").
dashboardUrl: string | null
// "active" | "degraded" | "down" | "unknown".
dashboardGatewayState: string
}
// An org the signed-in user belongs to — for the org picker shown when a
// multi-org user's discovery call needs disambiguation (NAS 409).
export interface DesktopCloudOrg {
id: string
slug: string | null
name: string
isPersonal: boolean
// "OWNER" | "MEMBER".
role: string
}
// Discovery result: either the agent list, OR a request to pick an org first
// (multi-org user, no org chosen yet). The renderer shows a picker on the
// latter and re-calls discover(org). On the agents branch, `org` echoes the
// authoritatively-resolved org the list was scoped to (from NAS), so the
// desktop persists it without relying on transient picker state.
export type DesktopCloudDiscoverResult =
| { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false }
| { needsOrgSelection: true; orgs: DesktopCloudOrg[] }
export interface DesktopCloudAgentSignInResult {
// The agent gateway base URL the silent sign-in targeted.
baseUrl: string
// Whether the agent's gateway session cookie landed (silent cascade done).
connected: boolean
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean

View File

@@ -530,6 +530,38 @@ export const en: Translations = {
remoteTitle: 'Remote gateway',
remoteDesc:
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
cloudTitle: 'Hermes Cloud',
cloudDesc:
'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: 'Sign in to Hermes Cloud',
cloudSignedIn: 'Signed in to Hermes Cloud',
cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.',
cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.',
cloudAgentsTitle: 'Your agents',
cloudOrgPickerTitle: 'Choose an organization',
cloudOrgSelect: 'Select',
cloudOrgChange: 'Change org',
cloudOrgRole: role => `Role: ${role}`,
cloudLoadingAgents: 'Loading your agents…',
cloudNoAgents: {
before: 'No agents found on this account. Create one in the ',
linkText: 'Nous portal',
after: ', then refresh.'
},
cloudRefresh: 'Refresh',
cloudConnect: 'Connect',
cloudConnecting: 'Connecting…',
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
cloudConnectFailed: 'Could not connect to that agent',
cloudSignInFailed: 'Hermes Cloud sign-in failed',
cloudSignedOutTitle: 'Signed out of Hermes Cloud',
cloudSignedOutMessage: 'Cleared the Hermes Cloud session.',
cloudConnectedTitle: 'Connected',
cloudConnectedPill: 'Connected',
cloudConnectedTo: name => `Connected to ${name}.`,
cloudAgentProvisioning: 'Provisioning…',
cloudStatusLabel: status => `Status: ${status}`,
remoteUrlTitle: 'Remote URL',
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
probing: 'Checking how this gateway authenticates…',

View File

@@ -442,6 +442,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

@@ -720,6 +720,37 @@ export const zh: Translations = {
remoteTitle: '远程网关',
remoteDesc:
'将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
cloudTitle: 'Hermes Cloud',
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: '登录 Hermes Cloud',
cloudSignedIn: '已登录 Hermes Cloud',
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
cloudAgentsTitle: '你的智能体',
cloudOrgPickerTitle: '选择一个组织',
cloudOrgSelect: '选择',
cloudOrgChange: '切换组织',
cloudOrgRole: role => `角色:${role}`,
cloudLoadingAgents: '正在加载你的智能体…',
cloudNoAgents: {
before: '此账户下未找到智能体。请在',
linkText: 'Nous 门户',
after: '中创建一个,然后刷新。'
},
cloudRefresh: '刷新',
cloudConnect: '连接',
cloudConnecting: '正在连接…',
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
cloudConnectFailed: '无法连接到该智能体',
cloudSignInFailed: 'Hermes Cloud 登录失败',
cloudSignedOutTitle: '已退出 Hermes Cloud',
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
cloudConnectedTitle: '已连接',
cloudConnectedPill: '已连接',
cloudConnectedTo: name => `已连接到 ${name}`,
cloudAgentProvisioning: '正在配置…',
cloudStatusLabel: status => `状态:${status}`,
remoteUrlTitle: '远程 URL',
remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀例如 /hermes。',
probing: '正在检查此网关的认证方式…',

View File

@@ -26,6 +26,7 @@ import {
IconCircle as CircleIcon,
IconClipboard as Clipboard,
IconClock as Clock,
IconCloud as Cloud,
IconCommand as Command,
IconCopy as Copy,
IconCopy as CopyIcon,
@@ -139,6 +140,7 @@ export {
CircleIcon,
Clipboard,
Clock,
Cloud,
Command,
Copy,
CopyIcon,