2024 lines
70 KiB
TypeScript
2024 lines
70 KiB
TypeScript
/**
|
|
* Tests for electron/connection-registry.ts — the v2 multi-connection
|
|
* registry: label rules (required, unique, @handle disambiguation), input
|
|
* validation, registry normalization from disk, the v1→v2 migration, and the
|
|
* pure upsert/remove/set-primary operations.
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import type { ConnectionRegistry } from './connection-registry'
|
|
import {
|
|
agentHandle,
|
|
backendScopeKey,
|
|
backendScopePrefix,
|
|
buildAgentRoster,
|
|
connectionDialFieldsChanged,
|
|
connectionIdForLabel,
|
|
labelKey,
|
|
labelSlug,
|
|
LOCAL_CONNECTION_ID,
|
|
mergeConnectionInput,
|
|
migrateV1ToRegistry,
|
|
normalizeConnectionInput,
|
|
normalizeRegistry,
|
|
parseRemoteProfileListing,
|
|
reconcileAppliedGlobalConnection,
|
|
reconcileRegistryDrift,
|
|
REGISTRY_VERSION,
|
|
registrySourceOwnsPrimaryBackend,
|
|
rememberSshEnumeration,
|
|
removeConnection,
|
|
resolvedConnectionId,
|
|
resolveRegistryLocalRoute,
|
|
reuseMatchingPrimarySshBackend,
|
|
setConnectionLaunchMode,
|
|
setLastUsedConnection,
|
|
setPrimaryConnection,
|
|
shouldDeferLocalEnumeration,
|
|
shouldRetrySshInventory,
|
|
uniqueLabel,
|
|
updateEligibility,
|
|
upsertConnection
|
|
} from './connection-registry'
|
|
|
|
function emptyRegistry(): ConnectionRegistry {
|
|
return normalizeRegistry(null)
|
|
}
|
|
|
|
// --- labels, slugs, handles ---
|
|
|
|
test('labelKey is case-insensitive and trimmed', () => {
|
|
assert.equal(labelKey(' Homelab '), 'homelab')
|
|
assert.equal(labelKey('HOMELAB'), labelKey('homelab'))
|
|
})
|
|
|
|
test('labelSlug kebab-cases and never returns empty for non-empty input', () => {
|
|
assert.equal(labelSlug('Work Laptop'), 'work-laptop')
|
|
assert.equal(labelSlug('Spark Box #2'), 'spark-box-2')
|
|
assert.equal(labelSlug('!!!'), 'connection')
|
|
})
|
|
|
|
test('registry SSH fingerprint failures name the connection and ssh -G step', async () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'ssh',
|
|
remote: { mode: 'ssh', host: 'build-host', user: 'alice' },
|
|
profiles: {}
|
|
})
|
|
|
|
const source = registry.connections.find(connection => connection.id === registry.primary)!
|
|
|
|
const cause = new Error('spawn ssh ENOENT')
|
|
|
|
source.label = 'Build box'
|
|
|
|
await assert.rejects(
|
|
reuseMatchingPrimarySshBackend({
|
|
connectionId: registry.primary,
|
|
effectiveFingerprint: async () => {
|
|
throw cause
|
|
},
|
|
ensurePrimary: async () => ({ mode: 'remote', remoteKind: 'ssh' }),
|
|
profile: 'default',
|
|
registry,
|
|
source
|
|
}),
|
|
error => {
|
|
assert.equal(
|
|
(error as Error).message,
|
|
`Could not resolve effective SSH config for connection "Build box" (${source.id}) via ssh -G: spawn ssh ENOENT`
|
|
)
|
|
assert.equal((error as Error).cause, cause)
|
|
|
|
return true
|
|
}
|
|
)
|
|
})
|
|
|
|
test('matching primary/default SSH route reuses the existing descriptor once', async () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'ssh',
|
|
remote: { mode: 'ssh', host: 'build-host', user: 'alice' },
|
|
profiles: {}
|
|
})
|
|
|
|
const source = registry.connections.find(connection => connection.id === registry.primary)
|
|
|
|
const descriptor = {
|
|
mode: 'remote' as const,
|
|
remoteKind: 'ssh' as const,
|
|
ssh: {
|
|
effectiveConfigFingerprint: 'same-effective-config',
|
|
host: 'build-host',
|
|
keyPath: '~/.ssh/id_ed25519',
|
|
remoteProfile: 'default',
|
|
user: 'alice'
|
|
}
|
|
}
|
|
|
|
let ensureCalls = 0
|
|
let fingerprintCalls = 0
|
|
|
|
assert.equal(source?.kind, 'ssh')
|
|
assert.equal(
|
|
await reuseMatchingPrimarySshBackend({
|
|
connectionId: registry.primary,
|
|
effectiveFingerprint: async () => {
|
|
fingerprintCalls += 1
|
|
|
|
return 'same-effective-config'
|
|
},
|
|
ensurePrimary: async () => {
|
|
ensureCalls += 1
|
|
|
|
return descriptor
|
|
},
|
|
profile: 'default',
|
|
registry,
|
|
source: source!
|
|
}),
|
|
descriptor
|
|
)
|
|
assert.equal(ensureCalls, 1)
|
|
assert.equal(fingerprintCalls, 1)
|
|
})
|
|
|
|
test('non-default or non-primary SSH routes do not resolve the primary backend', async () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'ssh',
|
|
remote: { mode: 'ssh', host: 'build-host', user: 'alice' },
|
|
profiles: {}
|
|
})
|
|
|
|
const source = registry.connections.find(connection => connection.id === registry.primary)!
|
|
let ensureCalls = 0
|
|
|
|
const opts = {
|
|
effectiveFingerprint: async () => 'same',
|
|
ensurePrimary: async () => {
|
|
ensureCalls += 1
|
|
|
|
return { mode: 'remote' as const, remoteKind: 'ssh' as const }
|
|
},
|
|
registry,
|
|
source
|
|
}
|
|
|
|
assert.equal(
|
|
await reuseMatchingPrimarySshBackend({ ...opts, connectionId: registry.primary, profile: 'researcher' }),
|
|
null
|
|
)
|
|
assert.equal(
|
|
await reuseMatchingPrimarySshBackend({ ...opts, connectionId: LOCAL_CONNECTION_ID, profile: 'default' }),
|
|
null
|
|
)
|
|
assert.equal(ensureCalls, 0)
|
|
})
|
|
|
|
test('primary SSH reuse rejects a descriptor with different effective dialing config', async () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'ssh',
|
|
remote: { mode: 'ssh', host: 'build-host', user: 'alice' },
|
|
profiles: {}
|
|
})
|
|
|
|
const source = registry.connections.find(connection => connection.id === registry.primary)!
|
|
|
|
assert.equal(
|
|
await reuseMatchingPrimarySshBackend({
|
|
connectionId: registry.primary,
|
|
effectiveFingerprint: async () => 'registry-config',
|
|
ensurePrimary: async () => ({
|
|
mode: 'remote',
|
|
remoteKind: 'ssh',
|
|
ssh: {
|
|
effectiveConfigFingerprint: 'active-config',
|
|
host: 'other-host',
|
|
remoteProfile: ''
|
|
}
|
|
}),
|
|
profile: 'default',
|
|
registry,
|
|
source
|
|
}),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('primary SSH reuse rejects a descriptor with a different remote Hermes path', async () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'ssh',
|
|
remote: { mode: 'ssh', host: 'build-host', remoteHermesPath: '/srv/hermes', user: 'alice' },
|
|
profiles: {}
|
|
})
|
|
|
|
const source = registry.connections.find(connection => connection.id === registry.primary)!
|
|
|
|
assert.equal(
|
|
await reuseMatchingPrimarySshBackend({
|
|
connectionId: registry.primary,
|
|
effectiveFingerprint: async () => 'same-effective-config',
|
|
ensurePrimary: async () => ({
|
|
mode: 'remote',
|
|
remoteKind: 'ssh',
|
|
ssh: {
|
|
effectiveConfigFingerprint: 'same-effective-config',
|
|
host: 'build-host',
|
|
remoteHermesPath: '/opt/hermes',
|
|
remoteProfile: '',
|
|
user: 'alice'
|
|
}
|
|
}),
|
|
profile: 'default',
|
|
registry,
|
|
source
|
|
}),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('registry primary reuses a matching primary backend descriptor', () => {
|
|
const registry = normalizeRegistry({
|
|
version: REGISTRY_VERSION,
|
|
primary: 'hermes-vps',
|
|
launchMode: 'primary',
|
|
lastUsed: 'hermes-vps',
|
|
connections: [
|
|
{ id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' },
|
|
{ id: 'hermes-vps', kind: 'ssh', label: 'Hermes VPS', host: 'hermes-vps' }
|
|
]
|
|
})
|
|
|
|
const descriptor = {
|
|
connectionId: 'hermes-vps',
|
|
mode: 'remote' as const,
|
|
remoteKind: 'ssh' as const,
|
|
ssh: { host: 'hermes-vps' }
|
|
}
|
|
|
|
assert.equal(registrySourceOwnsPrimaryBackend(registry, 'hermes-vps', descriptor), true)
|
|
assert.equal(registrySourceOwnsPrimaryBackend(registry, LOCAL_CONNECTION_ID, descriptor), false)
|
|
})
|
|
|
|
test('resolvedConnectionId identifies local and migrated remote descriptors', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'local',
|
|
profiles: {
|
|
personal: { mode: 'remote', url: 'https://personal.example:9443/', authMode: 'token' },
|
|
work: { mode: 'ssh', host: 'work-host', user: 'root' }
|
|
}
|
|
})
|
|
|
|
const personal = registry.connections.find(connection => connection.kind === 'remote')
|
|
const work = registry.connections.find(connection => connection.kind === 'ssh')
|
|
|
|
assert.equal(resolvedConnectionId(registry, { mode: 'local' }), LOCAL_CONNECTION_ID)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
baseUrl: 'https://personal.example:9443',
|
|
mode: 'remote',
|
|
remoteKind: 'url'
|
|
}),
|
|
personal?.id
|
|
)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
baseUrl: 'http://127.0.0.1:49152',
|
|
mode: 'remote',
|
|
remoteHost: 'ROOT@WORK-HOST',
|
|
remoteKind: 'ssh'
|
|
}),
|
|
work?.id
|
|
)
|
|
|
|
const ambiguousLocal: ConnectionRegistry = {
|
|
...registry,
|
|
connections: [...registry.connections, { id: 'local-copy', kind: 'local', label: 'Local copy' }]
|
|
}
|
|
|
|
assert.equal(resolvedConnectionId(ambiguousLocal, { mode: 'local' }), null)
|
|
})
|
|
|
|
test('resolvedConnectionId does not guess an unregistered remote', () => {
|
|
assert.equal(
|
|
resolvedConnectionId(emptyRegistry(), {
|
|
baseUrl: 'https://unknown.example',
|
|
mode: 'remote',
|
|
remoteKind: 'url'
|
|
}),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('agentHandle bare when unique, @name-device shape when duplicated', () => {
|
|
assert.equal(agentHandle('research', 'Homelab', false), 'research')
|
|
assert.equal(agentHandle('research', 'Homelab', true), 'research-homelab')
|
|
assert.equal(agentHandle('research', 'Work Laptop', true), 'research-work-laptop')
|
|
assert.equal(agentHandle('', 'Homelab', false), 'default')
|
|
})
|
|
|
|
test('resolvedConnectionId accepts only a current exact descriptor id and never falls back', () => {
|
|
const registry: ConnectionRegistry = {
|
|
version: REGISTRY_VERSION,
|
|
primary: 'remote-a',
|
|
launchMode: 'primary',
|
|
lastUsed: 'remote-a',
|
|
connections: [
|
|
{ id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' },
|
|
{
|
|
id: 'remote-a',
|
|
kind: 'remote',
|
|
label: 'Remote A',
|
|
url: 'https://shared.example',
|
|
authMode: 'token',
|
|
token: { encoding: 'safeStorage', value: 'token-a' },
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }
|
|
},
|
|
{
|
|
id: 'remote-b',
|
|
kind: 'remote',
|
|
label: 'Remote B',
|
|
url: 'https://shared.example',
|
|
authMode: 'oauth',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } }
|
|
}
|
|
]
|
|
}
|
|
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
authMode: 'token',
|
|
baseUrl: 'https://shared.example',
|
|
connectionId: 'remote-b',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } },
|
|
mode: 'remote',
|
|
remoteKind: 'url',
|
|
token: { encoding: 'safeStorage', value: 'token-a' }
|
|
}),
|
|
'remote-b'
|
|
)
|
|
|
|
const inferableRemoteA = {
|
|
authMode: 'token',
|
|
baseUrl: 'https://shared.example',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } },
|
|
mode: 'remote' as const,
|
|
remoteKind: 'url' as const,
|
|
token: { encoding: 'safeStorage', value: 'token-a' }
|
|
}
|
|
|
|
// Only true absence enters compatibility inference. Every explicitly
|
|
// present invalid value remains unresolved even though the remaining
|
|
// envelope uniquely identifies remote-a.
|
|
assert.equal(resolvedConnectionId(registry, inferableRemoteA), 'remote-a')
|
|
|
|
for (const connectionId of ['', ' ', null, undefined, 42, {}, 'unknown-source', 'retired-source']) {
|
|
assert.equal(resolvedConnectionId(registry, { ...inferableRemoteA, connectionId }), null)
|
|
}
|
|
|
|
// Registry order is never authority for either an exact current id or a
|
|
// rejected explicit claim.
|
|
const reordered = { ...registry, connections: [...registry.connections].reverse() }
|
|
|
|
assert.equal(
|
|
resolvedConnectionId(reordered, {
|
|
...inferableRemoteA,
|
|
connectionId: 'remote-b'
|
|
}),
|
|
'remote-b'
|
|
)
|
|
assert.equal(resolvedConnectionId(reordered, { ...inferableRemoteA, connectionId: 'retired-source' }), null)
|
|
})
|
|
|
|
test('resolvedConnectionId reuses the exact URL envelope and rejects weak or duplicate matches', () => {
|
|
const sharedUrl = 'https://shared.example/gateway'
|
|
|
|
const registry: ConnectionRegistry = {
|
|
version: REGISTRY_VERSION,
|
|
primary: 'remote-token',
|
|
launchMode: 'primary',
|
|
lastUsed: 'remote-token',
|
|
connections: [
|
|
{ id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' },
|
|
{
|
|
id: 'remote-token',
|
|
kind: 'remote',
|
|
label: 'Token remote',
|
|
url: sharedUrl,
|
|
authMode: 'token',
|
|
token: { encoding: 'safeStorage', value: 'token-a' },
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }
|
|
},
|
|
{
|
|
id: 'remote-oauth',
|
|
kind: 'remote',
|
|
label: 'OAuth remote',
|
|
url: `${sharedUrl}/`,
|
|
authMode: 'oauth',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } }
|
|
},
|
|
{
|
|
id: 'cloud-nous',
|
|
kind: 'cloud',
|
|
label: 'Nous cloud',
|
|
url: sharedUrl,
|
|
authMode: 'oauth',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } },
|
|
org: 'nous'
|
|
},
|
|
{
|
|
id: 'cloud-labs',
|
|
kind: 'cloud',
|
|
label: 'Labs cloud',
|
|
url: sharedUrl,
|
|
authMode: 'oauth',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } },
|
|
org: 'labs'
|
|
}
|
|
]
|
|
}
|
|
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
authMode: 'token',
|
|
baseUrl: sharedUrl,
|
|
headers: { 'cf-access-client-id': { encoding: 'safeStorage', value: 'header-a' } },
|
|
mode: 'remote',
|
|
remoteKind: 'url',
|
|
token: { encoding: 'safeStorage', value: 'token-a' }
|
|
}),
|
|
'remote-token'
|
|
)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
authMode: 'oauth',
|
|
baseUrl: sharedUrl,
|
|
headers: { 'CF-ACCESS-CLIENT-ID': { encoding: 'safeStorage', value: 'header-b' } },
|
|
mode: 'remote',
|
|
remoteKind: 'url'
|
|
}),
|
|
'remote-oauth'
|
|
)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
authMode: 'oauth',
|
|
baseUrl: sharedUrl,
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } },
|
|
mode: 'remote',
|
|
org: 'nous',
|
|
remoteKind: 'cloud'
|
|
}),
|
|
'cloud-nous'
|
|
)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
authMode: 'oauth',
|
|
baseUrl: sharedUrl,
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } },
|
|
mode: 'remote',
|
|
org: 'labs',
|
|
remoteKind: 'cloud'
|
|
}),
|
|
'cloud-labs'
|
|
)
|
|
|
|
// Post-dial URL-only shapes do not contain enough proof to choose a source.
|
|
assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'url' }), null)
|
|
assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'cloud' }), null)
|
|
|
|
// Even a complete envelope fails closed when two registrations are exact twins.
|
|
const duplicate: ConnectionRegistry = {
|
|
...registry,
|
|
connections: [
|
|
...registry.connections,
|
|
{ ...registry.connections.find(connection => connection.id === 'remote-token')!, id: 'remote-token-copy' }
|
|
]
|
|
}
|
|
|
|
assert.equal(
|
|
resolvedConnectionId(duplicate, {
|
|
authMode: 'token',
|
|
baseUrl: sharedUrl,
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } },
|
|
mode: 'remote',
|
|
remoteKind: 'url',
|
|
token: { encoding: 'safeStorage', value: 'token-a' }
|
|
}),
|
|
null
|
|
)
|
|
assert.equal(
|
|
resolvedConnectionId(
|
|
{ ...duplicate, connections: [...duplicate.connections].reverse() },
|
|
{
|
|
authMode: 'token',
|
|
baseUrl: sharedUrl,
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } },
|
|
mode: 'remote',
|
|
remoteKind: 'url',
|
|
token: { encoding: 'safeStorage', value: 'token-a' }
|
|
}
|
|
),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('resolvedConnectionId keeps same-host SSH routes distinct by port, key, path, and profile', () => {
|
|
const base = {
|
|
host: 'work-host',
|
|
keyPath: '/keys/a',
|
|
kind: 'ssh' as const,
|
|
remoteHermesPath: '/srv/hermes',
|
|
remoteProfile: 'alpha',
|
|
user: 'root'
|
|
}
|
|
|
|
const registry: ConnectionRegistry = {
|
|
version: REGISTRY_VERSION,
|
|
primary: 'ssh-base',
|
|
launchMode: 'primary',
|
|
lastUsed: 'ssh-base',
|
|
connections: [
|
|
{ id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' },
|
|
{ ...base, id: 'ssh-base', label: 'SSH base' },
|
|
{ ...base, id: 'ssh-port', label: 'SSH port', port: 2222 },
|
|
{ ...base, id: 'ssh-key', keyPath: '/keys/b', label: 'SSH key' },
|
|
{ ...base, id: 'ssh-path', label: 'SSH path', remoteHermesPath: '/opt/hermes' },
|
|
{ ...base, id: 'ssh-profile', label: 'SSH profile', remoteProfile: 'beta' }
|
|
]
|
|
}
|
|
|
|
const resolve = (ssh: NonNullable<Parameters<typeof resolvedConnectionId>[1]['ssh']>) =>
|
|
resolvedConnectionId(registry, { mode: 'remote', remoteKind: 'ssh', ssh })
|
|
|
|
assert.equal(resolve(base), 'ssh-base')
|
|
assert.equal(resolve({ ...base, port: 2222 }), 'ssh-port')
|
|
assert.equal(resolve({ ...base, keyPath: '/keys/b' }), 'ssh-key')
|
|
assert.equal(resolve({ ...base, remoteHermesPath: '/opt/hermes' }), 'ssh-path')
|
|
assert.equal(resolve({ ...base, remoteProfile: 'beta' }), 'ssh-profile')
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
connectionId: 'ssh-port',
|
|
mode: 'remote',
|
|
remoteKind: 'ssh',
|
|
ssh: { ...base, port: 9999 }
|
|
}),
|
|
'ssh-port'
|
|
)
|
|
|
|
// user@host is a transport hint, not a registry identity, when variants coexist.
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
mode: 'remote',
|
|
remoteHost: 'ROOT@WORK-HOST',
|
|
remoteKind: 'ssh'
|
|
}),
|
|
null
|
|
)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
mode: 'remote',
|
|
remoteHost: 'ROOT@WORK-HOST',
|
|
remoteKind: 'ssh',
|
|
ssh: undefined
|
|
}),
|
|
null
|
|
)
|
|
|
|
const duplicate: ConnectionRegistry = {
|
|
...registry,
|
|
connections: [...registry.connections, { ...base, id: 'ssh-base-copy', label: 'SSH base copy' }]
|
|
}
|
|
|
|
assert.equal(resolvedConnectionId(duplicate, { mode: 'remote', remoteKind: 'ssh', ssh: base }), null)
|
|
assert.equal(
|
|
resolvedConnectionId(
|
|
{ ...duplicate, connections: [...duplicate.connections].reverse() },
|
|
{ mode: 'remote', remoteKind: 'ssh', ssh: base }
|
|
),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('connectionIdForLabel suffixes on collision and never mints "local"', () => {
|
|
assert.equal(connectionIdForLabel('Homelab', []), 'homelab')
|
|
assert.equal(connectionIdForLabel('Homelab', ['homelab']), 'homelab-2')
|
|
assert.equal(connectionIdForLabel('Homelab', ['homelab', 'homelab-2']), 'homelab-3')
|
|
assert.equal(connectionIdForLabel('Local', []), 'local-2')
|
|
})
|
|
|
|
test('uniqueLabel counts up (never "X 2 2") and clamps long candidates', () => {
|
|
assert.equal(uniqueLabel('Homelab', []), 'Homelab')
|
|
assert.equal(uniqueLabel('Homelab', ['Homelab']), 'Homelab 2')
|
|
assert.equal(uniqueLabel('Homelab', ['Homelab', 'Homelab 2']), 'Homelab 3')
|
|
// Case-insensitive collision detection.
|
|
assert.equal(uniqueLabel('homelab', ['HOMELAB']), 'homelab 2')
|
|
|
|
const long = 'x'.repeat(300)
|
|
assert.ok(uniqueLabel(long, []).length <= 64)
|
|
assert.ok(uniqueLabel(long, [uniqueLabel(long, [])]).length <= 64)
|
|
})
|
|
|
|
// --- backendScopeKey (composite pool keys) ---
|
|
|
|
// The electron and @hermes/shared implementations MUST stay byte-identical —
|
|
// the renderer keys its socket registry with the shared copy while the main
|
|
// process keys the backend pool with this one. This contract test is the
|
|
// enforcement (see the NOTE on backendScopeKey).
|
|
test('backendScopeKey: electron and shared implementations agree everywhere', async () => {
|
|
// Non-literal specifier on purpose: tsconfig.electron.json's project
|
|
// boundary excludes apps/shared sources, but vitest resolves the workspace
|
|
// package fine at runtime — which is exactly what this test needs.
|
|
const shared = (await import(String('@hermes/shared'))) as {
|
|
backendScopeKey: typeof backendScopeKey
|
|
backendScopePrefix: typeof backendScopePrefix
|
|
LOCAL_CONNECTION_ID: string
|
|
}
|
|
|
|
const cases: [null | string | undefined, null | string | undefined][] = [
|
|
[null, null],
|
|
[undefined, undefined],
|
|
['', ''],
|
|
['local', 'research'],
|
|
['homelab', 'research'],
|
|
['homelab', ''],
|
|
[' homelab ', ' research '],
|
|
['spark-2', 'default']
|
|
]
|
|
|
|
for (const [conn, profile] of cases) {
|
|
assert.equal(backendScopeKey(conn, profile), shared.backendScopeKey(conn, profile))
|
|
}
|
|
|
|
assert.equal(backendScopePrefix('homelab'), shared.backendScopePrefix('homelab'))
|
|
assert.equal(LOCAL_CONNECTION_ID, shared.LOCAL_CONNECTION_ID)
|
|
})
|
|
|
|
test('backendScopeKey: local/empty connection keeps the bare profile key', () => {
|
|
assert.equal(backendScopeKey(null, 'research'), 'research')
|
|
assert.equal(backendScopeKey('', 'research'), 'research')
|
|
assert.equal(backendScopeKey(LOCAL_CONNECTION_ID, 'research'), 'research')
|
|
assert.equal(backendScopeKey('local', ''), 'default')
|
|
assert.equal(backendScopeKey(undefined, undefined), 'default')
|
|
})
|
|
|
|
test('backendScopeKey: non-local connections get an unambiguous composite', () => {
|
|
assert.equal(backendScopeKey('homelab', 'research'), 'conn:homelab::research')
|
|
assert.equal(backendScopeKey('homelab', ''), 'conn:homelab::default')
|
|
// Composite keys can never collide with a plain profile name, and the
|
|
// prefix helper matches exactly the keys the connection owns.
|
|
assert.ok(backendScopeKey('homelab', 'research').startsWith(backendScopePrefix('homelab')))
|
|
assert.ok(!backendScopeKey('homelab-2', 'research').startsWith(backendScopePrefix('homelab')))
|
|
assert.ok(!'research'.startsWith(backendScopePrefix('homelab')))
|
|
})
|
|
|
|
// --- resolveRegistryLocalRoute (registry 'local' entry vs the v1 route) ---
|
|
|
|
test('registry local route: delegates to the legacy path when v1 is local (single-source users byte-identical)', () => {
|
|
assert.deepEqual(resolveRegistryLocalRoute('research', {}), { delegate: true, poolKey: 'research' })
|
|
assert.deepEqual(resolveRegistryLocalRoute('', {}), { delegate: true, poolKey: 'default' })
|
|
assert.deepEqual(resolveRegistryLocalRoute(null, { globalRemote: false }), { delegate: true, poolKey: 'default' })
|
|
})
|
|
|
|
test('registry local route: v1 REMOTE global mode forces a genuinely-local backend (migration scenario)', () => {
|
|
// The migration keeps the mandatory 'local' entry AND makes the v1 remote
|
|
// the registry primary. If 'local' delegated to the v1 route here, the
|
|
// roster's "This device" rows would enumerate + dial the REMOTE primary —
|
|
// every profile duplicated and local agents talking to the remote box.
|
|
const route = resolveRegistryLocalRoute('default', { globalRemote: true })
|
|
|
|
assert.equal(route.delegate, false)
|
|
// The forced-local child must NOT pool under the bare profile key: that
|
|
// slot is where the v1 route caches the REMOTE descriptor. The composite
|
|
// form is prefix-owned by the local connection and collision-free.
|
|
assert.equal(route.poolKey, 'conn:local::default')
|
|
assert.ok(route.poolKey.startsWith(backendScopePrefix(LOCAL_CONNECTION_ID)))
|
|
assert.notEqual(route.poolKey, backendScopeKey(LOCAL_CONNECTION_ID, 'default'))
|
|
})
|
|
|
|
test('registry local route: a per-profile remote override delegates to the override (#90477)', () => {
|
|
// The per-profile SSH/remote override is the authoritative route for that
|
|
// profile. Forcing local here made the roster list the profile via its
|
|
// override but open the thread in a local child — which fails when the
|
|
// profile exists only on the remote. The override must win.
|
|
const route = resolveRegistryLocalRoute('research', { profileRemoteOverride: true })
|
|
|
|
assert.deepEqual(route, { delegate: true, poolKey: 'research' })
|
|
})
|
|
|
|
test('registry local route: per-profile override wins when global remote is also active', () => {
|
|
const route = resolveRegistryLocalRoute('research', {
|
|
globalRemote: true,
|
|
profileRemoteOverride: true
|
|
})
|
|
|
|
assert.deepEqual(route, { delegate: true, poolKey: 'research' })
|
|
})
|
|
|
|
// --- shouldDeferLocalEnumeration (roster's connect-on-demand for 'local') ---
|
|
|
|
test('local enumeration: delegate route (local-primary desktop) always enumerates', () => {
|
|
const route = resolveRegistryLocalRoute('default', {})
|
|
|
|
assert.equal(shouldDeferLocalEnumeration(route, []), false)
|
|
assert.equal(shouldDeferLocalEnumeration(route, ['conn:local::default']), false)
|
|
})
|
|
|
|
test('local enumeration: forced-local route defers until a local child exists (remote-primary desktop)', () => {
|
|
// Remote-gateway-only desktops: enumerating "This device" here would SPAWN
|
|
// a local backend the user never asked for — a phantom `default` agent
|
|
// that duplicates their real one and forces -device handles onto it.
|
|
const route = resolveRegistryLocalRoute('default', { globalRemote: true })
|
|
|
|
assert.equal(shouldDeferLocalEnumeration(route, []), true)
|
|
// The v1 remote descriptor cached at the BARE profile key is not a local child.
|
|
assert.equal(shouldDeferLocalEnumeration(route, ['default', 'research']), true)
|
|
// Once the user has genuinely opened a forced-local child, it enumerates.
|
|
assert.equal(shouldDeferLocalEnumeration(route, ['conn:local::default']), false)
|
|
})
|
|
|
|
// --- buildAgentRoster (union roster + @name-device rule) ---
|
|
|
|
test('roster: unique profiles keep bare handles; duplicates get @name-device', () => {
|
|
const local = { id: 'local', kind: 'local' as const, label: 'This device' }
|
|
const homelab = { id: 'homelab', kind: 'remote' as const, label: 'Homelab', url: 'http://h:1' }
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: local, profiles: ['default', 'research'] },
|
|
{ connection: homelab, profiles: ['research', 'coder'] }
|
|
])
|
|
|
|
const byKey = new Map(roster.map(a => [`${a.connectionId}/${a.profile}`, a.handle]))
|
|
|
|
// research exists on both sources → both disambiguate.
|
|
assert.equal(byKey.get('local/research'), 'research-this-device')
|
|
assert.equal(byKey.get('homelab/research'), 'research-homelab')
|
|
// default and coder are unique → bare names.
|
|
assert.equal(byKey.get('local/default'), 'default')
|
|
assert.equal(byKey.get('homelab/coder'), 'coder')
|
|
assert.equal(roster.length, 4)
|
|
})
|
|
|
|
test('roster: source profile metadata follows the connection-qualified row', () => {
|
|
const local = { id: 'local', kind: 'local' as const, label: 'This device' }
|
|
const vps = { id: 'vps', kind: 'remote' as const, label: 'VPS', url: 'http://vps:8642' }
|
|
|
|
const vpsMeta = {
|
|
display_name: 'Emma',
|
|
ui_meta: { 'hermes-bots': { title: 'Emma', shape: 'blobatar::sun', color: '#8b5cf6' } },
|
|
has_avatar: true
|
|
}
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: local, profiles: ['default'] },
|
|
{ connection: vps, profiles: ['default'], profileMetadata: { default: vpsMeta } }
|
|
])
|
|
|
|
assert.deepEqual(roster.find(agent => agent.connectionId === 'vps')?.profileMetadata, vpsMeta)
|
|
assert.equal(roster.find(agent => agent.connectionId === 'local')?.profileMetadata, undefined)
|
|
})
|
|
|
|
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('rememberSshEnumeration: a bounced remote source keeps its last-known roster (4-bots-show-as-2)', () => {
|
|
// A VPS restart makes the remote source unreachable for a few polls. The
|
|
// last successful enumeration must keep painting so the roster does not
|
|
// silently drop that source's bots mid-outage.
|
|
assert.deepEqual(
|
|
rememberSshEnumeration({ profiles: null, error: 'unreachable' }, ['default', 'ceo', 'accounter'], 'remote'),
|
|
{ profiles: ['default', 'ceo', 'accounter'], error: 'unreachable' }
|
|
)
|
|
// Never-seen remote source: no seed — an unreachable URL is not evidence a
|
|
// backend exists there.
|
|
assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'unreachable' }, null, 'remote'), {
|
|
profiles: null,
|
|
error: 'unreachable'
|
|
})
|
|
// Local enumeration failures never reuse a cache (the local runtime answers
|
|
// authoritatively or not at all).
|
|
assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'boom' }, ['default'], 'local'), {
|
|
profiles: null,
|
|
error: 'boom'
|
|
})
|
|
})
|
|
|
|
test('shouldRetrySshInventory: first try, cooldown, then retry; cache never retries', () => {
|
|
assert.equal(shouldRetrySshInventory(false, null, 1_000), true)
|
|
assert.equal(shouldRetrySshInventory(false, 1_000, 30_000, 60_000), false)
|
|
assert.equal(shouldRetrySshInventory(false, 1_000, 61_000, 60_000), true)
|
|
assert.equal(shouldRetrySshInventory(true, 1_000, 120_000, 60_000), false)
|
|
})
|
|
|
|
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' }
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: local, profiles: ['research'] },
|
|
{ connection: dead, profiles: null, error: 'unreachable' }
|
|
])
|
|
|
|
assert.equal(roster.length, 1)
|
|
// Only one live source has research → bare handle, no phantom duplicate.
|
|
assert.equal(roster[0].handle, 'research')
|
|
})
|
|
|
|
test('roster: duplicate profiles from one connection remain one routable agent', () => {
|
|
const local = { id: 'local', kind: 'local' as const, label: 'This device' }
|
|
const homelab = { id: 'homelab', kind: 'remote' as const, label: 'Homelab', url: 'http://h:1' }
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: local, profiles: ['default', 'research', 'default'] },
|
|
// A duplicate registry enumeration must not make local/research a second
|
|
// bot identity either.
|
|
{ connection: local, profiles: ['research'] },
|
|
{ connection: homelab, profiles: ['research', 'research'] }
|
|
])
|
|
|
|
assert.deepEqual(
|
|
roster.map(agent => `${agent.connectionId}/${agent.profile}`),
|
|
['local/default', 'local/research', 'homelab/research']
|
|
)
|
|
assert.equal(
|
|
roster.find(agent => agent.connectionId === 'local' && agent.profile === 'research')?.handle,
|
|
'research-this-device'
|
|
)
|
|
assert.equal(
|
|
roster.find(agent => agent.connectionId === 'homelab' && agent.profile === 'research')?.handle,
|
|
'research-homelab'
|
|
)
|
|
})
|
|
|
|
// --- buildAgentRoster: same-backend (install_id) collapse ---
|
|
|
|
test('roster: two connections with the same install_id collapse to one row per profile', () => {
|
|
const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' }
|
|
const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' }
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: hostname, profiles: ['default', 'research'], installId: 'aaa' },
|
|
{ connection: tailscale, profiles: ['default', 'research'], installId: 'aaa' }
|
|
])
|
|
|
|
// One row per (install, profile) — no duplicate bots for the same box.
|
|
assert.deepEqual(roster.map(agent => `${agent.connectionId}/${agent.profile}`).sort(), [
|
|
'spark/default',
|
|
'spark/research'
|
|
])
|
|
// Handle disambiguation runs AFTER the collapse: no more suffixed names.
|
|
assert.deepEqual(roster.map(agent => agent.handle).sort(), ['default', 'research'])
|
|
})
|
|
|
|
test('roster: collapse prefers the active (primary) connection', () => {
|
|
const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' }
|
|
const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' }
|
|
|
|
const roster = buildAgentRoster(
|
|
[
|
|
{ connection: hostname, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: tailscale, profiles: ['default'], installId: 'aaa' }
|
|
],
|
|
{ primaryConnectionId: 'spark-ts' }
|
|
)
|
|
|
|
assert.equal(roster.length, 1)
|
|
assert.equal(roster[0].connectionId, 'spark-ts')
|
|
})
|
|
|
|
test('roster: collapse pick order is local > ssh > remote > cloud, then registration order', () => {
|
|
const local = { id: 'local', kind: 'local' as const, label: 'This device' }
|
|
const remote = { id: 'loop', kind: 'remote' as const, label: 'Loopback', url: 'http://127.0.0.1:8642' }
|
|
const cloud = { id: 'cl', kind: 'cloud' as const, label: 'Cloud twin', url: 'http://cl:1' }
|
|
const ssh = { id: 'tun', kind: 'ssh' as const, label: 'Tunnel', host: 'box' }
|
|
|
|
// Same box registered four ways; primary is NOT one of them (unset).
|
|
const roster = buildAgentRoster([
|
|
{ connection: cloud, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: remote, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: ssh, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: local, profiles: ['default'], installId: 'aaa' }
|
|
])
|
|
|
|
assert.equal(roster.length, 1)
|
|
assert.equal(roster[0].connectionId, 'local')
|
|
|
|
// Without the local candidate, ssh wins over remote/cloud.
|
|
const noLocal = buildAgentRoster([
|
|
{ connection: cloud, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: remote, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: ssh, profiles: ['default'], installId: 'aaa' }
|
|
])
|
|
|
|
assert.equal(noLocal[0].connectionId, 'tun')
|
|
|
|
// Same kind → earliest-registered (enumeration order) wins.
|
|
const twin = { id: 'loop2', kind: 'remote' as const, label: 'Loopback 2', url: 'http://[::1]:8642' }
|
|
|
|
const sameKind = buildAgentRoster([
|
|
{ connection: remote, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: twin, profiles: ['default'], installId: 'aaa' }
|
|
])
|
|
|
|
assert.equal(sameKind[0].connectionId, 'loop')
|
|
})
|
|
|
|
test('roster: missing install_id bypasses the collapse (older backends keep current behavior)', () => {
|
|
const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' }
|
|
const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' }
|
|
|
|
// Neither reports an id → both rows survive, handles disambiguate.
|
|
const roster = buildAgentRoster([
|
|
{ connection: hostname, profiles: ['default'] },
|
|
{ connection: tailscale, profiles: ['default'] }
|
|
])
|
|
|
|
assert.equal(roster.length, 2)
|
|
assert.deepEqual(roster.map(a => a.handle).sort(), ['default-spark', 'default-spark-ts'])
|
|
|
|
// One id + one missing must NOT collapse either (undefined never matches).
|
|
const mixed = buildAgentRoster([
|
|
{ connection: hostname, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: tailscale, profiles: ['default'] }
|
|
])
|
|
|
|
assert.equal(mixed.length, 2)
|
|
})
|
|
|
|
test('roster: different install_ids stay separate rows with disambiguated handles', () => {
|
|
const spark = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' }
|
|
const mini = { id: 'mini', kind: 'remote' as const, label: 'Mini', url: 'http://mini:8642' }
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: spark, profiles: ['default'], installId: 'aaa' },
|
|
{ connection: mini, profiles: ['default'], installId: 'bbb' }
|
|
])
|
|
|
|
assert.equal(roster.length, 2)
|
|
assert.deepEqual(roster.map(a => a.handle).sort(), ['default-mini', 'default-spark'])
|
|
})
|
|
|
|
test('roster: collapse also folds a third same-box connection from a per-profile v1 override import', () => {
|
|
// The reporter's "profile with cron appears as another duplicate": the v1
|
|
// migration imports per-profile override blocks as EXTRA connections, so a
|
|
// cron profile pinned to the same box via a third URL becomes a third
|
|
// registry entry. Same install_id → still one row per profile.
|
|
const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' }
|
|
const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' }
|
|
const override = { id: 'spark-lan', kind: 'remote' as const, label: 'Spark LAN', url: 'http://192.168.1.5:8642' }
|
|
|
|
const roster = buildAgentRoster([
|
|
{ connection: hostname, profiles: ['default', 'cron-bot'], installId: 'aaa' },
|
|
{ connection: tailscale, profiles: ['default', 'cron-bot'], installId: 'aaa' },
|
|
{ connection: override, profiles: ['default', 'cron-bot'], installId: 'aaa' }
|
|
])
|
|
|
|
assert.deepEqual(roster.map(agent => `${agent.profile}:${agent.handle}`).sort(), [
|
|
'cron-bot:cron-bot',
|
|
'default:default'
|
|
])
|
|
})
|
|
|
|
// --- updateEligibility ---
|
|
|
|
test('update fan-out: cloud is platform-managed, everything else eligible', () => {
|
|
assert.deepEqual(updateEligibility({ id: 'c', kind: 'cloud', label: 'Cloud' }), {
|
|
eligible: false,
|
|
reason: 'cloud-managed'
|
|
})
|
|
assert.equal(updateEligibility({ id: 'local', kind: 'local', label: 'x' }).eligible, true)
|
|
assert.equal(updateEligibility({ id: 'r', kind: 'remote', label: 'x' }).eligible, true)
|
|
assert.equal(updateEligibility({ id: 's', kind: 'ssh', label: 'x' }).eligible, true)
|
|
})
|
|
|
|
// --- normalizeConnectionInput ---
|
|
|
|
test('save rejects the reserved "local" id on non-local kinds', () => {
|
|
assert.throws(
|
|
() =>
|
|
normalizeConnectionInput({ id: 'local', kind: 'remote', label: 'Sneaky', url: 'http://x:1' }, emptyRegistry()),
|
|
/reserved/
|
|
)
|
|
})
|
|
|
|
test('token only persists on token-auth remotes; oauth/cloud drop it', () => {
|
|
const registry = emptyRegistry()
|
|
|
|
const tokenAuth = normalizeConnectionInput(
|
|
{ kind: 'remote', label: 'A', url: 'http://a:1', authMode: 'token', token: { enc: 'x' } },
|
|
registry
|
|
)
|
|
|
|
assert.deepEqual(tokenAuth.token, { enc: 'x' })
|
|
|
|
const oauth = normalizeConnectionInput(
|
|
{ kind: 'remote', label: 'B', url: 'http://b:1', authMode: 'oauth', token: { enc: 'x' } },
|
|
registry
|
|
)
|
|
|
|
assert.equal(oauth.token, undefined)
|
|
|
|
const cloud = normalizeConnectionInput(
|
|
{ kind: 'cloud', label: 'C', url: 'https://c.hermes.cloud', authMode: 'oauth', token: { enc: 'x' } },
|
|
registry
|
|
)
|
|
|
|
assert.equal(cloud.token, undefined)
|
|
})
|
|
|
|
// --- mergeConnectionInput (edit inheritance) ---
|
|
|
|
test('merge preserves fields the editor does not carry (org, ssh extras)', () => {
|
|
const cloud = {
|
|
authMode: 'oauth' as const,
|
|
id: 'c',
|
|
kind: 'cloud' as const,
|
|
label: 'Cloud',
|
|
org: 'nous',
|
|
url: 'https://a.cloud'
|
|
}
|
|
|
|
const renamed = mergeConnectionInput({ id: 'c', kind: 'cloud', label: 'Renamed', url: 'https://a.cloud' }, cloud)
|
|
|
|
assert.equal(renamed.org, 'nous')
|
|
|
|
const ssh = {
|
|
host: 'homelab.lan',
|
|
id: 's',
|
|
keyPath: '/k/id',
|
|
kind: 'ssh' as const,
|
|
label: 'Box',
|
|
port: 2222,
|
|
remoteHermesPath: '/opt/hermes',
|
|
remoteProfile: 'research',
|
|
user: 'k'
|
|
}
|
|
|
|
const labelOnly = mergeConnectionInput({ id: 's', kind: 'ssh', label: 'Renamed box' }, ssh)
|
|
|
|
assert.equal(labelOnly.remoteHermesPath, '/opt/hermes')
|
|
assert.equal(labelOnly.remoteProfile, 'research')
|
|
assert.equal(labelOnly.host, 'homelab.lan')
|
|
assert.equal(labelOnly.user, 'k')
|
|
assert.equal(labelOnly.port, 2222)
|
|
})
|
|
|
|
test('merge: a supplied ssh host string beats stored user/port', () => {
|
|
const ssh = { host: 'spark1', id: 's', kind: 'ssh' as const, label: 'Spark', port: 2222, user: 'tek' }
|
|
const merged = mergeConnectionInput({ host: 'admin@newbox:2200', id: 's', kind: 'ssh', label: 'Spark' }, ssh)
|
|
|
|
// Stored user/port must NOT ride along — the host string is authoritative.
|
|
assert.equal(merged.user, undefined)
|
|
assert.equal(merged.port, undefined)
|
|
|
|
const entry = normalizeConnectionInput(merged, emptyRegistry())
|
|
|
|
assert.equal(entry.host, 'newbox')
|
|
assert.equal(entry.user, 'admin')
|
|
assert.equal(entry.port, 2200)
|
|
})
|
|
|
|
test('save rejects a missing label with a device-name message', () => {
|
|
assert.throws(
|
|
() => normalizeConnectionInput({ kind: 'remote', label: ' ', url: 'http://10.0.0.5:9119' }, emptyRegistry()),
|
|
/device name/
|
|
)
|
|
})
|
|
|
|
test('save rejects a duplicate label case-insensitively', () => {
|
|
let registry = emptyRegistry()
|
|
registry = upsertConnection(
|
|
registry,
|
|
normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
|
|
)
|
|
|
|
assert.throws(
|
|
() => normalizeConnectionInput({ kind: 'remote', label: ' homelab ', url: 'http://10.0.0.9:9119' }, registry),
|
|
/must be unique/
|
|
)
|
|
})
|
|
|
|
test('editing an entry does not collide with its own label', () => {
|
|
let registry = emptyRegistry()
|
|
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
|
|
registry = upsertConnection(registry, entry)
|
|
|
|
const edited = normalizeConnectionInput(
|
|
{ id: entry.id, kind: 'remote', label: 'Homelab', url: 'http://10.0.0.6:9119' },
|
|
registry
|
|
)
|
|
|
|
assert.equal(edited.id, entry.id)
|
|
assert.equal(edited.url, 'http://10.0.0.6:9119')
|
|
})
|
|
|
|
test('duplicate gateway URLs are rejected across remote and cloud kinds', () => {
|
|
let registry = emptyRegistry()
|
|
registry = upsertConnection(
|
|
registry,
|
|
normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
|
|
)
|
|
|
|
// Same URL modulo trailing slash → dupe, even as a different kind.
|
|
assert.throws(
|
|
() => normalizeConnectionInput({ kind: 'remote', label: 'Twin', url: 'http://10.0.0.5:9119/' }, registry),
|
|
/already exists/
|
|
)
|
|
assert.throws(
|
|
() => normalizeConnectionInput({ kind: 'cloud', label: 'Cloud twin', url: 'http://10.0.0.5:9119' }, registry),
|
|
/already exists/
|
|
)
|
|
// Editing the entry itself keeps its own URL without self-colliding.
|
|
const existing = registry.connections.find(c => c.kind === 'remote')!
|
|
|
|
const edited = normalizeConnectionInput(
|
|
{ id: existing.id, kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' },
|
|
registry
|
|
)
|
|
|
|
assert.equal(edited.id, existing.id)
|
|
})
|
|
|
|
test('duplicate ssh targets are rejected on user@host:port + remote profile', () => {
|
|
let registry = emptyRegistry()
|
|
registry = upsertConnection(
|
|
registry,
|
|
normalizeConnectionInput({ kind: 'ssh', label: 'Box', host: 'alice@box:22', remoteProfile: 'work' }, registry)
|
|
)
|
|
|
|
assert.throws(
|
|
() =>
|
|
normalizeConnectionInput(
|
|
{ kind: 'ssh', label: 'Box twin', host: 'alice@box:22', remoteProfile: 'work' },
|
|
registry
|
|
),
|
|
/already exists/
|
|
)
|
|
|
|
// A different remote profile on the same host is a distinct agent source.
|
|
const otherProfile = normalizeConnectionInput(
|
|
{ kind: 'ssh', label: 'Box other', host: 'alice@box:22', remoteProfile: 'other' },
|
|
registry
|
|
)
|
|
|
|
assert.equal(otherProfile.kind, 'ssh')
|
|
})
|
|
|
|
test('remote input normalizes URL and auth mode; cloud keeps org', () => {
|
|
const registry = emptyRegistry()
|
|
|
|
const remote = normalizeConnectionInput(
|
|
{ kind: 'remote', label: 'LAN box', url: '10.0.0.5:9119', authMode: 'weird' },
|
|
registry
|
|
)
|
|
|
|
assert.equal(remote.url, 'http://10.0.0.5:9119')
|
|
assert.equal(remote.authMode, 'token')
|
|
|
|
const cloud = normalizeConnectionInput(
|
|
{ kind: 'cloud', label: 'Cloud', url: 'https://foo.hermes.cloud', authMode: 'oauth', org: 'nous' },
|
|
registry
|
|
)
|
|
|
|
assert.equal(cloud.kind, 'cloud')
|
|
assert.equal(cloud.org, 'nous')
|
|
assert.equal(cloud.authMode, 'oauth')
|
|
})
|
|
|
|
test('ssh input requires a host; local input only carries the label', () => {
|
|
const registry = emptyRegistry()
|
|
|
|
assert.throws(() => normalizeConnectionInput({ kind: 'ssh', label: 'Spark', host: ' ' }, registry), /host/)
|
|
|
|
const ssh = normalizeConnectionInput({ kind: 'ssh', label: 'Spark', host: 'tek@spark1:2222' }, registry)
|
|
|
|
assert.equal(ssh.host, 'spark1')
|
|
assert.equal(ssh.user, 'tek')
|
|
assert.equal(ssh.port, 2222)
|
|
|
|
const local = normalizeConnectionInput({ kind: 'local', label: 'My MacBook' }, registry)
|
|
|
|
assert.equal(local.id, LOCAL_CONNECTION_ID)
|
|
assert.deepEqual(Object.keys(local).sort(), ['id', 'kind', 'label'])
|
|
})
|
|
|
|
// --- normalizeRegistry ---
|
|
|
|
test('normalizeRegistry degrades junk to a local-only registry', () => {
|
|
for (const junk of [null, undefined, 42, 'nope', { connections: 'zzz' }, { version: 99 }]) {
|
|
const registry = normalizeRegistry(junk)
|
|
|
|
assert.equal(registry.version, REGISTRY_VERSION)
|
|
assert.equal(registry.primary, LOCAL_CONNECTION_ID)
|
|
assert.equal(registry.launchMode, 'primary')
|
|
assert.equal(registry.lastUsed, LOCAL_CONNECTION_ID)
|
|
assert.equal(registry.connections.length, 1)
|
|
assert.equal(registry.connections[0].kind, 'local')
|
|
}
|
|
})
|
|
|
|
test('normalizeRegistry guarantees local, dedupes labels, fixes dangling primary', () => {
|
|
const registry = normalizeRegistry({
|
|
version: 2,
|
|
primary: 'ghost',
|
|
connections: [
|
|
{ id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' },
|
|
{ id: 'b', kind: 'remote', label: 'homelab', url: 'http://10.0.0.6:9119' },
|
|
{ id: 'c', kind: 'remote', label: 'No URL entry' },
|
|
{ kind: 'nonsense', label: 'x' }
|
|
]
|
|
})
|
|
|
|
assert.equal(registry.primary, LOCAL_CONNECTION_ID)
|
|
assert.ok(registry.connections.some(c => c.kind === 'local'))
|
|
|
|
const labels = registry.connections.map(c => labelKey(c.label))
|
|
|
|
assert.equal(new Set(labels).size, labels.length)
|
|
// The url-less remote entry is dropped, the junk kind is dropped.
|
|
assert.equal(registry.connections.filter(c => c.kind === 'remote').length, 2)
|
|
})
|
|
|
|
test('normalizeRegistry round-trips a valid registry unchanged in shape', () => {
|
|
const input = {
|
|
version: 2,
|
|
primary: 'homelab',
|
|
launchMode: 'last-used',
|
|
lastUsed: 'homelab',
|
|
connections: [
|
|
{ id: 'local', kind: 'local', label: 'This device' },
|
|
{
|
|
id: 'homelab',
|
|
kind: 'remote',
|
|
label: 'Homelab',
|
|
url: 'http://10.0.0.5:9119',
|
|
authMode: 'token',
|
|
token: { v: 1 }
|
|
},
|
|
{
|
|
id: 'cloud-1',
|
|
kind: 'cloud',
|
|
label: 'Hermes Cloud',
|
|
url: 'https://a.hermes.cloud',
|
|
authMode: 'oauth',
|
|
org: 'nous'
|
|
},
|
|
{ id: 'spark', kind: 'ssh', label: 'Spark', host: 'spark1', user: 'tek', port: 2222 }
|
|
]
|
|
}
|
|
|
|
const registry = normalizeRegistry(input)
|
|
|
|
assert.equal(registry.primary, 'homelab')
|
|
assert.equal(registry.launchMode, 'last-used')
|
|
assert.equal(registry.lastUsed, 'homelab')
|
|
assert.equal(registry.connections.length, 4)
|
|
assert.deepEqual(
|
|
registry.connections.map(c => c.id),
|
|
['local', 'homelab', 'cloud-1', 'spark']
|
|
)
|
|
assert.deepEqual(registry.connections[1].token, { v: 1 })
|
|
assert.equal(registry.connections[3].port, 2222)
|
|
})
|
|
|
|
test('normalizeRegistry falls back to Primary when the last-used source is missing', () => {
|
|
const registry = normalizeRegistry({
|
|
version: 2,
|
|
primary: 'homelab',
|
|
launchMode: 'last-used',
|
|
lastUsed: 'retired-host',
|
|
connections: [
|
|
{ id: 'local', kind: 'local', label: 'This device' },
|
|
{ id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }
|
|
]
|
|
})
|
|
|
|
assert.equal(registry.launchMode, 'last-used')
|
|
assert.equal(registry.lastUsed, 'homelab')
|
|
})
|
|
|
|
// --- v1 → v2 migration ---
|
|
|
|
test('migrate: v1 local-only config → local-only registry', () => {
|
|
const registry = migrateV1ToRegistry({ mode: 'local', remote: {}, profiles: {} })
|
|
|
|
assert.equal(registry.primary, LOCAL_CONNECTION_ID)
|
|
assert.equal(registry.connections.length, 1)
|
|
})
|
|
|
|
test('migrate: v1 global remote becomes a labeled entry and the primary', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'remote',
|
|
remote: { url: 'http://homelab.lan:9119', authMode: 'token', token: { enc: 'x' } }
|
|
})
|
|
|
|
const remote = registry.connections.find(c => c.kind === 'remote')
|
|
|
|
assert.ok(remote)
|
|
assert.equal(registry.primary, remote.id)
|
|
assert.equal(remote.label, 'homelab.lan:9119')
|
|
assert.deepEqual(remote.token, { enc: 'x' })
|
|
})
|
|
|
|
test('migrate: v1 cloud keeps cloud provenance + org', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'cloud',
|
|
remote: { url: 'https://a.hermes.cloud', authMode: 'oauth', org: 'nous' }
|
|
})
|
|
|
|
const cloud = registry.connections.find(c => c.kind === 'cloud')
|
|
|
|
assert.ok(cloud)
|
|
assert.equal(registry.primary, cloud.id)
|
|
assert.equal(cloud.org, 'nous')
|
|
})
|
|
|
|
test('migrate: per-profile overrides become extra sources, deduped by URL', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'remote',
|
|
remote: { url: 'http://homelab.lan:9119', authMode: 'token', token: { enc: 'x' } },
|
|
profiles: {
|
|
research: { mode: 'remote', url: 'http://homelab.lan:9119', authMode: 'token', token: { enc: 'x' } },
|
|
coder: { mode: 'remote', url: 'http://other.lan:9119', authMode: 'token', token: { enc: 'y' } },
|
|
sparky: { mode: 'ssh', host: 'spark1', user: 'tek' },
|
|
plain: { mode: 'local', savedSsh: { mode: 'ssh', host: 'spark1', user: 'tek' } }
|
|
}
|
|
})
|
|
|
|
// homelab (global+research deduped), other.lan, spark ssh (override+savedSsh deduped), local
|
|
assert.equal(registry.connections.length, 4)
|
|
assert.equal(registry.connections.filter(c => c.kind === 'remote').length, 2)
|
|
assert.equal(registry.connections.filter(c => c.kind === 'ssh').length, 1)
|
|
})
|
|
|
|
test('migrate: v1 global ssh becomes the primary', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'ssh',
|
|
remote: { mode: 'ssh', host: 'spark1', user: 'tek', port: 2222 }
|
|
})
|
|
|
|
const ssh = registry.connections.find(c => c.kind === 'ssh')
|
|
|
|
assert.ok(ssh)
|
|
assert.equal(registry.primary, ssh.id)
|
|
assert.equal(ssh.label, 'spark1')
|
|
})
|
|
|
|
test('migrate: duplicate host labels are suffixed, not dropped', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'remote',
|
|
remote: { url: 'http://box.lan:9119', authMode: 'token', token: {} },
|
|
profiles: {
|
|
a: { mode: 'ssh', host: 'box.lan' }
|
|
}
|
|
})
|
|
|
|
const labels = registry.connections.map(c => labelKey(c.label))
|
|
|
|
assert.equal(new Set(labels).size, labels.length)
|
|
assert.equal(registry.connections.length, 3)
|
|
})
|
|
|
|
// --- registry operations ---
|
|
|
|
test('removeConnection: local refuses, primary and last-used retarget safely', () => {
|
|
let registry = emptyRegistry()
|
|
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
|
|
registry = upsertConnection(registry, entry)
|
|
registry = setPrimaryConnection(registry, entry.id)
|
|
registry = setLastUsedConnection(registry, entry.id)
|
|
|
|
assert.throws(() => removeConnection(registry, LOCAL_CONNECTION_ID), /cannot be removed/)
|
|
|
|
const after = removeConnection(registry, entry.id)
|
|
|
|
assert.equal(after.primary, LOCAL_CONNECTION_ID)
|
|
assert.equal(after.lastUsed, LOCAL_CONNECTION_ID)
|
|
assert.equal(after.connections.length, 1)
|
|
// Removing an unknown id is a no-op, not an error.
|
|
assert.equal(removeConnection(after, 'ghost'), after)
|
|
})
|
|
|
|
test('setPrimaryConnection validates the target id', () => {
|
|
const registry = emptyRegistry()
|
|
|
|
assert.throws(() => setPrimaryConnection(registry, 'ghost'), /No connection/)
|
|
assert.equal(setPrimaryConnection(registry, LOCAL_CONNECTION_ID).primary, LOCAL_CONNECTION_ID)
|
|
})
|
|
|
|
test('last-used source and launch mode validate their persisted values', () => {
|
|
let registry = emptyRegistry()
|
|
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
|
|
registry = upsertConnection(registry, entry)
|
|
|
|
assert.throws(() => setLastUsedConnection(registry, 'ghost'), /No connection/)
|
|
assert.equal(setLastUsedConnection(registry, entry.id).lastUsed, entry.id)
|
|
assert.equal(setConnectionLaunchMode(registry, 'last-used').launchMode, 'last-used')
|
|
assert.throws(() => setConnectionLaunchMode(registry, 'sometimes'), /Unknown connection launch mode/)
|
|
})
|
|
|
|
test('upsertConnection replaces by id and appends new ids', () => {
|
|
let registry = emptyRegistry()
|
|
const a = normalizeConnectionInput({ kind: 'remote', label: 'A', url: 'http://a:1' }, registry)
|
|
registry = upsertConnection(registry, a)
|
|
registry = upsertConnection(registry, { ...a, url: 'http://a:2' })
|
|
|
|
assert.equal(registry.connections.filter(c => c.id === a.id).length, 1)
|
|
assert.equal(registry.connections.find(c => c.id === a.id)?.url, 'http://a:2')
|
|
})
|
|
|
|
test('Apply remote inserts into an existing local-only registry and becomes primary/current', () => {
|
|
const registry = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
|
mode: 'remote',
|
|
remote: { url: 'https://gateway.example.com/', authMode: 'oauth' }
|
|
})
|
|
|
|
const remote = registry.connections.find(connection => connection.kind === 'remote')
|
|
|
|
assert.ok(remote)
|
|
assert.equal(registry.primary, remote.id)
|
|
assert.equal(registry.lastUsed, remote.id)
|
|
assert.equal(
|
|
resolvedConnectionId(registry, {
|
|
authMode: 'oauth',
|
|
baseUrl: 'https://gateway.example.com',
|
|
headers: {},
|
|
mode: 'remote',
|
|
remoteKind: 'url'
|
|
}),
|
|
remote.id
|
|
)
|
|
})
|
|
|
|
test('Apply remote preserves an existing URL identity and label without duplicates', () => {
|
|
let registry = emptyRegistry()
|
|
|
|
registry = upsertConnection(registry, {
|
|
id: 'hermes-alex',
|
|
kind: 'remote',
|
|
label: 'Existing gateway',
|
|
url: 'https://gateway.example.com',
|
|
authMode: 'token',
|
|
token: { old: true }
|
|
})
|
|
|
|
const applied = reconcileAppliedGlobalConnection(registry, {
|
|
mode: 'remote',
|
|
remote: { url: 'https://GATEWAY.example.com/', authMode: 'oauth' }
|
|
})
|
|
|
|
const matches = applied.connections.filter(connection => connection.url === 'https://gateway.example.com')
|
|
|
|
assert.equal(matches.length, 1)
|
|
assert.equal(matches[0].id, 'hermes-alex')
|
|
assert.equal(matches[0].label, 'Existing gateway')
|
|
assert.equal(matches[0].authMode, 'oauth')
|
|
assert.equal(applied.primary, 'hermes-alex')
|
|
assert.equal(applied.lastUsed, 'hermes-alex')
|
|
})
|
|
|
|
test('Apply local moves primary/current to This device without deleting registered remotes', () => {
|
|
const remoteRegistry = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
|
mode: 'remote',
|
|
remote: { url: 'https://one.example.com', authMode: 'oauth' }
|
|
})
|
|
|
|
const localRegistry = reconcileAppliedGlobalConnection(remoteRegistry, { mode: 'local', remote: {} })
|
|
|
|
assert.equal(localRegistry.primary, LOCAL_CONNECTION_ID)
|
|
assert.equal(localRegistry.lastUsed, LOCAL_CONNECTION_ID)
|
|
assert.equal(localRegistry.connections.filter(connection => connection.kind === 'remote').length, 1)
|
|
assert.equal(resolvedConnectionId(localRegistry, { mode: 'local' }), LOCAL_CONNECTION_ID)
|
|
})
|
|
|
|
test('Apply between two remotes keeps each real registration once and activates the latest', () => {
|
|
const first = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
|
mode: 'remote',
|
|
remote: { url: 'https://one.example.com', authMode: 'oauth' }
|
|
})
|
|
|
|
const second = reconcileAppliedGlobalConnection(first, {
|
|
mode: 'remote',
|
|
remote: { url: 'https://two.example.com/', authMode: 'oauth' }
|
|
})
|
|
|
|
const remotes = second.connections.filter(connection => connection.kind === 'remote')
|
|
|
|
assert.deepEqual(remotes.map(connection => connection.url).sort(), [
|
|
'https://one.example.com',
|
|
'https://two.example.com'
|
|
])
|
|
assert.equal(new Set(remotes.map(connection => connection.id)).size, 2)
|
|
assert.equal(second.primary, remotes.find(connection => connection.url === 'https://two.example.com')?.id)
|
|
assert.equal(second.lastUsed, second.primary)
|
|
})
|
|
|
|
// --- reconcileRegistryDrift (v1 ↔ v2 healing) ---
|
|
|
|
test('drift heal registers a v1 remote the registry never learned about and makes it primary', () => {
|
|
// The exact shape users keep reporting: registry migrated while local-only,
|
|
// then Settings → Gateway pointed v1 at a remote. connections.json still
|
|
// says primary 'local', so every launch force-switches off the live remote.
|
|
const drifted = reconcileRegistryDrift(emptyRegistry(), {
|
|
mode: 'remote',
|
|
remote: { url: 'https://agent.example.com:4443', authMode: 'oauth' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, true)
|
|
|
|
const remote = drifted.registry.connections.find(connection => connection.kind === 'remote')
|
|
|
|
assert.ok(remote)
|
|
assert.equal(drifted.registry.primary, remote.id)
|
|
assert.equal(drifted.registry.lastUsed, remote.id)
|
|
// The whole point: the live v1 descriptor can now be named, so the boot pick
|
|
// resolves to the remote instead of re-homing to 'local'. Descriptor shape
|
|
// matches what buildRemoteConnection emits for an oauth remote.
|
|
assert.equal(
|
|
resolvedConnectionId(drifted.registry, {
|
|
authMode: 'oauth',
|
|
baseUrl: 'https://agent.example.com:4443',
|
|
headers: {},
|
|
mode: 'remote',
|
|
remoteKind: 'url'
|
|
}),
|
|
remote.id
|
|
)
|
|
})
|
|
|
|
test('drift heal leaves a registry that already knows the v1 route untouched', () => {
|
|
const registered = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
|
mode: 'remote',
|
|
remote: { url: 'https://agent.example.com', authMode: 'oauth' }
|
|
})
|
|
|
|
const drifted = reconcileRegistryDrift(registered, {
|
|
mode: 'remote',
|
|
remote: { url: 'https://AGENT.example.com/', authMode: 'oauth' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, false)
|
|
assert.equal(drifted.registry, registered)
|
|
})
|
|
|
|
test('drift heal respects a deliberate primary pick on a registered route', () => {
|
|
// Route IS registered, but the user chose This device in the Connections
|
|
// panel. That is a choice, not drift — never override it.
|
|
let registry = reconcileAppliedGlobalConnection(emptyRegistry(), {
|
|
mode: 'remote',
|
|
remote: { url: 'https://agent.example.com', authMode: 'oauth' }
|
|
})
|
|
|
|
registry = setPrimaryConnection(registry, LOCAL_CONNECTION_ID)
|
|
|
|
const drifted = reconcileRegistryDrift(registry, {
|
|
mode: 'remote',
|
|
remote: { url: 'https://agent.example.com', authMode: 'oauth' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, false)
|
|
assert.equal(drifted.registry.primary, LOCAL_CONNECTION_ID)
|
|
})
|
|
|
|
test('drift heal ignores local and unparseable v1 routes', () => {
|
|
const registry = emptyRegistry()
|
|
|
|
for (const v1 of [
|
|
{ mode: 'local', remote: {} },
|
|
{ mode: 'ssh', remote: {} },
|
|
{ mode: 'ssh', remote: { host: ' ' } },
|
|
{ mode: 'remote', remote: { url: 'not a url' } },
|
|
{ mode: 'remote', remote: {} },
|
|
null
|
|
]) {
|
|
const drifted = reconcileRegistryDrift(registry, v1)
|
|
|
|
assert.equal(drifted.changed, false, `expected no heal for ${JSON.stringify(v1)}`)
|
|
assert.equal(drifted.registry, registry)
|
|
}
|
|
})
|
|
|
|
test('drift heal registers a v1 SSH route the registry never learned about and makes it primary', () => {
|
|
// mgallmur-glitch's shape: registry migrated while local-only, then Settings
|
|
// pointed v1 at an SSH host (host, no url). The registry cannot name it, so
|
|
// primary stays 'local' and the files re-drift after every update relaunch.
|
|
const drifted = reconcileRegistryDrift(emptyRegistry(), {
|
|
mode: 'ssh',
|
|
remote: { host: 'devbox.example.com', user: 'omar', port: 2222 }
|
|
})
|
|
|
|
assert.equal(drifted.changed, true)
|
|
|
|
const ssh = drifted.registry.connections.find(connection => connection.kind === 'ssh')
|
|
|
|
assert.ok(ssh)
|
|
assert.equal(ssh.host, 'devbox.example.com')
|
|
assert.equal(ssh.user, 'omar')
|
|
assert.equal(ssh.port, 2222)
|
|
assert.equal(drifted.registry.primary, ssh.id)
|
|
assert.equal(drifted.registry.lastUsed, ssh.id)
|
|
// The whole point: the live v1 SSH descriptor can now be named.
|
|
assert.equal(
|
|
resolvedConnectionId(drifted.registry, {
|
|
mode: 'remote',
|
|
remoteKind: 'ssh',
|
|
ssh: { host: 'devbox.example.com', user: 'omar', port: 2222 }
|
|
}),
|
|
ssh.id
|
|
)
|
|
})
|
|
|
|
test('drift heal leaves a registry that already knows the v1 SSH route untouched', () => {
|
|
const first = reconcileRegistryDrift(emptyRegistry(), {
|
|
mode: 'ssh',
|
|
remote: { host: 'devbox.example.com', user: 'omar' }
|
|
})
|
|
|
|
assert.equal(first.changed, true)
|
|
|
|
const drifted = reconcileRegistryDrift(first.registry, {
|
|
mode: 'ssh',
|
|
remote: { host: 'DEVBOX.example.com', user: 'Omar' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, false)
|
|
assert.equal(drifted.registry, first.registry)
|
|
})
|
|
|
|
test('drift heal respects a deliberate primary pick on a registered SSH route', () => {
|
|
let registry = reconcileRegistryDrift(emptyRegistry(), {
|
|
mode: 'ssh',
|
|
remote: { host: 'devbox.example.com' }
|
|
}).registry
|
|
|
|
registry = setPrimaryConnection(registry, LOCAL_CONNECTION_ID)
|
|
|
|
const drifted = reconcileRegistryDrift(registry, {
|
|
mode: 'ssh',
|
|
remote: { host: 'devbox.example.com' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, false)
|
|
assert.equal(drifted.registry.primary, LOCAL_CONNECTION_ID)
|
|
})
|
|
|
|
test('drift heal adds the missing SSH source without disturbing other registered sources', () => {
|
|
let registry = emptyRegistry()
|
|
|
|
registry = upsertConnection(registry, {
|
|
id: 'homelab',
|
|
kind: 'remote',
|
|
label: 'Homelab',
|
|
url: 'https://homelab.example.com',
|
|
authMode: 'token',
|
|
token: { keep: true }
|
|
})
|
|
|
|
const drifted = reconcileRegistryDrift(registry, {
|
|
mode: 'ssh',
|
|
remote: { host: 'devbox.example.com' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, true)
|
|
assert.ok(drifted.registry.connections.some(connection => connection.id === 'homelab'))
|
|
assert.ok(drifted.registry.connections.some(connection => connection.kind === 'ssh'))
|
|
})
|
|
|
|
test('drift heal adds the missing remote without disturbing other registered sources', () => {
|
|
let registry = emptyRegistry()
|
|
|
|
registry = upsertConnection(registry, {
|
|
id: 'homelab',
|
|
kind: 'remote',
|
|
label: 'Homelab',
|
|
url: 'https://homelab.example.com',
|
|
authMode: 'token',
|
|
token: { keep: true }
|
|
})
|
|
|
|
const drifted = reconcileRegistryDrift(registry, {
|
|
mode: 'remote',
|
|
remote: { url: 'https://agent.example.com:4443', authMode: 'oauth' }
|
|
})
|
|
|
|
assert.equal(drifted.changed, true)
|
|
assert.equal(drifted.registry.connections.filter(connection => connection.kind === 'remote').length, 2)
|
|
assert.ok(drifted.registry.connections.some(connection => connection.id === 'homelab'))
|
|
})
|
|
|
|
// --- connectionDialFieldsChanged (edit → recycle decision) ---
|
|
|
|
test('connectionDialFieldsChanged: label-only edits do not recycle', () => {
|
|
const before = {
|
|
id: 'homelab',
|
|
kind: 'remote',
|
|
label: 'Homelab',
|
|
url: 'http://10.0.0.5:9119',
|
|
authMode: 'token',
|
|
token: { encoding: 'safeStorage', value: 'abc' }
|
|
} as const
|
|
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, label: 'Home lab (renamed)' }), false)
|
|
// Identity edit is also a no-op.
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before }), false)
|
|
})
|
|
|
|
test('connectionDialFieldsChanged: url / auth / token changes recycle', () => {
|
|
const before = {
|
|
id: 'homelab',
|
|
kind: 'remote',
|
|
label: 'Homelab',
|
|
url: 'http://10.0.0.5:9119',
|
|
authMode: 'token',
|
|
token: { encoding: 'safeStorage', value: 'abc' }
|
|
} as const
|
|
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, url: 'http://10.0.0.9:9119' }), true)
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, authMode: 'oauth', token: undefined }), true)
|
|
assert.equal(
|
|
connectionDialFieldsChanged(before, { ...before, token: { encoding: 'safeStorage', value: 'NEW' } }),
|
|
true
|
|
)
|
|
})
|
|
|
|
test('connectionDialFieldsChanged: ssh routing fields recycle, kind change recycles', () => {
|
|
const before = { id: 'box', kind: 'ssh', label: 'Box', host: 'box.lan', user: 'me', port: 22 } as const
|
|
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, label: 'Box 2' }), false)
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, host: 'other.lan' }), true)
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, port: 2222 }), true)
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, remoteProfile: 'work' }), true)
|
|
assert.equal(
|
|
connectionDialFieldsChanged(before, { id: 'box', kind: 'remote', label: 'Box', url: 'http://x:1' }),
|
|
true
|
|
)
|
|
})
|
|
|
|
// --- remote gateway headers (Cloudflare Access etc., #74466 / PR #74468) ---
|
|
|
|
test('normalizeConnectionInput keeps filtered headers on remote/cloud, drops them elsewhere', () => {
|
|
const registry = emptyRegistry()
|
|
|
|
const remote = normalizeConnectionInput(
|
|
{
|
|
kind: 'remote',
|
|
label: 'CF box',
|
|
url: 'https://hermes.example.com',
|
|
authMode: 'token',
|
|
token: { enc: 'x' },
|
|
headers: {
|
|
'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' },
|
|
Authorization: { encoding: 'plain', value: 'blocked' }
|
|
}
|
|
},
|
|
registry
|
|
)
|
|
|
|
assert.deepEqual(remote.headers, {
|
|
'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' }
|
|
})
|
|
|
|
const ssh = normalizeConnectionInput(
|
|
{
|
|
kind: 'ssh',
|
|
label: 'Box',
|
|
host: 'box.lan',
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'plain', value: 'id' } }
|
|
} as any,
|
|
registry
|
|
)
|
|
|
|
assert.equal((ssh as any).headers, undefined)
|
|
})
|
|
|
|
test('mergeConnectionInput inherits stored headers when the editor payload omits them', () => {
|
|
const stored = {
|
|
id: 'cf',
|
|
kind: 'remote' as const,
|
|
label: 'CF box',
|
|
url: 'https://hermes.example.com',
|
|
authMode: 'token' as const,
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } }
|
|
}
|
|
|
|
const renamed = mergeConnectionInput({ id: 'cf', kind: 'remote', label: 'Renamed' }, stored)
|
|
|
|
assert.deepEqual(renamed.headers, stored.headers)
|
|
|
|
// An explicit headers payload (even empty) is authoritative — clearing works.
|
|
const cleared = mergeConnectionInput({ id: 'cf', kind: 'remote', label: 'CF box', headers: {} }, stored)
|
|
|
|
assert.deepEqual(cleared.headers, {})
|
|
})
|
|
|
|
test('connectionDialFieldsChanged: a header change recycles live backends', () => {
|
|
const before = {
|
|
id: 'cf',
|
|
kind: 'remote',
|
|
label: 'CF box',
|
|
url: 'https://hermes.example.com',
|
|
authMode: 'token',
|
|
token: { enc: 'x' },
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } }
|
|
} as const
|
|
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before }), false)
|
|
assert.equal(
|
|
connectionDialFieldsChanged(before, {
|
|
...before,
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'OTHER' } }
|
|
}),
|
|
true
|
|
)
|
|
assert.equal(connectionDialFieldsChanged(before, { ...before, headers: undefined }), true)
|
|
})
|
|
|
|
test('normalizeRegistry preserves stored headers on remote entries (v2 additive field)', () => {
|
|
const registry = normalizeRegistry({
|
|
version: REGISTRY_VERSION,
|
|
primary: 'cf',
|
|
connections: [
|
|
{ id: 'local', kind: 'local', label: 'This device' },
|
|
{
|
|
id: 'cf',
|
|
kind: 'remote',
|
|
label: 'CF box',
|
|
url: 'https://hermes.example.com',
|
|
authMode: 'token',
|
|
token: { enc: 'x' },
|
|
headers: {
|
|
'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' },
|
|
Cookie: { encoding: 'plain', value: 'blocked' }
|
|
}
|
|
}
|
|
]
|
|
})
|
|
|
|
const remote = registry.connections.find(c => c.id === 'cf')
|
|
|
|
assert.ok(remote)
|
|
assert.deepEqual(remote.headers, {
|
|
'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' }
|
|
})
|
|
})
|
|
|
|
test('migrateV1ToRegistry carries v1 remote headers into the registry entry', () => {
|
|
const registry = migrateV1ToRegistry({
|
|
mode: 'remote',
|
|
remote: {
|
|
url: 'https://hermes.example.com',
|
|
authMode: 'token',
|
|
token: { enc: 'x' },
|
|
headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } }
|
|
}
|
|
})
|
|
|
|
const remote = registry.connections.find(c => c.kind === 'remote')
|
|
|
|
assert.ok(remote)
|
|
assert.deepEqual(remote.headers, {
|
|
'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' }
|
|
})
|
|
})
|
|
|
|
// --- normalizeRegistry per-entry quarantine (#94246 remainder) ---
|
|
//
|
|
// One malformed entry must never cost the user the rest of the registry, and
|
|
// malformed entries are USER DATA: they are preserved under `quarantined`
|
|
// (with the raw entry verbatim) instead of being silently deleted on the next
|
|
// registry write. "Only deleting connections.json recovers" was the reported
|
|
// failure shape; the recovery must never be data loss.
|
|
|
|
test('normalizeRegistry quarantines malformed entries instead of silently dropping them', () => {
|
|
const registry = normalizeRegistry({
|
|
version: 2,
|
|
primary: 'a',
|
|
connections: [
|
|
{ id: 'local', kind: 'local', label: 'This device' },
|
|
{ id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' },
|
|
{ id: 'c', kind: 'remote', label: 'No URL entry' },
|
|
{ kind: 'nonsense', label: 'Mystery box', extra: 'still my data' },
|
|
{ id: 's', kind: 'ssh', label: 'No host ssh' }
|
|
]
|
|
})
|
|
|
|
// Healthy entries all load.
|
|
assert.deepEqual(
|
|
registry.connections.map(c => c.id),
|
|
['local', 'a']
|
|
)
|
|
assert.equal(registry.primary, 'a')
|
|
|
|
// The malformed ones are preserved verbatim, with reasons.
|
|
assert.equal((registry.quarantined || []).length, 3)
|
|
|
|
const reasons = registry.quarantined!.map(q => q.reason).sort()
|
|
|
|
assert.deepEqual(reasons, ['entry-missing-ssh-host', 'entry-missing-url', 'entry-unrecognized-kind'])
|
|
|
|
const mystery = registry.quarantined!.find(q => q.reason === 'entry-unrecognized-kind')
|
|
|
|
assert.deepEqual(mystery!.entry, { kind: 'nonsense', label: 'Mystery box', extra: 'still my data' })
|
|
})
|
|
|
|
test('normalizeRegistry preserves previously quarantined entries across round trips', () => {
|
|
const first = normalizeRegistry({
|
|
version: 2,
|
|
connections: [{ id: 'c', kind: 'remote', label: 'No URL entry' }]
|
|
})
|
|
|
|
assert.equal((first.quarantined || []).length, 1)
|
|
|
|
// Simulate write → read → normalize again (what every registry save does).
|
|
const second = normalizeRegistry(JSON.parse(JSON.stringify(first)))
|
|
|
|
assert.equal((second.quarantined || []).length, 1)
|
|
assert.deepEqual(second.quarantined![0].entry, { id: 'c', kind: 'remote', label: 'No URL entry' })
|
|
})
|
|
|
|
test('normalizeRegistry quarantines an entry that explodes during normalization (no whole-load abort)', () => {
|
|
const poisoned: any = { id: 'boom', kind: 'remote', url: 'http://10.0.0.9:9119' }
|
|
|
|
Object.defineProperty(poisoned, 'label', {
|
|
enumerable: true,
|
|
get() {
|
|
throw new Error('poisoned entry')
|
|
}
|
|
})
|
|
|
|
const registry = normalizeRegistry({
|
|
version: 2,
|
|
primary: 'a',
|
|
connections: [poisoned, { id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }]
|
|
})
|
|
|
|
// The healthy entry still loads and keeps primary; the poisoned one is
|
|
// quarantined rather than aborting the whole registry load.
|
|
assert.deepEqual(
|
|
registry.connections.filter(c => c.kind === 'remote').map(c => c.id),
|
|
['a']
|
|
)
|
|
assert.equal(registry.primary, 'a')
|
|
assert.equal((registry.quarantined || []).length, 1)
|
|
assert.equal(registry.quarantined![0].reason, 'entry-normalization-failed')
|
|
})
|
|
|
|
test('normalizeRegistry keeps a clean registry free of the quarantined key and caps quarantine growth', () => {
|
|
const clean = normalizeRegistry({
|
|
version: 2,
|
|
connections: [{ id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }]
|
|
})
|
|
|
|
assert.equal('quarantined' in clean, false)
|
|
|
|
const flooded = normalizeRegistry({
|
|
version: 2,
|
|
connections: Array.from({ length: 100 }, (_, i) => ({ id: `q${i}`, kind: 'remote', label: `No URL ${i}` }))
|
|
})
|
|
|
|
assert.ok((flooded.quarantined || []).length <= 20)
|
|
})
|
|
|
|
test('normalizeRegistry quarantines non-object junk items that could still be user data', () => {
|
|
const registry = normalizeRegistry({
|
|
version: 2,
|
|
connections: ['{ mangled json fragment }', null, false, { id: 'a', kind: 'remote', label: 'A', url: 'http://x:1' }]
|
|
})
|
|
|
|
assert.deepEqual(
|
|
registry.connections.map(c => c.kind),
|
|
['local', 'remote']
|
|
)
|
|
// null/false carry no data and are dropped; the string is preserved.
|
|
assert.equal((registry.quarantined || []).length, 1)
|
|
assert.equal(registry.quarantined![0].entry, '{ mangled json fragment }')
|
|
})
|