From 6ab41598a369067e4b17567dcd9a8589b429c078 Mon Sep 17 00:00:00 2001 From: cat-that's-fat Date: Fri, 17 Jul 2026 10:13:54 -0700 Subject: [PATCH 1/2] chore: fix contributor attribution for desktop PR --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index b37396f6af33..8f1fb1c7e95d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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) From 7dd2b2dc7746621ec152107d3e7d8e2797ef6569 Mon Sep 17 00:00:00 2001 From: cat-that's-fat Date: Fri, 17 Jul 2026 10:13:59 -0700 Subject: [PATCH 2/2] 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 --- apps/desktop/README.md | 15 +- apps/desktop/electron/connection-apply.ts | 7 + .../electron/first-run-setup-gate.test.ts | 133 +++++ apps/desktop/electron/first-run-setup-gate.ts | 146 +++++ .../first-run-setup-main-process.test.ts | 119 +++++ apps/desktop/electron/main.ts | 200 ++++++- apps/desktop/electron/preload.ts | 1 + .../electron/primary-backend-startup.test.ts | 110 ++++ .../electron/primary-backend-startup.ts | 66 +++ .../electron/primary-connection-rehome.ts | 35 ++ .../src/components/boot-failure-reauth.ts | 14 +- .../desktop-install-overlay.test.tsx | 499 ++++++++++++++++++ .../components/desktop-install-overlay.tsx | 142 ++++- .../src/components/first-run-remote-form.tsx | 345 ++++++++++++ apps/desktop/src/global.d.ts | 14 + apps/desktop/src/i18n/en.ts | 34 ++ apps/desktop/src/i18n/ja.ts | 37 ++ apps/desktop/src/i18n/types.ts | 33 ++ apps/desktop/src/i18n/zh-hant.ts | 34 ++ apps/desktop/src/i18n/zh.ts | 34 ++ .../src/lib/desktop-remote-auth.test.ts | 50 ++ apps/desktop/src/lib/desktop-remote-auth.ts | 26 + 22 files changed, 2047 insertions(+), 47 deletions(-) create mode 100644 apps/desktop/electron/first-run-setup-gate.test.ts create mode 100644 apps/desktop/electron/first-run-setup-gate.ts create mode 100644 apps/desktop/electron/first-run-setup-main-process.test.ts create mode 100644 apps/desktop/electron/primary-backend-startup.test.ts create mode 100644 apps/desktop/electron/primary-backend-startup.ts create mode 100644 apps/desktop/electron/primary-connection-rehome.ts create mode 100644 apps/desktop/src/components/desktop-install-overlay.test.tsx create mode 100644 apps/desktop/src/components/first-run-remote-form.tsx create mode 100644 apps/desktop/src/lib/desktop-remote-auth.test.ts create mode 100644 apps/desktop/src/lib/desktop-remote-auth.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a1da176726d1..706611acb1ae 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 diff --git a/apps/desktop/electron/connection-apply.ts b/apps/desktop/electron/connection-apply.ts index 5764b9091e73..579af1a79e97 100644 --- a/apps/desktop/electron/connection-apply.ts +++ b/apps/desktop/electron/connection-apply.ts @@ -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() } diff --git a/apps/desktop/electron/first-run-setup-gate.test.ts b/apps/desktop/electron/first-run-setup-gate.test.ts new file mode 100644 index 000000000000..f8b53525f0fb --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.test.ts @@ -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) { + 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) +}) diff --git a/apps/desktop/electron/first-run-setup-gate.ts b/apps/desktop/electron/first-run-setup-gate.ts new file mode 100644 index 000000000000..2d83cbeafe69 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.ts @@ -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 + resolve: (decision: FirstRunSetupDecision) => void + } | null = null + + let stuckTimer: ReturnType | 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(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 + } +} diff --git a/apps/desktop/electron/first-run-setup-main-process.test.ts b/apps/desktop/electron/first-run-setup-main-process.test.ts new file mode 100644 index 000000000000..1dc1b920ef60 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-main-process.test.ts @@ -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']) +}) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index c448e3ac0318..e83fa416fcbc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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, diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd2..47d854a1938d 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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'), diff --git a/apps/desktop/electron/primary-backend-startup.test.ts b/apps/desktop/electron/primary-backend-startup.test.ts new file mode 100644 index 000000000000..6d3f5c8f44de --- /dev/null +++ b/apps/desktop/electron/primary-backend-startup.test.ts @@ -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 = {}) { + 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) +}) diff --git a/apps/desktop/electron/primary-backend-startup.ts b/apps/desktop/electron/primary-backend-startup.ts new file mode 100644 index 000000000000..edbe5167b059 --- /dev/null +++ b/apps/desktop/electron/primary-backend-startup.ts @@ -0,0 +1,66 @@ +import type { FirstRunSetupDecision } from './first-run-setup-gate' + +export interface PrimaryBackendStartupOptions { + connectRemote: (remote: Remote) => Promise + ensureLocalRuntime: (backend: Backend) => Promise + prepareLocalBackend: () => Backend | Promise + resolveRemote: () => Promise + waitForDecision: (backend: Backend) => Promise + waitForLocalStart: () => Promise +} + +export type PrimaryBackendStartupResult = + | { 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({ + connectRemote, + ensureLocalRuntime, + prepareLocalBackend, + resolveRemote, + waitForDecision, + waitForLocalStart +}: PrimaryBackendStartupOptions): Promise< + PrimaryBackendStartupResult +> { + 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) } +} diff --git a/apps/desktop/electron/primary-connection-rehome.ts b/apps/desktop/electron/primary-connection-rehome.ts new file mode 100644 index 000000000000..4952b8a90281 --- /dev/null +++ b/apps/desktop/electron/primary-connection-rehome.ts @@ -0,0 +1,35 @@ +export interface PrimaryConnectionRehomeOptions { + clearLocalBootstrapFailure: () => void + mode: string + notifyConnectionApplied: () => void + resumeFirstRunRemote: () => boolean + teardownPrimaryBackend: (options: { soft: boolean }) => Promise +} + +// 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 } +} diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts index 680445752132..703f3f745856 100644 --- a/apps/desktop/src/components/boot-failure-reauth.ts +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -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. diff --git a/apps/desktop/src/components/desktop-install-overlay.test.tsx b/apps/desktop/src/components/desktop-install-overlay.test.tsx new file mode 100644 index 000000000000..ce3b9a04548f --- /dev/null +++ b/apps/desktop/src/components/desktop-install-overlay.test.tsx @@ -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 { + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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(resolve => { + resolveProbe = resolve + }) + + desktop.probeConnectionConfig.mockReturnValue(pendingProbe) + + render() + + 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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index b701fa9ff6a1..8a56f93c8c45 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -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 = {} @@ -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(null) const [now, setNow] = useState(() => Date.now()) const logEndRef = useRef(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 setRemoteOpen(false)} /> + } + + if (state.setupChoice) { + return ( +
+
+
+ +
+

{copy.setupChoiceTitle}

+

{copy.setupChoiceDesc}

+
+
+ +
+ + + +
+ + {localStartError ? ( +
+ + {localStartError} +
+ ) : null} + +
+ {copy.installTo}{' '} + {state.setupChoice.activeRoot} +
+
+
+ ) + } + // 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 {copy.installTo} {ups.activeRoot} - +
+ + +
diff --git a/apps/desktop/src/components/first-run-remote-form.tsx b/apps/desktop/src/components/first-run-remote-form.tsx new file mode 100644 index 000000000000..ced31acdd444 --- /dev/null +++ b/apps/desktop/src/components/first-run-remote-form.tsx @@ -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('idle') + const [probe, setProbe] = useState(null) + const [oauthConnected, setOauthConnected] = useState(false) + const [signingIn, setSigningIn] = useState(false) + const [testing, setTesting] = useState(false) + const [applying, setApplying] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [lastTestedPayloadKey, setLastTestedPayloadKey] = useState(null) + const probeSeq = useRef(0) + const testSeq = useRef(0) + + const trimmedUrl = 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 ( +
+
+
+ +
+

{copy.remoteSetupTitle}

+

{copy.remoteSetupDesc}

+
+
+ +
+ + + {probeStatus === 'probing' ? ( +
+ + {copy.probing} +
+ ) : null} + + {probeStatus === 'error' ? ( +
+ + {probe?.error || copy.probeError} +
+ ) : null} + + {authResolved && authMode === 'oauth' ? ( +
+
+
+
{copy.authTitle}
+

+ {oauthConnected ? copy.authSignedIn : copy.authNeedsOauth(providerLabel)} +

+
+ {oauthConnected ? ( +
+ + {copy.connected} +
+ ) : ( + + )} +
+
+ ) : null} + + {authResolved && authMode === 'token' ? ( + + ) : null} + + {error ? ( +
+ + {error} +
+ ) : null} + + {success ? ( +
+ + {success} +
+ ) : null} +
+ +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d9d9af1ee13c..c0c3c7f246fd 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -211,6 +211,7 @@ declare global { onPowerResume?: (callback: () => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise + 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' diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c7397784cef5..1ec9f8f27905 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2054,6 +2054,40 @@ export const en: Translations = { viewDocs: 'View install docs', installTo: 'Will install to', retryAfterRun: 'I’ve 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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index b6ef78614131..9c640512595a 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: '仕上げ中', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 58836148a45d..6629a06c942a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4c07372af168..f94fefc74167 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '正在收尾', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0f2a1ba36295..6a5160591b39 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '正在收尾', diff --git a/apps/desktop/src/lib/desktop-remote-auth.test.ts b/apps/desktop/src/lib/desktop-remote-auth.test.ts new file mode 100644 index 000000000000..734cbae4a2aa --- /dev/null +++ b/apps/desktop/src/lib/desktop-remote-auth.test.ts @@ -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' + }) + }) +}) diff --git a/apps/desktop/src/lib/desktop-remote-auth.ts b/apps/desktop/src/lib/desktop-remote-auth.ts new file mode 100644 index 000000000000..5616c81e658f --- /dev/null +++ b/apps/desktop/src/lib/desktop-remote-auth.ts @@ -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(' / ') + } +}