mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-03 08:47:04 +08:00
Compare commits
9 Commits
fix/slack-
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b54d38e75 | ||
|
|
34d7d3fe2e | ||
|
|
7e1e9d62c4 | ||
|
|
d8c00b8ce2 | ||
|
|
883fa67b7e | ||
|
|
7e06e61fc8 | ||
|
|
318910ce80 | ||
|
|
2704e6e39c | ||
|
|
3a55f66602 |
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
77
apps/desktop/src/global.d.ts
vendored
77
apps/desktop/src/global.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '正在检查此网关的认证方式…',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -156,7 +156,8 @@ present (may be `null`); the rest are included only when set.
|
||||
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
|
||||
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
|
||||
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
|
||||
| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. |
|
||||
| `scope_id` | string | no | Platform-neutral **scope** discriminator: Discord guild / Slack workspace / Matrix server. **REQUIRED for Discord/Slack scope isolation.** Session-key discriminator. (Canonical name as of the D-Q2.5 wire migration.) |
|
||||
| `guild_id` | string | no | **Deprecated alias for `scope_id`** — still emitted and read during the cross-repo dual-read/dual-write overlap; readers resolve `scope_id ?? guild_id`. Dropped once both repos deploy on `scope_id`. |
|
||||
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
|
||||
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
|
||||
|
||||
|
||||
@@ -263,11 +263,11 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
platform_value = getattr(platform, "value", platform)
|
||||
if platform_value and platform_value != "relay":
|
||||
self._platform_by_chat[str(chat)] = str(platform_value)
|
||||
guild = getattr(src, "guild_id", None)
|
||||
guild = getattr(src, "scope_id", None) or getattr(src, "guild_id", None)
|
||||
if guild:
|
||||
self._scope_by_chat[str(chat)] = str(guild)
|
||||
return
|
||||
# DM: no guild_id. Remember the authentic author id for outbound
|
||||
# DM: no scope. Remember the authentic author id for outbound
|
||||
# author-binding resolution (the user we're replying to in this DM).
|
||||
user_id = getattr(src, "user_id", None)
|
||||
if user_id:
|
||||
@@ -279,8 +279,10 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
"""Ensure the outbound metadata carries the discriminator the connector's
|
||||
egress guard needs to resolve the owning tenant. Two cases:
|
||||
|
||||
- GUILD reply: re-attach metadata.guild_id (routing-table resolution).
|
||||
- DM reply: there is no guild_id, so re-attach metadata.user_id — the
|
||||
- GUILD reply: re-attach metadata.scope_id (routing-table resolution;
|
||||
also mirrored to the deprecated metadata.guild_id during the D-Q2.5
|
||||
wire migration so a connector on either side resolves the tenant).
|
||||
- DM reply: there is no scope, so re-attach metadata.user_id — the
|
||||
authentic author id we saw inbound — which the connector resolves to
|
||||
the tenant via the recipient's author binding (resolveByUser). Without
|
||||
one of these, egress is declined as 'target not routed to an onboarded
|
||||
@@ -289,14 +291,16 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
No-op when the relevant value is already present or unknown for this chat.
|
||||
"""
|
||||
meta: Dict[str, Any] = dict(metadata or {})
|
||||
if not meta.get("guild_id"):
|
||||
if not meta.get("scope_id") and not meta.get("guild_id"):
|
||||
scope = self._scope_by_chat.get(str(chat_id))
|
||||
if scope:
|
||||
# D-Q2.5 dual-write: canonical scope_id + deprecated guild_id alias.
|
||||
meta["scope_id"] = scope
|
||||
meta["guild_id"] = scope
|
||||
# DM author-binding discriminator. Only meaningful when there's no guild
|
||||
# (a guild reply resolves by guild_id); harmless to carry otherwise, but
|
||||
# DM author-binding discriminator. Only meaningful when there's no scope
|
||||
# (a guild reply resolves by scope_id); harmless to carry otherwise, but
|
||||
# we only set it when this chat is a known DM and the field is absent.
|
||||
if not meta.get("guild_id") and not meta.get("user_id"):
|
||||
if not meta.get("scope_id") and not meta.get("guild_id") and not meta.get("user_id"):
|
||||
dm_user = self._dm_user_by_chat.get(str(chat_id))
|
||||
if dm_user:
|
||||
meta["user_id"] = dm_user
|
||||
@@ -401,14 +405,14 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
member = payload.get("member") or {}
|
||||
user = (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {}
|
||||
channel_id = str(payload.get("channel_id") or "")
|
||||
guild_id = payload.get("guild_id")
|
||||
guild_id = payload.get("guild_id") # real Discord interaction field
|
||||
source = SessionSource(
|
||||
platform=Platform.RELAY,
|
||||
chat_id=channel_id,
|
||||
chat_type="channel" if guild_id else "dm",
|
||||
user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None,
|
||||
user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None,
|
||||
guild_id=str(guild_id) if guild_id else None,
|
||||
scope_id=str(guild_id) if guild_id else None, # Discord guild → generic scope slot (D-Q2.5)
|
||||
message_id=str(payload.get("id")) if payload.get("id") else None,
|
||||
)
|
||||
return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)
|
||||
|
||||
@@ -117,7 +117,8 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
|
||||
chat_topic=src.get("chat_topic"),
|
||||
user_id_alt=src.get("user_id_alt"),
|
||||
chat_id_alt=src.get("chat_id_alt"),
|
||||
guild_id=src.get("guild_id"),
|
||||
# D-Q2.5 dual-read: prefer canonical scope_id, fall back to legacy guild_id.
|
||||
scope_id=src.get("scope_id", src.get("guild_id")),
|
||||
parent_chat_id=src.get("parent_chat_id"),
|
||||
message_id=src.get("message_id"),
|
||||
# Authentic upstream-trust signal: this event arrived over the
|
||||
|
||||
@@ -110,7 +110,14 @@ class SessionSource:
|
||||
user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id)
|
||||
chat_id_alt: Optional[str] = None # Signal group internal ID
|
||||
is_bot: bool = False # True when the message author is a bot/webhook (Discord)
|
||||
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
|
||||
# Platform-neutral SCOPE discriminator (Discord guild / Slack workspace /
|
||||
# Matrix server). Drives server/workspace isolation + the relay δ/ε/ζ gate.
|
||||
# Wire migration (D-Q2.5): `scope_id` is the canonical name; `guild_id` is a
|
||||
# deprecated legacy alias kept during the cross-repo dual-read/dual-write
|
||||
# overlap. Both are written by to_dict and read by from_dict (scope_id wins);
|
||||
# the `guild_id` alias is dropped in a follow-up once both repos deploy.
|
||||
scope_id: Optional[str] = None
|
||||
guild_id: Optional[str] = None # @deprecated legacy alias for scope_id (D-Q2.5)
|
||||
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
|
||||
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
|
||||
role_authorized: bool = False # True when adapter granted access via role (not user ID)
|
||||
@@ -133,6 +140,16 @@ class SessionSource:
|
||||
# forge it across the wire or have it restored from persistence.
|
||||
delivered_via_upstream_relay: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# D-Q2.5 dual-field reconciliation: `scope_id` is canonical, `guild_id`
|
||||
# is the deprecated alias. Mirror whichever was provided onto the other
|
||||
# (scope_id wins on conflict) so internal readers of EITHER field see the
|
||||
# same value during the cross-repo wire migration overlap.
|
||||
if self.scope_id is None and self.guild_id is not None:
|
||||
self.scope_id = self.guild_id
|
||||
elif self.scope_id is not None:
|
||||
self.guild_id = self.scope_id
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Human-readable description of the source."""
|
||||
@@ -169,8 +186,14 @@ class SessionSource:
|
||||
d["user_id_alt"] = self.user_id_alt
|
||||
if self.chat_id_alt:
|
||||
d["chat_id_alt"] = self.chat_id_alt
|
||||
if self.guild_id:
|
||||
d["guild_id"] = self.guild_id
|
||||
# D-Q2.5 dual-write: emit BOTH the canonical `scope_id` and the
|
||||
# deprecated `guild_id` alias (mirrored in __post_init__) so a connector
|
||||
# on either side of the migration resolves the scope. Drop `guild_id`
|
||||
# in the follow-up once both repos are on `scope_id`.
|
||||
scope = self.scope_id if self.scope_id is not None else self.guild_id
|
||||
if scope:
|
||||
d["scope_id"] = scope
|
||||
d["guild_id"] = scope
|
||||
if self.parent_chat_id:
|
||||
d["parent_chat_id"] = self.parent_chat_id
|
||||
if self.message_id:
|
||||
@@ -192,7 +215,9 @@ class SessionSource:
|
||||
chat_topic=data.get("chat_topic"),
|
||||
user_id_alt=data.get("user_id_alt"),
|
||||
chat_id_alt=data.get("chat_id_alt"),
|
||||
guild_id=data.get("guild_id"),
|
||||
# D-Q2.5 dual-read: prefer the canonical `scope_id`, fall back to the
|
||||
# deprecated `guild_id` alias (a peer not yet migrated still sends it).
|
||||
scope_id=data.get("scope_id", data.get("guild_id")),
|
||||
parent_chat_id=data.get("parent_chat_id"),
|
||||
message_id=data.get("message_id"),
|
||||
profile=data.get("profile"),
|
||||
|
||||
Reference in New Issue
Block a user