65335549a6
Re-implements the #93042 main.ts wiring against current main (post-#94724 drift), making the extracted managed-ssh-update engine reachable: - ManagedConnectionUpdateGate instance + owner-only recovery journal at DESKTOP_MANAGED_SSH_RECOVERY_PATH (read/write/persist/mark/clear with strict record validation). - IPC: hermes:connections:update-managed (requestManagedSshUpdate with correlation-id claim + in-flight dedupe); update-all's ssh rows now route through the transactional drain/update/restore lifecycle instead of POSTing the remote backend updater. - Gate enforcement at every dial/mutate seam: bootstrapSshConnectionInner (pre-dial + publication fence with exact-serve rollback via rollbackSshBootstrapResult), resolveRemoteBackend, ensureRegistryBackend, saveRegistryConnection dial-field edits, connections:remove, and primary-routing mutations (set-primary, set-launch-mode, connection-config save/apply, profile:set) via assertCanMutateManagedPrimaryRouting. - Scope capture/drain/restore drivers: captureManagedSshScopes (pool + primary discovery, bootstrap fence join), drainManagedSshScope (exact identity-re-proof termination, no-kill forward recovery), ensureManagedSshBackend(AtKey)/restoreManagedPrimarySshBackend restores, openManagedSshUpdateTransport for serve-less connections. - Startup recovery (resumeManagedSshRecoveries before createWindow) and before-quit join of in-flight update/recovery operations BEFORE the SSH coordinator is sealed, so restore dials are not refused during quit. - Extended sshConnections state (spawnNonce/creationTime(Ns)/startedAt/ hermesPath/hermesHome/pythonPath/remoteProfile/registryConnectionId/ primaryRegistryScope) so drain can prove the exact serve it owns; bootstrap coordinator entries carry metadata for the update fence; persistSshConnectionToken mirrors tokens per managedSshTokenPersistencePlan. - preload/global.d.ts: connections.updateManaged + DesktopManagedConnectionUpdateResult/Receipt types. Renderer UI (fleet-updates store, about-settings, system.ts ProfileScope plumbing, i18n) intentionally NOT wired — it belongs to the deferred fleet rollout UI and follows separately. Wiring re-implemented against current main; design from #93042 by @andrexibiza tsc -p apps/desktop clean; electron project 1924/1924 passed (133 files, incl. 131/131 across the three engine suites); eslint clean on touched files.
152 lines
4.1 KiB
TypeScript
152 lines
4.1 KiB
TypeScript
import crypto from 'node:crypto'
|
|
|
|
function sshConfigFingerprint(scope, config) {
|
|
const parts = [
|
|
scope,
|
|
config.host,
|
|
config.user,
|
|
config.port,
|
|
config.keyPath,
|
|
config.remoteHermesPath,
|
|
config.remoteProfile,
|
|
config.effectiveConfigFingerprint
|
|
]
|
|
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(JSON.stringify(parts.map(value => value ?? '')))
|
|
.digest('hex')
|
|
}
|
|
|
|
function createBootstrapCoordinator() {
|
|
const active = new Set<any>()
|
|
const pending = new Map<string, any>()
|
|
const generations = new Map<string, number>()
|
|
const drains = new Map<string, Promise<void>>()
|
|
let shutdownRequested = false
|
|
|
|
function start(scope, fingerprint, run, metadata = null) {
|
|
if (shutdownRequested) {
|
|
const error: any = new Error('SSH bootstrap was cancelled because Desktop is quitting.')
|
|
error.kind = 'superseded'
|
|
|
|
return Promise.reject(error)
|
|
}
|
|
|
|
const current = pending.get(scope)
|
|
|
|
if (current?.fingerprint === fingerprint) {
|
|
return current.promise
|
|
}
|
|
|
|
current?.controller.abort()
|
|
|
|
const generation = (generations.get(scope) || 0) + 1
|
|
generations.set(scope, generation)
|
|
const controller = new AbortController()
|
|
const forceCleanups = new Set<() => any>()
|
|
|
|
const lease = {
|
|
signal: controller.signal,
|
|
onForceCleanup(cleanup) {
|
|
forceCleanups.add(cleanup)
|
|
|
|
return () => forceCleanups.delete(cleanup)
|
|
},
|
|
isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation,
|
|
assertCurrent() {
|
|
if (!this.isCurrent()) {
|
|
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
|
error.kind = 'superseded'
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
const drain = drains.get(scope) || Promise.resolve()
|
|
const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain
|
|
const entry: any = { controller, fingerprint, forceCleanups, generation, metadata, promise: null, scope }
|
|
|
|
const promise = predecessor
|
|
.then(() => {
|
|
lease.assertCurrent()
|
|
|
|
return run(lease)
|
|
})
|
|
.finally(() => {
|
|
forceCleanups.clear()
|
|
active.delete(entry)
|
|
|
|
if (pending.get(scope)?.generation === generation) {
|
|
pending.delete(scope)
|
|
}
|
|
})
|
|
|
|
entry.promise = promise
|
|
active.add(entry)
|
|
pending.set(scope, entry)
|
|
|
|
return promise
|
|
}
|
|
|
|
function cancel(scope) {
|
|
pending.get(scope)?.controller.abort()
|
|
}
|
|
|
|
async function cancelAndWait(scope) {
|
|
let release
|
|
|
|
const barrier = new Promise<void>(resolve => {
|
|
release = resolve
|
|
})
|
|
|
|
drains.set(scope, barrier)
|
|
const entries = [...active].filter(entry => entry.scope === scope)
|
|
|
|
for (const entry of entries) {
|
|
entry.controller.abort()
|
|
}
|
|
|
|
try {
|
|
// Cancellation alone only invalidates the lease; it does not interrupt a
|
|
// child process currently blocked in SSH connect/config resolution. Close
|
|
// registered resources first so rollback can settle promptly while the
|
|
// drain barrier still prevents stale resurrection.
|
|
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
|
|
await Promise.allSettled(entries.map(entry => entry.promise))
|
|
} finally {
|
|
if (drains.get(scope) === barrier) {
|
|
drains.delete(scope)
|
|
}
|
|
|
|
release()
|
|
}
|
|
}
|
|
|
|
function cancelAll() {
|
|
for (const entry of active) {
|
|
entry.controller.abort()
|
|
}
|
|
}
|
|
|
|
function shutdown() {
|
|
// Terminal: reconnect callbacks during a prevented first quit must not
|
|
// spawn a replacement serve --isolated for an app that is already leaving.
|
|
shutdownRequested = true
|
|
cancelAll()
|
|
}
|
|
|
|
async function forceCleanupAll() {
|
|
const cleanups = [...active].flatMap(entry => [...entry.forceCleanups])
|
|
await Promise.allSettled(cleanups.map(cleanup => cleanup()))
|
|
}
|
|
|
|
function promises() {
|
|
return [...active].map(entry => entry.promise)
|
|
}
|
|
|
|
return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, shutdown, start }
|
|
}
|
|
|
|
export { createBootstrapCoordinator, sshConfigFingerprint }
|