Merge pull request #70907 from NousResearch/bb/desktop-first-run-remote

feat(desktop): add "Connect to existing Hermes" option to first-run onboarding
This commit is contained in:
brooklyn! 2026-07-24 13:06:21 -05:00 committed by GitHub
commit fb8d824bcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2049 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

@ -2121,6 +2121,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

@ -1753,6 +1753,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

@ -2307,6 +2307,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(' / ')
}
}

View file

@ -44,6 +44,8 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
# This dict is kept only so existing history keeps resolving; the effective
# AUTHOR_MAP below merges it with the directory (directory wins).
LEGACY_AUTHOR_MAP = {
"declanbatesmith@outlook.com": "cat-thats-fat", # PR #60489 (desktop: first-run remote connection option)
"drbs2004@me.com": "cat-thats-fat", # PR #60489 (desktop: first-run remote connection option; historical merge email)
"122438640+ragingbulld@users.noreply.github.com": "ragingbulld", # PR #65606 salvage (non-finite API wait deadlines; #65746)
"zzpigpinggai@users.noreply.github.com": "zzpigpinggai", # PR #66017 salvage of #63617 (OpenRouter explicit-provider picker visibility)
"stellarisw@users.noreply.github.com": "StellarisW", # PR #66222 salvage (Discord WebSocket liveness + systemd watchdog; #26656 follow-up)