diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index 736a673851..d0b67a39ef 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -24,7 +24,9 @@ import { migrateV1ToRegistry, normalizeConnectionInput, normalizeRegistry, + parseRemoteProfileListing, REGISTRY_VERSION, + rememberSshEnumeration, removeConnection, resolveRegistryLocalRoute, setPrimaryConnection, @@ -204,6 +206,36 @@ test('roster: unique profiles keep bare handles; duplicates get @name-device', ( assert.equal(roster.length, 4) }) +test('rememberSshEnumeration: live list wins, cache then seed default', () => { + assert.deepEqual(rememberSshEnumeration({ profiles: ['bob', 'kai'] }, ['stale'], 'ssh'), { + profiles: ['bob', 'kai'] + }) + assert.deepEqual( + rememberSshEnumeration({ profiles: null, error: 'connect-on-demand' }, ['bob', 'kai', 'rook'], 'ssh'), + { profiles: ['bob', 'kai', 'rook'], error: 'connect-on-demand' } + ) + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'connect-on-demand' }, null, 'ssh'), { + profiles: ['default'], + error: 'connect-on-demand' + }) + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'connect-on-demand' }, null, 'remote'), { + profiles: null, + error: 'connect-on-demand' + }) +}) + +test('parseRemoteProfileListing: Mini/Spark dirs become roster names and drop rollbacks', () => { + const listed = parseRemoteProfileListing( + ['bob', 'dixie', 'goose', 'rambo', 'bob.rollback-old', '.hidden', '', 'not a name'].join('\n') + ) + + assert.deepEqual(listed, ['default', 'bob', 'dixie', 'goose', 'rambo']) +}) + +test('parseRemoteProfileListing: empty listing is still the default agent', () => { + assert.deepEqual(parseRemoteProfileListing(''), ['default']) +}) + test('roster: unreachable sources contribute no rows and cannot fake duplicates', () => { const local = { id: 'local', kind: 'local' as const, label: 'This device' } const dead = { id: 'dead', kind: 'remote' as const, label: 'Dead box', url: 'http://d:1' } diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index 2781631b44..4da82c9384 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -254,6 +254,60 @@ export interface RosterAgent { handle: string } +/** + * SSH roster enumeration skips undialed sources (connect-on-demand). Reuse the + * last successful profile list so Bot Mode does not go empty the moment the + * window switches back to local. Never-seen SSH sources still get a `default` + * seed so the device is clickable. + */ +export function rememberSshEnumeration( + enumeration: Pick, + cached: null | string[] | undefined, + kind: ConnectionKind +): Pick { + if (enumeration.profiles && enumeration.profiles.length > 0) { + return enumeration + } + + if (kind !== 'ssh') { + return enumeration + } + + if (cached && cached.length > 0) { + return { profiles: cached, error: enumeration.error } + } + + if (enumeration.error === 'connect-on-demand') { + return { profiles: ['default'], error: 'connect-on-demand' } + } + + return enumeration +} + +const PROFILE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ + +/** Turn `ls ~/.hermes/profiles` output into roster names. Always includes + * `default`. Drops rollback snapshots and junk lines. */ +export function parseRemoteProfileListing(text: string): string[] { + const names = new Set(['default']) + + for (const raw of String(text || '').split(/\r?\n/)) { + const name = raw.trim() + + if (!name || name.startsWith('.') || name.endsWith('.rollback-old')) { + continue + } + + if (!PROFILE_NAME_RE.test(name)) { + continue + } + + names.add(name) + } + + return ['default', ...[...names].filter(name => name !== 'default').sort()] +} + /** * Flatten per-connection profile enumerations into the union roster, applying * the duplicate-handle rule ONCE across all sources. Pure so the disambiguation diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8dcc1ef4cb..3afe067eb1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -101,6 +101,7 @@ import { migrateV1ToRegistry, normalizeConnectionInput, normalizeRegistry, + rememberSshEnumeration, removeConnection, resolveRegistryLocalRoute, setPrimaryConnection, @@ -12409,7 +12410,7 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => { // The ssh probe path in testDesktopConnectionConfig never consults v1 // connection state, so mapping the entry onto it is safe. if (entry.kind === 'ssh') { - return testDesktopConnectionConfig({ + const result = await testDesktopConnectionConfig({ mode: 'ssh', sshHost: entry.host, sshUser: entry.user, @@ -12417,6 +12418,14 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => { sshKeyPath: entry.keyPath, sshRemoteHermesPath: entry.remoteHermesPath }) + + if (result?.reachable) { + sshInventoryAttempted.delete(entry.id) + sshRosterCache.delete(entry.id) + await probeSshProfileInventory(entry) + } + + return result } // Remote/cloud/local probe built DIRECTLY from the registry entry. Routing @@ -12480,54 +12489,113 @@ ipcMain.handle('hermes:connections:test', async (_event, id) => { // instead of failing the whole roster. ssh sources that have never been dialed // are SKIPPED (connect-on-demand — dialing every ssh box just to list agents // would spawn tunnels the user never asked for); once dialed, their pooled -// descriptor serves the enumeration like any remote. +// descriptor serves the enumeration like any remote. Last-known SSH profile +// lists are reused so switching the window back to local does not empty Bot Mode. +const sshRosterCache = new Map() +const sshInventoryAttempted = new Set() + +async function probeSshProfileInventory(connection) { + if (sshRosterCache.has(connection.id) || sshInventoryAttempted.has(connection.id)) { + return + } + + sshInventoryAttempted.add(connection.id) + + const sshConfig = normalizeSshConfig({ + mode: 'ssh', + host: connection.host, + user: connection.user, + port: connection.port, + keyPath: connection.keyPath, + remoteHermesPath: connection.remoteHermesPath + }) + + if (!sshConfig) { + return + } + + const ssh = createSshProbeConnection( + { host: sshConfig.host, user: sshConfig.user, port: sshConfig.port, keyPath: sshConfig.keyPath }, + { rememberLog: sshRememberLog } + ) + + try { + await ssh.open() + const profiles = await remoteLifecycle.listRemoteHermesProfiles(ssh) + + if (profiles.length > 0) { + sshRosterCache.set(connection.id, profiles) + } + } catch (error: any) { + sshRememberLog(`[ssh] profile inventory failed for ${connection.id}: ${error?.message || error}`) + } finally { + try { + await ssh.close() + } catch { + void 0 + } + } +} + async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRegistry()) { return Promise.all( registry.connections.map(async connection => { + let raw: { connection: typeof connection; error?: string; profiles: null | string[] } + try { if ( connection.kind === 'ssh' && ![...sshConnections.keys()].some(scope => String(scope).startsWith(backendScopePrefix(connection.id))) ) { - return { connection, profiles: null, error: 'connect-on-demand' } - } + await probeSshProfileInventory(connection) + raw = { connection, profiles: null, error: 'connect-on-demand' } + } else { + // Same connect-on-demand courtesy for the forced-local path: when + // the primary route is remote, enumerating "This device" would + // SPAWN a local backend this user has never asked for — a phantom + // `default` agent that also forces -device handle disambiguation + // onto the real one (remote-gateway-only desktops showed their main + // agent twice, Aug 17 2026). Enumerate the local source only when + // it is the delegate route (local-primary desktops, unchanged + // behavior) or a forced-local child is ALREADY pooled (the user + // opened one). + if (connection.kind === 'local') { + const localRoute = resolveRegistryLocalRoute('default', { + globalRemote: globalRemoteActive(), + profileRemoteOverride: Boolean(profileHasRemoteOverride(primaryProfileKey())) + }) - // Same connect-on-demand courtesy for the forced-local path: when the - // primary route is remote, enumerating "This device" would SPAWN a - // local backend this user has never asked for — a phantom `default` - // agent that also forces -device handle disambiguation onto the real - // one (remote-gateway-only desktops showed their main agent twice, - // Aug 17 2026). Enumerate the local source only when it is the - // delegate route (local-primary desktops, unchanged behavior) or a - // forced-local child is ALREADY pooled (the user opened one). - if (connection.kind === 'local') { - const localRoute = resolveRegistryLocalRoute('default', { - globalRemote: globalRemoteActive(), - profileRemoteOverride: Boolean(profileHasRemoteOverride(primaryProfileKey())) - }) - - if (shouldDeferLocalEnumeration(localRoute, backendPool.keys(), connection.id)) { - return { connection, profiles: null, error: 'connect-on-demand' } + if (shouldDeferLocalEnumeration(localRoute, backendPool.keys(), connection.id)) { + return { connection, profiles: null, error: 'connect-on-demand' } + } } + + const descriptor: any = await ensureRegistryBackend(connection.id, null) + const body: any = await getJsonForBackend(descriptor, '/api/profiles', { timeoutMs: 8_000 }) + + const profiles = Array.isArray(body?.profiles) + ? body.profiles.map(p => String(p?.name || '').trim()).filter(Boolean) + : [] + + // The root HERMES_HOME is an agent too; enumerations that omit it + // (older backends list only named profiles) still get a default row. + if (!profiles.includes('default')) { + profiles.unshift('default') + } + + raw = { connection, profiles } } - const descriptor: any = await ensureRegistryBackend(connection.id, null) - const body: any = await getJsonForBackend(descriptor, '/api/profiles', { timeoutMs: 8_000 }) - - const profiles = Array.isArray(body?.profiles) - ? body.profiles.map(p => String(p?.name || '').trim()).filter(Boolean) - : [] - - // The root HERMES_HOME is an agent too; enumerations that omit it - // (older backends list only named profiles) still get a default row. - if (!profiles.includes('default')) { - profiles.unshift('default') - } - - return { connection, profiles } } catch (error: any) { - return { connection, profiles: null, error: String(error?.message || error) } + raw = { connection, profiles: null, error: String(error?.message || error) } } + + if (raw.profiles && raw.profiles.length > 0) { + sshRosterCache.set(connection.id, raw.profiles) + } + + const remembered = rememberSshEnumeration(raw, sshRosterCache.get(connection.id), connection.kind) + return { connection, ...remembered } }) ) } diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts index 5781b4de98..d942ae782f 100644 --- a/apps/desktop/electron/remote-lifecycle.test.ts +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -10,6 +10,7 @@ import { expandRemotePath, fingerprintToken, isForwardBindCollision, + listRemoteHermesProfiles, locateHermes, LOCKFILE_SCHEMA_VERSION, lockfilePath, @@ -79,6 +80,32 @@ function fakeSsh(rules: any[] = []) { } } +test('listRemoteHermesProfiles inventories Mini-style profile dirs without spawning a dashboard', async () => { + const ssh = fakeSsh([ + [/HERMES_HOME/, '/Users/zillajr/.hermes\n'], + [/ls -1/, 'bob\ndixie\ngoose\nrambo\nbob.rollback-old\n'] + ]) + + assert.deepEqual(await listRemoteHermesProfiles(ssh), ['default', 'bob', 'dixie', 'goose', 'rambo']) + assert.equal( + ssh.calls.some(cmd => cmd.includes('serve') || cmd.includes('dashboard')), + false + ) +}) + +test('listRemoteHermesProfiles rejects a hostile HERMES_HOME', async () => { + const ssh = fakeSsh([[/HERMES_HOME/, '/tmp/x; echo pwned\n']]) + + await assert.rejects(() => listRemoteHermesProfiles(ssh), (err: any) => { + assert.equal(err.kind, 'unsafe-path') + return true + }) + assert.equal( + ssh.calls.some(cmd => cmd.includes('ls -1')), + false + ) +}) + test('locateHermes prefers the explicit profile path when executable', async () => { const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']]) assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes') diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 9be878bfd5..56d3bc47d6 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -27,6 +27,8 @@ import crypto from 'node:crypto' +import { parseRemoteProfileListing } from './connection-registry' + const LOCKFILE_SCHEMA_VERSION = 2 // Bumped when the desktop<->dashboard reuse contract changes in a way that makes // an old running dashboard unsafe to reattach to (token handling, readiness/spawn @@ -254,6 +256,35 @@ async function probeRemoteHermesHome(ssh) { } } +async function listRemoteHermesProfiles(ssh) { + const home = assertSafeRemoteHome(await probeRemoteHermesHome(ssh)) + const dir = expandRemotePath(`${home}/profiles`) + let listing = '' + + try { + listing = await ssh.exec(`if [ -d ${dir} ]; then ls -1 ${dir}; fi`) + } catch (cause) { + const error: any = new Error('Could not list remote Hermes profiles.') + error.kind = 'transient-transport-error' + error.cause = cause + throw error + } + + return parseRemoteProfileListing(listing) +} + +function assertSafeRemoteHome(home) { + const value = String(home || '').trim() + + if (!/^(\/|~\/)[A-Za-z0-9._/+-]+$/.test(value) || value.includes('..')) { + const error: any = new Error('Unsafe remote Hermes home.') + error.kind = 'unsafe-path' + throw error + } + + return value.replace(/\/+$/, '') +} + async function readLockfile(ssh, ownershipId) { const lpath = lockfilePath(ownershipId) let raw @@ -882,6 +913,7 @@ export { locateHermes, LOCKFILE_SCHEMA_VERSION, lockfilePath, + listRemoteHermesProfiles, mintToken, openForward, ownershipDirectory, diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 93ec3481ac..823c0bb2f9 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -2211,7 +2211,7 @@ function useRoster() { if (typeof host.agents === 'function') { try { const union = await host.agents() - return mergeMultiSourceRoster(local, union, activeConnectionId) + return mergeMultiSourceRoster(local, union, activeConnectionId, $lastRoster.get()) } catch { /* older build or roster failure — single-source list stands */ } @@ -2237,10 +2237,17 @@ function useRoster() { * Rows from other sources become new roster entries tagged with their * source label so BotRow can badge them and route open/warm through * ensureAgent/warmAgent. Pure — exercised directly by the tests. */ -function mergeMultiSourceRoster(local, union, activeConnectionId = null) { +function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) { const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] const agents = Array.isArray(union?.agents) ? union.agents : [] - const activeId = String(activeConnectionId || union?.primaryConnectionId || '').trim() + // A live id of null/'' means the window is on the unscoped local backend + // (host.state.connectionId is null for mode:'local'). Do NOT fall back to + // registry primary in that case — primary can still say "spark" after the + // user clicked a local bot, which skipped every Spark row as "active" and + // invented a This-device shadow of default. + const liveProvided = arguments.length >= 3 + const liveId = String(activeConnectionId || '').trim() + const activeId = liveId || (liveProvided ? '' : String(union?.primaryConnectionId || '').trim()) const activeByName = new Map() // Treat the rich list as one row per active-source profile. Clone every @@ -2265,10 +2272,6 @@ function mergeMultiSourceRoster(local, union, activeConnectionId = null) { const profiles = [...activeByName.values()] - if (!agents.length) { - return { ...local, profiles } - } - // host.agents is an Electron/main-process capability. Defend the plugin // boundary too: older shells or reconnect races can still hand us repeated // identities even after the core roster deduplicates them. @@ -2322,6 +2325,35 @@ function mergeMultiSourceRoster(local, union, activeConnectionId = null) { }) } + // SSH sources drop to connect-on-demand the moment their tunnel is not + // the live gateway. Keep previously painted remote rows so clicking the + // local agent does not empty Bot Mode. + if (Array.isArray(previous) && previous.length > 0) { + const present = new Set(profiles.map(row => `${row.connectionId || ''}::${row.name}`)) + const unionSourceIds = new Set(agents.map(agent => String(agent?.connectionId || '').trim()).filter(Boolean)) + const omitted = new Set( + (Array.isArray(union?.sources) ? union.sources : []) + .filter(source => source?.error === 'connect-on-demand' || source?.reachable === false) + .map(source => String(source.connectionId || '').trim()) + .filter(Boolean) + ) + + for (const row of previous) { + const connectionId = String(row?.connectionId || '').trim() + const name = String(row?.name || '').trim() + const key = `${connectionId}::${name || 'default'}` + + if (!row?.remoteSource || !connectionId || !name || present.has(key)) { + continue + } + + if (omitted.has(connectionId) || !unionSourceIds.has(connectionId)) { + profiles.push({ ...row, remoteSource: true, sourceScoped: true }) + present.add(key) + } + } + } + return { ...local, profiles } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index 7f9a09f23f..eb1198a22d 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -343,6 +343,110 @@ test('merge: primary-local desktop keeps single-source behavior (no phantom rows // source's agent, profiles.list answers from THAT source — the merge must // classify against the live id, not the registry primary, or the active // source's agents duplicate all over again. +test('merge: live-null local window does not treat registry primary as active', () => { + const { __mergeMultiSourceRoster: merge } = runtime() + const local = { profiles: [{ name: 'default', last_session: { id: 'this-chat' } }] } + const union = { + primaryConnectionId: 'spark', + agents: [ + { + connectionId: 'local', + connectionKind: 'local', + connectionLabel: 'This device', + profile: 'default', + handle: 'default-this-device' + }, + { connectionId: 'spark', connectionKind: 'ssh', connectionLabel: 'Spark', profile: 'bob', handle: 'bob' }, + { connectionId: 'spark', connectionKind: 'ssh', connectionLabel: 'Spark', profile: 'kai', handle: 'kai' }, + { connectionId: 'spark', connectionKind: 'ssh', connectionLabel: 'Spark', profile: 'rook', handle: 'rook' } + ] + } + + // Clicking the local agent leaves host.state.connectionId null while the + // registry primary stays on Spark. That must not skip Spark bots or invent + // a second "This device" shadow of default. + const out = merge(local, union, null) + + assert.equal(out.profiles.filter(p => p.name === 'default').length, 1) + assert.equal(out.profiles.find(p => p.name === 'default').last_session.id, 'this-chat') + assert.equal(out.profiles.filter(p => p.remoteSource && p.connectionId === 'local').length, 0) + assert.equal( + out.profiles + .filter(p => p.remoteSource) + .map(p => p.name) + .sort() + .join(','), + 'bob,kai,rook' + ) +}) + +test('merge: previously seen remotes survive a connect-on-demand empty union', () => { + const { __mergeMultiSourceRoster: merge } = runtime() + const previous = [ + { name: 'default', last_session: { id: 'this-chat' } }, + { + name: 'bob', + remoteSource: true, + sourceScoped: true, + connectionId: 'spark', + connectionKind: 'ssh', + connectionLabel: 'Spark', + handle: 'bob' + } + ] + const local = { profiles: [{ name: 'default', last_session: { id: 'this-chat' } }] } + const union = { + primaryConnectionId: 'local', + agents: [ + { + connectionId: 'local', + connectionKind: 'local', + connectionLabel: 'This device', + profile: 'default', + handle: 'default' + } + ], + sources: [{ connectionId: 'spark', kind: 'ssh', error: 'connect-on-demand' }] + } + + const out = merge(local, union, 'local', previous) + const bob = out.profiles.find(p => p.name === 'bob' && p.connectionId === 'spark') + + assert.ok(bob) + assert.equal(bob.remoteSource, true) + assert.equal(out.profiles.filter(p => p.name === 'default').length, 1) +}) + +test('displayName: local default stays Hermes; remote default uses the device label', () => { + const { __displayName: name } = runtime() + + assert.equal( + name( + { + name: 'default', + sourceScoped: true, + connectionKind: 'local', + connectionLabel: 'This device' + }, + null + ), + 'Hermes' + ) + assert.equal( + name( + { + name: 'default', + sourceScoped: true, + remoteSource: true, + connectionKind: 'ssh', + connectionLabel: 'Spark' + }, + null + ), + 'Spark' + ) +}) + test('merge: live active id beats primaryConnectionId for active-source matching', () => { const { __mergeMultiSourceRoster: merge } = runtime() const local = { profiles: [{ name: 'default', last_session: { id: 's1' } }] } diff --git a/apps/desktop/src/sdk/host-state.test.ts b/apps/desktop/src/sdk/host-state.test.ts index efa1cb9ffb..68dc8918dd 100644 --- a/apps/desktop/src/sdk/host-state.test.ts +++ b/apps/desktop/src/sdk/host-state.test.ts @@ -65,7 +65,7 @@ describe('host.state focused-session atoms', () => { expect(host.state.connectionId.get()).toBe('work') session.setConnection({ mode: 'local' } as never) - expect(host.state.connectionId.get()).toBeNull() + expect(host.state.connectionId.get()).toBe('local') session.setConnection(null) }) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 29a46a9ac8..6803bcfb2a 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -169,7 +169,20 @@ if (typeof window !== 'undefined') { /** Live usage of the FOCUSED session, projected out of the streamed session * state — the same readout the core statusbar's context chip paints. */ const $focusedUsage = computed($focusedSessionState, state => state?.usage ?? null) -const $activeConnectionId = computed($connection, connection => connection?.connectionId ?? null) +const $activeConnectionId = computed($connection, connection => { + if (!connection) { + return null + } + + if (connection.connectionId) { + return connection.connectionId + } + + // mode:'local' used to report null, which made Bot Mode fall back to the + // registry primary (often an SSH box) and treat Spark as the active source + // while this window was actually local. + return connection.mode === 'local' ? 'local' : null +}) export const host = { state: {