diff --git a/apps/desktop/src/app/settings/gateway-settings.test.tsx b/apps/desktop/src/app/settings/gateway-settings.test.tsx
index a42a6ed77f..31d4fe470f 100644
--- a/apps/desktop/src/app/settings/gateway-settings.test.tsx
+++ b/apps/desktop/src/app/settings/gateway-settings.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Collect the component graph before the behavioral test deadline starts.
@@ -57,6 +57,178 @@ afterEach(() => {
})
describe('GatewaySettings', () => {
+ it('releases a pending save after a late probe invalidates its response', async () => {
+ const saved = { ...localConnection, mode: 'remote', remoteUrl: 'https://a.example', remoteTokenSet: true }
+ getConnectionConfig.mockResolvedValue(saved)
+ let finishSave!: (value: typeof saved) => void
+ let finishProbe!: (value: { reachable: boolean; authMode: string; providers: never[] }) => void
+ saveConnectionConfig.mockReturnValueOnce(
+ new Promise
(resolve => {
+ finishSave = resolve
+ })
+ )
+
+ const probeConnectionConfig = vi.fn(
+ () =>
+ new Promise<{ reachable: boolean; authMode: string; providers: never[] }>(resolve => {
+ finishProbe = resolve
+ })
+ )
+
+ Object.assign(window.hermesDesktop, { probeConnectionConfig })
+ render()
+ const saveButton = (await screen.findByRole('button', { name: 'Save for next restart' })) as HTMLButtonElement
+ await waitFor(() => expect(probeConnectionConfig).toHaveBeenCalledWith('https://a.example'))
+ fireEvent.click(saveButton)
+ expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({
+ mode: 'remote',
+ remoteUrl: 'https://a.example',
+ remoteAuthMode: 'token',
+ remoteToken: undefined
+ })
+ expect(saveButton.disabled).toBe(true)
+ await act(async () => finishProbe({ reachable: true, authMode: 'oauth', providers: [] }))
+ await act(async () => finishSave(saved))
+ expect(saveButton.disabled).toBe(false)
+ expect(screen.getByRole('button', { name: /Sign in with/ })).toBeTruthy()
+ expect(screen.queryByPlaceholderText('Existing token saved')).toBeNull()
+ })
+
+ it('pre-saves OAuth before login and applies the resolved auth mode without requiring a test', async () => {
+ getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'remote', remoteUrl: 'https://login.example' })
+ let finishSave!: () => void
+ saveConnectionConfig.mockReturnValueOnce(
+ new Promise(resolve => {
+ finishSave = resolve
+ })
+ )
+ const oauthLoginConnectionConfig = vi.fn().mockResolvedValue({ connected: true })
+ const applyConnectionConfig = vi.fn().mockResolvedValue(localConnection)
+ const testConnectionConfig = vi.fn()
+ Object.assign(window.hermesDesktop, {
+ oauthLoginConnectionConfig,
+ applyConnectionConfig,
+ testConnectionConfig,
+ probeConnectionConfig: vi.fn().mockResolvedValue({
+ reachable: true,
+ authMode: 'oauth',
+ providers: [{ name: 'password', displayName: 'Username & Password', supportsPassword: true }]
+ })
+ })
+ render()
+ fireEvent.click(await screen.findByRole('button', { name: 'Sign in' }))
+ expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({
+ mode: 'remote',
+ remoteAuthMode: 'oauth',
+ remoteUrl: 'https://login.example'
+ })
+ expect(oauthLoginConnectionConfig).not.toHaveBeenCalled()
+ await act(async () => finishSave())
+ await screen.findByText('Signed in')
+ expect(oauthLoginConnectionConfig).toHaveBeenCalledExactlyOnceWith('https://login.example')
+ fireEvent.click(screen.getByRole('button', { name: 'Save and reconnect' }))
+ await waitFor(() =>
+ expect(applyConnectionConfig).toHaveBeenCalledExactlyOnceWith({
+ mode: 'remote',
+ remoteAuthMode: 'oauth',
+ remoteUrl: 'https://login.example',
+ remoteToken: undefined
+ })
+ )
+ expect(testConnectionConfig).not.toHaveBeenCalled()
+ })
+
+ it('keeps a saved token when blank and requires consent before replacing it in plaintext', async () => {
+ const saved = {
+ ...localConnection,
+ mode: 'remote',
+ remoteUrl: 'https://a.example',
+ remoteTokenSet: true,
+ remoteTokenPreview: 'saved-preview',
+ secureTokenStorage: false,
+ remoteTokenPlainText: true
+ }
+
+ getConnectionConfig.mockResolvedValue(saved)
+ saveConnectionConfig.mockResolvedValue(saved)
+ let finishSave!: (value: typeof saved) => void
+ saveConnectionConfig.mockReturnValueOnce(
+ new Promise(resolve => {
+ finishSave = resolve
+ })
+ )
+ Object.assign(window.hermesDesktop, {
+ probeConnectionConfig: vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] })
+ })
+ render()
+ await screen.findByPlaceholderText('Existing token saved-preview')
+ fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
+ await waitFor(() =>
+ expect(saveConnectionConfig).toHaveBeenCalledExactlyOnceWith({
+ mode: 'remote',
+ remoteUrl: 'https://a.example',
+ remoteAuthMode: 'token',
+ remoteToken: undefined
+ })
+ )
+ // Flush the save's reset and probe effects before acquiring the replacement field.
+ await act(async () => finishSave(saved))
+ const tokenInput = await screen.findByPlaceholderText('Existing token saved-preview')
+ expect(tokenInput.isConnected, 'saved credential control must survive the refresh probe').toBe(true)
+ fireEvent.change(tokenInput, { target: { value: 'replacement' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
+ await screen.findByText('Store the gateway token in plain text?')
+ expect(saveConnectionConfig).toHaveBeenCalledTimes(1)
+ fireEvent.click(screen.getByRole('button', { name: 'Save as plain text' }))
+ await waitFor(() =>
+ expect(saveConnectionConfig).toHaveBeenLastCalledWith({
+ mode: 'remote',
+ remoteUrl: 'https://a.example',
+ remoteAuthMode: 'token',
+ remoteToken: 'replacement',
+ allowPlainTextToken: true
+ })
+ )
+ })
+
+ it('discards an old token test while saving the current credential-ready payload', async () => {
+ getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'remote', remoteUrl: 'https://a.example' })
+ const probeConnectionConfig = vi.fn().mockResolvedValue({ reachable: true, authMode: 'token', providers: [] })
+ let finishTest!: (value: { ok: boolean; baseUrl: string }) => void
+
+ const testConnectionConfig = vi.fn().mockReturnValue(
+ new Promise<{ ok: boolean; baseUrl: string }>(resolve => {
+ finishTest = resolve
+ })
+ )
+
+ Object.assign(window.hermesDesktop, { probeConnectionConfig, testConnectionConfig })
+ render()
+ const token = await screen.findByPlaceholderText('Paste session token')
+ fireEvent.change(token, { target: { value: 'old-token' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Test remote' }))
+ expect(testConnectionConfig).toHaveBeenCalledWith({
+ mode: 'remote',
+ remoteUrl: 'https://a.example',
+ remoteAuthMode: 'token',
+ remoteToken: 'old-token'
+ })
+ fireEvent.change(token, { target: { value: 'new-token' } })
+ await act(async () => finishTest({ ok: true, baseUrl: 'https://a.example' }))
+ expect(screen.queryByText('Connected to https://a.example')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Save for next restart' }))
+ await waitFor(() =>
+ expect(saveConnectionConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mode: 'remote',
+ remoteUrl: 'https://a.example',
+ remoteAuthMode: 'token',
+ remoteToken: 'new-token'
+ })
+ )
+ )
+ })
+
it('keeps saved Cloud instances usable without discovery and marks the live source, not the default', async () => {
getConnectionConfig.mockResolvedValue({ ...localConnection, mode: 'cloud', remoteUrl: 'https://a.example' })
registry.value = {
diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx
index ac5a2b43d2..5409a87c41 100644
--- a/apps/desktop/src/app/settings/gateway-settings.tsx
+++ b/apps/desktop/src/app/settings/gateway-settings.tsx
@@ -1,12 +1,14 @@
import { useStore } from '@nanostores/react'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
+import { RemoteSetupFields } from '@/components/remote-setup/fields'
+import { useRemoteSetup } from '@/components/remote-setup/use-remote-setup'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip } from '@/components/ui/tooltip'
-import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global'
+import type { DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionConfigInput } from '@/global'
import { useI18n } from '@/i18n'
import { ExternalLink } from '@/lib/external-link'
import {
@@ -41,7 +43,6 @@ import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
type AuthMode = 'oauth' | 'token'
-type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
@@ -164,15 +165,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
- const [signingIn, setSigningIn] = useState(false)
const [state, setState] = useState(EMPTY_STATE)
- const [remoteToken, setRemoteToken] = useState('')
+
+ const remote = useRemoteSetup({
+ host: 'settings',
+ enabled: !loading && state.mode === 'remote',
+ beforeOAuthLogin: async (payload: DesktopConnectionConfigInput): Promise => {
+ await window.hermesDesktop.saveConnectionConfig(payload)
+ },
+ onNotice: notify
+ })
+
const [lastTest, setLastTest] = useState(null)
const [sshHostSuggestions, setSshHostSuggestions] = useState([])
const [sshCustomHost, setSshCustomHost] = useState(false)
const sshResolveSeq = useRef(0)
const sshTestSeq = useRef(0)
const saveSeq = useRef(0)
+ const saveOwner = useRef(null)
const signingSeq = useRef(0)
const cloudConnectSeq = useRef(0)
const contextSeq = useRef(0)
@@ -224,10 +234,17 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
}
- const acceptSavedConfig = (config: GatewaySettingsState) => {
+ const acceptSavedConfig = (config: GatewaySettingsState): void => {
const normalized = normalizeGatewaySettingsState(config)
setState(normalized)
+ remote.reset({
+ url: normalized.remoteUrl,
+ authMode: normalized.remoteAuthMode,
+ oauthConnected: normalized.remoteOauthConnected,
+ tokenSet: normalized.remoteTokenSet,
+ tokenPreview: normalized.remoteTokenPreview
+ })
}
// When set, the plain-text opt-in dialog is open; `apply` remembers whether
@@ -261,13 +278,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setCloudOrgState(value)
}
- // Auth-mode probe: as the user types a remote URL we ask the gateway (via
- // its public /api/status) whether it gates with OAuth or a static session
- // token, so we can show the right control (login button vs token box).
- const [probeStatus, setProbeStatus] = useState('idle')
- const [probe, setProbe] = useState(null)
- const probeSeq = useRef(0)
-
useEffect(() => {
let cancelled = false
const desktop = window.hermesDesktop
@@ -300,12 +310,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount; copy is stable
}, [])
- // Debounced probe of the entered remote URL. Only runs in remote mode with a
- // syntactically plausible URL. The probe result drives whether we render the
- // OAuth login button or the session-token entry box. The effective auth mode
- // prefers a fresh probe result over the saved value.
- const trimmedUrl = coerceRemoteUrlScheme(state.remoteUrl)
-
const savedAgent = (agent: DesktopCloudAgent) =>
registry?.connections.find(
connection =>
@@ -330,105 +334,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
}
- useEffect(() => {
- if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
- setProbeStatus('idle')
- setProbe(null)
-
- return
- }
-
- const desktop = window.hermesDesktop
-
- if (!desktop?.probeConnectionConfig) {
- return
- }
-
- const seq = ++probeSeq.current
- setProbeStatus('probing')
-
- const timer = setTimeout(() => {
- desktop
- .probeConnectionConfig(trimmedUrl)
- .then(result => {
- if (seq !== probeSeq.current) {
- return
- }
-
- setProbe(result)
- setProbeStatus(result.reachable ? 'done' : 'error')
- })
- .catch(() => {
- if (seq !== probeSeq.current) {
- return
- }
-
- setProbe(null)
- setProbeStatus('error')
- })
- }, 500)
-
- return () => clearTimeout(timer)
- }, [state.mode, trimmedUrl])
-
- // Effective auth mode: a reachable probe wins; otherwise fall back to the
- // saved config's mode so a re-open of settings doesn't flicker.
- const authMode: AuthMode = useMemo(() => {
- if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
- return probe.authMode
- }
-
- return state.remoteAuthMode
- }, [probe, probeStatus, state.remoteAuthMode])
-
- // Whether we actually KNOW how this gateway authenticates yet. Until we do,
- // neither the OAuth button nor the session-token box should render —
- // `authMode` defaults to 'token', so without this gate the token box flashes
- // for every gateway (including OAuth ones) during the idle/probing window
- // before the first probe lands. The scheme is known when either:
- // * the live probe finished (probeStatus 'done'), or
- // * we're idle but showing a previously-saved remote config (re-opening
- // settings for a gateway already signed-in or with a saved token), so
- // its control appears immediately with no flicker.
- // While probing (or after a probe error), the scheme is unknown and we show
- // the probe status row instead of a control.
- const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
-
- const authResolved = useMemo(() => {
- if (probeStatus === 'done') {
- return true
- }
-
- return probeStatus === 'idle' && hasSavedRemote
- }, [probeStatus, hasSavedRemote])
-
- const providerLabel = useMemo(() => {
- const providers: DesktopAuthProvider[] = probe?.providers ?? []
-
- if (providers.length === 1) {
- return providers[0].displayName || providers[0].name
- }
-
- if (providers.length > 1) {
- return providers.map(p => p.displayName || p.name).join(' / ')
- }
-
- return t.boot.failure.identityProvider
- }, [probe, t.boot.failure.identityProvider])
-
- // A username/password gateway authenticates through a credential form on the
- // gateway's /login page (POST /auth/password-login) rather than an OAuth
- // redirect. Everything downstream — the session cookie, the ws-ticket mint,
- // the persistent partition — is identical, so the desktop drives it through
- // the same sign-in window; only the button copy changes. We treat the
- // gateway as password-style only when EVERY advertised provider supports
- // password, so a mixed deployment keeps the generic OAuth copy.
- const isPasswordProvider = useMemo(() => {
- const providers: DesktopAuthProvider[] = probe?.providers ?? []
-
- return providers.length > 0 && providers.every(p => p.supportsPassword)
- }, [probe])
-
useEffect(() => {
// One-directional: a saved host that isn't in the suggestions must render
// the free-text input (rehydration). Never force custom OFF here — that
@@ -472,6 +377,9 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
setLastTest(null)
}, [
state.mode,
+ remote.credentials.url,
+ remote.credentials.token,
+ remote.credentials.authMode,
state.sshHost,
state.sshUser,
state.sshPort,
@@ -480,33 +388,21 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
state.sshRemoteProfile
])
- const oauthConnected = state.remoteOauthConnected
-
- const canUseRemote = useMemo(() => {
- if (!trimmedUrl) {
- return false
- }
-
- if (authMode === 'oauth') {
- return oauthConnected
- }
-
- return Boolean(remoteToken.trim()) || state.remoteTokenSet
- }, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
-
- const payload = (allowPlainTextToken?: boolean) => ({
- mode: state.mode,
- remoteAuthMode: authMode,
- remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
- remoteUrl: trimmedUrl,
- sshHost: state.sshHost.trim(),
- sshUser: state.sshUser.trim() || undefined,
- sshPort: state.sshPort,
- sshKeyPath: state.sshKeyPath.trim() || undefined,
- sshRemoteHermesPath: state.sshRemoteHermesPath.trim(),
- // Preserve an intentional blank so an existing remote-profile mapping can
- // be cleared instead of being mistaken for an omitted field.
- sshRemoteProfile: state.sshRemoteProfile.trim(),
+ const payload = (allowPlainTextToken?: boolean): DesktopConnectionConfigInput => ({
+ ...(state.mode === 'remote'
+ ? remote.payload
+ : {
+ mode: state.mode,
+ remoteAuthMode: state.remoteAuthMode,
+ remoteUrl: coerceRemoteUrlScheme(state.remoteUrl),
+ sshHost: state.sshHost.trim(),
+ sshUser: state.sshUser.trim() || undefined,
+ sshPort: state.sshPort,
+ sshKeyPath: state.sshKeyPath.trim() || undefined,
+ sshRemoteHermesPath: state.sshRemoteHermesPath.trim(),
+ // A blank clears an existing remote-profile mapping.
+ sshRemoteProfile: state.sshRemoteProfile.trim()
+ }),
...(allowPlainTextToken ? { allowPlainTextToken: true } : {})
})
@@ -515,13 +411,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
// and this machine has no OS keyring (safeStorage unavailable). In that case
// we must get an explicit opt-in before persisting.
const wouldPersistPlainTextToken =
- (state.mode === 'remote' || state.mode === 'cloud') &&
- authMode !== 'oauth' &&
- Boolean(remoteToken.trim()) &&
+ state.mode === 'remote' &&
+ remote.credentials.authMode === 'token' &&
+ Boolean(remote.credentials.token.trim()) &&
state.secureTokenStorage === false
- const performSave = async (apply: boolean, allowPlainTextToken: boolean) => {
+ const performSave = async (apply: boolean, allowPlainTextToken: boolean): Promise => {
const seq = ++saveSeq.current
+ saveOwner.current = seq
setSaving(true)
try {
@@ -534,7 +431,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
acceptSavedConfig(next)
- setRemoteToken('')
notify({
kind: 'success',
title: apply ? g.restartingTitle : g.savedTitle,
@@ -574,18 +470,20 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
notifyError(err, apply ? g.applyFailed : g.saveFailed)
}
} finally {
- if (seq === saveSeq.current) {
+ // A stale response cannot replace the draft, but its request must release busy state.
+ if (seq === saveOwner.current) {
+ saveOwner.current = null
setSaving(false)
}
}
}
- const save = async (apply: boolean) => {
- if (state.mode === 'remote' && !canUseRemote) {
+ const save = async (apply: boolean): Promise => {
+ if (state.mode === 'remote' && !remote.canCommit) {
notify({
kind: 'warning',
title: g.incompleteTitle,
- message: authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken
+ message: remote.credentials.authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken
})
return
@@ -601,94 +499,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
await performSave(apply, false)
}
- // OAuth sign-in: persist the URL + oauth mode first (so the saved config has
- // the URL the login window needs), then open the gateway login window and
- // refresh the connection status from the saved config once it completes.
- const signIn = async () => {
- const seq = ++signingSeq.current
-
- if (!trimmedUrl) {
- notify({ kind: 'warning', title: g.incompleteTitle, message: g.enterUrlFirst })
-
- return
- }
-
- setSigningIn(true)
-
- try {
- // Save (don't apply/restart) so the login window has a URL to use and the
- // oauth mode is persisted, without yet flipping the live connection.
- const saved = await window.hermesDesktop.saveConnectionConfig({
- mode: state.mode,
- remoteAuthMode: 'oauth',
- remoteUrl: trimmedUrl
- })
-
- if (seq !== signingSeq.current) {
- return
- }
-
- acceptSavedConfig(saved)
-
- const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
-
- if (seq !== signingSeq.current) {
- return
- }
-
- if (result.connected) {
- const refreshed = await window.hermesDesktop.getConnectionConfig(null)
- acceptSavedConfig(refreshed)
- notify({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
- } else {
- notify({
- kind: 'warning',
- title: t.boot.failure.signInIncompleteTitle,
- message: result?.error
- ? `${t.boot.failure.signInIncompleteMessage}: ${result.error}`
- : t.boot.failure.signInIncompleteMessage
- })
- }
- } catch (err) {
- if (seq === signingSeq.current) {
- notifyError(err, g.signInFailed)
- }
- } finally {
- if (seq === signingSeq.current) {
- setSigningIn(false)
- }
- }
- }
-
- const signOut = async () => {
- if (!trimmedUrl) {
- return
- }
-
- const seq = ++signingSeq.current
- setSigningIn(true)
-
- try {
- await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl)
- const refreshed = await window.hermesDesktop.getConnectionConfig(null)
-
- if (seq !== signingSeq.current) {
- return
- }
-
- acceptSavedConfig(refreshed)
- notify({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
- } catch (err) {
- if (seq === signingSeq.current) {
- notifyError(err, g.signOutFailed)
- }
- } finally {
- if (seq === signingSeq.current) {
- setSigningIn(false)
- }
- }
- }
-
// --- Hermes Cloud handlers ---
// Pull the discovered agent list over the shared portal session. Tolerant of
@@ -1054,48 +864,6 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
}
}
- const testRemote = async () => {
- const seq = ++sshTestSeq.current
-
- if (!canUseRemote) {
- notify({
- kind: 'warning',
- title: g.incompleteTitle,
- message: authMode === 'oauth' ? g.incompleteSignInTest : g.incompleteTokenTest
- })
-
- return
- }
-
- setTesting(true)
- setLastTest(null)
-
- try {
- const result = await window.hermesDesktop.testConnectionConfig({
- mode: 'remote',
- remoteAuthMode: authMode,
- remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
- remoteUrl: trimmedUrl
- })
-
- if (seq !== sshTestSeq.current) {
- return
- }
-
- const message = g.connectedTo(result.baseUrl || trimmedUrl, result.version ?? undefined)
- setLastTest(message)
- notify({ kind: 'success', title: g.reachableTitle, message })
- } catch (err) {
- if (seq === sshTestSeq.current) {
- notifyError(err, g.testFailed)
- }
- } finally {
- if (seq === sshTestSeq.current) {
- setTesting(false)
- }
- }
- }
-
if (loading) {
return (
- setState(current => ({ ...current, remoteUrl: event.target.value }))}
- placeholder="https://gateway.example.com/hermes"
- value={state.remoteUrl}
- />
- }
- description={g.remoteUrlDesc}
- title={g.remoteUrlTitle}
- />
-
- {state.mode === 'remote' && probeStatus === 'probing' ? (
-
-
- {g.probing}
+
+
+ {remote.credentials.authMode === 'token' && state.remoteTokenPlainText ? (
+
+
{g.plainTextStoredTitle}
+
{g.plainTextStoredDesc}
) : null}
-
- {state.mode === 'remote' && probeStatus === 'error' ? (
-
- ) : null}
-
- {/* OAuth / password gateways: present a sign-in button + connection status. */}
- {state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
-
-
- {g.signedIn}
-
-
-
- ) : (
-
- )
- }
- description={
- oauthConnected
- ? isPasswordProvider
- ? g.authSignedInPassword
- : g.authSignedInOauth
- : isPasswordProvider
- ? g.authNeedsPassword
- : g.authNeedsOauth(providerLabel)
- }
- title={g.authTitle}
- />
- ) : null}
-
- {/* Session-token gateways: keep the existing token entry box. */}
- {state.mode === 'remote' && authResolved && authMode === 'token' ? (
- <>
-
setRemoteToken(event.target.value)}
- placeholder={
- state.remoteTokenSet
- ? g.existingToken(state.remoteTokenPreview ?? g.savedToken)
- : g.pasteSessionToken
- }
- type="password"
- value={remoteToken}
- />
- }
- description={g.tokenDesc}
- title={g.tokenTitle}
- />
-
- {/* The saved token is on disk in plain text (no OS keyring). Same
- banner idiom as envOverride so it reads as a real warning. */}
- {state.remoteTokenPlainText ? (
-
-
-
-
{g.plainTextStoredTitle}
-
{g.plainTextStoredDesc}
-
-
- ) : null}
- >
- ) : null}
) : null}
@@ -1575,12 +1252,12 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
{state.mode === 'remote' ? (
) : state.mode === 'ssh' ? (
diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx
index 51165480c4..ba98301881 100644
--- a/apps/desktop/src/components/desktop-install-overlay.test.tsx
+++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx
@@ -518,6 +518,39 @@ describe('DesktopInstallOverlay first-run setup', () => {
})
})
+ it('does not authorize a new URL with an old login result or save before Apply', async () => {
+ const desktop = installDesktopMock(bootstrapState({
+ setupChoice: { platform: 'linux', activeRoot: '/tmp/hermes', local: 'none', bundled: false }
+ }))
+
+ const saveConnectionConfig = vi.fn()
+ Object.assign(desktop, { saveConnectionConfig })
+ desktop.probeConnectionConfig.mockResolvedValue({
+ authMode: 'oauth', baseUrl: 'https://a.example', reachable: true, providers: [], error: null, version: null
+ })
+ let finishLogin!: (value: { connected: boolean }) => void
+ desktop.oauthLoginConnectionConfig.mockReturnValueOnce(new Promise<{ connected: boolean }>(resolve => {
+ finishLogin = resolve
+ }))
+ render()
+ fireEvent.click(await screen.findByText('Connect to existing Hermes'))
+ const url = screen.getByPlaceholderText('https://gateway.example.com/hermes')
+ fireEvent.change(url, { target: { value: 'https://a.example' } })
+ fireEvent.click(await screen.findByRole('button', { name: /Sign in with/ }))
+ await waitFor(() => expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://a.example'))
+ fireEvent.change(url, { target: { value: 'https://b.example' } })
+ await waitFor(() => expect(desktop.probeConnectionConfig).toHaveBeenCalledWith('https://b.example'))
+ await act(async () => finishLogin({ connected: true }))
+ fireEvent.click(screen.getByText('Test connection'))
+ expect(desktop.testConnectionConfig).not.toHaveBeenCalled()
+ expect(saveConnectionConfig).not.toHaveBeenCalled()
+ expect(desktop.applyConnectionConfig).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByText('Back'))
+ fireEvent.click(await screen.findByText('Install Hermes locally'))
+ expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1)
+ expect(saveConnectionConfig).not.toHaveBeenCalled()
+ })
+
it('offers remote connection from the unsupported packaged install screen', async () => {
const desktop = installDesktopMock(
bootstrapState({
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx
index 71026701d0..8cfb83d359 100644
--- a/apps/desktop/src/components/desktop-install-overlay.tsx
+++ b/apps/desktop/src/components/desktop-install-overlay.tsx
@@ -20,7 +20,7 @@ import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { localCardPresentation } from './desktop-install-local-card'
-import { FirstRunRemoteForm } from './first-run-remote-form'
+import { FirstRunRemoteSetup } from './remote-setup/first-run'
/**
* DesktopInstallOverlay
@@ -398,7 +398,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
}
if (remoteOpen) {
- return setRemoteOpen(false)} />
+ return setRemoteOpen(false)} />
}
if (state.setupChoice) {
diff --git a/apps/desktop/src/components/first-run-remote-form.tsx b/apps/desktop/src/components/first-run-remote-form.tsx
deleted file mode 100644
index 139f0bd2a9..0000000000
--- a/apps/desktop/src/components/first-run-remote-form.tsx
+++ /dev/null
@@ -1,346 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-
-import { BrandMark } from '@/components/brand-mark'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import type { DesktopConnectionProbeResult } from '@/global'
-import { useI18n } from '@/i18n'
-import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
-import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons'
-import { coerceRemoteUrlScheme } from '@/lib/remote-url'
-
-type AuthMode = 'oauth' | 'token'
-type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
-
-interface FirstRunRemoteFormProps {
- onBack: () => void
-}
-
-function errorMessage(err: unknown): string {
- return err instanceof Error ? err.message : String(err || 'Unknown error')
-}
-
-export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
- const { t } = useI18n()
- const copy = t.install
- const [remoteUrl, setRemoteUrl] = useState('')
- const [remoteToken, setRemoteToken] = useState('')
- const [probeStatus, setProbeStatus] = useState('idle')
- const [probe, setProbe] = useState(null)
- const [oauthConnected, setOauthConnected] = useState(false)
- const [signingIn, setSigningIn] = useState(false)
- const [testing, setTesting] = useState(false)
- const [applying, setApplying] = useState(false)
- const [error, setError] = useState(null)
- const [success, setSuccess] = useState(null)
- const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState(null)
- const probeSeq = useRef(0)
- const testSeq = useRef(0)
-
- const trimmedUrl = coerceRemoteUrlScheme(remoteUrl)
-
- const invalidateTest = useCallback(() => {
- testSeq.current += 1
- setTesting(false)
- setError(null)
- setSuccess(null)
- setLastTestedPayloadKey(null)
- }, [])
-
- useEffect(() => {
- const seq = ++probeSeq.current
-
- if (!trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
- setProbeStatus('idle')
- setProbe(null)
- setOauthConnected(false)
-
- return
- }
-
- const desktop = window.hermesDesktop
-
- if (!desktop?.probeConnectionConfig) {
- return
- }
-
- setProbeStatus('probing')
-
- const timer = window.setTimeout(() => {
- desktop
- .probeConnectionConfig(trimmedUrl)
- .then(result => {
- if (seq !== probeSeq.current) {
- return
- }
-
- invalidateTest()
- setProbe(result)
- setProbeStatus(result.reachable ? 'done' : 'error')
-
- if (result.reachable && result.authMode !== 'oauth') {
- setOauthConnected(false)
- }
- })
- .catch(err => {
- if (seq !== probeSeq.current) {
- return
- }
-
- setProbe(null)
- setProbeStatus('error')
- setError(errorMessage(err))
- })
- }, 500)
-
- return () => window.clearTimeout(timer)
- }, [invalidateTest, trimmedUrl])
-
- const authMode: AuthMode = probeStatus === 'done' && probe?.authMode === 'oauth' ? 'oauth' : 'token'
- const authResolved = probeStatus === 'done' && probe?.authMode !== 'unknown'
- const authProviderShape = deriveRemoteAuthProviderShape(probe?.providers, copy.identityProvider)
- const { isPassword: isPasswordProvider, providerLabel } = authProviderShape
- const canRetryProbe = Boolean(trimmedUrl && probeStatus === 'error')
-
- const canTest = Boolean(
- trimmedUrl && (canRetryProbe || (authResolved && (authMode === 'oauth' ? oauthConnected : remoteToken.trim())))
- )
-
- const payload = () => ({
- mode: 'remote' as const,
- remoteAuthMode: authMode,
- remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
- remoteUrl: trimmedUrl
- })
-
- const currentPayloadKey = JSON.stringify(payload())
- const payloadKeyRef = useRef(currentPayloadKey)
- payloadKeyRef.current = currentPayloadKey
- const canApply = lastTestedPayloadKey === currentPayloadKey
-
- const signIn = async () => {
- if (!trimmedUrl) {
- setError(copy.enterUrlFirst)
-
- return
- }
-
- setSigningIn(true)
- setError(null)
-
- try {
- // Unlike Settings, first-run intentionally does not pre-save remote mode:
- // backing out must still allow local install without leaving a remote
- // connection selected. The login IPC accepts the raw URL and stores only
- // its OAuth cookies; config is persisted once the user applies.
- const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
- invalidateTest()
- setOauthConnected(Boolean(result.connected))
-
- if (!result.connected) {
- setError(result.error ? `${copy.signInIncomplete}: ${result.error}` : copy.signInIncomplete)
- }
- } catch (err) {
- setError(errorMessage(err))
- } finally {
- setSigningIn(false)
- }
- }
-
- const testRemote = async () => {
- if (!canTest) {
- setError(authMode === 'oauth' ? copy.incompleteSignInTest : copy.incompleteTokenTest)
-
- return
- }
-
- const seq = ++testSeq.current
- const testedPayload = payload()
- const testedPayloadKey = JSON.stringify(testedPayload)
-
- setTesting(true)
- setError(null)
- setSuccess(null)
- setLastTestedPayloadKey(null)
-
- try {
- if (!authResolved) {
- const result = await window.hermesDesktop.probeConnectionConfig(trimmedUrl)
-
- if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) {
- return
- }
-
- setProbe(result)
- setProbeStatus(result.reachable ? 'done' : 'error')
- setError(result.reachable && result.authMode !== 'unknown' ? null : result.error || copy.probeError)
-
- return
- }
-
- const result = await window.hermesDesktop.testConnectionConfig(testedPayload)
-
- if (seq !== testSeq.current || testedPayloadKey !== payloadKeyRef.current) {
- return
- }
-
- setSuccess(copy.testSucceeded(result.baseUrl || trimmedUrl, result.version ?? undefined))
- setLastTestedPayloadKey(testedPayloadKey)
- } catch (err) {
- if (seq === testSeq.current && testedPayloadKey === payloadKeyRef.current) {
- setError(errorMessage(err))
- }
- } finally {
- if (seq === testSeq.current) {
- setTesting(false)
- }
- }
- }
-
- const applyRemote = async () => {
- if (!canApply) {
- return
- }
-
- const testedPayload = payload()
-
- setApplying(true)
- setError(null)
- let applied = false
-
- try {
- await window.hermesDesktop.applyConnectionConfig(testedPayload)
- applied = true
- } catch (err) {
- setError(errorMessage(err))
- } finally {
- setApplying(false)
- }
-
- if (applied) {
- onBack()
- }
- }
-
- return (
-
-
-
-
-
-
{copy.remoteSetupTitle}
-
{copy.remoteSetupDesc}
-
-
-
-
-
-
- {probeStatus === 'probing' ? (
-
-
- {copy.probing}
-
- ) : null}
-
- {probeStatus === 'error' ? (
-
-
-
{probe?.error || copy.probeError}
-
- ) : null}
-
- {authResolved && authMode === 'oauth' ? (
-
-
-
-
{copy.authTitle}
-
- {oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)}
-
-
- {oauthConnected ? (
-
-
- {copy.connected}
-
- ) : (
-
- )}
-
-
- ) : null}
-
- {authResolved && authMode === 'token' ? (
-
- ) : null}
-
- {error ? (
-
- ) : null}
-
- {success ? (
-
-
- {success}
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/desktop/src/components/remote-setup/fields.tsx b/apps/desktop/src/components/remote-setup/fields.tsx
new file mode 100644
index 0000000000..9ae00f239d
--- /dev/null
+++ b/apps/desktop/src/components/remote-setup/fields.tsx
@@ -0,0 +1,152 @@
+import type { ReactElement, ReactNode } from 'react'
+
+import { ListRow, Pill } from '@/app/settings/primitives'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { useI18n } from '@/i18n'
+import { AlertCircle, Check, Loader2, LogIn } from '@/lib/icons'
+
+import type { RemoteSetup } from './use-remote-setup'
+
+interface FieldProps {
+ stacked: boolean
+ title: string
+ description?: string
+ children: ReactNode
+}
+
+function Field({ stacked, title, description, children }: FieldProps): ReactElement {
+ return stacked ? (
+
+ {title}
+ {children}
+ {description ? {description} : null}
+
+ ) : (
+
+ )
+}
+
+interface RemoteSetupFieldsProps {
+ setup: RemoteSetup
+ disabled?: boolean
+ urlOnly?: boolean
+ onUrlChange?: () => void
+}
+
+export function RemoteSetupFields({
+ setup,
+ disabled = false,
+ urlOnly = false,
+ onUrlChange
+}: RemoteSetupFieldsProps): ReactElement {
+ const { t } = useI18n()
+ const g = t.settings.gateway
+ const firstRun = setup.host === 'first-run'
+ const registry = setup.host === 'registry'
+ const copy = firstRun ? t.install : g
+ const { credentials, isPassword, providerLabel } = setup
+ const urlTitle = registry ? t.settings.connections.urlTitle : copy.remoteUrlTitle
+
+ const authDescription = firstRun
+ ? credentials.oauthConnected
+ ? t.install.authSignedIn
+ : t.install.authNeedsOauth(providerLabel)
+ : credentials.oauthConnected
+ ? isPassword
+ ? g.authSignedInPassword
+ : g.authSignedInOauth
+ : isPassword
+ ? g.authNeedsPassword
+ : g.authNeedsOauth(providerLabel)
+
+ return (
+
+
+ {
+ setup.setUrl(event.target.value)
+ onUrlChange?.()
+ }}
+ placeholder={registry ? 'http://homelab.lan:9119' : t.install.remoteUrlPlaceholder}
+ value={credentials.url}
+ />
+
+ {!registry && setup.probeStatus === 'probing' ? (
+
+
+ {copy.probing}
+
+ ) : null}
+ {!registry && setup.probeStatus === 'error' ? (
+
+ ) : null}
+ {registry && !urlOnly ? (
+
+
+ {(['token', 'oauth'] as const).map(mode => (
+
+ ))}
+
+
+ ) : null}
+ {!urlOnly && setup.authResolved && credentials.authMode === 'oauth' ? (
+
+ {credentials.oauthConnected ? (
+
+
+
+ {firstRun ? t.install.connected : g.signedIn}
+
+ {setup.host === 'settings' ? (
+
+ ) : null}
+
+ ) : (
+
+ )}
+
+ ) : null}
+ {!urlOnly && setup.authResolved && credentials.authMode === 'token' ? (
+
+ setup.setToken(event.target.value)}
+ placeholder={
+ credentials.tokenSet ? g.existingToken(credentials.tokenPreview ?? g.savedToken) : copy.pasteSessionToken
+ }
+ type="password"
+ value={credentials.token}
+ />
+
+ ) : null}
+ {setup.error ?
{setup.error}
: null}
+ {setup.success ?
{setup.success}
: null}
+
+ )
+}
diff --git a/apps/desktop/src/components/remote-setup/first-run.tsx b/apps/desktop/src/components/remote-setup/first-run.tsx
new file mode 100644
index 0000000000..5c7fe3a541
--- /dev/null
+++ b/apps/desktop/src/components/remote-setup/first-run.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react'
+import type { ReactElement } from 'react'
+
+import { BrandMark } from '@/components/brand-mark'
+import { Button } from '@/components/ui/button'
+import { useI18n } from '@/i18n'
+import { Loader2 } from '@/lib/icons'
+
+import { RemoteSetupFields } from './fields'
+import { useRemoteSetup } from './use-remote-setup'
+
+interface FirstRunRemoteSetupProps {
+ onBack: () => void
+}
+
+export function FirstRunRemoteSetup({ onBack }: FirstRunRemoteSetupProps): ReactElement {
+ const { t } = useI18n()
+ const copy = t.install
+ const setup = useRemoteSetup({ host: 'first-run' })
+ const [applying, setApplying] = useState(false)
+ const [error, setError] = useState(null)
+
+ const apply = async (): Promise => {
+ if (!setup.canCommit || applying) {
+ return
+ }
+
+ setApplying(true)
+ setError(null)
+
+ try {
+ await window.hermesDesktop.applyConnectionConfig(setup.payload)
+ onBack()
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err || t.settings.gateway.applyFailed))
+ } finally {
+ setApplying(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
{copy.remoteSetupTitle}
+
{copy.remoteSetupDesc}
+
+
+
+
+ {error ?
{error}
: null}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx
new file mode 100644
index 0000000000..e15cf9443d
--- /dev/null
+++ b/apps/desktop/src/components/remote-setup/use-remote-setup.test.tsx
@@ -0,0 +1,100 @@
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { DesktopConnectionProbeResult } from '@/global'
+
+import { useRemoteSetup } from './use-remote-setup'
+import type { RemoteSetupHost } from './use-remote-setup'
+
+function probeResult(authMode: 'oauth' | 'token', label: string): DesktopConnectionProbeResult {
+ return {
+ authMode,
+ baseUrl: `https://${label}.example`,
+ reachable: true,
+ error: null,
+ version: null,
+ providers: [{ name: label, displayName: label }]
+ }
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ Reflect.deleteProperty(window, 'hermesDesktop')
+})
+
+describe('remote setup owner', () => {
+ it.each(['first-run', 'settings', 'registry'])(
+ 'rejects stale probe results in %s despite fresh host callbacks',
+ async host => {
+ const replies: ((value: DesktopConnectionProbeResult) => void)[] = []
+
+ const probeConnectionConfig = vi.fn(
+ () =>
+ new Promise(resolve => {
+ replies.push(resolve)
+ })
+ )
+
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { probeConnectionConfig } })
+ const { result, rerender } = renderHook(() => useRemoteSetup({ host, onNotice: () => {} }))
+ act(() => {
+ result.current.setAuthMode('oauth')
+ result.current.setUrl('https://a.example')
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(500)
+ })
+ act(() => result.current.setUrl('https://b.example'))
+ rerender()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(500)
+ })
+ expect(probeConnectionConfig.mock.calls).toEqual([['https://a.example'], ['https://b.example']])
+ await act(async () => {
+ replies[1](probeResult('oauth', 'new'))
+ replies[0](probeResult('token', 'old'))
+ })
+ expect(result.current.payload).toEqual({
+ mode: 'remote',
+ remoteUrl: 'https://b.example',
+ remoteAuthMode: 'oauth',
+ remoteToken: undefined
+ })
+ expect(result.current.providerLabel).toBe('new')
+ }
+ )
+
+ it('does not publish login completion after the editor unmounts without a probe bridge', async () => {
+ const onNotice = vi.fn()
+ let finish!: (value: { connected: boolean }) => void
+
+ const oauthLoginConnectionConfig = vi.fn(
+ () =>
+ new Promise<{ connected: boolean }>(resolve => {
+ finish = resolve
+ })
+ )
+
+ Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { oauthLoginConnectionConfig } })
+ const { result, unmount } = renderHook(() => useRemoteSetup({ host: 'registry', onNotice }))
+ act(() => {
+ result.current.setAuthMode('oauth')
+ result.current.setUrl('https://a.example')
+ })
+ let login!: Promise
+ await act(async () => {
+ login = result.current.signIn()
+ })
+ expect(oauthLoginConnectionConfig).toHaveBeenCalledExactlyOnceWith('https://a.example')
+ unmount()
+ await act(async () => {
+ finish({ connected: true })
+ await login
+ })
+ expect(onNotice).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/components/remote-setup/use-remote-setup.ts b/apps/desktop/src/components/remote-setup/use-remote-setup.ts
new file mode 100644
index 0000000000..f7c2bc6fe9
--- /dev/null
+++ b/apps/desktop/src/components/remote-setup/use-remote-setup.ts
@@ -0,0 +1,371 @@
+import { useEffect, useRef, useState } from 'react'
+
+import type { DesktopConnectionConfigInput, DesktopConnectionProbeResult } from '@/global'
+import { useI18n } from '@/i18n'
+import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
+import { coerceRemoteUrlScheme } from '@/lib/remote-url'
+import type { NotificationInput } from '@/store/notifications'
+
+export type RemoteSetupHost = 'first-run' | 'settings' | 'registry'
+type AuthMode = 'oauth' | 'token'
+type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
+
+export interface RemoteCredentials {
+ url: string
+ authMode: AuthMode
+ token: string
+ tokenSet: boolean
+ tokenPreview: string | null
+ oauthConnected: boolean
+}
+
+export interface RemoteSetupOptions {
+ host: RemoteSetupHost
+ enabled?: boolean
+ beforeOAuthLogin?: (payload: DesktopConnectionConfigInput) => Promise
+ onNotice?: (notice: NotificationInput) => void
+}
+
+export interface RemoteSetup {
+ host: RemoteSetupHost
+ credentials: RemoteCredentials
+ payload: DesktopConnectionConfigInput
+ probeStatus: ProbeStatus
+ authResolved: boolean
+ providerLabel: string
+ isPassword: boolean
+ signingIn: boolean
+ testing: boolean
+ error: string | null
+ success: string | null
+ canTest: boolean
+ canCommit: boolean
+ setUrl: (url: string) => void
+ setToken: (token: string) => void
+ setAuthMode: (mode: AuthMode) => void
+ reset: (saved?: Partial) => void
+ signIn: () => Promise
+ signOut: () => Promise
+ test: () => Promise
+}
+
+function credentialsFrom(saved: Partial = {}): RemoteCredentials {
+ return { url: '', authMode: 'token', token: '', tokenSet: false, tokenPreview: null, oauthConnected: false, ...saved }
+}
+
+/**
+ * Host contracts:
+ * first-run: no pre-save; Apply requires a test of this exact payload.
+ * settings: pre-save through beforeOAuthLogin; credentials permit Save/Apply.
+ * registry: explicit auth selection; storage-only Save may precede credentials.
+ * Persistence and live source changes belong to the host, never this editor.
+ */
+export function useRemoteSetup(options: RemoteSetupOptions): RemoteSetup {
+ const { t } = useI18n()
+ const g = t.settings.gateway
+ const { host, enabled = true } = options
+ const callbacks = useRef(options)
+ callbacks.current = options
+ const [credentials, setCredentials] = useState(credentialsFrom)
+ const [revision, setRevision] = useState(0)
+ const [probe, setProbe] = useState(null)
+ const [probeStatus, setProbeStatus] = useState('idle')
+ const [signingIn, setSigningIn] = useState(false)
+ const [testing, setTesting] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+ const [testedKey, setTestedKey] = useState(null)
+ const targetSeq = useRef(0)
+ const testSeq = useRef(0)
+ const loginSeq = useRef(0)
+ const url = coerceRemoteUrlScheme(credentials.url)
+ const manualAuth = host === 'registry'
+ const probeEnabled = enabled && (!manualAuth || credentials.authMode === 'oauth')
+
+ const payload: DesktopConnectionConfigInput = {
+ mode: 'remote',
+ remoteAuthMode: credentials.authMode,
+ remoteToken: credentials.authMode === 'token' ? credentials.token.trim() || undefined : undefined,
+ remoteUrl: url
+ }
+
+ const payloadKey = JSON.stringify(payload)
+ const currentKey = useRef(payloadKey)
+ currentKey.current = payloadKey
+ const { isPassword, providerLabel } = deriveRemoteAuthProviderShape(probe?.providers, t.boot.failure.identityProvider)
+
+ const authResolved =
+ manualAuth ||
+ (probeStatus === 'done' && probe?.authMode !== 'unknown') ||
+ (host === 'settings' && probeStatus === 'idle' && (credentials.tokenSet || credentials.oauthConnected))
+
+ const credentialReady = Boolean(
+ url &&
+ (credentials.authMode === 'oauth' ? credentials.oauthConnected : credentials.token.trim() || credentials.tokenSet)
+ )
+
+ const canTest = enabled && Boolean(url) && ((authResolved && credentialReady) || probeStatus === 'error')
+
+ const invalidateTest = (): void => {
+ testSeq.current += 1
+ setTesting(false)
+ setTestedKey(null)
+ setError(null)
+ setSuccess(null)
+ }
+
+ const invalidateTarget = (): void => {
+ targetSeq.current += 1
+ loginSeq.current += 1
+ setSigningIn(false)
+ setProbe(null)
+ setProbeStatus('idle')
+ invalidateTest()
+ }
+
+ const reset = (saved?: Partial): void => {
+ invalidateTarget()
+ setCredentials(credentialsFrom(saved))
+ setRevision(value => value + 1)
+ }
+
+ const setUrl = (value: string): void => {
+ invalidateTarget()
+ setCredentials(current => ({ ...current, url: value, oauthConnected: false, tokenSet: false, tokenPreview: null }))
+ setRevision(value => value + 1)
+ }
+
+ const setToken = (token: string): void => {
+ invalidateTest()
+ setCredentials(current => ({ ...current, token }))
+ }
+
+ const setAuthMode = (authMode: AuthMode): void => {
+ invalidateTarget()
+ setCredentials(current => ({ ...current, authMode, oauthConnected: false }))
+ setRevision(value => value + 1)
+ }
+
+ const reportError = (err: unknown, title: string = g.testFailed, kind: 'error' | 'warning' = 'error'): void => {
+ const message = err instanceof Error ? err.message : String(err || g.testFailed)
+ setError(message)
+ callbacks.current.onNotice?.({ kind, title, message })
+ }
+
+ const reportSuccess = (message: string): void => {
+ setSuccess(message)
+ callbacks.current.onNotice?.({ kind: 'success', title: g.reachableTitle, message })
+ }
+
+ const acceptProbe = (result: DesktopConnectionProbeResult): void => {
+ invalidateTest()
+ setProbe(result)
+ setProbeStatus(result.reachable ? 'done' : 'error')
+
+ if (!manualAuth && result.reachable && result.authMode !== 'unknown') {
+ const authMode = result.authMode
+ setCredentials(current => ({
+ ...current,
+ authMode,
+ oauthConnected: authMode === 'oauth' && current.oauthConnected
+ }))
+ }
+ }
+
+ // The effect reads current callbacks without restarting its debounce on each host render.
+ const acceptProbeRef = useRef<(result: DesktopConnectionProbeResult) => void>(acceptProbe)
+ acceptProbeRef.current = acceptProbe
+ // eslint-disable-next-line no-restricted-syntax -- request generations, not a reactive value mirror
+ useEffect(() => {
+ const seq = ++targetSeq.current
+ let timer: number | undefined
+
+ const cancel = (): void => {
+ targetSeq.current += 1
+ window.clearTimeout(timer)
+ }
+
+ setProbe(null)
+ setProbeStatus('idle')
+ setSigningIn(false)
+ setTesting(false)
+ setTestedKey(null)
+ setSuccess(null)
+
+ if (!probeEnabled || !/^https?:\/\//i.test(url) || !window.hermesDesktop?.probeConnectionConfig) {
+ return cancel
+ }
+
+ setProbeStatus('probing')
+ timer = window.setTimeout(() => {
+ void window.hermesDesktop
+ .probeConnectionConfig(url)
+ .then(result => {
+ if (seq === targetSeq.current) {
+ acceptProbeRef.current(result)
+ }
+ })
+ .catch(() => {
+ if (seq === targetSeq.current) {
+ setProbeStatus('error')
+ }
+ })
+ }, 500)
+
+ return cancel
+ }, [probeEnabled, revision, url])
+
+ const signIn = async (): Promise => {
+ if (!url || signingIn) {
+ return
+ }
+
+ const target = targetSeq.current
+ const seq = ++loginSeq.current
+ const current = (): boolean => target === targetSeq.current && seq === loginSeq.current
+ invalidateTest()
+ setSigningIn(true)
+
+ try {
+ await callbacks.current.beforeOAuthLogin?.({ mode: 'remote', remoteAuthMode: 'oauth', remoteUrl: url })
+
+ if (!current()) {
+ return
+ }
+
+ const result = await window.hermesDesktop.oauthLoginConnectionConfig(url)
+
+ if (!current()) {
+ return
+ }
+
+ setCredentials(value => ({ ...value, oauthConnected: Boolean(result.connected) }))
+
+ if (result.connected) {
+ callbacks.current.onNotice?.({ kind: 'success', title: g.signedIn, message: g.connectedTo(providerLabel) })
+ } else {
+ const message = host === 'first-run' ? t.install.signInIncomplete : t.boot.failure.signInIncompleteMessage
+ reportError(
+ result.error ? `${message}: ${result.error}` : message,
+ t.boot.failure.signInIncompleteTitle,
+ 'warning'
+ )
+ }
+ } catch (err) {
+ if (current()) {
+ reportError(err, g.signInFailed)
+ }
+ } finally {
+ if (current()) {
+ setSigningIn(false)
+ }
+ }
+ }
+
+ const signOut = async (): Promise => {
+ const target = targetSeq.current
+ const seq = ++loginSeq.current
+ const current = (): boolean => target === targetSeq.current && seq === loginSeq.current
+ invalidateTest()
+ setSigningIn(true)
+
+ try {
+ await window.hermesDesktop.oauthLogoutConnectionConfig(url)
+
+ if (current()) {
+ setCredentials(value => ({ ...value, oauthConnected: false }))
+ callbacks.current.onNotice?.({ kind: 'success', title: g.signedOutTitle, message: g.signedOutMessage })
+ }
+ } catch (err) {
+ if (current()) {
+ reportError(err, g.signOutFailed)
+ }
+ } finally {
+ if (current()) {
+ setSigningIn(false)
+ }
+ }
+ }
+
+ const test = async (): Promise => {
+ if (!canTest) {
+ return
+ }
+
+ const target = targetSeq.current
+ const seq = ++testSeq.current
+
+ const current = (): boolean =>
+ target === targetSeq.current && seq === testSeq.current && payloadKey === currentKey.current
+
+ setTesting(true)
+ setError(null)
+ setSuccess(null)
+ setTestedKey(null)
+
+ try {
+ if (!authResolved) {
+ const result = await window.hermesDesktop.probeConnectionConfig(url)
+
+ if (current()) {
+ acceptProbeRef.current(result)
+
+ if (!result.reachable || result.authMode === 'unknown') {
+ reportError(result.error || g.probeError)
+ }
+ }
+
+ return
+ }
+
+ const result = await window.hermesDesktop.testConnectionConfig(payload)
+
+ if (!current()) {
+ return
+ }
+
+ if (result.ok === false || result.reachable === false) {
+ throw new Error(result.error || g.testFailed)
+ }
+
+ reportSuccess(
+ (host === 'first-run' ? t.install.testSucceeded : g.connectedTo)(
+ result.baseUrl || url,
+ result.version ?? undefined
+ )
+ )
+ setTestedKey(payloadKey)
+ } catch (err) {
+ if (current()) {
+ reportError(err)
+ }
+ } finally {
+ if (current()) {
+ setTesting(false)
+ }
+ }
+ }
+
+ return {
+ host,
+ credentials,
+ payload,
+ probeStatus,
+ authResolved,
+ providerLabel,
+ isPassword,
+ signingIn,
+ testing,
+ error,
+ success,
+ canTest,
+ canCommit: enabled && (host === 'first-run' ? testedKey === payloadKey : host === 'registry' || credentialReady),
+ setUrl,
+ setToken,
+ setAuthMode,
+ reset,
+ signIn,
+ signOut,
+ test
+ }
+}