9581 Commits

Author SHA1 Message Date
kshitijk4poor
230f89b47b fix(sessions): share the write-guard filter and trim repeated walks
prune_sessions hand-rolled the same "guarded by a live lease/lock"
comprehension as the new _guarded_ids helper, so the two could drift on
the next guard change. Move _guarded_ids next to _write_guards_reject in
the maintenance mixin and have prune call it.

delete_session walked the delegate tree up to three times in one write
transaction; compute the target ids once for both the guard check and the
expected-ids fence. delete_sessions ran a per-root guard walk for every
selected id; do one batched check over all roots and their children first
and only attribute per root when something is actually guarded.

The CLI export --delete message repeated "session 'X'" because the
exception text already names the session.
2026-09-27 20:49:04 +05:30
kshitijk4poor
173770144f fix(sessions): tell the user when a delete is refused for a live turn
The browse picker swallowed SessionActiveWriteGuardError into a generic
"Delete failed.", and the dashboard bulk delete only reported the deleted
count, silently keeping rows a live turn owns. Surface both: the picker
flashes that the session is active, and SessionsPage shows a toast with the
skipped_active count (new en key; other locales fall back to English via
defineLocale). Also move the api_server import into its sorted slot.
2026-09-27 20:49:04 +05:30
kshitijk4poor
907e3188da fix(sessions): report bulk-delete rows skipped for a live turn
delete_sessions(exclude_active_write_guards=True) dropped guarded rows
silently: the web bulk-delete endpoint returned only a count and the
dashboard removed every selected row optimistically, so refused rows
reappeared on the next reload with no explanation.

The store now appends refused ids to an optional skipped_ids list inside
the same write transaction, the endpoint returns them as skipped_active,
and SessionsPage keeps those rows listed. Also hoists the
SessionActiveWriteGuardError imports to module top (hermes_state_errors
is stdlib-only) and drops the assertion-less lineage comment in the test.
2026-09-27 20:49:04 +05:30
shali10
40523600b0 fix(sessions): refuse to delete a session row a live turn still owns (#123583)
Refactor entry-side deletion refusal to execute in-transaction via
`_write_guards_reject(conn, sid)` (#123583), per maintainer review:

- Underlying `delete_session` and `delete_sessions` now accept an opt-in
  kwarg `exclude_active_write_guards=True` running inside `_do` write
  transaction, eliminating the race condition where a turn acquires the lease
  between check and delete.
- Raises `SessionActiveWriteGuardError` when refusing single delete, leaving
  the row untouched; `delete_sessions` atomically skips active rows.
- Checks both active turn leases and compression locks via the existing
  reclaim-aware `_write_guards_reject` helper.
- Covers all user-facing delete sinks:
  * Web `DELETE /api/sessions/{id}` -> 409 Conflict
  * Web `POST /api/sessions/bulk-delete` -> skips active rows
  * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage
    parents of active conversations are not pruned
  * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn
  * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1
  * CLI browse picker -> refuses active delete
  * TUI Gateway `session.delete` -> 4023 error
- Conforms to rubric with 2 targeted invariant tests in
  `tests/hermes_state/test_delete_session_write_guards.py`.
- Updates user guide and web dashboard docs for 409 / exit 1.

(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
2026-09-27 20:49:04 +05:30
kshitijk4poor
47a738da3b refactor(files): share the 409 mapping with the preview read; encode base64 after release
Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30
kshitijk4poor
fc53717f72 fix(files): one live-DB refusal source; hold the lock through /api/files/read
Gate r2 Low cleanups (house rule: no aliases/shims):
- Drop the is_live_database_file alias; its point-in-time caveat now lives on
  has_live_connection.
- _refuse_live_database reuses offline_file_access's message (via _serve_offline),
  so a download 409 on state.db-shm names the main database like the read path;
  the verb is "serve" so it fits read/download/stream.
- /api/files/read reads whole files in-process, so _read_base64_file now holds
  offline_file_access through close (409 on a live DB; OSError stays 500). Only
  the streamed FileResponse routes keep the point-in-time check.
- That check takes the global _live_lock, which other threads hold across
  whole-file reads, so fs_download and the managed stream routes run it via
  asyncio.to_thread instead of stalling the event loop.
- _managed_readable_file docstring no longer claims a size cap;
  _read_file_reference returns (early, text) instead of a str|Expansion union
  sniffed with isinstance.

Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30
kshitijk4poor
48196b0ceb fix(files): refuse live-DB downloads and share one sidecar-aware liveness check
FileResponse opens and closes the file in the dashboard process, so
downloading a live state.db (or its -shm/-wal) via /api/fs/download or
the managed-file read/download/media routes still cancelled the
connection's POSIX locks. Both now return 409 via is_live_database_file;
the registry lock is not held across the streamed response.

The main-or-WAL-sidecar rule now lives in one _live_main_key helper used
by offline_file_access, has_live_connection and read_header_bytes_preopen,
and the sidecar refusal names the main database the connection is open on.

@file previews hold _live_lock only for the raw read; token counting and
formatting run after release. The Linux lock test gains requires_wal
(Hermes uses DELETE mode on WAL-reset-vulnerable SQLite), covers the
download refusal, and drops an ambiguous conditional assert.

Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30
Benjamin PERRY
af595ff8a6 fix(files): preserve SQLite locks during previews
Co-Authored-By: Hermes Agent / OpenAI Codex / gpt-6-sol <noreply@agents.invalid>
(cherry picked from commit 60d879c125177cd4cacdbc840f16c2e048b4199c)
2026-09-27 20:45:56 +05:30
kshitijk4poor
3dac1b3ae1 fix(auth): read flat refresh_token for forkable pool rows; reuse pool ownership in load_pool
_is_forkable_pool_row only ever receives flat credential_pool rows (the
strip loop over pool entries and heal_pool_rows over _pool_rows), so the
tokens-nesting fallback of _block_tokens was dead weight; read
refresh_token the same way _is_oauth_pool_payload does.

load_pool asked _profile_owns_pool_provider (an uncached auth.json read)
twice. Compute it once after the fork heal and reuse it for the
_borrowed_root_ids check unless _persist() rewrote the store in between
(that write can give the profile its own rows). _seed_nous_singleton keeps
its own call: threading the value through _seed_from_singletons would
change a signature that tests monkeypatch with fixed-arity fakes.
2026-09-27 18:37:01 +05:30
kshitijk4poor
e61c45bc2d fix(auth): keep agent_key-only nous pool rows when stripping/healing forks
Adding nous to SINGLE_USE_REFRESH_POOL_PROVIDERS made the clone strip drop
every nous oauth pool row, including agent_key-only ones, while the
refresh_token-gated block strip kept the matching agent_key-only
providers.nous block. The profile then borrowed root's pool rows and its next
load_pool('nous') seeded its own block over root's shared row, writing the
profile's agent key into root (and every borrowing sibling).

Strip (and heal) a nous pool row only when it carries a refresh_token, the
same predicate the providers-block strip uses. The heal test now also covers
a fork that lives only in providers.nous (flat tokens), which previously had
no test coverage.
2026-09-27 18:37:01 +05:30
kshitijk4poor
ec262e177d fix(auth): strip and heal cloned nous providers refresh grant
With nous in SINGLE_USE_REFRESH_POOL_PROVIDERS the pool row is stripped,
but providers.nous still carried the same single-use refresh token, and
nous load_pool/refresh re-seed from that block, so the profile still
forked the grant. Add nous to _DEVICE_CODE_BLOCK_PROVIDERS, read the
flat Nous token shape as well as the nested tokens shape, and only
strip/heal a block that carries a refresh token so an agent_key-only
nous block survives.

Refs #121649

Co-authored-by: salch-cred <salch-cred@users.noreply.github.com>
2026-09-27 18:37:01 +05:30
salch-cred
6a3db7c487 fix(auth): treat nous as a single-use refresh pool provider
Nous portal refresh tokens rotate on redemption, so a nous pool row
cloned into a profile forks one grant into two owners and the first to
refresh strands the other. Adding nous to
SINGLE_USE_REFRESH_POOL_PROVIDERS makes profile clone strip, fork heal
and borrowed-row persistence cover it.

Salvaged (auth_oauth_grants.py hunk only) from PR #121781, commit
dc44df5c741; the dashboard_procs.py half is out of scope here.

Fixes #121649
2026-09-27 18:37:01 +05:30
kshitijk4poor
818c096033 fix(kanban): count only real gc removals and resolve kanban home once
gc ran the full managed-root predicate before checking the dir exists, and
rmtree on a symlinked scratch path silently did nothing (ignore_errors) yet
still bumped the removed count. Check is_dir()/is_symlink() first (cheap,
and most archived rows were already cleaned at completion) and count a
removal only when the path is actually gone.

_managed_scratch_path_info re-resolved the same kanban home once per board
root; resolve it once and pass the real anchor into _add_root.

Co-authored-by: Kyle Caponi <94931731+kylecap9@users.noreply.github.com>
2026-09-27 18:29:18 +05:30
kshitijk4poor
7dfdef64bb refactor(kanban): drop gc's redundant resolved relative_to check
_is_managed_scratch_path now requires a scratch path to be strictly below a
managed workspaces root both lexically and after resolving symlinks, which
subsumes the old resolve()+relative_to(scratch_root) guard. That leftover
check only narrowed gc to the current board's root and rmtree'd the
resolved spelling; gc now deletes the same path, with the same predicate,
as completion cleanup.

Co-authored-by: Kyle Caponi <94931731+kylecap9@users.noreply.github.com>
2026-09-27 18:29:18 +05:30
Kyle Caponi
37e89a7abe fix(kanban): gc must never delete the scratch workspaces root itself
`hermes kanban gc` checked archived scratch paths with resolve() +
relative_to(scratch_root), which also accepts the root itself. A scratch
task whose workspace_path is the managed workspaces root (the kanban_create
tool accepts an explicit workspace_path) therefore made gc rmtree every
task's scratch directory once it was archived. Apply the same strict
containment predicate completion cleanup already uses (#28818).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b61e1fb22e74f6d2aed0d2dfb5122f263b18e78d)
2026-09-27 18:29:18 +05:30
Kyle Caponi
b8a149cf48 fix(kanban): require lexical containment before scratch rmtree
The scratch-cleanup containment guard (#28818) resolved both the task's
workspace_path and the managed workspaces roots before comparing them.
When a root is itself a symlink to a broad directory (storage relocated
to another disk, or a planted link), every path inside the link target
resolves "under" the root. A legacy explicit-path scratch task naming such
a path directly then passed the guard, and task completion, deferred parent
cleanup and artifact persistence treated user data as scratch; completion
rmtree'd it.

Require the path to be strictly below the root lexically (absolute,
normalised, NFC, symlinks not followed) as well as after resolution. Tasks
created through the root are spelled through it, so relocated roots and
symlinked HERMES_HOMEs keep working. The root's lexical form is also
accepted with its anchor (kanban home, or the override's parent) resolved,
so a process that spells a symlinked home by its real path still matches;
the managed kanban/.../workspaces components are never resolved for this.

`hermes kanban gc` calls the same predicate once its own root-deletion
fix lands, so it inherits this check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 36b1d0453d153307e8d8d10e481e22392332fd2d)
2026-09-27 18:29:18 +05:30
kshitijk4poor
09472a30a7 fix(gateway): trim reap-grace docs and dead test state (#122533)
Review cleanups on the orphan-reap startup grace:
- Drop the docstring claim that the Desktop boot sweep is "the one caller
  that races a launch": web_server._spawn_gateway_restart also reaps
  (grace-less) before its coalesce check, so the claim was wrong.
- Cut the 7-line lifespan comment to one line; the full reasoning lives in
  the _reap_unsupervised_gateway_orphans docstring, so the two can't drift.
- Drop the never-asserted seen["extra_exclude"] and the constant-only
  `_REAP_MIN_AGE_SECONDS > 0` assert; a grace-less revert already fails the
  `seen["min_age_s"] == _REAP_MIN_AGE_SECONDS` check.

Co-authored-by: Halldrix <halldrix@users.noreply.github.com>
2026-09-27 18:28:26 +05:30
kshitijk4poor
b7c5207b9e refactor(gateway): fold the reap-grace age probe into the filter
The standalone _gateway_process_age_s wrapper only re-wrapped
dashboard_procs._process_age_seconds in a try/except. Inline it as a local
fail-closed predicate (same shape as dashboard_procs._is_stale_orphan) so
the grace lives entirely inside the one reaper that uses it; an
undeterminable age still never widens the reap.

Co-authored-by: Halldrix <halldrix@users.noreply.github.com>
2026-09-27 18:28:26 +05:30
Halldrix
bbaf54584e fix(gateway): reuse the shared process-age probe in the orphan reap grace (#122533)
(cherry picked from commit 1661c66c0a9015ad085edb739b580e445725f804)
2026-09-27 18:28:26 +05:30
Halldrix
33a7ce0fa6 fix(gateway): spare a still-booting gateway from the Desktop orphan reap (#122533)
(cherry picked from commit b616766e30c830cf07096f02cedb1ca5098d3433)
2026-09-27 18:28:26 +05:30
kshitijk4poor
9a6417cd6b fix(backup): decide full-zip publish target once and pin all-failed keep
_write_full_zip_backup_locked chose clean/salvage/discard in _publish_path
and then re-derived the same choice with an inverse test after the with
block. If only one copy changed later, .stat() could hit a path that was
never published and raise out of a "never raises" helper. _publish_path now
records the destination and the stat/return reuse it.

The `destination is None` discard branch in _atomic_output_path had no
teeth: publishing the empty all-failed archive over out_path kept every
test green. The serialization test now asserts an all-failed automatic run
leaves the previous good archive's members unchanged. Also refresh a stale
comment that still described a renamed salvage archive.
2026-09-27 18:19:17 +05:30
kshitijk4poor
25a0b3e3d2 fix(backup): cap logged zip-backup errors and clarify salvage messages
Review cleanups on the incomplete-backup salvage path:

- The incomplete / nothing-salvaged warnings joined every per-entry
  error into one log line; a broken tree can fail thousands of entries,
  so log the first 10 plus "(+N more)".
- Drop the _entry_error helper: its per-entry logger.debug duplicated
  the summary warning, so errors are now collected by a plain lambda.
- claw migrate: a None pre-migration backup can mean an incomplete run
  whose salvage was kept, so point the user at the possible
  pre-migration-*.incomplete.zip instead of claiming there is no
  restore point at all.
- Wrap an overlong create_pre_update_backup docstring line.

No partial can land under the complete out_path name: publish is a
single os.replace from the hidden partial, and any failure (including
the replace itself) unlinks the partial in _atomic_output_path.
2026-09-27 18:19:17 +05:30
kshitijk4poor
736a06beac fix(backup): never publish an incomplete full-zip over the previous backup
_write_full_zip_backup_locked published the partial archive to out_path via
_atomic_output_path and only then renamed it to the .incomplete.zip salvage
name, so an incomplete run destroyed a pre-existing good backup at out_path
(test_zip_captures_live_wal_and_cleans_failed_staging[True] regressed vs base).

_atomic_output_path now takes an optional publish_path callable evaluated at
publish time: the full-zip writer publishes the hidden partial straight to
out_path when clean, to the salvage path when some entries failed, and
discards it (returns None) when every entry failed, since an empty salvage
archive restores nothing. out_path is never touched on an incomplete run.
2026-09-27 18:19:17 +05:30
kshitijk4poor
6125c0c707 fix(backup): keep incomplete salvage zips out of retention and the file
Incomplete automatic backups kept the normal <prefix><ts>.zip name, so they
counted toward retention: the next complete run pruned by count and deleted
the last complete backups, and repeated failing runs piled up. Rename them to
<stem>.incomplete.zip, exclude that suffix from _prune_prefixed_zips, and cap
salvage archives at one.

The failed member's bytes also stayed in the file behind a valid local header,
visible to streaming readers as a ghost entry (#124564). Truncate at the first
dropped header and rewind start_dir so later members overwrite it.

Also: name skipped paths in one merged warning, fix a stale comment, drop a
redundant str(), update None-return docstrings, remove an empty duplicate
section heading, and warn in claw migrate when no pre-migration backup was made.
2026-09-27 18:19:17 +05:30
JoaoMarcos44
a71ff10071 test(backup): cover failed zip member cleanup
(cherry picked from commit 857b969c57cd1c7f50b4d5c3d41800e2bc24e4d0)
2026-09-27 18:19:17 +05:30
JoaoMarcos44
da1bc7d502 fix(backup): drop failed partial zip members
(cherry picked from commit e19cb0d5880008098066c987e8edc6ad67b0ab66)
2026-09-27 18:19:17 +05:30
Octopustank
a6686cc396 fix(desktop): carry the window app id in the Linux launcher entry
Packaged windows claim `com.nousresearch.hermes` as their Wayland app id
(electron-builder bakes product-identity.cjs's `appId` into
extraMetadata.desktopName; Electron hands that string to the compositor
verbatim), while the entry was written as `hermes.desktop` with
`StartupWMClass=Hermes` — so GNOME matched neither StartupWMClass nor a
`<app_id>.desktop` file name and every launch fell back to the placeholder
icon, with the raw app id in the tooltip.

- write `<app_id>.desktop` with `StartupWMClass=<app_id>` (Name= stays "Hermes")
- retire a leftover `hermes.desktop` once the new entry is on disk, and only
  when the file still names this app and launcher management is enabled;
  foreign files at that path are left alone
- nix/desktop.nix derives the entry file name from the module instead of
  hardcoding it
- tests: the installed entry carries the app id, legacy retirement, foreign-file
  preservation, opt-out preservation, plus a node probe asserting
  APP_ID == product-identity.cjs appId

Known consequence: an existing taskbar pin points at the old entry id and has to
be re-added once.
2026-09-27 07:46:31 -05:00
JoaoMarcos44
2a977be999 fix(status): render the session profile home
Let the shared status field builder accept an explicit owning home and have the
multiplexed TUI/Desktop session.status path pass its session profile_home.
Unscoped CLI/gateway callers keep the historical process-home fallback.

Add focused coverage for a secondary-profile session and for the launch-profile
fallback.

Fixes #124500.
2026-09-27 07:43:23 -05:00
pasmud
8b7a1620a1 fix(review): surface real gh stderr in PR-create errors
Every non-zero gh exit collapsed to a generic "is gh installed and
authenticated?" — a lie whenever gh was fine and the real failure was
"no commits between main and feature", a missing upstream, or a refused
push (#87731). The user had to drop to a terminal to learn what gh
already printed.

- apps/desktop git-review-ops.ts runGh() now resolves {ok, stdout,
  stderr} (execFile's err.stderr carries the exit's own stderr), and
  reviewCreatePr() prefixes the surfaced message with gh's reason,
  keeping the generic text only when gh reported nothing.
- hermes_cli web_git.py _gh() keeps (ok, stdout, stderr) through the
  same collapse — _run already captured stderr; it was discarded at the
  tuple boundary — and review_create_pr() surfaces a bounded stderr
  tail (400 chars) plus the gh context.

Tests pin the contract with unique stderr markers and non-zero exits on
both wrappers; successful-path return contracts unchanged.

Electron half salvaged from PR #87751 (author preserved); CLI half added
per the issue's acceptance scope.

Fixes #87731
2026-09-27 06:54:56 -05:00
Brooklyn Nicholson
31a68e9235 fix(tools): resolve the live plugin catalog once per plugins.list
The #119975 display-name lookup called get_live_catalog_entry() inside
the per-plugin loop of _plugin_rows, paying a full catalog resolution
(load_catalog_live: fetch or cache read + parse of every in-tree catalog
yaml, no memoization) once per installed plugin. plugins.manage list went
from O(1) to O(installed plugins) catalog resolutions, and a dead
catalog host cost one request timeout per plugin — the exact per-candidate
cost resolved_removed_entries() exists to eliminate.

Hoist the resolution next to pins/versions: catalog_titles() builds
{catalog_name: title} in one resolution and _plugin_server_rows reads
from the pre-resolved map, mirroring catalog_pins/catalog_versions.
Regression test counts load_catalog_live calls across a 3-plugin
listing: 3 before, 1 after.
2026-09-27 06:26:52 -05:00
Brooklyn Nicholson
76e2111c14 fix(update): drop the cached live plugin catalog on boot after a code change
Bundled/sealed app updates never run hermes update's maintenance tail, so
their pre-update catalog snapshot stayed authoritative for the cache TTL.
Add a boot home step (once per installed revision, per profile) that drops
the active home's cache (#119340).
2026-09-27 06:26:52 -05:00
Brooklyn Nicholson
97e0bae47c fix(update): drop the cached live plugin catalog after an update
Salvages the intent of #119354 by JoaoMarcos44 (its _invalidate_update_cache
hook no longer exists on main — the update pipeline was rewritten around
source_completion/update_finish; the invalidation is re-landed at the new
post-update maintenance seam).

A plugin added to plugin-catalog/ stayed "not in the plugin catalog" for up
to 6h after hermes update: LIVE_CATALOG_TTL_SECONDS keeps the on-disk live
snapshot authoritative for 6h, the update pipeline never touched it, and
load_catalog_live() iterates only the live entries — so the stale pre-update
snapshot out-voted the newer in-tree catalog the update just installed
(#119340).

Drop HERMES_HOME/cache/plugin-catalog.json under the active home AND every
sibling profile's (the checkout is shared, mirroring the post-update state.db
guard's home + siblings sweep) in _run_post_update_maintenance. The next
fetch_live_catalog() re-fetches the published doc, or falls back to the
in-tree catalog while the network is down — both newer than what was deleted.

Fixes #119340 (fix A)
2026-09-27 06:26:52 -05:00
teknium1
d1484ab44b fix(update): only an explicit hermes update retries channel reads
Passive checks (`hermes --version`, the banner, the Desktop/dashboard
update check) now make one channel-read attempt again. Offline usually
surfaces as DNS EAI_AGAIN or ENETUNREACH, which pm.network.is_transient
classes as transient, so wrapping every read in retry_network added 7 s
of backoff to the synchronous version line, stretched a hung CDN from
30 s to ~127 s, and logged a WARNING to stderr on every retry.

`release_channels.retrying_reads()` opts a block in; update_cmd wraps
the channel resolution of `hermes update` in it. Tests: keep the
Retry-After retry test (now scoped to the update path) and replace the
budget-exhaustion test with one pinning that passive reads and 404s make
exactly one attempt.
2026-09-27 03:41:42 -07:00
JoaoMarcos44
0da04c192c fix(update): retry transient release channel reads 2026-09-27 03:41:42 -07:00
teknium1
54388a21ca fix(cli): compare interpreter paths lexically, not through symlinks
resolve() on both sides also collapses a venv interpreter onto the store
binary it links to, so a caller still running in its old venv stopped
re-executing into the store interpreter (two tests in
tests/pm/test_source_update_launch.py go red). The loop in #122513 is a
spelling difference, not a symlink: PM spells the store Python through a
HERMES_HOME that may contain '..', and the OS reports sys.executable
normalized. normcase(abspath()) matches those spellings and keeps a venv
interpreter distinct.

Tests: the '..' spelling is current (red on main); a venv python symlinked
to the store binary still relaunches (red with resolve()).
2026-09-27 03:38:52 -07:00
Konstantin Khlopkov
1d6e53eb05 fix(cli): compare resolved interpreter paths so a current venv launch does not relaunch 2026-09-27 03:38:52 -07:00
teknium1
5a0225dfff fix(gateway): restart watcher names its checkout instead of inheriting the cwd
The detached watcher runs as `<python> -c <program>`, so `hermes_cli` resolved only
because update_completion happened to spawn it with cwd=<checkout>. The program now puts
the checkout on sys.path itself, and the bare-Python test runs it from an unrelated cwd
(red without the sys.path line).

Also drops the two unused re-export aliases in gateway.status (`_posix_is_zombie`,
`_pid_exists_win32_ctypes`): nothing imports them and neither is in the old-updater
compat surface.
2026-09-27 03:37:09 -07:00
teknium1
029445545c fix(gateway): keep the update restart watcher stdlib-only so it survives the bare store Python
After the package-manager handoff, hermes update finishes on the bare store
interpreter and spawns the detached restart watcher as sys.executable -c.
The watcher imported gateway.status (utils -> hermes_yaml -> ruamel) and
hermes_cli.config, died with ModuleNotFoundError before relaunching, and
left every manually started gateway down after the update (gated on #124649).

Move the stdlib liveness probe (zombie-aware POSIX kill(0), Windows
OpenProcess) into hermes_cli._subprocess_compat, have gateway.status's
fallback delegate to it, and import only stdlib-backed modules in the
watcher.

Fixes #124649.
2026-09-27 03:37:09 -07:00
teknium1
60e531cb52 fix(gateway): read every Hermes inline bootstrap's argv as its identity, on all OSes
The #107002 guard keeps an inline ``-c`` program's trailing argv as data. Every
Hermes launcher runs the entry point IN the ``-c`` process, so the guard hid
real gateways on every OS (#124318, #124588):

- the store launcher / Windows updater relaunch (_launchers.runtime_command)
- the published launcher script (POSIX shell launcher: every PM-install
  systemd/launchd gateway) and its Windows .cmd base64 wrapper
- the venv_sync re-entry, whose argv is assigned inside the source

gateway.status.inline_bootstrap_argv recognises exactly those emitted source
shapes, anchored at both ends so a program merely CARRYING one (the restart
watcher's respawn argv) still never matches, and rewrites the process to the
equivalent ``python -m <entry> <argv>``. /proc, psutil and ``ps`` space-join
argv, which splits the source across tokens; the shortest token run ending
in a recognised tail is the source whichever reader joined it. Both
canonical matchers (looks_like_gateway_command_line and
update_cmd_windows._hermes_holder_subcommand) use it.

Live on a real PM install (Linux, bwrap): a gateway started through the
installed launcher script or runtime_command read "Gateway is not running"
on main; with this change both read running, find_gateway_pids and
get_running_pid see them. Drops the four #124318 known_failure gates in
tests/e2e/core/windows_update.

Co-authored-by: Hermes Agent <dmyou@users.noreply.github.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
Co-authored-by: DianaBudin <dianabudin0307@gmail.com>
2026-09-27 02:57:28 -07:00
teknium1
878d902147 fix(update): judge Windows gateway ownership by home, warn on unreadable ones
Rework of the #124676 salvage onto the canonical fleet scope instead of a
second path heuristic:

- the pause, the cold-start guard and the post-relaunch readiness poll all
  filter the host-wide find_gateway_pids(all_profiles=True) scan through
  update_cmd_fleet._scoped_manual_gateway_pids, the same home scope the
  POSIX fleet restart uses (#93349). A PM install's venv lives under
  installs/, so exe-under-PROJECT_ROOT rarely proved an own gateway; its
  live HERMES_HOME (or the LOCALAPPDATA default) always does.
- a foreign gateway with no HERMES_HOME resolves to its own default home and
  no longer blocks this install's cold start (#124659, second bullet).
- a gateway whose home cannot be read is named in the pause output instead
  of skipped silently; the spawn ledger's verified (pid, create_time) entry
  proves an own gateway whose environment is unreadable.
- tests trimmed to two invariants on real processes; a real-Windows journey
  (two installs, one updates while the other serves) replaces the harness
  comment that documented the bug.
2026-09-27 02:57:28 -07:00
JoaoMarcos44
46fba4c9a4 fix(update): scope Windows gateway lifecycle to current install 2026-09-27 02:57:28 -07:00
JoaoMarcos44
c11a6bc0b2 fix(pm): collect generations after maintenance syncs 2026-09-27 02:55:39 -07:00
teknium1
2270d3511d fix(update): expose PM's git only for git checkouts, and record the installer's copy
A git-less Windows ZIP install has no .git and never runs git, yet
_prepare_git_command called expose_pm_git() before it checked
use_zip_update: offline or with no pin, pm.ensure raised and aborted an
update that never needed git. expose_pm_git now takes the project root
and does nothing unless it is a git checkout (both callers).

When install.ps1 runs as one process, the git it staged into PM's store
is on the inherited PATH, so expose_pm_git returned early and PM's facts
never recorded it; plain hermes processes (plugin git installs, doctor,
version info) had no git until the first `hermes update`. A git found
under PM's store is now treated as that unrecorded copy and ensured, so
the source completion writes the git fact at install time.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 02:54:03 -07:00
teknium1
e5ccf4a26c fix(update): look up git through the hermes_platform resolver
expose_pm_git() probed with a bare shutil.which, which the managed-runtime
resolution guard rejects outside hermes_platform/. Route the lookup through
hermes_platform.resolver.locate_command instead.
2026-09-27 02:54:03 -07:00
teknium1
305072f4a6 fix(update): give git-less Windows installs PM's git for update and source completion
On a Windows machine with no git, scripts/install.ps1 stages the pinned Git
for Windows into the pm store for its own process only, and pm's facts
never record it. Every later product process that ran a bare `git` failed:

- `hermes update` died before its first step:
  "✗ Update failed: [WinError 2] The system cannot find the file specified"
- the source completion that finishes an installer re-run or an update
  found no commit, deleted the install stamp, and the next boot wrote an
  adoption stamp that names no commit. It also printed
  "Could not refresh release history ([WinError 2] ...)".

expose_pm_git(): when Windows resolves no git, ensure PM's git explicitly
(both callers are user-initiated, like ensure_tools_for_sync) and put its
composed PATH (cmd and usr\bin) on the process, so children inherit it.
The update calls it before its first git, and the source completion calls it
before its builds and stamp. A machine with a working git is untouched.
main_desktop already ensures PM's git for its build for the same reason.
2026-09-27 02:54:03 -07:00
teknium1
20dabfa869 fix(update): health-check a restore that parks the stash for replaced untracked files
When the update replaced a user's untracked file, _apply_stash returned
False after the tracked changes and the other untracked files were
already in the tree, so _restore_stashed_changes skipped the syntax,
critical-import and reject path: a restore that broke Hermes finished
the update instead of resetting the tree and exiting 1. _apply_stash now
returns the replaced paths; the restore validates the tree as before and
only skips the stash drop, recording it as parked.

A refused path counts as replaced only when HEAD tracks it
(git cat-file -e HEAD:<path>), so a file that was undeletable at stash
time and changed since (#70127) is no longer reported as the update's.
2026-09-27 02:53:32 -07:00
teknium1
78ee5e96db fix(update): keep the autostash when the update replaced a user's untracked file (#124641) 2026-09-27 02:53:32 -07:00
teknium1
ff46826872 fix(update): keep commits made on a detached HEAD behind a named rescue ref
Both update shapes move HEAD off a detached commit: the branch update checks
out main, the release update checks out the release commit. A commit made on
the detached HEAD is on no branch, so after the move only the expiring reflog
reached it, and the branch path printed nothing about it (gated on #124643).

One helper, _park_detached_head, now runs at both sites before HEAD moves.
When HEAD is detached at a commit no ref contains (refs/stash excluded: the
autostash is dropped after the update, which is why the branch path parks
before stashing), it writes refs/hermes-update-backups/detached-<branch>-<ts>-<sha>
(the divergence rescue refs' scheme, now pruned on the same terms) and prints
the ref and how to list the commits. If the write fails the update stops
rather than orphan the work. A detached HEAD already on a branch or tag (a
pinned release) gets no ref. It replaces the release path's silent, never
pruned refs/hermes/pre-release/<sha>.
2026-09-27 01:00:31 -07:00
Brooklyn Nicholson
28e6496a5e fix(web): scrub a session's on-disk artifacts in the delete endpoint
DELETE /api/sessions/{id} removed the DB row but never passed the
profile's sessions dir to SessionDB.delete_session, so the on-disk
transcript artifacts survived the UI delete: legacy session_<id>.json
snapshots (which can carry plaintext secrets) and the gateway's
request_dump_<id>_*.json dumps. The CLI delete path threaded the
directory all along; the endpoint was the outlier.

Also sweep the legacy session_<id>.json snapshot name in
SessionDB._remove_session_files so deletes and prunes clear it from
installs whose older builds wrote it.

Fixes #60207

Co-authored-by: izumi0uu <izumi0uu@gmail.com>
2026-09-26 22:52:51 -05:00
John Paul Soliva
c9ccdb967d fix(desktop): the skip notice names the in-app update, and hermes desktop reopens the app it kept
Review follow-ups on the Windows skip:

- Most people who hit the boot loop launched Desktop from the Start menu and
  never open a terminal. The in-app update also rebuilds the app: the
  Windows shim waits for Desktop to exit, and the already-up-to-date path
  still completes with desktop=True. The notice and updating.md now name
  Update now in Settings -> About next to `hermes desktop`.
- cmd_gui took the skip's None as "no launchable app was found". A build
  that fails raises instead of returning None, so None there only means the
  skip, and `hermes desktop` now reopens the app that was kept.

Tests fold to the two invariants, each red when its half of the fix is
reverted: the stop spares its ancestor Desktop on every platform, and a
Windows packaged build under its own Desktop is skipped (now driven through
the real _desktop_ancestor_in with a fake process tree, instead of a stub).
2026-09-26 23:50:01 -04:00