import { execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' import https from 'node:https' import os from 'node:os' import path from 'node:path' import tls from 'node:tls' import { pathToFileURL } from 'node:url' import { app, BrowserWindow, clipboard, dialog, net as electronNet, webContents as electronWebContents, globalShortcut, ipcMain, Menu, nativeTheme, Notification, powerMonitor, powerSaveBlocker, protocol, safeStorage, screen, session, shell, systemPreferences } from 'electron' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { appIconCandidates, resolveAppIcon } from './app-icon' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' import { type BackendOutputTail, claimDecision, createBackendOutputTail, execText, isPidOnlyStartMarker, pidOnlyStartMarker, probeStartMarker, processStartMarker, REAP_PROBE_TIMEOUT_MS } from './backend-claim' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { BackendDialClaims } from './backend-dial-claim' import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' import { isReauthRequiredError, makeNousCloudBackendDownError, makeUnsignedOauthError, waitForHermesReady } from './backend-health' import { backendCommandMatches, createBackendOwnership, createBackendShutdownCoordinator } from './backend-ownership' import { canImportHermesCli, execProbeSync, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' import { recycleOwnedBackend } from './backend-recycle' import { isPidAliveWindows, waitForBackendRelease } from './backend-release-gate' import { isHostKeyChangedBootFailure, isRetryableRemoteBootFailure, shouldLatchBackendStartFailure, shouldLatchHostKeyChangedFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment, resolveLinuxPasswordStore } from './bootstrap-platform' import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { BROWSER_WINDOW_HEIGHT, BROWSER_WINDOW_MIN_HEIGHT, BROWSER_WINDOW_MIN_WIDTH, BROWSER_WINDOW_WIDTH, buildBrowserWindowUrl } from './browser-windows' import { detectBundleSkew } from './bundle-skew' import { detectBundleSwap } from './bundle-swap' import { applyConnectionChange, sshQuitShouldBlock, teardownSshState } from './connection-apply' import { apiRequestRegistryConnectionId, authModeFromStatus, buildGatewayWsUrl, buildGatewayWsUrlWithTicket, connectionScopeKey, cookiesHaveLiveSession, cookiesHavePrivyAccessToken, cookiesHavePrivySession, cookiesHaveSession, gatewayTicketFailure, gatewayWsUrlIpcResult, hostLabelFromBaseUrl, localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, pathForRegistryBackendRequest, pathWithGlobalRemoteProfile, profileHasRemoteConnection, profileRemoteOverride, profileSshOverride, type RegistryBackendRequestScope, remoteRequestMatchesBaseUrl, resolveAuthMode, resolveProfileApiRequest, resolveProfileBackendRoute, resolveRemoteSshDashboardProfile, resolveTestWsUrl, savedProfileSsh, tokenPreview, withTransientRetries } from './connection-config' import { applyConnectionConfigAtomically } from './connection-config-apply' import { backendScopeKey, backendScopePrefix, buildAgentRoster, connectionDialFieldsChanged, mergeConnectionInput, migrateV1ToRegistry, normalizeConnectionInput, normalizeRegistry, parseBackendScopeKey, reconcileAppliedGlobalConnection, reconcileRegistryDrift, registrySourceOwnsPrimaryBackend, rememberSshEnumeration, removeConnection, resolvedConnectionId, resolveRegistryLocalRoute, reuseMatchingPrimarySshBackend, setConnectionLaunchMode, setLastUsedConnection, setPrimaryConnection, shouldDeferLocalEnumeration, shouldRetrySshInventory, updateEligibility, upsertConnection } from './connection-registry' import type { RosterProfileMetadata } from './connection-registry' import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { formatDesktopLogLine } from './desktop-log-line' import { resolveDesktopRemoteRoute } from './desktop-remote-route' import { buildPosixCleanupScript, buildWindowsCleanupScript, modeRemovesAgent, modeRemovesUserData, resolveRemovableAppPath, shouldRemoveAppBundle, uninstallArgsForMode } from './desktop-uninstall' import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { buildTerminalScript, resolveTerminalLaunch, terminalScriptEnv, terminalScriptExtension, tuiResumeArgs } from './external-terminal' import { type FaviconIo, resolveFavicon } from './favicon' import { findGitBash as _findGitBash } from './find-git-bash' import { installFindShortcut, installFoundInPageForwarder, performFindAfterIndexingStarted, stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' import { registerFsIpc } from './fs-ipc' import { filenameFromContentDisposition, fsPumpDeps, gatewayFilePath, gatewayFileRequestPaths, isNotFoundError, parseDataUrlToBuffer, pumpStreamToFile, resolveGatewayFileBackend, writeBufferToFile } from './gateway-file-download' import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update' import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' import { readAndConsumeHandoffResult } from './handoff-result' import { ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, clampDataUrlReadMaxMb, DATA_URL_READ_DEFAULT_MAX_MB, dataUrlReadMaxBytesFromMb, DEFAULT_FETCH_TIMEOUT_MS, enableBasicPasswordStoreEncryption, encryptDesktopSecret as encryptDesktopSecretStrict, readFileDataUrlForIpc, resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, SAFE_STORAGE_ENCODING, TEXT_PREVIEW_SOURCE_MAX_BYTES, tightenSecretFileMode, writeSecretFileAtomic } from './hardening' import { cursorPointInWindow } from './hud-cursor' import { startHudGameOverlayWatch } from './hud-game-overlay' import { applyHudResetBounds, defaultHudBounds } from './hud-geometry' import { registerHudIpc } from './hud-ipc' import { applyHudElectronOverlay, promoteHudOverlay } from './hud-overlay' import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' import { resolveHudWindowing } from './hud-windowing' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { assertManagedUpdatePreflightClear, executeManagedRemoteUpdate, fenceManagedSshBootstrapPublication, ManagedConnectionUpdateGate, managedSshRecoveryScopes, managedSshScopeRole, managedSshTokenPersistencePlan, recoverManagedSshScopes, refusedManagedSshUpdate, type RemoteUpdateTarget, runManagedSshUpdate, validateCorrelationId, waitForManagedRemoteClearance, waitForManagedSshBootstrapFence, waitForManagedUpdateOperations } from './managed-ssh-update' import { registerMcpOauthCallbackIpc } from './mcp-oauth-callback-ipc' import { createMediaProtocolHandler, MEDIA_PROTOCOL } from './media-protocol' import { oauthGuardMayHardFail, oauthSessionIsLive, oauthTicketFailureAuthMessage, resolveGatedDownloadAuth, resolveJsonBody, resolveOauthRestAuth, resolveReadinessProbeAuth } from './native-auth-decisions' import { nativeRefreshUrl, type NativeTokenSet, parseTokenResponse, resolveLoginStrategy, tokenNeedsRefresh } from './native-oauth' import { runNativeLogin } from './native-oauth-login' import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, isLocalEnumerationFailure, localRouteFallbackProfiles, undialedSshRouteSeeds } from './plugin-profile-routes' import { selectPoolEvictions } from './pool-eviction' import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_DEFAULTS } from './pool-limits' import { LocalBackendSpawnCoordinator, type LocalBackendSpawnRequest, releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator' import { createPoolStopper } from './pool-stop' import { poolTouchKeys } from './pool-touch-scope' import { createKeepAwake } from './power-save' import { capturePreviewContents } from './preview-capture' import { PreviewReachRegistry } from './preview-reach' import { createPrimaryRemoteConnection, FirstRunSetupResetError, runPrimaryBackendStartup } from './primary-backend-startup' import { rehomePrimaryConnection } from './primary-connection-rehome' import { assertLocalProfileCanStart, decideProfileDeleteAction, dispatchConnectionScopedProfileDelete, localProfilePoolKeys, ProfileDeletionGate, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { migrateActiveProfileIfMissing as migrateActiveProfileIfMissingPure } from './profile-migration' import { prepareProfileRenameLifecycle, profileRenameFromRequest } from './profile-rename-routing' import { buildSidebarSessionSliceParams, fetchPrimaryProfileSessions, fetchRegistrySessionRows, fetchRemoteProfileSessions, findRemoteOwnerProfileForSession, mergeProfileSessionWindow, type RegistrySessionSource, spliceRegistrySessionRows, tagRegistrySessionResponse } from './profile-session-routing' import { createQuickEntryShortcut, quickEntryWindowBounds, sanitizeQuickEntrySettings } from './quick-entry' import { type ActiveWork, mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard' import * as remoteLifecycle from './remote-lifecycle' import { attachPowerResumeRemoteRevalidation, ensureHealthyPooledRemoteBackendForDispatch, RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidatePooledRemoteBackends, revalidateRemoteConnection, revalidateSuspectPooledRemoteBackends } from './remote-liveness' import { applyRemoteRequestHeaders, createRegistryGatewayWsUrlHandler, createRemoteWsHeaderStore } from './remote-ws-headers' import { missingRendererAssets } from './renderer-bundle' import { loadRendererLoadErrorPage } from './renderer-load-error-page' import { attachRendererConsoleCapture, formatRendererBoundaryReport } from './renderer-log' import { classifyStoredSecret, readSecretStoragePolicy, SECRET_STORAGE_POLICY_FILE, type SecretStoragePolicy, writeSecretStoragePolicy } from './secret-storage-policy' import { buildInstanceWindowUrl, buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, instanceWindowBounds, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureLoginShellPath } from './shell-path' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection' import { createStreamThrottle } from './stream-throttle' import { registerTerminalIpc } from './terminal-ipc' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { backgroundMaterialFor, defaultTranslucencyState, glassActive, glassSupportedOn, normalizeState as normalizeTranslucency, opacityNeedsSetting, translucencySupportedOn, vibrancyFor as vibrancyForTranslucency, windowBackingOptions, windowOpacityFor, windowOpacityOptions } from './translucency' import { compareApiUrl, parseCompareBehindCount, resolveBehindCount, resolveCommitLogSelection, shouldCountCommits } from './update-count' import { waitForUpdateClearance } from './update-gate' import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from './update-marker' import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote' import { collectRelaunchArgs, observeUpdaterHandoff, resolvePosixScriptHandoff, resolveStagedUpdaterBinary, resolveUpdateScriptHandoff, sandboxFallbackFromEnv, spawnUpdaterProcess, stagedUpdaterSupportsPrewrittenMarker, wrapHandoffForDetachedConsole } from './updater-process' import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers, stopSafeVenvBlockers } from './venv-blocker-scan' import { isHermesOwnedVenvDaemon } from './venv-holder-select' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { createWakeIndicatorWindowController } from './wake-indicator-window' import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below' import { registrySshScopeForWindowRoute, WindowConnectionRouteRegistry } from './window-connection-route' import { installWindowRendererLifecycle } from './window-renderer-lifecycle' import { createWindowRevealController } from './window-reveal' import { bindGeometryPersistence, computeWindowOptions, debounce, sanitizeWindowState, MIN_HEIGHT as WINDOW_MIN_HEIGHT, MIN_WIDTH as WINDOW_MIN_WIDTH } from './window-state' import { hiddenWindowsChildOptions } from './windows-child-options' import { buildPathExtCandidates, chooseUpdaterArgs, getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' import { connectWindowsRemote, detectRemotePlatform, helper, probeWindowsRemote, terminateOwnedWindowsDashboardForUpdate } from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, decideWindowsSandboxLaunch, fallbackMarker, grantAllApplicationPackagesAcl, markerAfterSuccessfulBoot, readSandboxMarker, type SandboxFallbackReason, shouldAttemptAclRepair, shouldRelaunchForGpuSandboxCrash, shouldRelaunchForRendererSandboxCrashLoop, writeSandboxMarker } from './windows-sandbox-fallback' import { installWindowsSystemCaTrust } from './windows-system-ca' import { readWindowsUserEnvVar } from './windows-user-env' import { AITURK_PRODUCT, resolveAiturkHome } from './aiturk-product' import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' import { readWslWindowsClipboardImage } from './wsl-clipboard-image' import { resolvePickerDefaultPath, setActiveGatewayProfile, setWslBridgeProfileState } from './wsl-path-bridge' const USER_DATA_OVERRIDE = process.env.AITURK_IDE_USER_DATA_DIR || process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) fs.mkdirSync(resolvedUserData, { recursive: true }) app.setPath('userData', resolvedUserData) } const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER const IS_PACKAGED = app.isPackaged || Boolean(process.env.HERMES_DESKTOP_IS_PACKAGED) const IS_MAC = process.platform === 'darwin' const IS_WINDOWS = process.platform === 'win32' const IS_WSL = isWslEnvironment() // Truthful macOS kernel major (Tahoe = 25). Product version lies (16 vs 26) per // build SDK, so gate Tahoe workarounds on Darwin instead. const DARWIN_MAJOR = IS_MAC ? Number.parseInt(os.release(), 10) || 0 : 0 // Glass: macOS vibrancy, or Windows 11 22H2+ system backdrop. Computed once // so the renderer, the persisted default, and every chat window agree. const GLASS_SUPPORTED = glassSupportedOn(process.platform, os.release()) // Clear rides setOpacity, a documented no-op on Linux, so neither mode works // there and Settings drops the row entirely. const TRANSLUCENCY_SUPPORTED = translucencySupportedOn(process.platform) const APP_ROOT = app.getAppPath() // Device-local preference: block F12 from opening DevTools. // Set dynamically via IPC from the renderer Settings → Advanced. let f12Blocked = false // Preload must be plain JS — Electron's sandbox can't run .ts, and tsx's // ESM loader is broken on Electron 40's Node (ERR_INVALID_RETURN_PROPERTY_VALUE). // Dev (`npm run dev`) and prod both load the esbuild output from dist/. const PRELOAD_PATH = path.join(APP_ROOT, 'dist', 'electron-preload.js') // Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU // compositor flicker — accelerated layers can't be presented cleanly over the // wire, so the window flashes during scroll/streaming/animation. Local // Windows/macOS (and WSLg, which renders locally via vGPU) composite on the // GPU and never see it. Fall back to software rendering when a remote display // is detected; it's rock-steady over the wire and the CPU cost is negligible // next to the connection's latency. Must run before app `ready` — these // switches only apply pre-launch. Override with HERMES_DESKTOP_DISABLE_GPU // (1/true → always disable, 0/false → keep GPU on). const REMOTE_DISPLAY_REASON = detectRemoteDisplay() if (REMOTE_DISPLAY_REASON) { app.disableHardwareAcceleration() // Belt-and-suspenders for X11/VNC, where the Viz compositor can still glitch // with only --disable-gpu: force compositing onto the CPU too. app.commandLine.appendSwitch('disable-gpu-compositing') console.log( `[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker` ) } // Renderer debugging port. On for dev-server runs (`hgui` / `npm run dev`) so // the CDP tooling in scripts/ can attach; never for a packaged build — see // electron/dev-cdp.ts. Must run before app `ready` like the switches above; // Chromium binds it at launch. const DEV_CDP = resolveDevCdpPort({ env: process.env, isPackaged: IS_PACKAGED, devServer: DEV_SERVER }) if (DEV_CDP.port) { app.commandLine.appendSwitch('remote-debugging-port', String(DEV_CDP.port)) // Loopback only. Chromium already defaults to 127.0.0.1, but say it out loud // so a future edit can't widen it by omission. app.commandLine.appendSwitch('remote-debugging-address', '127.0.0.1') console.log( `[hermes] renderer debugging on http://127.0.0.1:${DEV_CDP.port} — anything that can reach it ` + 'can run code in the renderer. HERMES_DESKTOP_CDP_PORT=off to disable.' ) } else { const why = describeDevCdpDecision(DEV_CDP) if (why) { console.warn(`[hermes] ${why}`) } } // WSLg: Chromium blocklists the Mesa vGPU → software compositing → typing lag. // /dev/dxg means a real GPU is available; un-blocklist it. Skipped when a remote // display already forced software (SSH'd-into-WSL). if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { app.commandLine.appendSwitch('ignore-gpu-blocklist') app.commandLine.appendSwitch('enable-gpu-rasterization') app.commandLine.appendSwitch('enable-zero-copy') console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration') } // Linux: point Chromium at the session's keychain backend so safeStorage can // encrypt remote gateway tokens (hardening.ts refuses to persist them without // it). The value arrives via HERMES_DESKTOP_PASSWORD_STORE, bridged by the // `hermes desktop` launcher from detection or `desktop.password_store` in // config.yaml. Must run before app `ready` — the switch only applies pre-launch. const PASSWORD_STORE = resolveLinuxPasswordStore() if (PASSWORD_STORE.warning) { console.warn(`[hermes] ${PASSWORD_STORE.warning}`) } if (PASSWORD_STORE.store) { app.commandLine.appendSwitch('password-store', PASSWORD_STORE.store) console.log(`[hermes] using password-store backend: ${PASSWORD_STORE.store}`) } // Windows sandbox / GPU breakpoint crash recovery (#38216). // // Some hosts (AMD RX 6000 drivers, orphan AppContainer SIDs under %LOCALAPPDATA%, // missing S-1-15-2-2 ACEs) kill Chromium's sandboxed GPU/renderer children with // 0x80000003. After enough GPU deaths the browser process FATAL-exits before the // UI is usable. Must run before app `ready` so `--no-sandbox` applies to child // processes. The sticky marker recovers Start Menu / shortcut launches that // never go through `hermes desktop`; it is version-scoped so an app update // re-probes the sandbox instead of degrading forever. // // `windowsSandboxFallbackActive` = this process runs without the Chromium // sandbox (any cause, including a manual --no-sandbox flag) — guards the // relaunch handlers. `windowsSandboxFallbackSticky` = the fallback machinery // engaged and the marker must stay `fallback` after a successful boot; a // manual flag alone is honored but never made sticky. let windowsSandboxFallbackActive = false let windowsSandboxFallbackSticky = false let windowsSandboxFallbackReason: SandboxFallbackReason = 'boot-loop' let windowsNoSandboxRelaunchAttempted = false if (IS_WINDOWS) { const windowsUserData = app.getPath('userData') const priorMarker = readSandboxMarker(windowsUserData) // Best-effort ACL repair, only when the last boot aborted or the fallback is // engaged — icacls /T recurses the whole install tree, so healthy launches // skip it (the installer already granted the ACE at install time). Repair // targets the install dir only: granting AppContainer read on userData would // expose Hermes sessions/config to every packaged app on the machine. if (shouldAttemptAclRepair(priorMarker)) { const exeDir = path.dirname(process.execPath) const acl = grantAllApplicationPackagesAcl(exeDir, { execFileSync }) if (acl.ok) { console.log(`[hermes] granted ALL APPLICATION PACKAGES RX on ${exeDir} (#38216)`) } else if (acl.error && acl.error !== 'missing-target-or-exec') { console.warn(`[hermes] AppContainer ACL grant failed on ${exeDir}: ${acl.error}`) } } const sandboxDecision = decideWindowsSandboxLaunch({ argv: process.argv, env: process.env, marker: priorMarker, appVersion: app.getVersion() }) windowsSandboxFallbackActive = sandboxDecision.enable windowsSandboxFallbackSticky = sandboxDecision.nextMarker.state === 'fallback' if (sandboxDecision.nextMarker.state === 'fallback' && sandboxDecision.nextMarker.reason) { windowsSandboxFallbackReason = sandboxDecision.nextMarker.reason } if (sandboxDecision.enable && sandboxDecision.reason !== 'already-enabled') { app.commandLine.appendSwitch('no-sandbox') process.env.ELECTRON_DISABLE_SANDBOX = '1' console.log( `[hermes] Windows sandbox fallback enabled (${sandboxDecision.reason}); launching with --no-sandbox (#38216)` ) } writeSandboxMarker(windowsUserData, sandboxDecision.nextMarker) // Catch the first GPU breakpoint death and relaunch before Chromium's // "GPU process isn't usable" FATAL abort ends the process with no recovery. app.on('child-process-gone', (_event, details) => { if ( !shouldRelaunchForGpuSandboxCrash({ details, alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env), relaunchAttempted: windowsNoSandboxRelaunchAttempted }) ) { return } windowsNoSandboxRelaunchAttempted = true windowsSandboxFallbackActive = true windowsSandboxFallbackSticky = true windowsSandboxFallbackReason = 'gpu-breakpoint' try { writeSandboxMarker(app.getPath('userData'), fallbackMarker('gpu-breakpoint', app.getVersion())) } catch { void 0 } console.warn( `[hermes] Windows GPU sandbox crashed (exit=${details?.exitCode}); relaunching once with --no-sandbox (#38216)` ) try { app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) }) void exitAfterBackendShutdown(0) } catch (error) { console.error(`[hermes] --no-sandbox relaunch failed: ${error?.message || error}`) } }) } ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer's PROCESS priority normal while its windows are hidden — // a deprioritized renderer streams a live answer visibly slower once the // window is minimized. This switch only affects scheduling priority; it does // not exempt timers from throttling and costs nothing at idle. // // The timer/rAF throttling story is deliberately NOT handled here anymore. // The old process-wide `disable-background-timer-throttling` / // `disable-backgrounding-occluded-windows` switches (plus a static // `backgroundThrottling: false` on every chat window) pinned every renderer's // `document.visibilityState` to 'visible' forever — which silently turned all // the renderer's visibility-gated backstop polls and clock ticks into // always-on timers. A completely idle, minimized Hermes burned ~20% CPU // around the clock. Throttling is now a runtime dial scoped to streaming: // see createStreamThrottle() — chat windows are unthrottled while any turn is // in flight (so a live answer keeps painting while blurred, occluded, or // minimized, exactly as before) and return to Chromium's default throttling // once the work settles. app.commandLine.appendSwitch('disable-renderer-backgrounding') const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Build-time install stamp -- the git ref this .exe was built against. // // Written by apps/desktop/scripts/write-build-stamp.mjs during `npm run build` // and bundled into packaged apps via electron-builder's extraResources entry, // so the runtime stamp ends up at process.resourcesPath/install-stamp.json // after install. The bootstrap runner (Phase 1D) reads it to know which // commit to clone when running install.ps1 stages at first launch. // // Returns null when the file is missing (dev runs from a checkout where // build hasn't been invoked, or schema mismatch). Callers must handle null. // // Schema: // { schemaVersion: 1, commit, branch, builtAt, dirty, source } const INSTALL_STAMP_SCHEMA_VERSION = 1 function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the // dev/local build output (apps/desktop/build/install-stamp.json) so // someone running `npm run start` after a local `npm run build` also // sees a stamp without needing a packaged build. const candidates = [ process.resourcesPath ? path.join(process.resourcesPath, 'install-stamp.json') : null, path.join(APP_ROOT, 'build', 'install-stamp.json') ].filter(Boolean) for (const p of candidates) { try { const raw = fs.readFileSync(p, 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) continue } return Object.freeze({ schemaVersion: parsed.schemaVersion, commit: parsed.commit, branch: parsed.branch || null, builtAt: parsed.builtAt || null, dirty: Boolean(parsed.dirty), source: parsed.source || null, path: p }) } } catch (e) { console.warn(`[hermes] install-stamp.json found at ${p} , but parsing failed with ${e}`) // Either ENOENT or malformed JSON; try the next candidate } } return null } const INSTALL_STAMP = loadInstallStamp() if (INSTALL_STAMP) { console.log( `[hermes] install stamp: ${INSTALL_STAMP.commit.slice(0, 12)}${INSTALL_STAMP.branch ? ` (${INSTALL_STAMP.branch})` : ''}${INSTALL_STAMP.dirty ? ' [DIRTY]' : ''} from ${INSTALL_STAMP.source || 'unknown'}` ) } else if (IS_PACKAGED) { // Dev builds without a stamp are normal; packaged builds without one // mean the bootstrap won't know what to clone. Surface clearly. console.error( '[hermes] WARNING: no install-stamp.json found in packaged build. First-launch bootstrap will not have a pinned ref to install.' ) } // HERMES_HOME — the user-facing root for everything Hermes-related. Mirrors // scripts/install.ps1's $HermesHome and scripts/install.sh's $HERMES_HOME. // // Defaults: // Windows: %LOCALAPPDATA%\hermes (matches install.ps1) // macOS / Linux: ~/.hermes (matches install.sh) // // Special case for Windows: if the user has a legacy ~/.hermes directory // (e.g., from a prior pip install or a manual setup) AND no // %LOCALAPPDATA%\hermes yet, prefer the legacy path so we don't orphan their // existing config / sessions / .env. New installs go to %LOCALAPPDATA%. // // HERMES_DESKTOP_USER_DATA_DIR (used by test:desktop:fresh) puts the sandbox // HERMES_HOME beneath the throwaway userData dir so a fresh-install run never // touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes. function resolveHermesHome() { return resolveAiturkHome({ env: process.env, platform: process.platform, home: app.getPath('home'), userDataOverride: USER_DATA_OVERRIDE }) } const HERMES_HOME = resolveHermesHome() function pathWithHermesManagedNode(...entries) { const managed = hermesManagedNodePathEntries(HERMES_HOME).filter(directoryExists) return [...managed, ...entries, process.env.PATH].filter(Boolean).join(path.delimiter) } // ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path // install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end // up with identical layouts and can share one install. const ACTIVE_HERMES_ROOT = path.join(HERMES_HOME, 'hermes-agent') // VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it. const VENV_ROOT = path.join(ACTIVE_HERMES_ROOT, 'venv') // BOOTSTRAP_COMPLETE_MARKER — written by the first-launch bootstrap runner // (Phase 1D) after install.ps1 has completed all stages and the user has // finished initial configuration. Presence of this marker means the install // is in a known-good state and we can skip the bootstrap flow on subsequent // boots, going straight to `resolveHermesBackend()`. Missing or stale marker // means we re-run the bootstrap; install.ps1's stages are idempotent so a // re-run on an already-good install just discovers everything in place. // // We deliberately put the marker INSIDE ACTIVE_HERMES_ROOT (not alongside) // so that deleting the checkout to start fresh also deletes the marker -- // avoids the confusing "marker exists but checkout is gone" state. const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete') const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json') // v2 multi-connection registry (named agent sources). Lives BESIDE // connection.json — v1 stays on disk untouched so older builds sharing the // profile keep working; the registry imports from it once and then owns its // own file. Same secret posture as connection.json (encrypted tokens, 0600). const DESKTOP_CONNECTIONS_REGISTRY_PATH = path.join(app.getPath('userData'), 'connections.json') const DESKTOP_INSTALLATION_PATH = path.join(app.getPath('userData'), 'desktop-installation.json') const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json') const DESKTOP_WINDOW_STATE_PATH = path.join(app.getPath('userData'), 'window-state.json') const DESKTOP_BACKEND_OWNERSHIP_PATH = path.join(app.getPath('userData'), 'backend-ownership.json') const DESKTOP_MANAGED_SSH_RECOVERY_PATH = path.join(app.getPath('userData'), 'managed-ssh-update-recovery.json') // active-profile.json records which Hermes profile the desktop launches its // local backend as. When set, startHermes() passes `hermes --profile // dashboard …`, which deterministically pins HERMES_HOME (see // _apply_profile_override in hermes_cli/main.py) and bypasses the sticky // ~/.hermes/active_profile file. Unset (null) preserves the legacy behavior: // no --profile flag, so the backend honors active_profile / default. const DESKTOP_PROFILE_CONFIG_PATH = path.join(app.getPath('userData'), 'active-profile.json') // Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never hand the backend a // value its profile resolver would reject and exit on. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // Branch we track for self-update. The GUI work has merged to main, so this // tracks main. User can also override at runtime via // hermesDesktop.updates.setBranch(). const DEFAULT_UPDATE_BRANCH = 'main' // desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log, // errors.log, gateway.log produced by hermes_logging.setup_logging — one log // directory per user, regardless of which UI surface produced the line. const DESKTOP_LOG_PATH = path.join(HERMES_HOME, 'logs', 'desktop.log') const DESKTOP_LOG_FLUSH_MS = 120 const DESKTOP_LOG_BUFFER_MAX_CHARS = 64 * 1024 // Bound desktop.log on disk. It is an append-only forensic log, so a boot loop // (version-skew crash -> backend exits instantly -> renderer keeps hitting // Retry) appends the full bootstrap transcript every attempt and grows without // bound — we have seen it reach ~326 GB and exhaust the disk, which then breaks // update/install (no room for git/venv/npm temp files). // // Mirror the Python logs (hermes_logging.py RotatingFileHandler, maxBytes x // backupCount): cascade live -> .1 -> .2 -> .3, drop the oldest. Steady-state // stays bounded at ~(backupCount + 1) x cap however hard the app loops. // // Bounding alone never RECLAIMS an already-huge file: a plain rotation just // renames the monster to .1 and strands it for a cycle a healthy app may never // reach. A multi-GB boot-loop transcript has no diagnostic value, so anything // past the discard ceiling is deleted outright — the updated app self-heals a // disk a stale build filled, on the next launch. const DESKTOP_LOG_MAX_BYTES = 10 * 1024 * 1024 const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' // Automated teardown (Playwright's app.close(), harness scripts) quits with // nobody to answer a modal, so the active-work confirmation would hang the // caller instead of letting the process exit. Force quits set this. const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) if (!Number.isFinite(raw) || raw <= 0) { return 650 } return Math.max(120, raw) })() const APP_NAME = process.env.HERMES_DESKTOP_APP_NAME || AITURK_PRODUCT.name const HUD_WINDOW_TITLE = `${APP_NAME} HUD` const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 const WINDOW_BUTTON_POSITION = { x: 24, y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2 } // Right-edge window-control reservation lives in titlebar-overlay-width.ts // (pure + unit-testable); computeNativeOverlayWidth() applies it per platform. // It's only the pre-layout fallback — the renderer measures the exact overlay // width live via the Window Controls Overlay API. // The apple-touch PNG bakes in the macOS-style ~10% margin, which is correct // for the dock but renders visibly smaller than neighboring taskbar icons on // Windows, where icons are full-bleed. Windows prefers the full-bleed // assets/icon.ico (shipped to resources/ via extraResources) and only falls // back to the padded PNG if the ico is missing. // The ladder is BUILT once here but each window factory RE-RESOLVES through // resolveAppIcon (decoding probe): existence alone is not proof the bytes // decode, and an undecodable icon must never take the main process down. const APP_ICON_PATHS = appIconCandidates({ isWindows: IS_WINDOWS, appRoot: APP_ROOT, resourcesPath: process.resourcesPath, unpackedPathFor }) let rendererTitleBarTheme = null // Force the NATIVE window appearance (vibrancy material, titlebar, the // pre-first-paint window background) to follow the APP theme instead of the // OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that // tracks the window's effective appearance and ignores `backgroundColor` — // so a dark-themed app on a light-mode Mac flashes a white material on every // new window until the renderer covers it. The renderer reports its mode via // 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin // nativeTheme.themeSource to it and persist the value so cold launches paint // correctly before the renderer has even loaded. const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json') const THEME_SOURCES = new Set(['dark', 'light', 'system']) function readPersistedThemeSource() { try { const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8')) if (parsed && THEME_SOURCES.has(parsed.themeSource)) { return parsed.themeSource } } catch { // Missing / malformed → follow the OS like a fresh install. } return 'system' } function writePersistedThemeSource(mode) { try { fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true }) fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8') } catch (error) { rememberLog(`[theme] write native theme failed: ${error.message}`) } } nativeTheme.themeSource = readPersistedThemeSource() // Window translucency (see-through window). One lever, 0–100; 0 = off (the // default). Two modes share the lever (see electron/translucency.ts and // store/translucency): 'clear' maps it to the native window opacity so the // desktop shows through the whole window; 'glass' keeps the window opaque // and lets the renderer thin its surfaces over a platform material instead // — a matte blur with full-contrast text. macOS uses vibrancy; Windows 11 // uses DWM acrylic/mica/tabbed. Persisted so a cold launch applies it at // window creation, before the renderer reports its value. // macOS + Windows only; `setOpacity` is a no-op on Linux. const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json') function readPersistedTranslucency() { try { return normalizeTranslucency(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')), GLASS_SUPPORTED) } catch { // Nothing persisted yet — a first launch. Glass ships on, so the FIRST // window has to be created with the glass backing already: a window born // opaque cannot reliably be swapped to glass afterwards (see // windowBackingOptions). nativeTheme is the only appearance signal main // has this early; the renderer's first resolved send corrects it. return defaultTranslucencyState(nativeTheme.shouldUseDarkColors ? 'dark' : 'light', GLASS_SUPPORTED, IS_WINDOWS) } } function writePersistedTranslucency(state) { try { fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true }) fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify(state, null, 2), 'utf8') } catch (error) { rememberLog(`[translucency] write failed: ${error.message}`) } } let translucencyState = readPersistedTranslucency() // Chat windows whose webContents backing follows translucency (primary, // instance peers, session windows). The HUD / pet overlay / quick entry / // wake indicator are `transparent: true` windows that own their backgrounds — // painting a themed backing onto them would turn them into opaque rectangles. const translucencyBackedWindows = new WeakSet() // Set a live window's native opacity, but only when the state asks it to fade // — or when the window is already faded and is on its way back to opaque. The // window's own opacity is the record of whether that door was ever opened; see // opacityNeedsSetting for why it matters that it stays shut. function applyWindowOpacity(win) { const opacity = windowOpacityFor(translucencyState) if (typeof win.setOpacity === 'function' && opacityNeedsSetting(opacity, win.getOpacity?.() ?? 1)) { win.setOpacity(opacity) } } // Re-apply translucency to a live window (runtime toggle, no recreation). // Opacity goes through applyWindowOpacity, which knows when the call is worth // making at all. The backing swap is the glass half: Chromium composites the // page against the window backing BEFORE the OS composites the window, so // glass needs the backing dropped for the platform material to reach it, and // every other state needs the opaque themed backing (anti-flash, and it is // what makes clear mode fade to the desktop instead of to black). // // `changed` says which native properties actually need touching. Dragging the // intensity slider emits ~100 updates, and in glass mode NONE of them change // anything native — the tint is painted by the renderer and windowOpacityFor // answers off `fade`, not `intensity`, there. Re-issuing setVibrancy on every // tick restarts its 150ms animation before macOS can settle the material, // which reads as jank and flattens the frost levels into each other. Windows // setBackgroundMaterial is instantaneous but still skipped on tint-only ticks. // The glass Fade lever is the one glass drag that does reach main, and it // costs exactly what a Clear drag costs: one setOpacity. // // CAUTION (measured, macOS 26 / Electron 40): a runtime // setBackgroundColor('#00000000') is silently LOST on a window whose // compositor hasn't been up for a few seconds — including calls from // 'ready-to-show' and 'did-finish-load'. Cold launches therefore must not // rely on this path: windows are BORN with the right backing // (windowBackingOptions at each creation site). This path only has to cover // live toggles from Settings, where the window is long settled. function applyWindowTranslucency(win, changed = { backing: true, material: true, opacity: true }) { if (!win || win.isDestroyed()) { return } try { // Backing swap + material are scoped to registered chat windows (see // translucencyBackedWindows above). if (translucencyBackedWindows.has(win)) { if (changed.backing && typeof win.setBackgroundColor === 'function') { win.setBackgroundColor(glassActive(translucencyState) ? '#00000000' : getWindowBackgroundColor()) } if (changed.material) { // Glass frost level = the platform material. Animate the macOS hop so // a deliberate frost switch feels continuous — which only works if we // don't re-issue it on unrelated updates. Windows has no equivalent // animation option; setBackgroundMaterial is instantaneous. if (IS_MAC && typeof win.setVibrancy === 'function') { win.setVibrancy(vibrancyForTranslucency(translucencyState), { animationDuration: 150 }) } if (IS_WINDOWS && GLASS_SUPPORTED && typeof win.setBackgroundMaterial === 'function') { win.setBackgroundMaterial(backgroundMaterialFor(translucencyState)) } } } if (changed.opacity) { applyWindowOpacity(win) } } catch (error) { rememberLog(`[translucency] apply failed: ${error.message}`) } } // Constructor options every chat window shares for its translucency surface: // the platform material, the webContents backing, and a native opacity only if // the state actually fades — all under the CURRENT state. Glass omits // backgroundColor so the material shows from the first frame (Electron hands a // translucent window a transparent default backing, and runtime swaps are lost // early in a window's life — see applyWindowTranslucency); otherwise the opaque // themed anti-flash backing. // // Call sites also register the window in translucencyBackedWindows so a live // toggle can re-apply. The HUD, pet overlay, quick entry and wake indicator // are `transparent: true` windows that own their backgrounds and are // deliberately not chat windows. function chatWindowSurfaceOptions() { return { vibrancy: IS_MAC ? vibrancyForTranslucency(translucencyState) : undefined, // Pin the material to its ACTIVE appearance: several NSVisualEffectView // materials collapse to a shared inactive look when the window blurs // (measured on macOS 26: sidebar, popover and under-window composited // pixel-identically once unfocused), which would quietly erase the // user's frost choice whenever they click elsewhere. Only observable // under glass — everywhere else the page buries the material. visualEffectState: IS_MAC ? ('active' as const) : undefined, // NOT `transparent: true` on Windows. The backdrop material already makes // the window translucent on its own: `IsTranslucent` answers yes off // `background_material_` alone, which is what gives the page its transparent // default backing, and `SetBackgroundMaterial` flips widget translucency // live, so a Clear→Glass toggle needs no recreate either way. Its one gate // is a frameless window, and `titleBarStyle: 'hidden'` already makes // `has_frame()` false here. // // What `transparent` adds on top is permanent and unwanted: it pins the // widget to kTranslucent for the window's whole life, so even glass-OFF // windows pay a DirectComposition redraw per frame (electron#39895), and it // opts into the documented transparent-window limits — including that a // RESIZABLE transparent window is unsupported and breaks (electron#48421). // Every chat window is resizable. backgroundMaterial: IS_WINDOWS && GLASS_SUPPORTED ? backgroundMaterialFor(translucencyState) : undefined, ...windowOpacityOptions(translucencyState), ...windowBackingOptions(translucencyState, getWindowBackgroundColor()) } } function isHexColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) } // Background color to paint a window with BEFORE its renderer loads, so a new // (or reopened) window doesn't flash white/light in dark mode. Prefer the theme // the renderer last reported; fall back to the OS preference on first launch. function getWindowBackgroundColor() { if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) { return rendererTitleBarTheme.background } return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7' } // Transparent WCO — renderer chrome shows through. rgba(0,0,0,0) can fall back // to GetFrameColor() on some Electron builds; rgba(1,0,0,0) is the escape hatch. const TITLEBAR_OVERLAY_COLOR = 'rgba(1, 0, 0, 0)' function getTitleBarOverlayOptions() { if (IS_MAC) { // Tahoe (Darwin 25+) misplaces the traffic lights when the overlay has a // nonzero height (electron#49183); 0 there keeps them at the configured // inset. See macTitleBarOverlayHeight. return { height: macTitleBarOverlayHeight({ darwinMajor: DARWIN_MAJOR, titlebarHeight: TITLEBAR_HEIGHT }) } } // WSLg paints WCO via the RDP host's own min/max/close, so requesting // an Electron overlay there just leaves a dead gap. Plain Linux (KDE, // GNOME) can use the native overlay — let it through. if (!IS_WINDOWS && IS_WSL) { return false } return { color: TITLEBAR_OVERLAY_COLOR, height: TITLEBAR_HEIGHT, symbolColor: rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground) ? rendererTitleBarTheme.foreground : nativeTheme.shouldUseDarkColors ? '#f7f7f7' : '#242424' } } // Push refreshed overlay options to a live window after a theme/appearance // change. No-op only on plain (non-WSL) Linux, where getTitleBarOverlayOptions() // returns false; the try/catch additionally guards builds where // setTitleBarOverlay isn't supported. function applyTitleBarOverlay(win) { const options = getTitleBarOverlayOptions() if (!options || typeof options !== 'object') { return } try { win?.setTitleBarOverlay?.(options) } catch { // Overlay not supported on this platform/build — leave the frameless // titlebar as-is. } } const MEDIA_MIME_TYPES = { '.avi': 'video/x-msvideo', '.bmp': 'image/bmp', '.flac': 'audio/flac', '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.m4a': 'audio/mp4', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.ogg': 'audio/ogg', '.opus': 'audio/ogg; codecs=opus', '.pdf': 'application/pdf', '.png': 'image/png', '.svg': 'image/svg+xml', '.wav': 'audio/wav', '.webm': 'video/webm', '.webp': 'image/webp' } const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm']) const PREVIEW_PDF_EXTENSIONS = new Set(['.pdf']) const PREVIEW_WATCH_DEBOUNCE_MS = 120 const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost']) const TEXT_PREVIEW_MAX_BYTES = 512 * 1024 const PREVIEW_LANGUAGE_BY_EXT = { '.c': 'c', '.conf': 'ini', '.cpp': 'cpp', '.css': 'css', '.csv': 'csv', '.go': 'go', '.graphql': 'graphql', '.h': 'c', '.hpp': 'cpp', '.html': 'html', '.java': 'java', '.js': 'javascript', '.json': 'json', '.jsx': 'jsx', '.kt': 'kotlin', '.lua': 'lua', '.md': 'markdown', '.mjs': 'javascript', '.py': 'python', '.rb': 'ruby', '.rs': 'rust', '.sh': 'shell', '.sql': 'sql', '.svg': 'xml', '.toml': 'toml', '.ts': 'typescript', '.tsx': 'tsx', '.txt': 'text', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml', '.zsh': 'shell' } function looksBinary(buffer) { if (!buffer.length) { return false } let suspicious = 0 for (const byte of buffer) { if (byte === 0) { return true } // Allow common whitespace controls: tab, LF, CR. if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { suspicious += 1 } } return suspicious / buffer.length > 0.12 } function previewFileMetadata(filePath, mimeType) { let byteSize = 0 let binary = false try { const stat = fs.statSync(filePath) byteSize = stat.size if (!mimeType.startsWith('image/')) { const fd = fs.openSync(filePath, 'r') try { const sample = Buffer.alloc(Math.min(byteSize, 4096)) const bytesRead = fs.readSync(fd, sample, 0, sample.length, 0) binary = looksBinary(sample.subarray(0, bytesRead)) } finally { fs.closeSync(fd) } } } catch { // Metadata is best-effort; the read handlers surface hard errors later. } return { binary, byteSize, large: byteSize > TEXT_PREVIEW_MAX_BYTES } } app.setName(APP_NAME) // Windows toast notifications silently no-op unless an AppUserModelID is set: // `new Notification().show()` returns without error and nothing appears. The // AUMID must match the installed Start Menu shortcut's AUMID, which // electron-builder derives from the build `appId` (com.nousresearch.hermes) — // keep this string in sync with package.json `build.appId`. macOS/Linux don't // need this, so gate it on Windows. (Fixes: desktop approval/turn notifications // never firing on Windows.) if (IS_WINDOWS) { app.setAppUserModelId(AITURK_PRODUCT.appId) } // Seed the native About panel with the live Hermes version. This is refreshed // on every open via the explicit "About" menu handler (refreshAboutPanel), so // an in-place `hermes update` mid-session is reflected without an app restart; // the seed here just covers the first open and any non-menu invocation path. app.setAboutPanelOptions({ applicationName: APP_NAME, applicationVersion: resolveProductVersion(), copyright: 'Copyright © 2026 AITURK / TurkServis; Hermes © 2025 Nous Research' }) // Custom scheme for streaming audio/video into the renderer. Local paths read // from this machine; remote paths are proxied through the configured gateway // with main-process authentication. This avoids whole-file data URLs and keeps // playback seekable and Range-aware. Must be registered before app readiness. protocol.registerSchemesAsPrivileged([ { scheme: MEDIA_PROTOCOL, privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true } } ]) function registerMediaProtocol() { const handler = createMediaProtocolHandler({ ensureRemoteBearer: baseUrl => ensureNativeAccessToken(baseUrl).catch(() => null), fetchLocal: (resolvedPath, headers, method) => electronNet.fetch(pathToFileURL(resolvedPath).toString(), { bypassCustomProtocolHandlers: true, credentials: 'omit', headers, method }), fetchRemote: (url, headers, method) => electronNet.fetch(url, { bypassCustomProtocolHandlers: true, credentials: 'omit', headers, method }), fetchRemoteWithCookies: (url, headers, method) => { const oauthSession = getOauthSessionForUrl(url) if (!oauthSession) { throw new Error('OAuth session partition is unavailable.') } return oauthSession.fetch(url, { bypassCustomProtocolHandlers: true, credentials: 'include', headers, method }) }, resolveLocalFile: async filePath => { const { resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }) return resolvedPath }, // Claim-guarded (#90812): a media stream load can race a renderer's own // reconnect dial for the same (connectionId, profile) scope; coalescing // here avoids bootstrapping a second SSH tunnel / remote dashboard. resolveRemoteConnection: ({ connectionId, profile }) => backendDialClaims.run(backendScopeKey(connectionId, profile), () => connectionId ? ensureRegistryBackend(connectionId, profile) : ensureBackend(profile) ) }) protocol.handle(MEDIA_PROTOCOL, handler) } let mainWindow = null const backendConnectionState = createBackendConnectionState, any>() const remoteLiveness = new RemoteLivenessTracker() const remoteRevalidation = new RemoteRevalidationCoordinator() const registryDispatchRevalidation = new RemoteRevalidationCoordinator() // Single-owner reconnect/dial claim (#90812): reconnectGateway()'s in-flight // lock is per-renderer, so two windows racing one wake can both invoke the // backend ensure IPC and double-dial a pooled SSH backend. Main owns backend // lifecycles, so concurrent dials for one (connectionId, profile) scope // coalesce here — the second caller awaits the first spawn's result. const backendDialClaims = new BackendDialClaims() // True while connection-config:apply soft-rehomes the primary — suppresses the // backend-exit toast so an intentional kill doesn't look like a crash. let softRehomeInProgress = false // Additional per-profile backends, keyed by profile name. The PRIMARY backend // (the desktop's launch profile) stays managed by backendConnectionState + // startHermes(); this pool only holds EXTRA profile // backends spawned lazily when a session belongs to a different profile. A user // with no named profiles never populates this map, so their experience is // byte-for-byte the single-backend behavior. const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt } const profileDeletionGate = new ProfileDeletionGate() // Keep the pool light: cap concurrent profile backends (LRU eviction) and reap // idle ones. A user idles at exactly the primary backend; pool backends only // exist while a non-primary profile is actively being chatted through. // Pool sizing is a device preference (Settings → Advanced → pool rows), not a // launch constant: mutable at runtime, persisted in userData, applied live. // The legacy HERMES_DESKTOP_POOL_* env vars remain the initial-value fallback // for scripted/headless setups; after launch the stored preference wins. const POOL_LIMITS_PATH = path.join(app.getPath('userData'), 'pool-limits.json') function readPersistedPoolLimits() { try { const limits = parsePoolLimits(fs.readFileSync(POOL_LIMITS_PATH, 'utf8')) rememberLog( `[pool-limits] loaded from ${POOL_LIMITS_PATH}: maxBackends=${limits.maxBackends}, idleMs=${limits.idleMs}` ) return limits } catch { // No persisted file yet — fall back to the legacy env vars so scripted // setups keep working. Log which source won: a silently-ignored env var // here costs a scripted-setup user a debugging session. const fromEnv = clampPoolLimits({ maxBackends: Number(process.env.HERMES_DESKTOP_POOL_MAX) || undefined, idleMs: Number(process.env.HERMES_DESKTOP_POOL_IDLE_MS) || undefined }) if (fromEnv.maxBackends !== POOL_LIMITS_DEFAULTS.maxBackends || fromEnv.idleMs !== POOL_LIMITS_DEFAULTS.idleMs) { rememberLog( `[pool-limits] no saved file; using env-var overrides: maxBackends=${fromEnv.maxBackends}, idleMs=${fromEnv.idleMs}` ) } else { rememberLog('[pool-limits] no saved file and no env overrides; using defaults') } return fromEnv } } function persistPoolLimits(limits) { try { fs.mkdirSync(path.dirname(POOL_LIMITS_PATH), { recursive: true }) // Atomic write: write to a temp file in the same directory, then rename. // A crash mid-write would otherwise leave truncated JSON and silently // lose the user's saved sizing. const tmpPath = `${POOL_LIMITS_PATH}.tmp` fs.writeFileSync(tmpPath, JSON.stringify(limits, null, 2), 'utf8') fs.renameSync(tmpPath, POOL_LIMITS_PATH) } catch (error) { rememberLog(`[pool-limits] write failed: ${error.message}`) } } // rememberLog() state. Declared here, before the top-level // readPersistedPoolLimits() call below, because that call logs during module // evaluation; declaring these later crashed launch with `undefined.push` in // the packaged build (esbuild lowers the TDZ to undefined instead of throwing). const hermesLog = [] let desktopLogBuffer = '' let desktopLogFlushTimer = null let desktopLogFlushPromise = Promise.resolve() let poolLimits = readPersistedPoolLimits() // Hard cap on local backends that are starting OR running (the LRU eviction // above is soft — it spares keepalive-fresh entries). Follows the live // preference: setPoolLimits() pushes a new max into the coordinator. const localBackendSpawnCoordinator = new LocalBackendSpawnCoordinator(poolLimits.maxBackends) // How long a spawn may wait for a free local slot. Must stay under the // renderer's BACKEND_BOOT_WAIT_TIMEOUT_MS (45s, src/lib/with-timeout.ts) so // the queued ticket fails before the renderer does and the user sees why. const POOL_SLOT_WAIT_MS = 30_000 function poolMaxBackends() { return poolLimits.maxBackends } function poolIdleMs() { return poolLimits.idleMs } /** * Apply new limits live: persist, then converge the running pool — evict * LRU backends down to the new max, and let the (already running) idle * reaper handle a shortened idle window on its next tick. Returns the * limits actually in force (post-clamp). */ function setPoolLimits(raw) { poolLimits = clampPoolLimits(raw) persistPoolLimits(poolLimits) localBackendSpawnCoordinator.setLimit(poolLimits.maxBackends) evictLruPoolBackends(poolMaxBackends()) startPoolIdleReaper() return { ...poolLimits } } // A backend touched within this window has a live renderer socket (the keepalive // pings every 60s for every open profile). LRU eviction must spare these — a // concurrent multi-profile session keeps several backends "fresh" at once, and // killing one to honor the soft cap would abort a running agent. // // The window is intentionally MUCH wider than the 60s ping cadence: // * 1 missed ping = +60s of apparent silence // * WSL2 IPC stall = the renderer's `hermes:backend:touch` roundtrips // through 9p; a single brief 9p hiccup can stretch a // ping to ~30s of observed silence (#95189: gateways // exited every ~2 min on WSL2 because the previous // 90s window left no headroom — one delayed ping // pushed a live backend past the threshold and the // cap-driven eviction killed the active profile's // backend mid-session, re-minting runtime ids and // re-allocating pooled gateway secondaries ~700×/day). // * 3× ping + 60s headroom = ~4 min, comfortable margin for two missed // pings + WSL2 IPC stall. The hard ceiling for the cap-eligible set is // pool idle window above (default 10 min) — this constant only governs the // "is this backend plausibly still alive" question for LRU eviction, // not when the idle reaper definitively tears a backend down. const POOL_KEEPALIVE_FRESH_MS = Math.max( 120_000, Number(process.env.HERMES_DESKTOP_POOL_KEEPALIVE_FRESH_MS) || 4 * 60_000 ) let poolIdleReaper = null let backendOrphanReapPromise = null // Auto-reload budget for renderer crashes, shared by EVERY window (primary, // secondary session, instance) so a crash loop anywhere is suppressed after // the same budget instead of reloading per-window forever. A deterministic // startup crash would otherwise loop forever (reload → crash → reload), // pinning CPU and spamming logs. Allow a few reloads per rolling window, then // stop and leave the dead window so the user can read the error / quit. const RENDERER_RELOAD_WINDOW_MS = 60_000 const RENDERER_RELOAD_MAX = 3 const rendererReloadTimesRef: { current: number[] } = { current: [] } // Latched bootstrap failure: when the first-launch install fails, we hold // onto the error so subsequent startHermes() calls (e.g. the renderer's // ensureGatewayOpen retrying after the WS won't open) return the same error // instead of re-running install.ps1 in a hot loop. Cleared explicitly by // the renderer's "Reload and retry" path or by quitting the app. let bootstrapFailure = null // Latched non-bootstrap backend spawn failure — stops getConnection() from // respawning hermes serve backend children in a tight loop while boot is broken. let backendStartFailure = null // Latched CONFIRMED remote reauth failure. Remote failures deliberately do not // latch via backendStartFailure (they're usually transient and must stay // retryable), but a rejected session cannot self-heal — and the non-latching // path actively breaks recovery: each retry re-emits running:true and hides // the boot-failure overlay, so the "Sign in" button flickers away before it // can be clicked. Cleared on every recovery path and on a confirmed sign-in. let remoteReauthFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. let bootstrapAbortController = null // Explicit "the user asked for a repair" flag. Repair used to signal intent by // deleting the bootstrap marker, which stranded healthy installs whose only // problem was a transient backend error (#72166). Intent now lives here, so // repair can force the installer without destroying provenance about how the // install was created. Cleared once the reinstall is under way. let bootstrapRepairRequested = false // Counter for in-flight repair attempts. Reset on a clean boot completion // (see runBootstrap -> ensureRuntime resolve path). Each successive repair // in the same failure episode increments this; once it crosses // MAX_BOOTSTRAP_REPAIR_SOFT_ATTEMPTS the guard escalates from "soft restart" // to "hard reinstall" so a transient backend stall (issue #74874) stops // looping the user through a destructive venv reinstall. let bootstrapRepairAttempt = 0 const MAX_BOOTSTRAP_REPAIR_SOFT_ATTEMPTS = 3 let connectionConfigCache = null let connectionConfigCacheMtime = null let connectionRegistryCache = null let connectionRegistryCacheMtime = null let remoteHeaderRulesInstalled = false const remoteWsHeaderStore = createRemoteWsHeaderStore() const previewWatchers = new Map() let previewShortcutActive = false let nativeThemeListenerInstalled = false let bootProgressState = { error: null, fakeMode: BOOT_FAKE_MODE, isCloudBackendDown: false, message: 'Waiting to start Hermes backend', phase: 'idle', progress: 0, retryable: false, running: false, statusCode: null, timestamp: Date.now() } // Pure planner: ordered fs ops to bound a live log of `size`. [] = nothing. // Each step is ['rm', path] or ['mv', src, dst]; executed best-effort so a // missing chain link never aborts the rest. function planDesktopLogRotation(size) { if (size < DESKTOP_LOG_MAX_BYTES) { return [] } const backups = n => Array.from({ length: n }, (_, i) => desktopLogBackupPath(i + 1)) // Pathological boot-loop log: reclaim live + every backup outright. if (size > DESKTOP_LOG_DISCARD_BYTES) { return [DESKTOP_LOG_PATH, ...backups(DESKTOP_LOG_BACKUP_COUNT)].map(p => ['rm', p]) } // Cascade: drop oldest, shift each up, live -> .1. const ops = [['rm', desktopLogBackupPath(DESKTOP_LOG_BACKUP_COUNT)]] for (let i = DESKTOP_LOG_BACKUP_COUNT - 1; i >= 1; i--) { ops.push(['mv', desktopLogBackupPath(i), desktopLogBackupPath(i + 1)]) } ops.push(['mv', DESKTOP_LOG_PATH, desktopLogBackupPath(1)]) return ops } function rotateDesktopLogIfNeededSync() { let size try { size = fs.statSync(DESKTOP_LOG_PATH).size } catch { return // No live file yet — the append (re)creates it. } for (const [op, src, dst] of planDesktopLogRotation(size)) { try { if (op === 'rm') { fs.rmSync(src, { force: true }) } else { fs.renameSync(src, dst) } } catch { // Best-effort — logging must never block startup/shutdown. } } } async function rotateDesktopLogIfNeededAsync() { let size try { size = (await fs.promises.stat(DESKTOP_LOG_PATH)).size } catch { return // No live file yet — the append (re)creates it. } for (const [op, src, dst] of planDesktopLogRotation(size)) { try { if (op === 'rm') { await fs.promises.rm(src, { force: true }) } else { await fs.promises.rename(src, dst) } } catch { // Best-effort — logging must never crash the shell. } } } function flushDesktopLogBufferSync() { if (!desktopLogBuffer) { return } const chunk = desktopLogBuffer desktopLogBuffer = '' try { fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) rotateDesktopLogIfNeededSync() fs.appendFileSync(DESKTOP_LOG_PATH, chunk) } catch { // Logging must never block app startup/shutdown. } } function flushDesktopLogBufferAsync() { if (!desktopLogBuffer) { return desktopLogFlushPromise } const chunk = desktopLogBuffer desktopLogBuffer = '' desktopLogFlushPromise = desktopLogFlushPromise .then(async () => { await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) await rotateDesktopLogIfNeededAsync() await fs.promises.appendFile(DESKTOP_LOG_PATH, chunk) }) .catch(() => { // Logging must never crash the desktop shell. }) return desktopLogFlushPromise } function scheduleDesktopLogFlush() { if (desktopLogFlushTimer) { return } desktopLogFlushTimer = setTimeout(() => { desktopLogFlushTimer = null void flushDesktopLogBufferAsync() }, DESKTOP_LOG_FLUSH_MS) } function rememberLog(chunk) { const text = String(chunk || '').trim() if (!text) { return } // One timestamp per chunk: lines arriving in the same event happened // at the same moment. ISO-8601 UTC, matching agent.log/gateway.log. const stamp = new Date().toISOString() const lines = text.split(/\r?\n/).map(line => formatDesktopLogLine(line, stamp)) hermesLog.push(...lines) if (hermesLog.length > 300) { hermesLog.splice(0, hermesLog.length - 300) } desktopLogBuffer += `${lines.join('\n')}\n` if (desktopLogBuffer.length >= DESKTOP_LOG_BUFFER_MAX_CHARS) { if (desktopLogFlushTimer) { clearTimeout(desktopLogFlushTimer) desktopLogFlushTimer = null } void flushDesktopLogBufferAsync() return } scheduleDesktopLogFlush() } installCrashForensics({ flush: flushDesktopLogBufferSync, log: rememberLog }) // A rejected loadURL leaves a blank window and, unhandled, no trace anywhere // the user can send us. `label` names the surface so the log says which one. function loadWindowUrl(win, url, label) { win.loadURL(url).catch(error => rememberLog(`${label} failed to load: ${describeCrashReason(error)}`)) } function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() if (!raw) { return false } let parsed try { parsed = new URL(raw) } catch { return false } // `file://` URLs come from the artifacts panel (the renderer can't open // them itself because Chromium blocks file:// navigation from the app // origin). Hand them to `shell.openPath`, which dispatches to the OS // file association. If the OS can't open it (`error` is a non-empty // string), fall back to revealing the file in the system file manager. if (parsed.protocol === 'file:') { let localPath try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open external file' }) } catch { return false } void shell .openPath(localPath) .then(error => { if (!error) { return } rememberLog(`[file] openPath failed: ${error}; revealing in folder instead`) try { shell.showItemInFolder(localPath) } catch (revealError) { rememberLog(`[file] showItemInFolder failed: ${revealError.message}`) } }) .catch(error => rememberLog(`[file] openPath rejected: ${error.message}`)) return true } if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) { return false } const url = parsed.toString() if (IS_WSL) { rememberLog(`[link] opening via WSL→Windows: ${url}`) const proc = spawn('cmd.exe', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true }) proc.on('error', error => { rememberLog(`[link] cmd.exe start failed: ${error.message}; falling back to xdg-open`) shell.openExternal(url).catch(fallback => rememberLog(`[link] xdg-open failed: ${fallback.message}`)) }) proc.unref() return true } shell.openExternal(url).catch(error => rememberLog(`[link] openExternal failed: ${error.message}`)) return true } async function openPreviewInBrowser(rawUrl) { const raw = String(rawUrl || '').trim() if (!raw) { return false } let parsed try { parsed = new URL(raw) } catch { return false } if (parsed.protocol === 'file:') { let localPath try { localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' }) } catch { return false } await shell.openExternal(pathToFileURL(localPath).toString()) return true } return openExternalUrl(raw) } function ensureWslWindowsFonts() { if (!IS_WSL) { return } const fontsDir = ['/mnt/c/Windows/Fonts', '/mnt/c/windows/fonts'].find(candidate => { try { return fs.statSync(candidate).isDirectory() } catch { return false } }) if (!fontsDir) { return } try { const confDir = path.join(app.getPath('home'), '.config', 'fontconfig', 'conf.d') const confPath = path.join(confDir, '99-hermes-wsl-windows-fonts.conf') let existing = '' try { existing = fs.readFileSync(confPath, 'utf8') } catch { existing = '' } if (existing.includes(fontsDir)) { return } fs.mkdirSync(confDir, { recursive: true }) fs.writeFileSync( confPath, `\n\n\n ${fontsDir}\n\n` ) rememberLog(`[fonts] wired WSL Windows fonts for renderer: ${fontsDir}`) const cache = spawn('fc-cache', ['-f', fontsDir], { detached: true, stdio: 'ignore' }) cache.on('error', () => undefined) cache.unref() } catch (error) { rememberLog(`[fonts] WSL font setup skipped: ${error.message}`) } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } function clampBootProgress(value) { const numeric = Number(value) if (!Number.isFinite(numeric)) { return 0 } return Math.max(0, Math.min(100, Math.round(numeric))) } function broadcastBootProgress() { if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:boot-progress', bootProgressState) } // Bootstrap-event broadcast channel + state. The bootstrap runner emits a // stream of events (manifest, stage, log, complete, failed) that the renderer // install overlay subscribes to. We also keep a running snapshot: // - manifest: the stage list (rendered as a checklist in the overlay) // - stages: per-stage state ('pending' | 'running' | 'succeeded' | // 'skipped' | 'failed') keyed by stage name // - active: true while a bootstrap is in flight; false otherwise // - error: last 'failed' event's error message // - log: bounded ring buffer of the last 200 log lines for the // "Show details" affordance in the overlay // // The snapshot is queryable via the hermes:bootstrap:get IPC handler so a // reloaded renderer (e.g. devtools reload during dev) recovers state. // Bootstrap log ring: bounded buffer so a long install (npm + playwright // downloads can emit thousands of lines) doesn't grow unbounded in memory // AND so the renderer's getBootstrapState() reply stays a reasonable size. // We keep enough to cover an entire failed stage's transcript so the // 'Copy output' button gives the user actually-actionable context, not // just the last few lines. const BOOTSTRAP_LOG_RING_MAX = 500 let bootstrapState = { active: false, manifest: null, stages: {}, error: null, log: [], startedAt: null, completedAt: null, setupChoice: null, unsupportedPlatform: null } let firstRunSetupGate = null function broadcastBootstrapEvent(ev) { if (ev.type === 'manifest') { bootstrapState.manifest = ev bootstrapState.active = true bootstrapState.setupChoice = null bootstrapState.startedAt = bootstrapState.startedAt || Date.now() bootstrapState.stages = {} for (const stage of ev.stages || []) { bootstrapState.stages[stage.name] = { state: 'pending', json: null, durationMs: null, error: null } } } else if (ev.type === 'stage') { bootstrapState.stages[ev.name] = { state: ev.state, durationMs: ev.durationMs ?? null, json: ev.json ?? null, error: ev.error ?? null } } else if (ev.type === 'log') { bootstrapState.log.push({ ts: Date.now(), stage: ev.stage || null, line: ev.line, stream: ev.stream || 'stdout' }) if (bootstrapState.log.length > BOOTSTRAP_LOG_RING_MAX) { bootstrapState.log.splice(0, bootstrapState.log.length - BOOTSTRAP_LOG_RING_MAX) } } else if (ev.type === 'complete') { bootstrapState.active = false bootstrapState.completedAt = Date.now() bootstrapState.error = null bootstrapState.unsupportedPlatform = null } else if (ev.type === 'failed') { bootstrapState.active = false bootstrapState.error = ev.error || 'unknown error' bootstrapState.setupChoice = null } else if (ev.type === 'unsupported-platform') { bootstrapState.active = false bootstrapState.setupChoice = null bootstrapState.unsupportedPlatform = { platform: ev.platform, activeRoot: ev.activeRoot, installCommand: ev.installCommand, docsUrl: ev.docsUrl } } else if (ev.type === 'setup-choice') { bootstrapState.active = false bootstrapState.error = null bootstrapState.manifest = null bootstrapState.stages = {} bootstrapState.setupChoice = ev.active ? { platform: ev.platform, activeRoot: ev.activeRoot } : null bootstrapState.unsupportedPlatform = null } else if (ev.type === 'dismissed') { resetBootstrapSnapshot() } if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:bootstrap:event', ev) } function getBootstrapState() { return bootstrapState } function resetBootstrapSnapshot() { bootstrapState = { active: false, manifest: null, stages: {}, error: null, log: [], startedAt: null, completedAt: null, setupChoice: null, unsupportedPlatform: null } } function promptFirstRunSetupChoice(backend) { broadcastBootstrapEvent({ type: 'setup-choice', active: true, platform: backend.platform || process.platform, activeRoot: backend.activeRoot || ACTIVE_HERMES_ROOT }) } function hideFirstRunSetupChoice() { if (bootstrapState.setupChoice) { broadcastBootstrapEvent({ type: 'setup-choice', active: false }) } } function getFirstRunSetupGate() { if (!firstRunSetupGate) { firstRunSetupGate = createFirstRunSetupGate({ hideChoice: hideFirstRunSetupChoice, log: rememberLog, onStuck: (_backend, stuckAfterMs) => { updateBootProgress( { error: null, message: `Still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)} seconds`, phase: 'bootstrap.choice', progress: 12, running: true }, { allowDecrease: true } ) }, promptChoice: promptFirstRunSetupChoice }) } return firstRunSetupGate } async function waitForFirstRunSetupChoice(backend) { const gate = getFirstRunSetupGate() if (!gate.shouldGate(backend)) { return 'continue-local' } updateBootProgress( { error: null, message: 'Waiting for first-run setup choice', phase: 'bootstrap.choice', progress: 12, running: true }, { allowDecrease: true } ) return gate.wait(backend) } function continueFirstRunLocalBootstrap() { getFirstRunSetupGate().continueLocal() } function abandonFirstRunSetupChoiceForRemoteApply() { const gate = getFirstRunSetupGate() if (!gate.hasWaiter()) { return false } const resumedGatedConnection = gate.abandonForRemoteApply() if (resumedGatedConnection) { broadcastBootstrapEvent({ type: 'dismissed' }) } return resumedGatedConnection } function updateBootProgress(update, options: { allowDecrease?: boolean } = {}) { const nextProgressRaw = typeof update.progress === 'number' ? clampBootProgress(update.progress) : bootProgressState.progress const nextProgress = options.allowDecrease ? nextProgressRaw : Math.max(bootProgressState.progress, nextProgressRaw) bootProgressState = { ...bootProgressState, ...update, error: update.error === undefined ? bootProgressState.error : update.error, fakeMode: BOOT_FAKE_MODE || Boolean(update.fakeMode), progress: nextProgress, // `retryable` rides with `error`: it survives updates that preserve the // error and resets alongside a new/cleared error unless explicitly set. retryable: update.retryable === undefined ? update.error === undefined && Boolean(bootProgressState.retryable) : Boolean(update.retryable), timestamp: Date.now() } if (update.message) { rememberLog(`[boot] ${update.message}`) } broadcastBootProgress() } async function advanceBootProgress(phase, message, progress) { updateBootProgress({ phase, message, progress, running: true, error: null }) if (BOOT_FAKE_MODE) { await sleep(BOOT_FAKE_STEP_MS) } } function fileExists(filePath) { try { return fs.statSync(filePath).isFile() } catch { return false } } function directoryExists(filePath) { try { return fs.statSync(filePath).isDirectory() } catch { return false } } // --- in-app update mutual exclusion (#50238) ------------------------------- // The Tauri updater writes HERMES_HOME/.hermes-update-in-progress for the whole // duration of an `--update` run (see update.rs UpdateMarkerGuard). If the user // relaunches the desktop mid-update — because the window vanished with no // progress and looks crashed — a fresh instance must NOT spawn its own local // backend: that backend re-locks the venv shim, the updater's straggler cleanup // (`force_kill_other_hermes`, taskkill /IM hermes.exe) kills it, the launch // fails with the 45s "backend didn't come up" error, and the relaunch/kill // cycle loops. Instead the fresh instance parks until the update finishes, then // brings the backend up itself (it is the surviving instance — the updater's // own relaunch hits our single-instance lock and quits). Marker parsing + // staleness self-heal live in update-marker.ts (unit-tested). // How long we'll park the launch waiting for a live update to finish before // giving up and starting the backend anyway (belt-and-suspenders alongside the // marker's own age ceiling; covers a stuck-but-alive updater). const UPDATE_WAIT_TIMEOUT_MS = 20 * 60 * 1000 const UPDATE_WAIT_POLL_MS = 1000 // How long the desktop lingers on the "updating, don't reopen" overlay after // spawning the detached updater, before it quits to release the venv shim. The // old 600ms was long enough to register the child process but far too short for // the user to READ the overlay — the window just vanished, looked like a crash, // and the user relaunched mid-update (the #50238 restart-loop trigger). A // couple of seconds lets the message land and bridges the gap until the // updater's own progress window appears. (#50419) const UPDATE_HANDOFF_DWELL_MS = 2500 // Gate deps shared by the primary-window boot path and the pool-backend // spawn path. Consulting BOTH the on-disk marker and the in-process // updateInFlight flag is load-bearing (#73822): applyUpdates kills its own // backend BEFORE the Windows venv-blocker scan but only writes the marker // AFTER it, so a marker-only gate lets the renderer's ~1s reconnect respawn // a backend inside the update's own critical section — which the scan then // reports as a blocker, aborting every update attempt. function updateGateDeps() { return { hasLiveMarker: () => Boolean(readLiveUpdateMarker(HERMES_HOME)), isUpdateInFlight: () => updateInFlight } } // One-shot guard for the automatic bundle-swap relaunch below: the relaunched // instance carries this flag so a stamp that still mismatches (unreadable // resources, exotic packaging) can never produce a relaunch loop. const BUNDLE_SWAP_RELAUNCH_FLAG = '--hermes-bundle-swap-relaunched' // How long the parked instance waits for its own scheduled exit to land before // giving up and booting the stale build anyway. Better a torn renderer with a // banner than a window that never comes back. const BUNDLE_SWAP_RELAUNCH_FAILSAFE_MS = 15_000 // The detached updater swaps the packaged bundle on disk AFTER `hermes update` // exits (posix.sh mac_swap / windows.ps1). An instance reopened mid-update — // the #50238 gesture the gate above exists for — was launched from the // PRE-swap bundle, and the updater's `open` leg then merely focuses us (single // instance), so no process ever loads the new build. Letting boot proceed here // runs the new runtime under the old renderer: exactly the skew // detectRendererSkew() warns about, except the Updates card already says // "latest", so the warning's own remedy has nothing to run. // // This is the earliest point where the swap is PROVABLE — it happens while we // are parked on the gate, so checking any sooner (at `ready`, before the gate) // only ever compares a stamp with itself. Relaunching here also keeps the // boot-progress window up for the whole wait instead of leaving the user with // no window at all. // // Returns true when the relaunch was scheduled; the caller must park rather // than continue booting, because the process exits underneath it. function relaunchIntoSwappedBundle() { if (!IS_PACKAGED || process.argv.includes(BUNDLE_SWAP_RELAUNCH_FLAG)) { return false } if (!detectBundleSwap(INSTALL_STAMP, loadInstallStamp())) { return false } rememberLog('[updates] app bundle was swapped during the update; relaunching into the new build') try { app.relaunch({ args: [...buildNoSandboxRelaunchArgs(process.argv.slice(1)), BUNDLE_SWAP_RELAUNCH_FLAG] }) } catch (err) { rememberLog(`[updates] bundle-swap relaunch failed: ${err?.message || err}; continuing with the current build`) return false } void exitAfterBackendShutdown(0) return true } // Block until no live update is in progress (or we hit the wait timeout). // Emits a boot-progress phase so the renderer shows "Update in progress…" // rather than a frozen splash. Returns true if it parked at all. async function waitForUpdateToFinish() { let announced = false const outcome = await waitForUpdateClearance(updateGateDeps(), { onWaitTick: async reason => { if (!announced) { announced = true rememberLog(`[updates] update in progress (${reason}); deferring backend start until it finishes`) } await advanceBootProgress( 'backend.update-wait', 'An update is finishing — Hermes will start automatically when it completes…', 12 ) }, pollMs: UPDATE_WAIT_POLL_MS, timeoutMs: UPDATE_WAIT_TIMEOUT_MS }) // The detached hand-off script (scripts/desktop-update/windows.ps1) runs hidden; // its result file is the ONLY way the user learns a detached update // failed. Consume it exactly once, here, right where boot passes the // update gate — success gets a log line, failure gets a real dialog // (previously a failed detached update was indistinguishable from // "nothing happened"). try { const result = readAndConsumeHandoffResult(HERMES_HOME) if (result && result.ok && result.manual) { // Update landed but the user must act (reopen/reinstall/sandbox). On // machines with no shim browser and no notifier this dialog is the // FIRST time the message is visible — it must not be a log line. rememberLog(`[updates] detached update finished with manual action (branch ${result.branch}): ${result.message}`) dialog.showMessageBox({ type: 'warning', title: 'Hermes update', message: 'The update finished, but needs one more step', detail: result.message }) } else if (result && result.ok) { rememberLog(`[updates] detached update finished OK (branch ${result.branch})`) } else if (result) { rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`) dialog.showErrorBox( 'Hermes update did not finish', `${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}` ) } } catch (err) { rememberLog(`[updates] could not read hand-off result: ${err.message}`) } if (outcome === 'clear') { return false } if (outcome === 'timeout') { rememberLog('[updates] update still in progress after wait timeout; starting backend anyway') } else if (relaunchIntoSwappedBundle()) { await advanceBootProgress('backend.update-restart', 'Restarting Hermes to load the updated app…', 14) // Park while the scheduled exit lands so this stale build never starts a // backend; the failsafe below only runs if the exit somehow does not. await new Promise(resolve => setTimeout(resolve, BUNDLE_SWAP_RELAUNCH_FAILSAFE_MS)) rememberLog( `[updates] relaunch did not land within ${BUNDLE_SWAP_RELAUNCH_FAILSAFE_MS}ms; continuing with the current build` ) } else { rememberLog('[updates] update finished; proceeding with backend start') } return true } function unpackedPathFor(filePath) { return filePath.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked') } function findOnPath(command) { if (!command) { return null } if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) { if (!fileExists(command)) { return null } if (isWindowsBinaryPathInWsl(command, { isWsl: IS_WSL })) { return null } return command } const pathEntries = String(process.env.PATH || '') .split(path.delimiter) .filter(Boolean) // On Windows, try PATHEXT extensions BEFORE the bare (empty-extension) name. // A real command must resolve via its .exe/.cmd (Windows command-resolution // semantics consult PATHEXT); an extensionless file — e.g. a Git-Bash // shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`. // The empty entry is kept LAST so callers that already include the extension // (py.exe, pwsh.exe, powershell.exe) still resolve. const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS) for (const entry of pathEntries) { for (const extension of extensions) { const candidate = path.join(entry, `${command}${extension}`) if (fileExists(candidate)) { return candidate } } } return null } function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } function unwrapWindowsVenvHermesCommand(command, backendArgs) { return resolveVenvHermesCommand(command, backendArgs, { isWindows: IS_WINDOWS, isCommandScript, fileExists, directoryExists, canImportHermesCli, getVenvPython, getVenvSitePackagesEntries, buildDesktopBackendEnv, hermesHome: HERMES_HOME, resolvePath: (...segments) => path.resolve(...segments), dirname: p => path.dirname(p), basename: p => path.basename(p), rememberLog }) } // Does the resolved runtime understand the `serve` subcommand? The desktop // spawns `hermes serve`; runtimes older than serve only have `dashboard`. We // detect support so getBackendArgsForRuntime() can route old runtimes through // the legacy `dashboard --no-open` form instead of crashing on an unknown // subcommand (would brick every user mid-upgrade — #54568 follow-up). // // Fast path: read the runtime's own dashboard.py (instant, covers managed // installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once // (covers a bare `hermes` resolved from PATH with no known source root). Result // is cached per resolved runtime so we probe at most once per backend. const _serveSupportCache = new Map() function backendSupportsServe(backend) { if (!backend || !backend.command) { return true } const key = `${backend.command}::${backend.root || ''}` if (_serveSupportCache.has(key)) { return _serveSupportCache.get(key) } let supported = null if (backend.root) { try { const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8') supported = sourceDeclaresServe(src) } catch { supported = null // source unreadable — fall through to the probe } } if (supported === null) { try { const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : [] // Same cold-Windows Python-startup class as the runtime probes // (#61764/#72632/#72707): `serve --help` imports at least as much as // `hermes --version` (~10.5s measured cold), and a false negative here // is cached for the process lifetime, silently routing a modern // runtime through the legacy `dashboard` form. Share the probe budget // and its timeout-only retry instead of a thinner local bound. execProbeSync(backend.command, [...prefix, 'serve', '--help'], { cwd: backend.root || undefined, env: { ...process.env, HERMES_HOME, ...(backend.env || {}) }, timeout: PROBE_TIMEOUT_MS, stdio: 'ignore', // `.cmd`/`.bat` shim backends carry shell: true in their descriptor // (see resolveHermesBackend step 4); execFileSync of a .cmd without // shell throws EINVAL on modern Node, which the catch below would // mis-cache as "serve unsupported" for the process lifetime. shell: Boolean(backend.shell), windowsHide: true }) supported = true } catch { supported = false } } _serveSupportCache.set(key, supported) rememberLog( `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` ) return supported } // Given a resolved backend whose args target `serve`, return the args the // runtime actually understands: unchanged when `serve` is supported, or // rewritten to `dashboard --no-open` for older runtimes. function getBackendArgsForRuntime(backend) { return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args) } function normalizeExecutablePathForCompare(commandPath) { if (!commandPath) { return null } let resolved = path.resolve(String(commandPath)) try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { // Fallback to path.resolve() above. } return IS_WINDOWS ? resolved.toLowerCase() : resolved } function looksLikeDesktopAppBinary(commandPath) { if (!IS_WINDOWS || !commandPath) { return false } const normalizedCandidate = normalizeExecutablePathForCompare(commandPath) const normalizedCurrentExec = normalizeExecutablePathForCompare(process.execPath) if (normalizedCandidate && normalizedCurrentExec && normalizedCandidate === normalizedCurrentExec) { return true } let resolved = path.resolve(String(commandPath)) try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved) } catch { // Keep resolved path fallback. } const resourcesDir = path.join(path.dirname(resolved), 'resources') return ( fileExists(path.join(resourcesDir, 'app.asar')) || directoryExists(path.join(resourcesDir, 'app.asar.unpacked')) ) } function isHermesSourceRoot(root) { return directoryExists(root) && fileExists(path.join(root, 'hermes_cli', 'main.py')) } function findPythonForRoot(root) { const override = process.env.HERMES_DESKTOP_PYTHON if (override && fileExists(override)) { return override } const relativePaths = IS_WINDOWS ? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')] : [path.join('.venv', 'bin', 'python'), path.join('venv', 'bin', 'python')] for (const relativePath of relativePaths) { const candidate = path.join(root, relativePath) if (fileExists(candidate)) { return candidate } } return findSystemPython() } function findSystemPython() { if (!IS_WINDOWS) { // POSIX systems: PATH lookup is safe. for (const command of ['python3', 'python']) { const candidate = findOnPath(command) if (candidate) { return candidate } } return null } // Windows: PATH-based detection has TWO landmines we have to dodge. // // (1) The Microsoft Store "Python stub" lives at // %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe and is on PATH // by default on modern Windows. It's a redirector that opens the // Store window if no Store Python is installed. Running it for // `-m venv` would either succeed (real Store install — fine) or // pop the Store dialog (bad UX during boot). // (2) `py.exe` (Python launcher) is missing from per-user installs // that didn't check the launcher option, so PATH-only checks // miss real Python 3.13 installs (user-reported case). // // We also restrict ourselves to Python 3.11–3.13. 3.14 is the latest // CPython but several Hermes deps (notably pywinpty's Rust-built // windows_x86_64_msvc crate) don't yet publish 3.14 wheels, and // `pip install -e .` falls back to source-build, which fails without // a Rust toolchain. install.ps1 sidesteps this by pinning to 3.11 // via uv; until we add the same uv-managed Python pathway here, the // simplest fix is to refuse 3.14 detection and let the NSIS prereq // page offer to install 3.11 alongside. // // Strategy: probe in three passes, in order from most-precise to // least-precise, and ONLY use PATH lookup as a last resort after // confirming the candidate isn't the WindowsApps redirector. // // Pass 1: PEP 514 registry — every standards-compliant Python // installer registers itself at SOFTWARE\Python\PythonCore. // The MS Store stub does NOT register here, so a hit means // a real Python install. Versions are explicit so we // inherently filter 3.14 out. // Pass 2: Filesystem probe of standard install locations // (Program Files, LocalAppData\Programs\Python). Same // version filtering by directory name. // Pass 3: PATH lookup of `py.exe` (the launcher itself never // triggers the Store) — but call it with a version flag so // we resolve to a SPECIFIC supported version, not whatever // py.exe's default is (which on a 3.14-only box would be // 3.14). const SUPPORTED_VERSIONS = ['3.11', '3.12', '3.13'] const SUPPORTED_VERSIONS_NO_DOT = ['311', '312', '313'] // Pass 1: registry. Use `reg query` since main process doesn't have // a reliable in-process registry API across all electron versions. for (const hive of ['HKLM', 'HKCU']) { for (const version of SUPPORTED_VERSIONS) { try { const out = execFileSync( 'reg', ['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'], // Registry reads are near-instant; the bound only exists so a // pathologically wedged reg.exe can't hang the synchronous boot // resolver forever (this ran unbounded before). hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 }) ) // Output format: " (Default) REG_SZ C:\Path\To\Python\" const match = out.match(/REG_SZ\s+(.+?)\s*$/m) if (match) { const installPath = match[1].trim() const pythonExe = path.join(installPath, 'python.exe') if (fileExists(pythonExe)) { return pythonExe } } } catch { // Key not present — try next. } } } // Pass 2: filesystem probe of standard locations. const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files' const localAppData = process.env.LOCALAPPDATA || '' for (const versionDir of SUPPORTED_VERSIONS_NO_DOT) { const systemWide = path.join(programFiles, `Python${versionDir}`, 'python.exe') if (fileExists(systemWide)) { return systemWide } if (localAppData) { const perUser = path.join(localAppData, 'Programs', 'Python', `Python${versionDir}`, 'python.exe') if (fileExists(perUser)) { return perUser } } } // Pass 3: py.exe with explicit version flag. The launcher itself is // safe to invoke (no Store popup) and `py -3.13 -c "import sys; // print(sys.executable)"` resolves to the actual python.exe path of // the requested version. We try in version-priority order so the // first hit wins. const pyExe = findOnPath('py.exe') if (pyExe) { for (const version of SUPPORTED_VERSIONS) { try { const out = execFileSync( pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], // Bare interpreter startup — much lighter than the hermes-import // probes, but still python.exe under cold cache / AV scan, so // share the probe budget rather than running unbounded (this // synchronous exec previously had no timeout at all). timeout: PROBE_TIMEOUT_MS }) ) const candidate = out.trim() if (candidate && fileExists(candidate)) { return candidate } } catch { // py couldn't find that version — try next. } } } // We deliberately do NOT fall back to plain `python.exe` on PATH. // Without a way to verify the version safely (running `python -V` // risks the Microsoft Store popup), accepting whatever's there // could land us on 3.14 and trigger the Rust-build-from-source // failure. Better to return null and let the NSIS prereq page // offer to install a known-good 3.11 via winget. return null } // findGitBash — locate bash.exe on Windows. Resolves HERMES_GIT_BASH_PATH // first (mirrors tools/environments/local.py:_find_bash), then PortableGit, // standard install locations, and finally PATH. function findGitBash() { return _findGitBash({ isWindows: IS_WINDOWS, env: process.env, fileExists, findOnPath }) } function getVenvPython(venvRoot) { return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python')) } // Map a selected interpreter back to the venv that OWNS it (the directory // above bin/ or Scripts/), but only when that venv lives inside `root`. // Returns null for system pythons — they own no site-packages we should mount. // // This exists because findPythonForRoot() probes `.venv` before `venv`, and a // checkout can legitimately have BOTH (dev tooling venv + the CLI install // venv, possibly on different Python versions). The interpreter and the // site-packages placed on PYTHONPATH must come from the SAME venv: pairing a // .venv 3.12 python with venv/lib/python3.11/site-packages makes the backend // die on its first native import (pydantic_core) before the gateway binds — // the renderer then reports "Gateway offline" on every profile. function venvRootForPython(python: string, root: string) { const parent = path.dirname(python) const binName = path.basename(parent).toLowerCase() if (binName !== 'bin' && binName !== 'scripts') { return null } const candidate = path.dirname(parent) const relative = path.relative(root, candidate) if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { return null } return candidate } // Windows console-window flashes are governed by the *parent's* console, not by // each child spawn. A GUI-subsystem parent (pythonw.exe) has no console, so every // console-subsystem child it spawns (git, gh, cmd, ...) must allocate its own — // which flashes a window. A console-subsystem parent (python.exe) instead owns a // single console that all of its children inherit, so none of them flash. // // Note this change adds no new creationflag: the backend spawn is ALREADY wrapped // in hiddenWindowsChildOptions() (windowsHide: true), but that setting is INERT // against pythonw.exe — a GUI-subsystem process has no console for it to act on. // Switching the backend to the venv's console python.exe is what makes the // existing wrapper load-bearing: with windowsHide the process comes up owning a // *windowless* console (verified at runtime — it has an attachable console whose // window handle is NULL), and its children inherit that one windowless console // instead of each allocating a visible one. // // This makes "no flashing windows" a property of the one backend launch rather // than a flag that has to be remembered at every descendant spawn site. Restoring // console python also restores stdout, so the backend announces its port on the // normal HERMES_DASHBOARD_READY stdout line and no ready-file side channel is // needed. function makeDashboardReadyFile() { const dir = path.join(app.getPath('userData'), 'backend-ready') fs.mkdirSync(dir, { recursive: true }) return path.join(dir, `dashboard-${process.pid}-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.json`) } // resolveGitBinary — locate git.exe on Windows. A fresh installer-driven // install only has PortableGit under %LOCALAPPDATA%\hermes\git (never on // PATH), so a bare spawn('git') ENOENTs and self-update checks fail with // "Couldn't check for updates". Mirror findGitBash: PortableGit first, then // standard Git-for-Windows locations, then PATH. Cached after first probe. let _gitBinaryCache = null function resolveGitBinary() { if (_gitBinaryCache) { return _gitBinaryCache } if (!IS_WINDOWS) { _gitBinaryCache = findOnPath('git') || 'git' return _gitBinaryCache } const localAppData = process.env.LOCALAPPDATA || '' const candidates = [] if (localAppData) { candidates.push(path.join(localAppData, 'hermes', 'git', 'cmd', 'git.exe')) candidates.push(path.join(localAppData, 'hermes', 'git', 'bin', 'git.exe')) } candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'cmd', 'git.exe')) candidates.push(path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'cmd', 'git.exe')) if (localAppData) { candidates.push(path.join(localAppData, 'Programs', 'Git', 'cmd', 'git.exe')) } _gitBinaryCache = candidates.find(fileExists) || findOnPath('git') || 'git' return _gitBinaryCache } // resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH // that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually // lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's // terminal. Check the common install locations first, then PATH. Cached. let _ghBinaryCache = null function resolveGhBinary() { if (_ghBinaryCache) { return _ghBinaryCache } const candidates = [] if (IS_WINDOWS) { candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe')) if (process.env.LOCALAPPDATA) { candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe')) } } else { const home = app.getPath('home') candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh')) } _ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh' return _ghBinaryCache } function recentHermesLog() { return hermesLog.slice(-20).join('\n') } // ─── Self-update (git-pull against the running backend's hermes root) ────── function readDesktopUpdateConfig() { try { const parsed = JSON.parse(fs.readFileSync(DESKTOP_UPDATE_CONFIG_PATH, 'utf8')) const branch = typeof parsed?.branch === 'string' ? parsed.branch.trim() : '' return { branch: branch || DEFAULT_UPDATE_BRANCH } } catch { return { branch: DEFAULT_UPDATE_BRANCH } } } // Atomic file write: temp + rename (atomic on all platforms). Prevents // partial writes on crash/power loss that corrupt JSON config files. function writeFileAtomic(targetPath, data, encoding?: BufferEncoding) { const tmp = targetPath + '.tmp' fs.writeFileSync(tmp, data, encoding) fs.renameSync(tmp, targetPath) } function writeDesktopUpdateConfig(config) { fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true }) writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2)) } // ─── Main-window geometry persistence (window-state.json) ────────────────── function readWindowState() { try { return sanitizeWindowState(JSON.parse(fs.readFileSync(DESKTOP_WINDOW_STATE_PATH, 'utf8'))) } catch { return null } } // Persist the window's restored (non-maximized) bounds plus its maximized flag. // getNormalBounds() keeps the pre-maximize size, so un-maximizing next session // lands back where the user actually sized the window. function persistWindowState() { if (!mainWindow || mainWindow.isDestroyed() || mainWindow.isMinimized()) { return } try { const { x, y, width, height } = mainWindow.getNormalBounds() fs.mkdirSync(path.dirname(DESKTOP_WINDOW_STATE_PATH), { recursive: true }) writeFileAtomic( DESKTOP_WINDOW_STATE_PATH, JSON.stringify({ x, y, width, height, isMaximized: mainWindow.isMaximized() }, null, 2) ) } catch (err) { rememberLog(`[window-state] persist failed: ${err?.message || err}`) } } // move/resize fire many times mid-drag; debounce to one write. const schedulePersistWindowState = debounce(persistWindowState, 250) // Zoom's primary store is a main-process JSON file. The renderer localStorage // mirror lives under Electron's cache/storage folders, which crash recovery // can move or recreate — wiping the zoom setting exactly when the user just // recovered from a crash (#56726). JSON survives; localStorage is kept as a // secondary mirror so pre-JSON installs migrate transparently on first read. const DESKTOP_ZOOM_STATE_PATH = path.join(app.getPath('userData'), 'zoom-state.json') function readZoomState() { try { const raw = JSON.parse(fs.readFileSync(DESKTOP_ZOOM_STATE_PATH, 'utf8')) const level = Number(raw?.zoomLevel) return Number.isFinite(level) ? level : null } catch { return null } } function writeZoomState(zoomLevel) { try { fs.mkdirSync(path.dirname(DESKTOP_ZOOM_STATE_PATH), { recursive: true }) writeFileAtomic(DESKTOP_ZOOM_STATE_PATH, JSON.stringify({ zoomLevel }, null, 2)) } catch (error) { rememberLog(`[zoom] json persist failed: ${error?.message || error}`) } } // Match the backend's source resolution but bias toward a real git checkout. // Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT. // HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree. function resolveUpdateRoot() { const candidates = [ process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT), !IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT) ? SOURCE_REPO_ROOT : null, isHermesSourceRoot(ACTIVE_HERMES_ROOT) ? ACTIVE_HERMES_ROOT : null ].filter(Boolean) return candidates.find(c => directoryExists(path.join(c, '.git'))) || candidates[0] || ACTIVE_HERMES_ROOT } function runGit(args, options: any = {}): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn( resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({ cwd: options.cwd, env: { ...process.env, ...((options.env || {}) as any), GIT_TERMINAL_PROMPT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] }) ) let stdout = '' let stderr = '' child.stdout.on('data', chunk => { const text = chunk.toString() stdout += text options.onLine?.('stdout', text) }) child.stderr.on('data', chunk => { const text = chunk.toString() stderr += text options.onLine?.('stderr', text) }) child.once('error', reject) child.once('exit', code => resolve({ code, stdout, stderr })) }) } const firstLine = text => (text || '').split('\n').find(Boolean) || '' async function getOriginUrl(updateRoot) { const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) return origin.code === 0 ? origin.stdout.trim() : '' } function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) for (const window of BrowserWindow.getAllWindows()) { window.webContents.send('hermes:updates:progress', merged) } } // Self-heal the tracked update branch: if origin no longer publishes it (e.g. // bb/gui was merged into main and deleted), fall back to main and persist so // every later check/apply follows main — no manual flip, even for already- // installed clients. Read-only ls-remote probe; only flips on a definitive // "ref absent" (exit 2), never on a transient network error, so a flaky // connection can't strand a user on the wrong branch. async function resolveHealedBranch(updateRoot, branch) { if (!branch || branch === 'main') { return branch || 'main' } const originUrl = await getOriginUrl(updateRoot) const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) if (probe.code !== 2) { return branch } rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`) const config = readDesktopUpdateConfig() if (config.branch !== 'main') { writeDesktopUpdateConfig({ ...config, branch: 'main' }) } return 'main' } async function checkUpdates() { if (IS_PACKAGED) { return { supported: false, reason: 'aiturk-package-managed', message: `AITURK IDE güncellemeleri: ${AITURK_PRODUCT.downloads}`, fetchedAt: Date.now() } } const updateRoot = resolveUpdateRoot() let { branch } = readDesktopUpdateConfig() const gitDir = path.join(updateRoot, '.git') if (!directoryExists(gitDir)) { return { supported: false, reason: 'not-a-git-checkout', message: `${updateRoot} isn't a git checkout — desktop self-update only runs against a source install.`, hermesRoot: updateRoot, branch } } branch = await resolveHealedBranch(updateRoot, branch) const originUrl = await getOriginUrl(updateRoot) if (isOfficialSshRemote(originUrl)) { const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ git(['rev-parse', 'HEAD']), runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']) ]) const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' if (target.code !== 0 || !targetSha) { return { supported: true, branch, error: 'fetch-failed', message: firstLine(target.stderr) || 'git ls-remote failed.', hermesRoot: updateRoot, fetchedAt: Date.now() } } // Passive SSH-official checks only know tip SHAs (ls-remote) — never // fabricate a "1 commit behind". Recover the exact count via the GitHub // compare API when possible; otherwise behind stays null ("update // available, count unknown") and updateAvailable carries the signal. // ahead_by === 0 with differing tips means the remote tip is reachable // from our HEAD — a local carried commit sitting AHEAD, not behind: // flagging that as an update nudges the user into wiping their work. const tipsEqual = Boolean(currentSha && currentSha === targetSha) const sshBehind = tipsEqual ? 0 : await fetchCompareBehindCount({ currentSha, originUrl: OFFICIAL_REPO_HTTPS_URL, targetSha }) const upToDate = tipsEqual || sshBehind === 0 return { supported: true, branch, currentBranch, behind: upToDate ? 0 : sshBehind, updateAvailable: !upToDate, currentSha, targetSha, commits: [], dirty: dirtyStr.length > 0, hermesRoot: updateRoot, fetchedAt: Date.now() } } // Self-heal abandoned git lock files before fetching. A stale // .git/shallow.lock from a crashed/interrupted fetch otherwise fails every // later fetch ("Unable to create '.git/shallow.lock': File exists") and this // check reports 'fetch-failed' forever — git never removes these itself. await clearStaleGitLocks(updateRoot) const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) if (fetched.code !== 0) { return { supported: true, branch, error: 'fetch-failed', message: firstLine(fetched.stderr) || 'git fetch failed.', hermesRoot: updateRoot, fetchedAt: Date.now() } } const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) const [currentSha, targetSha, dirtyStr, currentBranch, shallowStr] = await Promise.all([ git(['rev-parse', 'HEAD']), git(['rev-parse', `origin/${branch}`]), git(['status', '--porcelain']), git(['rev-parse', '--abbrev-ref', 'HEAD']), git(['rev-parse', '--is-shallow-repository']) ]) const isShallow = shallowStr === 'true' // A shallow graph cannot provide a trustworthy exact count, even when it has // a visible merge-base. Skip the ancestry walk and use the SHA fallback. const countStr = shouldCountCommits({ isShallow }) ? await git(['rev-list', `HEAD..origin/${branch}`, '--count']) : '' // A positive directional ancestry result remains trustworthy in a shallow // graph and prevents a local commit on top of origin from looking outdated. const targetIsAncestorOfHead = isShallow && currentSha !== targetSha && (await runGit(['merge-base', '--is-ancestor', `origin/${branch}`, 'HEAD'], { cwd: updateRoot })).code === 0 let behind = resolveBehindCount({ countStr, currentSha, targetSha, isShallow, targetIsAncestorOfHead }) // Recover the exact count a shallow clone can't compute: the GitHub compare // API knows the full graph regardless of local clone depth. Best-effort — // offline, rate-limited, or non-GitHub origins keep the honest null // ("update available", no fabricated number). if (behind === null) { behind = await fetchCompareBehindCount({ currentSha, originUrl, targetSha }) } // behind === null means "update available, exact count unknown" (shallow // clone): still list what origin offers — resolveCommitLogSelection keeps // the shallow log to the fetched tip so the range walk can't enumerate the // contaminated ancestry — so "See what's new" stays useful and honest. const commits = behind !== 0 ? await readCommitLog(updateRoot, branch, isShallow) : [] return { supported: true, branch, currentBranch, behind, updateAvailable: behind === null || behind > 0, currentSha, targetSha, commits, dirty: dirtyStr.length > 0, hermesRoot: updateRoot, fetchedAt: Date.now() } } // Best-effort exact behind-count for graphs the local clone can't measure. // Delegates URL building + response parsing to update-count.ts (pure, unit // tested); this wrapper only does the bounded network call. Any failure — // offline, 4xx/5xx, rate limit, shape surprise — returns null so callers keep // the honest "update available, count unknown" state. async function fetchCompareBehindCount({ currentSha, originUrl, targetSha }) { const url = compareApiUrl({ currentSha, originUrl, targetSha }) if (!url) { return null } try { const payload = await new Promise((resolve, reject) => { const req = https.get( url, { headers: { Accept: 'application/vnd.github+json', // GitHub requires a UA on api.github.com; requests without one 403. 'User-Agent': 'hermes-desktop-update-check' }, timeout: 10_000 }, res => { const chunks = [] res.on('error', reject) res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { if ((res.statusCode || 500) >= 400) { reject(new Error(`compare API ${res.statusCode}`)) return } try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) } }) } ) req.on('timeout', () => req.destroy(new Error('compare API timeout'))) req.on('error', reject) }) return parseCompareBehindCount(payload) } catch { return null } } async function readCommitLog(cwd, branch, isShallow) { const SEP = '\x1f' const REC = '\x1e' const { limit, revision } = resolveCommitLogSelection({ branch, isShallow }) const { stdout } = await runGit( ['log', revision, `--pretty=format:%H${SEP}%s${SEP}%an${SEP}%at${REC}`, '-n', String(limit)], { cwd } ) return stdout .split(REC) .map(line => line.trim()) .filter(Boolean) .map(line => { const [sha, summary, author, at] = line.split(SEP) return { sha, summary, author, at: Number.parseInt(at, 10) * 1000 } }) } let updateInFlight = false // Set to true when the desktop is about to quit so a detached swap/install/ // uninstall script can take over. On macOS, app.quit() closes windows but // window-all-closed deliberately keeps the process alive (standard Electron // macOS convention). Without this flag the process never exits — the detached // hand-off script spins its PID-wait for the full timeout, and the user sees a // blank app with no window (and an uninstall that appears to do nothing). When // set, window-all-closed calls app.quit() on every platform so the process // actually dies and the hand-off script can proceed immediately. let isQuittingForHandoff = false // Quit-guard latches: one while the confirmation is on screen (a second // Cmd-Q must not stack dialogs), one after the user has said "quit anyway" // (the app.quit() that follows re-enters before-quit and must pass through). let quitPromptOpen = false let quitConfirmedWithActiveWork = false // Resolve the staged updater binary the desktop may hand an update to. On // Windows that binary owns ALL repo mutation — running `hermes update` + // rebuilding the desktop — so the desktop never touches its own bits while // running. macOS/Linux stage the same binary but deliberately do not use it; // see resolveStagedUpdaterBinary for the policy and for #74836. Returns null // whenever no hand-off applies; callers degrade gracefully. function resolveUpdaterBinary() { return resolveStagedUpdaterBinary(HERMES_HOME, { fileExists, isWindows: IS_WINDOWS }) } function repairMacUpdaterHelper(updater) { if (!IS_MAC || !updater) { return } try { execFileSync('/usr/bin/xattr', ['-cr', updater], { stdio: 'ignore' }) } catch (err) { rememberLog(`[updates] macOS updater helper quarantine repair skipped: ${err.message}`) } try { execFileSync('/usr/bin/codesign', ['--verify', updater], { stdio: 'ignore' }) return } catch { // Unsigned or invalid helper. Apply a local ad-hoc signature so Gatekeeper // does not block the staged updater before it can run. } try { execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', updater], { stdio: 'ignore' }) rememberLog('[updates] repaired macOS updater helper signature') } catch (err) { rememberLog(`[updates] macOS updater helper signature repair skipped: ${err.message}`) } } // Path to the venv shim whose lock decides whether `hermes update` can write // fresh entry points. On Windows this is the file the running backend // `hermes.exe` holds open; on POSIX it's never mandatory-locked. function venvHermesShimPath(updateRoot) { return IS_WINDOWS ? path.join(updateRoot, 'venv', 'Scripts', 'hermes.exe') : path.join(updateRoot, 'venv', 'bin', 'hermes') } // Best-effort lock probe mirroring the Rust updater's is_locked(): a running // .exe on Windows refuses an O_RDWR open with a sharing violation. On POSIX // this practically always succeeds (no mandatory locking), so it returns false // — correct, since the shim-contention brick is Windows-only. function isShimLocked(shimPath) { if (!IS_WINDOWS) { return false } let fd try { fd = fs.openSync(shimPath, 'r+') return false } catch (err) { // ENOENT ⇒ not there ⇒ nothing locking it. Anything else (EBUSY/EPERM/ // EACCES) on Windows means a live handle holds it. return err && err.code !== 'ENOENT' } finally { if (fd !== undefined) { try { fs.closeSync(fd) } catch { void 0 } } } } // Kill only Hermes-OWNED venv daemons (the memory plugin's hindsight daemon: // exe under venv\Scripts AND cmdline referencing hindsight_api.main). The // daemon is spawned DETACHED, so it outlives the backend tree-kill and keeps // venv files mapped. External holders (a user terminal running `hermes`, // unrelated scripts) are NOT killed — scanVenvBlockers reports them and the // hand-off aborts, per existing design. Selection lives in the pure // venv-holder-select module (ordinal path-prefix, no PowerShell -like // wildcard hazards) so it's testable without Electron. function killHermesOwnedVenvDaemons(updateRoot) { if (!IS_WINDOWS) { return } const scriptsDir = path.join(updateRoot, 'venv', 'Scripts') let holders = [] try { const out = execFileSync( 'powershell', [ '-NoProfile', '-Command', 'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.CommandLine } | Select-Object ProcessId, ExecutablePath, CommandLine | ConvertTo-Json -Compress' ], hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000 }) ) const parsed = JSON.parse(String(out || '[]')) holders = (Array.isArray(parsed) ? parsed : [parsed]).filter(p => isHermesOwnedVenvDaemon(p?.ExecutablePath, p?.CommandLine, scriptsDir) ) } catch { // Best-effort: the venv-blocker scan downstream is the real backstop. return } for (const holder of holders) { const pid = Number(holder?.ProcessId) if (Number.isInteger(pid) && pid > 0) { rememberLog(`[updates] stopping Hermes-owned venv daemon (hindsight) PID ${pid} before hand-off`) forceKillProcessTree(pid) } } } // Force-kill the entire process TREE rooted at each PID. Node's child.kill() // only signals the direct child, so on Windows a backend `hermes.exe` that // spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the // gateway) would survive and keep the venv shim locked. taskkill /T /F reaps // the whole tree synchronously. Windows-only: this is called solely from the // Windows shim-unlock path, and the backend is NOT spawned detached (so it's // not a process-group leader — a POSIX negative-pgid kill would be meaningless // here anyway). POSIX teardown stays with the existing before-quit SIGTERM. function forceKillProcessTree(pid) { if (!IS_WINDOWS) { return } if (!Number.isInteger(pid) || pid <= 0) { return } try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' })) } catch { // Already gone, or no permission — best effort; the unlock wait below is // the real gate. } } function writeBackendOwnership(contents) { fs.mkdirSync(path.dirname(DESKTOP_BACKEND_OWNERSHIP_PATH), { recursive: true }) const tempPath = `${DESKTOP_BACKEND_OWNERSHIP_PATH}.${process.pid}.tmp` try { fs.writeFileSync(tempPath, contents, { encoding: 'utf8', mode: 0o600 }) fs.renameSync(tempPath, DESKTOP_BACKEND_OWNERSHIP_PATH) } finally { try { fs.rmSync(tempPath, { force: true }) } catch { void 0 } } } // execText and processStartMarker moved to backend-claim.ts (#93608) so the // claim/probe policy is testable — including on Windows CI with real // PowerShell — without booting Electron. main.ts calls through the module. async function backendCommandForPid(pid) { try { const command = IS_WINDOWS ? 'powershell.exe' : 'ps' const args = IS_WINDOWS ? [ '-NoProfile', '-NonInteractive', '-Command', `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine` ] : ['-p', String(pid), '-o', 'command='] return (await execText(command, args)) || null } catch { return null } } async function processIdentityMatches(identity, timeoutMs: number = 30_000) { // Degraded PID-only identity (#93608): the start-marker probe failed while // the child was verifiably alive, so only PID liveness can be checked here. // backendIdentityMatches layers the command-line check on top before // anything destructive relies on the answer. if (isPidOnlyStartMarker(identity.startMarker)) { try { process.kill(identity.pid, 0) return true } catch (error) { const code = (error as NodeJS.ErrnoException | null)?.code return code === 'ESRCH' || code === 'ENOENT' ? false : code === 'EPERM' ? true : undefined } } try { return (await processStartMarker(identity.pid, timeoutMs)) === identity.startMarker } catch (error) { return error?.code === 'ENOENT' || error?.code === 'ESRCH' ? false : undefined } } async function backendIdentityMatches(identity) { const processMatches = await processIdentityMatches(identity, REAP_PROBE_TIMEOUT_MS) if (processMatches !== true) { return processMatches } const command = await backendCommandForPid(identity.pid) return command === null ? undefined : backendCommandMatches(command) } // True when the recorded parent Electron is still running (same PID AND start // marker); false when it is gone or its PID was reused; undefined when the // ownership record predates parent tracking. Undefined deliberately falls back // to the pre-parent reap behaviour so legacy orphan cleanup keeps working. async function backendParentMatches(entry) { if (!Number.isInteger(entry.parentPid) || typeof entry.parentStartMarker !== 'string' || !entry.parentStartMarker) { return undefined } try { return (await processStartMarker(entry.parentPid, REAP_PROBE_TIMEOUT_MS)) === entry.parentStartMarker } catch (error) { return error?.code === 'ENOENT' || error?.code === 'ESRCH' ? false : undefined } } async function stopOwnedBackend(identity) { const matches = await processIdentityMatches(identity, REAP_PROBE_TIMEOUT_MS) if (matches === false) { return } if (matches !== true) { // Identity probe failed (not confirmed gone): preserve the record so a // later launch retries the stop instead of dropping it and leaking the // backend. reapOrphans keeps the entry when stop() throws. throw new Error(`Could not verify backend PID ${identity.pid} before stopping it.`) } if (IS_WINDOWS) { forceKillProcessTree(identity.pid) } else { try { process.kill(-identity.pid, 'SIGTERM') } catch { try { process.kill(identity.pid, 'SIGTERM') } catch { return } } const deadline = Date.now() + 1500 while (Date.now() < deadline) { if ((await processIdentityMatches(identity, REAP_PROBE_TIMEOUT_MS)) !== true) { return } await new Promise(resolve => setTimeout(resolve, 50)) } // Revalidate immediately before escalation so PID reuse cannot target a // replacement process. if ((await processIdentityMatches(identity, REAP_PROBE_TIMEOUT_MS)) === true) { try { process.kill(-identity.pid, 'SIGKILL') } catch { process.kill(identity.pid, 'SIGKILL') } } } await new Promise(resolve => setTimeout(resolve, 50)) const remaining = await processIdentityMatches(identity, REAP_PROBE_TIMEOUT_MS) if (remaining !== false) { throw new Error(`Backend PID ${identity.pid} did not stop cleanly.`) } } const backendOwnership = createBackendOwnership({ matchesIdentity: backendIdentityMatches, matchesParent: backendParentMatches, stop: stopOwnedBackend, store: { read: () => { try { return fs.readFileSync(DESKTOP_BACKEND_OWNERSHIP_PATH, 'utf8') } catch { return null } }, write: writeBackendOwnership, // A corrupt ownership file is moved aside instead of being rewritten // away by the reap sweep — its records are the only pointer to any // still-running backends it described (#89298). quarantine: () => { const parked = `${DESKTOP_BACKEND_OWNERSHIP_PATH}.corrupt` try { fs.renameSync(DESKTOP_BACKEND_OWNERSHIP_PATH, parked) rememberLog(`Backend ownership file was unreadable; moved to ${parked}`) } catch { // Nothing to move (or no permission) — the sweep already skipped. } } } }) const desktopParentStartMarker = createParentStartMarkerResolver({ load: () => processStartMarker(process.pid), onError: error => { const detail = error instanceof Error ? error.message : String(error) rememberLog( `Could not resolve the Desktop process start marker; starting the backend with PID-only parent tracking: ${detail}` ) } }) async function claimBackendChild(child, command, profile, nonce, outputTail: BackendOutputTail | null = null) { // Probe/claim policy lives in backend-claim.ts (#93608): a marker probe // that fails against a LIVE child degrades to PID-only identity — matching // createParentStartMarkerResolver — instead of killing a healthy backend // over a flaky Get-Process (PS 5.1 cold starts, #87169). Only a child that // actually died keeps the fail-closed throw, now carrying its stderr tail. const probe = await probeStartMarker(child.pid) const decision = claimDecision(child.exitCode === null && !child.killed, probe) if (decision.action === 'fail') { stopBackendChild(child) await waitForBackendExit(child) throw new Error( `Hermes backend (PID ${child.pid}) died before its identity could be recorded: ${decision.reason}${outputTail?.describe() ?? ''}` ) } let startMarker if (decision.action === 'degrade') { startMarker = pidOnlyStartMarker(child.pid) rememberLog( `WARNING: process start marker probe failed for live Hermes backend PID ${child.pid}; ` + `claiming with PID-only identity instead of stopping it: ${decision.reason}` ) } else { startMarker = decision.startMarker } try { const identity = await backendOwnership.claim({ command, nonce, pid: child.pid, profile, startMarker, // Record the spawning Electron so reapOrphans can tell an orphaned // backend (parent gone) from one owned by a live instance — a live // parent's backend is never reaped (#87295). parentPid: process.pid, parentStartMarker: await desktopParentStartMarker() }) child.hermesBackendIdentity = identity return identity } catch (error) { stopBackendChild(child) await waitForBackendExit(child) throw new Error( `Could not persist ownership for the Hermes backend: ${error.message}${outputTail?.describe() ?? ''}` ) } } function releaseBackendChild(child) { const identity = child?.hermesBackendIdentity if (!identity) { return } try { backendOwnership.release(identity) } catch (error) { rememberLog(`Could not release backend ownership for PID ${identity.pid}: ${error.message}`) } } function reapOrphanedBackendsOnce() { if (!backendOrphanReapPromise) { backendOrphanReapPromise = backendOwnership .reapOrphans() .then(pids => { if (pids.length) { rememberLog(`Reaped orphaned desktop backend PID(s): ${pids.join(', ')}`) } }) .catch(error => { backendOrphanReapPromise = null throw error }) } return backendOrphanReapPromise } // Before handing off the update on Windows, the desktop MUST stop every backend // it spawned and WAIT for the venv shim to actually unlock. The old code did // `hermesProcess.kill('SIGTERM')` + `app.quit()` fire-and-forget: SIGTERM on // Windows doesn't reap the backend's grandchildren, and quit didn't wait for // teardown, so the updater raced a still-locked `hermes.exe`, the quarantine // rename failed, uv's `pip install` hit "Access is denied", and the git path // bailed into a full ZIP re-download that ALSO couldn't write the locked shim — // a half-applied install (ryanc's update.log). Here we tree-kill the primary + // pool backends and poll the shim until it's writable (or a bounded timeout), // so by the time we spawn the updater the lock is genuinely gone. // // Windows-only: the venv-shim mandatory lock is a Windows phenomenon. On // macOS/Linux there's no REPLACE-on-running-exe block, the existing before-quit // SIGTERM + app.quit() teardown already works (the macOS path is flawless), and // aggressively SIGKILL-ing the backend here would be an untested behavior change // for no benefit. So we no-op off Windows and leave that path exactly as it was. async function releaseBackendLockForUpdate(updateRoot) { return releaseBackendLock(updateRoot, 'updates') } // Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update // hand-off and the desktop uninstaller — they have the identical Windows // problem: the desktop's backend (and the grandchildren IT spawned — a hermes // REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the // venv mandatory-locked, so any in-place replace/delete of the install tree // races a live handle and half-fails (#37532). We tree-kill every backend PID // the desktop owns, then poll the shim until it's genuinely writable. // // `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory // locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice). async function releaseBackendLock(updateRoot, tag) { if (!IS_WINDOWS) { return { unlocked: true } } const hermesProcess = backendConnectionState.getProcess() // Seed the release gate with every PID we are about to signal: the // supervised primary backend and all pool backends. The gate waits for // these to actually LEAVE the process table, not just for the shim to // unlock — the shim probe only covers venv\Scripts\hermes.exe, but the // backend is `python.exe -m hermes_cli.main serve`, which need not hold // the shim at all (#74805 first-attempt race). const initialPids = [] if (hermesProcess && Number.isInteger(hermesProcess.pid)) { initialPids.push(hermesProcess.pid) } for (const entry of backendPool.values()) { if (entry.process && Number.isInteger(entry.process.pid)) { initialPids.push(entry.process.pid) } } stopBackendTreesForUpdate(hermesProcess, { forceKillProcessTree, stopAllPoolBackends }) // Stop separately-running messaging gateways (all profiles) BEFORE the // release gate. The gateway is launched by the gateway-launcher desktop // plugin via /api/gateway/start and is NOT in backendConnectionState or // backendPool, so the tree-kills above never see it — on Windows its // launcher (venv\Scripts\python.exe) keeps the venv mandatory-locked and // the 15s gate aborts the hand-off before the venv-blocker scan's // pausable-gateway exemption ever gets a chance (#70337). Delegate to // `hermes gateway stop --all`: the CLI discovers every profile's gateway // (launcher + worker — gateway.pid records only the uv WORKER, and // taskkill /T from the worker never reaches its parent), drains in-flight // agents, and force-kills survivors. Best-effort; abort paths restore via // startGatewaysAfterUpdateAbort. No-op off Windows. stopGatewayBeforeUpdate(venvHermesShimPath(updateRoot), HERMES_HOME) // Reap Hermes-OWNED venv daemons the tree-kill above cannot reach: the // memory plugin's hindsight daemon is spawned DETACHED (it outlives the // backend) yet runs off venv\Scripts\pythonw.exe, keeping venv files // mapped past the backend teardown (#75477/#75478). Narrowly scoped // (venv-holder-select) — external holders are never killed here. killHermesOwnedVenvDaemons(updateRoot) const shim = venvHermesShimPath(updateRoot) const gate = await waitForBackendRelease( initialPids, { isShimLocked: () => Boolean(isShimLocked(shim)), isPidAlive: isPidAliveWindows, collectStragglerPids: () => { const stragglers = [] const currentHermesProcess = backendConnectionState.getProcess() if (currentHermesProcess && Number.isInteger(currentHermesProcess.pid)) { stragglers.push(currentHermesProcess.pid) } for (const entry of backendPool.values()) { if (entry.process && Number.isInteger(entry.process.pid)) { stragglers.push(entry.process.pid) } } return stragglers }, killProcessTree: forceKillProcessTree, sleep: (ms: number) => new Promise(r => setTimeout(r, ms)), now: () => Date.now(), log: rememberLog }, tag ) if (gate.unlocked) { return { unlocked: true } } // Do NOT proceed past a held lock: handing off to the updater while another // process (a second desktop window, a user terminal, an unkillable child) // still maps the venv's files guarantees a half-updated venv — the updater's // dependency sync dies on access-denied partway through uninstalls, leaving // imports broken (the July 2026 brotlicffi/_sodium.pyd incidents). Failing // the update loudly and keeping the app running is strictly better than a // bricked install that needs manual venv surgery. rememberLog( `[${tag}] venv shim still locked after 15s; aborting hand-off (something outside this app holds the venv)` ) return { unlocked: false } } // applyUpdates — hand off to the installer's --update flow, then exit. // // The desktop is a pure consumer: it does NOT git pull / pip install / rebuild // itself (the old open-coded git dance lived here and drifted from // `hermes update`). Instead we spawn the staged Hermes-Setup binary with // --update and quit, so it can run `hermes update` (which refuses while we // hold the venv shim) and rebuild the desktop with our exe already gone. // // Detection (checkUpdates / commit changelog / "N behind") stays in the UI; // only this apply action changed. async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { if (IS_PACKAGED) { await shell.openExternal(AITURK_PRODUCT.downloads) return { ok: false, error: 'aiturk-package-managed', message: 'AITURK IDE indirme sayfası açıldı.' } } if (updateInFlight) { throw new Error('An update is already in progress.') } updateInFlight = true try { const updater = resolveUpdaterBinary() if (!updater && !IS_WINDOWS) { // macOS/Linux: hand off to the repo-owned posix script — same shape as // Windows (quit → detached orchestrator → `hermes update` → relaunch), // minus the venv-lock gauntlet POSIX doesn't need. The old in-app // updater (applyUpdatesPosixInApp) is gone with everything it dragged // in: the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance (#37532), // the in-window rebuild retry, and the relaunch-outcome matrix — the // script owns swap/relaunch, and the app is DEAD during the update so // there is nothing to reap around. Checkouts that predate the script // get the manual `hermes update` card once; their next update pulls it. return await applyUpdatesPosixHandoff(opts) } if (!updater) { // No staged updater binary — this is a CLI-installed user (they ran // `hermes desktop`, never the Tauri installer that self-copies // hermes-setup.exe into HERMES_HOME). On Windows the repo hand-off // script serves them just as well as installer users — it only needs // PowerShell and the checkout — so fall through to the normal hand-off // when the script exists. Only when the checkout predates the script do // we surface the manual one-liner. const updateRoot = resolveUpdateRoot() if (!resolveUpdateScriptHandoff(updateRoot)) { // They DO have a working `hermes` on PATH / in the venv, so the // correct path is the one-liner in their native medium. We show the // EXACT command, branch-pinned to the checkout they're on — bare // `hermes update` defaults to main and would silently switch a // bb/gui (or any non-main) install off-branch. Mirror the GUI // button's contract: append --branch for non-main // checkouts, keep it bare for main so the card stays clean. let command = 'hermes update' try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() if (head.code === 0 && current && current !== 'HEAD') { const branch = await resolveHealedBranch(updateRoot, current) if (branch !== 'main') { command = `hermes update --branch ${branch}` } } } catch { // Best-effort: fall back to bare `hermes update` if branch detection fails. } rememberLog(`[updates] no staged updater; surfacing manual \`${command}\` for CLI install at ${updateRoot}`) emitUpdateProgress({ stage: 'manual', message: command, percent: null }) return { ok: true, manual: true, command, hermesRoot: updateRoot } } rememberLog('[updates] no staged updater; using repo hand-off script for CLI install') } const handoffConflict = updateHandoffConflict(HERMES_HOME) if (handoffConflict) { // A different updater already owns the marker — most often a previous // "Update" click whose updater is still alive and parked mid-run. // Spawning another here would overwrite its claim and let two updaters // mutate the checkout at once (#75778); refuse instead. rememberLog(`[updates] refusing hand-off: ${handoffConflict.message}`) emitUpdateProgress({ stage: 'error', message: handoffConflict.message, percent: null }) return { ok: false, error: 'update-already-running', message: handoffConflict.message } } emitUpdateProgress({ stage: 'restart', message: 'Updating Hermes — this window will close and the updater will open. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.', percent: 100 }) repairMacUpdaterHelper(updater) const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() const branch = await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) const updaterArgs = ['--update', '--branch', branch] const targetApp = IS_MAC ? runningAppBundle() : null if (targetApp) { updaterArgs.push('--target-app', targetApp) } const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') // ── Pre-flight state.db integrity guard (#68474) ───────────────── // Emergency backup and header verification before the update touches // anything. Runs while the backend is still alive. preflightStateDb(HERMES_HOME, rememberLog) // Stop our own backend(s) and wait for the venv shim to unlock BEFORE we // spawn the updater. Without this the updater races a still-locked // hermes.exe (held by the backend child / its grandchildren) and the update // bricks. See releaseBackendLockForUpdate for the full failure analysis. const lock = await releaseBackendLockForUpdate(updateRoot) if (!lock.unlocked) { // Something OUTSIDE this app holds the venv (a second window, a user // terminal running hermes, an unkillable child). Handing off anyway // guarantees a half-updated venv — abort loudly instead and let the // user close the holder and retry. Restart our own backend so the app // keeps working after the failed attempt. const message = 'Update aborted: another process is holding the Hermes install open ' + '(a second Hermes window or a terminal running hermes?). Close it and retry.' emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) if (IS_WINDOWS) { // The pre-gate `gateway stop --all` (#70337) took every profile's // gateway down for an update that never happened — bring them back. startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) } return { ok: false, error: message } } // Preflight: after releasing our own backends, check for remaining // Hermes processes running from this venv. The updater normally refuses // when it detects a holder, but because the updater is spawned detached // with stdio:ignore, the user never sees that refusal and the update // silently fails. This preflight detects holders early and gives the // user an actionable error. Windows-only; the .pyd lock hazard is a // Windows phenomenon. ALL failures (blocked, missing python, timeout, // malformed output, missing psutil) abort the handoff — never proceed // to the detached updater when the venv state is unknown. if (IS_WINDOWS) { let scanOutcome = await scanVenvBlockers(updateRoot) if (scanOutcome.kind === 'blocked' && opts.stopSafeBlockers) { const stopResult = await stopSafeVenvBlockers(updateRoot, scanOutcome.result) rememberLog( `[updates] user-approved blocker cleanup: stopped=${stopResult.stopped.join(',') || 'none'} failed=${stopResult.failed.join(',') || 'none'}` ) // Let verified process-tree termination finish unwinding wrapper shells, // then make the scanner — not the stale renderer payload — authoritative. await new Promise(resolve => setTimeout(resolve, 300)) scanOutcome = await scanVenvBlockers(updateRoot) } // Re-scan before aborting on 'blocked' (#74805). Process-table teardown // is asynchronous on Windows: even after releaseBackendLock's PID-exit // wait, a grandchild the desktop never tracked (or a process an AV / // NTFS filter driver is holding in teardown) can stay enumerable for a // few more seconds and read as a holder. Each scan already costs // seconds (spawns a venv python + psutil sweep), so two retries with a // short dwell give the table time to settle without meaningfully // delaying the abort path when a REAL holder (a user terminal, second // window) is present — that holder is still there on the third scan. for (let attempt = 0; scanOutcome.kind === 'blocked' && attempt < 2; attempt++) { rememberLog( `[updates] venv-blocker scan reported ${scanOutcome.result.processes.length} holder(s); re-scanning after settle (attempt ${attempt + 2}/3)` ) await new Promise(resolve => setTimeout(resolve, 1500)) scanOutcome = await scanVenvBlockers(updateRoot) } if (scanOutcome.kind === 'blocked') { const message = formatBlockerMessage(scanOutcome.result) rememberLog(`[updates] venv-blocked: ${scanOutcome.result.processes.length} process(es) hold the install`) emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) // Restore the gateways the pre-gate stop took down (#70337 drain // semantics): the update aborted, so nothing else will relaunch them. startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) return { ok: false, error: 'venv-blocked', message, blockers: scanOutcome.result.processes } } if (scanOutcome.kind === 'probe-failure') { const message = formatProbeFailedMessage(scanOutcome.error) rememberLog(`[updates] venv-blocker probe failed: ${scanOutcome.error}`) emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) // Same drain-semantics restore as the venv-blocked abort above. startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) return { ok: false, error: 'venv-probe-failed', message } } } // Detached so the updater outlives this process — it needs us GONE before // `hermes update` will run (the venv shim is locked while we live). // // Prefer the repo-owned hand-off script over the staged Tauri binary. // The staged binary is frozen (no self-update path) and historically runs // months-stale updater logic — pre-#67369 cache resolver, pre-#74782 // marker adoption — producing failures that were fixed on main long ago // (2026-08-09 incident). scripts/desktop-update/windows.ps1 ships WITH the // checkout, so each `hermes update` refreshes the code that drives the // next one. Checkouts that predate the script fall back to the binary // path unchanged. const scriptHandoff = resolveUpdateScriptHandoff(updateRoot) let child if (scriptHandoff) { const updateStartedAt = Math.floor(Date.now() / 1000) // A bare detached+hidden powershell spawn silently dies before -File // processing (console-subsystem init failure — see // wrapHandoffForDetachedConsole). Route through `cmd start` so the // script gets its own minimized console and survives our exit. The // wrapper cmd.exe exits immediately, so child.pid is NOT the script's // pid — the script claims the update marker itself with its own $PID // as its first action, and a relaunched Desktop parks on that. const wrapped = wrapHandoffForDetachedConsole(scriptHandoff, [ '-InstallRoot', updateRoot, '-Branch', branch, '-DesktopPid', String(process.pid), '-RelaunchExe', process.execPath ]) child = spawnUpdaterProcess(wrapped.command, wrapped.args, { cwd: HERMES_HOME, env: { ...process.env, HERMES_HOME, HERMES_UPDATE_STARTED_AT: String(updateStartedAt), PATH: pathWithHermesManagedNode(venvBin) }, detached: true, stdio: 'ignore' }) // Bridge marker: child.pid is the short-lived cmd.exe WRAPPER, not the // script (see wrapHandoffForDetachedConsole). Write it anyway to cover // the first moments of the hand-off — the script's step 0 overwrites it // with its own live $PID, and if the script never starts the wrapper's // dead pid makes the marker read as stale and self-delete (no wedge). // The `hermes update` child adopts the SCRIPT's claim via // update_lock.py's process-ancestry rule; no mtime heuristics needed. if (Number.isInteger(child.pid)) { writeUpdateMarker(HERMES_HOME, child.pid, { startedAt: updateStartedAt }) } rememberLog( `[updates] launched repo hand-off script: ${scriptHandoff.scriptPath} (branch ${branch}); exiting desktop to release venv shim` ) } else { child = spawnUpdaterProcess(updater, updaterArgs, { cwd: HERMES_HOME, env: { ...process.env, HERMES_HOME, PATH: pathWithHermesManagedNode(venvBin) }, detached: true, stdio: 'ignore' }) // Write the update-in-progress marker IMMEDIATELY — before the 2.5s // quit dwell. The Tauri updater won't write its own marker for several // seconds (window init + manifest), and during that gap our renderer // can reconnect and spawn a fresh backend that re-locks .pyd files in // the venv. By writing the marker ourselves the renderer's // waitForUpdateToFinish() gate sees a live update and parks instead. // The updater overwrites this with its own PID later; same format. // // SKIPPED for pre-#74782 staged updaters: those have no self-PID // exclusion, so they read this very marker as a foreign live owner and // abort with "Another Hermes update is already running (PID )" — // an unbreakable loop, because the update that would replace the stale // binary is the one being refused. Losing the anti-respawn hardening is // strictly better than never updating again, and the updater still writes // its own marker moments later. if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) { writeUpdateMarker(HERMES_HOME, child.pid) } else if (Number.isInteger(child.pid)) { rememberLog( `[updates] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim` ) } rememberLog( `[updates] launched updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release venv shim` ) } // Linger on the "updating — don't reopen" overlay long enough for the user // to actually read it (and to bridge the gap until the updater's own window // appears), THEN quit to release the venv shim. The updater rebuilds and // relaunches us when it's done. (#50419 — a 600ms quit looked like a crash // and lured users into the #50238 relaunch loop.) // // The dwell doubles as the hand-off settle window (#66753): watch the // detached child for an async spawn `error` (ENOENT/EACCES) or an early // non-zero/signal exit. On failure, DON'T quit — the user would be left // with no app, no updater, and no evidence. Restart our backend and // surface the error instead. The pre-written marker names the dead child // pid, so readLiveUpdateMarker self-heals it; no cleanup needed. const dwellStartedAt = Date.now() const handoffOutcome = await observeUpdaterHandoff(child, UPDATE_HANDOFF_DWELL_MS) if (!handoffOutcome.ok) { const message = `Update failed to start: ${handoffOutcome.message}. Hermes will keep running — try again, or run \`hermes update\` from a terminal.` rememberLog(`[updates] hand-off not viable, aborting quit: ${handoffOutcome.message}`) emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) if (IS_WINDOWS) { // Same drain-semantics restore as the earlier abort paths (#70337). startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) } return { ok: false, error: 'updater-spawn-failed', message } } isQuittingForHandoff = true setTimeout( () => { app.quit() }, Math.max(0, UPDATE_HANDOFF_DWELL_MS - (Date.now() - dwellStartedAt)) ) return { ok: true, handedOff: true, updater } } finally { updateInFlight = false } } async function handOffWindowsBootstrapRecovery(reason) { if (!IS_WINDOWS || !IS_PACKAGED) { return false } const updater = resolveUpdaterBinary() if (!updater) { return false } const handoffConflict = updateHandoffConflict(HERMES_HOME) if (handoffConflict) { // Same hazard as applyUpdates (#75778): a live foreign updater already // owns the marker. Spawning another here would overwrite its claim and // race a second updater over the same install tree. The live updater // is already working on this exact install and will restart us when // it finishes, so treat this the same as a successful hand-off instead // of clobbering it with our own. rememberLog(`[bootstrap] refusing recovery hand-off: ${handoffConflict.message}`) isQuittingForHandoff = true setTimeout(() => { app.quit() }, UPDATE_HANDOFF_DWELL_MS) return true } const updateRoot = resolveUpdateRoot() const { branch: configuredBranch } = readDesktopUpdateConfig() const branch = directoryExists(path.join(updateRoot, '.git')) ? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH) : configuredBranch || DEFAULT_UPDATE_BRANCH const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin') const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') // The updater invokes the venv's Hermes launcher, which in turn requires the // venv interpreter. A bootstrap-complete marker proves only that setup once // finished; it can outlive a manually removed or quarantined venv. Sending a // marker-only install through --update dead-ends at "Could not find the hermes // CLI" instead of rebuilding the runtime, so only a runnable pair gets the // gentle update path. Partial or missing runtimes go through full repair. const updaterArgs = chooseUpdaterArgs( { hasBootstrapMarker: fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')), hasVenvHermes: fileExists(venvHermes), hasVenvPython: fileExists(venvPython) }, branch ) await releaseBackendLockForUpdate(updateRoot) const child = spawnUpdaterProcess(updater, updaterArgs, { cwd: HERMES_HOME, env: { ...process.env, HERMES_HOME, PATH: pathWithHermesManagedNode(venvBin) }, detached: true, stdio: 'ignore' }) // Same marker pre-write as applyUpdates — see comment there. The recovery // hand-off has the same window where the renderer can respawn a backend // before the updater writes its own marker, and the same stale-updater // exclusion: a pre-#74782 binary would refuse its own pre-written claim and // strand the very recovery meant to heal the install. if (Number.isInteger(child.pid) && stagedUpdaterSupportsPrewrittenMarker(updater)) { writeUpdateMarker(HERMES_HOME, child.pid) } else if (Number.isInteger(child.pid)) { rememberLog( `[bootstrap] skipping marker pre-write: staged updater predates self-adopt (${updater}); it would refuse its own claim` ) } rememberLog( `[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar` ) // Same dwell as the in-app update hand-off (#50419): give the updater's // window time to appear before we vanish, so the recovery doesn't look like // a crash and provoke a mid-recovery relaunch. The dwell doubles as the // hand-off settle window (#66753): a spawn error or early updater death // returns false so the caller falls through to its next recovery path // instead of quitting into nothing. const dwellStartedAt = Date.now() const handoffOutcome = await observeUpdaterHandoff(child, UPDATE_HANDOFF_DWELL_MS) if (!handoffOutcome.ok) { rememberLog(`[bootstrap] recovery hand-off not viable, staying alive: ${handoffOutcome.message}`) return false } isQuittingForHandoff = true setTimeout( () => { app.quit() }, Math.max(0, UPDATE_HANDOFF_DWELL_MS - (Date.now() - dwellStartedAt)) ) return true } // The running app's .app bundle (packaged macOS): execPath is // .app/Contents/MacOS/; climb three levels to the bundle root. function runningAppBundle() { if (!IS_MAC) { return null } let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS for (let i = 0; i < 2; i++) { dir = path.dirname(dir) } // -> .../X.app return dir.endsWith('.app') ? dir : null } // ── Pre-flight state.db integrity guard (#68474) ───────────────────── // Take an emergency snapshot of state.db and verify the live copy is // intact before any update process mutates the install. Runs in the // desktop Electron process itself, before the backend is killed and // before the updater is spawned — a separate safety net from the // Python-level pre-update snapshot inside `hermes update`. function preflightStateDb(hermesHome, rememberLog) { const stateDbPath = path.join(hermesHome, 'state.db') if (!fileExists(stateDbPath)) { rememberLog('[updates] state.db pre-flight: not found (fresh install?)') return } try { const stat = fs.statSync(stateDbPath) if (stat.size > 100) { const fd = fs.openSync(stateDbPath, 'r') const header = Buffer.alloc(16) fs.readSync(fd, header, 0, 16, 0) fs.closeSync(fd) const expectedHeader = Buffer.from('SQLite format 3\0') const headerOk = header.equals(expectedHeader) rememberLog( `[updates] state.db pre-flight: size=${stat.size}, ` + `headerOk=${headerOk}, headerHex=${header.toString('hex')}` ) if (!headerOk) { rememberLog( '[updates] state.db header is INVALID before update — ' + 'this indicates pre-existing corruption or a concurrent write issue' ) } // Emergency timestamped backup, separate from the Python-level snapshot. const ts = new Date().toISOString().replace(/[:.]/g, '-') const emergencyPath = path.join(hermesHome, `state.db.pre-update-emergency-${ts}.bak`) try { fs.copyFileSync(stateDbPath, emergencyPath) const emergStat = fs.statSync(emergencyPath) rememberLog(`[updates] emergency state.db backup: ${emergencyPath} ` + `(${emergStat.size} bytes)`) // Prune to the 2 most recent emergency backups. try { const homeDir = fs.readdirSync(hermesHome) const backups = homeDir .filter( f => f.startsWith('state.db.pre-update-emergency-') && f.endsWith('.bak') && f !== path.basename(emergencyPath) ) .sort() .reverse() for (const old of backups.slice(2)) { try { fs.unlinkSync(path.join(hermesHome, old)) } catch { void 0 } } } catch { void 0 } } catch (copyErr) { rememberLog(`[updates] emergency state.db backup failed: ${copyErr.message}`) } } else { rememberLog(`[updates] state.db too small (${stat.size} bytes) for a valid SQLite database`) } } catch (statErr) { rememberLog(`[updates] could not stat state.db before update: ${statErr.message}`) } } // macOS/Linux update hand-off: spawn the repo-owned posix orchestrator // (scripts/desktop-update/posix.sh) detached and QUIT. The script waits us // out, runs `hermes update`, swaps/relaunches the app bundle, and writes // .hermes-update-result.json for the relaunched Desktop to surface. It shows // its own tiny shim window (or nothing, headless) — this process only needs // to leave. Checkouts that predate the script get the manual card once. async function applyUpdatesPosixHandoff(opts: any) { const updateRoot = resolveUpdateRoot() const handoff = resolvePosixScriptHandoff(updateRoot) if (!handoff) { emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null }) return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot } } const handoffConflict = updateHandoffConflict(HERMES_HOME) if (handoffConflict) { // Same hazard as the Windows path (#75778): a live foreign updater // already owns the marker — refuse rather than double-mutate the tree. rememberLog(`[updates] refusing posix hand-off: ${handoffConflict.message}`) emitUpdateProgress({ stage: 'error', message: handoffConflict.message, percent: null }) return { ok: false, error: 'update-already-running', message: handoffConflict.message } } // ── Pre-flight state.db integrity guard (#68474) ── preflightStateDb(HERMES_HOME, rememberLog) // Branch-pin so a non-main checkout doesn't get switched to main (and // self-heal to main when the pinned branch no longer exists on origin). let branch = 'main' try { const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot }) const current = (head.stdout || '').trim() if (head.code === 0 && current && current !== 'HEAD') { branch = await resolveHealedBranch(updateRoot, current) } } catch { // best effort } const args = [...handoff.args, '--install-root', updateRoot, '--branch', branch, '--desktop-pid', String(process.pid)] const updateStartedAt = Math.floor(Date.now() / 1000) // Relaunch target: the running .app bundle on mac (script swaps the // rebuilt bundle over it), the running binary elsewhere. The script's gate // (an exact port of update-relaunch.ts's decideRelaunchOutcome) relaunches // only a binary the rebuild replaced with a launchable sandbox helper — // replaying the original launch context (filtered args, cwd, sandbox // opt-out) so a deep-link or --no-sandbox launch survives the update. const targetApp = IS_MAC ? runningAppBundle() : process.execPath if (targetApp) { args.push('--relaunch-target', targetApp) } const relaunchArgs = collectRelaunchArgs(process.argv.slice(1)) if (!IS_MAC) { args.push('--relaunch-cwd', process.cwd()) if (sandboxFallbackFromEnv(process.env, relaunchArgs)) { args.push('--sandbox-fallback') } if (relaunchArgs.length) { args.push('--', ...relaunchArgs) } } const child = spawnUpdaterProcess(handoff.command, args, { cwd: HERMES_HOME, env: { ...process.env, HERMES_HOME, HERMES_UPDATE_STARTED_AT: String(updateStartedAt), PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) }, detached: true, stdio: 'ignore' }) // Bridge marker (same contract as the Windows hand-off): cover the gap // until the script claims the marker with its own pid as step 0. If the // script never starts, the dead pid reads as stale and self-deletes. if (Number.isInteger(child.pid)) { writeUpdateMarker(HERMES_HOME, child.pid, { startedAt: updateStartedAt }) } rememberLog(`[updates] launched posix hand-off: ${handoff.scriptPath} (branch ${branch}); quitting to hand off`) emitUpdateProgress({ stage: 'restart', message: 'Updating Hermes — this window will close. Don’t reopen Hermes yourself; it restarts automatically when the update finishes.', percent: 100 }) // Settle window (#66753): the reported macOS failure mode is exactly this // path — the app quits, bash/posix.sh dies early (or was never spawnable), // and the user is left with no app, no updater, and no relaunch. Watch the // child through the dwell; on spawn error or early death, stay alive and // surface the failure instead of quitting into nothing. const dwellStartedAt = Date.now() const handoffOutcome = await observeUpdaterHandoff(child, UPDATE_HANDOFF_DWELL_MS) if (!handoffOutcome.ok) { const message = `Update failed to start: ${handoffOutcome.message}. Hermes will keep running — try again, or run \`hermes update\` from a terminal.` rememberLog(`[updates] posix hand-off not viable, aborting quit: ${handoffOutcome.message}`) emitUpdateProgress({ stage: 'error', message, percent: null }) return { ok: false, error: 'updater-spawn-failed', message } } isQuittingForHandoff = true setTimeout( () => { app.quit() }, Math.max(0, UPDATE_HANDOFF_DWELL_MS - (Date.now() - dwellStartedAt)) ) return { ok: true, handedOff: true, updater: handoff.scriptPath } } function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')) } catch { return null } } // Bootstrap-complete marker helpers. The marker is written by whichever // installer ran: install.ps1, install.sh, the Rust bootstrap installer, or the // first-launch bootstrap runner. It is provenance ("a bootstrap finished // here"), NOT the launch gate -- activeRuntimeState() decides that, because a // healthy runtime can predate the marker or outlive a repair that cleared it. // // Marker schema (version 1): // { // schemaVersion: 1, // pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against // pinnedBranch: "" | null, // completedAt: "", // desktopVersion: "" // for forensics // } function readBootstrapMarker() { return readJson(BOOTSTRAP_COMPLETE_MARKER) } // Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually // runnable right now? A complete CLI install (`install.sh --include-desktop`) // or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop // ever having written the bootstrap marker -- so we must be able to recognise // "already installed" off the filesystem alone, not just the marker. function isActiveRuntimeUsable() { const venvPython = getVenvPython(VENV_ROOT) return ( isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(venvPython) && canImportHermesCli(venvPython, { env: { PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter) } }) ) } function activeRuntimeState() { // We DELIBERATELY do NOT verify that the checkout is currently at the // pinned commit -- users update via the in-app update path or `hermes // update`, which moves HEAD legitimately. The marker only attests "a // desktop-managed bootstrap ran here at least once"; runtime usability is // what decides whether we can actually launch. return classifyActiveRuntime(readBootstrapMarker(), BOOTSTRAP_MARKER_SCHEMA_VERSION, isActiveRuntimeUsable()) } function writeBootstrapMarker(payload) { fs.mkdirSync(path.dirname(BOOTSTRAP_COMPLETE_MARKER), { recursive: true }) const merged = { schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, pinnedCommit: payload.pinnedCommit || null, pinnedBranch: payload.pinnedBranch || null, completedAt: new Date().toISOString(), desktopVersion: app.getVersion() } writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8') return merged } function resolveWebDist() { const override = process.env.HERMES_DESKTOP_WEB_DIST if (override && directoryExists(path.resolve(override))) { return path.resolve(override) } const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist') if (directoryExists(unpackedDist)) { return unpackedDist } // Final fallback: APP_ROOT/dist. When packaged with asar:true this lives // INSIDE app.asar — not a servable filesystem directory — so the embedded // dashboard backend 404s on static routes (see #41327, #39472). The durable // fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2 // unpackedDist above resolves). If we still land here while packaged, log it // so the cause isn't silent. const fallback = path.join(APP_ROOT, 'dist') if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) { rememberLog( `[web-dist] dashboard frontend dir resolved to an asar-internal path that ` + `is not a real directory: ${fallback}. Static routes will 404. ` + `Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.` ) } return fallback } function resolveRendererIndex() { const asarIndex = path.join(APP_ROOT, 'dist', 'index.html') const webDistIndex = path.join(resolveWebDist(), 'index.html') // A packaged build ships dist/ twice: inside app.asar AND — because // asarUnpack lists dist/** — beside it in app.asar.unpacked. Prefer the // unpacked tree, matching the resolveWebDist()/unpackedPathFor precedent: // it is the copy the embedded dashboard serves and the copy a repair // rewrites, while pointing the window at the asar-internal index.html is // exactly how lazy chunks end up fetched from a path that cannot serve // them (#93479). Every window loader shares this resolver (main, overlay, // quick), so the ordering fix covers all of them. Dev is unchanged: // unpackedPathFor is a no-op outside an asar, so both candidates collapse // to APP_ROOT/dist and the original order is preserved. const candidates = IS_PACKAGED ? [webDistIndex, asarIndex] : [asarIndex, webDistIndex] const present = [...new Set(candidates)].filter(fileExists) // index.html and the hashed chunks it names are one generation. An update // that replaces only one of the two shipped copies (app.asar vs // app.asar.unpacked) leaves a TORN copy: the window loads, then dies on the // first lazy import with "Failed to fetch dynamically imported module" and // every restart reloads the same torn copy. Prefer a copy whose modules are // all present, so the intact generation heals the boot by itself. for (const candidate of present) { const missing = missingRendererAssets(candidate) if (missing.length === 0) { return candidate } rememberLog( `[renderer] skipping torn renderer bundle at ${candidate}: ` + `${missing.length} module file(s) named by index.html are missing ` + `(${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''})` ) } if (present.length > 0) { // Every copy is torn. Load the first one anyway — the boundary's error is // still better than a blank window — but say what is wrong and how to fix // it, because no amount of restarting repairs a torn bundle. rememberLog( `[renderer] every renderer bundle is incomplete (${present.join(', ')}). ` + `The last update replaced the app while its files were locked. ` + `Repair with: hermes desktop --force-build` ) return present[0] } // Nothing on disk. A packaged build with no renderer bundle blank-pages with // a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause // and the fix before Electron loads the missing file. rememberLog( `[renderer] index.html not found — the desktop app was packaged without a ` + `renderer bundle. Tried: ${candidates.join(', ')}. ` + `Rebuild with: hermes desktop --force-build` ) return candidates[0] } // True when `dir` lives inside the packaged app bundle / install tree. // Packaged Electron's process.cwd() (and npm's INIT_CWD when dev tooling // leaked into a release build) often resolve here — e.g. win-unpacked on // Windows — which is exactly where PR #37536 item 16 said we must NOT run. function isPackagedInstallPath(dir) { return isPackagedInstallPathUnderRoots(dir, { isPackaged: IS_PACKAGED, installRoots: [ APP_ROOT, path.dirname(process.execPath), resolveRemovableAppPath(process.execPath, process.platform, process.env) ] }) } function resolveHermesCwd() { // In a packaged build, `process.cwd()` resolves to the install root (e.g. // `…/win-unpacked` on Windows or `/Applications/Hermes.app/Contents/...` // on macOS). Sessions spawned there leave files inside the app bundle // and bewilder users when "where did my files go?" is the install dir. // The user-configurable default project directory wins over everything, // followed by env hints (only honored when packaged if they point at a // real directory), then the home dir. const candidates = [ readDefaultProjectDir(), process.env.HERMES_DESKTOP_CWD, IS_PACKAGED ? null : process.env.INIT_CWD, IS_PACKAGED ? null : process.cwd(), !IS_PACKAGED ? SOURCE_REPO_ROOT : null, app.getPath('home') ] for (const candidate of candidates) { if (!candidate) { continue } const resolved = path.resolve(String(candidate)) if (isPackagedInstallPath(resolved)) { continue } if (directoryExists(resolved)) { return resolved } } return app.getPath('home') } function sanitizeWorkspaceCwd(cwd) { const trimmed = typeof cwd === 'string' ? cwd.trim() : '' if (!trimmed || isPackagedInstallPath(trimmed)) { return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) } } try { const resolved = path.resolve(trimmed) if (directoryExists(resolved)) { return { cwd: resolved, sanitized: false } } } catch { // Fall through to the resolved default. } return { cwd: resolveHermesCwd(), sanitized: Boolean(trimmed) } } // Persisted "Default project directory" — surfaced as a setting in the // renderer (see app/settings/sessions-settings.tsx). Stored as JSON in // userData so it survives self-updates without bleeding into the new // install. `null` means "no preference, fall back to the usual chain". const DEFAULT_PROJECT_DIR_CONFIG_FILENAME = 'project-dir.json' function defaultProjectDirConfigPath() { return path.join(app.getPath('userData'), DEFAULT_PROJECT_DIR_CONFIG_FILENAME) } function readDefaultProjectDir() { try { const raw = fs.readFileSync(defaultProjectDirConfigPath(), 'utf8') const parsed = JSON.parse(raw) if (parsed && typeof parsed.dir === 'string' && parsed.dir.trim()) { const resolved = path.resolve(parsed.dir) if (directoryExists(resolved)) { return resolved } } } catch { // Missing / unreadable / malformed → fall through to the rest of the // candidate chain. } return null } function writeDefaultProjectDir(dir) { const target = defaultProjectDirConfigPath() const payload = dir ? JSON.stringify({ dir: path.resolve(dir) }, null, 2) : JSON.stringify({}, null, 2) try { fs.mkdirSync(path.dirname(target), { recursive: true }) fs.writeFileSync(target, payload, 'utf8') } catch (error) { rememberLog(`[settings] write default project dir failed: ${error.message}`) } } function createPythonBackend(root, label, backendArgs, options: any = {}) { const python = findPythonForRoot(root) if (!python) { return null } // The venv whose interpreter we selected is the venv whose site-packages // belong on PYTHONPATH — findPythonForRoot may have picked `.venv` over // `venv`, and mixing the two crashes the backend on its first native // import (see venvRootForPython). Fall back to root/venv only for a // system python, where the historical layout is the best guess. const venvRoot = venvRootForPython(python, root) ?? path.join(root, 'venv') const venvPython = getVenvPython(venvRoot) const command = IS_WINDOWS && fileExists(venvPython) ? venvPython : python return { kind: 'python', label, command, args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], venvRoot }), root, bootstrap: Boolean(options.bootstrap), shell: false } } // createActiveBackend — build a backend pointing at ACTIVE_HERMES_ROOT, the // canonical install location shared with the CLI installer. The venv at // VENV_ROOT may not exist yet on first run; bootstrap=true tells // ensureRuntime() to create / refresh it before launch. function createActiveBackend(backendArgs) { const venvPython = getVenvPython(VENV_ROOT) const command = fileExists(venvPython) ? venvPython : findSystemPython() return { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], venvRoot: VENV_ROOT }), root: ACTIVE_HERMES_ROOT, bootstrap: true, shell: false } } function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) if (overrideRoot && isHermesSourceRoot(overrideRoot)) { const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) if (backend) { return backend } } // 2. Development source -- when running `npm run dev` from a checkout, the // cloned repo at SOURCE_REPO_ROOT takes precedence over ACTIVE and any // installed `hermes` on PATH so local Python edits are actually exercised. // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) if (backend) { return backend } } // 3. ACTIVE_HERMES_ROOT — the canonical install at // %LOCALAPPDATA%\\hermes\\hermes-agent (Windows) or ~/.hermes/hermes-agent. // A valid bootstrap marker proves Desktop finished the first-run install // flow, but marker provenance is NOT the same thing as runtime usability: // the CLI can create the exact same repo+venv layout, and older desktop // builds could leave a healthy install behind without the marker. If the // active runtime is usable, launch it directly; only fall through to // bootstrap when the runtime itself is unusable. const activeRuntime = activeRuntimeState() if (needsPackagedRuntimeUpgrade(IS_PACKAGED, INSTALL_STAMP, readBootstrapMarker())) { rememberLog('[bootstrap] AITURK package changed; updating its managed agent before launch.') return createBootstrapBackend(backendArgs) } if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) { if (!activeRuntime.hasValidMarker) { rememberLog( `[bootstrap] Active Hermes runtime at ${ACTIVE_HERMES_ROOT} is usable but the bootstrap marker is missing or stale; skipping first-run bootstrap.` ) } return createActiveBackend(backendArgs) } if (bootstrapRepairRequested) { rememberLog('[bootstrap] repair requested; bypassing the usable active runtime to re-run the installer') } // A packaged AITURK install owns its pinned runtime. Discovering a separate // Hermes executable on PATH could transfer update ownership to that install. if (IS_PACKAGED) return createBootstrapBackend(backendArgs) // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from // a previous tool-only setup, or pip-installed system-wide. Use it but // do NOT write a bootstrap marker; the user did this themselves and we // don't want to take ownership of an install we didn't perform. // HERMES_DESKTOP_IGNORE_EXISTING=1 forces the bootstrap path for testing. if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') { let hermesCommand = null const hermesOverride = process.env.HERMES_DESKTOP_HERMES if (hermesOverride) { const resolvedOverride = findOnPath(hermesOverride) if (resolvedOverride) { hermesCommand = resolvedOverride } else if (!isWindowsBinaryPathInWsl(hermesOverride, { isWsl: IS_WSL })) { hermesCommand = hermesOverride } else { rememberLog(`Ignoring Windows Hermes override under WSL: ${hermesOverride}`) } } else { hermesCommand = findOnPath('hermes') } if (hermesCommand) { if (looksLikeDesktopAppBinary(hermesCommand)) { rememberLog(`Ignoring desktop app executable on PATH while resolving Hermes CLI: ${hermesCommand}`) hermesCommand = null } } if (hermesCommand) { const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) if (unwrapped) { return unwrapped } // Smoke-test the candidate before trusting it. A `hermes` shim // left behind by a half-uninstalled pip install (or a venv // entry-point pointing at a deleted interpreter) still resolves // via findOnPath but explodes on spawn -- the user then sees a // dead backend instead of the first-launch installer. The cheap // `--version` probe (see backend-probes.ts) catches that case // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) // HERMES_DESKTOP_HERMES is an explicit deployment override (used by // the Nix wrapper), not a discovered PATH candidate. It must not fall // through to the install-script bootstrap if the optional probe times // out under load; the pinned backend is the only valid runtime there. if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) { // `unwrapped` above already answered "is this a Windows venv shim?" — // it was null (not a shim, or its import probe failed). Do NOT re-run // unwrapWindowsVenvHermesCommand here: the second call repeats the // same un-memoized import probe, costing up to another full probe // timeout on the boot path for an answer we already have. return { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, args: backendArgs, bootstrap: false, env: {}, kind: 'command', shell: shellForProbe } } rememberLog( `Ignoring existing Hermes CLI at ${hermesCommand}: --version probe failed; falling through to bootstrap.` ) } } // 5. Last-ditch: pip-installed hermes_cli module via system Python. // Same rationale as #4 -- the user installed this; we use it but don't // take ownership. const python = findSystemPython() if (python) { // Same smoke-test rationale as step 4: a system Python in the // SUPPORTED_VERSIONS range can be registered (PEP 514) without // having hermes_cli installed -- common on dev boxes that have // a python.org install from prior unrelated work. Returning that // backend hands the spawn step a guaranteed ModuleNotFoundError. // Verify the import works before trusting the candidate; on // failure, fall through to step 6 so the bootstrap runner pulls // a uv-managed 3.11 into %LOCALAPPDATA%\hermes\hermes-agent\venv. if (canImportHermesCli(python)) { return { kind: 'python', label: `installed hermes_cli module via ${python}`, command: python, args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: {}, shell: false } } rememberLog(`Ignoring system Python ${python}: hermes_cli is not importable; falling through to bootstrap.`) } // 6. Nothing usable yet -- signal the bootstrap runner that we need to // clone+install. Phase 1D's bootstrap-runner consumes this sentinel // and drives install.ps1 stages with a progress UI. Until 1D lands, // callers see the sentinel and surface it as a user-facing error // explaining what's missing. // // We deliberately do NOT throw here -- throwing inside // resolveHermesBackend was the old "no payload" path and forced the // user into a dead end. With the bootstrap protocol, "no install yet" // is a recoverable state the GUI can drive through. return createBootstrapBackend(backendArgs) } function createBootstrapBackend(backendArgs) { return { kind: 'bootstrap-needed', label: 'Hermes Agent not installed yet; bootstrap required', command: null, args: backendArgs, bootstrap: true, env: {}, shell: false, // Hints for the bootstrap runner / UI layer: activeRoot: ACTIVE_HERMES_ROOT, installStamp: INSTALL_STAMP, // may be null in dev isPackaged: IS_PACKAGED, platform: process.platform } } async function ensureRuntime(backend) { if (!backend.bootstrap) { await advanceBootProgress('runtime.external', `Using ${backend.label}`, 32) return backend } // backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't // find anything to spawn. Hand off to the bootstrap runner which drives the // platform installer, writes the bootstrap-complete marker on success, then // we re-resolve to get the now-installed backend. // // Phase 1D status: bootstrap runs but events go to desktop.log only // (renderer window isn't created until later in startBackend). Phase 1E // will rewire startup to spawn the window first and route bootstrap events // to a renderer-side install overlay. if (backend.kind === 'bootstrap-needed') { rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap') if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) { const handoffError: Error & { isBootstrapFailure?: boolean; bootstrapHandedOff?: boolean } = new Error( 'Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.' ) handoffError.isBootstrapFailure = true handoffError.bootstrapHandedOff = true bootstrapFailure = handoffError throw handoffError } // Eagerly flip the bootstrap UI state to 'active' so the renderer // shows the install overlay BEFORE the runner finishes fetching the // manifest (which on slow networks can take tens of seconds and would // otherwise leave the user staring at the generic 'Preparing' splash). // We emit a synthetic manifest with an empty stages list -- the real // manifest event will overwrite it once install.ps1 -Manifest returns. try { broadcastBootstrapEvent({ type: 'manifest', stages: [], protocolVersion: null }) } catch { void 0 } bootstrapAbortController = new AbortController() // The repair request has been honoured by reaching the installer; clear it // so a later boot isn't forced through bootstrap again. bootstrapRepairRequested = false bootstrapRepairAttempt = 0 const bootstrapResult = await runBootstrap({ installStamp: backend.installStamp, activeRoot: backend.activeRoot, sourceRepoRoot: SOURCE_REPO_ROOT, hermesHome: HERMES_HOME, logRoot: path.join(HERMES_HOME, 'logs'), abortSignal: bootstrapAbortController.signal, onEvent: ev => { // Tee every bootstrap event to (a) the desktop log for forensics // and (b) the renderer for live progress UI. Either may be absent; // tolerate both gracefully so a renderer crash doesn't stall the // bootstrap and a log-write failure doesn't suppress the UI signal. try { rememberLog(`[bootstrap] ${JSON.stringify(ev)}`) } catch { void 0 } try { broadcastBootstrapEvent(ev) } catch { void 0 } }, writeMarker: writeBootstrapMarker }) bootstrapAbortController = null if (bootstrapResult.cancelled) { const cancelledError = new Error('Hermes install was cancelled.') as any cancelledError.isBootstrapFailure = true cancelledError.bootstrapCancelled = true bootstrapFailure = cancelledError throw cancelledError } if (!bootstrapResult.ok) { const bootstrapError = new Error( `Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` + `${bootstrapResult.error || 'unknown error'}. ` + `Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.` ) as any bootstrapError.isBootstrapFailure = true bootstrapError.failedStage = bootstrapResult.failedStage || null // Latch the failure so subsequent startHermes() calls return this // same error without re-running install.ps1. Cleared by the // hermes:bootstrap:reset IPC (renderer's "Reload and retry"). bootstrapFailure = bootstrapError throw bootstrapError } rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.') // Re-resolve now that the install exists. The new resolution lands in // step 3 (bootstrap-complete marker) and we recurse to wire venvPython. return ensureRuntime(resolveHermesBackend(backend.args)) } // bootstrap=true with a real backend (createActiveBackend path) means we // have a checkout and need to ensure the venv-derived Python command is // wired into the backend before launch. Same code path the old factory // sync flow exited through, minus all the factory/pip/marker machinery // (install.ps1 owns those concerns now and the bootstrap-complete marker // attests they ran successfully). if (!isHermesSourceRoot(ACTIVE_HERMES_ROOT)) { throw new Error( `Hermes install at ${ACTIVE_HERMES_ROOT} is missing or incomplete. ` + 'Reinstall via the desktop installer or scripts/install.ps1.' ) } // On Windows, preflight Git Bash. Hermes' terminal tool calls bash.exe // directly (tools/environments/local.py); without it the agent can't run // terminal commands. install.ps1's Stage-Git puts PortableGit at // %LOCALAPPDATA%\hermes\git\, which findGitBash() picks up, so for any // user who completed the bootstrap this is a no-op. For users who got // here via an external `hermes` on PATH, this check still helps. if (IS_WINDOWS && !findGitBash()) { throw new Error( 'Git for Windows is required for Hermes on Windows (provides Git Bash, ' + "which the agent's terminal tool uses). Install it from " + 'https://git-scm.com/download/win or run `winget install -e --id Git.Git`, ' + 'then relaunch Hermes.' ) } const venvPython = getVenvPython(VENV_ROOT) if (!fileExists(venvPython)) { // No venv at the expected location AND no bootstrap-needed sentinel // means we have a half-installed checkout: .git exists, source files // exist, but venv is missing or broken. This shouldn't happen in // normal flow because activeRuntimeState() requires isHermesSourceRoot() // plus an importable hermes_cli before it hands back the active runtime. // If we hit this, the user (or a deleted venv) broke the invariant; tell // them to re-run the install. throw new Error( `Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.' ) } backend.command = getVenvPython(VENV_ROOT) backend.label = `Hermes at ${ACTIVE_HERMES_ROOT} (venv: ${VENV_ROOT})` updateBootProgress({ phase: 'runtime.ready', message: 'Hermes runtime is ready', progress: 82, running: true, error: null }) return backend } // Assemble a single-file multipart/form-data body (FastAPI `UploadFile` // endpoints, e.g. kanban attachments). Hand-rolled because node's http has no // FormData and the payload is one file — a dependency would be overkill. function multipartBody(upload) { const boundary = `----hermes-${crypto.randomBytes(12).toString('hex')}` const filename = String(upload.filename || 'file').replace(/["\r\n]/g, '_') const body = Buffer.concat([ Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + `Content-Type: ${upload.contentType || 'application/octet-stream'}\r\n\r\n` ), Buffer.from(upload.bytes), Buffer.from(`\r\n--${boundary}--\r\n`) ]) return { body, contentType: `multipart/form-data; boundary=${boundary}` } } function fetchJson(url, token, options: any = {}) { // Retry policy lives in api-transport.ts: idempotent verbs retry on any // transient transport error; POST/PUT/DELETE only when the request provably // never reached the server (see shouldRetryRequest) — never double-submit. return withRetry( (requestState: any) => new Promise((resolve, reject) => { const { body, contentType } = options.upload ? multipartBody(options.upload) : { body: options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)), contentType: 'application/json' } const parsed = new URL(url) const client = parsed.protocol === 'https:' ? https : http const agent = jsonAgentFor(parsed.protocol) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) return } const req = client.request( parsed, { agent, method: options.method || 'GET', headers: { ...headersForRemoteRequest(url), ...(options.headers || {}), 'Content-Type': contentType, 'X-Hermes-Session-Token': token, // RFC 8252 native flow authenticates the gated gateway with a bearer // token instead of the loopback session-token header. When // ``options.bearer`` is set we send Authorization: Bearer ; // the gateway's OAuth gate verifies it via the provider stack with // no cookie involved. ...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}), ...(body ? { 'Content-Length': String(body.length) } : {}) } }, res => { const chunks = [] res.on('error', reject) res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) return } if (!text) { resolve(null) return } // A 2xx response whose body is HTML means the request fell through // to the SPA index.html (e.g. an unregistered /api path). JSON.parse // would throw an opaque `Unexpected token '<'` here, so surface a // clear diagnostic with the offending URL instead. const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') if (looksHtml || contentType.includes('text/html')) { reject( new Error( `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + 'The endpoint is likely missing on the Hermes backend.' ) ) return } try { resolve(JSON.parse(text)) } catch { reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) } }) } ) req.on('error', reject) req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) // From here the request goes on the wire: a later transport error can no // longer prove the server didn't process it, so non-idempotent verbs must // not be retried past this point. requestState.bodySent = true if (body) { req.write(body) } req.end() }), { method: options.method || 'GET' } ) } // Token-auth download that streams the response body straight to a // user-selected destination (via finalizeGatewayDownload) instead of buffering // the whole file in memory. The connect timeout is cleared once headers arrive // so a slow save dialog or a large stream doesn't trip it. `options.bearer` // switches the header to Authorization (RFC 8252 native flow), matching fetchJson. function downloadViaTokenToFile(url, token, ctx, options: any = {}) { return new Promise((resolve, reject) => { let parsed try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) return } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) return } const client = parsed.protocol === 'https:' ? https : http const agent = downloadAgentFor(parsed.protocol) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const req = client.request( parsed, { agent, method: 'GET', headers: options.bearer ? { Authorization: `Bearer ${options.bearer}` } : { 'X-Hermes-Session-Token': token } }, res => { // Headers arrived — the connection phase is done. Drop the idle timeout // so it can't abort mid-stream or while the save dialog is open. req.setTimeout(0) finalizeGatewayDownload(res, res.statusCode || 500, res.headers || {}, { ...ctx, abort: () => { try { req.destroy() } catch { // already finished } } }).then(resolve, reject) } ) req.on('error', reject) req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) req.end() }) } function fetchPublicJson(url, options: any = {}) { // Credential-free JSON GET/POST for public gateway endpoints // (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends // NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before // any credentials exist, and any time we must not leak a token to an // endpoint that doesn't need one. return withRetry( (requestState: any) => new Promise((resolve, reject) => { const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body)) let parsed try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) return } const client = parsed.protocol === 'https:' ? https : http const agent = jsonAgentFor(parsed.protocol) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) return } const req = client.request( parsed, { agent, method: options.method || 'GET', headers: { ...headersForRemoteRequest(url), ...(options.headers || {}), 'Content-Type': 'application/json', ...(body ? { 'Content-Length': String(body.length) } : {}) } }, res => { const chunks = [] res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8') if ((res.statusCode || 500) >= 400) { reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`)) return } if (!text) { resolve(null) return } const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || '') if (looksHtml || contentType.includes('text/html')) { reject( new Error( `Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` + 'The endpoint is likely missing on the Hermes backend.' ) ) return } try { resolve(JSON.parse(text)) } catch { reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`)) } }) } ) req.on('error', reject) req.setTimeout(timeoutMs, () => { req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }) // Past this point the request is on the wire — see fetchJson. requestState.bodySent = true if (body) { req.write(body) } req.end() }), { method: options.method || 'GET' } ) } function mimeTypeForPath(filePath) { const ext = path.extname(filePath || '').toLowerCase() return MEDIA_MIME_TYPES[ext] || 'application/octet-stream' } function extensionForMimeType(mimeType) { const type = String(mimeType || '') .split(';')[0] .trim() .toLowerCase() if (type === 'image/png') { return '.png' } if (type === 'image/jpeg') { return '.jpg' } if (type === 'image/gif') { return '.gif' } if (type === 'image/webp') { return '.webp' } if (type === 'image/bmp') { return '.bmp' } if (type === 'image/svg+xml') { return '.svg' } return '' } function filenameFromUrl(rawUrl, fallback = 'image') { try { const parsed = new URL(rawUrl) const base = path.basename(decodeURIComponent(parsed.pathname || '')) return base && base.includes('.') ? base : fallback } catch { return fallback } } // Link title resolution — curl (tier 1) → hidden BrowserWindow (tier 2). const titleCache = new Map() const titleInflight = new Map() const TITLE_CACHE_LIMIT = 500 const TITLE_BYTE_BUDGET = 96 * 1024 const TITLE_TIMEOUT_MS = 5000 const TITLE_MAX_REDIRECTS = 3 // Browser-shaped UA — many bot-walled sites (GetYourGuide, Cloudflare-protected // pages) refuse anything that doesn't look like a real Chrome. const TITLE_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' const TITLE_ERROR_RE = /\b(access denied|attention required|captcha|error|forbidden|just a moment|request blocked|too many requests)\b/i const HTML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" } // Tier-2 renderer fallback config. Only invoked when curl came back empty or // matched TITLE_ERROR_RE — keeps cold/CDN-cached pages on the cheap path. const RENDER_TITLE_MAX_CONCURRENT = 2 const RENDER_TITLE_TIMEOUT_MS = 8000 const RENDER_TITLE_GRACE_MS = 700 // Resource types we cancel before the network even fires — keeps the hidden // renderer fast and cuts third-party tracking noise. const RENDER_TITLE_BLOCKED_RESOURCES = new Set([ 'cspReport', 'font', 'imageset', 'media', 'object', 'ping', 'stylesheet' ]) let linkTitleSession = null let oauthSession = null let renderTitleInFlight = 0 const renderTitleQueue = [] function canonicalTitleCacheKey(rawUrl) { const value = String(rawUrl || '').trim() if (!value) { return '' } try { const url = new URL(value) const host = url.hostname.replace(/^www\./i, '').toLowerCase() const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/' return `${host}${pathname}${url.search || ''}` } catch { return value } } function cacheTitle(key, title) { if (titleCache.size >= TITLE_CACHE_LIMIT) { titleCache.delete(titleCache.keys().next().value) } titleCache.set(key, title) } function decodeHtmlEntities(value) { return value .replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_, k) => HTML_ENTITIES[k.toLowerCase()] ?? '') .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16) || 32)) .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10) || 32)) } function parseHtmlTitle(html) { const raw = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : '' } function fetchHtmlTitleWithCurl(rawUrl: string): Promise { return new Promise(resolve => { const url = String(rawUrl || '').trim() if (!url) { return resolve('') } const args = [ '--silent', '--show-error', '--location', '--max-redirs', String(TITLE_MAX_REDIRECTS), '--max-time', String(Math.max(2, Math.ceil(TITLE_TIMEOUT_MS / 1000))), '--connect-timeout', '4', '--user-agent', TITLE_USER_AGENT, '--header', 'Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.5', '--header', 'Accept-Language: en-US,en;q=0.7', '--header', 'Accept-Encoding: identity', '--raw', url ] const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] })) const chunks = [] let bytes = 0 child.stdout.on('data', chunk => { if (bytes >= TITLE_BYTE_BUDGET) { return } const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = TITLE_BYTE_BUDGET - bytes const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer chunks.push(next) bytes += next.length }) child.on('error', () => resolve('')) child.on('close', () => { if (!chunks.length) { return resolve('') } resolve(parseHtmlTitle(Buffer.concat(chunks).toString('utf8'))) }) }) } function getLinkTitleSession() { if (linkTitleSession || !app.isReady()) { return linkTitleSession } linkTitleSession = session.fromPartition('hermes:link-titles', { cache: false }) linkTitleSession.webRequest.onBeforeRequest((details, callback) => { callback({ cancel: RENDER_TITLE_BLOCKED_RESOURCES.has(details.resourceType) }) }) guardLinkTitleSession(linkTitleSession) return linkTitleSession } function dequeueRenderTitle() { while (renderTitleInFlight < RENDER_TITLE_MAX_CONCURRENT && renderTitleQueue.length) { const item = renderTitleQueue.shift() renderTitleInFlight += 1 runRenderTitleJob(item.url).then(title => { renderTitleInFlight -= 1 item.resolve(title) dequeueRenderTitle() }) } } function runRenderTitleJob(rawUrl) { return new Promise(resolve => { if (!app.isReady()) { return resolve('') } const partitionSession = getLinkTitleSession() if (!partitionSession) { return resolve('') } let settled = false let window = null let hardTimer = null let graceTimer = null const finish = title => { if (settled) { return } settled = true if (hardTimer) { clearTimeout(hardTimer) } if (graceTimer) { clearTimeout(graceTimer) } const value = (title || '').replace(/\s+/g, ' ').trim() try { if (window && !window.isDestroyed()) { window.destroy() } } catch { // BrowserWindow may already be torn down; ignore. } resolve(value) } try { window = createLinkTitleWindow(BrowserWindow, partitionSession) } catch { return finish('') } const finishWithTitle = () => finish(readLinkTitleWindowTitle(window)) const scheduleGrace = () => { if (graceTimer) { clearTimeout(graceTimer) } graceTimer = setTimeout(finishWithTitle, RENDER_TITLE_GRACE_MS) } hardTimer = setTimeout(finishWithTitle, RENDER_TITLE_TIMEOUT_MS) window.webContents.setUserAgent(TITLE_USER_AGENT) window.webContents.on('page-title-updated', scheduleGrace) window.webContents.on('did-finish-load', scheduleGrace) window.webContents.on('did-fail-load', (_event, _code, _desc, _validatedURL, isMainFrame) => { if (isMainFrame) { finish('') } }) window .loadURL(rawUrl, { httpReferrer: 'https://www.google.com/', userAgent: TITLE_USER_AGENT }) .catch(() => finish('')) }) } function fetchHtmlTitleWithRenderer(rawUrl: string): Promise { return new Promise(resolve => { renderTitleQueue.push({ resolve, url: rawUrl }) dequeueRenderTitle() }) } // Strips known error/captcha titles (e.g. "GetYourGuide – Error", "Just a // moment...") so they don't get cached as the resolved title. function usableTitle(value: string): string { return value && !TITLE_ERROR_RE.test(value) ? value : '' } function fetchLinkTitle(rawUrl) { const url = String(rawUrl || '').trim() const key = canonicalTitleCacheKey(url) if (!key) { return Promise.resolve('') } if (titleCache.has(key)) { return Promise.resolve(titleCache.get(key)) } if (titleInflight.has(key)) { return titleInflight.get(key) } const pending = fetchHtmlTitleWithCurl(url) .catch(() => '') .then(value => usableTitle((value || '').slice(0, 240))) .then( async value => value || usableTitle(((await fetchHtmlTitleWithRenderer(url).catch(() => '')) || '').slice(0, 240)) ) .then(clean => { cacheTitle(key, clean) titleInflight.delete(key) return clean }) titleInflight.set(key, pending) return pending } // ─── Favicon resolution ────────────────────────────────────────────────────── // The ladder itself is electron/favicon.ts; this is its I/O, its cache, and // the one rule that belongs to the app rather than the algorithm: one icon // per host. A connector's mark doesn't vary by path, and hosting the cache on // the host key means Linear's docs page and Linear's MCP endpoint cost one // lookup between them. const FAVICON_CACHE_PATH = path.join(app.getPath('userData'), 'favicon-cache.json') const FAVICON_CACHE_LIMIT = 400 const FAVICON_TTL_MS = 30 * 24 * 60 * 60 * 1000 // A miss is cheap to re-check and expensive to be wrong about (a site that // was behind a captcha yesterday has a logo today), so it expires fast. const FAVICON_MISS_TTL_MS = 12 * 60 * 60 * 1000 const FAVICON_TIMEOUT_MS = 6000 const FAVICON_MAX_BYTES = 256 * 1024 const FAVICON_WRITE_DEBOUNCE_MS = 3000 let faviconCache: Map | null = null let faviconWriteTimer: null | ReturnType = null const faviconInflight = new Map>() function faviconCacheKey(rawUrl: string): string { try { return new URL(rawUrl).hostname.replace(/^www\./i, '').toLowerCase() } catch { return '' } } function loadFaviconCache(): Map { if (faviconCache) { return faviconCache } faviconCache = new Map() try { const raw = JSON.parse(fs.readFileSync(FAVICON_CACHE_PATH, 'utf8')) for (const [host, entry] of Object.entries(raw?.icons ?? {})) { const at = Number((entry as { at?: number })?.at) const icon = String((entry as { icon?: string })?.icon ?? '') if (Number.isFinite(at) && Date.now() - at < (icon ? FAVICON_TTL_MS : FAVICON_MISS_TTL_MS)) { faviconCache.set(host, { at, icon }) } } } catch { // No cache yet, or it's unreadable — resolving again is the whole cost. } return faviconCache } function saveFaviconCacheSoon() { if (faviconWriteTimer) { return } faviconWriteTimer = setTimeout(() => { faviconWriteTimer = null try { const icons = Object.fromEntries(loadFaviconCache()) fs.writeFileSync(FAVICON_CACHE_PATH, JSON.stringify({ icons }), 'utf8') } catch { // Cache is an optimization; failing to persist it costs one refetch. } }, FAVICON_WRITE_DEBOUNCE_MS) faviconWriteTimer.unref?.() } async function faviconFetch(url: string, accept: string) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), FAVICON_TIMEOUT_MS) try { return await electronNet.fetch(url, { // Same browser-shaped identity the title fetcher uses: a plain Electron // UA gets a challenge page from anything behind a bot wall. headers: { Accept: accept, 'Accept-Language': 'en-US,en;q=0.7', 'User-Agent': TITLE_USER_AGENT }, redirect: 'follow', signal: controller.signal }) } finally { clearTimeout(timer) } } const faviconIo: FaviconIo = { fetchImage: async url => { const response = await faviconFetch(url, 'image/avif,image/webp,image/svg+xml,image/*;q=0.8,*/*;q=0.5') if (!response.ok) { return null } const buffer = await response.arrayBuffer() if (buffer.byteLength === 0 || buffer.byteLength > FAVICON_MAX_BYTES) { return null } return { bytes: new Uint8Array(buffer), mime: response.headers.get('content-type') ?? '' } }, fetchText: async url => { const response = await faviconFetch(url, 'text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.5') return response.ok ? (await response.text()).slice(0, TITLE_BYTE_BUDGET * 2) : '' } } function resolveFaviconCached(rawUrl: string): Promise { const key = faviconCacheKey(String(rawUrl || '').trim()) if (!key) { return Promise.resolve('') } const cache = loadFaviconCache() const hit = cache.get(key) if (hit && Date.now() - hit.at < (hit.icon ? FAVICON_TTL_MS : FAVICON_MISS_TTL_MS)) { return Promise.resolve(hit.icon) } const inflight = faviconInflight.get(key) if (inflight) { return inflight } const pending = resolveFavicon(rawUrl, faviconIo) .catch(() => '') .then(icon => { if (cache.size >= FAVICON_CACHE_LIMIT) { cache.delete(cache.keys().next().value) } cache.set(key, { at: Date.now(), icon }) saveFaviconCacheSoon() faviconInflight.delete(key) return icon }) faviconInflight.set(key, pending) return pending } async function resourceBufferFromUrl(rawUrl) { if (!rawUrl) { throw new Error('Missing URL') } if (rawUrl.startsWith('data:')) { const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s) if (!match) { throw new Error('Invalid data URL') } const mimeType = match[1] || 'application/octet-stream' const encoded = match[3] || '' const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8') return { buffer, mimeType } } if (/^file:/i.test(rawUrl)) { const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' }) const buffer = await fs.promises.readFile(resolvedPath) return { buffer, mimeType: mimeTypeForPath(resolvedPath) } } const parsed = new URL(rawUrl) const client = parsed.protocol === 'https:' ? https : http return new Promise((resolve, reject) => { const req = client.get(parsed, res => { if ((res.statusCode || 500) >= 400) { reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`)) res.resume() return } const chunks = [] res.on('error', reject) res.on('data', chunk => chunks.push(chunk)) res.on('end', () => { resolve({ buffer: Buffer.concat(chunks), mimeType: res.headers['content-type'] || 'application/octet-stream' }) }) }) req.on('error', reject) }) } async function saveImageFromUrl(rawUrl) { const { buffer, mimeType } = (await resourceBufferFromUrl(rawUrl)) as any const extension = extensionForMimeType(mimeType) || '.png' // Generated-image URLs (fal.media etc.) usually end in an extensionless // content hash. Keep the name but always guarantee an extension — without // one Windows saves an unopenable "All Files" blob (#image18 report). const baseName = filenameFromUrl(rawUrl, `image${extension}`) const fallbackName = path.extname(baseName) ? baseName : `${baseName}${extension}` let downloadsDir = '' try { downloadsDir = app.getPath('downloads') } catch { // Leave the dialog at its last-used location when the OS has no // Downloads directory to offer. } const result = await dialog.showSaveDialog(mainWindow, { title: 'Save Image', defaultPath: downloadsDir ? path.join(downloadsDir, fallbackName) : fallbackName, filters: [ { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, { name: 'All Files', extensions: ['*'] } ] }) if (result.canceled || !result.filePath) { return false } await fs.promises.writeFile(result.filePath, buffer) return true } async function writeComposerImage(buffer, ext = '.png', name = '') { const rawExt = String(ext || '.png') .trim() .toLowerCase() const normalizedExt = rawExt.startsWith('.') ? rawExt : `.${rawExt}` const safeExt = /^\.[a-z0-9]{1,5}$/.test(normalizedExt) ? normalizedExt : '.png' const dir = path.join(app.getPath('userData'), 'composer-images') await fs.promises.mkdir(dir, { recursive: true }) const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '') const random = crypto.randomBytes(3).toString('hex') const baseName = String(name || '') .split(/[\\/]/) .pop() ?.replace(/\.[^.]+$/, '') const safeName = (baseName || '') .replace(/[^\p{L}\p{N}._-]+/gu, '_') .replace(/^[._-]+|[._-]+$/g, '') .slice(0, 80) const fileName = safeName ? `${safeName}_${random}${safeExt}` : `composer_${stamp}_${random}${safeExt}` const filePath = path.join(dir, fileName) await fs.promises.writeFile(filePath, buffer) return filePath } function previewLabelForUrl(url) { return `${url.host}${url.pathname === '/' ? '' : url.pathname}` } function expandUserPath(filePath) { const value = String(filePath || '').trim() if (value === '~') { return app.getPath('home') } if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { return path.join(app.getPath('home'), value.slice(2)) } return value } async function previewFileTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() const base = baseDir ? path.resolve(expandUserPath(baseDir)) : resolveHermesCwd() let resolved = resolveRequestedPathForIpc(/^file:/i.test(raw) ? raw : expandUserPath(raw), { baseDir: base, purpose: 'Preview target' }) if (directoryExists(resolved)) { resolved = path.join(resolved, 'index.html') } const ext = path.extname(resolved).toLowerCase() if (!fileExists(resolved)) { return null } ;({ resolvedPath: resolved } = await resolveReadableFileForIpc(resolved, { purpose: 'Preview target' })) const mimeType = mimeTypeForPath(resolved) const metadata = previewFileMetadata(resolved, mimeType) const isHtml = PREVIEW_HTML_EXTENSIONS.has(ext) const isImage = mimeType.startsWith('image/') const isPdf = PREVIEW_PDF_EXTENSIONS.has(ext) || mimeType === 'application/pdf' const previewKind = isHtml ? 'html' : isImage ? 'image' : isPdf ? 'pdf' : metadata.binary ? 'binary' : 'text' return { binary: metadata.binary, byteSize: metadata.byteSize, kind: 'file', large: metadata.large, label: path.basename(resolved), language: PREVIEW_LANGUAGE_BY_EXT[ext] || 'text', mimeType, path: resolved, previewKind, source: raw, url: pathToFileURL(resolved).toString() } } function previewUrlTarget(rawTarget) { const raw = String(rawTarget || '').trim() const url = new URL(raw) if (!['http:', 'https:'].includes(url.protocol)) { return null } if (!LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())) { return null } if (url.hostname === '0.0.0.0') { url.hostname = '127.0.0.1' } return { kind: 'url', label: previewLabelForUrl(url), source: raw, url: url.toString() } } async function normalizePreviewTarget(rawTarget, baseDir) { const raw = String(rawTarget || '').trim() if (!raw) { return null } try { if (/^https?:\/\//i.test(raw)) { return previewUrlTarget(raw) } return await previewFileTarget(raw, baseDir) } catch { return null } } async function filePathFromPreviewUrl(rawUrl) { const { resolvedPath } = await resolveReadableFileForIpc(String(rawUrl || ''), { purpose: 'Preview file' }) return resolvedPath } function sendPreviewFileChanged(payload) { if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:preview-file-changed', payload) } async function watchPreviewFile(rawUrl) { const filePath = await filePathFromPreviewUrl(rawUrl) const watchDir = path.dirname(filePath) const targetName = path.basename(filePath) const id = crypto.randomBytes(12).toString('base64url') let timer = null const watcher = fs.watch(watchDir, (_eventType, filename) => { const changedName = filename ? path.basename(String(filename)) : '' if (changedName && changedName !== targetName) { return } if (timer) { clearTimeout(timer) } timer = setTimeout(() => { timer = null if (!fileExists(filePath)) { return } sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { if (timer) { clearTimeout(timer) } watcher.close() } }) return { id, path: filePath } } function stopPreviewFileWatch(id) { const watcher = previewWatchers.get(id) if (!watcher) { return false } watcher.close() previewWatchers.delete(id) return true } function closePreviewWatchers() { for (const id of previewWatchers.keys()) { stopPreviewFileWatch(id) } } function requestOptionsWithHeaders(options: any = {}, headers = {}) { return { ...options, headers: { ...headers, ...(options.headers || {}) } } } /** Watch a DIRECTORY for entry churn (folders appearing/vanishing) — the * disk-plugin door's "new plugin folder" signal, replacing the renderer's 5s * readdir poll. Same registry + change channel as the preview file watchers * (the renderer reconciles on any tick; per-file edits stay on their own * watches), so stopPreviewFileWatch/closePreviewWatchers manage these too. */ function watchDirectory(rawDir) { const watchDir = path.resolve(String(rawDir || '')) if (!fs.existsSync(watchDir) || !fs.statSync(watchDir).isDirectory()) { throw new Error(`Not a directory: ${watchDir}`) } const id = crypto.randomBytes(12).toString('base64url') let timer = null const watcher = fs.watch(watchDir, () => { if (timer) { clearTimeout(timer) } timer = setTimeout(() => { timer = null sendPreviewFileChanged({ id, path: watchDir, url: pathToFileURL(watchDir).toString() }) }, PREVIEW_WATCH_DEBOUNCE_MS) }) previewWatchers.set(id, { close: () => { if (timer) { clearTimeout(timer) } watcher.close() } }) return { id, path: watchDir } } // Best-effort read of a gateway's advertised auth providers, cached per base // URL for the life of the process. Used by the oauth pre-flight guard to tell // a password-provider gateway (which cannot satisfy the bearer/cookie checks // by design) from a real OAuth one. Any failure returns [] so callers keep the // strict guard — backends predating /api/auth/providers are unaffected. const gatewayAuthProvidersCache = new Map() async function gatewayAuthProviders(baseUrl, headers = {}) { const cached = gatewayAuthProvidersCache.get(baseUrl) if (cached) { return cached } let providers = [] try { const body = (await fetchPublicJson( `${baseUrl}/api/auth/providers`, requestOptionsWithHeaders({ timeoutMs: 8_000 }, headers) )) as any if (Array.isArray(body?.providers)) { providers = body.providers .filter(p => p && typeof p === 'object') .map(p => ({ name: String(p.name || ''), supportsPassword: Boolean(p.supports_password) })) .filter(p => p.name) } gatewayAuthProvidersCache.set(baseUrl, providers) } catch { // Optional metadata — an unreadable list keeps the strict guard. } return providers } // Build the readiness probe for a connection's auth mode. A gated gateway // must be probed with the SAME credentials the rest of the connection uses: // an anonymous probe 401s forever against a live session, and it can never // see the 404 that identifies a backend predating /api/health (the auth gate // answers before the SPA catch-all). `probeIsCredentialed` tells // waitForHermesReady how to read a 401 — rejected session vs gated route. async function buildReadinessHealthProbe(baseUrl, authMode, token) { const nativeAt = authMode === 'oauth' ? await ensureNativeAccessToken(baseUrl).catch(() => null) : null const probeAuth = resolveReadinessProbeAuth(authMode, nativeAt, token) if (probeAuth.kind === 'bearer') { return { // fetchJson takes the bearer via `options.bearer` — a raw `headers` // option is ignored, so passing one here would silently probe // uncredentialed and reintroduce the 401 loop. probeHealth: (url, options: any = {}) => fetchJson(url, null, { ...options, bearer: probeAuth.token }), probeIsCredentialed: true } } if (probeAuth.kind === 'cookie') { return { probeHealth: (url, options: any = {}) => fetchJsonViaOauthSession(url, options), probeIsCredentialed: true } } if (probeAuth.kind === 'token' && probeAuth.token) { return { probeHealth: (url, options: any = {}) => fetchJson(url, probeAuth.token, options), probeIsCredentialed: true } } return { probeHealth: fetchPublicJson, probeIsCredentialed: false } } async function waitForHermes(baseUrl, token, signal?, authMode?, headers = {}) { const { probeHealth, probeIsCredentialed } = await buildReadinessHealthProbe(baseUrl, authMode, token) return waitForHermesReady(baseUrl, { token, signal, fetchPublicJson, fetchJson: probeIsCredentialed ? (url, _token, options = {}) => probeHealth(url, requestOptionsWithHeaders(options, headers)) : fetchJson, probeHealth: (url, options = {}) => probeHealth(url, requestOptionsWithHeaders(options, headers)), probeIsCredentialed }) } function getWindowButtonPosition(win = mainWindow) { if (!IS_MAC) { return null } // Fullscreen hides the traffic lights — treat as no left-side controls so the // renderer drops the traffic-light dodge inset and Y nudge. if (win?.isFullScreen?.()) { return null } return win?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION } function getNativeOverlayWidth() { return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC }) } function getWindowState(win = mainWindow) { return { isFullscreen: Boolean(win?.isFullScreen?.()), isMinimized: Boolean(win?.isMinimized?.()), isVisible: Boolean(win?.isVisible?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition(win), darwinMajor: IS_MAC ? DARWIN_MAJOR : 0 } } function sendBackendExit(payload) { // Intentional soft re-home (gateway mode apply) kills the child on purpose — // don't surface the "backend stopped" error toast / boot-failure path. if (softRehomeInProgress) { return } if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:backend-exit', payload) } function sendClosePreviewRequested() { if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:close-preview-requested') } /** * Run a browser gesture on the guest page the user is actually in, if any. * * A `` guest is its own out-of-process webContents: pointer and focus * events inside the page never reach the host document, so NOTHING in the * renderer — not `document.activeElement`, not the layout tree's hover/focus * ladder — can see that the user is in there. Main can: Electron tracks the * focused webContents across processes, which is the definition of a runtime * fact it owns. * * Returns false when focus is in the app's own chrome, where the renderer is * the one that knows which pane is active. */ function commandFocusedGuest(command: 'back' | 'forward' | 'reload'): boolean { const focused = electronWebContents.getFocusedWebContents() if (!focused || focused.isDestroyed() || focused.getType() !== 'webview') { return false } const history = focused.navigationHistory if (command === 'reload') { focused.reload() } else if (command === 'back') { if (!history.canGoBack()) { return true } history.goBack() } else { if (!history.canGoForward()) { return true } history.goForward() } return true } /** * Ask the renderer to run a browser-navigation gesture on its focused preview * pane. `reload` also has an app-level fallback (reload the window); `back` and * `forward` mean nothing outside the browser, so the renderer just ignores them. */ function sendPreviewNavCommand(command: 'back' | 'forward' | 'reload') { // The user is inside the page itself — main is the only party that can see // that, so act here and never round-trip. if (commandFocusedGuest(command)) { return } if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:preview-nav', command) } /** * The native back/forward gestures, which never reach the renderer on their own. * * - macOS: a two/three-finger swipe. Chromium's own overscroll navigation is * off in an Electron window, so the OS gesture surfaces as this event and * nothing consumes it. Requires "Swipe between pages" in System Settings. * - Windows/Linux: the dedicated back/forward buttons on a mouse, delivered as * `WM_APPCOMMAND`. */ function installBrowserNavGestures(window) { window.on('swipe', (_event, direction) => { if (direction === 'left' || direction === 'right') { // Swipe LEFT moves the page left, revealing what's behind it — that's // back. Matches Safari, Chrome, and Finder. sendPreviewNavCommand(direction === 'left' ? 'back' : 'forward') } }) window.on('app-command', (event, command) => { if (command !== 'browser-backward' && command !== 'browser-forward') { return } // Claim it either way: unhandled, Chromium walks the HOST document's // history, which would navigate the app shell itself. event.preventDefault() sendPreviewNavCommand(command === 'browser-backward' ? 'back' : 'forward') }) } function sendOpenFolderRequested() { if (!mainWindow || mainWindow.isDestroyed()) { return } const webContents = mainWindow.webContents if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:open-folder-requested') } // Tell the renderer the machine just woke. Sleep silently drops the // renderer's WebSocket to the local backend; the renderer reconnects on this // signal so the chat composer doesn't stay stuck on "Starting Hermes...". function sendPowerResume() { if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:power-resume') } let powerResumeRegistered = false // Mirror of powerMonitor's AC/battery state, broadcast to every window so // renderer backstop polls can slow down on battery (see store/power.ts). // `null` until the first powerMonitor read after app ready. let onBatteryPower: boolean | null = null // Renderer-side battery gating seeds from this and stays current via the // 'hermes:power-battery' push below. ipcMain.handle('hermes:power-battery:get', () => onBatteryPower === true) function broadcastBatteryState(next: boolean) { if (onBatteryPower === next) { return } onBatteryPower = next for (const win of BrowserWindow.getAllWindows()) { const { webContents } = win if (webContents && !webContents.isDestroyed()) { webContents.send('hermes:power-battery', next) } } } function registerPowerResumeListeners() { if (powerResumeRegistered) { return } powerResumeRegistered = true try { // 'resume' covers sleep/wake; 'unlock-screen' covers lock/unlock without a // full suspend. Either can drop an idle socket. powerMonitor.on('resume', sendPowerResume) powerMonitor.on('unlock-screen', sendPowerResume) powerMonitor.on('on-battery', () => broadcastBatteryState(true)) powerMonitor.on('on-ac', () => broadcastBatteryState(false)) onBatteryPower = powerMonitor.isOnBatteryPower() // Pooled remote/SSH backends are also suspect after a wake (#93910): the // renderer nudge above only re-drives the PRIMARY socket, while pooled // tunnels have no renderer loop of their own. Bounded + coalesced inside; // never a hot loop. attachPowerResumeRemoteRevalidation({ log: rememberLog, powerMonitor, revalidate: () => revalidateSuspectPoolAfterResume() }) } catch { // powerMonitor is unavailable before app 'ready' on some platforms; the // caller registers after 'ready', so this should not normally throw. } } function getAppIconPath() { // Fail-soft: skip candidates that exist but don't decode (truncated PNG in a // packaged app.asar previously crashed createWindow mid-session). Missing // every candidate is fine — the window then uses the platform default icon. try { return resolveAppIcon(APP_ICON_PATHS) } catch { return undefined } } function sendOpenUpdatesRequested() { if (!mainWindow || mainWindow.isDestroyed()) { return } const { webContents } = mainWindow if (!webContents || webContents.isDestroyed()) { return } webContents.send('hermes:open-updates') if (!mainWindow.isVisible()) { mainWindow.show() } mainWindow.focus() } // Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the // primary, but any full chat window (primary or a secondary "instance" peer) // passes itself so its own fullscreen toggle drives its own traffic-light inset. function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) { if (!target || target.isDestroyed()) { return } const { webContents } = target if (!webContents || webContents.isDestroyed()) { return } const state = getWindowState(target) if (typeof nextIsFullscreen === 'boolean') { state.isFullscreen = nextIsFullscreen } webContents.send('hermes:window-state-changed', state) } function buildApplicationMenu() { const template = [] const checkForUpdatesItem = { label: 'AITURK IDE güncellemeleri…', click: () => IS_PACKAGED ? void shell.openExternal(AITURK_PRODUCT.downloads) : sendOpenUpdatesRequested() } if (IS_MAC) { template.push({ label: APP_NAME, submenu: [ { label: `About ${APP_NAME}`, click: () => showAboutPanelFresh() }, checkForUpdatesItem, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] }) } template.push({ label: 'File', submenu: [ // No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow); // a menu accelerator would fight the rebind panel and (on macOS) be // swallowed before the renderer sees it. Here purely for discoverability. { click: () => createInstanceWindow(), label: 'New Window' }, // Same no-accelerator rationale: ⌘O is the rebindable renderer keybind // (workspace.openFolder). Clicking runs the same open-folder-as-project // flow through the renderer. { click: () => sendOpenFolderRequested(), label: 'Open Folder…' }, { type: 'separator' }, IS_MAC ? { // NO accelerator: on macOS a registered ⌘W is consumed by the OS // menu before the web contents ever sees it (and registerAccelerator // false is a no-op on mac — electron#18295). Leaving it off lets the // `before-input-event` handler below intercept ⌘W and route it to the // renderer's close-active-tab. Clicking the item still closes the tab // (or window) via the same request. click: () => sendClosePreviewRequested(), label: 'Close' } : { role: 'quit' } ] }) template.push({ label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, // ⌘⇧V is only wired up by this item existing: an accelerator with no menu // entry is never translated into an editor command, so the chord was a // no-op in every input in the app. The composer inserts plain text on // every paste anyway, so this is the same result as ⌘V there — it's the // terminal, preview, and other editable surfaces that need the strip. { role: 'pasteAndMatchStyle' }, { role: 'delete' }, { role: 'selectAll' } ] }) template.push({ label: 'View', submenu: [ // Not `role: 'reload'`: that hard-reloads the RENDERER (every pane, the // whole shell) and a focused in-app browser needs ⌘R to mean "reload // this page", the way it does in every other browser. ⇧⌘R // (`forceReload`) below stays the unconditional escape hatch. // // No accelerator: ⌘R is claimed in `installPreviewShortcut`, which works // on every platform (this menu exists only on macOS). Declaring it here // too would fire the item and the input hook for one keypress. { click: () => sendPreviewNavCommand('reload'), label: 'Reload' }, { role: 'forceReload' }, { label: 'Toggle Developer Tools', accelerator: process.platform === 'darwin' ? 'Alt+Cmd+I' : 'Ctrl+Shift+I', click: (_menuItem, browserWindow) => toggleDevTools(browserWindow || mainWindow) }, { type: 'separator' }, { label: 'Actual Size', accelerator: 'CommandOrControl+0', click: () => { setAndPersistZoomLevel(mainWindow, DEFAULT_ZOOM_LEVEL) } }, { label: 'Zoom In', accelerator: 'CommandOrControl+Plus', click: () => { if (mainWindow && !mainWindow.isDestroyed()) { setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + ZOOM_STEP) } } }, { label: 'Zoom Out', accelerator: 'CommandOrControl+-', click: () => { if (mainWindow && !mainWindow.isDestroyed()) { setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - ZOOM_STEP) } } }, { type: 'separator' }, { role: 'togglefullscreen' } ] }) template.push({ label: 'Window', submenu: IS_MAC ? [{ role: 'minimize' }, { role: 'zoom' }, { role: 'front' }] : [{ role: 'minimize' }, { role: 'close' }] }) template.push({ label: 'Help', role: 'help', submenu: [checkForUpdatesItem] }) return Menu.buildFromTemplate(template) } function toggleDevTools(window) { // DevTools is enabled in packaged builds so users can diagnose renderer // issues without needing a dev build. Trade-off: tiny attack surface // increase versus a much better support story when WS connection or // CSP issues surface in the field. const { webContents } = window if (webContents.isDevToolsOpened()) { webContents.closeDevTools() } else { webContents.openDevTools({ mode: 'detach' }) } } function installDevToolsShortcut(window) { // Only Ctrl+Shift+I (or Cmd+Opt+I on Mac) opens DevTools. // F12 is explicitly blocked so Chromium's built-in handler doesn't open it. window.webContents.on('before-input-event', (event, input) => { const key = input.key.toLowerCase() // F12 opens DevTools by default; block only when the user disabled it. if (input.key === 'F12') { if (f12Blocked) { event.preventDefault() return } // Not blocked — fall through to open DevTools. } const isInspectShortcut = input.key === 'F12' || (IS_MAC && input.meta && input.alt && key === 'i') || (!IS_MAC && input.control && input.shift && key === 'i') if (!isInspectShortcut) { return } event.preventDefault() toggleDevTools(window) }) } function installPreviewShortcut(window) { window.webContents.on('before-input-event', (event, input) => { const key = String(input.key || '').toLowerCase() const accel = (IS_MAC ? input.meta : input.control) && !input.alt const isCloseTabShortcut = key === 'w' && accel && !input.shift // Always claim ⌘W here (the File>Close item deliberately has no // accelerator, so nothing else does). The renderer decides tab-vs-window // — no `previewShortcutActive` gate, so it works for every closeable tab. if (isCloseTabShortcut) { event.preventDefault() sendClosePreviewRequested() return } // ⌘R rides here rather than on the View menu item for the same reason: // the application menu only exists on macOS (it is set to null elsewhere, // see #77845), so a menu accelerator would leave Windows and Linux with no // way to reload a page at all. ⇧⌘R is left alone — that is `forceReload`, // the unconditional whole-window escape hatch. if (key === 'r' && accel && !input.shift) { event.preventDefault() sendPreviewNavCommand('reload') } }) } // Zoom level is persisted in the renderer's own localStorage (per-origin, // survives reloads/restarts) rather than a main-process JSON file. The main // process owns setZoomLevel, so we mirror each change into localStorage and // read it back on did-finish-load to re-apply after reloads or crash recovery. import { applyZoomLevel, DEFAULT_ZOOM_LEVEL, installZoomReassertOnNavigation, installZoomReassertOnWindowEvents, percentToZoomLevel, ZOOM_STEP, ZOOM_STORAGE_KEY, zoomLevelToPercent, zoomWiringForWindowKind } from './zoom' function setAndPersistZoomLevel(window, zoomLevel) { if (!window || window.isDestroyed()) { return } // Apply + notify in one funnel so the settings UI stays in sync, including // changes made via the keyboard shortcuts or the View menu. const next = applyZoomLevel(window.webContents, zoomLevel) // Primary store: main-process JSON (survives crash recovery — #56726). writeZoomState(next) // Secondary mirror: renderer localStorage (legacy store; kept in sync so a // downgrade or JSON read failure still finds a sane value). window.webContents .executeJavaScript( `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch { void 0 }` ) .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) } function restorePersistedZoomLevel(window) { if (!window || window.isDestroyed()) { return } // Prefer the JSON file — it survives crash recovery wiping Electron's // cache/storage folders (#56726). applyZoomLevel notifies the renderer so // the Appearance UI Scale control stays in sync. const saved = readZoomState() if (saved != null) { // Drift-guard: skip when this window already shows the persisted level. // Blindly re-applying on every resize/move would race the compositor's // surface reconfigure during a Wayland resize storm (Cosmic tiled mode // fires one whenever a new session window opens — #84818) and keep the // renderer notification stream churning for no gain. The settle-verify // chain in installZoomReassertOnWindowEvents re-applies only when the // window actually drifted from the persisted level. const current = window.webContents?.getZoomLevel?.() if (current != null && Math.abs(current - saved) < 1e-9) { return } applyZoomLevel(window.webContents, saved) return } // No JSON yet: paint the shipped default immediately so a fresh install // doesn't flash Chromium 100%, then try localStorage for pre-JSON installs // and overwrite if a legacy value is there. applyZoomLevel(window.webContents, DEFAULT_ZOOM_LEVEL) window.webContents .executeJavaScript( `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` ) .then(stored => { if (!window || window.isDestroyed()) { return } const level = stored == null ? DEFAULT_ZOOM_LEVEL : Number(stored) const applied = applyZoomLevel(window.webContents, level) writeZoomState(applied) }) .catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`)) } function installZoomShortcuts(window) { // Override Ctrl/Cmd + +/-/0 with half Chromium's default zoom step (ZOOM_STEP // is 0.1 vs Chromium's 0.2). The menu items handle this on macOS (where the // menu is always present), but on Linux/Windows the menu is null and // Chromium's default handler would use the full 0.2 step, so we intercept // here for consistency. Ctrl/Cmd+0 resets to DEFAULT_ZOOM_LEVEL, not Chromium 0. window.webContents.on('before-input-event', (event, input) => { const mod = IS_MAC ? input.meta : input.control if (!mod || input.alt) { return } const key = input.key if (key === '0') { if (input.shift) { return // Ctrl/Cmd+Shift+0 is not a zoom chord — leave it alone } event.preventDefault() setAndPersistZoomLevel(window, DEFAULT_ZOOM_LEVEL) } else if (key === '=' || key === '+') { // Zoom-in must accept the shift modifier: on US layouts Plus is // physically Shift+=, so Cmd+Plus arrives as Cmd+Shift+'+' (or '=' // depending on platform). The old blanket shift guard silently // dropped keyboard zoom-in on macOS (#43517). event.preventDefault() setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP) } else if (key === '-') { if (input.shift) { return // Shift+'-' is '_' territory on most layouts, not zoom-out } event.preventDefault() setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP) } }) // Ctrl/Cmd + mouse wheel — the standard desktop/browser zoom gesture // (#40295). Chromium surfaces it as the main-process 'zoom-changed' event // (wheel events are DOM-side, so before-input-event never sees them). // Route through the same persist+notify funnel as the keyboard shortcuts // so wheel zoom survives restarts and the settings Scale control stays in // sync, and use the same half step for consistency. window.webContents.on('zoom-changed', (event, zoomDirection) => { event.preventDefault() const delta = zoomDirection === 'in' ? ZOOM_STEP : -ZOOM_STEP setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + delta) }) } /** * The custom (renderer) context menu's main-process half. * * The app popups no native menus: the renderer owns the menu UI so labels * are translated with the rest of the app. Main keeps only what Chromium * reports here and the renderer cannot see: * - spell-check facts (misspelled word + suggestions) — forwarded so the * renderer appends them to its already-open menu, * - the gesture coordinates — kept for copyImageAt, which needs them. */ const lastContextMenuPoint = new Map() function installContextMenuBridge(window: BrowserWindow) { window.webContents.on('context-menu', (_event, params) => { lastContextMenuPoint.set(window.webContents.id, { x: params.x, y: params.y }) const suggestions = Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [] if (params.isEditable && params.misspelledWord) { window.webContents.send('hermes:context-menu-spellcheck', { misspelledWord: params.misspelledWord, suggestions }) } }) } // Microphone and camera capture. The voice composer drives mic access and // renderer features (e.g. desktop plugins) can drive camera access, both // through getUserMedia, which Chromium gates behind these two session hooks. // // The naive `details.mediaTypes.includes('audio')` check works on macOS but // breaks on Windows: Chromium frequently fires the request with an empty or // undefined `mediaTypes`, so a strict check denies it and getUserMedia throws // NotAllowedError. We therefore allow the capture permissions and treat absent // metadata as allowed. // // Granting here is not the last gate: the OS still applies its own capture // permission (macOS TCC prompts on first use, per the NSMicrophone/NSCamera // usage strings), so the user keeps a real allow/deny and can revoke it in // System Settings afterwards. function isMediaCapturePermission(permission, details) { if (permission === 'audioCapture' || permission === 'videoCapture') { return true } if (permission !== 'media') { return false } const mediaTypes = details?.mediaTypes // Windows: mediaTypes is often empty for a capture request. Don't deny on // missing metadata. if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) { return true } return mediaTypes.includes('audio') || mediaTypes.includes('video') } // Chromium-initiated downloads (renderer anchor/blob downloads, drag-outs) // land here. Without a handler the OS save dialog opens with the process cwd // as the default directory (win-unpacked in packaged installs) and whatever // extensionless name the anchor carried. Route every download to the user's // Downloads directory and guarantee a MIME-derived extension. function installDownloadHandling() { session.defaultSession.on('will-download', (_event, item) => { const suggested = item.getFilename() || 'download' const hasExtension = Boolean(path.extname(suggested)) const extension = hasExtension ? '' : extensionForMimeType(item.getMimeType()) const filename = `${suggested}${extension}` try { item.setSaveDialogOptions({ title: 'Save File', defaultPath: path.join(app.getPath('downloads'), filename), filters: extension || /^image\//i.test(item.getMimeType() || '') ? [ { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, { name: 'All Files', extensions: ['*'] } ] : undefined }) } catch { // No Downloads directory to offer — keep Chromium's default prompt. } }) } function installMediaPermissions() { // Async request handler: the prompt-style path (most platforms). session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { callback(isMediaCapturePermission(permission, details)) }) // Synchronous check handler: Chromium consults this for getUserMedia on // Windows in addition to (or instead of) the request handler. Without it, // the check defaults to false and capture is denied before the request // handler ever runs. session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { return ( permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */ || permission === ('videoCapture' as any) ) }) } // --------------------------------------------------------------------------- // OAuth remote-gateway auth. // // Hosted Hermes gateways gate the dashboard behind an OAuth provider (e.g. // Nous Research) instead of a static session token. The auth model is // fundamentally different from the token path: // // * REST is authed by HttpOnly session cookies (``hermes_session_at``), // established by a browser redirect round-trip (/login → IDP → // /auth/callback sets cookies). We cannot read the HttpOnly cookie value // in JS — instead we let an Electron BrowserWindow complete the round // trip into a PERSISTENT session partition, and thereafter route our REST // through Electron's ``net`` bound to that same partition so the cookie // jar attaches the cookie automatically. // * WebSocket upgrades require a single-use ``?ticket=`` minted at // ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=`` // path is unconditionally rejected by gated gateways. // * Nous Portal now issues a 24h ROTATING, reuse-detected refresh token // alongside the ~15-min access token (Portal NAS #293 / hermes #37247). // Both are set as HttpOnly cookies (``hermes_session_at`` ~15 min, // ``hermes_session_rt`` 24h). When the AT cookie lapses but the RT cookie // is still alive, the gateway middleware transparently rotates a fresh AT // on the next authenticated request — so connectivity must NOT be gated on // the AT cookie alone. We probe liveness by actually minting a ws-ticket // (which triggers that server-side refresh) and treat a real 401 as // "needs re-login"; the AT-or-RT cookie presence check is only a cheap // "is the user signed in at all?" gate / display signal. // --------------------------------------------------------------------------- const OAUTH_SESSION_PARTITION = LEGACY_OAUTH_PARTITION function getOauthSession() { if (oauthSession || !app.isReady()) { return oauthSession } oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION) return oauthSession } // Per-connection cookie jars (#92183). A NON-primary v2 registry remote with // cookie auth rides its own partition so two registered gateways can never // evict — or be handed — each other's session cookies (Chromium jars ignore // the port, so two dashboards on one VPN host used to collide in the shared // jar above). The primary / v1 remote / cloud / portal flows keep the legacy // shared partition; see oauth-partition.ts for the full rules. const oauthSessionsByPartition = new Map() function resolveOauthPartitionForUrl(url) { try { return resolveOauthPartition(url, { registry: readDesktopConnectionsRegistry(), v1RemoteUrl: readDesktopConnectionConfig()?.remote?.url }) } catch { // A broken registry read must never take cookie auth down with it. return OAUTH_SESSION_PARTITION } } function getOauthSessionForUrl(url) { const partition = resolveOauthPartitionForUrl(url) if (partition === OAUTH_SESSION_PARTITION) { return getOauthSession() } if (!app.isReady()) { return null } let sess = oauthSessionsByPartition.get(partition) if (!sess) { sess = session.fromPartition(partition) oauthSessionsByPartition.set(partition, sess) } return sess } // Cold-start cookie-jar warm-up. A `persist:` partition materialized via // session.fromPartition() loads its on-disk cookie store LAZILY: the very first // cookies.get() on a fresh cold start can resolve BEFORE the jar has finished // hydrating from disk and return an empty array — even though the user is // signed in. That false-negative used to make hasLiveOauthSession() report // "not signed in", which on the initial boot path (startHermes → the renderer's // single-shot boot() with no retry) surfaced as the "Hermes couldn't start" // OAuth overlay that vanishes the instant the user clicks Retry. // // We force the store to hydrate once, up front: flushStorageData() then a // throwaway cookies.get(). The promise is memoized so every caller awaits the // same single warm-up. Best-effort — any error resolves so we fall back to the // live read (which then does its own bounded re-check). // Memoized per PARTITION: per-connection jars (#92183) hydrate independently. const oauthCookieWarmups = new Map() function warmOauthCookieStore(url?) { const partition = resolveOauthPartitionForUrl(url) const pending = oauthCookieWarmups.get(partition) if (pending) { return pending } const warmup = (async () => { const sess = getOauthSessionForUrl(url) if (!sess) { // App not ready yet — don't memoize a no-op; let a later call retry. oauthCookieWarmups.delete(partition) return } try { // flushStorageData() forces Chromium to reconcile the in-memory cookie // monster with the on-disk SQLite store; the subsequent get() then reads // a populated jar rather than racing the lazy first-access load. sess.flushStorageData?.() await sess.cookies.get({}) } catch { // Best effort; the real read below re-checks with bounded retries. } })() oauthCookieWarmups.set(partition, warmup) return warmup } // Bare + prefixed variants of the session cookies live in // connection-config.ts (cookiesHaveSession / cookiesHaveLiveSession). See // that module for details. async function hasOauthSessionCookie(baseUrl) { const sess = getOauthSessionForUrl(baseUrl) if (!sess) { return false } const parsed = new URL(baseUrl) try { // Query by URL so the cookie jar applies Domain/Path/Secure scoping for us. const cookies = await sess.cookies.get({ url: baseUrl }) return cookiesHaveSession(cookies) } catch { // Fall back to a host match if the URL query path errors. try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) return cookiesHaveSession(cookies) } catch { return false } } } // Like hasOauthSessionCookie, but returns true when EITHER a live access-token // cookie OR a (longer-lived) refresh-token cookie is present. This is the right // "is the user signed in at all?" check: an expired AT with a live RT is still // a connectable session because the gateway rotates a fresh AT server-side on // the next authenticated request. Gating on the AT alone forces a needless full // re-login every ~15 min. Used for the Settings "connected" indicator and as a // cheap early-out before attempting a network round-trip in resolveRemoteBackend. async function hasLiveOauthSession(baseUrl) { const sess = getOauthSessionForUrl(baseUrl) if (!sess) { return false } const parsed = new URL(baseUrl) const readLive = async () => { try { const cookies = await sess.cookies.get({ url: baseUrl }) return cookiesHaveLiveSession(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) return cookiesHaveLiveSession(cookies) } catch { return false } } } // First read against the (possibly still-hydrating) jar. if (await readLive()) { return true } // Cold-start false-negative guard. A `persist:` partition's cookie store // loads lazily, so the FIRST read on a fresh boot can come back empty even // for a signed-in user — the exact race that produced the transient "Hermes // couldn't start / not signed in" overlay that Retry always cleared. Before // trusting a negative, force the store to hydrate and re-read a couple of // times with a short backoff. A genuinely signed-out user still resolves // false quickly (≤ ~180ms); a signed-in user racing the load now wins. await warmOauthCookieStore(baseUrl) for (const delayMs of [30, 60, 90]) { if (await readLive()) { return true } await new Promise(resolve => setTimeout(resolve, delayMs)) } return readLive() } async function clearOauthSession(baseUrl) { const sess = getOauthSessionForUrl(baseUrl) if (!sess) { return } try { const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {}) await Promise.all( cookies.map(c => { const scheme = c.secure ? 'https' : 'http' const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}` return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined) }) ) } catch { // Best effort — a stale cookie self-expires anyway. } } // Open a gateway login window in the OAuth session partition, resolving once // the access-token cookie appears (login done) or rejecting if the user closes // the window first. The window navigates through the IDP and back to // /auth/callback, which sets the session cookies on the partition; we poll the // cookie jar rather than try to read the HttpOnly value. // // `silent` selects the URL the window loads, which decides interactive-vs-silent: // - silent=false (default): load ``/login`` — the public interstitial that // renders the "Log in with X" provider chooser. This is the interactive // remote-gateway login the settings UI drives. // - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public // route, so loading it NEVER triggers the gate's auto-SSO and always shows // the chooser. Loading a protected page with no session cookie makes the // gate run ``_auto_sso_response``: single registered provider + a live // portal session in this partition → a silent 302 through // ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members) // → ``/auth/callback``, which sets the gateway cookie with NO interactive // prompt. This is the per-agent cloud cascade (decisions.md Q5). function openOauthLoginWindow(baseUrl, { silent = false } = {}) { return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start an OAuth login.')) return } const sess = getOauthSessionForUrl(baseUrl) if (!sess) { reject(new Error('OAuth session partition is unavailable.')) return } let settled = false let win = null let pollTimer = null let revealTimer = null const finish = err => { if (settled) { return } settled = true if (pollTimer) { clearInterval(pollTimer) } if (revealTimer) { clearTimeout(revealTimer) } try { if (win && !win.isDestroyed()) { win.destroy() } } catch { // window already torn down } if (err) { reject(err) } else { resolve({ baseUrl, ok: true }) } } const checkCookie = async () => { if (settled) { return } if (await hasOauthSessionCookie(baseUrl)) { finish(null) } } try { win = new BrowserWindow({ width: 520, height: 720, title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway', autoHideMenuBar: true, // Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in // well under a second, so the window normally never needs to show. We // only reveal it as a fallback if the cascade DOESN'T complete quickly // (e.g. the portal session lapsed and the gate fell through to the // interactive chooser) — see the reveal timer below. show: !silent, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, session: sess, webSecurity: true } }) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) return } // Re-check the cookie jar on every successful navigation (the callback // redirect is the moment cookies get set) plus a low-frequency poll as a // belt-and-braces fallback for IDPs that finish via in-page JS. win.webContents.on('did-navigate', () => void checkCookie()) win.webContents.on('did-redirect-navigation', () => void checkCookie()) win.webContents.on('did-frame-navigate', () => void checkCookie()) // Log-only lifecycle diagnostics: a crashed sign-in renderer is invisible // to the window's promise path (it never settles), so without this the // failure leaves no trace in desktop.log (#81290 follow-up). installWindowRendererLifecycle(win, { kind: 'oauth', callbacks: { log: rememberLog } }) pollTimer = setInterval(() => void checkCookie(), 750) // Silent-mode reveal fallback: if the cascade hasn't settled shortly, the // auto-SSO didn't go through silently (no portal session, multi-provider, // loop-guard tripped, etc.) and the window is now showing an interactive // page. Reveal it so the user can complete sign-in manually rather than // staring at nothing. Cleared on finish(). if (silent && win) { revealTimer = setTimeout(() => { try { if (!settled && win && !win.isDestroyed() && !win.isVisible()) { win.show() } } catch { // window torn down } }, 2500) } win.on('closed', () => { if (!settled) { finish(new Error('Login window closed before authentication completed.')) } }) // ``next`` is intentionally omitted: the gateway lands on ``/`` after // login, which is a valid authenticated page that sets the cookies. We // only care that the cookie jar is populated. // // silent=true loads the protected root so the gate auto-SSOs (no chooser); // silent=false loads the public ``/login`` chooser for interactive sign-in. const normalizedBase = normalizeRemoteBaseUrl(baseUrl) const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login` win.loadURL(loginUrl).catch(error => { finish(error instanceof Error ? error : new Error(String(error))) }) }) } // JSON request routed through the OAuth session partition so the HttpOnly // session cookie is attached automatically by Electron's net stack. Used for // authed REST against a gated gateway, including minting WS tickets. function fetchJsonViaOauthSession(url, options: any = {}) { return new Promise((resolve, reject) => { const sess = getOauthSessionForUrl(url) if (!sess) { reject(new Error('OAuth session partition is unavailable.')) return } let parsed try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) return } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) return } const body = serializeJsonBody(options.body) const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const request = electronNet.request({ method: options.method || 'GET', url, session: sess, useSessionCookies: true, redirect: 'follow' } as any) setJsonRequestHeaders(request) for (const [name, value] of Object.entries({ ...headersForRemoteRequest(url), ...(options.headers || {}) })) { request.setHeader(name, String(value)) } let timedOut = false const timer = setTimeout(() => { timedOut = true try { request.abort() } catch { // already finished } reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }, timeoutMs) request.on('response', res => { const chunks = [] res.on('data', chunk => chunks.push(Buffer.from(chunk))) res.on('end', () => { if (timedOut) { return } clearTimeout(timer) const text = Buffer.concat(chunks).toString('utf8') const statusCode = res.statusCode || 500 if (statusCode >= 400) { const err = new Error(`${statusCode}: ${text || ''}`) as any err.statusCode = statusCode reject(err) return } if (!text) { resolve(null) return } const looksHtml = /^\s*<(?:!doctype|html)/i.test(text) const contentType = String(res.headers['content-type'] || res.headers['Content-Type'] || '') if (looksHtml || contentType.includes('text/html')) { reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`)) return } try { resolve(JSON.parse(text)) } catch { reject(new Error(`Invalid JSON from ${url} (status ${statusCode}): ${text.slice(0, 200)}`)) } }) }) request.on('error', error => { if (timedOut) { return } clearTimeout(timer) reject(error) }) if (body) { request.write(body) } request.end() }) } // --------------------------------------------------------------------------- // RFC 8252 native-app tokens (system-browser + loopback + PKCE). // // Unlike the cookie flow, the native flow hands the desktop opaque bearer // tokens it holds itself: the access token authenticates REST via // ``Authorization: Bearer`` (which the gateway gate now accepts) and mints WS // tickets the same way, so NO browser session cookie or embedded webview is // involved. Tokens are persisted encrypted at rest via Electron ``safeStorage`` // (OS keychain) keyed by gateway base URL, and refreshed via // ``/auth/native/refresh`` before expiry. This is the desktop half of the // feature; the server half lives in hermes_cli/dashboard_auth/native_flow.py. // --------------------------------------------------------------------------- // In-memory cache of decrypted native tokens, keyed by normalized base URL. // Backed by the encrypted on-disk store so it survives restarts. const _nativeTokens = new Map() function _nativeTokenStorePath() { // Co-located with the connection config under userData; one JSON file mapping // baseUrl → { encoding, value } safeStorage payloads. return path.join(app.getPath('userData'), 'native-oauth-tokens.json') } // The electron-coupled half of the token store: safeStorage encryption plus the // userData file. native-token-store.ts owns the serialization/parse round trip // so it can be tested without an Electron runtime. function _nativeTokenStoreIo(): NativeTokenStoreIo { return { encrypt: encryptDesktopSecret, decrypt: decryptDesktopSecret, readStoreText: () => fs.readFileSync(_nativeTokenStorePath(), 'utf8'), writeStoreText: (text: string) => { fs.mkdirSync(path.dirname(_nativeTokenStorePath()), { recursive: true }) fs.writeFileSync(_nativeTokenStorePath(), text, { mode: 0o600 }) }, rememberLog } } function _persistNativeTokens(baseUrl: string, tokens: NativeTokenSet | null) { persistNativeTokenSet(baseUrl, tokens, _nativeTokenStoreIo()) } function _loadNativeTokens(baseUrl: string): NativeTokenSet | null { const cached = _nativeTokens.get(baseUrl) if (cached) { return cached } const tokens = loadNativeTokenSet(baseUrl, _nativeTokenStoreIo()) if (tokens) { _nativeTokens.set(baseUrl, tokens) } return tokens } function _storeNativeTokens(baseUrl: string, tokens: NativeTokenSet) { _nativeTokens.set(baseUrl, tokens) _persistNativeTokens(baseUrl, tokens) } function _clearNativeTokens(baseUrl: string) { _nativeTokens.delete(baseUrl) _persistNativeTokens(baseUrl, null) } // True when we hold native bearer tokens for this gateway (the native-flow // analogue of hasLiveOauthSession's cookie check). function hasNativeSession(baseUrl: string): boolean { return _loadNativeTokens(baseUrl) !== null } // POST JSON WITHOUT the OAuth cookie partition — used for the native token + // refresh exchanges, which are cookieless by design. Thin wrapper over // fetchJson (no token) so it shares timeout/JSON handling. function postJsonNoAuth(url: string, body: unknown, opts: any = {}) { // resolveJsonBody passes the object through UNCHANGED — fetchJson owns // JSON.stringify. Pre-stringifying here double-encodes the body (a JSON // string inside a JSON string), which the gateway's Pydantic model rejects // with a 422 "Input should be a valid dictionary" (the native // /auth/native/token + /auth/native/refresh legs both go through here). return fetchJson(url, null, { method: 'POST', body: resolveJsonBody(body), ...opts }) } // Return a valid native access token for baseUrl, refreshing via // /auth/native/refresh if the stored one is at/near expiry. Returns null when // there are no tokens or the refresh is terminally rejected (caller re-logins). async function ensureNativeAccessToken(baseUrl: string): Promise { const tokens = _loadNativeTokens(baseUrl) if (!tokens) { return null } if (!tokenNeedsRefresh(tokens, Math.floor(Date.now() / 1000))) { return tokens.accessToken } if (!tokens.refreshToken) { // Access token expired and no RT to rotate — force re-login. _clearNativeTokens(baseUrl) return null } try { const body = await postJsonNoAuth( nativeRefreshUrl(baseUrl), { refresh_token: tokens.refreshToken, provider: tokens.provider }, { timeoutMs: 10_000 } ) const rotated = parseTokenResponse(body) _storeNativeTokens(baseUrl, rotated) return rotated.accessToken } catch (error: any) { // A 401 means the RT is dead (session_expired) — drop tokens so the UI // prompts a fresh native login. A 503/transient keeps them for a retry. if (error && error.statusCode === 401) { _clearNativeTokens(baseUrl) return null } throw error } } // OAuth-session download that streams the response body straight to a // user-selected destination (via finalizeGatewayDownload). The connect timeout // is cleared once the response headers arrive. function downloadViaOauthSessionToFile(url, ctx, options: any = {}) { return new Promise((resolve, reject) => { const sess = getOauthSessionForUrl(url) if (!sess) { reject(new Error('OAuth session partition is unavailable.')) return } let parsed try { parsed = new URL(url) } catch (error) { reject(new Error(`Invalid URL: ${error.message}`)) return } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`)) return } const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) const request = electronNet.request({ method: 'GET', url, session: sess, useSessionCookies: true, redirect: 'follow' } as any) let settled = false const timer = setTimeout(() => { if (settled) { return } settled = true try { request.abort() } catch { // already finished } reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)) }, timeoutMs) request.on('response', res => { if (settled) { return } // Response headers arrived — cancel the connect timeout so it can't abort // the stream while the save dialog is open or bytes are still flowing. settled = true clearTimeout(timer) finalizeGatewayDownload(res, res.statusCode || 500, res.headers || {}, { ...ctx, abort: () => { try { request.abort() } catch { // already finished } } }).then(resolve, reject) }) request.on('error', error => { if (settled) { return } settled = true clearTimeout(timer) reject(error) }) request.end() }) } // Shared tail for both transports: validate status, pick a filename, prompt the // save dialog, then stream the (still-unconsumed) response body to the chosen // destination. On an HTTP error the status code is attached so saveGatewayFile // can trigger the 404-only compatibility fallback. async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {}) { if (statusCode >= 400) { const message = await readGatewayErrorText(res) const error: any = new Error(`${statusCode}: ${message}`) error.statusCode = statusCode throw error } const disposition = headers['content-disposition'] || headers['Content-Disposition'] const filename = filenameFromContentDisposition(disposition) || ctx.suggested || ctx.fallbackName const result = await dialog.showSaveDialog(mainWindow, { defaultPath: filename, title: 'Save File' }) if (result.canceled || !result.filePath) { ctx.abort?.() return { canceled: true, saved: false } } try { // Failure-atomic: exclusive temp create beside the destination, rename into // place only once the body is complete (#96597). await pumpStreamToFile(res, result.filePath, fsPumpDeps()) } catch (error) { ctx.abort?.() throw error } return { path: result.filePath, saved: true } } // Read a bounded amount of an error response body for the thrown message. function readGatewayErrorText(res): Promise { return new Promise(resolve => { const chunks = [] let total = 0 res.on('data', chunk => { if (total >= 500) { return } const buffer = Buffer.from(chunk) total += buffer.length chunks.push(buffer) }) res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8').slice(0, 500))) res.on('error', () => resolve(Buffer.concat(chunks).toString('utf8').slice(0, 500))) }) } interface GatewayFileConnection extends RegistryBackendRequestScope { authMode?: 'oauth' | 'token' baseUrl: string token?: null | string } interface GatewayFileSaveContext { fallbackName: string suggested: string } interface GatewayFileSavePayload { connectionId?: unknown path?: unknown profile?: unknown suggestedName?: unknown } async function gatedFileAuth(connection: GatewayFileConnection) { const nativeAt = connection.authMode === 'oauth' ? await ensureNativeAccessToken(connection.baseUrl).catch(() => null) : null return resolveGatedDownloadAuth(connection.authMode, nativeAt, connection.token) } function gatewayFileRequestPath( connection: GatewayFileConnection, connectionId: null | string, profile: null | string, requestPath: string ) { return connectionId ? pathForRegistryBackendRequest(requestPath, profile, connection) : pathWithGlobalRemoteProfile(requestPath, profile, profileRouteOptions(profile)) } async function saveGatewayFile(payload: GatewayFileSavePayload = {}) { const filePath = gatewayFilePath(payload.path) if (!filePath) { throw new Error('Missing gateway file path') } const { connection, connectionId, profile } = await resolveGatewayFileBackend(payload, { ensureLegacy: ensureBackend, ensureRegistry: ensureRegistryBackend }) const suggested = String(payload.suggestedName || '').trim() const fallbackName = path.basename(filePath) || suggested || 'download' const ctx = { suggested, fallbackName } const requestPaths = gatewayFileRequestPaths(filePath, requestPath => gatewayFileRequestPath(connection, connectionId, profile, requestPath) ) const url = `${connection.baseUrl}${requestPaths.download}` try { const auth = await gatedFileAuth(connection) if (auth.kind === 'bearer') { return await downloadViaTokenToFile(url, auth.token, ctx, { bearer: auth.token }) } if (auth.kind === 'cookie') { return await downloadViaOauthSessionToFile(url, ctx) } return await downloadViaTokenToFile(url, auth.token, ctx) } catch (error) { // Desktop and the remote gateway update independently. A gateway predating // /api/fs/download 404s here; fall back (ONLY on 404) to the older capped // data-URL route so downloads keep working against older backends. if (isNotFoundError(error)) { return await saveGatewayFileViaDataUrl(connection, requestPaths.dataUrl, ctx) } throw error } } // Compatibility fallback: fetch the file through the capped // `/api/fs/read-data-url` route, decode it, and save. Bounded by the gateway's // data-URL cap, so it only serves smaller files — enough to keep older gateways // working until they gain the streaming route. async function saveGatewayFileViaDataUrl( connection: GatewayFileConnection, requestPath: string, ctx: GatewayFileSaveContext ) { const url = `${connection.baseUrl}${requestPath}` const auth = await gatedFileAuth(connection) let json: unknown if (auth.kind === 'bearer') { json = await fetchJson(url, null, { bearer: auth.token }) } else if (auth.kind === 'cookie') { json = await fetchJsonViaOauthSession(url) } else { json = await fetchJson(url, auth.token) } const dataUrl = json && typeof json === 'object' && 'dataUrl' in json && typeof json.dataUrl === 'string' ? json.dataUrl : '' if (!dataUrl) { throw new Error('Gateway returned no file data') } const buffer = parseDataUrlToBuffer(dataUrl) const filename = ctx.suggested || ctx.fallbackName const result = await dialog.showSaveDialog(mainWindow, { defaultPath: filename, title: 'Save File' }) if (result.canceled || !result.filePath) { return { canceled: true, saved: false } } // Same failure-atomic contract as the streaming path: a direct writeFile // truncates an existing destination before the write completes (#96597). await writeBufferToFile(buffer, result.filePath, fsPumpDeps()) return { path: result.filePath, saved: true } } // Mint a single-use WS ticket for a gated gateway. Returns the ticket string. // Prefers a native bearer token (cookieless RFC 8252 flow) when present, // falling back to the OAuth cookie partition otherwise. // Throws (with statusCode 401) if the session cookie is missing/expired — // callers treat that as "needs re-login". // Transient transport blips (brief host unreachable, 5xx, timeouts) are retried // a few times before failing — those 1-3s flaps were promoting into the // full-screen "couldn't start" lockout on reconnect. async function mintGatewayWsTicket(baseUrl, headers = {}) { return withTransientRetries(async () => { // Native flow: mint the ticket with the bearer token, no cookie involved. const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null) if (nativeAt) { const body = (await fetchJson(`${baseUrl}/api/auth/ws-ticket`, null, { method: 'POST', timeoutMs: 8_000, bearer: nativeAt, headers })) as any const ticket = body?.ticket if (!ticket || typeof ticket !== 'string') { throw new Error('Gateway did not return a WS ticket.') } return ticket } const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, { method: 'POST', timeoutMs: 8_000, headers })) as any const ticket = body?.ticket if (!ticket || typeof ticket !== 'string') { throw new Error('Gateway did not return a WS ticket.') } return ticket }) } // Build a fresh WS URL for the *current* connection. Critical for reconnects: // OAuth WS tickets are single-use with a ~30s TTL, so the ticket baked into // the cached connection's wsUrl is stale on the second connect. The renderer // calls this immediately before every gateway.connect() so each WS upgrade // carries a freshly-minted ticket. For local/token connections this just // reuses the static token (no minting needed). async function freshGatewayWsUrl(profile) { // Mint for the requested profile's backend, NOT always the primary. The // renderer re-mints right before every gateway.connect(); when swapping to a // pooled profile we must return THAT backend's ws URL, otherwise the connect // silently lands back on the primary (default) backend and writes sessions to // the wrong profile's DB. A null/empty profile resolves to the primary, so // legacy callers and single-profile users are unchanged. const connection = await ensureBackend(profile) if (connection.authMode === 'oauth') { const ticket = await mintGatewayWsTicket(connection.baseUrl, connection.headers) const wsUrl = buildGatewayWsUrlWithTicket(connection.baseUrl, ticket) rememberRemoteWsHeaders(wsUrl, connection.headers) return wsUrl } // Local/token: the cached wsUrl already carries the (long-lived) token. rememberRemoteWsHeaders(connection.wsUrl, connection.headers) return connection.wsUrl } // --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery // Phase 3) --------------------------------------------------------------- // // The "cloud" connection mode lets a user sign in to the Nous portal ONCE in // the OAuth session partition, then (a) discover their hosted agents and (b) // connect to any of them with no second interactive sign-in. Both ride the one // portal session cookie living in `persist:hermes-remote-oauth`: // - discovery → GET {portal}/api/agents over the partition-bound net; the // portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie). // - cascade → opening an agent's own /login in the same partition hits the // portal's silent auto-approve (org member, existing session) and 302s back // with that agent's session cookie — no prompt. Each agent still completes // its own PKCE exchange; SSO removes the human click, not a security check. // Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI // convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names) // so a single override flips every Hermes surface to the same portal. const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com' function resolvePortalBaseUrl() { const raw = process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL return String(raw).trim().replace(/\/+$/, '') } // Whether the OAuth partition currently holds a live Nous portal session — the // credential that powers both discovery and the silent cascade. The portal // authenticates via PRIVY, not the Hermes gateway session cookies, so this // checks for the `privy-token` cookie on the portal host (NOT // hasLiveOauthSession, which looks for hermes_session_at/rt that the portal // never sets). See connection-config.ts cookiesHavePrivySession. // // Mirrors hasLiveOauthSession's cold-start guard (#73495): a `persist:` // partition's cookie store hydrates lazily, so the FIRST read on a fresh boot // can come back empty even for a signed-in user. The renderer checks Cloud // status exactly once on entering cloud mode, so a single false-negative here // used to clear the discovered agent list and demand a re-login that a plain // retry would have avoided. Warm the store and re-read with a short backoff // before trusting a negative. async function hasLivePortalSession() { const sess = getOauthSession() if (!sess) { return false } const portalBaseUrl = resolvePortalBaseUrl() const parsed = new URL(portalBaseUrl) const readPortal = async () => { try { const cookies = await sess.cookies.get({ url: portalBaseUrl }) return cookiesHavePrivySession(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) return cookiesHavePrivySession(cookies) } catch { return false } } } if (await readPortal()) { return true } await warmOauthCookieStore() for (const delayMs of [30, 60, 90]) { if (await readPortal()) { return true } await new Promise(resolve => setTimeout(resolve, delayMs)) } return readPortal() } // Whether the jar holds the short-lived Privy ACCESS token — the exact cookie // `/api/agents` validates. hasLivePortalSession() answers "signed in at all?" // (renewal material counts); this answers "can discovery succeed right now?". async function hasPortalAccessToken() { const sess = getOauthSession() if (!sess) { return false } const portalBaseUrl = resolvePortalBaseUrl() const parsed = new URL(portalBaseUrl) try { const cookies = await sess.cookies.get({ url: portalBaseUrl }) return cookiesHavePrivyAccessToken(cookies) } catch { try { const cookies = await sess.cookies.get({ domain: parsed.hostname }) return cookiesHavePrivyAccessToken(cookies) } catch { return false } } } // Bounded silent renewal of the short-lived Privy access token (#73495). // // After a Desktop restart the long-lived `privy-session` / `privy-refresh-token` // cookies routinely survive while the ~1h `privy-token` access cookie has // expired. Discovery then 401s and the only offered recovery used to be a full // interactive re-login — even though the persisted refresh material can mint a // fresh access token with no user action: loading any portal page runs the // Privy client, which rotates a new `privy-token` from the refresh session. // // This drives exactly that, headlessly: a hidden window on the portal root in // the OAuth partition, polled until the access cookie lands, torn down on a // bounded timeout. Never shown — if renewal can't complete silently the caller // falls back to the interactive needsCloudLogin path. The in-flight promise is // shared so concurrent discovery + cascade calls ride one renewal. let portalAccessRenewal: Promise | null = null function renewPortalAccessSilently() { if (portalAccessRenewal) { return portalAccessRenewal } portalAccessRenewal = (async () => { if (!app.isReady()) { return false } const sess = getOauthSession() if (!sess) { return false } // No renewal material at all → nothing to renew; interactive login is // genuinely required. if (!(await hasLivePortalSession())) { return false } if (await hasPortalAccessToken()) { return true } const portalBaseUrl = resolvePortalBaseUrl() return await new Promise(resolve => { let settled = false let win = null let pollTimer = null let deadlineTimer = null const finish = (ok: boolean) => { if (settled) { return } settled = true if (pollTimer) { clearInterval(pollTimer) } if (deadlineTimer) { clearTimeout(deadlineTimer) } try { if (win && !win.isDestroyed()) { win.destroy() } } catch { // window already torn down } rememberLog(`[cloud] silent portal access renewal ${ok ? 'succeeded' : 'did not complete'}`) resolve(ok) } const checkCookie = async () => { if (settled) { return } if (await hasPortalAccessToken()) { finish(true) } } try { win = new BrowserWindow({ width: 520, height: 720, show: false, title: 'Renewing Hermes Cloud session…', autoHideMenuBar: true, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, session: sess, webSecurity: true } }) } catch { finish(false) return } win.webContents.on('did-navigate', () => void checkCookie()) win.webContents.on('did-redirect-navigation', () => void checkCookie()) win.webContents.on('did-frame-navigate', () => void checkCookie()) installWindowRendererLifecycle(win, { kind: 'portal-renew', callbacks: { log: rememberLog } }) pollTimer = setInterval(() => void checkCookie(), 500) // Hard deadline: this window is never revealed, so an unrenewable session // (revoked refresh token, portal down) must resolve false rather than // hang the discovery call behind an invisible window. deadlineTimer = setTimeout(() => finish(false), 12_000) win.on('closed', () => finish(false)) win.loadURL(portalBaseUrl).catch(() => finish(false)) }) })().finally(() => { portalAccessRenewal = null }) as Promise return portalAccessRenewal } // Drive a one-time interactive portal sign-in in the OAuth partition. Unlike // openOauthLoginWindow (which targets a gateway's /login), this lands on the // portal itself so the resulting session cookie is portal-scoped — the cookie // that authenticates discovery AND is reused for every silent per-agent // cascade. Resolves once the portal session cookie appears. function openPortalLoginWindow() { const portalBaseUrl = resolvePortalBaseUrl() return new Promise((resolve, reject) => { if (!app.isReady()) { reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.')) return } const sess = getOauthSession() if (!sess) { reject(new Error('OAuth session partition is unavailable.')) return } let settled = false let win = null let pollTimer = null const finish = err => { if (settled) { return } settled = true if (pollTimer) { clearInterval(pollTimer) } try { if (win && !win.isDestroyed()) { win.destroy() } } catch { // window already torn down } if (err) { reject(err) } else { resolve({ portalBaseUrl, ok: true }) } } const checkCookie = async () => { if (settled) { return } // A live portal (Privy) session cookie means sign-in completed. if (await hasLivePortalSession()) { finish(null) } } try { win = new BrowserWindow({ width: 520, height: 720, title: 'Sign in to Hermes Cloud', autoHideMenuBar: true, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, session: sess, webSecurity: true } }) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) return } win.webContents.on('did-navigate', () => void checkCookie()) win.webContents.on('did-redirect-navigation', () => void checkCookie()) win.webContents.on('did-frame-navigate', () => void checkCookie()) // Log-only lifecycle diagnostics, same rationale as the OAuth window: // a crashed portal sign-in renderer never settles the promise, so the // failure would otherwise leave no trace in desktop.log (#81290 // follow-up). installWindowRendererLifecycle(win, { kind: 'portal', callbacks: { log: rememberLog } }) pollTimer = setInterval(() => void checkCookie(), 750) win.on('closed', () => { if (!settled) { finish(new Error('Sign-in window closed before authentication completed.')) } }) // Land on the portal root; any authenticated portal page sets the session // cookie. We only care that the partition cookie jar is populated. win.loadURL(portalBaseUrl).catch(error => { finish(error instanceof Error ? error : new Error(String(error))) }) }) } // Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls // the NAS trimmed-summary endpoint over the partition-bound net, so the portal // session cookie is attached automatically (no bearer needed — NAS accepts the // cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs } // when the user belongs to multiple orgs and hasn't picked one yet (NAS 409 // org_selection_required). Pass `org` (a slug/id from a prior org list) to // scope discovery to that org. Throws a needsCloudLogin-tagged error when no // portal session is present. async function discoverCloudAgents(org?: string) { const portalBaseUrl = resolvePortalBaseUrl() if (!(await hasLivePortalSession())) { const err = new Error( 'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.' ) as any err.needsCloudLogin = true throw err } // Renewable session present but the short-lived access token `/api/agents` // validates is gone (typical after a restart — `privy-token` is ~1h, // `privy-session`/`privy-refresh-token` last ~30 days). Renew silently up // front instead of letting the request 401 into a re-login demand (#73495). if (!(await hasPortalAccessToken())) { await renewPortalAccessSilently() } const orgQuery = org ? `?org=${encodeURIComponent(org)}` : '' let body const fetchAgents = () => fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, { method: 'GET', timeoutMs: 15_000 }) try { body = (await fetchAgents()) as any } catch (initialError) { let error = initialError as any // A 401 with renewal material still in the jar: attempt ONE bounded silent // renewal and retry, so a lapsed access token doesn't surface as a full // interactive re-login while a 30-day refresh session sits unused. Only a // rejected/failed renewal (or a second 401 on genuinely fresh access) // falls through to needsCloudLogin. if (error && error.statusCode === 401 && (await renewPortalAccessSilently())) { try { body = (await fetchAgents()) as any } catch (retryError) { error = retryError } } if (body === undefined) { // A 401 means the portal session lapsed (and silent renewal could not // recover it) — surface it as a re-login, not a generic failure. if (error && error.statusCode === 401) { const err = new Error( 'Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.' ) as any err.needsCloudLogin = true err.cause = error throw err } // A 409 means we're a multi-org user who hasn't picked an org. The body // carries the user's org list; surface it so the renderer shows a picker // and re-calls discovery with the chosen org. (fetchJsonViaOauthSession // throws on >=400 with err.statusCode + err.message "409: ".) if (error && error.statusCode === 409) { const orgs = parseOrgSelectionError(error) if (orgs) { return { needsOrgSelection: true, orgs } } } throw error } } return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) } } // Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed // shape the renderer persists, or null when absent/malformed. function trimCloudOrg(org) { if (!org || typeof org !== 'object' || typeof org.id !== 'string') { return null } return { id: org.id, slug: typeof org.slug === 'string' ? org.slug : null, name: typeof org.name === 'string' ? org.name : org.id, isPersonal: Boolean(org.isPersonal), role: typeof org.role === 'string' ? org.role : 'MEMBER' } } // Extract the org list from a 409 org_selection_required error body. The error // message is "409: " (see fetchJsonViaOauthSession); parse defensively // and return null if it isn't the shape we expect (caller then rethrows). function parseOrgSelectionError(error) { const msg = String(error?.message || '') const jsonStart = msg.indexOf('{') if (jsonStart < 0) { return null } let parsed try { parsed = JSON.parse(msg.slice(jsonStart)) } catch { return null } if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) { return null } return parsed.orgs .filter(o => o && typeof o === 'object' && typeof o.id === 'string') .map(o => ({ id: o.id, slug: typeof o.slug === 'string' ? o.slug : null, name: typeof o.name === 'string' ? o.name : o.id, isPersonal: Boolean(o.isPersonal), role: typeof o.role === 'string' ? o.role : 'MEMBER' })) } // Project NAS's agent rows to the trimmed DTO the renderer consumes. function trimCloudAgents(body) { const agents = Array.isArray(body?.agents) ? body.agents : [] return agents .filter(a => a && typeof a === 'object' && typeof a.id === 'string') .map(a => ({ id: a.id, name: typeof a.name === 'string' ? a.name : a.id, status: typeof a.status === 'string' ? a.status : 'unknown', dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null, dashboardGatewayState: typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown' })) } // Silent per-agent sign-in: open the selected agent dashboard's /login in the // SAME OAuth partition. Because the user already holds a live portal session // there, the agent's /oauth/authorize auto-approves (org member) and 302s back, // setting that agent's gateway session cookie WITHOUT a second interactive // prompt. Reuses openOauthLoginWindow — the window self-closes the instant the // agent's session cookie lands (a silent flow finishes in well under a second; // if the portal session were absent it would fall through to an interactive // login, which the discovery gate already prevents). Returns once the agent's // gateway session cookie is present. async function cloudAgentSilentSignIn(dashboardUrl) { const baseUrl = normalizeRemoteBaseUrl(dashboardUrl) // Pre-req: a live portal session must exist, or this would surface an // interactive prompt rather than a silent cascade. Discovery already gates on // this, but a selection can arrive after the session lapsed. if (!(await hasLivePortalSession())) { const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.') as any err.needsCloudLogin = true throw err } // The cascade rides the portal's auto-approve, which needs the short-lived // access state just like discovery. If only renewal material survived the // restart, mint a fresh access token first so the hidden cascade window // auto-SSOs instead of stalling on an interactive chooser (#73495). if (!(await hasPortalAccessToken())) { await renewPortalAccessSilently() } await openOauthLoginWindow(baseUrl, { silent: true }) return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } } // --------------------------------------------------------------------------- // Opt-in keychain encryption (secret-storage-policy.ts owns the decision). // Default OFF: no safeStorage call is ever made, so a broken/locked macOS // login keychain can never throw its password dialog on launch. Settings → // Gateway exposes the toggle; flipping it re-encrypts (or decrypts) the // stored secrets in place. // --------------------------------------------------------------------------- const SECRET_STORAGE_POLICY_PATH = path.join(app.getPath('userData'), SECRET_STORAGE_POLICY_FILE) const _secretStoragePolicyIo = { readText: () => fs.readFileSync(SECRET_STORAGE_POLICY_PATH, 'utf8'), writeText: (text: string) => writeSecretFileAtomic(SECRET_STORAGE_POLICY_PATH, text, { encoding: 'utf8' }) } let _secretStoragePolicy: SecretStoragePolicy | null = null function secretStoragePolicy(): SecretStoragePolicy { if (!_secretStoragePolicy) { _secretStoragePolicy = readSecretStoragePolicy(_secretStoragePolicyIo) } return _secretStoragePolicy } function setSecretStoragePolicy(next: SecretStoragePolicy) { _secretStoragePolicy = { on: next.on === true, migrated: next.migrated === true } writeSecretStoragePolicy(_secretStoragePolicy, _secretStoragePolicyIo) } /** * Keychain availability as the renderer should see it. With encryption * opted out this must NOT probe safeStorage — isEncryptionAvailable() is * itself a keychain touch that raises the macOS dialog this feature exists * to avoid. We report `true` so no plain-text warning banners fire: storing * plaintext is the user's chosen (default) mode, not a degraded state. */ function probeSecureTokenStorage(): boolean { if (!secretStoragePolicy().on) { return true } try { return Boolean(safeStorage.isEncryptionAvailable()) } catch { return false } } /** * Rewrite every stored desktop secret (v1 connection.json token/headers + * per-profile overrides, v2 registry connections, native OAuth token store) * through `reencode`. Returns true when any store was rewritten. Shared by * the one-shot legacy migration and the Settings encryption toggle. */ function rewriteAllStoredSecrets(shouldRewrite: (secret: any) => boolean, reencode: (secret: any) => any): boolean { let touched = false const rewriteBlock = (block: any) => { if (!block || typeof block !== 'object') { return block } const next = { ...block, ...(block.token ? { token: reencode(block.token) } : {}) } if (block.headers && typeof block.headers === 'object') { next.headers = Object.fromEntries(Object.entries(block.headers).map(([k, v]) => [k, reencode(v)])) } return next } const blockNeedsRewrite = (o: any) => shouldRewrite(o?.token) || Object.values(o?.headers && typeof o.headers === 'object' ? o.headers : {}).some(shouldRewrite) // v1 connection.json. const config = readDesktopConnectionConfig() if (blockNeedsRewrite(config.remote) || Object.values(config.profiles || {}).some(blockNeedsRewrite)) { touched = true writeDesktopConnectionConfig({ ...config, remote: rewriteBlock(config.remote), profiles: Object.fromEntries(Object.entries(config.profiles || {}).map(([k, v]) => [k, rewriteBlock(v)])) }) } // v2 connections.json registry. const registry = readDesktopConnectionsRegistry() if (registry.connections?.some(blockNeedsRewrite)) { touched = true writeDesktopConnectionsRegistry({ ...registry, connections: registry.connections.map(rewriteBlock) }) } // Native OAuth token store: baseUrl → blob. const io = _nativeTokenStoreIo() try { const store = JSON.parse(io.readStoreText()) if (store && typeof store === 'object' && !Array.isArray(store)) { const entries = Object.entries(store) if (entries.some(([, v]) => shouldRewrite(v))) { touched = true io.writeStoreText(JSON.stringify(Object.fromEntries(entries.map(([k, v]) => [k, reencode(v)])))) } } } catch { // Missing/corrupt native token store: nothing to rewrite. } return touched } /** * One-shot legacy migration: builds before the opt-in policy wrote every * secret as a safeStorage blob. With encryption now defaulting OFF, decrypt * each stored blob once and rewrite it as plain so no future launch touches * the keychain. Marked `migrated` whether or not every blob decrypts — a * broken keychain costs at most ONE prompt (this pass), never one per * launch; blobs that would not decrypt are left in place and simply read as * absent from then on (classifyStoredSecret → 'drop'), so opting encryption * back ON later can still recover them on a healthy keychain. * * Runs before createWindow() so every later read sees the final encodings. */ function migrateLegacyEncryptedSecretsOnce() { const policy = secretStoragePolicy() if (policy.on || policy.migrated) { return } const needsMigration = (secret: any) => classifyStoredSecret(secret, policy) === 'migrate' const reencode = (secret: any) => { if (!needsMigration(secret)) { return secret } const plaintext = decryptDesktopSecret(secret) // Undecryptable now (locked/absent keychain): keep the blob for a // potential future opt-in, but post-migration reads treat it as unset. return plaintext ? { encoding: 'plain', value: plaintext } : secret } let touchedKeychain = false try { touchedKeychain = rewriteAllStoredSecrets(needsMigration, reencode) } catch (error) { const detail = error instanceof Error ? error.message : String(error) rememberLog(`[secret-storage] legacy migration pass failed: ${detail}`) } setSecretStoragePolicy({ on: false, migrated: true }) if (touchedKeychain) { rememberLog('[secret-storage] migrated legacy keychain-encrypted secrets to opt-out storage (one-shot pass)') } } /** * Settings → Gateway toggle: flip keychain-backed encryption and re-encode * every stored secret to match. Turning ON encrypts plain blobs through * strict safeStorage (throws loudly when the keychain is unusable — the * toggle stays off and the renderer shows the error). Turning OFF decrypts * back to plain; this is user-initiated, so a keychain prompt here is * expected and acceptable. */ function applySecretStorageEncryption(on: boolean) { const enable = on === true if (secretStoragePolicy().on === enable) { return { on: enable } } if (enable) { const needsEncrypt = (secret: any) => secret?.encoding === 'plain' && Boolean(secret.value) // Probe FIRST so an unusable keychain fails before any store is touched. if ( !(() => { try { return Boolean(safeStorage.isEncryptionAvailable()) } catch { return false } })() ) { throw new Error( 'OS keychain encryption is unavailable on this machine, so stored gateway secrets cannot be encrypted.' ) } setSecretStoragePolicy({ on: true, migrated: true }) try { rewriteAllStoredSecrets(needsEncrypt, secret => needsEncrypt(secret) ? encryptDesktopSecretStrict(String(secret.value), safeStorage) : secret ) } catch (error) { // Encryption failed midway: revert the policy so reads keep working // against whatever encodings are on disk (mixed stores read fine — // decryptDesktopSecret handles both encodings under either policy). setSecretStoragePolicy({ on: false, migrated: true }) throw error } return { on: true } } // Turning OFF: decrypt everything back to plain while the keychain is // still readable, then flip the policy. const needsDecrypt = (secret: any) => secret?.encoding === SAFE_STORAGE_ENCODING rewriteAllStoredSecrets(needsDecrypt, (secret: any) => { if (!needsDecrypt(secret)) { return secret } const plaintext = decryptDesktopSecret(secret) return plaintext ? { encoding: 'plain', value: plaintext } : secret }) setSecretStoragePolicy({ on: false, migrated: true }) return { on: false } } function encryptDesktopSecret(value, options = {}) { if (!secretStoragePolicy().on) { const raw = String(value || '') return raw ? { encoding: 'plain', value: raw } : null } return encryptDesktopSecretStrict(value, safeStorage, options) } function decryptDesktopSecret(secret) { if (!secret || typeof secret !== 'object') { return '' } const value = String(secret.value || '') if (!value) { return '' } if (secret.encoding === SAFE_STORAGE_ENCODING) { // Legacy blob under an opted-out policy: once the one-shot migration pass // has run, never touch safeStorage again — a dead keychain would otherwise // prompt on every read. Before that pass, decryption is allowed so the // migration itself (and this launch's reads) can recover the value. if (classifyStoredSecret(secret, secretStoragePolicy()) === 'drop') { return '' } try { return safeStorage.decryptString(Buffer.from(value, 'base64')) } catch { return '' } } // Any other encoding (a hand-edited config, or one written by a pre-release // build) is returned verbatim on purpose: this fallback is what lets such a // config connect at all. Not a plaintext-writing path — nothing in this file // persists a token this way. return value } function decryptRemoteHeaders(headers) { const normalized = normalizeRemoteHeaders(headers) const out = {} for (const [name, secret] of Object.entries(normalized)) { const value = decryptDesktopSecret(secret) if (value) { out[name] = value } } return out } /** * Turn an editor payload of remote gateway headers into stored secret * envelopes. The payload map is authoritative (a name missing from it is * cleared); per-name values are: * - non-empty string → new plaintext value, encrypted like a token * - null → keep the currently stored envelope for that name * (the editor shows a set-but-hidden secret) * - envelope object → stored verbatim (hand-edited import path) * Name filtering (forbidden/managed headers) happens in * normalizeRemoteHeaders at the registry/config layer. */ function encryptIncomingRemoteHeaders(raw, existing, options: { allowPlainText?: boolean } = {}) { const out = {} const stored = normalizeRemoteHeaders(existing) for (const [name, value] of Object.entries(raw || {})) { const key = String(name || '').trim() if (!key) { continue } if (typeof value === 'string') { const trimmed = value.trim() if (trimmed) { out[key] = encryptDesktopSecret(trimmed, { allowPlainText: options.allowPlainText === true }) } continue } if (value === null) { if (stored[key]) { out[key] = stored[key] } continue } if (value && typeof value === 'object') { out[key] = value } } return out } function rememberRemoteWsHeaders(wsUrl, headers = {}) { remoteWsHeaderStore.remember(wsUrl, headers) } function headersForRemoteRequest(requestUrl) { const exactWsHeaders = remoteWsHeaderStore.headersFor(requestUrl) if (exactWsHeaders && Object.keys(exactWsHeaders).length > 0) { return exactWsHeaders } const config = readDesktopConnectionConfig() if (modeIsRemoteLike(config.mode) && config.remote?.url) { const headers = decryptRemoteHeaders(config.remote.headers) if (Object.keys(headers).length > 0 && remoteRequestMatchesBaseUrl(requestUrl, config.remote.url)) { return headers } } return {} } function installRemoteHeaderRules() { if (remoteHeaderRulesInstalled) { return } remoteHeaderRulesInstalled = true session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { applyRemoteRequestHeaders(details, callback, headersForRemoteRequest) }) } // Validate + normalize the per-profile remote overrides map read from disk. // Drops malformed names/entries and keeps only the recognized fields so a // hand-edited or stale connection.json can't inject junk into resolution. function sanitizeConnectionProfiles(raw: Record) { if (!raw || typeof raw !== 'object') { return {} } const out = {} for (const [name, entry] of Object.entries(raw)) { if (!entry || typeof entry !== 'object') { continue } if (name !== 'default' && !PROFILE_NAME_RE.test(name)) { continue } if (entry.mode === 'ssh') { const ssh = normalizeSshConfig(entry) if (ssh) { if (entry.token && typeof entry.token === 'object') { ssh.token = entry.token } out[name] = ssh } continue } const cleaned: { mode: 'remote' | 'local' | 'cloud' url?: string authMode?: string token?: object headers?: object org?: string savedSsh?: object } = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' } if (cleaned.mode === 'local') { const savedSsh = normalizeSshConfig(entry.savedSsh) if (savedSsh) { cleaned.savedSsh = savedSsh } } const url = String(entry.url || '').trim() if (url) { cleaned.url = url } cleaned.authMode = normAuthMode(entry.authMode) if ((entry as any).token && typeof entry.token === 'object') { cleaned.token = entry.token } const headers = normalizeRemoteHeaders((entry as any).headers) if (Object.keys(headers).length > 0) { cleaned.headers = headers } // Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can // reopen into the same org for a per-profile cloud connection. if (cleaned.mode === 'cloud') { const org = String(entry.org || '').trim() if (org) { cleaned.org = org } } out[name] = cleaned } return out } function readDesktopConnectionConfig() { // Check if file changed on disk since last read (e.g. modified by another // process or an external tool). Our own writes update the cache inline // via writeDesktopConnectionConfig, but external changes would be missed. let mtime = null try { mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } catch { mtime = null } if (connectionConfigCache && connectionConfigCacheMtime === mtime) { return connectionConfigCache } let config = { mode: 'local', remote: {}, profiles: {} } try { const raw = fs.readFileSync(DESKTOP_CONNECTION_CONFIG_PATH, 'utf8') // Tighten an install written before this file was owner-only. Every write // now goes out at 0600, but a file already on disk keeps its old 0644 bits // until something chmods it, and waiting for the user's next Settings save // would leave it group/other-readable indefinitely. Runs on a cache miss // only (once per launch, plus after an external edit); chmod moves ctime, // not mtime, so it cannot invalidate the cache it sits inside. // // Deliberately BEFORE JSON.parse, not after: a truncated or hand-mangled // connection.json still contains the token bytes, and parse throws into the // catch below, which swallows the error and falls back to local mode. With // the tighten after the parse, exactly the file that is both corrupt AND // world-readable would be the one file never tightened — and nothing would // ever retry it, because the fallback config is not written back. The chmod // needs only the path, so it has no reason to wait for valid JSON. tightenSecretFileMode(DESKTOP_CONNECTION_CONFIG_PATH) const parsed = JSON.parse(raw) // NOT done here: migrating a legacy non-safeStorage token payload to // ciphertext at rest. Deferred deliberately — it has to honor the opt-in // plaintext choice PR #62319 adds (re-encrypting it converts a portable // credential into a keychain-bound one and can lose the token), write // through sanitizeConnectionProfiles below rather than persisting raw // `parsed`, and tell the user to ROTATE, since every existing backup copy // still holds the old secret. Do not add it without those three. if (parsed && typeof parsed === 'object') { const remote = parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {} // authMode lives on the remote sub-object: 'oauth' (cookie + ws-ticket) // or 'token' (legacy static session token). Default to 'token' for // backward compatibility with configs written before OAuth support. remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token' config = { mode: parsed.mode === 'ssh' ? 'ssh' : modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local', remote, // Per-profile remote overrides: each profile may point at its own // backend (local spawn or its own remote URL). Preserved verbatim so // profileRemoteOverride() can resolve them; normalized lazily on save. profiles: sanitizeConnectionProfiles(parsed.profiles) } } } catch { // Missing or malformed connection settings should fall back to local. } connectionConfigCache = config connectionConfigCacheMtime = mtime return config } function writeDesktopConnectionConfig(config) { fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true }) // Owner-only, not writeFileAtomic: this is the single choke point for every // connection.json write (the IPC save/apply handlers and // persistSshConnectionToken all land here), and the file carries the // safeStorage-encrypted gateway token plus its URL and SSH host/user/keyPath. // safeStorage keeps the token opaque; 0600 keeps the whole record — and the // fields that are NOT encrypted — off other local accounts, matching // native-oauth-tokens.json and desktop-installation.json. writeSecretFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2)) connectionConfigCache = config connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs } // ── v2 connection registry (multi-source) ────────────────────────────────── /** * Read the v2 registry, importing from v1 connection.json exactly once (when * connections.json does not exist yet). Same mtime-cache + tighten-mode * discipline as readDesktopConnectionConfig; a corrupt registry degrades to * local-only via normalizeRegistry rather than throwing at boot. * * An EXISTING registry is additionally reconciled against v1 when the two have * drifted — see reconcileRegistryDrift. The one-shot migration cannot cover a * user who registered nothing and then pointed Settings -> Gateway at a remote, * and until that heals, every launch re-homes them onto a local backend. */ function readDesktopConnectionsRegistry() { let mtime = null try { mtime = fs.statSync(DESKTOP_CONNECTIONS_REGISTRY_PATH).mtimeMs } catch { mtime = null } if (connectionRegistryCache && connectionRegistryCacheMtime === mtime) { return connectionRegistryCache } let registry if (mtime === null) { // First run on this build: import the v1 single-connection config. The v1 // file is NOT modified or deleted — older builds keep reading it. The // migration is deterministic over the v1 input, so even if two processes // race the first run (updater relaunch, second window), both derive the // same registry and the later atomic write is a no-op content-wise. registry = migrateV1ToRegistry(readDesktopConnectionConfig()) try { writeDesktopConnectionsRegistry(registry) } catch { // Write failed (full disk, read-only userData). Keep the migrated // registry in memory so list/save keep working this session instead of // hard-failing every hermes:connections:* call. connectionRegistryCache = registry connectionRegistryCacheMtime = null } return connectionRegistryCache } try { // Same rationale as connection.json: tighten BEFORE parse so a corrupt // file that still holds token bytes gets its mode fixed anyway. tightenSecretFileMode(DESKTOP_CONNECTIONS_REGISTRY_PATH) registry = normalizeRegistry(JSON.parse(fs.readFileSync(DESKTOP_CONNECTIONS_REGISTRY_PATH, 'utf8'))) } catch { // Whole-file corruption (truncated write, mangled hand-edit). The // degraded local-only registry keeps boot working, but the file BYTES are // the user's connection data — preserve them in a sidecar BEFORE any // later write (drift reconcile, connection save) overwrites the file // (#94246: recovery must never be data loss). preserveCorruptRegistrySidecar() registry = normalizeRegistry(null) } if (registry?.quarantined?.length) { rememberLog( `[connections] ${registry.quarantined.length} malformed registry entr${registry.quarantined.length === 1 ? 'y was' : 'ies were'} quarantined (kept under "quarantined" in connections.json); healthy connections loaded normally.` ) } // Heal v1 -> v2 drift: the v1 global route names a remote this registry has // never heard of, so the live descriptor resolves to no connectionId and the // launch pick sends the window somewhere else. Persist so the repair is a // one-time event rather than a recomputation on every read; a failed write // still returns the healed registry for this session. const reconciled = reconcileRegistryDrift(registry, readDesktopConnectionConfig()) if (reconciled.changed) { registry = reconciled.registry try { writeDesktopConnectionsRegistry(registry) return connectionRegistryCache } catch { connectionRegistryCache = registry connectionRegistryCacheMtime = null return registry } } connectionRegistryCache = registry connectionRegistryCacheMtime = mtime return registry } // Copy an unparseable connections.json aside (once per corruption event) so a // later registry write can never destroy the only copy of the user's saved // connections (#94246). Best effort: failure to preserve must not block boot. function preserveCorruptRegistrySidecar() { try { const rawText = fs.readFileSync(DESKTOP_CONNECTIONS_REGISTRY_PATH, 'utf8') if (!rawText.trim()) { return } const sidecar = `${DESKTOP_CONNECTIONS_REGISTRY_PATH}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}` if (!fs.existsSync(sidecar)) { fs.writeFileSync(sidecar, rawText, { mode: 0o600 }) } rememberLog( `[connections] connections.json could not be parsed; preserved the original file at ${sidecar} and continuing with a local-only registry. No connection data was deleted.` ) } catch { // The read itself failed (missing file, permissions) — nothing to save. } } function writeDesktopConnectionsRegistry(registry) { fs.mkdirSync(path.dirname(DESKTOP_CONNECTIONS_REGISTRY_PATH), { recursive: true }) // Owner-only for the same reason as connection.json: entries carry // safeStorage-encrypted tokens plus URLs and SSH host/user/keyPath. writeSecretFileAtomic(DESKTOP_CONNECTIONS_REGISTRY_PATH, JSON.stringify(registry, null, 2)) connectionRegistryCache = registry connectionRegistryCacheMtime = fs.statSync(DESKTOP_CONNECTIONS_REGISTRY_PATH).mtimeMs } /** * Renderer-facing view of a registry entry: token bytes never cross the IPC * boundary — the renderer gets a preview + set flag, mirroring * sanitizeDesktopConnectionConfig. */ function sanitizeRegistryConnection(entry) { const { token, headers, ...rest } = entry const decrypted = decryptDesktopSecret(token) // Last-known stable backend identity (from roster enumeration / Test) so // Settings can hint "Same backend as