1625 lines
54 KiB
TypeScript
1625 lines
54 KiB
TypeScript
/**
|
|
* connection-registry.ts
|
|
*
|
|
* Pure, electron-free helpers for the desktop's multi-connection registry —
|
|
* the v2 successor to the single global `mode` + `remote` block in
|
|
* connection.json. The registry is a named list of agent SOURCES (local
|
|
* runtime, remote gateways, Hermes Cloud instances, SSH hosts) that are all
|
|
* registered at once; routing/pooling changes that consume the registry land
|
|
* separately, so this module is deliberately storage-shaped, not
|
|
* transport-shaped.
|
|
*
|
|
* Design rules (agreed with Teknium, Aug 2026):
|
|
* - Every connection carries a REQUIRED, registry-unique `label` (the
|
|
* "device name"). Uniqueness is case-insensitive so `Homelab` and
|
|
* `homelab` can't coexist and produce two identical badges.
|
|
* - When two sources expose the same profile name, surfaces disambiguate as
|
|
* `@<profile>-<label-slug>` — `agentHandle()` is the one place that rule
|
|
* lives.
|
|
* - The registry ALWAYS contains exactly one `local` connection (the app's
|
|
* own runtime). It cannot be removed; it is the default primary.
|
|
* - `primary` designates the connection that owns the window backend (boot
|
|
* overlay, install/update machinery). Removing the primary retargets to
|
|
* the local entry rather than leaving a dangling id.
|
|
*
|
|
* Kept standalone (no `import 'electron'`) so it unit-tests with `node --test`
|
|
* — same pattern as connection-config.ts / backend-probes.ts. main.ts wires
|
|
* these into the IPC layer and owns file I/O + secret encryption.
|
|
*/
|
|
|
|
import {
|
|
hostLabelFromBaseUrl,
|
|
modeIsRemoteLike,
|
|
normalizeRemoteBaseUrl,
|
|
normalizeRemoteHeaders,
|
|
normalizeSshConfig,
|
|
normAuthMode
|
|
} from './connection-config'
|
|
import { matchingConnectionId, type StoredRoute } from './connection-route-identity'
|
|
|
|
export const REGISTRY_VERSION = 2
|
|
|
|
export const LOCAL_CONNECTION_ID = 'local'
|
|
|
|
/** Connection kinds. 'cloud' is remote-shaped (see modeIsRemoteLike) but keeps
|
|
* its provenance so the UI can render the right card and updates can skip
|
|
* platform-managed instances. */
|
|
export type ConnectionKind = 'cloud' | 'local' | 'remote' | 'ssh'
|
|
|
|
export interface RegistryConnection {
|
|
id: string
|
|
kind: ConnectionKind
|
|
/** Required, unique (case-insensitive) display name — the "device name". */
|
|
label: string
|
|
/** remote/cloud: normalized base URL. */
|
|
url?: string
|
|
/** remote/cloud: 'token' | 'oauth'. */
|
|
authMode?: 'oauth' | 'token'
|
|
/** remote: encrypted token envelope (opaque here; main.ts encrypts/decrypts). */
|
|
token?: unknown
|
|
/** remote/cloud: extra gateway headers (Cloudflare Access etc.). Secret
|
|
* envelopes, same shape as `token`; names pre-filtered through
|
|
* normalizeRemoteHeaders. Optional and additive — v2 registries written
|
|
* before this field keep loading unchanged. */
|
|
headers?: Record<string, unknown>
|
|
/** cloud: portal org slug/id the instance was discovered under. */
|
|
org?: string
|
|
/** ssh fields (normalizeSshConfig shapes). */
|
|
host?: string
|
|
user?: string
|
|
port?: number
|
|
keyPath?: string
|
|
remoteHermesPath?: string
|
|
remoteProfile?: string
|
|
}
|
|
|
|
/**
|
|
* A registry entry that failed normalization (#94246). The raw entry is USER
|
|
* DATA — it is preserved verbatim here (and re-persisted on every write)
|
|
* instead of being silently dropped, so a malformed/corrupt entry never
|
|
* requires "delete connections.json" recovery and never loses the user's
|
|
* connection material.
|
|
*/
|
|
export interface QuarantinedRegistryEntry {
|
|
reason: string
|
|
entry: unknown
|
|
}
|
|
|
|
/** Upper bound on preserved quarantine entries so a pathological file cannot
|
|
* grow the registry without limit. Oldest-first within one load pass. */
|
|
export const REGISTRY_QUARANTINE_CAP = 20
|
|
|
|
export interface ConnectionRegistry {
|
|
version: typeof REGISTRY_VERSION
|
|
/** id of the connection that owns the window/primary backend. */
|
|
primary: string
|
|
/** Which saved source Sessions should restore when the app launches. */
|
|
launchMode: 'last-used' | 'primary'
|
|
/** Last source the Sessions workspace successfully opened. Additive in v2
|
|
* so registries written before multi-source switching still normalize. */
|
|
lastUsed: string
|
|
connections: RegistryConnection[]
|
|
/** Entries preserved from a malformed load — absent when empty. */
|
|
quarantined?: QuarantinedRegistryEntry[]
|
|
}
|
|
|
|
// ── Labels and ids ──────────────────────────────────────────────────────────
|
|
|
|
const LABEL_MAX = 64
|
|
|
|
/** Canonical comparison key for label uniqueness. */
|
|
export function labelKey(label: string): string {
|
|
return String(label || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* Derive a registry-unique label from a candidate: clamps to LABEL_MAX (a
|
|
* migrated URL host can exceed it, which would fail validation on any later
|
|
* edit) and suffixes " 2" / " 3" / … on collision. The single home of the
|
|
* label-dedup rule — normalizeRegistry and the migration both use it.
|
|
*/
|
|
export function uniqueLabel(candidate: string, taken: Iterable<string>): string {
|
|
const used = new Set([...taken].map(labelKey))
|
|
|
|
// Reserve room for a collision suffix so the suffixed form stays in-bounds.
|
|
const base = String(candidate || '')
|
|
.trim()
|
|
.slice(0, LABEL_MAX - 4)
|
|
|
|
if (!used.has(labelKey(base))) {
|
|
return base
|
|
}
|
|
|
|
for (let n = 2; ; n += 1) {
|
|
const suffixed = `${base} ${n}`
|
|
|
|
if (!used.has(labelKey(suffixed))) {
|
|
return suffixed
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Kebab-slug of a label for ids and @handles. Never empty for a non-empty label. */
|
|
export function labelSlug(label: string): string {
|
|
const slug = String(label || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 48)
|
|
|
|
return slug || 'connection'
|
|
}
|
|
|
|
/**
|
|
* The one place the duplicate-agent naming rule lives: a profile that exists
|
|
* on several registered sources renders as `@<profile>-<label-slug>`;
|
|
* a profile unique across the roster keeps its bare name.
|
|
*/
|
|
export function agentHandle(profile: string, connectionLabel: string, duplicated: boolean): string {
|
|
const name = String(profile || '').trim() || 'default'
|
|
|
|
return duplicated ? `${name}-${labelSlug(connectionLabel)}` : name
|
|
}
|
|
|
|
/**
|
|
* Pool key for a backend serving (connection, profile). The local/primary
|
|
* connection keeps the BARE profile key so every legacy pool entry, reaper
|
|
* log line, and touch call stays byte-identical for single-source users;
|
|
* non-local connections get an unambiguous composite (`conn:<id>::<profile>`)
|
|
* that cannot collide with a plain profile name (colons are invalid in
|
|
* profile names).
|
|
*
|
|
* NOTE: the renderer's socket registry uses the twin implementation in
|
|
* apps/shared/src/backend-scope.ts (`@hermes/shared`) — tsconfig project
|
|
* boundaries prevent a single physical module here. The two are pinned
|
|
* byte-identical by the cross-copy contract test in
|
|
* connection-registry.test.ts; change BOTH or that test fails.
|
|
*/
|
|
export function backendScopeKey(connectionId: null | string | undefined, profile: null | string | undefined): string {
|
|
const profileKey = String(profile ?? '').trim() || 'default'
|
|
const connection = String(connectionId ?? '').trim()
|
|
|
|
if (!connection || connection === LOCAL_CONNECTION_ID) {
|
|
return profileKey
|
|
}
|
|
|
|
return `conn:${connection}::${profileKey}`
|
|
}
|
|
|
|
/**
|
|
* Inverse of backendScopeKey(): recover (connectionId, profile) from a pool
|
|
* key. A bare profile key (the local/primary scope) maps to a null
|
|
* connectionId. Used by the post-resume rebuild path (#93910) to re-dial a
|
|
* retired pool entry through the same claim-guarded ensure path a renderer
|
|
* would use.
|
|
*/
|
|
export function parseBackendScopeKey(key: string): { connectionId: null | string; profile: string } {
|
|
const value = String(key ?? '').trim()
|
|
const match = /^conn:(.+?)::(.+)$/.exec(value)
|
|
|
|
if (!match) {
|
|
return { connectionId: null, profile: value || 'default' }
|
|
}
|
|
|
|
return { connectionId: match[1], profile: match[2] }
|
|
}
|
|
|
|
/** All pool keys owned by a connection share this prefix (used to stop them on remove). */
|
|
export function backendScopePrefix(connectionId: string): string {
|
|
return `conn:${String(connectionId).trim()}::`
|
|
}
|
|
|
|
export interface RegistryLocalRoute {
|
|
/** Reuse the legacy v1 ensureBackend path — it already resolves to the
|
|
* app's own local runtime, so single-source behavior stays byte-identical. */
|
|
delegate: boolean
|
|
/** Pool key for the forced-local child when not delegating. */
|
|
poolKey: string
|
|
}
|
|
|
|
export interface ResolvedConnectionSshDescriptor {
|
|
effectiveConfigFingerprint?: string
|
|
host?: string
|
|
keyPath?: string
|
|
port?: number
|
|
remoteHermesPath?: string
|
|
remoteProfile?: string
|
|
user?: string
|
|
}
|
|
|
|
export interface ResolvedConnectionDescriptor {
|
|
authMode?: unknown
|
|
baseUrl?: string
|
|
/** Property presence means this descriptor claims registry qualification.
|
|
* Invalid or retired claims fail closed; only descriptors with no such
|
|
* property may enter the legacy compatibility resolver. */
|
|
connectionId?: unknown
|
|
headers?: Record<string, unknown>
|
|
mode?: 'local' | 'remote'
|
|
org?: unknown
|
|
remoteHost?: string
|
|
remoteKind?: 'cloud' | 'ssh' | 'url'
|
|
ssh?: ResolvedConnectionSshDescriptor
|
|
token?: unknown
|
|
}
|
|
|
|
/**
|
|
* Recover registry identity for a descriptor resolved through the legacy v1
|
|
* profile path. Registry-scoped routes already carry `connectionId`; that
|
|
* exact identity is authoritative only while it names a current registry
|
|
* entry. Only genuinely unqualified descriptors may use compatibility
|
|
* inference, which keeps migrated per-profile remotes truthful until v1 is
|
|
* retired without letting malformed qualification fall through to a weaker
|
|
* endpoint-shaped identity.
|
|
*/
|
|
export function resolvedConnectionId(
|
|
registry: ConnectionRegistry,
|
|
descriptor: ResolvedConnectionDescriptor
|
|
): null | string {
|
|
if (Object.prototype.hasOwnProperty.call(descriptor, 'connectionId')) {
|
|
const explicitConnectionId = descriptor.connectionId
|
|
|
|
// Presence is authoritative even when the value is unusable. Never turn
|
|
// a malformed, blank, unknown, or retired registry claim into permission
|
|
// to infer a different source from mutable endpoint metadata.
|
|
if (typeof explicitConnectionId !== 'string' || !explicitConnectionId.trim()) {
|
|
return null
|
|
}
|
|
|
|
return registry.connections.some(connection => connection.id === explicitConnectionId) ? explicitConnectionId : null
|
|
}
|
|
|
|
if (descriptor.mode === 'local') {
|
|
const localConnections = registry.connections.filter(connection => connection.kind === 'local')
|
|
|
|
return localConnections.length === 1 ? localConnections[0].id : null
|
|
}
|
|
|
|
if (descriptor.mode !== 'remote') {
|
|
return null
|
|
}
|
|
|
|
if (descriptor.remoteKind === 'ssh') {
|
|
if (Object.prototype.hasOwnProperty.call(descriptor, 'ssh')) {
|
|
if (!descriptor.ssh || typeof descriptor.ssh !== 'object') {
|
|
return null
|
|
}
|
|
|
|
return matchingConnectionId(registry, { ...descriptor.ssh, kind: 'ssh' }, 'unique') ?? null
|
|
}
|
|
|
|
// Old descriptors expose only user@host after the tunnel has discarded
|
|
// port/key/path/profile. That weak shape is compatible only when exactly
|
|
// one registered SSH source even shares the target, and the defaulted
|
|
// route still satisfies the canonical #88922 full-envelope matcher.
|
|
const ssh = normalizeSshConfig({ mode: 'ssh', host: descriptor.remoteHost })
|
|
|
|
if (!ssh) {
|
|
return null
|
|
}
|
|
|
|
const target = normalizedSshTarget(ssh)
|
|
|
|
const coarseMatches = registry.connections.filter(
|
|
connection => connection.kind === 'ssh' && normalizedSshTarget(connection) === target
|
|
)
|
|
|
|
if (!target || coarseMatches.length !== 1) {
|
|
return null
|
|
}
|
|
|
|
return matchingConnectionId(registry, { kind: 'ssh', ...ssh }, 'unique') ?? null
|
|
}
|
|
|
|
const kind = descriptor.remoteKind === 'cloud' ? 'cloud' : descriptor.remoteKind === 'url' ? 'remote' : null
|
|
|
|
if (!kind) {
|
|
return null
|
|
}
|
|
|
|
let url = ''
|
|
|
|
try {
|
|
url = normalizeRemoteBaseUrl(descriptor.baseUrl)
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
const authMode = normAuthMode(descriptor.authMode)
|
|
|
|
const route: StoredRoute = {
|
|
authMode,
|
|
headers: descriptor.headers,
|
|
kind,
|
|
org: descriptor.org,
|
|
token: descriptor.token,
|
|
url
|
|
}
|
|
|
|
const hasExactEnvelope =
|
|
Object.prototype.hasOwnProperty.call(descriptor, 'authMode') &&
|
|
Object.prototype.hasOwnProperty.call(descriptor, 'headers') &&
|
|
(authMode === 'oauth' || Object.prototype.hasOwnProperty.call(descriptor, 'token')) &&
|
|
(kind === 'remote' || Object.prototype.hasOwnProperty.call(descriptor, 'org'))
|
|
|
|
if (!hasExactEnvelope) {
|
|
// A URL alone cannot choose among legal registrations that differ by auth,
|
|
// headers, Cloud organization, or account. Require one coarse candidate
|
|
// before the same full-envelope matcher is allowed to accept the legacy
|
|
// defaults; otherwise zero/multiple candidates fail closed.
|
|
const coarseMatches = registry.connections.filter(connection => {
|
|
if (connection.kind !== kind) {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
return normalizeRemoteBaseUrl(connection.url) === url
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
if (coarseMatches.length !== 1) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return matchingConnectionId(registry, route, 'unique') ?? null
|
|
}
|
|
|
|
export interface ReuseMatchingPrimarySshBackendOptions {
|
|
connectionId: null | string | undefined
|
|
effectiveFingerprint: (source: RegistryConnection) => Promise<string>
|
|
ensurePrimary: () => Promise<ResolvedConnectionDescriptor>
|
|
profile: null | string | undefined
|
|
registry: ConnectionRegistry
|
|
source: RegistryConnection
|
|
}
|
|
|
|
/**
|
|
* Reuse the v1 window SSH backend only when its actual dialing identity matches
|
|
* the registry primary. Resolving that descriptor may boot the primary; a
|
|
* mismatch returns null without reusing it so the caller continues with its
|
|
* separately scoped registry backend. A matching descriptor is returned
|
|
* unchanged and the caller may re-stamp routing fields such as profile and
|
|
* connectionId. Guards run before either async dependency so secondary
|
|
* profiles and sources never bootstrap the primary.
|
|
*/
|
|
export async function reuseMatchingPrimarySshBackend({
|
|
connectionId,
|
|
effectiveFingerprint,
|
|
ensurePrimary,
|
|
profile,
|
|
registry,
|
|
source
|
|
}: ReuseMatchingPrimarySshBackendOptions): Promise<null | ResolvedConnectionDescriptor> {
|
|
const id = String(connectionId ?? '').trim()
|
|
const profileKey = String(profile ?? '').trim() || 'default'
|
|
|
|
if (profileKey !== 'default' || !id || id !== registry.primary || source.id !== id || source.kind !== 'ssh') {
|
|
return null
|
|
}
|
|
|
|
let sourceFingerprint
|
|
|
|
try {
|
|
sourceFingerprint = String(await effectiveFingerprint(source)).trim()
|
|
} catch (cause) {
|
|
const detail = cause instanceof Error ? cause.message : String(cause)
|
|
|
|
throw new Error(
|
|
`Could not resolve effective SSH config for connection "${source.label}" (${source.id}) via ssh -G: ${detail}`,
|
|
{ cause }
|
|
)
|
|
}
|
|
|
|
const descriptor = await ensurePrimary()
|
|
const activeSsh = descriptor.mode === 'remote' && descriptor.remoteKind === 'ssh' ? descriptor.ssh : null
|
|
const rootProfile = (value: unknown) => String(value || '').trim() || 'default'
|
|
|
|
if (
|
|
!sourceFingerprint ||
|
|
!activeSsh ||
|
|
sourceFingerprint !== String(activeSsh.effectiveConfigFingerprint || '').trim() ||
|
|
String(source.remoteHermesPath || '').trim() !== String(activeSsh.remoteHermesPath || '').trim() ||
|
|
rootProfile(source.remoteProfile) !== rootProfile(activeSsh.remoteProfile)
|
|
) {
|
|
return null
|
|
}
|
|
|
|
return descriptor
|
|
}
|
|
|
|
/**
|
|
* Whether a registry-scoped request names the already-running primary backend.
|
|
* Main uses this before opening a pooled registry backend so the registry's
|
|
* primary SSH/remote source cannot spawn a second isolated server for the same
|
|
* descriptor.
|
|
*/
|
|
export function registrySourceOwnsPrimaryBackend(
|
|
registry: ConnectionRegistry,
|
|
connectionId: null | string | undefined,
|
|
descriptor: ResolvedConnectionDescriptor
|
|
): boolean {
|
|
const id = String(connectionId ?? '').trim()
|
|
|
|
return Boolean(id) && id === registry.primary && resolvedConnectionId(registry, descriptor) === id
|
|
}
|
|
|
|
function normalizedSshTarget(route: { host?: unknown; port?: unknown; user?: unknown }): null | string {
|
|
const ssh = normalizeSshConfig({ ...route, mode: 'ssh' })
|
|
|
|
if (!ssh) {
|
|
return null
|
|
}
|
|
|
|
const host = String(ssh.host || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
|
|
const user = String(ssh.user || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
|
|
return user ? `${user}@${host}` : host
|
|
}
|
|
|
|
/**
|
|
* How the registry's 'local' entry resolves a backend for `profile`.
|
|
*
|
|
* The 'local' entry means THIS machine's runtime — always. The legacy
|
|
* ensureBackend() path instead follows the v1 connection.json routing table,
|
|
* where a global remote mode (or a per-profile remote override) resolves to a
|
|
* REMOTE descriptor. A migrated user whose v1 global mode was remote gets that
|
|
* remote as the registry primary AND keeps the mandatory 'local' entry, so
|
|
* delegating 'local' to the v1 route made the roster's "This device" rows
|
|
* enumerate and dial the remote box: every profile appeared twice (forcing
|
|
* -slug handles) and "local" agents talked to the remote.
|
|
*
|
|
* When the v1 route is already local we delegate (legacy path, byte-identical
|
|
* pool keys). When v1 says remote, the local entry spawns its own genuinely
|
|
* local child under a composite pool key: backendScopeKey('local', p) maps to
|
|
* the BARE profile key by design, and that slot may already hold the v1
|
|
* route's REMOTE descriptor — so the forced-local child pools under the
|
|
* `conn:local::<profile>` form instead (colons are invalid in profile names,
|
|
* so it cannot collide).
|
|
*/
|
|
export function resolveRegistryLocalRoute(
|
|
profile: null | string | undefined,
|
|
opts: { globalRemote?: boolean; profileRemoteOverride?: boolean } = {}
|
|
): RegistryLocalRoute {
|
|
const profileKey = String(profile ?? '').trim() || 'default'
|
|
|
|
// A per-profile SSH/remote override is an explicit per-profile routing
|
|
// decision: the override owns this profile's backend, so the 'local' entry
|
|
// must delegate to the legacy profile route (which resolves the override),
|
|
// not spawn a forced-local child. Forcing local here is the #90477 split:
|
|
// the roster lists the profile via its override, but opening the thread
|
|
// spawned a local backend that fails when the profile doesn't exist locally.
|
|
if (opts.profileRemoteOverride) {
|
|
return { delegate: true, poolKey: profileKey }
|
|
}
|
|
|
|
if (opts.globalRemote) {
|
|
return { delegate: false, poolKey: `${backendScopePrefix(LOCAL_CONNECTION_ID)}${profileKey}` }
|
|
}
|
|
|
|
return { delegate: true, poolKey: profileKey }
|
|
}
|
|
|
|
/**
|
|
* Whether the roster enumeration should SKIP the registry's local entry as
|
|
* connect-on-demand. True when the local source is the forced-local route
|
|
* (primary resolves remote — enumerating would spawn a local backend the
|
|
* user never asked for, minting a phantom `default` agent and forcing
|
|
* -device handles onto the real one) AND no forced-local child is already
|
|
* pooled. Pure — main.ts feeds it the live route + pool keys.
|
|
*/
|
|
export function shouldDeferLocalEnumeration(
|
|
route: RegistryLocalRoute,
|
|
poolKeys: Iterable<string>,
|
|
connectionId: string = LOCAL_CONNECTION_ID
|
|
): boolean {
|
|
if (route.delegate) {
|
|
return false
|
|
}
|
|
|
|
const prefix = backendScopePrefix(connectionId)
|
|
|
|
return ![...poolKeys].some(key => String(key).startsWith(prefix))
|
|
}
|
|
|
|
// ── Union agent roster ──────────────────────────────────────────────────────
|
|
|
|
export interface ConnectionAgents {
|
|
connection: RegistryConnection
|
|
/** Profile names enumerated from the connection, or null when unreachable /
|
|
* connect-on-demand (ssh not yet dialed). */
|
|
profiles: null | string[]
|
|
/** Credential-free profile metadata from the same connection. Kept separate
|
|
* from `profiles` so old enumerators can continue returning names only. */
|
|
profileMetadata?: Record<string, RosterProfileMetadata>
|
|
/** Present when profiles is null: why enumeration was skipped. */
|
|
error?: string
|
|
/** Stable backend identity from the connection's /api/status (`install_id`).
|
|
* Two connections reporting the same id are the SAME physical install
|
|
* registered under two addresses (hostname + Tailscale IP), so the roster
|
|
* collapses their rows. Absent on older backends → no collapse (fully
|
|
* backward compatible). */
|
|
installId?: string
|
|
}
|
|
|
|
export interface RosterAgent {
|
|
connectionId: string
|
|
connectionKind: ConnectionKind
|
|
connectionLabel: string
|
|
profile: string
|
|
/** Backend profile when the registry route maps the Desktop profile name. */
|
|
targetProfile?: string
|
|
/** Bare profile name, or `<profile>-<label-slug>` when the profile name
|
|
* exists on more than one registered source (the @name-device rule). */
|
|
handle: string
|
|
/** Rich metadata for this exact connection + profile, when enumerated. */
|
|
profileMetadata?: RosterProfileMetadata
|
|
}
|
|
|
|
export interface RosterProfileMetadata {
|
|
display_name?: string
|
|
title?: string
|
|
ui_meta?: Record<string, unknown>
|
|
has_avatar?: boolean
|
|
}
|
|
|
|
/**
|
|
* Roster enumeration skips undialed sources (connect-on-demand) and reports
|
|
* unreachable ones with `profiles: null`. Reuse the last successful profile
|
|
* list so Bot Mode does not go empty (or drop to a partial roster) the moment
|
|
* a source is briefly unreachable — SSH tunnels drop on sleep/wake, and a
|
|
* remote gateway bounce (VPS restart) otherwise erased its bots from the
|
|
* roster until the next successful enumeration ("my 4 bots show as 2", Aug
|
|
* 2026 bundle). Never-seen SSH sources still get a `default` seed so the
|
|
* device is clickable; never-seen remote sources stay empty (no seed) since
|
|
* an unreachable URL is not evidence a backend exists there.
|
|
*/
|
|
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 === 'local') {
|
|
return enumeration
|
|
}
|
|
|
|
if (cached && cached.length > 0) {
|
|
return { profiles: cached, error: enumeration.error }
|
|
}
|
|
|
|
if (kind === 'ssh' && enumeration.error === 'connect-on-demand') {
|
|
return { profiles: ['default'], error: 'connect-on-demand' }
|
|
}
|
|
|
|
return enumeration
|
|
}
|
|
|
|
/** Whether an undialed SSH source should be inventoried again. Cached
|
|
* successes never retry. Failures retry after `retryAfterMs` so a cold box
|
|
* does not stay seeded as `default` until the user hits Test. */
|
|
export function shouldRetrySshInventory(
|
|
hasCache: boolean,
|
|
lastAttemptMs: null | number | undefined,
|
|
nowMs: number,
|
|
retryAfterMs = 60_000
|
|
): boolean {
|
|
if (hasCache) {
|
|
return false
|
|
}
|
|
|
|
if (lastAttemptMs == null) {
|
|
return true
|
|
}
|
|
|
|
return nowMs - lastAttemptMs >= retryAfterMs
|
|
}
|
|
|
|
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
|
|
* policy is testable without IPC; main.ts feeds it live enumerations.
|
|
*/
|
|
export function buildAgentRoster(
|
|
enumerations: ConnectionAgents[],
|
|
opts: { primaryConnectionId?: string } = {}
|
|
): RosterAgent[] {
|
|
// A connection can transiently report the same profile more than once (or
|
|
// arrive twice while registry state is reconciling). A roster row represents
|
|
// one routable identity, so collapse strictly by connection + profile before
|
|
// counting names for @name-device disambiguation.
|
|
const identities = new Map<
|
|
string,
|
|
{
|
|
connection: RegistryConnection
|
|
installId?: string
|
|
order: number
|
|
profile: string
|
|
profileMetadata?: RosterProfileMetadata
|
|
}
|
|
>()
|
|
|
|
let order = 0
|
|
|
|
for (const { connection, installId, profiles, profileMetadata } of enumerations) {
|
|
for (const profile of profiles || []) {
|
|
const name = String(profile || '').trim() || 'default'
|
|
const key = `${connection.id}\0${name}`
|
|
|
|
if (!identities.has(key)) {
|
|
identities.set(key, {
|
|
connection,
|
|
installId,
|
|
order,
|
|
profile: name,
|
|
...(profileMetadata?.[name] ? { profileMetadata: profileMetadata[name] } : {})
|
|
})
|
|
}
|
|
}
|
|
|
|
order += 1
|
|
}
|
|
|
|
// Backend-identity collapse: two connections reporting the same install_id
|
|
// are the SAME physical install registered under two addresses, so their
|
|
// (install, profile) rows are one bot, not two. Connections without an id
|
|
// (older backends, undialed ssh) keep a per-connection key — no collapse.
|
|
const backends = new Map<
|
|
string,
|
|
{ connection: RegistryConnection; order: number; profile: string; profileMetadata?: RosterProfileMetadata }[]
|
|
>()
|
|
|
|
for (const { connection, installId, order: rank, profile, profileMetadata } of identities.values()) {
|
|
const key = installId ? `id:${installId}\0${profile}` : `conn:${connection.id}\0${profile}`
|
|
const group = backends.get(key)
|
|
|
|
if (group) {
|
|
group.push({ connection, order: rank, profile, profileMetadata })
|
|
} else {
|
|
backends.set(key, [{ connection, order: rank, profile, profileMetadata }])
|
|
}
|
|
}
|
|
|
|
const rows = [...backends.values()].map(group => pickCanonicalConnection(group, opts.primaryConnectionId))
|
|
|
|
// The @name-device duplicate-handle rule runs AFTER the collapse, so a
|
|
// profile that only *looked* duplicated (one box, two addresses) keeps its
|
|
// bare name once the rows are recognized as one backend.
|
|
const counts = new Map<string, number>()
|
|
|
|
for (const { profile } of rows) {
|
|
counts.set(profile, (counts.get(profile) || 0) + 1)
|
|
}
|
|
|
|
const roster: RosterAgent[] = []
|
|
|
|
for (const { connection, profile, profileMetadata } of rows) {
|
|
roster.push({
|
|
connectionId: connection.id,
|
|
connectionKind: connection.kind,
|
|
connectionLabel: connection.label,
|
|
profile,
|
|
targetProfile: connection.remoteProfile || profile,
|
|
handle: agentHandle(profile, connection.label, (counts.get(profile) || 0) > 1),
|
|
...(profileMetadata ? { profileMetadata } : {})
|
|
})
|
|
}
|
|
|
|
return roster
|
|
}
|
|
|
|
/** Deterministic route priority for same-backend rows: local is definitionally
|
|
* this box; ssh beats HTTP remotes; cloud last. */
|
|
const CANONICAL_KIND_PRIORITY: Record<ConnectionKind, number> = { cloud: 3, local: 0, remote: 2, ssh: 1 }
|
|
|
|
/**
|
|
* Which connection represents a collapsed same-backend roster row: the ACTIVE
|
|
* (primary) connection when it is one of the candidates — the row should route
|
|
* where the window already routes — else the highest kind priority, else the
|
|
* earliest-registered (enumeration order follows registry order).
|
|
*/
|
|
function pickCanonicalConnection<T extends { connection: RegistryConnection; order: number }>(
|
|
candidates: T[],
|
|
primaryConnectionId?: string
|
|
): T {
|
|
const active = primaryConnectionId ? candidates.find(c => c.connection.id === primaryConnectionId) : undefined
|
|
|
|
if (active) {
|
|
return active
|
|
}
|
|
|
|
return [...candidates].sort(
|
|
(a, b) =>
|
|
CANONICAL_KIND_PRIORITY[a.connection.kind] - CANONICAL_KIND_PRIORITY[b.connection.kind] || a.order - b.order
|
|
)[0]
|
|
}
|
|
|
|
// ── Fan-out update eligibility ──────────────────────────────────────────────
|
|
|
|
export interface UpdateEligibility {
|
|
eligible: boolean
|
|
/** Present when not eligible: 'cloud-managed' (platform updates it). */
|
|
reason?: 'cloud-managed'
|
|
}
|
|
|
|
/**
|
|
* Whether "Update all instances" may drive this connection. Hermes Cloud
|
|
* instances are platform-managed — we never run `hermes update` against them.
|
|
* Local, remote, and ssh sources are all eligible (reachability and busy
|
|
* checks happen at dispatch time, not here).
|
|
*/
|
|
export function updateEligibility(connection: RegistryConnection): UpdateEligibility {
|
|
if (connection.kind === 'cloud') {
|
|
return { eligible: false, reason: 'cloud-managed' }
|
|
}
|
|
|
|
return { eligible: true }
|
|
}
|
|
|
|
/** Mint a registry-unique id from a label (slug, then -2/-3… suffixes). */
|
|
export function connectionIdForLabel(label: string, taken: Iterable<string>): string {
|
|
const used = new Set([...taken])
|
|
const base = labelSlug(label)
|
|
|
|
if (!used.has(base) && base !== LOCAL_CONNECTION_ID) {
|
|
return base
|
|
}
|
|
|
|
for (let n = 2; ; n += 1) {
|
|
const candidate = `${base}-${n}`
|
|
|
|
if (!used.has(candidate) && candidate !== LOCAL_CONNECTION_ID) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Validation ──────────────────────────────────────────────────────────────
|
|
|
|
export interface ConnectionInput {
|
|
id?: string
|
|
kind: ConnectionKind
|
|
label: string
|
|
url?: string
|
|
authMode?: string
|
|
token?: unknown
|
|
headers?: Record<string, unknown>
|
|
org?: string
|
|
host?: string
|
|
user?: string
|
|
port?: number | string
|
|
keyPath?: string
|
|
remoteHermesPath?: string
|
|
remoteProfile?: string
|
|
}
|
|
|
|
/**
|
|
* Validate + normalize a save payload into a RegistryConnection.
|
|
* Throws with a user-facing message on any violation. `registry` supplies the
|
|
* uniqueness context; when `input.id` matches an existing entry this is an
|
|
* edit and that entry is excluded from the label-collision check.
|
|
*/
|
|
export function normalizeConnectionInput(input: ConnectionInput, registry: ConnectionRegistry): RegistryConnection {
|
|
const label = String(input.label || '').trim()
|
|
|
|
if (!label) {
|
|
throw new Error('Every connection needs a name. Give this instance a device name (e.g. "Homelab", "Work laptop").')
|
|
}
|
|
|
|
if (label.length > LABEL_MAX) {
|
|
throw new Error(`Connection name is too long (max ${LABEL_MAX} characters).`)
|
|
}
|
|
|
|
const key = labelKey(label)
|
|
const collision = registry.connections.find(c => labelKey(c.label) === key && c.id !== input.id)
|
|
|
|
if (collision) {
|
|
throw new Error(`A connection named "${collision.label}" already exists. Connection names must be unique.`)
|
|
}
|
|
|
|
const kind = input.kind
|
|
|
|
if (kind === 'local') {
|
|
// The local entry is managed by the app; only its label is editable.
|
|
return { id: LOCAL_CONNECTION_ID, kind: 'local', label }
|
|
}
|
|
|
|
// The reserved local id can never be claimed by a non-local entry — a
|
|
// crafted IPC payload ({id:'local', kind:'remote', …}) would otherwise
|
|
// replace the local entry via upsert and break the exactly-one-local
|
|
// invariant. connectionIdForLabel never mints 'local'; reject it when
|
|
// supplied, too.
|
|
if (input.id === LOCAL_CONNECTION_ID) {
|
|
throw new Error('The id "local" is reserved for the local connection.')
|
|
}
|
|
|
|
const id =
|
|
input.id ||
|
|
connectionIdForLabel(
|
|
label,
|
|
registry.connections.map(c => c.id)
|
|
)
|
|
|
|
if (kind === 'ssh') {
|
|
const ssh = normalizeSshConfig({
|
|
mode: 'ssh',
|
|
host: input.host,
|
|
user: input.user,
|
|
port: input.port,
|
|
keyPath: input.keyPath,
|
|
remoteHermesPath: input.remoteHermesPath,
|
|
remoteProfile: input.remoteProfile
|
|
})
|
|
|
|
if (!ssh) {
|
|
throw new Error('SSH connections need a host.')
|
|
}
|
|
|
|
const { mode: _mode, ...sshFields } = ssh
|
|
|
|
// Duplicate prevention (enforced here so a crafted IPC payload can't slip
|
|
// past the editor's check): two ssh entries collide on the same
|
|
// user@host:port + remote profile.
|
|
const sshKey = (c: { host?: string; port?: number; remoteProfile?: string; user?: string }) =>
|
|
`${(c.user || '').toLowerCase()}@${(c.host || '').toLowerCase()}:${c.port ?? 22}::${(c.remoteProfile || '').trim()}`
|
|
|
|
const sshDupe = registry.connections.find(c => c.kind === 'ssh' && c.id !== id && sshKey(c) === sshKey(sshFields))
|
|
|
|
if (sshDupe) {
|
|
throw new Error(`A connection to this SSH host already exists ("${sshDupe.label}").`)
|
|
}
|
|
|
|
return { id, kind: 'ssh', label, ...sshFields }
|
|
}
|
|
|
|
if (kind === 'remote' || kind === 'cloud') {
|
|
// normalizeRemoteBaseUrl throws its own user-facing message on bad input.
|
|
const url = normalizeRemoteBaseUrl(input.url)
|
|
|
|
// Duplicate prevention: remote/cloud entries collide on the normalized URL
|
|
// (trimmed, trailing slashes stripped, lowercased) regardless of kind — a
|
|
// cloud entry and a remote entry pointing at the same gateway are dupes.
|
|
const urlKey = (value: string) => value.trim().replace(/\/+$/, '').toLowerCase()
|
|
|
|
const urlDupe = registry.connections.find(
|
|
c => (c.kind === 'remote' || c.kind === 'cloud') && c.id !== id && urlKey(c.url || '') === urlKey(url)
|
|
)
|
|
|
|
if (urlDupe) {
|
|
throw new Error(`A connection to this gateway URL already exists ("${urlDupe.label}").`)
|
|
}
|
|
|
|
const authMode = normAuthMode(input.authMode)
|
|
const entry: RegistryConnection = { id, kind, label, url, authMode }
|
|
|
|
// A token is only meaningful for token-auth remotes. Dropping it here is
|
|
// what clears the stale envelope when an entry is switched token→oauth
|
|
// (or is a cloud entry, which authenticates via the portal session) —
|
|
// otherwise dead secret material rides along on the edited entry.
|
|
if (input.token !== undefined && kind === 'remote' && authMode === 'token') {
|
|
entry.token = input.token
|
|
}
|
|
|
|
// Extra gateway headers (access-proxy credentials) apply to any
|
|
// remote-shaped entry regardless of auth mode — Cloudflare Access sits in
|
|
// front of both token- and OAuth-gated gateways. Normalization drops
|
|
// transport-/Hermes-managed names; an empty result stores nothing.
|
|
if (input.headers !== undefined) {
|
|
const headers = normalizeRemoteHeaders(input.headers)
|
|
|
|
if (Object.keys(headers).length > 0) {
|
|
entry.headers = headers
|
|
}
|
|
}
|
|
|
|
const org = String(input.org || '').trim()
|
|
|
|
if (kind === 'cloud' && org) {
|
|
entry.org = org
|
|
}
|
|
|
|
return entry
|
|
}
|
|
|
|
throw new Error(`Unknown connection kind: ${String(kind)}`)
|
|
}
|
|
|
|
/**
|
|
* Merge a (possibly partial) edit payload over the stored entry so fields the
|
|
* editor doesn't carry survive a save. Renaming a migrated cloud entry must
|
|
* not drop its `org` (downstream update-fanout uses it to skip
|
|
* platform-managed instances), and renaming an ssh entry must not drop
|
|
* `remoteHermesPath`/`remoteProfile`. Only fields the payload explicitly
|
|
* carries (non-undefined) override; `token` is deliberately NOT merged here —
|
|
* the caller owns secret handling.
|
|
*/
|
|
export function mergeConnectionInput(input: ConnectionInput, existing?: null | RegistryConnection): ConnectionInput {
|
|
if (!existing || existing.kind !== input.kind) {
|
|
return input
|
|
}
|
|
|
|
const merged: ConnectionInput = { ...input }
|
|
|
|
const inherit = (field: keyof ConnectionInput & keyof RegistryConnection) => {
|
|
if (merged[field] === undefined && existing[field] !== undefined) {
|
|
;(merged as unknown as Record<string, unknown>)[field] = existing[field]
|
|
}
|
|
}
|
|
|
|
inherit('url')
|
|
inherit('authMode')
|
|
inherit('org')
|
|
inherit('host')
|
|
inherit('keyPath')
|
|
inherit('remoteHermesPath')
|
|
inherit('remoteProfile')
|
|
// Headers inherit like other dial fields: an edit payload that omits the
|
|
// field keeps the stored set; an explicit payload (even {}) is
|
|
// authoritative so the editor can clear them.
|
|
inherit('headers')
|
|
|
|
// ssh user/port: the editor shows ONE composite host field (user@host:port),
|
|
// and normalizeSshConfig gives explicit user/port fields precedence over the
|
|
// parsed host string. Inheriting stored user/port alongside a NEW host string
|
|
// would resurrect the old values over what the user just typed — so when the
|
|
// payload carries a host, the host string is authoritative and stored
|
|
// user/port are NOT inherited.
|
|
if (input.host === undefined || !String(input.host).trim()) {
|
|
inherit('user')
|
|
inherit('port')
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
/**
|
|
* True when an edit changes how a connection is DIALED — endpoint, auth, or
|
|
* ssh routing fields — as opposed to a cosmetic label rename. Callers use
|
|
* this to decide whether live pooled backends / renderer sockets for the
|
|
* connection must be recycled after a save: a label-only edit keeps traffic
|
|
* flowing, while a url/token/host change means everything currently open
|
|
* points at the OLD target and must be torn down and re-dialed.
|
|
*/
|
|
export function connectionDialFieldsChanged(before: RegistryConnection, after: RegistryConnection): boolean {
|
|
if (before.kind !== after.kind) {
|
|
return true
|
|
}
|
|
|
|
const fields: (keyof RegistryConnection)[] = [
|
|
'url',
|
|
'authMode',
|
|
'org',
|
|
'host',
|
|
'user',
|
|
'port',
|
|
'keyPath',
|
|
'remoteHermesPath',
|
|
'remoteProfile'
|
|
]
|
|
|
|
for (const field of fields) {
|
|
if ((before[field] ?? null) !== (after[field] ?? null)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Token envelopes are opaque here (main.ts encrypts). An edit that carries
|
|
// no new token inherits the stored envelope verbatim, so structural
|
|
// equality is exact for the label-only case.
|
|
if (JSON.stringify(before.token ?? null) !== JSON.stringify(after.token ?? null)) {
|
|
return true
|
|
}
|
|
|
|
// Headers are dial material too: a changed access-proxy credential means
|
|
// every open socket/backend authenticated with the OLD set.
|
|
return JSON.stringify(before.headers ?? null) !== JSON.stringify(after.headers ?? null)
|
|
}
|
|
|
|
// ── Registry-level operations (all pure: return a new registry) ────────────
|
|
|
|
function localEntry(label = 'This device'): RegistryConnection {
|
|
return { id: LOCAL_CONNECTION_ID, kind: 'local', label }
|
|
}
|
|
|
|
/**
|
|
* Coerce arbitrary parsed JSON into a valid registry: version stamped, a
|
|
* local entry guaranteed, labels de-duplicated defensively (suffix, never
|
|
* drop), primary always pointing at an existing entry. A hand-edited or
|
|
* corrupt file degrades to a minimal local-only registry rather than
|
|
* throwing at boot.
|
|
*/
|
|
export function normalizeRegistry(raw: unknown): ConnectionRegistry {
|
|
const parsed = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
|
const rawConnections = Array.isArray(parsed.connections) ? parsed.connections : []
|
|
const seenLabels = new Set<string>()
|
|
const seenIds = new Set<string>()
|
|
const connections: RegistryConnection[] = []
|
|
const quarantined: QuarantinedRegistryEntry[] = []
|
|
|
|
const quarantine = (reason: string, entry: unknown) => {
|
|
if (quarantined.length < REGISTRY_QUARANTINE_CAP) {
|
|
quarantined.push({ reason, entry })
|
|
}
|
|
}
|
|
|
|
// Entries quarantined by a previous load are user data too — carry them
|
|
// through every subsequent normalize/write cycle rather than dropping them
|
|
// the first time the file is rewritten.
|
|
if (Array.isArray(parsed.quarantined)) {
|
|
for (const item of parsed.quarantined) {
|
|
if (item && typeof item === 'object' && 'entry' in (item as Record<string, unknown>)) {
|
|
quarantine(
|
|
String((item as Record<string, unknown>).reason || 'unknown'),
|
|
(item as Record<string, unknown>).entry
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Best-effort plain-data copy for entries that blew up mid-normalization —
|
|
// the raw object may carry whatever poisoned it, so never persist it as-is.
|
|
const safeEntryCopy = (item: unknown) => {
|
|
try {
|
|
return JSON.parse(JSON.stringify(item))
|
|
} catch {
|
|
return { unserializable: true }
|
|
}
|
|
}
|
|
|
|
for (const item of rawConnections) {
|
|
if (!item) {
|
|
continue // null/false/'' carry no user data
|
|
}
|
|
|
|
if (typeof item !== 'object') {
|
|
// A string/number here is usually a mangled hand-edit — still user data.
|
|
quarantine('entry-malformed', item)
|
|
|
|
continue
|
|
}
|
|
|
|
// One bad entry must never abort the whole registry load (#94246): any
|
|
// unexpected throw quarantines THIS entry and the loop moves on.
|
|
try {
|
|
const entry = item as Record<string, unknown>
|
|
const kind = entry.kind
|
|
|
|
if (kind !== 'local' && kind !== 'remote' && kind !== 'cloud' && kind !== 'ssh') {
|
|
quarantine('entry-unrecognized-kind', item)
|
|
|
|
continue
|
|
}
|
|
|
|
let label = String(entry.label || '').trim()
|
|
|
|
if (!label) {
|
|
// Defensive: registry entries are always written with labels, but a
|
|
// hand-edited file may drop one. Derive rather than discard.
|
|
label =
|
|
kind === 'ssh' ? String(entry.host || 'ssh') : hostLabelFromBaseUrl(String(entry.url || '')) || String(kind)
|
|
}
|
|
|
|
label = uniqueLabel(label, seenLabels)
|
|
|
|
let id = kind === 'local' ? LOCAL_CONNECTION_ID : String(entry.id || '').trim()
|
|
|
|
if (!id || (seenIds.has(id) && kind !== 'local')) {
|
|
id = connectionIdForLabel(label, seenIds)
|
|
}
|
|
|
|
if (seenIds.has(id)) {
|
|
continue // second 'local' entry — first one wins
|
|
}
|
|
|
|
seenLabels.add(labelKey(label))
|
|
seenIds.add(id)
|
|
|
|
const clean: RegistryConnection = { id, kind, label }
|
|
|
|
if (kind === 'remote' || kind === 'cloud') {
|
|
const url = String(entry.url || '').trim()
|
|
|
|
if (!url) {
|
|
quarantine('entry-missing-url', item)
|
|
|
|
continue
|
|
}
|
|
|
|
clean.url = url
|
|
clean.authMode = normAuthMode(entry.authMode)
|
|
|
|
if (entry.token !== undefined) {
|
|
clean.token = entry.token
|
|
}
|
|
|
|
const storedHeaders = normalizeRemoteHeaders(entry.headers)
|
|
|
|
if (Object.keys(storedHeaders).length > 0) {
|
|
clean.headers = storedHeaders
|
|
}
|
|
|
|
const org = String(entry.org || '').trim()
|
|
|
|
if (kind === 'cloud' && org) {
|
|
clean.org = org
|
|
}
|
|
} else if (kind === 'ssh') {
|
|
const ssh = normalizeSshConfig({ ...entry, mode: 'ssh' })
|
|
|
|
if (!ssh) {
|
|
quarantine('entry-missing-ssh-host', item)
|
|
|
|
continue
|
|
}
|
|
|
|
const { mode: _mode, ...sshFields } = ssh
|
|
Object.assign(clean, sshFields)
|
|
}
|
|
|
|
connections.push(clean)
|
|
} catch {
|
|
quarantine('entry-normalization-failed', safeEntryCopy(item))
|
|
}
|
|
}
|
|
|
|
if (!connections.some(c => c.kind === 'local')) {
|
|
connections.unshift(localEntry())
|
|
}
|
|
|
|
const storedPrimary = String(parsed.primary || '').trim()
|
|
const primary = connections.some(c => c.id === storedPrimary) ? storedPrimary : LOCAL_CONNECTION_ID
|
|
const storedLastUsed = String(parsed.lastUsed || '').trim()
|
|
|
|
const normalized: ConnectionRegistry = {
|
|
version: REGISTRY_VERSION,
|
|
primary,
|
|
launchMode: parsed.launchMode === 'last-used' ? 'last-used' : 'primary',
|
|
lastUsed: connections.some(c => c.id === storedLastUsed) ? storedLastUsed : primary,
|
|
connections
|
|
}
|
|
|
|
if (quarantined.length > 0) {
|
|
normalized.quarantined = quarantined
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
/**
|
|
* One-time import of the v1 connection.json shape (global `mode` + `remote`
|
|
* block + per-profile `profiles` map) into a v2 registry. v1 had no labels,
|
|
* so they are derived (URL host, SSH host, "This device") and uniqued by
|
|
* suffixing. The active v1 global connection becomes the primary. The v1
|
|
* file is left untouched by the caller — old builds keep working.
|
|
*
|
|
* Per-profile override entries become registry connections too (deduped by
|
|
* URL/host against the global block), so a user who had `research` pinned to
|
|
* a second gateway sees both sources registered on first launch.
|
|
*/
|
|
export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
|
|
const config = v1 && typeof v1 === 'object' ? (v1 as Record<string, any>) : {}
|
|
const connections: RegistryConnection[] = [localEntry()]
|
|
const byFingerprint = new Map<string, RegistryConnection>()
|
|
|
|
const addRemoteLike = (block: Record<string, any>, kind: 'cloud' | 'remote'): null | RegistryConnection => {
|
|
const url = String(block?.url || '').trim()
|
|
|
|
if (!url) {
|
|
return null
|
|
}
|
|
|
|
const fingerprint = `${kind}:${url}`
|
|
const existing = byFingerprint.get(fingerprint)
|
|
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
|
|
const label = uniqueLabel(
|
|
hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'),
|
|
connections.map(c => c.label)
|
|
)
|
|
|
|
const entry: RegistryConnection = {
|
|
id: connectionIdForLabel(
|
|
label,
|
|
connections.map(c => c.id)
|
|
),
|
|
kind,
|
|
label,
|
|
url,
|
|
authMode: normAuthMode(block.authMode)
|
|
}
|
|
|
|
if (block.token !== undefined) {
|
|
entry.token = block.token
|
|
}
|
|
|
|
const v1Headers = normalizeRemoteHeaders(block.headers)
|
|
|
|
if (Object.keys(v1Headers).length > 0) {
|
|
entry.headers = v1Headers
|
|
}
|
|
|
|
const org = String(block.org || '').trim()
|
|
|
|
if (kind === 'cloud' && org) {
|
|
entry.org = org
|
|
}
|
|
|
|
connections.push(entry)
|
|
byFingerprint.set(fingerprint, entry)
|
|
|
|
return entry
|
|
}
|
|
|
|
const addSsh = (block: Record<string, any>): null | RegistryConnection => {
|
|
const ssh = normalizeSshConfig({ ...block, mode: 'ssh' })
|
|
|
|
if (!ssh) {
|
|
return null
|
|
}
|
|
|
|
const fingerprint = `ssh:${ssh.user || ''}@${ssh.host}:${ssh.port || 22}`
|
|
const existing = byFingerprint.get(fingerprint)
|
|
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
|
|
const label = uniqueLabel(
|
|
ssh.host,
|
|
connections.map(c => c.label)
|
|
)
|
|
|
|
const { mode: _mode, ...sshFields } = ssh
|
|
|
|
const entry: RegistryConnection = {
|
|
id: connectionIdForLabel(
|
|
label,
|
|
connections.map(c => c.id)
|
|
),
|
|
kind: 'ssh',
|
|
label,
|
|
...sshFields
|
|
}
|
|
|
|
connections.push(entry)
|
|
byFingerprint.set(fingerprint, entry)
|
|
|
|
return entry
|
|
}
|
|
|
|
// Global connection → an entry + the primary designation.
|
|
let primary = LOCAL_CONNECTION_ID
|
|
const globalMode = config.mode
|
|
|
|
if (modeIsRemoteLike(globalMode)) {
|
|
const entry = addRemoteLike(config.remote || {}, globalMode === 'cloud' ? 'cloud' : 'remote')
|
|
|
|
if (entry) {
|
|
primary = entry.id
|
|
}
|
|
} else if (globalMode === 'ssh') {
|
|
const entry = addSsh(config.remote || {})
|
|
|
|
if (entry) {
|
|
primary = entry.id
|
|
}
|
|
}
|
|
|
|
// Per-profile overrides → additional registered sources (deduped).
|
|
const profiles = config.profiles && typeof config.profiles === 'object' ? config.profiles : {}
|
|
|
|
for (const block of Object.values(profiles) as Record<string, any>[]) {
|
|
if (!block || typeof block !== 'object') {
|
|
continue
|
|
}
|
|
|
|
if (modeIsRemoteLike(block.mode)) {
|
|
addRemoteLike(block, block.mode === 'cloud' ? 'cloud' : 'remote')
|
|
} else if (block.mode === 'ssh') {
|
|
addSsh(block)
|
|
} else if (block.mode === 'local' && block.savedSsh) {
|
|
addSsh(block.savedSsh)
|
|
}
|
|
}
|
|
|
|
return { version: REGISTRY_VERSION, primary, launchMode: 'primary', lastUsed: primary, connections }
|
|
}
|
|
|
|
/** Insert or replace by id. Input must already be normalized/validated. */
|
|
export function upsertConnection(registry: ConnectionRegistry, entry: RegistryConnection): ConnectionRegistry {
|
|
const connections = registry.connections.some(c => c.id === entry.id)
|
|
? registry.connections.map(c => (c.id === entry.id ? entry : c))
|
|
: [...registry.connections, entry]
|
|
|
|
return { ...registry, connections }
|
|
}
|
|
|
|
/**
|
|
* Remove a connection. The local entry is not removable; removing the
|
|
* current primary retargets primary to local.
|
|
*/
|
|
export function removeConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry {
|
|
const target = registry.connections.find(c => c.id === id)
|
|
|
|
if (!target) {
|
|
return registry
|
|
}
|
|
|
|
if (target.kind === 'local') {
|
|
throw new Error('The local connection cannot be removed.')
|
|
}
|
|
|
|
const primary = registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary
|
|
|
|
return {
|
|
...registry,
|
|
primary,
|
|
lastUsed: registry.lastUsed === id ? primary : registry.lastUsed,
|
|
connections: registry.connections.filter(c => c.id !== id)
|
|
}
|
|
}
|
|
|
|
/** Point the window/primary backend at another registered connection. */
|
|
export function setPrimaryConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry {
|
|
if (!registry.connections.some(c => c.id === id)) {
|
|
throw new Error(`No connection with id "${id}".`)
|
|
}
|
|
|
|
return { ...registry, primary: id }
|
|
}
|
|
|
|
/** Remember the last source the Sessions workspace opened successfully. */
|
|
export function setLastUsedConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry {
|
|
if (!registry.connections.some(c => c.id === id)) {
|
|
throw new Error(`No connection with id "${id}".`)
|
|
}
|
|
|
|
return { ...registry, lastUsed: id }
|
|
}
|
|
|
|
/**
|
|
* Reconcile a successfully-coerced global v1 connection config into the v2
|
|
* registry. Settings still writes connection.json for compatibility, but an
|
|
* Apply must publish the same primary identity to connections.json in the
|
|
* same transaction or the live remote descriptor has no connectionId.
|
|
*
|
|
* Remote-shaped entries are matched by normalized URL across remote/cloud so
|
|
* changing provenance never duplicates a gateway. Existing identity and
|
|
* user-chosen label win; a new entry derives both from the host. Switching to
|
|
* local keeps registered remotes available while moving primary/last-used
|
|
* back to This device.
|
|
*/
|
|
export function reconcileAppliedGlobalConnection(
|
|
registry: ConnectionRegistry,
|
|
config: Record<string, any>
|
|
): ConnectionRegistry {
|
|
const mode = config?.mode
|
|
|
|
if (!modeIsRemoteLike(mode)) {
|
|
if (mode === 'local') {
|
|
return { ...registry, primary: LOCAL_CONNECTION_ID, lastUsed: LOCAL_CONNECTION_ID }
|
|
}
|
|
|
|
// SSH registry identity is managed by its existing registry editor and
|
|
// migration path. Do not reinterpret or delete it here.
|
|
return registry
|
|
}
|
|
|
|
const block = config.remote && typeof config.remote === 'object' ? config.remote : {}
|
|
const url = normalizeRemoteBaseUrl(block.url)
|
|
|
|
const existing = registry.connections.find(connection => {
|
|
if (connection.kind !== 'remote' && connection.kind !== 'cloud') {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
return normalizeRemoteBaseUrl(connection.url) === url
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
const kind: ConnectionKind = mode === 'cloud' ? 'cloud' : 'remote'
|
|
|
|
const label =
|
|
existing?.label ||
|
|
uniqueLabel(
|
|
hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'),
|
|
registry.connections.map(connection => connection.label)
|
|
)
|
|
|
|
const entry = normalizeConnectionInput(
|
|
{
|
|
id: existing?.id,
|
|
kind,
|
|
label,
|
|
url,
|
|
authMode: block.authMode,
|
|
token: block.token,
|
|
headers: block.headers,
|
|
org: block.org
|
|
},
|
|
registry
|
|
)
|
|
|
|
return {
|
|
...upsertConnection(registry, entry),
|
|
primary: entry.id,
|
|
lastUsed: entry.id
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Heal an already-created registry that never learned about the v1 route it is
|
|
* supposed to be serving.
|
|
*
|
|
* `migrateV1ToRegistry` runs exactly once — only when connections.json does not
|
|
* exist. A user who was local at that moment and configured a remote gateway
|
|
* afterwards (Settings -> Gateway writes connection.json alone) ends up with a
|
|
* live remote that the registry cannot name: `resolvedConnectionId` returns
|
|
* null, `primary` still says `local`, and every launch force-switches the
|
|
* window onto a fresh local backend seconds after boot. Deleting
|
|
* connections.json by hand is the only recovery today.
|
|
*
|
|
* Deliberately narrow: heal ONLY when the v1 global route has no matching
|
|
* registry entry at all. That is the drift state and nothing else. If the
|
|
* route is already registered but `primary` names another source, the user
|
|
* chose that in the Connections panel and we leave it alone.
|
|
*
|
|
* SSH drifts the same way remote does: a v1 global `mode:'ssh'` route (host,
|
|
* no url) written by Settings after the one-shot migration has no registry
|
|
* identity, so `resolvedConnectionId` returns null, `primary` stays `local`,
|
|
* and every launch re-homes the window onto a local backend — and because the
|
|
* heal used to skip SSH entirely, the two files re-drifted after every update
|
|
* relaunch instead of converging once.
|
|
*/
|
|
export function reconcileRegistryDrift(
|
|
registry: ConnectionRegistry,
|
|
v1: unknown
|
|
): { changed: boolean; registry: ConnectionRegistry } {
|
|
const config = v1 && typeof v1 === 'object' ? (v1 as Record<string, any>) : {}
|
|
const unchanged = { changed: false, registry }
|
|
|
|
if (config.mode === 'ssh') {
|
|
const ssh = normalizeSshConfig({
|
|
...(config.remote && typeof config.remote === 'object' ? config.remote : {}),
|
|
mode: 'ssh'
|
|
})
|
|
|
|
if (!ssh) {
|
|
// A v1 SSH route without a usable host is not a route we can register.
|
|
return unchanged
|
|
}
|
|
|
|
const target = normalizedSshTarget(ssh)
|
|
|
|
const alreadyRegistered = registry.connections.some(
|
|
connection =>
|
|
connection.kind === 'ssh' &&
|
|
normalizedSshTarget(connection) === target &&
|
|
(connection.port ?? 22) === (ssh.port ?? 22)
|
|
)
|
|
|
|
if (alreadyRegistered) {
|
|
// Route is known; if primary names another source, that is the user's
|
|
// Connections-panel choice, not drift.
|
|
return unchanged
|
|
}
|
|
|
|
const { mode: _mode, ...sshFields } = ssh
|
|
|
|
let entry: RegistryConnection
|
|
|
|
try {
|
|
entry = normalizeConnectionInput(
|
|
{
|
|
kind: 'ssh',
|
|
label: uniqueLabel(
|
|
ssh.host,
|
|
registry.connections.map(connection => connection.label)
|
|
),
|
|
...sshFields
|
|
},
|
|
registry
|
|
)
|
|
} catch {
|
|
// Validation failure (e.g. a crafted collision) must not corrupt the
|
|
// registry; the v1 path keeps failing the way it already does.
|
|
return unchanged
|
|
}
|
|
|
|
return {
|
|
changed: true,
|
|
registry: { ...upsertConnection(registry, entry), primary: entry.id, lastUsed: entry.id }
|
|
}
|
|
}
|
|
|
|
if (!modeIsRemoteLike(config.mode)) {
|
|
return unchanged
|
|
}
|
|
|
|
const block = config.remote && typeof config.remote === 'object' ? config.remote : {}
|
|
|
|
let url = ''
|
|
|
|
try {
|
|
url = normalizeRemoteBaseUrl(block.url)
|
|
} catch {
|
|
// An unparseable v1 URL is not a route we can register. The v1 path keeps
|
|
// failing the way it already does; do not corrupt the registry over it.
|
|
return unchanged
|
|
}
|
|
|
|
if (!url) {
|
|
return unchanged
|
|
}
|
|
|
|
const alreadyRegistered = registry.connections.some(connection => {
|
|
if (connection.kind !== 'remote' && connection.kind !== 'cloud') {
|
|
return false
|
|
}
|
|
|
|
try {
|
|
return normalizeRemoteBaseUrl(connection.url) === url
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
if (alreadyRegistered) {
|
|
return unchanged
|
|
}
|
|
|
|
return { changed: true, registry: reconcileAppliedGlobalConnection(registry, config) }
|
|
}
|
|
|
|
/** Choose whether launch restores the explicit primary or the last-used source. */
|
|
export function setConnectionLaunchMode(registry: ConnectionRegistry, launchMode: string): ConnectionRegistry {
|
|
if (launchMode !== 'last-used' && launchMode !== 'primary') {
|
|
throw new Error(`Unknown connection launch mode "${String(launchMode)}".`)
|
|
}
|
|
|
|
return { ...registry, launchMode }
|
|
}
|