Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions apps/desktop/electron/connection-config-apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'

import { applyConnectionConfigAtomically } from './connection-config-apply'

describe('applyConnectionConfigAtomically', () => {
it('commits legacy and registry state before activation', async () => {
const events: string[] = []

await applyConnectionConfigAtomically({
previousConfig: 'old-config',
previousRegistry: 'old-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
writeConfig: value => events.push(`config:${value}`),
writeRegistry: value => events.push(`registry:${value}`),
apply: async () => {
events.push('activate')
}
})

expect(events).toEqual(['config:remote-config', 'registry:remote-registry', 'activate'])
})

it('rolls both stores back when activation fails', async () => {
const writeConfig = vi.fn()
const writeRegistry = vi.fn()

await expect(
applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
writeConfig,
writeRegistry,
apply: async () => {
throw new Error('activation failed')
}
})
).rejects.toThrow('activation failed')

expect(writeConfig.mock.calls).toEqual([['remote-config'], ['local-config']])
expect(writeRegistry.mock.calls).toEqual([['remote-registry'], ['local-registry']])
})

it('rolls legacy state back when the registry write fails', async () => {
const writes: string[] = []
let registryWrites = 0

await expect(
applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
writeConfig: value => writes.push(`config:${value}`),
writeRegistry: value => {
registryWrites += 1

if (registryWrites === 1) {
throw new Error('disk full')
}

writes.push(`registry:${value}`)
},
apply: vi.fn()
})
).rejects.toThrow('disk full')

expect(writes).toEqual(['config:remote-config', 'config:local-config', 'registry:local-registry'])
})

it('preflights before writing either store', async () => {
const events: string[] = []

await applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
preflight: async () => {
events.push('preflight')
},
writeConfig: value => events.push(`config:${value}`),
writeRegistry: value => events.push(`registry:${value}`),
apply: async () => {
events.push('activate')
}
})

expect(events).toEqual(['preflight', 'config:remote-config', 'registry:remote-registry', 'activate'])
})

it('leaves both stores untouched when the preflight rejects', async () => {
const writeConfig = vi.fn()
const writeRegistry = vi.fn()
const apply = vi.fn()

await expect(
applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
preflight: async () => {
throw new Error('gateway unreachable')
},
writeConfig,
writeRegistry,
apply
})
).rejects.toThrow('gateway unreachable')

expect(writeConfig).not.toHaveBeenCalled()
expect(writeRegistry).not.toHaveBeenCalled()
expect(apply).not.toHaveBeenCalled()
})
})
53 changes: 53 additions & 0 deletions apps/desktop/electron/connection-config-apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
interface ApplyConnectionConfigAtomicallyOptions<TConfig, TRegistry> {
apply: () => Promise<void>
nextConfig: TConfig
nextRegistry: TRegistry
/**
* Optional reachability check (authenticated REST + a real WebSocket leg).
* Runs BEFORE either file is written, so a rejected OAuth session or a
* blocked /api/ws leaves the previous primary/current connection intact
* rather than committing a gateway the app cannot actually reach.
*/
preflight?: () => Promise<unknown>
previousConfig: TConfig
previousRegistry: TRegistry
writeConfig: (config: TConfig) => void
writeRegistry: (registry: TRegistry) => void
}

/**
* Commit the legacy config and v2 registry as one recoverable Apply boundary.
* File replacement itself is atomic per file; this wrapper restores both
* previous snapshots when the second write or synchronous re-home fails.
*/
export async function applyConnectionConfigAtomically<TConfig, TRegistry>({
apply,
nextConfig,
nextRegistry,
preflight,
previousConfig,
previousRegistry,
writeConfig,
writeRegistry
}: ApplyConnectionConfigAtomicallyOptions<TConfig, TRegistry>): Promise<void> {
// Outside the try: a preflight failure has written nothing, so there is
// nothing to roll back and no reason to touch either store.
await preflight?.()

try {
writeConfig(nextConfig)
writeRegistry(nextRegistry)
await apply()
} catch (error) {
try {
writeConfig(previousConfig)
writeRegistry(previousRegistry)
} catch {
// Preserve the original activation/write failure. Both storage writers
// are atomic replacements, so a rollback failure cannot be repaired by
// retrying one side blindly here.
}

throw error
}
}
194 changes: 194 additions & 0 deletions apps/desktop/electron/connection-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
normalizeConnectionInput,
normalizeRegistry,
parseRemoteProfileListing,
reconcileAppliedGlobalConnection,
reconcileRegistryDrift,
REGISTRY_VERSION,
rememberSshEnumeration,
removeConnection,
Expand Down Expand Up @@ -1218,6 +1220,198 @@ test('upsertConnection replaces by id and appends new ids', () => {
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, ssh, and unparseable v1 routes', () => {
const registry = emptyRegistry()

for (const v1 of [
{ mode: 'local', remote: {} },
{ mode: 'ssh', remote: { host: 'box' } },
{ 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 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', () => {
Expand Down
Loading
Loading