mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
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
119 lines
4 KiB
TypeScript
119 lines
4 KiB
TypeScript
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'])
|
|
})
|