diff --git a/apps/desktop/electron/fs-ipc.ts b/apps/desktop/electron/fs-ipc.ts new file mode 100644 index 0000000000..ff25db2013 --- /dev/null +++ b/apps/desktop/electron/fs-ipc.ts @@ -0,0 +1,197 @@ +// IPC surface for local filesystem operations the renderer's project/file +// surfaces use: directory reads, reveal/open in the OS file manager, plugin +// roots + git installs, rename/write/trash. Extracted from main.ts; path +// hardening, HERMES_HOME resolution, and the git binary stay injected. +import fs from 'node:fs' +import path from 'node:path' + +import { ipcMain, shell } from 'electron' + +import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install' +import { readDirForIpc } from './fs-read-dir' +import { gitRootForIpc } from './git-root' + +export interface FsIpcDeps { + hermesHome: string + readActiveDesktopProfile: () => null | string + expandUserPath: (value: string) => string + resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string + directoryExists: (value: string) => boolean + resolveGitBinary: () => string +} + +export function registerFsIpc({ + hermesHome, + readActiveDesktopProfile, + expandUserPath, + resolveRequestedPathForIpc, + directoryExists, + resolveGitBinary +}: FsIpcDeps) { + ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) + + ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) + + // Reveal a path in the OS file manager (Finder / Explorer / Files). + ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + return false + } + + try { + shell.showItemInFolder(target) + + return true + } catch { + return false + } + }) + + // Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike + // `reveal` (which selects an existing item and silently no-ops on a missing + // path — the "Open plugins folder" Windows bug), this is for the plugins door, + // which often doesn't exist on first use. `shell.openPath` returns '' on + // success or an error string; both mkdir + openPath failures are surfaced. + ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { + const dir = String(dirPath || '').trim() + + if (!dir) { + return { ok: false, error: 'no path' } + } + + try { + await fs.promises.mkdir(dir, { recursive: true }) + const error = await shell.openPath(path.normalize(dir)) + + return error ? { ok: false, error } : { ok: true } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + + // The LOCAL Desktop runtime-plugin root: `/desktop-plugins`, + // resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from + // the connected backend. A remote backend reports its own `hermes_home` over + // the gateway, which is a path on the REMOTE box; deriving the plugin dir from + // it yields `undefined/desktop-plugins` (or a non-existent remote path) and the + // on-disk plugin door silently breaks (#66899). Electron owns this resolution + // so it stays valid in every connection mode. Created on demand, like openDir. + async function localPluginsRoot(dirName: string): Promise { + // Profile-aware: a named Desktop profile gets its own plugin root under + // profiles//, matching the profile-scoped hermes_home the backend + // reported before this resolver existed. 'default'/unset pins the global root. + const profile = readActiveDesktopProfile() + const base = profile && profile !== 'default' ? path.join(hermesHome, 'profiles', profile) : hermesHome + const dir = path.join(base, dirName) + + try { + await fs.promises.mkdir(dir, { recursive: true }) + } catch { + // Best-effort create; return the path regardless so the reveal action can + // still surface a real openPath error and the scanner can retry later. + } + + return dir + } + + ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins')) + + // The LOCAL agent-plugin root (`/plugins`), same Electron-local + // resolution as above. This is the desktop half of a UNIFIED plugin package: + // an agent plugin may ship `desktop/plugin.js` alongside its Python code (the + // same shape as `dashboard/manifest.json`), and the renderer's disk door scans + // this root for it — one installable folder serving both SDKs. + ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins')) + + ipcMain.handle('hermes:plugin:probe', async (_event, payload) => { + const identifier = String(payload?.identifier || payload?.repo || '').trim() + + if (!identifier) { + return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] } + } + + return probePluginRepo(resolveGitBinary(), identifier) + }) + + ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => { + const identifier = String(payload?.identifier || payload?.repo || '').trim() + + if (!identifier) { + return { ok: false, error: 'identifier is required' } + } + + const desktopPluginsRoot = await localPluginsRoot('desktop-plugins') + + return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force)) + }) + + // Rename a file/folder in place. The renderer passes the existing path + a new + // base name; the destination is resolved in the SAME parent dir so a rename can + // never move the item elsewhere or traverse out. Rejects on a name collision. + ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { + const src = String(targetPath || '').trim() + const name = String(newName || '').trim() + + if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error('Invalid rename') + } + + const dst = path.join(path.dirname(src), name) + + if (dst === src) { + return { path: dst } + } + + if (fs.existsSync(dst)) { + throw new Error(`"${name}" already exists`) + } + + await fs.promises.rename(src, dst) + + return { path: dst } + }) + + // Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path + // is hardened (resolveRequestedPathForIpc) and the parent must already exist — + // this never creates directory trees or escapes the allowed roots, and content + // is size-capped so it can't be abused as a bulk-write primitive. + ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { + const raw = String(filePath || '').trim() + + if (!raw) { + throw new Error('Invalid path') + } + + const text = String(content ?? '') + + if (text.length > 1_000_000) { + throw new Error('Content too large') + } + + const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) + + if (!directoryExists(path.dirname(resolved))) { + throw new Error('Parent directory does not exist') + } + + await fs.promises.writeFile(resolved, text, 'utf8') + + return { path: resolved } + }) + + // Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" + // default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. + ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { + const target = String(targetPath || '').trim() + + if (!target) { + throw new Error('Invalid delete') + } + + await shell.trashItem(target) + + return true + }) +} diff --git a/apps/desktop/electron/git-ipc.ts b/apps/desktop/electron/git-ipc.ts new file mode 100644 index 0000000000..105307d03e --- /dev/null +++ b/apps/desktop/electron/git-ipc.ts @@ -0,0 +1,120 @@ +// IPC surface for git-driven features: worktree management ("Start work"), +// the composer coding rail's repo status, the Codex-style review pane, and +// repo-first project discovery. Extracted from main.ts; the git/gh binary +// resolvers stay injected because main.ts also uses them for self-update and +// plugin installs. +import { ipcMain } from 'electron' + +import { scanGitRepos } from './git-repo-scan' +import { + fileDiffVsHead, + repoStatus, + reviewCommit, + reviewCommitContext, + reviewCreatePr, + reviewDiff, + reviewFetchPrComment, + reviewList, + reviewPrList, + reviewPush, + reviewRevert, + reviewRevParse, + reviewShipInfo, + reviewStage, + reviewUnstage +} from './git-review-ops' +import { + addWorktree, + listBaseBranches, + listBranches, + listWorktrees, + removeWorktree, + switchBranch +} from './git-worktree-ops' + +export interface GitIpcDeps { + resolveGitBinary: () => string + resolveGhBinary: () => string +} + +export function registerGitIpc({ resolveGitBinary, resolveGhBinary }: GitIpcDeps) { + // Git-driven worktree management ("Start work" flow). Errors surface to the + // renderer as rejected promises so it can toast a friendly message. + ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary())) + + ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => + addWorktree(repoPath, options || {}, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => + removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => + switchBranch(repoPath, branch, resolveGitBinary()) + ) + + ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) + + ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => + listBaseBranches(repoPath, resolveGitBinary()) + ) + + // Compact repo status (branch, ahead/behind, change counts + files) for the + // composer coding rail. Returns null on a non-repo / remote backend so the rail + // hides cleanly rather than erroring. + ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) + + // Codex-style review pane: list changed files for a scope, fetch one file's + // unified diff, and stage / unstage / revert. Reads return empty on failure; + // mutations reject so the renderer can toast. + ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => + reviewList(repoPath, scope, baseRef, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => + reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) + ) + // Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). + ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => + fileDiffVsHead(repoPath, filePath, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => + reviewStage(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => + reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => + reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => + reviewRevParse(repoPath, ref, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => + reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => + reviewCommitContext(repoPath, resolveGitBinary()) + ) + ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) + ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) + ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) => + reviewPrList(repoPath, resolveGhBinary(), branches, numbers) + ) + ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) => + reviewFetchPrComment(repoPath, resolveGhBinary(), url) + ) + ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => + reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) + ) + + // Repo-first project discovery: scan bounded roots for git repos (pure fs walk, + // no native addon). Never throws to the renderer — failures yield an empty list. + ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { + try { + return await scanGitRepos(roots || [], options || {}) + } catch { + return [] + } + }) +} diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index b885dda1af..f8c77f3186 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -3,31 +3,23 @@ import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +// Relative, not `@hermes/shared`: the electron bundle is built by esbuild with +// no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare +// specifier would typecheck and then fail to bundle. +import { + clampDataUrlReadMaxMb, + DATA_URL_READ_DEFAULT_MAX_MB, + DATA_URL_READ_MAX_MAX_MB, + DATA_URL_READ_MIN_MAX_MB +} from '../../shared/src/data-url-read-max' + const DEFAULT_FETCH_TIMEOUT_MS = 15_000 -// Default / floor / ceiling for Desktop's data-URL file load (composer attach, -// image preview, etc.). The whole file is base64-buffered in main, so this is -// a memory guard — not a model limit. Settings → Chat takes a free-form MB -// value; 16 MB ships as default. The ceiling is only a typo guard (very large -// values can OOM / crash the app). -const DATA_URL_READ_DEFAULT_MAX_MB = 16 -const DATA_URL_READ_MIN_MAX_MB = 1 -const DATA_URL_READ_MAX_MAX_MB = 4096 // Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach // reader so the payload still fits uvicorn's raised ws_max_size (384 MiB) // after base64 + framing. Preview stays on the Settings-configurable path. const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024 const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024 -function clampDataUrlReadMaxMb(value) { - const parsed = Number(value) - - if (!Number.isFinite(parsed)) { - return DATA_URL_READ_DEFAULT_MAX_MB - } - - return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed))) -} - function dataUrlReadMaxBytesFromMb(maxMb) { return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024 } diff --git a/apps/desktop/electron/hud-ipc.ts b/apps/desktop/electron/hud-ipc.ts new file mode 100644 index 0000000000..8a8b7cf000 --- /dev/null +++ b/apps/desktop/electron/hud-ipc.ts @@ -0,0 +1,128 @@ +// IPC surface for HUD mode (the chrome-free floating chat band). Extracted +// from main.ts; the HUD window handle and session-id latch stay injected +// because main.ts owns the window lifecycle and the close broadcast reads the +// latch when handing the session back to the app window. +import { type BrowserWindow, ipcMain } from 'electron' + +export interface HudIpcDeps { + isMac: boolean + getHudWindow: () => BrowserWindow | null + openHudWindow: (sessionId: null | string, profile: null | string) => void + closeHudWindow: () => void + setHudSessionId: (sessionId: null | string) => void +} + +export function registerHudIpc({ isMac, getHudWindow, openHudWindow, closeHudWindow, setHudSessionId }: HudIpcDeps) { + ipcMain.handle('hermes:hud:open', async (_event, request) => { + openHudWindow( + typeof request?.sessionId === 'string' ? request.sessionId : null, + typeof request?.profile === 'string' ? request.profile : null + ) + + return { ok: true } + }) + + // Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, + // because Chromium composites a transparent window's page against nothing and + // the desktop is not in its backdrop root. Vibrancy IS the window's content + // view, so it frosts the whole rectangle; the HUD's layout leaves no dead + // margins for that reason, and the renderer only turns it on while the band is + // showing (idle HUD mode must be the bar and nothing else). + ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed() && isMac) { + hudWindow.setVibrancy(on ? 'hud' : null) + } + + return { ok: true } + }) + + // Let clicks fall through the HUD wherever it isn't really there. An + // always-on-top window eats every click inside its rectangle, and most of that + // rectangle is a faded-out band over whatever the user is actually working in. + // `forward` keeps mousemove flowing so the renderer can re-arm when the cursor + // reaches the bar. + ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed()) { + hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + + ipcMain.on('hermes:hud:move-by', (event, delta) => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { + return + } + + const dx = Number(delta?.x) + const dy = Number(delta?.y) + const width = Number(delta?.width) + const height = Number(delta?.height) + + if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { + return + } + + const [x, y] = hudWindow.getPosition() + + // setBounds — NOT setPosition: on Windows, a transparent frameless window + // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer + // snapshots outerWidth/outerHeight when the composer drag arms and re-pins + // to that size on every moveBy (same pattern as the pet overlay drag). + hudWindow.setBounds({ + x: Math.round(x + dx), + y: Math.round(y + dy), + width: Math.round(width), + height: Math.round(height) + }) + }) + + // Resize from the HUD's corner handle. The window is created non-resizable + // (see spawnHudWindow — a transparent frameless window must not expose a + // system resize hot-zone, or dragging grows it), which on Windows/Linux also + // blocks programmatic setBounds sizing — so briefly flip resizable on while + // the size actually changes, exactly like the pet overlay's wheel-scale does. + ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { + const hudWindow = getHudWindow() + + if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { + return + } + + const win = hudWindow + const width = Math.max(380, Math.round(Number(bounds.width))) + const height = Math.max(160, Math.round(Number(bounds.height))) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + + // The HUD renderer reporting which session it is on, so the close broadcast + // can hand it back to the app window (see hudSessionId). + ipcMain.on('hermes:hud:session', (event, sessionId) => { + const hudWindow = getHudWindow() + + if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { + setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null) + } + }) + + ipcMain.handle('hermes:hud:close', async () => { + closeHudWindow() + + return { ok: true } + }) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8e35bbefd9..394abbbf87 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -29,7 +29,6 @@ import { shell, systemPreferences } from 'electron' -import nodePty from 'node-pty' import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' @@ -60,7 +59,7 @@ import { import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { detectBundleSkew } from './bundle-skew' -import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' +import { applyConnectionChange } from './connection-apply' import { apiRequestRegistryConnectionId, authModeFromStatus, @@ -120,7 +119,6 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { formatDesktopLogLine } from './desktop-log-line' -import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install' import { resolveDesktopRemoteRoute } from './desktop-remote-route' import { buildPosixCleanupScript, @@ -149,7 +147,7 @@ import { stopFind } from './find-in-page' import { createFirstRunSetupGate } from './first-run-setup-gate' -import { readDirForIpc } from './fs-read-dir' +import { registerFsIpc } from './fs-ipc' import { filenameFromContentDisposition, gatewayFilePath, @@ -158,33 +156,7 @@ import { pumpStreamToFile } from './gateway-file-download' import { probeGatewayWebSocket } from './gateway-ws-probe' -import { scanGitRepos } from './git-repo-scan' -import { - fileDiffVsHead, - repoStatus, - reviewCommit, - reviewCommitContext, - reviewCreatePr, - reviewDiff, - reviewFetchPrComment, - reviewList, - reviewPrList, - reviewPush, - reviewRevert, - reviewRevParse, - reviewShipInfo, - reviewStage, - reviewUnstage -} from './git-review-ops' -import { gitRootForIpc } from './git-root' -import { - addWorktree, - listBaseBranches, - listBranches, - listWorktrees, - removeWorktree, - switchBranch -} from './git-worktree-ops' +import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' import { readAndConsumeHandoffResult } from './handoff-result' import { @@ -206,6 +178,7 @@ import { writeSecretFileAtomic } from './hardening' import { cursorPointInWindow } from './hud-cursor' +import { registerHudIpc } from './hud-ipc' import { snapHudBounds } from './hud-snap' import { createHudSnapShortcut } from './hud-snap-shortcut' import { buildHudWindowUrl } from './hud-url' @@ -234,6 +207,7 @@ import { electronProcessStartMarker, parentWatchdogEnv } from './parent-process-identity' +import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, localRouteFallbackProfiles, @@ -289,17 +263,11 @@ import { SESSION_WINDOW_MIN_WIDTH } from './session-windows' import { ensureLoginShellPath } from './shell-path' -import { ensureSpawnHelperExecutable } from './spawn-helper-perms' import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator' import { collectSshConfigHosts, parseSshGOutput } from './ssh-config' -import { - buildInteractiveSshArgs, - createSshProbeConnection, - pickLocalPort, - redactSecrets, - SshConnection -} from './ssh-connection' +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 { glassActive, @@ -355,12 +323,7 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' -import { - buildWindowsInteractiveCommand, - connectWindowsRemote, - detectRemotePlatform, - helper -} from './windows-remote-lifecycle' +import { connectWindowsRemote, detectRemotePlatform, helper } from './windows-remote-lifecycle' import { alreadyHasNoSandbox, buildNoSandboxRelaunchArgs, @@ -851,7 +814,6 @@ const APP_ICON_PATHS = [ ] let rendererTitleBarTheme = null -const terminalSessions = new Map() // Force the NATIVE window appearance (vibrancy material, titlebar, the // pre-first-paint window background) to follow the APP theme instead of the @@ -8862,11 +8824,7 @@ async function teardownSshConnection(profile) { sshConnections.delete(scope) - for (const [id, info] of [...terminalSessions.entries()]) { - if (info.sshScope === scope) { - disposeTerminalSession(id) - } - } + terminalIpc.disposeTerminalSessionsForSshScope(scope) try { if (state.localPort && state.remotePort) { @@ -12139,232 +12097,23 @@ ipcMain.on('hermes:zoom:set-percent', (event, percent) => { setAndPersistZoomLevel(window, percentToZoomLevel(Number(percent))) }) -// --- Pet overlay (pop-out mascot) ----------------------------------------- -// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space -// bounds (screen=false): convert to screen space by adding the main window's -// content origin so the pet lands where it sat in-window. A remembered/dragged -// spot passes screen-space bounds (screen=true) and is used as-is. We return the -// resolved screen bounds so the renderer can persist exactly where it opened. -ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { - const bounds = request && request.bounds ? request.bounds : request - const isScreen = Boolean(request && request.screen) - let screenBounds = bounds - - try { - if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { - const content = mainWindow.getContentBounds() - screenBounds = { - x: content.x + (bounds.x || 0), - y: content.y + (bounds.y || 0), - width: bounds.width, - height: bounds.height - } - } - } catch { - // Fall back to raw bounds if the window geometry is unavailable. - } - - openPetOverlay(screenBounds) - - return { ok: true, bounds: screenBounds } -}) -ipcMain.handle('hermes:pet-overlay:close', async () => { - closePetOverlay() - - return { ok: true } -}) -// Drag/resize: the overlay reports new absolute screen bounds (it already knows -// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale -// gesture grows/shrinks it so the sprite is never cropped by the window edge. -// The window is created non-resizable (no stray edge-drag on the transparent -// frameless panel), which on Windows/Linux also blocks programmatic setBounds -// sizing — so briefly flip resizable on whenever the size actually changes. -ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { - if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { - return - } - - const win = petOverlayWindow - const width = Math.max(80, Math.round(bounds.width)) - const height = Math.max(80, Math.round(bounds.height)) - const [curW, curH] = win.getSize() - const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) - } - - win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) - - if (resizing) { - win.setResizable(false) - } -}) -// Click-through: the overlay window is a full rectangle but only the pet pixels -// should be interactive. The renderer toggles this as the cursor enters/leaves -// the sprite so transparent margins pass clicks to whatever is behind. -ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { - petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) - } -}) -// The overlay is a non-activating panel (focusable:false) so it never steals -// the app's cmd/alt-tab anchor from the main window. But the pop-up composer -// needs the keyboard, so the renderer asks us to flip it focusable + focus it -// while the composer is open, then back to non-activating when it closes. -ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { - if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { - return - } - - petOverlayWindow.setFocusable(Boolean(focusable)) - - if (focusable) { - petOverlayWindow.focus() - } -}) -// Main renderer → overlay: forward the latest pet state for the overlay to render. -ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { - if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { - petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) - } -}) -// Overlay → main renderer: control messages (pop back in, composer submit). -ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - - // Double-click toggles the app window: hide it away if it's up front, bring it - // back if it's minimized/buried. Pure window control — nothing for the - // renderer to do, so don't forward it. - if (payload && payload.type === 'toggle-app') { - if (mainWindow.isMinimized() || !mainWindow.isVisible()) { - mainWindow.show() - mainWindow.focus() - } else { - mainWindow.minimize() - } - - return - } - - // The mail icon means "take me to the app": raise the main window (it may be - // minimized or buried) before the renderer navigates to the latest thread. - if (payload && payload.type === 'open-app') { - if (mainWindow.isMinimized()) { - mainWindow.restore() - } - - mainWindow.show() - mainWindow.focus() - } - - mainWindow.webContents.send('hermes:pet-overlay:control', payload) +// --- Pet overlay (pop-out mascot) — see pet-overlay-ipc.ts. --------------- +registerPetOverlayIpc({ + getMainWindow: () => mainWindow, + getPetOverlayWindow: () => petOverlayWindow, + openPetOverlay, + closePetOverlay }) -// --- HUD mode (chrome-free floating chat) ----------------------------------- -ipcMain.handle('hermes:hud:open', async (_event, request) => { - openHudWindow( - typeof request?.sessionId === 'string' ? request.sessionId : null, - typeof request?.profile === 'string' ? request.profile : null - ) - - return { ok: true } -}) - -// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do, -// because Chromium composites a transparent window's page against nothing and -// the desktop is not in its backdrop root. Vibrancy IS the window's content -// view, so it frosts the whole rectangle; the HUD's layout leaves no dead -// margins for that reason, and the renderer only turns it on while the band is -// showing (idle HUD mode must be the bar and nothing else). -ipcMain.handle('hermes:hud:vibrancy', (_event, on) => { - if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) { - hudWindow.setVibrancy(on ? 'hud' : null) +// --- HUD mode (chrome-free floating chat) — see hud-ipc.ts. --------------- +registerHudIpc({ + isMac: IS_MAC, + getHudWindow: () => hudWindow, + openHudWindow, + closeHudWindow, + setHudSessionId: value => { + hudSessionId = value } - - return { ok: true } -}) - -// Let clicks fall through the HUD wherever it isn't really there. An -// always-on-top window eats every click inside its rectangle, and most of that -// rectangle is a faded-out band over whatever the user is actually working in. -// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor -// reaches the bar. -ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => { - if (hudWindow && !hudWindow.isDestroyed()) { - hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) - } -}) - -ipcMain.on('hermes:hud:move-by', (event, delta) => { - if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) { - return - } - - const dx = Number(delta?.x) - const dy = Number(delta?.y) - const width = Number(delta?.width) - const height = Number(delta?.height) - - if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) { - return - } - - const [x, y] = hudWindow.getPosition() - - // setBounds — NOT setPosition: on Windows, a transparent frameless window - // silently grows ~1px per setPosition call (worse at >100% DPI). The renderer - // snapshots outerWidth/outerHeight when the composer drag arms and re-pins - // to that size on every moveBy (same pattern as the pet overlay drag). - hudWindow.setBounds({ - x: Math.round(x + dx), - y: Math.round(y + dy), - width: Math.round(width), - height: Math.round(height) - }) -}) - -// Resize from the HUD's corner handle. The window is created non-resizable -// (see spawnHudWindow — a transparent frameless window must not expose a -// system resize hot-zone, or dragging grows it), which on Windows/Linux also -// blocks programmatic setBounds sizing — so briefly flip resizable on while -// the size actually changes, exactly like the pet overlay's wheel-scale does. -ipcMain.on('hermes:hud:set-bounds', (event, bounds) => { - if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) { - return - } - - const win = hudWindow - const width = Math.max(380, Math.round(Number(bounds.width))) - const height = Math.max(160, Math.round(Number(bounds.height))) - const [curW, curH] = win.getSize() - const resizing = width !== curW || height !== curH - - if (resizing && !win.isResizable()) { - win.setResizable(true) - } - - win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height }) - - if (resizing) { - win.setResizable(false) - } -}) - -// The HUD renderer reporting which session it is on, so the close broadcast -// can hand it back to the app window (see hudSessionId). -ipcMain.on('hermes:hud:session', (event, sessionId) => { - if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) { - hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null - } -}) - -ipcMain.handle('hermes:hud:close', async () => { - closeHudWindow() - - return { ok: true } }) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and @@ -14383,565 +14132,30 @@ ipcMain.on('hermes:logs:renderer-error', (_event, report) => { flushDesktopLogBufferSync() }) -function isExecutableFile(filePath) { - if (!filePath || !path.isAbsolute(filePath)) { - return false - } - - try { - fs.accessSync(filePath, fs.constants.X_OK) - - return true - } catch { - return false - } -} - -function posixShellSpec(shellPath) { - const shellName = path.basename(shellPath) - const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] - - return { args: interactiveArgs, command: shellPath, name: shellName } -} - -// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; -// prefer it only after PowerShell 7+ (`pwsh`). -function windowsPowerShellPath() { - const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' - const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') - - return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') -} - -// Map a resolved shell path to its spawn spec, picking interactive flags by -// family: PowerShell drops its logo banner (so the prompt sits flush like the -// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) -// gets POSIX interactive-login flags. -function shellSpecFor(shellPath) { - const name = path.basename(shellPath).toLowerCase() - - if (name.startsWith('pwsh') || name.startsWith('powershell')) { - return { args: ['-NoLogo'], command: shellPath, name } - } - - if (name.startsWith('cmd')) { - return { args: [], command: shellPath, name } - } - - return posixShellSpec(shellPath) -} - -// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell -// 5.1, then comspec/cmd.exe as the universal fallback. -function windowsShellSpec() { - const command = - findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' - - return shellSpecFor(command) -} - -// Resolve the interactive shell for the embedded terminal: an explicit user -// override wins, otherwise auto-detect the best one installed for the platform. -function terminalShellCommand() { - // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare - // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical - // choice, but ignored on Windows, where it's usually a stray MSYS/Git path - // node-pty can't spawn natively. - const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim() - - if (override) { - const resolved = isExecutableFile(override) ? override : findOnPath(override) - - if (resolved) { - return shellSpecFor(resolved) - } - } - - if (IS_WINDOWS) { - return windowsShellSpec() - } - - const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) - - return posixShellSpec(shellPath || '/bin/sh') -} - -function safeTerminalCwd(cwd) { - const candidate = path.resolve(String(cwd || app.getPath('home'))) - - try { - const stat = fs.statSync(candidate) - - return stat.isDirectory() ? candidate : path.dirname(candidate) - } catch { - return app.getPath('home') - } -} - -function terminalShellEnv() { - const env = { ...process.env } - - // Electron is commonly launched through `npm run dev`; do not leak npm's - // managed prefix into a user's interactive shell (nvm/proto warn loudly). - for (const key of Object.keys(env)) { - if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { - delete env[key] - } - } - - // Strip color/theme-detection vars that ride along when Electron is launched - // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 - // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into - // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. - delete env.NO_COLOR - delete env.FORCE_COLOR - delete env.COLORFGBG - - env.COLORTERM = 'truecolor' - env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' - env.TERM = 'xterm-256color' - env.TERM_PROGRAM = 'Hermes' - env.TERM_PROGRAM_VERSION = app.getVersion() - - // Let a hermes/--tui launched in this pane know it's embedded in the desktop - // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, - // which marks the agent *backend* and gates cron/gateway behavior. - env.HERMES_DESKTOP_TERMINAL = '1' - - return env -} - -function terminalChannel(id, suffix) { - return `hermes:terminal:${id}:${suffix}` -} - -// Best-effort read of a live PTY child's current working directory so a -// reopened tab can restart the shell where the user last `cd`'d, instead of the -// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on -// POSIX; Windows has no cheap per-process cwd query without a native module, so -// it returns null and the caller falls back to the launch cwd. -function readProcessCwd(pid) { - return new Promise(resolve => { - if (!Number.isInteger(pid) || pid <= 0) { - resolve(null) - - return - } - - if (process.platform === 'linux') { - fs.promises - .readlink(`/proc/${pid}/cwd`) - .then(target => resolve(target || null)) - .catch(() => resolve(null)) - - return - } - - if (process.platform === 'darwin') { - // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. - execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { - if (err) { - resolve(null) - - return - } - - const line = String(stdout || '') - .split('\n') - .find(entry => entry.startsWith('n')) - - resolve(line ? line.slice(1) : null) - }) - - return - } - - resolve(null) - }) -} - -function disposeTerminalSession(id) { - const sessionInfo = terminalSessions.get(id) - - if (!sessionInfo) { - return false - } - - terminalSessions.delete(id) - - try { - sessionInfo.pty.kill() - } catch { - // Process may already be gone. - } - - return true -} - -ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath)) - -ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath)) - -// Reveal a path in the OS file manager (Finder / Explorer / Files). -ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - return false - } - - try { - shell.showItemInFolder(target) - - return true - } catch { - return false - } +// Local filesystem + plugin-root IPC (readDir/reveal/rename/trash/…) — see fs-ipc.ts. +registerFsIpc({ + hermesHome: HERMES_HOME, + readActiveDesktopProfile, + expandUserPath, + resolveRequestedPathForIpc, + directoryExists, + resolveGitBinary }) -// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike -// `reveal` (which selects an existing item and silently no-ops on a missing -// path — the "Open plugins folder" Windows bug), this is for the plugins door, -// which often doesn't exist on first use. `shell.openPath` returns '' on -// success or an error string; both mkdir + openPath failures are surfaced. -ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => { - const dir = String(dirPath || '').trim() +// Git-driven features (worktrees, review pane, repo scan) — see git-ipc.ts. +registerGitIpc({ resolveGitBinary, resolveGhBinary }) - if (!dir) { - return { ok: false, error: 'no path' } - } - - try { - await fs.promises.mkdir(dir, { recursive: true }) - const error = await shell.openPath(path.normalize(dir)) - - return error ? { ok: false, error } : { ok: true } - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } - } +// Embedded terminal PTY host (hermes:terminal:*) — see terminal-ipc.ts. +const terminalIpc = registerTerminalIpc({ + isWindows: IS_WINDOWS, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend: () => ensureBackend(primaryProfileKey()), + getSshConnectionState: scope => sshConnections.get(scope) }) -// The LOCAL Desktop runtime-plugin root: `/desktop-plugins`, -// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from -// the connected backend. A remote backend reports its own `hermes_home` over -// the gateway, which is a path on the REMOTE box; deriving the plugin dir from -// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the -// on-disk plugin door silently breaks (#66899). Electron owns this resolution -// so it stays valid in every connection mode. Created on demand, like openDir. -async function localPluginsRoot(dirName: string): Promise { - // Profile-aware: a named Desktop profile gets its own plugin root under - // profiles//, matching the profile-scoped hermes_home the backend - // reported before this resolver existed. 'default'/unset pins the global root. - const profile = readActiveDesktopProfile() - const base = profile && profile !== 'default' ? path.join(HERMES_HOME, 'profiles', profile) : HERMES_HOME - const dir = path.join(base, dirName) - - try { - await fs.promises.mkdir(dir, { recursive: true }) - } catch { - // Best-effort create; return the path regardless so the reveal action can - // still surface a real openPath error and the scanner can retry later. - } - - return dir -} - -ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins')) - -// The LOCAL agent-plugin root (`/plugins`), same Electron-local -// resolution as above. This is the desktop half of a UNIFIED plugin package: -// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the -// same shape as `dashboard/manifest.json`), and the renderer's disk door scans -// this root for it — one installable folder serving both SDKs. -ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins')) - -ipcMain.handle('hermes:plugin:probe', async (_event, payload) => { - const identifier = String(payload?.identifier || payload?.repo || '').trim() - - if (!identifier) { - return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] } - } - - return probePluginRepo(resolveGitBinary(), identifier) -}) - -ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => { - const identifier = String(payload?.identifier || payload?.repo || '').trim() - - if (!identifier) { - return { ok: false, error: 'identifier is required' } - } - - const desktopPluginsRoot = await localPluginsRoot('desktop-plugins') - - return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force)) -}) - -// Rename a file/folder in place. The renderer passes the existing path + a new -// base name; the destination is resolved in the SAME parent dir so a rename can -// never move the item elsewhere or traverse out. Rejects on a name collision. -ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => { - const src = String(targetPath || '').trim() - const name = String(newName || '').trim() - - if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { - throw new Error('Invalid rename') - } - - const dst = path.join(path.dirname(src), name) - - if (dst === src) { - return { path: dst } - } - - if (fs.existsSync(dst)) { - throw new Error(`"${name}" already exists`) - } - - await fs.promises.rename(src, dst) - - return { path: dst } -}) - -// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path -// is hardened (resolveRequestedPathForIpc) and the parent must already exist — -// this never creates directory trees or escapes the allowed roots, and content -// is size-capped so it can't be abused as a bulk-write primitive. -ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => { - const raw = String(filePath || '').trim() - - if (!raw) { - throw new Error('Invalid path') - } - - const text = String(content ?? '') - - if (text.length > 1_000_000) { - throw new Error('Content too large') - } - - const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' }) - - if (!directoryExists(path.dirname(resolved))) { - throw new Error('Parent directory does not exist') - } - - await fs.promises.writeFile(resolved, text, 'utf8') - - return { path: resolved } -}) - -// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete" -// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform. -ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => { - const target = String(targetPath || '').trim() - - if (!target) { - throw new Error('Invalid delete') - } - - await shell.trashItem(target) - - return true -}) - -// Git-driven worktree management ("Start work" flow). Errors surface to the -// renderer as rejected promises so it can toast a friendly message. -ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary())) - -ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) => - addWorktree(repoPath, options || {}, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) => - removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) => - switchBranch(repoPath, branch, resolveGitBinary()) -) - -ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary())) - -ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) => listBaseBranches(repoPath, resolveGitBinary())) - -// Compact repo status (branch, ahead/behind, change counts + files) for the -// composer coding rail. Returns null on a non-repo / remote backend so the rail -// hides cleanly rather than erroring. -ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary())) - -// Codex-style review pane: list changed files for a scope, fetch one file's -// unified diff, and stage / unstage / revert. Reads return empty on failure; -// mutations reject so the renderer can toast. -ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) => - reviewList(repoPath, scope, baseRef, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) => - reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary()) -) -// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view). -ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) => - fileDiffVsHead(repoPath, filePath, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) => - reviewStage(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) => - reviewUnstage(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) => - reviewRevert(repoPath, filePath ?? null, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) => - reviewRevParse(repoPath, ref, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) => - reviewCommit(repoPath, message, Boolean(push), resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) => - reviewCommitContext(repoPath, resolveGitBinary()) -) -ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary())) -ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary())) -ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) => - reviewPrList(repoPath, resolveGhBinary(), branches, numbers) -) -ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) => - reviewFetchPrComment(repoPath, resolveGhBinary(), url) -) -ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) => - reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary()) -) - -// Repo-first project discovery: scan bounded roots for git repos (pure fs walk, -// no native addon). Never throws to the renderer — failures yield an empty list. -ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => { - try { - return await scanGitRepos(roots || [], options || {}) - } catch { - return [] - } -}) - -// node-pty's published tarball ships the POSIX `spawn-helper` without an exec -// bit; the dev flow resolves node-pty straight from node_modules (nothing -// chmods it there), so the first terminal spawn dies with `posix_spawnp -// failed`. Restore the bit once, lazily, right before the first spawn. Packaged -// builds already stage an executable copy, so this is a no-op there. -let _spawnHelperEnsured = false - -function ensureNodePtySpawnHelper() { - if (_spawnHelperEnsured || IS_WINDOWS) { - return - } - - _spawnHelperEnsured = true - - try { - const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) - const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) - - for (const helperPath of fixed) { - rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) - } - - for (const failure of errors) { - rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) - } - } catch (error) { - rememberLog(`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`) - } -} - -ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { - ensureNodePtySpawnHelper() - - const id = crypto.randomUUID() - const { args, command, name } = terminalShellCommand() - const cwd = safeTerminalCwd(payload?.cwd) - const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) - - const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, () => ensureBackend(primaryProfileKey())) - const remote = Boolean(sshTarget) - const remoteState = remote ? sshConnections.get(sshTarget.scope) : null - - const remoteCommand = - remoteState?.remotePlatform === 'Windows' - ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) - : undefined - - const ptyProcess = remote - ? nodePty.spawn( - process.platform === 'win32' - ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') - : 'ssh', - buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), - { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } - ) - : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) - - terminalSessions.set(id, { - pty: ptyProcess, - webContentsId: event.sender.id, - ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) - }) - - const send = (suffix, payload) => { - if (event.sender.isDestroyed()) { - return - } - - event.sender.send(terminalChannel(id, suffix), payload) - } - - ptyProcess.onData(data => send('data', data)) - ptyProcess.onExit(({ exitCode, signal }) => { - terminalSessions.delete(id) - send('exit', { code: exitCode, signal: signal || null }) - }) - event.sender.once('destroyed', () => disposeTerminalSession(id)) - - return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } -}) - -ipcMain.handle('hermes:terminal:write', (_event, id, data) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - sessionInfo.pty.write(String(data || '')) - - return true -}) - -ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return false - } - - const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) - const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) - - sessionInfo.pty.resize(cols, rows) - - return true -}) -ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { - const sessionInfo = terminalSessions.get(String(id || '')) - - if (!sessionInfo) { - return null - } - - return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) -}) - -ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) +const disposeTerminalSession = terminalIpc.disposeTerminalSession ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ @@ -15644,9 +14858,7 @@ app.on('before-quit', event => { // Kill open PTYs before environment teardown to avoid the node-pty#904 // ThreadSafeFunction SIGABRT race. - for (const id of [...terminalSessions.keys()]) { - disposeTerminalSession(id) - } + terminalIpc.disposeAllTerminalSessions() void backendShutdown.run() }) diff --git a/apps/desktop/electron/pet-overlay-ipc.ts b/apps/desktop/electron/pet-overlay-ipc.ts new file mode 100644 index 0000000000..9738f4e8e0 --- /dev/null +++ b/apps/desktop/electron/pet-overlay-ipc.ts @@ -0,0 +1,151 @@ +// IPC surface for the pop-out pet overlay (mascot window). Extracted from +// main.ts; window handles stay injected because main.ts owns their lifecycle. +import { type BrowserWindow, ipcMain } from 'electron' + +export interface PetOverlayIpcDeps { + getMainWindow: () => BrowserWindow | null + getPetOverlayWindow: () => BrowserWindow | null + openPetOverlay: (bounds: unknown) => void + closePetOverlay: () => void +} + +export function registerPetOverlayIpc({ + getMainWindow, + getPetOverlayWindow, + openPetOverlay, + closePetOverlay +}: PetOverlayIpcDeps) { + // `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space + // bounds (screen=false): convert to screen space by adding the main window's + // content origin so the pet lands where it sat in-window. A remembered/dragged + // spot passes screen-space bounds (screen=true) and is used as-is. We return the + // resolved screen bounds so the renderer can persist exactly where it opened. + ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + const mainWindow = getMainWindow() + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } + }) + ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } + }) + // Drag/resize: the overlay reports new absolute screen bounds (it already knows + // the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale + // gesture grows/shrinks it so the sprite is never cropped by the window edge. + // The window is created non-resizable (no stray edge-drag on the transparent + // frameless panel), which on Windows/Linux also blocks programmatic setBounds + // sizing — so briefly flip resizable on whenever the size actually changes. + ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + const win = petOverlayWindow + const width = Math.max(80, Math.round(bounds.width)) + const height = Math.max(80, Math.round(bounds.height)) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + // Click-through: the overlay window is a full rectangle but only the pet pixels + // should be interactive. The renderer toggles this as the cursor enters/leaves + // the sprite so transparent margins pass clicks to whatever is behind. + ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + // The overlay is a non-activating panel (focusable:false) so it never steals + // the app's cmd/alt-tab anchor from the main window. But the pop-up composer + // needs the keyboard, so the renderer asks us to flip it focusable + focus it + // while the composer is open, then back to non-activating when it closes. + ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + + if (focusable) { + petOverlayWindow.focus() + } + }) + // Main renderer → overlay: forward the latest pet state for the overlay to render. + ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } + }) + // Overlay → main renderer: control messages (pop back in, composer submit). + ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + const mainWindow = getMainWindow() + + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) + }) +} diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index 501786863c..4a672685ec 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -28,6 +28,7 @@ import crypto from 'node:crypto' import { parseRemoteProfileListing } from './connection-registry' +import { assertBootstrapNotSuperseded } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 // Bumped when the desktop<->dashboard reuse contract changes in a way that makes @@ -558,7 +559,7 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT const remoteLog = expandRemotePath(logPath) while (Date.now() < deadline) { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) if (isAlive && !(await isAlive())) { const err: any = new Error('Remote dashboard process exited before announcing its port.') @@ -696,14 +697,6 @@ async function cancelForwardSafe(deps, localPort, remotePort) { } } -function assertNotAborted(signal) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was cancelled.') - error.kind = 'superseded' - throw error - } -} - function isForwardBindCollision(error) { return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || '')) } @@ -769,7 +762,7 @@ async function connect(deps) { const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const platform = await probeRemotePlatform(ssh) log(`remote platform ${platform.os}/${platform.arch}`) const hermesPath = await locateHermes(ssh, remoteHermesPath) @@ -810,7 +803,7 @@ async function connect(deps) { lock.hermesHome === hermesHome if (reusable) { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const localPort = await openForward(deps, lock.port) try { @@ -827,7 +820,7 @@ async function connect(deps) { } if (reuseClassification === 'authenticated-stale') { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) await cancelForwardSafe(deps, localPort, lock.port) await cleanupStale(ssh, ownershipId, lock) } else if (reuseClassification === 'authenticated-ok') { @@ -840,7 +833,7 @@ async function connect(deps) { 'reused remote dashboard' ) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`) return { @@ -868,12 +861,12 @@ async function connect(deps) { throw error } } else { - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) await cleanupStale(ssh, ownershipId, lock, pidAlive) } } - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const spawnToken = mintToken() const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, { @@ -914,21 +907,21 @@ async function connect(deps) { isAlive: () => remotePidAlive(ssh, pid), signal }) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) log(`remote dashboard bound port ${remotePort}`) localPort = await openForward(deps, remotePort) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const baseUrl = `http://127.0.0.1:${localPort}` await waitForHermes(baseUrl, spawnToken) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard') - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) const tokenFingerprint = fingerprintToken(token) await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint }) - assertNotAborted(signal) + assertBootstrapNotSuperseded(signal) return { baseUrl, diff --git a/apps/desktop/electron/ssh-connection.ts b/apps/desktop/electron/ssh-connection.ts index 5015f979e3..57264ec639 100644 --- a/apps/desktop/electron/ssh-connection.ts +++ b/apps/desktop/electron/ssh-connection.ts @@ -988,7 +988,19 @@ function createSshProbeConnection(config, options: any = {}) { return new SshConnection(config, { ...options, mux: false }) } +// Bootstrap loops poll a remote for readiness; a newer attempt aborts the +// signal so the stale one stops polling and unwinds. `superseded` tells the +// caller this was replaced, not that it failed. +function assertBootstrapNotSuperseded(signal) { + if (signal?.aborted) { + const error: any = new Error('SSH bootstrap was cancelled.') + error.kind = 'superseded' + throw error + } +} + export { + assertBootstrapNotSuperseded, baseSshOptions, buildControlArgs, buildExecArgs, diff --git a/apps/desktop/electron/terminal-ipc.ts b/apps/desktop/electron/terminal-ipc.ts new file mode 100644 index 0000000000..1b5953b859 --- /dev/null +++ b/apps/desktop/electron/terminal-ipc.ts @@ -0,0 +1,377 @@ +// The embedded terminal's PTY host: shell resolution, env scrubbing, session +// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the +// factory owns the session map and returns the dispose helpers main.ts needs +// for SSH teardown. findOnPath / logging / connection routing stay injected. +import { execFile } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { app, ipcMain } from 'electron' +import nodePty from 'node-pty' + +import { resolveTerminalConnection } from './connection-apply' +import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { buildInteractiveSshArgs } from './ssh-connection' +import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle' + +export interface TerminalIpcDeps { + isWindows: boolean + findOnPath: (command: string) => null | string + rememberLog: (line: string) => void + activeSshTerminalTarget: () => unknown + ensureBackend: () => Promise + getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string } +} + +export interface TerminalIpcApi { + disposeTerminalSession: (id: string) => boolean + disposeTerminalSessionsForSshScope: (scope: string) => void + disposeAllTerminalSessions: () => void +} + +export function registerTerminalIpc({ + isWindows, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend, + getSshConnectionState +}: TerminalIpcDeps): TerminalIpcApi { + const terminalSessions = new Map() + + function isExecutableFile(filePath) { + if (!filePath || !path.isAbsolute(filePath)) { + return false + } + + try { + fs.accessSync(filePath, fs.constants.X_OK) + + return true + } catch { + return false + } + } + + function posixShellSpec(shellPath) { + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } + } + + // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; + // prefer it only after PowerShell 7+ (`pwsh`). + function windowsPowerShellPath() { + const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + + return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') + } + + // Map a resolved shell path to its spawn spec, picking interactive flags by + // family: PowerShell drops its logo banner (so the prompt sits flush like the + // POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) + // gets POSIX interactive-login flags. + function shellSpecFor(shellPath) { + const name = path.basename(shellPath).toLowerCase() + + if (name.startsWith('pwsh') || name.startsWith('powershell')) { + return { args: ['-NoLogo'], command: shellPath, name } + } + + if (name.startsWith('cmd')) { + return { args: [], command: shellPath, name } + } + + return posixShellSpec(shellPath) + } + + // Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell + // 5.1, then comspec/cmd.exe as the universal fallback. + function windowsShellSpec() { + const command = + findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' + + return shellSpecFor(command) + } + + // Resolve the interactive shell for the embedded terminal: an explicit user + // override wins, otherwise auto-detect the best one installed for the platform. + function terminalShellCommand() { + // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare + // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical + // choice, but ignored on Windows, where it's usually a stray MSYS/Git path + // node-pty can't spawn natively. + const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim() + + if (override) { + const resolved = isExecutableFile(override) ? override : findOnPath(override) + + if (resolved) { + return shellSpecFor(resolved) + } + } + + if (isWindows) { + return windowsShellSpec() + } + + const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) + + return posixShellSpec(shellPath || '/bin/sh') + } + + function safeTerminalCwd(cwd) { + const candidate = path.resolve(String(cwd || app.getPath('home'))) + + try { + const stat = fs.statSync(candidate) + + return stat.isDirectory() ? candidate : path.dirname(candidate) + } catch { + return app.getPath('home') + } + } + + function terminalShellEnv() { + const env = { ...process.env } + + // Electron is commonly launched through `npm run dev`; do not leak npm's + // managed prefix into a user's interactive shell (nvm/proto warn loudly). + for (const key of Object.keys(env)) { + if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { + delete env[key] + } + } + + // Strip color/theme-detection vars that ride along when Electron is launched + // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 + // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into + // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. + delete env.NO_COLOR + delete env.FORCE_COLOR + delete env.COLORFGBG + + env.COLORTERM = 'truecolor' + env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' + env.TERM = 'xterm-256color' + env.TERM_PROGRAM = 'Hermes' + env.TERM_PROGRAM_VERSION = app.getVersion() + + // Let a hermes/--tui launched in this pane know it's embedded in the desktop + // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, + // which marks the agent *backend* and gates cron/gateway behavior. + env.HERMES_DESKTOP_TERMINAL = '1' + + return env + } + + function terminalChannel(id, suffix) { + return `hermes:terminal:${id}:${suffix}` + } + + // Best-effort read of a live PTY child's current working directory so a + // reopened tab can restart the shell where the user last `cd`'d, instead of the + // tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on + // POSIX; Windows has no cheap per-process cwd query without a native module, so + // it returns null and the caller falls back to the launch cwd. + function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) + } + + function disposeTerminalSession(id: string) { + const sessionInfo = terminalSessions.get(id) + + if (!sessionInfo) { + return false + } + + terminalSessions.delete(id) + + try { + sessionInfo.pty.kill() + } catch { + // Process may already be gone. + } + + return true + } + + // SSH teardown: close every pane whose PTY rode the disconnected tunnel. + function disposeTerminalSessionsForSshScope(scope: string) { + for (const [id, info] of [...terminalSessions.entries()]) { + if (info.sshScope === scope) { + disposeTerminalSession(id) + } + } + } + + // App shutdown: kill every open PTY before environment teardown. + function disposeAllTerminalSessions() { + for (const id of [...terminalSessions.keys()]) { + disposeTerminalSession(id) + } + } + + // node-pty's published tarball ships the POSIX `spawn-helper` without an exec + // bit; the dev flow resolves node-pty straight from node_modules (nothing + // chmods it there), so the first terminal spawn dies with `posix_spawnp + // failed`. Restore the bit once, lazily, right before the first spawn. Packaged + // builds already stage an executable copy, so this is a no-op there. + let _spawnHelperEnsured = false + + function ensureNodePtySpawnHelper() { + if (_spawnHelperEnsured || isWindows) { + return + } + + _spawnHelperEnsured = true + + try { + const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) + const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) + + for (const helperPath of fixed) { + rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) + } + + for (const failure of errors) { + rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) + } + } catch (error) { + rememberLog( + `[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + ensureNodePtySpawnHelper() + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + + const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, ensureBackend) + const remote = Boolean(sshTarget) + const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null + + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + + const ptyProcess = remote + ? nodePty.spawn( + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh', + buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), + { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } + ) + : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) + + terminalSessions.set(id, { + pty: ptyProcess, + webContentsId: event.sender.id, + ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) + }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + ptyProcess.onData(data => send('data', data)) + ptyProcess.onExit(({ exitCode, signal }) => { + terminalSessions.delete(id) + send('exit', { code: exitCode, signal: signal || null }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } + }) + + ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true + }) + + ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true + }) + ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) + }) + + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) + + return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions } +} diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts index 5d96853a1a..40d89b3ece 100644 --- a/apps/desktop/electron/windows-remote-lifecycle.ts +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto' -import { redactSecrets, SSH_ERROR } from './ssh-connection' +import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection' const LOCKFILE_SCHEMA_VERSION = 2 const PROTOCOL_VERSION = 1 @@ -162,14 +162,6 @@ function reusableWindowsLock(lock, state, profile, reuseToken, runtime) { ) } -function assertCurrent(signal) { - if (signal?.aborted) { - const error: any = new Error('SSH bootstrap was cancelled.') - error.kind = 'superseded' - throw error - } -} - async function processState(ssh, runtime, lock) { return helper(ssh, runtime, 'process-state', [ String(lock.pid), @@ -215,7 +207,7 @@ async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) let state try { @@ -286,7 +278,7 @@ async function connectWindowsRemote(deps) { readyTimeoutMs = 45_000 } = deps - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) const runtime = await probeWindowsRemote(ssh, remoteHermesPath) const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) @@ -356,7 +348,7 @@ async function connectWindowsRemote(deps) { await helper(ssh, runtime, 'remove-lock', [ownershipId]) } - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) const token = crypto.randomBytes(32).toString('hex') const spawnNonce = crypto.randomBytes(8).toString('hex') await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token) @@ -405,7 +397,7 @@ async function connectWindowsRemote(deps) { await forward(localPort, remotePort) const baseUrl = `http://127.0.0.1:${localPort}` await waitForHermes(baseUrl, token) - assertCurrent(signal) + assertBootstrapNotSuperseded(signal) await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort })) return { diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json index 6b331453fb..058f132dea 100644 --- a/apps/desktop/tsconfig.electron.json +++ b/apps/desktop/tsconfig.electron.json @@ -16,6 +16,6 @@ "rootDir": "..", "outDir": "build/electron-types" }, - "include": ["electron", "../shared/src/translucency.ts"], + "include": ["electron", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], "exclude": ["src", "electron/**/*.e2e.mts"] }