fix(desktop): heal v1 SSH gateway routes into the v2 connections registry
reconcileRegistryDrift only healed remote/cloud v1 routes. A v1 global mode:'ssh' route (host, no url) written by Settings after the one-shot migration had no registry identity: resolvedConnectionId returned null, primary stayed 'local', and every launch re-homed the window onto a fresh local backend. Because the heal skipped SSH entirely, the two config files re-drifted after every update relaunch instead of converging once. Normalize the v1 SSH descriptor into a v2 kind:'ssh' entry (via the same validated normalizeConnectionInput path the editor uses) and align primary/lastUsed, with the same narrow-heal rules as remote: already- registered targets and deliberate primary picks are left alone, and unusable hosts never touch the registry. Diagnosis credit: mgallmur-glitch (root cause) and jakewvincent (re-drift after update relaunch) on #93888.
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user