feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) (#40355)

* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI)

Adds a GUI-only uninstall path so people can remove the desktop Chat GUI
while keeping the Hermes agent + their config/sessions/.env, and surfaces
the three CLI uninstall modes inside the desktop app's Settings → About.

CLI:
- New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the
  desktop GUI's artifacts (source-built dist/release/node_modules + build
  stamp, the packaged app bundle, and the Electron userData dir) on Linux,
  macOS, and Windows. Never touches the agent source, venv, or user data.
- `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a
  JSON install snapshot (used by the desktop UI to gate options + detect a
  missing agent for a future lite client).
- `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing
  the destructive sequence via a new _perform_uninstall() helper. The keep-data
  and full flows also sweep the GUI artifacts.

Desktop:
- electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full)
  to CLI flags, resolving the running app bundle per OS, and building the
  detached cleanup script that waits for the app to exit, runs the Python
  uninstall, and removes the bundle.
- IPC hermes:uninstall:summary / :run, preload bridge, and types.
- Settings → About "Danger zone" with the three options; agent-removing
  options hide when no local agent is detected.

Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing
uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into
test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md.

* fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety)

The desktop uninstall cleanup script waited only on the desktop app's own
PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can
outlive it and keep hermes.exe + venv files mandatory-locked on Windows —
making the script's rmdir half-fail and leaving a partial install, the same
failure class as the self-update path's #37532.

- main.cjs: runDesktopUninstall now awaits releaseBackendLock() before
  spawning the cleanup script — tree-kills every backend PID the desktop owns
  (primary + pool) via taskkill /T /F and polls the venv shim until unlocked.
  Extracted the shared core out of releaseBackendLockForUpdate so both the
  update hand-off and the uninstaller use the identical, incident-hardened
  teardown. No-op on macOS/Linux (no mandatory locks).
- desktop-uninstall.cjs: Windows cleanup script removes the bundle via a
  bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows
  releases directory handles lazily even after the holding process exits.
- Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side
  tree-kill-by-PID is the reliable mechanism.

Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass).

* fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop)

Resolves @OutThisLife's review on #40355:

1. full mode now gated on agent presence (needsAgent: true). It removes the
   agent + user data, so on a lite client with no local agent it's hidden
   like lite — no more offering to remove an agent that isn't there.

2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the
   venv's OWN python. On Windows a running python.exe is mandatory-locked, so
   that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X'
   entrypoint (stdlib-only imports) lets the desktop run agent-removing modes
   under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so
   import hermes_cli resolves from source while the venv is torn down. Falls
   back to venv python + logs when no system python (gui-only unaffected).

3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the
   PID as a whole space-delimited token via findstr (no substring 99->990
   trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted.

4. Renamed the misleading 'returns null for dev run' test — the dev-run safety
   is shouldRemoveAppBundle(isPackaged=false), which the test now asserts.

Docs: note that --gui on a source checkout also sweeps node_modules/build
output. Tests: 18 python + 19 desktop pass.
This commit is contained in:
Teknium
2026-06-06 18:22:38 -07:00
committed by GitHub
parent f2e8234307
commit 5b43bf7d02
14 changed files with 1784 additions and 10 deletions
+232
View File
@@ -0,0 +1,232 @@
/**
* desktop-uninstall.cjs
*
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
* `hermes uninstall --gui --yes`
* - 'lite' → remove the GUI + agent code, KEEP user data (config / sessions
* / .env) for a future reinstall. `hermes uninstall --yes`
* - 'full' → remove everything: GUI + agent + all user data.
* `hermes uninstall --full --yes`
*
* Why a detached cleanup script: 'lite'/'full' delete the very venv the
* `hermes` command runs from, and every mode may need to delete the running
* app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle — then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.cjs.
*/
const path = require('node:path')
const UNINSTALL_MODES = ['gui', 'lite', 'full']
/**
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
* python executable). Uses the dedicated lightweight module entrypoint (not
* `hermes_cli.main`) so it can run under a system Python OUTSIDE the venv that
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
* Throws on an unknown mode so a typo can't silently become a full wipe.
*/
function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
/** True when `mode` removes the agent (lite/full), false for gui-only. */
function modeRemovesAgent(mode) {
return mode === 'lite' || mode === 'full'
}
/** True when `mode` removes user data (full only). */
function modeRemovesUserData(mode) {
return mode === 'full'
}
/**
* Resolve the on-disk app bundle/dir to remove for the running desktop app,
* given the path to the running executable (`process.execPath`) and platform.
*
* macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Hermes.app
* Windows: …\Hermes\Hermes.exe → …\Hermes (install dir)
* Linux: AppImage → the APPIMAGE env path; unpacked → the *-unpacked dir
*
* Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree).
*/
function resolveRemovableAppPath(execPath, platform, env = {}) {
const exe = String(execPath || '')
if (!exe) return null
// Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even
// when these pure helpers are unit-tested on Linux/macOS CI.
const p = platform === 'win32' ? path.win32 : path.posix
if (platform === 'darwin') {
// …/Hermes.app/Contents/MacOS/Hermes → strip 3 segments to the .app
const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
return null
}
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) return env.APPIMAGE
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
return null
}
/**
* Should we even try to remove the running app bundle from a cleanup script?
* Only when packaged AND we resolved a concrete removable path. Dev runs
* (electron from node_modules) and system-package installs return null above
* and are left to the OS package manager.
*/
function shouldRemoveAppBundle(isPackaged, appPath) {
return Boolean(isPackaged) && Boolean(appPath)
}
/**
* Build a POSIX cleanup shell script (macOS / Linux). It:
* 1. waits (bounded ~30s) for the desktop PID to exit (venv/bundle unlock),
* 2. runs the Python uninstall module with the mode,
* 3. removes the app bundle if one was resolved.
*
* `pythonExe` should be a Python OUTSIDE the venv for lite/full (the venv is
* being deleted); `pythonPath` is prepended to PYTHONPATH so `import hermes_cli`
* resolves from the agent source. `q()` single-quote-escapes for the shell
* (closes-escapes-reopens any embedded apostrophe), defending against spaces.
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
'# and the app bundle are no longer in use.',
`pid=${Number(desktopPid) || 0}`,
'if [ "$pid" -gt 0 ]; then',
' for _ in $(seq 1 60); do',
' kill -0 "$pid" 2>/dev/null || break',
' sleep 0.5',
' done',
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(
`cd ${q(agentRoot)} 2>/dev/null || true`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
/**
* Build a Windows cleanup batch script. Same three steps, cmd.exe flavored.
*
* Finding 3 (venv self-deletion): for lite/full the agent uninstall rmtree's
* the venv that contains `python.exe`. A running .exe is mandatory-locked on
* Windows, so running the uninstall from the venv's OWN python half-fails. The
* desktop passes a system Python (findSystemPython) as `pythonExe` for those
* modes + `pythonPath`=agentRoot so `import hermes_cli` resolves from source
* while the venv is torn down. gui-only doesn't touch the venv, so it can use
* either interpreter.
*
* Wait-loop: bounded (matches POSIX's ~30s cap) so a never-exiting / mismatched
* PID can't wedge the cleanup forever. The `/FI "PID eq"` filter is an EXACT
* match, so no redundant `| find` (which would substring-match 99→990).
*
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
*/
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
'rem /FI "PID eq %PID%" is an EXACT filter — tasklist outputs the one task',
'rem row for that PID, or "INFO: No tasks..." otherwise. /NH drops the',
'rem header; findstr matches the PID as a whole space-delimited token so',
'rem PID 99 cannot match 990 (the substring trap of a bare `find`).',
'tasklist /NH /FI "PID eq %PID%" 2>nul | findstr /r /c:" %PID% " >nul',
'if %ERRORLEVEL% neq 0 goto waited_done',
'set /a waited+=1',
'if %waited% geq 60 goto waited_done',
'timeout /t 1 /nobreak >nul',
'goto waitloop',
':waited_done',
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
':rmloop',
`if not exist ${q(appPath)} goto rmdone`,
`rmdir /s /q ${q(appPath)} >nul 2>&1`,
`if not exist ${q(appPath)} goto rmdone`,
'set /a tries+=1',
'if %tries% geq 10 goto rmdone',
'timeout /t 1 /nobreak >nul',
'goto rmloop',
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
module.exports = {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
}
@@ -0,0 +1,246 @@
/**
* Tests for electron/desktop-uninstall.cjs.
*
* Run with: node --test electron/desktop-uninstall.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
* mode → CLI-flag mapping, the running-app-bundle resolution per OS, and the
* cleanup-script builders (POSIX + Windows).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
// --- uninstallArgsForMode ---
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
})
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
})
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
})
// --- modeRemovesAgent / modeRemovesUserData ---
test('mode predicates classify what each mode removes', () => {
assert.equal(modeRemovesAgent('gui'), false)
assert.equal(modeRemovesAgent('lite'), true)
assert.equal(modeRemovesAgent('full'), true)
assert.equal(modeRemovesUserData('gui'), false)
assert.equal(modeRemovesUserData('lite'), false)
assert.equal(modeRemovesUserData('full'), true)
})
// --- resolveRemovableAppPath ---
test('resolveRemovableAppPath finds the .app bundle on macOS', () => {
assert.equal(
resolveRemovableAppPath('/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
'/Applications/Hermes.app'
)
assert.equal(
resolveRemovableAppPath('/Users/x/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
'/Users/x/Applications/Hermes.app'
)
})
test('resolveRemovableAppPath: dev-run .app resolves (safety is shouldRemoveAppBundle, not null)', () => {
// A dev run from node_modules' Electron DOES resolve to a .app — the real
// dev-run safety gate is shouldRemoveAppBundle(isPackaged=false,...), not a
// null return here. This test documents that contract.
assert.equal(
resolveRemovableAppPath('/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', 'darwin'),
'/repo/node_modules/electron/dist/Electron.app'
)
assert.equal(shouldRemoveAppBundle(false, '/repo/node_modules/electron/dist/Electron.app'), false)
// A bare path with no .app ancestor → null.
assert.equal(resolveRemovableAppPath('/usr/bin/electron', 'darwin'), null)
})
test('resolveRemovableAppPath finds the install dir on Windows', () => {
assert.equal(
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\Programs\\Hermes\\Hermes.exe', 'win32'),
'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes'
)
assert.equal(
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\hermes-desktop\\Hermes.exe', 'win32'),
'C:\\Users\\x\\AppData\\Local\\hermes-desktop'
)
})
test('resolveRemovableAppPath returns null for an unrecognized Windows dir', () => {
assert.equal(resolveRemovableAppPath('C:\\Temp\\foo\\Hermes.exe', 'win32'), null)
})
test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
assert.equal(
resolveRemovableAppPath('/tmp/.mount_HermesXXXX/hermes', 'linux', { APPIMAGE: '/home/x/Apps/Hermes.AppImage' }),
'/home/x/Apps/Hermes.AppImage'
)
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
assert.equal(
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
'/opt/hermes/linux-unpacked'
)
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})
test('resolveRemovableAppPath returns null for an empty exe path', () => {
assert.equal(resolveRemovableAppPath('', 'darwin'), null)
assert.equal(resolveRemovableAppPath(null, 'win32'), null)
})
// --- shouldRemoveAppBundle ---
test('shouldRemoveAppBundle requires packaged AND a resolved path', () => {
assert.equal(shouldRemoveAppBundle(true, '/Applications/Hermes.app'), true)
assert.equal(shouldRemoveAppBundle(false, '/Applications/Hermes.app'), false)
assert.equal(shouldRemoveAppBundle(true, null), false)
assert.equal(shouldRemoveAppBundle(false, null), false)
})
// --- buildPosixCleanupScript ---
test('buildPosixCleanupScript waits for the PID, runs the uninstall module, removes bundle', () => {
const script = buildPosixCleanupScript({
desktopPid: 4321,
pythonExe: '/home/x/.hermes/hermes-agent/venv/bin/python',
pythonPath: null,
agentRoot: '/home/x/.hermes/hermes-agent',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
// bounded wait (~30s), not unbounded
assert.match(script, /seq 1 60/)
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'gui'/)
assert.match(script, /rm -rf '\/opt\/hermes\/linux-unpacked'/)
assert.match(script, /export HERMES_HOME='\/home\/x\/\.hermes'/)
})
test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/full)', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/usr/bin/python3',
pythonPath: '/home/x/.hermes/hermes-agent',
agentRoot: '/home/x/.hermes/hermes-agent',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
assert.match(script, /'\/usr\/bin\/python3' '-m' 'hermes_cli\.uninstall' '--mode' 'full'/)
})
test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/p/python',
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/p/python',
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'lite'],
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
})
test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: "/home/o'brien/python",
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/)
})
// --- buildWindowsCleanupScript ---
test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir bundle', () => {
const script = buildWindowsCleanupScript({
desktopPid: 9988,
pythonExe: 'C:\\Python313\\python.exe',
pythonPath: 'C:\\hermes',
agentRoot: 'C:\\hermes',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source.
assert.match(script, /set "PYTHONPATH=C:\\hermes;%PYTHONPATH%"/)
assert.match(script, /"C:\\Python313\\python.exe" "-m" "hermes_cli\.uninstall" "--mode" "full"/)
// Bounded wait-loop (no infinite loop), whole-token PID match (no substring).
assert.match(script, /if %waited% geq 60 goto waited_done/)
assert.match(script, /findstr \/r \/c:" %PID% "/)
assert.doesNotMatch(script, /find "%PID%"/) // the old substring-prone form is gone
// Removal is a retry loop (Windows releases dir handles lazily).
assert.match(script, /:rmloop/)
assert.match(script, /rmdir \/s \/q "C:\\Users\\x\\AppData\\Local\\Programs\\Hermes" >nul 2>&1/)
assert.match(script, /if %tries% geq 10 goto rmdone/)
assert.match(script, /del "%~f0"/)
})
test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, no bundle)', () => {
const script = buildWindowsCleanupScript({
desktopPid: 2,
pythonExe: 'C:\\h\\venv\\Scripts\\python.exe',
pythonPath: null,
agentRoot: 'C:\\h',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/)
})
+218 -4
View File
@@ -29,6 +29,15 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -1488,6 +1497,20 @@ function forceKillProcessTree(pid) {
// 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 }
// Collect every backend PID the desktop owns: primary window backend + pool.
@@ -1512,14 +1535,12 @@ async function releaseBackendLockForUpdate(updateRoot) {
const deadlineMs = Date.now() + 15000
while (Date.now() < deadlineMs) {
if (!isShimLocked(shim)) {
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
return { unlocked: true }
}
await new Promise(r => setTimeout(r, 300))
}
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
// line of defense, and we pass --force so the guard won't dead-end. Log it.
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
return { unlocked: false }
}
@@ -5473,6 +5494,199 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ===========================================================================
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
// ===========================================================================
//
// The renderer's About → Danger Zone surfaces three options that mirror the
// CLI exactly: GUI only, Lite (keep user data), Full. We ask the agent to do
// the actual removal via `hermes uninstall …` so the cross-platform PATH /
// registry / service / node-symlink cleanup all lives in one place
// (hermes_cli/uninstall.py + hermes_cli/gui_uninstall.py).
//
// getUninstallSummary() shells out to `--gui-summary` (a fast, no-side-effect
// JSON probe) so the UI can gate options on what's actually installed — and
// detect a missing agent (a future "lite client" that ships without the
// bundled agent), hiding the agent/full options when there's nothing to remove.
function uninstallVenvPython() {
return getVenvPython(VENV_ROOT)
}
async function getUninstallSummary() {
const py = uninstallVenvPython()
const agentRoot = ACTIVE_HERMES_ROOT
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
// probe fails — the renderer still needs *something* to render options from.
const fallback = () => ({
hermes_home: HERMES_HOME,
agent_installed: isHermesSourceRoot(agentRoot) && fileExists(py),
gui_installed: true,
source_built_artifacts: [],
packaged_app_paths: [],
userdata_dir: app.getPath('userData'),
userdata_exists: true,
platform: process.platform,
probe: 'fallback'
})
if (!fileExists(py)) {
return fallback()
}
return new Promise(resolve => {
let stdout = ''
let settled = false
const done = value => {
if (settled) return
settled = true
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
child.on('error', () => done(fallback()))
child.on('exit', code => {
if (code !== 0) return done(fallback())
try {
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'
const parsed = JSON.parse(line)
// The app bundle the renderer would be removing on *this* machine,
// resolved from the running exe (the Python probe only knows the
// standard locations, not where THIS build actually runs from).
parsed.running_app_path = resolveRemovableAppPath(process.execPath, process.platform, process.env)
done(parsed)
} catch {
done(fallback())
}
})
setTimeout(() => done(fallback()), 8000)
} catch {
done(fallback())
}
})
}
async function runDesktopUninstall(mode) {
let uninstallArgs
try {
uninstallArgs = uninstallArgsForMode(mode)
} catch (error) {
return { ok: false, error: 'invalid-mode', message: error.message }
}
const venvPy = uninstallVenvPython()
if (!fileExists(venvPy)) {
return {
ok: false,
error: 'agent-missing',
message: `Can't run the uninstaller: no Hermes agent venv at ${VENV_ROOT}.`
}
}
// Interpreter choice (Finding 3): lite/full rmtree the venv that holds the
// running python.exe. On Windows a running .exe is mandatory-locked, so the
// rmtree must NOT be driven by the venv's own interpreter — use a system
// Python with PYTHONPATH=<agentRoot> so `import hermes_cli` resolves from
// source while the venv is torn down. gui-only doesn't touch the venv, so the
// venv python is fine there. If no system Python exists (the Windows edge
// case), fall back to the venv python — gui-only is unaffected; lite/full may
// leave venv remnants the user can delete, which we log.
let py = venvPy
let pythonPath = null
if (modeRemovesAgent(mode)) {
const sysPy = findSystemPython()
if (sysPy) {
py = sysPy
pythonPath = ACTIVE_HERMES_ROOT
} else if (IS_WINDOWS) {
rememberLog(
'[uninstall] no system Python found for lite/full on Windows; falling back ' +
'to the venv python — venv files locked by the running interpreter may ' +
'remain and need manual deletion.'
)
}
}
const appPath = resolveRemovableAppPath(process.execPath, process.platform, process.env)
const removeBundle = shouldRemoveAppBundle(IS_PACKAGED, appPath) ? appPath : null
// CRITICAL (Windows): tear down every backend the desktop owns and wait for
// the venv shim to unlock BEFORE the cleanup script runs. lite/full delete
// the venv, and even gui-only removes the install tree's GUI artifacts — a
// live backend grandchild (gateway / pty / REPL) holding a mandatory file
// lock would make the script's rmdir half-fail (#37532 for the update path).
// Reuses the incident-hardened update teardown; no-op on macOS/Linux.
try {
await releaseBackendLock(ACTIVE_HERMES_ROOT, 'uninstall')
} catch (error) {
rememberLog(`[uninstall] backend teardown errored (continuing): ${error.message}`)
}
const scriptArgs = {
desktopPid: process.pid,
pythonExe: py,
pythonPath,
agentRoot: ACTIVE_HERMES_ROOT,
uninstallArgs,
appPath: removeBundle,
hermesHome: HERMES_HOME
}
let scriptPath
let runner
let runnerArgs
try {
if (IS_WINDOWS) {
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`)
fs.writeFileSync(scriptPath, buildWindowsCleanupScript(scriptArgs))
runner = process.env.ComSpec || 'cmd.exe'
runnerArgs = ['/c', scriptPath]
} else {
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.sh`)
fs.writeFileSync(scriptPath, buildPosixCleanupScript(scriptArgs), { mode: 0o755 })
runner = '/bin/bash'
runnerArgs = [scriptPath]
}
} catch (error) {
return { ok: false, error: 'script-write-failed', message: error.message }
}
try {
const child = spawn(runner, runnerArgs, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
} catch (error) {
return { ok: false, error: 'spawn-failed', message: error.message }
}
rememberLog(
`[uninstall] launched detached cleanup (${mode}): ${scriptPath} ` +
`(removesAgent=${modeRemovesAgent(mode)} removesUserData=${modeRemovesUserData(mode)} bundle=${removeBundle || 'none'})`
)
// Give the renderer a beat to show its "uninstalling…" state, then quit so
// the venv python shim + app bundle unlock and the cleanup script can run.
setTimeout(() => app.quit(), 800)
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
}
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
+4
View File
@@ -117,6 +117,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
uninstall: {
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
},
updates: {
check: () => ipcRenderer.invoke('hermes:updates:check'),
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
+1 -1
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -17,6 +17,7 @@ import {
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { UninstallSection } from './uninstall-section'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
@@ -167,6 +168,8 @@ export function AboutSettings() {
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<UninstallSection />
</div>
</SettingsContent>
)
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Loader2, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { DesktopUninstallMode, DesktopUninstallSummary } from '@/global'
import { SectionHeading } from './primitives'
interface ModeOption {
mode: DesktopUninstallMode
title: string
description: string
/** Shown in the confirm step so people know exactly what disappears. */
consequence: string
/** True when the option removes the Python agent (hidden if no agent). */
needsAgent: boolean
}
const OPTIONS: ModeOption[] = [
{
mode: 'gui',
title: 'Uninstall Chat GUI only',
description: 'Remove this desktop app. The Hermes agent, your config, and chats all stay.',
consequence: 'the desktop Chat GUI (this app and its data)',
needsAgent: false
},
{
mode: 'lite',
title: 'Uninstall GUI + agent, keep my data',
description: 'Remove the app and the Hermes agent, but keep config, chats, and secrets for a future reinstall.',
consequence: 'the Chat GUI and the Hermes agent (config, chats, and secrets are kept)',
needsAgent: true
},
{
mode: 'full',
title: 'Uninstall everything',
description: 'Remove the app, the agent, and all user data — config, chats, scheduled jobs, secrets, logs.',
consequence: 'EVERYTHING — the Chat GUI, the Hermes agent, and all of your config, chats, secrets, and logs',
// full removes the agent (and user data), so it's an agent-removing option:
// hide it on a lite client with no local agent, same as lite. A lite client
// connecting to a remote backend has no local agent OR local user data the
// GUI installed, so gui-only is the correct (and only) option there.
needsAgent: true
}
]
export function UninstallSection() {
const [summary, setSummary] = useState<DesktopUninstallSummary | null>(null)
const [loading, setLoading] = useState(true)
const [pending, setPending] = useState<DesktopUninstallMode | null>(null)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
setLoading(false)
return
}
void bridge
.summary()
.then(result => {
if (alive) {
setSummary(result)
}
})
.catch(() => {
// Non-fatal — we degrade to offering the GUI-only option.
})
.finally(() => {
if (alive) {
setLoading(false)
}
})
return () => {
alive = false
}
}, [])
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
return null
}
// Gate the agent-removing options on whether an agent is actually present.
// A future lite client that ships without the bundled agent shows GUI-only.
const agentInstalled = summary?.agent_installed ?? false
const visibleOptions = OPTIONS.filter(opt => agentInstalled || !opt.needsAgent)
const handleConfirm = async () => {
if (!pending) {
return
}
setRunning(true)
setError(null)
try {
const result = await bridge.run(pending)
if (!result.ok) {
setError(result.message || result.error || 'Uninstall could not start.')
setRunning(false)
setPending(null)
}
// On success the app quits shortly; keep the spinner up until it does.
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setRunning(false)
setPending(null)
}
}
const pendingOption = OPTIONS.find(opt => opt.mode === pending) ?? null
return (
<div className="mx-auto mt-8 w-full max-w-2xl">
<SectionHeading icon={AlertTriangle} title="Danger zone" />
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
{loading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking what&apos;s installed
</div>
) : pendingOption ? (
<div>
<p className="text-sm font-medium text-destructive">Confirm uninstall</p>
<p className="mt-1 text-xs text-muted-foreground">
This removes {pendingOption.consequence}. This can&apos;t be undone.
</p>
{summary?.running_app_path && (
<p className="mt-1 font-mono text-[0.68rem] text-muted-foreground/60">
App: {summary.running_app_path}
</p>
)}
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
disabled={running}
onClick={() => void handleConfirm()}
size="sm"
variant="destructive"
>
{running && <Loader2 className="size-3 animate-spin" />}
{running ? 'Uninstalling…' : 'Yes, uninstall'}
</Button>
<Button disabled={running} onClick={() => setPending(null)} size="sm" variant="text">
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Uninstall Hermes</p>
<p className="text-xs text-muted-foreground">
Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back.
</p>
<div className="mt-1 flex flex-col gap-2">
{visibleOptions.map(opt => (
<button
className={cn(
'flex items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 text-left transition',
'hover:border-destructive/40 hover:bg-destructive/5'
)}
key={opt.mode}
onClick={() => {
setError(null)
setPending(opt.mode)
}}
type="button"
>
<Trash2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{opt.title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{opt.description}</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}
+28
View File
@@ -81,6 +81,10 @@ declare global {
setBranch: (name: string) => Promise<{ branch: string }>
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
}
uninstall: {
summary: () => Promise<DesktopUninstallSummary>
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
}
}
}
}
@@ -104,6 +108,30 @@ export interface DesktopVersionInfo {
hermesRoot: string
}
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
export interface DesktopUninstallSummary {
hermes_home: string
agent_installed: boolean
gui_installed: boolean
source_built_artifacts: string[]
packaged_app_paths: string[]
userdata_dir: string
userdata_exists: boolean
platform: string
running_app_path?: null | string
probe?: string
}
export interface DesktopUninstallResult {
ok: boolean
mode?: DesktopUninstallMode
willRemoveAppBundle?: boolean
scriptPath?: string
error?: string
message?: string
}
export interface DesktopUpdateCommit {
sha: string
summary: string