mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(desktop): persist the selected Hermes Cloud org + instance; restore on reopen
Settings → Gateway remembered 'cloud' mode but not WHICH org/instance, so reopening dropped multi-org users back to the org picker, hiding the connected agent (reported live). - Persist a cloudOrg on the saved cloud connection (rides the remote block: coerce reads input.cloudOrg / inherits saved; buildRemoteBlock + profile sanitizer carry it; sanitize echoes it back as config.cloudOrg). Only for mode:'cloud'; plain remote is unchanged. The instance was already persisted as remoteUrl (the dashboardUrl). - discoverCloudAgents now returns the org NAS echoes in the response (trimCloudOrg), and the renderer records cloudOrg AUTHORITATIVELY from result.org — so it's set even on single-membership auto-resolve where no picker ran (the exact case that left the org unpersisted). Requires NAS #550 (echo resolved org in /api/agents); before that deploys, falls back to the requested org. - On open, the cloud-status effect seeds cloudOrg from the persisted config.cloudOrg and discovers scoped to it, so Settings reopens straight into that org's agent list instead of the picker. - connectCloudAgent passes cloudOrg when saving so the choice sticks. - The connected instance is highlighted (primary tint + ring) and shows a 'Connected' pill instead of a Connect button (compares saved remoteUrl to each agent's dashboardUrl, normalized). tsc + eslint clean; 57 node --test + 16 vitest pass. Connected-pill verified live; org-restore pending NAS #550 deploy for the authoritative echo. cloud-auto-discovery Phase 3/4 follow-up.
This commit is contained in:
parent
d8c00b8ce2
commit
7e1e9d62c4
7 changed files with 138 additions and 32 deletions
|
|
@ -4815,7 +4815,20 @@ async function discoverCloudAgents(org) {
|
|||
throw error
|
||||
}
|
||||
|
||||
return { agents: trimCloudAgents(body) }
|
||||
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
|
||||
|
|
@ -4933,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
|
||||
}
|
||||
|
||||
|
|
@ -5064,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
|
||||
|
|
@ -5075,11 +5099,19 @@ 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 = {}) {
|
||||
|
|
@ -5096,6 +5128,11 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
|||
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
|
||||
|
|
@ -5109,7 +5146,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
|||
// default. The mode tag (remote vs cloud) is preserved on the entry.
|
||||
const profiles = { ...(existing.profiles || {}) }
|
||||
if (remoteLike) {
|
||||
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
|
||||
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) }
|
||||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
|
|
@ -5121,7 +5158,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
|||
}
|
||||
|
||||
const nextRemote = remoteLike
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken)
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg)
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
|
||||
// Preserve per-profile overrides when saving the global connection.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface GatewaySettingsState {
|
|||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
cloudOrg: string
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
|
|
@ -42,7 +43,8 @@ const EMPTY_STATE: GatewaySettingsState = {
|
|||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
remoteUrl: '',
|
||||
cloudOrg: ''
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
|
|
@ -186,6 +188,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')
|
||||
|
|
@ -433,7 +450,16 @@ export function GatewaySettings() {
|
|||
// Single org (or org now chosen): we have agents.
|
||||
setCloudAgents('agents' in result ? result.agents : [])
|
||||
|
||||
if (org) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
@ -483,7 +509,17 @@ export function GatewaySettings() {
|
|||
setCloudSignedIn(status.signedIn)
|
||||
|
||||
if (status.signedIn) {
|
||||
void discoverCloud()
|
||||
// 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([])
|
||||
|
|
@ -578,11 +614,13 @@ export function GatewaySettings() {
|
|||
}
|
||||
|
||||
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
|
||||
// Include the selected org so Settings reopens into the same org + instance.
|
||||
const next = await desktop.applyConnectionConfig({
|
||||
mode: 'cloud',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: agent.dashboardUrl
|
||||
remoteUrl: agent.dashboardUrl,
|
||||
cloudOrg: cloudOrg ?? undefined
|
||||
})
|
||||
|
||||
setState(next)
|
||||
|
|
@ -806,27 +844,45 @@ export function GatewaySettings() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{cloudAgents.map(agent => (
|
||||
<ListRow
|
||||
action={
|
||||
<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)}
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
|||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: 'https://box:9119',
|
||||
cloudOrg: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
apps/desktop/src/global.d.ts
vendored
13
apps/desktop/src/global.d.ts
vendored
|
|
@ -412,6 +412,10 @@ 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 {
|
||||
|
|
@ -422,6 +426,9 @@ export interface DesktopConnectionConfigInput {
|
|||
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 {
|
||||
|
|
@ -493,9 +500,11 @@ export interface DesktopCloudOrg {
|
|||
|
||||
// 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).
|
||||
// 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[]; needsOrgSelection?: false }
|
||||
| { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false }
|
||||
| { needsOrgSelection: true; orgs: DesktopCloudOrg[] }
|
||||
|
||||
export interface DesktopCloudAgentSignInResult {
|
||||
|
|
|
|||
|
|
@ -558,6 +558,7 @@ export const en: Translations = {
|
|||
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}`,
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ export interface Translations {
|
|||
cloudSignedOutTitle: string
|
||||
cloudSignedOutMessage: string
|
||||
cloudConnectedTitle: string
|
||||
cloudConnectedPill: string
|
||||
cloudConnectedTo: (name: string) => string
|
||||
cloudAgentProvisioning: string
|
||||
cloudStatusLabel: (status: string) => string
|
||||
|
|
|
|||
|
|
@ -747,6 +747,7 @@ export const zh: Translations = {
|
|||
cloudSignedOutTitle: '已退出 Hermes Cloud',
|
||||
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
|
||||
cloudConnectedTitle: '已连接',
|
||||
cloudConnectedPill: '已连接',
|
||||
cloudConnectedTo: name => `已连接到 ${name}。`,
|
||||
cloudAgentProvisioning: '正在配置…',
|
||||
cloudStatusLabel: status => `状态:${status}`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue