feat(desktop): add "Connect to existing Hermes" option to first-run onboarding

Adds a first-run Desktop choice between installing Hermes locally and
connecting to an existing remote Hermes gateway. The choice appears after
backend resolution but before ensureRuntime(), so selecting remote cannot
accidentally trigger local bootstrap.

New modules:
- first-run-setup-gate: concurrent first-run decision gate and reset semantics
- primary-backend-startup: Electron-free orchestration seam (saved remote
  resolution, gate decision, remote re-resolution, local continuation)
- primary-connection-rehome: prevents dual-owner race where both cold boot()
  and renderer softSwitch() could connect simultaneously
- first-run-remote-form: extracted remote form with stale-result guards

Reuses existing connection-config IPC, encrypted token storage, OAuth
session partition, and primary backend resolution.

Fixes #38602
Fixes #36970
This commit is contained in:
cat-that's-fat 2026-07-17 10:13:59 -07:00 committed by Brooklyn Nicholson
parent 6ab41598a3
commit 7dd2b2dc77
22 changed files with 2047 additions and 47 deletions

View file

@ -30,7 +30,7 @@ Already have the Hermes CLI? Just run:
hermes desktop
```
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.
### Prebuilt installers
@ -134,6 +134,19 @@ Desktop supports a managed local backend, explicit remote gateways, and Hermes
Cloud connections. Remote and cloud modes use the same remote-capability path;
authentication and discovery differ, not the renderer feature model.
When no usable local runtime or saved remote connection exists, the first-run
screen offers **Connect to existing Hermes** before starting the local installer.
Desktop probes the gateway to discover token or OAuth authentication, requires a
successful HTTP and WebSocket connection test, and saves the connection using
the same encrypted Desktop configuration used by Settings. A saved remote
connection bypasses this choice on later launches. The regular Desktop build
still includes the local-install option; this is a remote operating mode, not a
separate client-only application.
In remote mode the gateway host is the execution boundary: agent tools,
terminal commands, and file operations run against the remote Hermes host, not
the computer displaying the Desktop UI.
Projects are the workspace abstraction. A project may own multiple folders,
repositories, worktrees, and sessions; a bare new chat remains detached unless
the user enters a project or configures a default project directory. Use the

View file

@ -1,6 +1,7 @@
async function applyConnectionChange({
cancelAndWait,
isPrimary,
rehomePrimary = null,
scope,
sendApplied,
stopPool,
@ -16,6 +17,12 @@ async function applyConnectionChange({
return
}
if (rehomePrimary) {
await rehomePrimary()
return
}
await teardownPrimary()
sendApplied()
}

View file

@ -0,0 +1,133 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function settledState(promise: Promise<unknown>) {
return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')])
}
test('first-run setup gate skips non-bootstrap backends', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
await gate.wait({ kind: 'remote' })
await gate.wait(null)
assert.deepEqual(prompts, [])
assert.equal(gate.hasWaiter(), false)
})
test('first-run setup gate prompts once for concurrent waits', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const first = gate.wait(bootstrapBackend)
const second = gate.wait(bootstrapBackend)
assert.equal(gate.hasWaiter(), true)
assert.equal(prompts.length, 1)
assert.equal(await settledState(first), 'pending')
gate.continueLocal()
assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local'])
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
assert.equal(await pending, 'continue-local')
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})
test('retry reset preserves the local install confirmation', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
gate.resetForRetry()
await gate.wait(bootstrapBackend)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
assert.equal(prompts.length, 1)
assert.equal(gate.hasWaiter(), false)
})
test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.resetForRetry()
assert.equal(await pending, 'reset')
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
})
test('repair reset clears the local install confirmation and shows the gate again', async () => {
const prompts = []
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
gate.resetForRepair()
const next = gate.wait(bootstrapBackend)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(prompts.length, 2)
assert.equal(gate.hasWaiter(), true)
gate.continueLocal()
await next
})
test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
const resumedWaiter = gate.abandonForRemoteApply()
assert.equal(resumedWaiter, true)
assert.equal(hidden, 1)
assert.equal(gate.hasWaiter(), false)
assert.equal(gate.isLocalBootstrapConfirmed(), false)
assert.equal(await pending, 'remote-applied')
})
test('remote apply without a waiter has no first-run side effects', async () => {
let hidden = 0
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
const pending = gate.wait(bootstrapBackend)
gate.continueLocal()
await pending
assert.equal(gate.abandonForRemoteApply(), false)
assert.equal(hidden, 0)
assert.equal(gate.isLocalBootstrapConfirmed(), true)
})

View file

@ -0,0 +1,146 @@
interface FirstRunSetupBackend {
activeRoot?: string
kind?: string
platform?: string
}
interface FirstRunSetupGateOptions {
hideChoice?: () => void
log?: (message: string) => void
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
promptChoice?: (backend: FirstRunSetupBackend) => void
stuckAfterMs?: number
}
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
export function createFirstRunSetupGate({
hideChoice,
log,
onStuck,
promptChoice,
stuckAfterMs = 120000
}: FirstRunSetupGateOptions = {}) {
let localBootstrapConfirmed = false
let waiter: {
promise: Promise<FirstRunSetupDecision>
resolve: (decision: FirstRunSetupDecision) => void
} | null = null
let stuckTimer: ReturnType<typeof setTimeout> | null = null
const clearStuckTimer = () => {
if (stuckTimer) {
clearTimeout(stuckTimer)
stuckTimer = null
}
}
const armStuckTimer = (backend: FirstRunSetupBackend) => {
clearStuckTimer()
if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') {
return
}
stuckTimer = setTimeout(() => {
onStuck?.(backend, stuckAfterMs)
log(
`[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` +
`(platform=${backend?.platform || 'unknown'})`
)
}, stuckAfterMs)
if (typeof stuckTimer.unref === 'function') {
stuckTimer.unref()
}
}
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
const wait = async (backend?: FirstRunSetupBackend | null) => {
if (!shouldGate(backend)) {
return 'continue-local' as const
}
if (waiter) {
return waiter.promise
}
promptChoice?.(backend)
armStuckTimer(backend)
let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {}
const promise = new Promise<FirstRunSetupDecision>(resolve => {
resolveWaiter = resolve
})
waiter = { promise, resolve: resolveWaiter }
return promise
}
const settleWaiter = (decision: FirstRunSetupDecision) => {
clearStuckTimer()
if (!waiter) {
return false
}
const activeWaiter = waiter
waiter = null
activeWaiter.resolve(decision)
return true
}
const continueLocal = () => {
localBootstrapConfirmed = true
settleWaiter('continue-local')
}
const resetForRetry = () => {
// Reset paths are followed by a renderer reload / fresh startHermes() call.
// Settle the old boot explicitly so it cannot fall through into local
// bootstrap and cannot leak a forever-pending connection promise.
settleWaiter('reset')
}
const resetForRepair = () => {
resetForRetry()
localBootstrapConfirmed = false
}
const abandonForRemoteApply = () => {
// Resume the gated startHermes() with an explicit remote decision. The
// caller re-resolves the newly-persisted remote config instead of falling
// through into local bootstrap or leaking the original connection promise.
const resumedWaiter = settleWaiter('remote-applied')
if (!resumedWaiter) {
return false
}
localBootstrapConfirmed = false
hideChoice?.()
return true
}
const isLocalBootstrapConfirmed = () => localBootstrapConfirmed
const hasWaiter = () => Boolean(waiter)
return {
abandonForRemoteApply,
continueLocal,
hasWaiter,
isLocalBootstrapConfirmed,
resetForRepair,
resetForRetry,
shouldGate,
wait
}
}

View file

@ -0,0 +1,119 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { applyConnectionChange } from './connection-apply'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
test('a first-run bootstrap-needed remote apply connects without ensuring or bootstrapping locally', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
const candidateRemote = {
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
source: 'settings',
token: 'secret',
wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret'
}
let savedRemote: typeof candidateRemote | null = null
const resolveRemote = vi.fn(async () => savedRemote)
const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const }))
const runBootstrap = vi.fn()
const ensureLocalRuntime = vi.fn(async backend => {
await runBootstrap()
return { ...backend, command: 'hermes' }
})
const teardownPrimaryBackend = vi.fn(async () => {})
const cancelSshBootstrap = vi.fn(async () => {})
const teardownSsh = vi.fn(async () => {})
const clearLocalBootstrapFailure = vi.fn()
const notifyConnectionApplied = vi.fn()
const waitForLocalStart = vi.fn(async () => {})
const prepareLocalBackend = vi.fn(async () => bootstrapBackend)
const pendingConnection = runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision: gate.wait,
waitForLocalStart
})
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
// Mirrors the IPC handler's production ordering: persist the tested config,
// then re-home. The pending start must re-resolve this saved value.
savedRemote = candidateRemote
await applyConnectionChange({
cancelAndWait: cancelSshBootstrap,
isPrimary: true,
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: gate.abandonForRemoteApply,
teardownPrimaryBackend
}),
scope: '',
sendApplied: notifyConnectionApplied,
stopPool: vi.fn(),
teardownPrimary: teardownPrimaryBackend,
teardownSsh
})
assert.deepEqual(await pendingConnection, {
kind: 'remote',
connection: { ...candidateRemote, mode: 'remote' }
})
assert.deepEqual(resolveRemote.mock.calls, [[], []])
assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]])
assert.deepEqual(waitForLocalStart.mock.calls, [[]])
assert.deepEqual(prepareLocalBackend.mock.calls, [[]])
assert.equal(ensureLocalRuntime.mock.calls.length, 0)
assert.equal(runBootstrap.mock.calls.length, 0)
assert.deepEqual(cancelSshBootstrap.mock.calls, [['']])
assert.deepEqual(teardownSsh.mock.calls, [['']])
assert.equal(teardownPrimaryBackend.mock.calls.length, 0)
assert.equal(clearLocalBootstrapFailure.mock.calls.length, 1)
assert.equal(notifyConnectionApplied.mock.calls.length, 0)
})
test('a primary apply without an active first-run gate tears down before reconnect notification', async () => {
const order: string[] = []
const clearLocalBootstrapFailure = vi.fn(() => order.push('clear-failure'))
const teardownPrimaryBackend = vi.fn(async () => {
order.push('teardown')
})
const notifyConnectionApplied = vi.fn(() => order.push('notify'))
assert.deepEqual(
await rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode: 'remote',
notifyConnectionApplied,
resumeFirstRunRemote: () => false,
teardownPrimaryBackend
}),
{ resumedFirstRunRemote: false }
)
assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]])
assert.deepEqual(order, ['clear-failure', 'teardown', 'notify'])
})

View file

@ -79,6 +79,7 @@ import {
import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { findGitBash as _findGitBash } from './find-git-bash'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { scanGitRepos } from './git-repo-scan'
@ -128,6 +129,8 @@ import {
import { runNativeLogin } from './native-oauth-login'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { createKeepAwake } from './power-save'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
import { rehomePrimaryConnection } from './primary-connection-rehome'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
import * as remoteLifecycle from './remote-lifecycle'
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
@ -1399,13 +1402,17 @@ let bootstrapState = {
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
let firstRunSetupGate = null
function broadcastBootstrapEvent(ev) {
if (ev.type === 'manifest') {
bootstrapState.manifest = ev
bootstrapState.active = true
bootstrapState.setupChoice = null
bootstrapState.startedAt = bootstrapState.startedAt || Date.now()
bootstrapState.stages = {}
@ -1433,14 +1440,30 @@ function broadcastBootstrapEvent(ev) {
} else if (ev.type === 'failed') {
bootstrapState.active = false
bootstrapState.error = ev.error || 'unknown error'
bootstrapState.setupChoice = null
} else if (ev.type === 'unsupported-platform') {
bootstrapState.active = false
bootstrapState.setupChoice = null
bootstrapState.unsupportedPlatform = {
platform: ev.platform,
activeRoot: ev.activeRoot,
installCommand: ev.installCommand,
docsUrl: ev.docsUrl
}
} else if (ev.type === 'setup-choice') {
bootstrapState.active = false
bootstrapState.error = null
bootstrapState.manifest = null
bootstrapState.stages = {}
bootstrapState.setupChoice = ev.active
? {
platform: ev.platform,
activeRoot: ev.activeRoot
}
: null
bootstrapState.unsupportedPlatform = null
} else if (ev.type === 'dismissed') {
resetBootstrapSnapshot()
}
if (!mainWindow || mainWindow.isDestroyed()) {
@ -1460,6 +1483,100 @@ function getBootstrapState() {
return bootstrapState
}
function resetBootstrapSnapshot() {
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
}
function promptFirstRunSetupChoice(backend) {
broadcastBootstrapEvent({
type: 'setup-choice',
active: true,
platform: backend.platform || process.platform,
activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT
})
}
function hideFirstRunSetupChoice() {
if (bootstrapState.setupChoice) {
broadcastBootstrapEvent({ type: 'setup-choice', active: false })
}
}
function getFirstRunSetupGate() {
if (!firstRunSetupGate) {
firstRunSetupGate = createFirstRunSetupGate({
hideChoice: hideFirstRunSetupChoice,
log: rememberLog,
onStuck: (_backend, stuckAfterMs) => {
updateBootProgress(
{
error: null,
message: `Still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)} seconds`,
phase: 'bootstrap.choice',
progress: 12,
running: true
},
{ allowDecrease: true }
)
},
promptChoice: promptFirstRunSetupChoice
})
}
return firstRunSetupGate
}
async function waitForFirstRunSetupChoice(backend) {
const gate = getFirstRunSetupGate()
if (!gate.shouldGate(backend)) {
return 'continue-local'
}
updateBootProgress(
{
error: null,
message: 'Waiting for first-run setup choice',
phase: 'bootstrap.choice',
progress: 12,
running: true
},
{ allowDecrease: true }
)
return gate.wait(backend)
}
function continueFirstRunLocalBootstrap() {
getFirstRunSetupGate().continueLocal()
}
function abandonFirstRunSetupChoiceForRemoteApply() {
const gate = getFirstRunSetupGate()
if (!gate.hasWaiter()) {
return false
}
const resumedGatedConnection = gate.abandonForRemoteApply()
if (resumedGatedConnection) {
broadcastBootstrapEvent({ type: 'dismissed' })
}
return resumedGatedConnection
}
function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) {
const nextProgressRaw =
typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress
@ -7767,14 +7884,7 @@ async function startHermes() {
let attemptedRemote = primaryBackendIsRemote()
const connectionPromise = (async () => {
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
// Re-read once resolved so the classification tracks the value actually used.
attemptedRemote = primaryBackendIsRemote()
const remote = await resolveRemoteBackend(primaryProfileKey())
if (remote) {
const connectRemote = async remote => {
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
await waitForHermes(remote.baseUrl, remote.token)
updateBootProgress({
@ -7800,14 +7910,9 @@ async function startHermes() {
}
}
// Mutual exclusion with an in-app update (#50238). If this instance was
// relaunched while the Tauri updater is still applying an update, spawning
// a local backend now re-locks the venv shim and gets killed by the
// updater's straggler cleanup — looping. Park until the update finishes (or
// is detected stale), THEN start the backend. Local backends only; remote
// connections returned above and never touch the install tree.
await waitForUpdateToFinish()
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
// Resolve for the desktop's primary profile so a per-profile remote
// override on the active profile is honored (falls back to env / global).
const token = crypto.randomBytes(32).toString('base64url')
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0']
@ -7822,8 +7927,32 @@ async function startHermes() {
backendArgs.unshift('--profile', activeProfile)
}
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
const setup = await runPrimaryBackendStartup({
connectRemote,
ensureLocalRuntime: ensureRuntime,
prepareLocalBackend: async () => {
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
return resolveHermesBackend(backendArgs)
},
resolveRemote: () => {
// Classify immediately before each throwing resolve. This callback runs
// both for an already-saved remote and after first-run remote Apply.
attemptedRemote = primaryBackendIsRemote()
return resolveRemoteBackend(primaryProfileKey())
},
waitForDecision: waitForFirstRunSetupChoice,
// Mutual exclusion with an in-app update (#50238). Remote connections
// return before this waiter; local starts park until the updater exits.
waitForLocalStart: waitForUpdateToFinish
})
if (setup.kind === 'remote') {
return setup.connection
}
const backend = setup.backend
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
backend.args = getBackendArgsForRuntime(backend)
const hermesCwd = resolveHermesCwd()
@ -7979,6 +8108,10 @@ async function startHermes() {
throw error
}
if (error instanceof FirstRunSetupResetError) {
throw error
}
const message = error instanceof Error ? error.message : String(error)
// Only latch LOCAL boot failures. A remote failure (lapsed session / mint
@ -8793,16 +8926,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
await teardownPrimaryBackendAndWait()
bootstrapFailure = null
backendStartFailure = null
bootstrapState = {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
unsupportedPlatform: null
}
getFirstRunSetupGate().resetForRetry()
resetBootstrapSnapshot()
return { ok: true }
})
@ -8823,10 +8948,17 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
bootstrapFailure = null
backendStartFailure = null
getFirstRunSetupGate().resetForRepair()
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:continue-local', async () => {
rememberLog('[bootstrap] local install selected by renderer; continuing first-launch bootstrap')
continueFirstRunLocalBootstrap()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:cancel', async () => {
// Renderer's Cancel button during first-launch install. Abort the running
// install script (SIGTERM via the runner's abortSignal). runBootstrap
@ -9005,6 +9137,18 @@ ipcMain.handle('hermes:connection-config:apply', async (_event, payload) => {
await applyConnectionChange({
cancelAndWait: value => sshBootstrapCoordinator.cancelAndWait(value),
isPrimary: !key || key === primaryProfileKey(),
rehomePrimary: () =>
rehomePrimaryConnection({
clearLocalBootstrapFailure: () => {
// A remote connection bypasses local runtime/bootstrap failures. Clear
// the local-install latch so unsupported/failure escape paths can re-home.
bootstrapFailure = null
},
mode: config.mode,
notifyConnectionApplied: sendConnectionApplied,
resumeFirstRunRemote: abandonFirstRunSetupChoiceForRemoteApply,
teardownPrimaryBackend: teardownPrimaryBackendAndWait
}),
scope,
sendApplied: sendConnectionApplied,
stopPool: stopPoolBackend,

View file

@ -237,6 +237,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// current snapshot via getBootstrapState() to recover after a devtools
// reload mid-bootstrap.
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),

View file

@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup'
const bootstrapBackend = {
activeRoot: '/tmp/hermes-home/hermes-agent',
kind: 'bootstrap-needed',
platform: 'linux'
}
function startupOptions(overrides: Record<string, unknown> = {}) {
return {
connectRemote: vi.fn(async remote => ({ baseUrl: remote.baseUrl, mode: 'remote' as const })),
ensureLocalRuntime: vi.fn(async backend => ({ ...backend, command: 'hermes' })),
prepareLocalBackend: vi.fn(async () => bootstrapBackend),
resolveRemote: vi.fn(async () => null),
waitForDecision: vi.fn(async () => 'continue-local' as const),
waitForLocalStart: vi.fn(async () => {}),
...overrides
}
}
test('remote apply re-resolves the saved connection without ensuring a local runtime', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }
let configuredRemote: typeof savedRemote | null = null
const options = startupOptions({
resolveRemote: vi.fn(async () => configuredRemote),
waitForDecision: gate.wait
})
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
configuredRemote = savedRemote
assert.equal(gate.abandonForRemoteApply(), true)
assert.deepEqual(await pending, {
kind: 'remote',
connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' }
})
assert.deepEqual(options.resolveRemote.mock.calls, [[], []])
assert.deepEqual(options.connectRemote.mock.calls, [[savedRemote]])
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('an already-saved remote bypasses every local startup step', async () => {
const savedRemote = { baseUrl: 'https://gateway.example.com/hermes' }
const options = startupOptions({ resolveRemote: vi.fn(async () => savedRemote) })
assert.deepEqual(await runPrimaryBackendStartup(options), {
kind: 'remote',
connection: { baseUrl: savedRemote.baseUrl, mode: 'remote' }
})
assert.equal(options.waitForLocalStart.mock.calls.length, 0)
assert.equal(options.prepareLocalBackend.mock.calls.length, 0)
assert.equal(options.waitForDecision.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('remote apply fails clearly when no saved remote can be resolved', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.abandonForRemoteApply()
await assert.rejects(pending, /without a saved remote backend/)
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})
test('continue local waits for update exclusion and ensures the prepared runtime exactly once', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const runtimeBackend = { ...bootstrapBackend, command: 'hermes' }
const options = startupOptions({
ensureLocalRuntime: vi.fn(async () => runtimeBackend),
waitForDecision: gate.wait
})
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.continueLocal()
assert.deepEqual(await pending, { kind: 'local', backend: runtimeBackend })
assert.deepEqual(options.waitForLocalStart.mock.calls, [[]])
assert.deepEqual(options.prepareLocalBackend.mock.calls, [[]])
assert.deepEqual(options.ensureLocalRuntime.mock.calls, [[bootstrapBackend]])
assert.deepEqual(options.resolveRemote.mock.calls, [[]])
})
test('reset rejects with a typed error and never enters either backend', async () => {
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
const options = startupOptions({ waitForDecision: gate.wait })
const pending = runPrimaryBackendStartup(options)
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
gate.resetForRetry()
await assert.rejects(pending, error => error instanceof FirstRunSetupResetError && error.firstRunSetupReset)
assert.equal(options.connectRemote.mock.calls.length, 0)
assert.equal(options.ensureLocalRuntime.mock.calls.length, 0)
})

View file

@ -0,0 +1,66 @@
import type { FirstRunSetupDecision } from './first-run-setup-gate'
export interface PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection> {
connectRemote: (remote: Remote) => Promise<Connection>
ensureLocalRuntime: (backend: Backend) => Promise<RuntimeBackend>
prepareLocalBackend: () => Backend | Promise<Backend>
resolveRemote: () => Promise<Remote | null>
waitForDecision: (backend: Backend) => Promise<FirstRunSetupDecision>
waitForLocalStart: () => Promise<unknown>
}
export type PrimaryBackendStartupResult<RuntimeBackend, Connection> =
| { kind: 'local'; backend: RuntimeBackend }
| { kind: 'remote'; connection: Connection }
export class FirstRunSetupResetError extends Error {
readonly firstRunSetupReset = true
constructor() {
super('First-run setup was reset before a choice completed.')
this.name = 'FirstRunSetupResetError'
}
}
// Owns the production startHermes path up to the local process spawn. Keeping
// the full ordering here makes the first-run remote boundary executable in a
// test: an already-saved remote wins immediately; otherwise update exclusion
// and local backend resolution happen before the setup gate, and a remote Apply
// re-resolves persisted config without ever entering ensureRuntime/bootstrap.
export async function runPrimaryBackendStartup<Backend, RuntimeBackend, Remote, Connection>({
connectRemote,
ensureLocalRuntime,
prepareLocalBackend,
resolveRemote,
waitForDecision,
waitForLocalStart
}: PrimaryBackendStartupOptions<Backend, RuntimeBackend, Remote, Connection>): Promise<
PrimaryBackendStartupResult<RuntimeBackend, Connection>
> {
const savedRemote = await resolveRemote()
if (savedRemote) {
return { kind: 'remote', connection: await connectRemote(savedRemote) }
}
await waitForLocalStart()
const backend = await prepareLocalBackend()
const decision = await waitForDecision(backend)
if (decision === 'remote-applied') {
const appliedRemote = await resolveRemote()
if (!appliedRemote) {
throw new Error('First-run remote setup completed without a saved remote backend.')
}
return { kind: 'remote', connection: await connectRemote(appliedRemote) }
}
if (decision === 'reset') {
throw new FirstRunSetupResetError()
}
return { kind: 'local', backend: await ensureLocalRuntime(backend) }
}

View file

@ -0,0 +1,35 @@
export interface PrimaryConnectionRehomeOptions {
clearLocalBootstrapFailure: () => void
mode: string
notifyConnectionApplied: () => void
resumeFirstRunRemote: () => boolean
teardownPrimaryBackend: (options: { soft: boolean }) => Promise<void>
}
// Production seam shared by the connection-config IPC handler and the
// first-run integration test. A remote apply that resumes the active setup
// gate must keep that connection attempt alive; ordinary mode changes tear the
// current backend down before the renderer is told to reconnect.
export async function rehomePrimaryConnection({
clearLocalBootstrapFailure,
mode,
notifyConnectionApplied,
resumeFirstRunRemote,
teardownPrimaryBackend
}: PrimaryConnectionRehomeOptions): Promise<{ resumedFirstRunRemote: boolean }> {
let resumedFirstRunRemote = false
if (mode === 'remote') {
resumedFirstRunRemote = resumeFirstRunRemote()
clearLocalBootstrapFailure()
}
if (resumedFirstRunRemote) {
return { resumedFirstRunRemote: true }
}
await teardownPrimaryBackend({ soft: true })
notifyConnectionApplied()
return { resumedFirstRunRemote: false }
}

View file

@ -1,4 +1,5 @@
import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global'
import { deriveRemoteAuthProviderShape } from '@/lib/desktop-remote-auth'
// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out
// of the .tsx so they can be unit-tested without a React/jsdom render (the
@ -136,18 +137,7 @@ export function deriveProviderShape(providers: DesktopAuthProvider[] | null | un
isPassword: boolean
providerLabel: string
} {
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: 'your identity provider' }
}
const isPassword = list.every(p => Boolean(p.supportsPassword))
const providerLabel =
list.length === 1 ? list[0].displayName || list[0].name : list.map(p => p.displayName || p.name).join(' / ')
return { isPassword, providerLabel }
return deriveRemoteAuthProviderShape(providers)
}
// Button copy for the remote sign-in action.

View file

@ -0,0 +1,499 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopBootstrapEvent, DesktopBootstrapState, DesktopConnectionProbeResult } from '@/global'
import { DesktopInstallOverlay } from './desktop-install-overlay'
function bootstrapState(overrides: Partial<DesktopBootstrapState> = {}): DesktopBootstrapState {
return {
active: false,
manifest: null,
stages: {},
error: null,
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null,
...overrides
}
}
function installDesktopMock(state: DesktopBootstrapState) {
const bootstrapListeners = new Set<(event: DesktopBootstrapEvent) => void>()
const desktop = {
getBootstrapState: vi.fn().mockResolvedValue(state),
onBootstrapEvent: vi.fn((listener: (event: DesktopBootstrapEvent) => void) => {
bootstrapListeners.add(listener)
return () => bootstrapListeners.delete(listener)
}),
continueBootstrapLocal: vi.fn().mockResolvedValue({ ok: true }),
probeConnectionConfig: vi.fn(),
testConnectionConfig: vi.fn(),
applyConnectionConfig: vi.fn(),
oauthLoginConnectionConfig: vi.fn(),
openExternal: vi.fn(),
emitBootstrapEvent: (event: DesktopBootstrapEvent) => {
for (const listener of bootstrapListeners) {
listener(event)
}
}
}
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: desktop
})
return desktop
}
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
Reflect.deleteProperty(window, 'hermesDesktop')
})
describe('DesktopInstallOverlay first-run setup', () => {
it('shows the remote/local choice without installer progress', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Connect to existing Hermes')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
expect(screen.queryByText(/steps complete/i)).toBeNull()
expect(screen.queryByText(/Fetching installer manifest/i)).toBeNull()
})
it('continues local bootstrap only when Install Hermes locally is selected', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Install Hermes locally'))
expect(desktop.continueBootstrapLocal).toHaveBeenCalledTimes(1)
expect(screen.getByText('Set up Hermes Desktop')).toBeTruthy()
act(() => {
desktop.emitBootstrapEvent({ type: 'manifest', protocolVersion: 1, stages: [] })
})
await waitFor(() => expect(screen.queryByText('Set up Hermes Desktop')).toBeNull())
expect(screen.getByText(/Fetching installer manifest/i)).toBeTruthy()
})
it('surfaces a recoverable error when the local-bootstrap bridge is unavailable', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'win32', activeRoot: 'C:\\Users\\me\\AppData\\Local\\hermes\\hermes-agent' }
})
)
desktop.continueBootstrapLocal = undefined as never
render(<DesktopInstallOverlay />)
const install = (await screen.findByText('Install Hermes locally')).closest('button') as HTMLButtonElement
fireEvent.click(install)
expect(
await screen.findByText('Local installation could not start. Restart Hermes Desktop and try again.')
).toBeTruthy()
expect(install.disabled).toBe(false)
})
it('opens the remote connection form from the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
expect(screen.getByText('Test connection')).toBeTruthy()
expect(screen.getByText('Apply and reconnect')).toBeTruthy()
})
it('returns from the remote connection form to the first-run choice', async () => {
installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
fireEvent.click(screen.getByText('Back'))
expect(await screen.findByText('Set up Hermes Desktop')).toBeTruthy()
expect(screen.getByText('Install Hermes locally')).toBeTruthy()
})
it('requires a successful token connection test before applying remote config', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockImplementation(async () => {
desktop.emitBootstrapEvent({ type: 'dismissed' })
return { mode: 'remote' }
})
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
expect(apply.disabled).toBe(true)
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => {
expect(desktop.testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'token',
remoteToken: 'session-secret',
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
expect(apply.disabled).toBe(false)
fireEvent.click(screen.getByText('Apply and reconnect'))
await waitFor(() => {
expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'token',
remoteToken: 'session-secret',
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull())
})
it('ignores a completed probe after the gateway URL becomes invalid', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
let resolveProbe: ((result: DesktopConnectionProbeResult) => void) | undefined
const pendingProbe = new Promise<DesktopConnectionProbeResult>(resolve => {
resolveProbe = resolve
})
desktop.probeConnectionConfig.mockReturnValue(pendingProbe)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
const urlInput = await screen.findByPlaceholderText('https://gateway.example.com/hermes')
fireEvent.change(urlInput, { target: { value: 'https://gateway.example.com/hermes' } })
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
expect(desktop.probeConnectionConfig).toHaveBeenCalledTimes(1)
fireEvent.change(urlInput, { target: { value: 'not-a-url' } })
await act(async () => {
resolveProbe?.({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
await pendingProbe
})
expect(screen.queryByPlaceholderText('Paste session token')).toBeNull()
expect((screen.getByText('Test connection').closest('button') as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement).disabled).toBe(true)
})
it('does not enable Apply when credentials change during a connection test', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
let resolveTest: ((result: { baseUrl: string; ok: boolean; version: string }) => void) | undefined
const pendingTest = new Promise<{ baseUrl: string; ok: boolean; version: string }>(resolve => {
resolveTest = resolve
})
desktop.testConnectionConfig.mockReturnValue(pendingTest)
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
const tokenInput = await screen.findByPlaceholderText('Paste session token')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
fireEvent.change(tokenInput, { target: { value: 'token-a' } })
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => expect(desktop.testConnectionConfig).toHaveBeenCalledTimes(1))
fireEvent.change(tokenInput, { target: { value: 'token-b' } })
await act(async () => {
resolveTest?.({ baseUrl: 'https://gateway.example.com/hermes', ok: true, version: '0.17.0' })
await pendingTest
})
expect(screen.queryByText('Connected to https://gateway.example.com/hermes (0.17.0).')).toBeNull()
expect(apply.disabled).toBe(true)
})
it('restores remote apply controls when applying the tested connection fails', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockRejectedValue(new Error('remote apply failed'))
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
fireEvent.click(apply)
expect(await screen.findByText('remote apply failed')).toBeTruthy()
expect(apply.disabled).toBe(false)
expect(screen.getByText('Gateway URL')).toBeTruthy()
})
it('signs in, tests, and applies a password-style remote gateway', async () => {
const desktop = installDesktopMock(
bootstrapState({
setupChoice: { platform: 'linux', activeRoot: '/home/me/.hermes/hermes-agent' }
})
)
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'oauth',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [{ displayName: 'Username & Password', name: 'password', supportsPassword: true }],
reachable: true,
version: '0.17.0'
})
desktop.oauthLoginConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
connected: true,
ok: true
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: null
})
desktop.applyConnectionConfig.mockResolvedValue({ mode: 'remote' })
render(<DesktopInstallOverlay />)
fireEvent.click(await screen.findByText('Connect to existing Hermes'))
fireEvent.change(await screen.findByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
expect(screen.queryByText('Sign in with Username & Password')).toBeNull()
fireEvent.click(await screen.findByText('Sign in'))
await waitFor(() => {
expect(desktop.oauthLoginConnectionConfig).toHaveBeenCalledWith('https://gateway.example.com/hermes')
})
fireEvent.click(screen.getByText('Test connection'))
await waitFor(() => {
expect(desktop.testConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteToken: undefined,
remoteUrl: 'https://gateway.example.com/hermes'
})
})
await screen.findByText('Connected to https://gateway.example.com/hermes.')
const apply = screen.getByText('Apply and reconnect').closest('button') as HTMLButtonElement
expect(apply.disabled).toBe(false)
fireEvent.click(apply)
await waitFor(() => {
expect(desktop.applyConnectionConfig).toHaveBeenCalledWith({
mode: 'remote',
remoteAuthMode: 'oauth',
remoteToken: undefined,
remoteUrl: 'https://gateway.example.com/hermes'
})
})
})
it('offers remote connection from the unsupported packaged install screen', async () => {
const desktop = installDesktopMock(
bootstrapState({
unsupportedPlatform: {
platform: 'darwin',
activeRoot: '/Users/me/.hermes/hermes-agent',
installCommand: 'curl -fsSL https://example.invalid/install.sh | sh',
docsUrl: 'https://example.invalid/docs'
}
})
)
render(<DesktopInstallOverlay />)
expect(await screen.findByText('Hermes needs a one-time install')).toBeTruthy()
fireEvent.click(screen.getByText('Connect existing'))
expect(await screen.findByText('Gateway URL')).toBeTruthy()
desktop.probeConnectionConfig.mockResolvedValue({
authMode: 'token',
baseUrl: 'https://gateway.example.com/hermes',
error: null,
providers: [],
reachable: true,
version: '0.17.0'
})
desktop.testConnectionConfig.mockResolvedValue({
baseUrl: 'https://gateway.example.com/hermes',
ok: true,
version: '0.17.0'
})
desktop.applyConnectionConfig.mockImplementation(async () => {
desktop.emitBootstrapEvent({ type: 'dismissed' })
return { mode: 'remote' }
})
fireEvent.change(screen.getByPlaceholderText('https://gateway.example.com/hermes'), {
target: { value: 'https://gateway.example.com/hermes' }
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 550))
})
fireEvent.change(await screen.findByPlaceholderText('Paste session token'), {
target: { value: 'session-secret' }
})
fireEvent.click(screen.getByText('Test connection'))
await screen.findByText('Connected to https://gateway.example.com/hermes (0.17.0).')
fireEvent.click(screen.getByText('Apply and reconnect'))
await waitFor(() => expect(screen.queryByText('Gateway URL')).toBeNull())
expect(screen.queryByText('Hermes needs a one-time install')).toBeNull()
})
})

View file

@ -15,10 +15,12 @@ import type {
DesktopBootstrapState
} from '@/global'
import { useI18n } from '@/i18n'
import { ChevronDown, ChevronRight, iconSize } from '@/lib/icons'
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { FirstRunRemoteForm } from './first-run-remote-form'
/**
* DesktopInstallOverlay
*
@ -155,6 +157,10 @@ function StageRow({ descriptor, result, now }: StageRowProps) {
)
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err || 'Unknown error')
}
const EMPTY_STATE: DesktopBootstrapState = {
active: false,
manifest: null,
@ -163,10 +169,32 @@ const EMPTY_STATE: DesktopBootstrapState = {
log: [],
startedAt: null,
completedAt: null,
setupChoice: null,
unsupportedPlatform: null
}
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
if (ev.type === 'dismissed') {
return { ...EMPTY_STATE }
}
if (ev.type === 'setup-choice') {
return {
...state,
active: false,
manifest: null,
stages: {},
error: null,
setupChoice: ev.active
? {
platform: ev.platform || state.setupChoice?.platform || 'unknown',
activeRoot: ev.activeRoot || state.setupChoice?.activeRoot || ''
}
: null,
unsupportedPlatform: null
}
}
if (ev.type === 'manifest') {
const stages: Record<string, DesktopBootstrapStageResult> = {}
@ -180,6 +208,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
manifest: { type: 'manifest', stages: ev.stages, protocolVersion: ev.protocolVersion },
stages,
error: null,
setupChoice: null,
startedAt: state.startedAt || Date.now()
}
}
@ -219,13 +248,14 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
}
if (ev.type === 'failed') {
return { ...state, active: false, error: ev.error || 'unknown error' }
return { ...state, active: false, error: ev.error || 'unknown error', setupChoice: null }
}
if (ev.type === 'unsupported-platform') {
return {
...state,
active: false,
setupChoice: null,
unsupportedPlatform: {
platform: ev.platform,
activeRoot: ev.activeRoot,
@ -246,6 +276,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [remoteOpen, setRemoteOpen] = useState(false)
const [localStarting, setLocalStarting] = useState(false)
const [localStartError, setLocalStartError] = useState<string | null>(null)
const [now, setNow] = useState(() => Date.now())
const logEndRef = useRef<HTMLDivElement | null>(null)
@ -312,6 +345,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
}
}, [state.error])
// The choice remains mounted while main hands off to local bootstrap. Once
// a manifest/failure takes ownership (or a later repair presents a fresh
// choice), clear the transient button state so it cannot leak across phases.
useEffect(() => {
setLocalStarting(false)
setLocalStartError(null)
}, [state.setupChoice?.activeRoot])
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
// or actively running with a manifest. Hide entirely after a successful
// completion so the rest of the UI can take over.
@ -332,13 +373,96 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return true
}
if (state.setupChoice) {
return true
}
return false
}, [enabled, state.active, state.error, state.unsupportedPlatform])
}, [enabled, state.active, state.error, state.setupChoice, state.unsupportedPlatform])
if (!shouldShow) {
return null
}
if (remoteOpen) {
return <FirstRunRemoteForm onBack={() => setRemoteOpen(false)} />
}
if (state.setupChoice) {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.setupChoiceTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.setupChoiceDesc}</p>
</div>
</div>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover)"
onClick={() => setRemoteOpen(true)}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
<Globe className="size-4 text-muted-foreground" />
<span>{copy.connectExistingTitle}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.connectExistingDesc}</p>
</button>
<button
className="rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-4 text-left transition hover:bg-(--chrome-action-hover) disabled:cursor-wait disabled:opacity-60"
disabled={localStarting}
onClick={async () => {
setLocalStarting(true)
setLocalStartError(null)
try {
const desktop = window.hermesDesktop
if (!desktop || typeof desktop.continueBootstrapLocal !== 'function') {
throw new Error(copy.localStartUnavailable)
}
await desktop.continueBootstrapLocal()
} catch (err) {
setLocalStartError(errorMessage(err))
setLocalStarting(false)
}
}}
type="button"
>
<div className="flex items-center gap-2 text-sm font-medium">
{localStarting ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : (
<Monitor className="size-4 text-muted-foreground" />
)}
<span>{copy.installLocalTitle}</span>
</div>
<p className="mt-2 text-sm leading-5 text-muted-foreground">{copy.installLocalDesc}</p>
</button>
</div>
{localStartError ? (
<div className="mt-4 flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{localStartError}</span>
</div>
) : null}
<div className="mt-6 text-xs text-muted-foreground">
{copy.installTo}{' '}
<code className="font-mono text-(--ui-text-secondary)">{state.setupChoice.activeRoot}</code>
</div>
</div>
</div>
)
}
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
// there's no Hermes Agent installed yet and we can't drive install.sh
// (no stage protocol equivalent yet). Show a copy-paste install command
@ -384,9 +508,15 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
{copy.installTo} <code className="font-mono text-(--ui-text-secondary)">{ups.activeRoot}</code>
</span>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
{copy.retryAfterRun}
</Button>
<div className="flex items-center gap-2">
<Button onClick={() => setRemoteOpen(true)} size="sm" variant="secondary">
<Globe className="size-4" />
{copy.connectExistingShort}
</Button>
<Button onClick={() => window.location.reload()} size="sm" variant="default">
{copy.retryAfterRun}
</Button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,345 @@
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'
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<ProbeStatus>('idle')
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(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<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState<string | null>(null)
const probeSeq = useRef(0)
const testSeq = useRef(0)
const trimmedUrl = remoteUrl.trim()
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(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 (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
<div className="min-w-0">
<h2 className="text-xl font-semibold tracking-tight">{copy.remoteSetupTitle}</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{copy.remoteSetupDesc}</p>
</div>
</div>
<div className="mt-6 grid gap-4">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{copy.remoteUrlTitle}</span>
<Input
autoComplete="url"
disabled={applying}
onChange={event => {
invalidateTest()
setRemoteUrl(event.target.value)
}}
placeholder={copy.remoteUrlPlaceholder}
value={remoteUrl}
/>
<span className="text-xs text-muted-foreground">{copy.remoteUrlDesc}</span>
</label>
{probeStatus === 'probing' ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{copy.probing}
</div>
) : null}
{probeStatus === 'error' ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{probe?.error || copy.probeError}</span>
</div>
) : null}
{authResolved && authMode === 'oauth' ? (
<div className="rounded-md border border-(--ui-stroke-tertiary) p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{copy.authTitle}</div>
<p className="mt-1 text-xs text-muted-foreground">
{oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)}
</p>
</div>
{oauthConnected ? (
<div className="flex items-center gap-1.5 text-sm text-primary">
<Check className="size-4" />
{copy.connected}
</div>
) : (
<Button disabled={signingIn || applying} onClick={() => void signIn()} size="sm">
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? copy.signIn : copy.signInWith(providerLabel)}
</Button>
)}
</div>
</div>
) : null}
{authResolved && authMode === 'token' ? (
<label className="grid gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{copy.tokenTitle}</span>
<Input
autoComplete="off"
disabled={applying}
onChange={event => {
invalidateTest()
setRemoteToken(event.target.value)
}}
placeholder={copy.pasteSessionToken}
type="password"
value={remoteToken}
/>
<span className="text-xs text-muted-foreground">{copy.tokenDesc}</span>
</label>
) : null}
{error ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
{success ? (
<div className="flex items-center gap-2 text-sm text-primary">
<Check className="size-4" />
<span>{success}</span>
</div>
) : null}
</div>
<div className="mt-7 flex flex-wrap items-center justify-between gap-3">
<Button disabled={applying} onClick={onBack} size="sm" variant="ghost">
{copy.backToSetup}
</Button>
<div className="flex items-center gap-2">
<Button
disabled={testing || applying || !canTest}
onClick={() => void testRemote()}
size="sm"
variant="secondary"
>
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.testConnection}
</Button>
<Button disabled={applying || !canApply} onClick={() => void applyRemote()} size="sm">
{applying ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.applyRemote}
</Button>
</div>
</div>
</div>
</div>
)
}

View file

@ -211,6 +211,7 @@ declare global {
onPowerResume?: (callback: () => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
continueBootstrapLocal: () => Promise<{ ok: boolean }>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
@ -627,6 +628,11 @@ export interface DesktopBootstrapUnsupportedPlatform {
docsUrl: string
}
export interface DesktopBootstrapSetupChoice {
platform: string
activeRoot: string
}
export interface DesktopBootstrapState {
active: boolean
manifest: { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null } | null
@ -635,10 +641,18 @@ export interface DesktopBootstrapState {
log: Array<{ ts: number; stage: string | null; line: string; stream?: 'stdout' | 'stderr' }>
startedAt: number | null
completedAt: number | null
setupChoice: DesktopBootstrapSetupChoice | null
unsupportedPlatform: DesktopBootstrapUnsupportedPlatform | null
}
export type DesktopBootstrapEvent =
| { type: 'dismissed' }
| {
type: 'setup-choice'
active: boolean
platform?: string
activeRoot?: string
}
| { type: 'manifest'; stages: DesktopBootstrapStageDescriptor[]; protocolVersion: number | null }
| {
type: 'stage'

View file

@ -2054,6 +2054,40 @@ export const en: Translations = {
viewDocs: 'View install docs',
installTo: 'Will install to',
retryAfterRun: 'Ive run it -- retry',
setupChoiceTitle: 'Set up Hermes Desktop',
setupChoiceDesc:
'Connect this app to a Hermes gateway you already run, or install Hermes locally on this computer.',
connectExistingTitle: 'Connect to existing Hermes',
connectExistingShort: 'Connect existing',
connectExistingDesc: 'Use a remote backend with a session token or browser sign-in. No local install will start.',
installLocalTitle: 'Install Hermes locally',
installLocalDesc: 'Download Hermes, create its Python environment, and run the backend on this computer.',
localStartUnavailable: 'Local installation could not start. Restart Hermes Desktop and try again.',
remoteSetupTitle: 'Connect to existing Hermes',
remoteSetupDesc: 'Enter your gateway URL. Hermes Desktop will detect whether it needs a token or browser sign-in.',
remoteUrlTitle: 'Gateway URL',
remoteUrlDesc: 'Use the base URL of the Hermes gateway, including https:// when remote.',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: 'Detecting gateway authentication...',
probeError: 'Could not reach that Hermes gateway.',
identityProvider: 'your identity provider',
authTitle: 'Authentication',
authNeedsOauth: provider => `Sign in with ${provider} before testing this gateway.`,
authSignedIn: 'Browser sign-in completed.',
connected: 'Connected',
signIn: 'Sign in',
signInWith: provider => `Sign in with ${provider}`,
enterUrlFirst: 'Enter a gateway URL first.',
signInIncomplete: 'The sign-in window closed before authentication completed.',
tokenTitle: 'Session token',
tokenDesc: 'Paste the session token from the remote gateway .env file.',
pasteSessionToken: 'Paste session token',
incompleteSignInTest: 'Sign in before testing this OAuth-gated gateway.',
incompleteTokenTest: 'Enter a session token before testing this gateway.',
testConnection: 'Test connection',
testSucceeded: (baseUrl, version) => `Connected to ${baseUrl}${version ? ` (${version})` : ''}.`,
applyRemote: 'Apply and reconnect',
backToSetup: 'Back',
failedTitle: 'Installation failed',
settingUpTitle: 'Setting up Hermes Agent',
finishingTitle: 'Finishing up',

View file

@ -1989,6 +1989,43 @@ export const ja = defineLocale({
viewDocs: 'インストールドキュメントを見る',
installTo: 'インストール先',
retryAfterRun: '実行しました — 再試行',
setupChoiceTitle: 'Hermes Desktop をセットアップ',
setupChoiceDesc:
'すでに実行している Hermes ゲートウェイに接続するか、このコンピューターに Hermes をローカルインストールします。',
connectExistingTitle: '既存の Hermes に接続',
connectExistingShort: '既存環境に接続',
connectExistingDesc:
'セッショントークンまたはブラウザーサインインでリモートバックエンドを使用します。ローカルインストールは開始されません。',
installLocalTitle: 'Hermes をローカルにインストール',
installLocalDesc: 'Hermes をダウンロードし、Python 環境を作成して、このコンピューターでバックエンドを実行します。',
localStartUnavailable:
'ローカルインストールを開始できません。Hermes Desktop を再起動して、もう一度お試しください。',
remoteSetupTitle: '既存の Hermes に接続',
remoteSetupDesc:
'ゲートウェイ URL を入力してください。Hermes Desktop がトークンとブラウザーサインインのどちらが必要かを検出します。',
remoteUrlTitle: 'ゲートウェイ URL',
remoteUrlDesc: 'Hermes ゲートウェイのベース URL を使用します。リモートの場合は https:// を含めてください。',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: 'ゲートウェイ認証方式を検出中...',
probeError: 'その Hermes ゲートウェイに到達できませんでした。',
identityProvider: 'ID プロバイダー',
authTitle: '認証',
authNeedsOauth: provider => `このゲートウェイをテストする前に ${provider} でサインインしてください。`,
authSignedIn: 'ブラウザーサインインが完了しました。',
connected: '接続済み',
signIn: 'サインイン',
signInWith: provider => `${provider} でサインイン`,
enterUrlFirst: '先にゲートウェイ URL を入力してください。',
signInIncomplete: '認証が完了する前にサインインウィンドウが閉じられました。',
tokenTitle: 'セッショントークン',
tokenDesc: 'リモートゲートウェイの .env ファイルからセッショントークンを貼り付けます。',
pasteSessionToken: 'セッショントークンを貼り付け',
incompleteSignInTest: 'OAuth で保護されたこのゲートウェイをテストする前にサインインしてください。',
incompleteTokenTest: 'このゲートウェイをテストする前にセッショントークンを入力してください。',
testConnection: '接続をテスト',
testSucceeded: (baseUrl, version) => `${baseUrl}${version ? ` (${version})` : ''} に接続しました。`,
applyRemote: '適用して再接続',
backToSetup: '戻る',
failedTitle: 'インストールに失敗しました',
settingUpTitle: 'Hermes Agent を設定中',
finishingTitle: '仕上げ中',

View file

@ -1695,6 +1695,39 @@ export interface Translations {
viewDocs: string
installTo: string
retryAfterRun: string
setupChoiceTitle: string
setupChoiceDesc: string
connectExistingTitle: string
connectExistingShort: string
connectExistingDesc: string
installLocalTitle: string
installLocalDesc: string
localStartUnavailable: string
remoteSetupTitle: string
remoteSetupDesc: string
remoteUrlTitle: string
remoteUrlDesc: string
remoteUrlPlaceholder: string
probing: string
probeError: string
identityProvider: string
authTitle: string
authNeedsOauth: (provider: string) => string
authSignedIn: string
connected: string
signIn: string
signInWith: (provider: string) => string
enterUrlFirst: string
signInIncomplete: string
tokenTitle: string
tokenDesc: string
pasteSessionToken: string
incompleteSignInTest: string
incompleteTokenTest: string
testConnection: string
testSucceeded: (baseUrl: string, version?: string) => string
applyRemote: string
backToSetup: string
failedTitle: string
settingUpTitle: string
finishingTitle: string

View file

@ -1930,6 +1930,40 @@ export const zhHant = defineLocale({
viewDocs: '檢視安裝文件',
installTo: '將安裝至',
retryAfterRun: '我已執行 -- 重試',
setupChoiceTitle: '設定 Hermes Desktop',
setupChoiceDesc:
'將此應用程式連線到您已執行的 Hermes 閘道,或在這台電腦上本機安裝 Hermes。',
connectExistingTitle: '連線到現有 Hermes',
connectExistingShort: '連線現有環境',
connectExistingDesc: '使用工作階段權杖或瀏覽器登入連線遠端後端。不會啟動本機安裝。',
installLocalTitle: '本機安裝 Hermes',
installLocalDesc: '下載 Hermes、建立 Python 環境,並在這台電腦上執行後端。',
localStartUnavailable: '無法啟動本機安裝。請重新啟動 Hermes Desktop 後再試一次。',
remoteSetupTitle: '連線到現有 Hermes',
remoteSetupDesc: '輸入閘道 URL。Hermes Desktop 會偵測需要權杖還是瀏覽器登入。',
remoteUrlTitle: '閘道 URL',
remoteUrlDesc: '使用 Hermes 閘道的基礎 URL遠端位址請包含 https://。',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: '正在偵測閘道驗證方式...',
probeError: '無法連線到該 Hermes 閘道。',
identityProvider: '您的身分提供者',
authTitle: '驗證',
authNeedsOauth: provider => `測試此閘道前請先使用 ${provider} 登入。`,
authSignedIn: '瀏覽器登入已完成。',
connected: '已連線',
signIn: '登入',
signInWith: provider => `使用 ${provider} 登入`,
enterUrlFirst: '請先輸入閘道 URL。',
signInIncomplete: '驗證完成前登入視窗已關閉。',
tokenTitle: '工作階段權杖',
tokenDesc: '貼上遠端閘道 .env 檔案中的工作階段權杖。',
pasteSessionToken: '貼上工作階段權杖',
incompleteSignInTest: '測試此受 OAuth 保護的閘道前請先登入。',
incompleteTokenTest: '測試此閘道前請輸入工作階段權杖。',
testConnection: '測試連線',
testSucceeded: (baseUrl, version) => `已連線到 ${baseUrl}${version ? ` (${version})` : ''}`,
applyRemote: '套用並重新連線',
backToSetup: '返回',
failedTitle: '安裝失敗',
settingUpTitle: '正在設定 Hermes Agent',
finishingTitle: '正在收尾',

View file

@ -2241,6 +2241,40 @@ export const zh: Translations = {
viewDocs: '查看安装文档',
installTo: '将安装到',
retryAfterRun: '我已运行 -- 重试',
setupChoiceTitle: '设置 Hermes Desktop',
setupChoiceDesc:
'将此应用连接到你已运行的 Hermes 网关,或在这台电脑上本地安装 Hermes。',
connectExistingTitle: '连接到现有 Hermes',
connectExistingShort: '连接现有环境',
connectExistingDesc: '使用会话令牌或浏览器登录连接远程后端。不会启动本地安装。',
installLocalTitle: '本地安装 Hermes',
installLocalDesc: '下载 Hermes创建 Python 环境,并在这台电脑上运行后端。',
localStartUnavailable: '无法启动本地安装。请重启 Hermes Desktop 后重试。',
remoteSetupTitle: '连接到现有 Hermes',
remoteSetupDesc: '输入网关 URL。Hermes Desktop 会检测需要令牌还是浏览器登录。',
remoteUrlTitle: '网关 URL',
remoteUrlDesc: '使用 Hermes 网关的基础 URL远程地址请包含 https://。',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: '正在检测网关认证方式...',
probeError: '无法连接到该 Hermes 网关。',
identityProvider: '你的身份提供方',
authTitle: '认证',
authNeedsOauth: provider => `测试此网关前请先使用 ${provider} 登录。`,
authSignedIn: '浏览器登录已完成。',
connected: '已连接',
signIn: '登录',
signInWith: provider => `使用 ${provider} 登录`,
enterUrlFirst: '请先输入网关 URL。',
signInIncomplete: '认证完成前登录窗口已关闭。',
tokenTitle: '会话令牌',
tokenDesc: '粘贴远程网关 .env 文件中的会话令牌。',
pasteSessionToken: '粘贴会话令牌',
incompleteSignInTest: '测试此受 OAuth 保护的网关前请先登录。',
incompleteTokenTest: '测试此网关前请输入会话令牌。',
testConnection: '测试连接',
testSucceeded: (baseUrl, version) => `已连接到 ${baseUrl}${version ? ` (${version})` : ''}`,
applyRemote: '应用并重新连接',
backToSetup: '返回',
failedTitle: '安装失败',
settingUpTitle: '正在设置 Hermes Agent',
finishingTitle: '正在收尾',

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { deriveRemoteAuthProviderShape } from './desktop-remote-auth'
describe('deriveRemoteAuthProviderShape', () => {
it('uses fallback copy when the gateway has not reported providers', () => {
expect(deriveRemoteAuthProviderShape(null)).toEqual({
isPassword: false,
providerLabel: 'your identity provider'
})
expect(deriveRemoteAuthProviderShape([], 'the configured gateway')).toEqual({
isPassword: false,
providerLabel: 'the configured gateway'
})
})
it('marks providers as password-style only when every provider supports password login', () => {
expect(
deriveRemoteAuthProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }])
).toEqual({
isPassword: true,
providerLabel: 'Username & Password'
})
})
it('keeps OAuth copy for redirect providers and mixed deployments', () => {
expect(deriveRemoteAuthProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }]))
.toEqual({
isPassword: false,
providerLabel: 'Nous Research'
})
expect(
deriveRemoteAuthProviderShape([
{ name: 'basic', displayName: 'Username & Password', supportsPassword: true },
{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }
])
).toEqual({
isPassword: false,
providerLabel: 'Username & Password / Nous Research'
})
})
it('falls back to provider names when display names are missing', () => {
expect(deriveRemoteAuthProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }])).toEqual({
isPassword: true,
providerLabel: 'basic'
})
})
})

View file

@ -0,0 +1,26 @@
import type { DesktopAuthProvider } from '@/global'
export interface RemoteAuthProviderShape {
isPassword: boolean
providerLabel: string
}
function providerDisplayName(provider: DesktopAuthProvider): string {
return provider.displayName || provider.name
}
export function deriveRemoteAuthProviderShape(
providers: DesktopAuthProvider[] | null | undefined,
fallback = 'your identity provider'
): RemoteAuthProviderShape {
const list = providers ?? []
if (list.length === 0) {
return { isPassword: false, providerLabel: fallback }
}
return {
isPassword: list.every(provider => Boolean(provider.supportsPassword)),
providerLabel: list.map(providerDisplayName).join(' / ')
}
}