fix(desktop): keep SSH Bot Mode agents visible across local clicks
Clicking the local agent left connectionId null, so the roster treated the registry primary (often an SSH box) as active and dropped its profiles while inventing a "This device" shadow of default. Inventory undialed SSH sources with a cached ls of ~/.hermes/profiles instead of requiring the window to switch onto that machine. Hostile HERMES_HOME values are rejected before the listing command runs.
This commit is contained in:
@@ -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' }
|
||||
|
||||
@@ -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<ConnectionAgents, 'error' | 'profiles'>,
|
||||
cached: null | string[] | undefined,
|
||||
kind: ConnectionKind
|
||||
): Pick<ConnectionAgents, 'error' | 'profiles'> {
|
||||
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<string>(['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
|
||||
|
||||
+103
-35
@@ -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<string, string[]>()
|
||||
const sshInventoryAttempted = new Set<string>()
|
||||
|
||||
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 }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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' } }] }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user