fix(desktop): Hermes Cloud sign-in uses Privy session + multi-org org picker

Two fixes surfaced by the first live end-to-end test of cloud sign-in (both
would have shipped broken — green units + code review did not catch them).

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

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

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

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

cloud-auto-discovery Phases 3+4 follow-up (decisions.md Q7, Q8).
This commit is contained in:
Ben 2026-07-01 09:44:23 +10:00
parent 318910ce80
commit 7e06e61fc8
9 changed files with 253 additions and 33 deletions

View file

@ -37,6 +37,20 @@
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
// also exactly what the `/api/agents` cookie-auth path validates. So portal
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
// cookies above. `privy-token` is the access token (the required signal);
// variants cover the secured-prefix forms and the older `privy-session` name.
const PRIVY_SESSION_COOKIE_VARIANTS = [
'__Host-privy-token',
'__Secure-privy-token',
'privy-token',
'privy-session'
]
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@ -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,

View file

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

View file

@ -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: <json body>".)
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: <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 : []
// 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

View file

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

View file

@ -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<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, setCloudOrg] = useState<null | string>(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.
<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>
<Button
disabled={cloudDiscover === 'loading'}
onClick={() => void discoverCloud()}
size="sm"
variant="text"
>
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{g.cloudRefresh}
</Button>
<div className="flex items-center gap-2">
{cloudOrgs.length > 1 ? (
// Let a multi-org user switch back to the org picker.
<Button onClick={() => setCloudOrg(null)} 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' ? (
@ -750,6 +823,7 @@ export function GatewaySettings() {
</div>
)}
</div>
)
) : null}
</div>
) : null}

View file

@ -61,7 +61,7 @@ declare global {
status: () => Promise<DesktopCloudStatus>
login: () => Promise<DesktopCloudStatus & { ok: boolean }>
logout: () => Promise<DesktopCloudStatus & { ok: boolean }>
discover: () => Promise<DesktopCloudDiscoverResult>
discover: (org?: string) => Promise<DesktopCloudDiscoverResult>
agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult>
}
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

View file

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

View file

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

View file

@ -728,6 +728,10 @@ export const zh: Translations = {
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
cloudAgentsTitle: '你的智能体',
cloudOrgPickerTitle: '选择一个组织',
cloudOrgSelect: '选择',
cloudOrgChange: '切换组织',
cloudOrgRole: role => `角色:${role}`,
cloudLoadingAgents: '正在加载你的智能体…',
cloudNoAgents: '此账户下未找到智能体。请在 Nous 门户中创建一个,然后刷新。',
cloudRefresh: '刷新',