mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
318910ce80
commit
7e06e61fc8
9 changed files with 253 additions and 33 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
20
apps/desktop/src/global.d.ts
vendored
20
apps/desktop/src/global.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -728,6 +728,10 @@ export const zh: Translations = {
|
|||
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
|
||||
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
|
||||
cloudAgentsTitle: '你的智能体',
|
||||
cloudOrgPickerTitle: '选择一个组织',
|
||||
cloudOrgSelect: '选择',
|
||||
cloudOrgChange: '切换组织',
|
||||
cloudOrgRole: role => `角色:${role}`,
|
||||
cloudLoadingAgents: '正在加载你的智能体…',
|
||||
cloudNoAgents: '此账户下未找到智能体。请在 Nous 门户中创建一个,然后刷新。',
|
||||
cloudRefresh: '刷新',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue