diff --git a/apps/desktop/src/app/settings/connections-registry.tsx b/apps/desktop/src/app/settings/connections-registry.tsx index 131f21a3b645..b6e9cdac3ee7 100644 --- a/apps/desktop/src/app/settings/connections-registry.tsx +++ b/apps/desktop/src/app/settings/connections-registry.tsx @@ -6,6 +6,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Input } from '@/components/ui/input' import type { DesktopConnectionKind, + DesktopConnectionProbeResult, DesktopConnectionsRegistry, DesktopRegistryConnection, DesktopRegistryConnectionInput @@ -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' @@ -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) + // 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(null) + const [signingIn, setSigningIn] = useState(false) + const [oauthConnected, setOauthConnected] = useState(false) + const probeSeq = useRef(0) const bridge = window.hermesDesktop?.connections @@ -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) @@ -731,6 +838,34 @@ export function ConnectionsRegistrySection() { title={t.settings.gateway.tokenTitle} /> )} + {editor.authMode === 'oauth' && ( + + {t.settings.gateway.signedIn} + + ) : ( + + ) + } + 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} + /> + )} )} diff --git a/apps/desktop/src/store/profile-select-source.test.ts b/apps/desktop/src/store/profile-select-source.test.ts new file mode 100644 index 000000000000..0bbc1ef7a0fb --- /dev/null +++ b/apps/desktop/src/store/profile-select-source.test.ts @@ -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({ 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() + }) +}) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index c0cbb262a0a2..4ad397744f00 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -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' @@ -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 { + const connectionId = activeGatewayConnectionId() + + return connectionId ? ensureGatewayAgent(connectionId, target) : ensureGatewayProfile(target) } // Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse @@ -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