diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 83a351a1dc9..e72290bcf01 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -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() @@ -275,15 +289,30 @@ 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, diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 845a3743d2f..aa61fb4be74 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -22,6 +22,7 @@ const { connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + cookiesHavePrivySession, modeIsRemoteLike, normAuthMode, normalizeRemoteBaseUrl, @@ -360,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', () => { diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 6d3395d04e3..82536b64f81 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -102,6 +102,7 @@ const { connectionScopeKey, cookiesHaveSession, cookiesHaveLiveSession, + cookiesHavePrivySession, modeIsRemoteLike, normAuthMode, normalizeRemoteBaseUrl, @@ -4622,10 +4623,27 @@ function resolvePortalBaseUrl() { } // Whether the OAuth partition currently holds a live Nous portal session — the -// credential that powers both discovery and the silent cascade. Reuses the -// same AT-or-RT liveness notion as the per-gateway check. +// 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() { - return hasLiveOauthSession(resolvePortalBaseUrl()) + 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 @@ -4665,8 +4683,8 @@ function openPortalLoginWindow() { const checkCookie = async () => { if (settled) return - // A live portal session (AT or RT cookie) means sign-in completed. - if (await hasLiveOauthSession(portalBaseUrl)) finish(null) + // A live portal (Privy) session cookie means sign-in completed. + if (await hasLivePortalSession()) finish(null) } try { @@ -4707,12 +4725,15 @@ function openPortalLoginWindow() { // Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls // the NAS trimmed-summary endpoint over the partition-bound net, so the portal -// session cookie is attached automatically (no bearer needed — NAS Phase 2.5 -// accepts the cookie). Returns the trimmed agent list; throws a -// needsCloudLogin-tagged error when no portal session is present. -async function discoverCloudAgents() { +// 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 hasLiveOauthSession(portalBaseUrl))) { + if (!(await hasLivePortalSession())) { const err = new Error( 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' ) @@ -4720,9 +4741,10 @@ async function discoverCloudAgents() { throw err } + const orgQuery = org ? `?org=${encodeURIComponent(org)}` : '' let body try { - body = await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents`, { + body = await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, { method: 'GET', timeoutMs: 15_000 }) @@ -4735,12 +4757,50 @@ async function discoverCloudAgents() { 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: ".) + if (error && error.statusCode === 409) { + const orgs = parseOrgSelectionError(error) + if (orgs) { + return { needsOrgSelection: true, orgs } + } + } throw error } + return { agents: trimCloudAgents(body) } +} + +// Extract the org list from a 409 org_selection_required error body. The error +// message is "409: " (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 : [] - // Pass the trimmed DTO straight through; the renderer renders name/status/ - // health and uses dashboardUrl to resolve a connection on selection. return agents .filter(a => a && typeof a === 'object' && typeof a.id === 'string') .map(a => ({ @@ -6485,9 +6545,10 @@ ipcMain.handle('hermes:cloud:logout', async () => { await clearOauthSession(resolvePortalBaseUrl()) return { ok: true, signedIn: await hasLivePortalSession() } }) -ipcMain.handle('hermes:cloud:discover', async () => { - const agents = await discoverCloudAgents() - return { agents } +ipcMain.handle('hermes:cloud: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 diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index b9890b2fb2c..a4bfeddd30a 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -47,7 +47,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { status: () => ipcRenderer.invoke('hermes:cloud:status'), login: () => ipcRenderer.invoke('hermes:cloud:login'), logout: () => ipcRenderer.invoke('hermes:cloud:logout'), - discover: () => ipcRenderer.invoke('hermes:cloud:discover'), + discover: org => ipcRenderer.invoke('hermes:cloud:discover', org), agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl) }, profile: { diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 58c384bd19e..9c7ffdbbe4d 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -3,7 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import type { DesktopAuthProvider, DesktopCloudAgent, DesktopConnectionProbeResult } from '@/global' +import type { + DesktopAuthProvider, + DesktopCloudAgent, + DesktopCloudOrg, + DesktopConnectionProbeResult +} from '@/global' import { useI18n } from '@/i18n' import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -116,6 +121,11 @@ export function GatewaySettings() { const [cloudAgents, setCloudAgents] = useState([]) const [cloudDiscover, setCloudDiscover] = useState('idle') const [cloudConnectingId, setCloudConnectingId] = useState(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([]) + const [cloudOrg, setCloudOrg] = useState(null) // Connection scope: null = the global/default connection (the original // behavior); a profile name = that profile's per-profile remote override, so @@ -395,7 +405,9 @@ export function GatewaySettings() { // Pull the discovered agent list over the shared portal session. Tolerant of // a lapsed session: a needsCloudLogin error flips us back to signed-out. - const discoverCloud = async () => { + // `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) { @@ -405,8 +417,25 @@ export function GatewaySettings() { setCloudDiscover('loading') try { - const { agents } = await desktop.cloud.discover() - setCloudAgents(agents) + 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 : []) + + if (org) { + setCloudOrg(org) + } + setCloudDiscover('done') } catch (err) { setCloudAgents([]) @@ -421,6 +450,14 @@ export function GatewaySettings() { } } + // 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) + } + // 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(() => { @@ -448,6 +485,8 @@ export function GatewaySettings() { void discoverCloud() } else { setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) setCloudDiscover('idle') } }) @@ -497,6 +536,8 @@ export function GatewaySettings() { await desktop.cloud.logout() setCloudSignedIn(false) setCloudAgents([]) + setCloudOrgs([]) + setCloudOrg(null) setCloudDiscover('idle') notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage }) } catch (err) { @@ -698,20 +739,52 @@ export function GatewaySettings() { /> {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. +
+
+ {g.cloudOrgPickerTitle} +
+
+ {cloudOrgs.map(orgEntry => ( + selectCloudOrg(orgEntry)} size="sm"> + {g.cloudOrgSelect} + + } + description={g.cloudOrgRole(orgEntry.role)} + key={orgEntry.id} + title={orgEntry.name} + /> + ))} +
+
+ ) : (
{g.cloudAgentsTitle}
- +
+ {cloudOrgs.length > 1 ? ( + // Let a multi-org user switch back to the org picker. + + ) : null} + +
{cloudDiscover === 'loading' ? ( @@ -750,6 +823,7 @@ export function GatewaySettings() {
)} + ) ) : null} ) : null} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 9e7dabba8dc..cf5089392f5 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -61,7 +61,7 @@ declare global { status: () => Promise login: () => Promise logout: () => Promise - discover: () => Promise + discover: (org?: string) => Promise agentSignIn: (dashboardUrl: string) => Promise } profile: { @@ -480,10 +480,24 @@ export interface DesktopCloudAgent { dashboardGatewayState: string } -export interface DesktopCloudDiscoverResult { - agents: DesktopCloudAgent[] +// 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). +export type DesktopCloudDiscoverResult = + | { agents: DesktopCloudAgent[]; needsOrgSelection?: false } + | { needsOrgSelection: true; orgs: DesktopCloudOrg[] } + export interface DesktopCloudAgentSignInResult { // The agent gateway base URL the silent sign-in targeted. baseUrl: string diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c96a18fd025..b465ac5e742 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -539,6 +539,10 @@ export const en: Translations = { 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: 'No agents found on this account. Create one in the Nous portal, then refresh.', cloudRefresh: 'Refresh', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index ba2f45d859e..19e682d00d0 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -450,6 +450,10 @@ export interface Translations { cloudNeedsSignIn: string cloudSignedInDesc: string cloudAgentsTitle: string + cloudOrgPickerTitle: string + cloudOrgSelect: string + cloudOrgChange: string + cloudOrgRole: (role: string) => string cloudLoadingAgents: string cloudNoAgents: string cloudRefresh: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f7b4a7e578b..df768258edb 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -728,6 +728,10 @@ export const zh: Translations = { cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。', cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。', cloudAgentsTitle: '你的智能体', + cloudOrgPickerTitle: '选择一个组织', + cloudOrgSelect: '选择', + cloudOrgChange: '切换组织', + cloudOrgRole: role => `角色:${role}`, cloudLoadingAgents: '正在加载你的智能体…', cloudNoAgents: '此账户下未找到智能体。请在 Nous 门户中创建一个,然后刷新。', cloudRefresh: '刷新',