Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 136 additions & 1 deletion apps/desktop/src/app/settings/connections-registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Input } from '@/components/ui/input'
import type {
DesktopConnectionKind,
DesktopConnectionProbeResult,
DesktopConnectionsRegistry,
DesktopRegistryConnection,
DesktopRegistryConnectionInput
Expand All @@ -16,8 +17,23 @@ import {
connectionMatchesQuery,
sortConnectionsForDisplay
} from '@/lib/connection-display'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
import { triggerHaptic } from '@/lib/haptics'
import { Cloud, Globe, Loader2, Monitor, Pencil, Plus, RefreshCw, SearchIcon, Terminal, Trash2 } from '@/lib/icons'
import {
Check,
Cloud,
Globe,
Loader2,
LogIn,
Monitor,
Pencil,
Plus,
RefreshCw,
SearchIcon,
Terminal,
Trash2
} from '@/lib/icons'
import { coerceRemoteUrlScheme } from '@/lib/remote-url'
import { $activeConnectionId, setConnectionsRegistry } from '@/store/connections'
import { notify, notifyError } from '@/store/notifications'

Expand Down Expand Up @@ -238,6 +254,14 @@ export function ConnectionsRegistrySection() {
// Inline duplicate rejection from the save path (dedupe is also enforced in
// the main process, so a crafted payload can't slip past the UI check).
const [dupeError, setDupeError] = useState<null | string>(null)
// A gated remote gateway (OAuth, or username/password) never accepts a
// session token: it authenticates with a browser sign-in and keeps the
// session itself. Probe the edited URL so this row can name the provider,
// and remember whether the login round-trip actually completed.
const [authProbe, setAuthProbe] = useState<DesktopConnectionProbeResult | null>(null)
const [signingIn, setSigningIn] = useState(false)
const [oauthConnected, setOauthConnected] = useState(false)
const probeSeq = useRef(0)

const bridge = window.hermesDesktop?.connections

Expand All @@ -248,6 +272,89 @@ export function ConnectionsRegistrySection() {
setConnectionsRegistry(next)
}, [])

const editorUrl = editor?.kind === 'remote' ? coerceRemoteUrlScheme(editor.url) : ''
const editorWantsOauth = editor?.kind === 'remote' && editor.authMode === 'oauth'
const authProviderShape = deriveRemoteAuthProviderShape(authProbe?.providers, t.boot.failure.identityProvider)

// Probe only while the sign-in row is on screen, and debounce it so typing a
// URL doesn't fire a request per keystroke. Best-effort: a failed probe just
// leaves the generic provider label, it never blocks signing in.
useEffect(() => {
if (!editorWantsOauth || !editorUrl || !window.hermesDesktop?.probeConnectionConfig) {
setAuthProbe(null)

return
}

const seq = ++probeSeq.current
// Staleness is covered by probeSeq, but not unmount: a probe resolving
// after the editor closes would still call setAuthProbe on an unmounted
// component. Harmless in React 18, still worth not doing.
let cancelled = false

const timer = setTimeout(() => {
window.hermesDesktop
.probeConnectionConfig(editorUrl)
.then(result => {
if (!cancelled && seq === probeSeq.current) {
setAuthProbe(result)
}
})
.catch(() => {
if (!cancelled && seq === probeSeq.current) {
setAuthProbe(null)
}
})
}, 400)

return () => {
cancelled = true
clearTimeout(timer)
}
}, [editorUrl, editorWantsOauth])

// The session is scoped to an origin, so pointing the editor at a different
// URL invalidates the "signed in" state this row is reporting. Flipping the
// auth mode invalidates it too: a saved row edited token -> oauth must not
// present a stale "Signed in" pill from an earlier oauth stint.
useEffect(() => {
setOauthConnected(false)
}, [editorUrl, editorWantsOauth])

// Open the gateway's own login window and let the main process keep whatever
// it mints (native PKCE bearer tokens, or the legacy session cookies). This
// is the same IPC the first-run form and the gateway panel use — the
// registry editor simply had no affordance to reach it.
const signInOauth = useCallback(async () => {
if (!editorUrl) {
notify({ kind: 'warning', title: t.settings.gateway.authTitle, message: t.settings.gateway.enterUrlFirst })

return
}

setSigningIn(true)

try {
const result = await window.hermesDesktop.oauthLoginConnectionConfig(editorUrl)

setOauthConnected(Boolean(result.connected))

if (result.connected) {
notify({ title: t.settings.gateway.signedIn, message: t.settings.gateway.connectedTo(authProviderShape.providerLabel) })
} else {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
}
} catch (err) {
notifyError(err, t.settings.gateway.signInFailed)
} finally {
setSigningIn(false)
}
}, [authProviderShape.providerLabel, editorUrl, t])

const load = useCallback(async () => {
if (!bridge) {
setLoading(false)
Expand Down Expand Up @@ -731,6 +838,34 @@ export function ConnectionsRegistrySection() {
title={t.settings.gateway.tokenTitle}
/>
)}
{editor.authMode === 'oauth' && (
<ListRow
action={
oauthConnected ? (
<Pill tone="primary">
<Check className="size-3" /> {t.settings.gateway.signedIn}
</Pill>
) : (
<Button disabled={signingIn || !editorUrl} onClick={() => void signInOauth()} size="sm">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{authProviderShape.isPassword
? t.settings.gateway.signIn
: t.settings.gateway.signInWith(authProviderShape.providerLabel)}
</Button>
)
}
description={
oauthConnected
? authProviderShape.isPassword
? t.settings.gateway.authSignedInPassword
: t.settings.gateway.authSignedInOauth
: authProviderShape.isPassword
? t.settings.gateway.authNeedsPassword
: t.settings.gateway.authNeedsOauth(authProviderShape.providerLabel)
}
title={t.settings.gateway.authTitle}
/>
)}
</>
)}

Expand Down
75 changes: 75 additions & 0 deletions apps/desktop/src/store/profile-select-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'

// Picking a profile must stay on the source the user is LOOKING at. $profiles
// is the active gateway's list, so a pick made while a registry source is live
// names one of THAT source's profiles. Routing it through the profile-only
// path resolved the descriptor with a bare name, which the main process
// answers against the primary — the gateway snapped back home and the pick
// looked like it never took.

const ensureGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const ensureGatewayForAgent = vi.fn(async (_connectionId: null | string, _profile: string) => true)
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const activeGatewayConnectionId = vi.fn<() => null | string>(() => null)
const $gateway = atom<unknown>({ id: 'live-socket' })
const resetStarmapGraph = vi.fn()

vi.mock('@/store/gateway', () => ({
$gateway,
activeGatewayConnectionId,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForProfile
}))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))

const { $activeGatewayProfile, newSessionInProfile, selectProfile } = await import('./profile')

beforeEach(() => {
ensureGatewayForProfile.mockClear()
ensureGatewayForAgent.mockClear()
activeGatewayConnectionId.mockReset()
activeGatewayConnectionId.mockReturnValue(null)
$gateway.set({ id: 'live-socket' })
$activeGatewayProfile.set('default')
// resolveConnectionForAgent is best-effort; without a bridge it resolves
// null and the previous descriptor stays, which is fine here.
;(globalThis as { window?: unknown }).window = {}
})

describe('selectProfile', () => {
it('activates the pick on the live registry source, not the primary', async () => {
activeGatewayConnectionId.mockReturnValue('mini')

selectProfile('researcher')

await vi.waitFor(() => expect(ensureGatewayForAgent).toHaveBeenCalledWith('mini', 'researcher'))
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})

it('keeps the legacy profile-only path when the primary is live', async () => {
activeGatewayConnectionId.mockReturnValue(null)

selectProfile('ops')

await vi.waitFor(() => expect(ensureGatewayForProfile).toHaveBeenCalledWith('ops'))
expect(ensureGatewayForAgent).not.toHaveBeenCalled()
})
})

describe('newSessionInProfile', () => {
it('opens the new chat on the live registry source', async () => {
activeGatewayConnectionId.mockReturnValue('mini')

newSessionInProfile('designer')

await vi.waitFor(() => expect(ensureGatewayForAgent).toHaveBeenCalledWith('mini', 'designer'))
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})
})
26 changes: 23 additions & 3 deletions apps/desktop/src/store/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import {
storedStringRecord
} from '@/lib/storage'
import { invalidateCronModelImpactScopeState } from '@/store/cron-model-impact-scope'
import { $gateway, ensureGatewayForAgent, ensureGatewayForProfile, openGatewayForProfile } from '@/store/gateway'
import {
$gateway,
activeGatewayConnectionId,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForProfile
} from '@/store/gateway'
import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override'
import { setConnection } from '@/store/session'
import { resetStarmapGraph } from '@/store/starmap'
Expand Down Expand Up @@ -520,7 +526,21 @@ export function selectProfile(name: string): void {
// A profile with a remote override can fail to activate because the remote
// host rejected its saved token (rotated/revoked). That must surface as a
// "re-enter token" affordance, never a silently dead profile (#91349).
void ensureGatewayProfile(target).catch(error => notifyRemoteOverrideAuthFailure(target, error))
void activateOnCurrentSource(target).catch(error => notifyRemoteOverrideAuthFailure(target, error))
}

// Route a profile pick at the source the user is LOOKING at. $profiles is the
// active gateway's list, so a pick made while a registry source is live names
// one of THAT source's profiles. Sending it through the profile-only path
// resolves the descriptor with a bare name (getConnection(profile)), which the
// main process answers against the primary — so picking "researcher" on a
// remote source opened a local backend of the same name and dropped the user
// back home, making the pick look like it never took. A null connection id
// means the primary is live, which is exactly the legacy path.
function activateOnCurrentSource(target: string): Promise<void> {
const connectionId = activeGatewayConnectionId()

return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target)
}

// Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse
Expand All @@ -534,7 +554,7 @@ export function newSessionInProfile(name: string): void {
$newChatProfile.set(target)
$newChatRoute.set(null)
requestFreshSession()
void ensureGatewayProfile(target).catch(error => notifyRemoteOverrideAuthFailure(target, error))
void activateOnCurrentSource(target).catch(error => notifyRemoteOverrideAuthFailure(target, error))
}

/** Start a draft owned by a specific registry agent. Foreground activation is
Expand Down
Loading