diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index 9053d4d045..f0af94e759 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -1595,12 +1595,13 @@ test('drift heal respects a deliberate primary pick on a registered route', () = assert.equal(drifted.registry.primary, LOCAL_CONNECTION_ID) }) -test('drift heal ignores local, ssh, and unparseable v1 routes', () => { +test('drift heal ignores local and unparseable v1 routes', () => { const registry = emptyRegistry() for (const v1 of [ { mode: 'local', remote: {} }, - { mode: 'ssh', remote: { host: 'box' } }, + { mode: 'ssh', remote: {} }, + { mode: 'ssh', remote: { host: ' ' } }, { mode: 'remote', remote: { url: 'not a url' } }, { mode: 'remote', remote: {} }, null @@ -1612,6 +1613,92 @@ test('drift heal ignores local, ssh, and unparseable v1 routes', () => { } }) +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() diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index bb9d1f0f82..9d1b8d5518 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -1496,6 +1496,13 @@ export function reconcileAppliedGlobalConnection( * 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, @@ -1504,6 +1511,57 @@ export function reconcileRegistryDrift( const config = v1 && typeof v1 === 'object' ? (v1 as Record) : {} 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 }