Commit Graph

46394 Commits

Author SHA1 Message Date
Austin Pickett
dfcbef358a fix(desktop): find a source install through its published user-bin launcher
The PM rework dropped the resolver's "existing hermes on PATH" rung, so
Desktop only accepts HERMES_DESKTOP_HERMES or $HERMES_HOME/hermes-agent. A
healthy install elsewhere (install.sh --dir, a setup-hermes.sh clone) now
falls to bootstrap-needed and the first-run choice, offering to install a
second copy. The old rung also read raw process.env.PATH, which a
Finder/Dock launch inherits without ~/.local/bin.

Add a rung before bootstrap-needed: probe the launcher published at its
fixed user-bin location (~/.local/bin or $HERMES_HOME/bin; Windows:
%LOCALAPPDATA%\hermes\bin), read the install directory its --version
reports, and resolve that root through resolveSourceInstallationBackend,
the same validated path ACTIVE_HERMES_ROOT uses. HERMES_DESKTOP_IGNORE_EXISTING
and repair requests still skip it, and a miss is now logged instead of
silent. The launcher lookup is shared with resolveInstallationLauncher.

Fixes #73014

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
2026-09-29 22:57:06 -04:00
Austin Pickett
a35f600b8a fix(desktop): resolve the Windows ssh client instead of hard-coding System32 OpenSSH
All ssh spawns (the -G config probe, the ssh-config:resolve IPC, SSH
terminals, and SshConnection) now go through one pure resolver:
desktop.ssh_path in config.yaml, then System32 OpenSSH, then Git for
Windows' usr\bin\ssh.exe, then bare ssh. Non-Windows keeps bare ssh.

A failed local ssh -G probe is now tagged terminal: boot latches on the
failure overlay (naming the client and the ssh_path setting) instead of
re-driving the same probe every ~2s.

Fixes #103288
2026-09-29 22:56:15 -04:00
DavidMetcalfe
57a22675ef fix: only unresolved gateway rows make successor evidence ambiguous
Review round 3 (Gemini 3.8 Flash): counting every planned gateway row per profile
made a dirty baseline exit 1 even when the fleet was fully accounted for. With an
orphan row the restart phase killed (killed_pids) plus an active row that was
restarted, the killed row's own verdict ("stopped") was already in hand, yet its
count still marked the profile ambiguous and blocked credit for the row that did
restart.

The baseline count now skips rows bookkeeping already resolves (killed /
relaunched / name-matched) and counts only rows that still need a verdict, so the
ambiguity guard fires on genuine ambiguity instead of on any multi-row plan. The
debug message no longer claims a successor exists when the probe returned none.
2026-09-29 21:50:16 -05:00
DavidMetcalfe
2594c5786f refactor: count planned gateways by kind, document the deliberate limits
Review round 3 (@GPT-OSS 120B): `kind not in _SERVE_KINDS` was read as
"excludes gateways" — the set is ("serve", "dashboard"), so the behaviour was
already gateway-only (a profile with a gateway plus a serve and a dashboard row
still credits the gateway), but the intent is now explicit: `kind == "gateway"`.
The served-kind test covers both non-gateway kinds for one profile to pin that.

Also documents what this fallback deliberately does NOT do, so the next reader
does not have to re-derive it:
- ambiguity resolves conservatively (pairing a successor to the planned process
  needs a start-time identity neither the plan record nor the fleet row carries);
- credit is not gated on restart bookkeeping (the reported failure already had
  bookkeeping; a self-respawned gateway has none, while the fleet row still
  proves the successor runs the new code).
2026-09-29 21:50:16 -05:00
DavidMetcalfe
f42fd748c8 fix: require an unambiguous baseline for successor-credited gateway restarts
Review (@ehz0ah): one live successor cannot prove that two planned same-profile
gateways both restarted. `_collect_gateway_runtimes()` de-duplicates by PID, not
by profile, so duplicate/orphan states can put two `coder` rows in the plan —
and while the post-restart fleet probe publishes at most one row per profile,
both planned rows were credited from that single successor, letting an untouched
sibling vanish behind it and suppressing the unaccounted tripwire.

Successor credit now requires the profile to hold exactly ONE planned gateway
runtime (baseline identity); an ambiguous profile keeps the name-path verdict and
stays armed. Logged at debug so the reason is visible.

Reported repro, before -> after:
  plan=[coder 76508, coder 76795], live_gateway_pids={"coder": {76796}},
  empty bookkeeping
  before: ['restarted', 'restarted']        tripwire: False
  after:  ['unaccounted', 'unaccounted']    tripwire: True
2026-09-29 21:50:16 -05:00
DavidMetcalfe
b7bb7685d3 fix: log missing successor evidence for a planned gateway
Review feedback (@kvnloo): when the post-restart fleet probe has no row for a
planned gateway's profile, the incarnation fallback silently does nothing and
the tripwire output reads identically to a genuinely missed restart. Log the
missing evidence at debug so the next report is diagnosable:

  No post-restart gateway evidence for profile 'coder' (planned pid 76508 via
  launchd) — reconciling on restart bookkeeping alone

Also make the call-site comment name the snapshot dependency the evidence
rests on (the same note #110343 carries at its wiring point).
2026-09-29 21:50:16 -05:00
DavidMetcalfe
d5c8097909 fix(update): credit a gateway restart by successor incarnation, not service name
`match_runtime_outcomes()` can only bridge a planned gateway runtime and the
restart bookkeeping by SERVICE NAME, and the two disagree whenever a service
serves a profile its name does not encode:

- macOS: the root-home LaunchAgent `ai.hermes.gateway` honours the install's
  sticky active profile (`hermes profile use <name>`), so the process declares
  `profile: coder` on its control socket while its label encodes the install
  root. `_gateway_service_matches_profile("coder", "ai.hermes.gateway")` is
  False for every naming rule, so the planned runtime is reported
  `unaccounted` → `restart.incomplete` → exit 1 after a restart that verified
  clean (fleet row: new PID, new SHA). The leftover `fleet_restart_pending`
  marker then warns on every later invocation.
- Linux: a hash-suffixed unit for a custom HERMES_HOME has the same shape of
  problem (#110238), which the open name-matching PRs only paper over for the
  default profile.

Reconcile gateways on incarnation evidence too — the rule serve/dashboard
runtimes already use via `stale_serve_pids`: the post-restart fleet snapshot
(control-socket identity per profile) is passed in as `live_gateway_pids`, and
a planned PID that is gone while a gateway answers for the same profile counts
as `restarted`. Requiring a successor keeps the tripwire honest: no successor,
or the planned PID still answering, stays `unaccounted` and still exits 1.
`down` rows are excluded from the evidence — they report the pre-restart PID.

Fixes #110333
2026-09-29 21:50:16 -05:00
ethernet
046f709b6d Merge pull request #128749 from Navlem/fix/desktop-first-send-abort
fix(desktop): keep the first send when only the active runtime is retargeted
2026-09-29 22:49:59 -04:00
ethernet
c5947dbc66 Merge pull request #128667 from thefiredev-cloud/fix/desktop-fresh-draft-first-send
fix(desktop): send the first prompt of a fresh-draft chat
2026-09-29 22:49:03 -04:00
Brooklyn Nicholson
3ec91de490 fix(logs): parse the update/handoff logs' ISO-8601 stamps; cover them in the LOG_FILES stamp contract
- _TS_RE now accepts the T-separated stamps update.log and
  desktop-update-handoff.log actually write (posix.sh date +%Y-%m-%dT%H:%M:%S%z,
  windows.ps1 yyyy-MM-ddTHH:mm:ssK, the update banner), so --since filtering
  works for hermes logs update/handoff instead of silently dropping everything.
- test_logs' LOG_FILES stamp contract gains real writer samples for update/handoff.
- Replace the PR's source-shape (inspect.getsource) upload tests with behavioral
  ones (monkeypatched uploader, --local capture); the upload/--local loops stay
  data-driven over bundle.items() as on main.
2026-09-29 21:48:25 -05:00
Halldrix
60f9d2abb1 fix(debug-share): capture update.log and desktop-update-handoff.log
When a Desktop-driven update fails at the Electron rebuild (updater
exit 6, 'Code and dependencies updated, but the Desktop app REBUILD
FAILED'), the only artifacts holding the root cause are
~/.hermes/logs/update.log (full stdout/stderr mirror of hermes update,
written by _UpdateOutputStream) and ~/.hermes/logs/desktop-update-handoff.log
(hand-off stages including the 'desktop --force-build --build-only' retry).
Both the updater's failure messages and the Desktop error box direct
users to 'hermes debug share', but the share captured neither file:
LOG_FILES and the debug.py capture chain covered only the classic
five logs, so update-failure issues (e.g. #100874) arrived with the
diagnostics that matter most missing.

Additionally, 'hermes logs list' (a directory scan) already listed
both files while LOG_FILES had no readable key for them, so
'hermes logs update' failed with 'Unknown log' against a file the
CLI itself displays.

Wire both files through the whole chain:
- hermes_cli/logs.py: add 'update' and 'handoff' LOG_FILES keys
  (fixes list-vs-read inconsistency; tail/follow/filters come free).
- hermes_cli/debug.py: _capture_default_log_snapshots,
  collect_debug_report (512 KB snapshot cap and the 100-line tail
  budget both apply; a hand-built snapshot dict without the new keys
  degrades to the old five-section report instead of KeyError),
  collect_share_bundle full logs, the paste-upload label list in
  build_debug_share, and the --local print list. The --nous bundle
  and privacy notice inherit the new keys; force-redaction applies
  via _capture_log_snapshot as for every other log.
- hermes_cli/subcommands/logs.py + website docs: name the new logs.

Tests (tests/hermes_cli/test_debug_share_update_logs.py): 12 new
tests — snapshot keys present with real content, missing files
degrade to the honest note without raising, report sections appear,
bundle keys omitted when absent, upload/--local label lists include
the new logs, LOG_FILES keys resolve, tail_log end-to-end. Sabotage
pass: 10 of 12 fail without the fix.
2026-09-29 21:48:25 -05:00
Brooklyn Nicholson
0466a04bc8 fix(desktop): pipe Windows spawn lock to stdin
The windows_ssh_runtime `write-lock` op takes the ownership ID as its one
argv element and reads the lock JSON on stdin, but the atomic Windows
spawn command appended $lock as a second positional argument, so the
runtime's arity check rejected the spawn with `invalid operation` and a
Windows SSH backend could never claim ownership of its process
(#119111).

Pipe $lock into the helper's stdin instead, keeping the ownership ID
argument, non-zero-exit propagation and $spawnLines|Write-Output intact.

Fixes #119111

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-29 21:45:14 -05:00
Konstantin Khlopkov
f429fef30c fix(desktop): settle managed SSH updates before the local updater handoff 2026-09-29 21:44:34 -05:00
Brooklyn Nicholson
60e2e33e9b fix(desktop): re-arm the terminal mount when the xterm host connects late
The font wait resolves null when host.isConnected is false at an await
boundary; the mount handler returned silently, so a pane rendered before
its host connected stayed blank forever — no term.open, no PTY spawn, no
log line, no retry (#118004). Poll frames until the host connects, then
retry the wait+mount once; cancel the watch on teardown. Same fix for the
agent mirror terminal, which shares the single-shot mount shape.
2026-09-29 21:44:18 -05:00
LordMelkor
1c659fe53a fix(desktop): cleared SSH fields reach main as '' and drift heal aligns route identity
Two bugs that together made an SSH-primary Desktop show "This device" and
spawn a phantom local backend even though it was connected to the remote.

1. The Gateways form serialised a cleared Identity file (and User) as
   `undefined`. Main merges the SSH settings with `??`, where `undefined`
   means "inherit the saved value" and only '' clears, so a stale keyPath
   could never be removed: the form refilled it on every Save and reconnect.

2. reconcileRegistryDrift treated a v1 SSH route as "already registered" on
   host/user/port alone, while matchingConnectionId tags the live window by
   the full route identity (keyPath, remoteHermesPath, remoteProfile too).
   A v1 keyPath the registered entry never carried therefore resolved to no
   connectionId; the renderer treats an untagged window as the unscoped
   local backend, labels it "This device", and the roster force-spawns a
   local child the user never asked for. The heal now aligns the registered
   entry's identity fields with the route the app actually dials.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Ai-assisted: true
2026-09-29 21:43:29 -05:00
Brooklyn Nicholson
830287c745 style: blank line before the retirer override call in the pool-retire test 2026-09-29 21:43:12 -05:00
Brooklyn Nicholson
654300240a fix(desktop): reap keepalive-pinned pool backends idle past a streamed-activity TTL
The renderer's 60s keepalive (touchPoolBackend) refreshes lastActiveAt
for every OPEN chat — the only clock both the idle reaper and LRU
eviction read — so the pinned tier (backends whose profile chat was ever
opened) was exempt from both the maxBackends cap and the idle reaper
forever: N profile chats held N ~120 MB serve children until app quit
(126 processes / 7.5 GB, all parented to the live Hermes.exe) (#105239).

A keepalive proves the chat is open, not that anything streamed.

- touchPoolBackend stamps entry.lastStreamedAt on activeTurn=true
  touches (a prompt turn leasing the backend IS streamed activity).
- pool-retire: PoolRetireEntry gains lastStreamedAt; retireIdle accepts
  an idle-eligibility override so the reaper can key on the streamed
  clock. Every other safeguard is unchanged: activeTurn veto, identity
  checks, admission permit, backend proof probe.
- startPoolIdleReaper retires a local child whose last streamed turn is
  older than POOL_PINNED_IDLE_MS (default 60 min,
  HERMES_DESKTOP_POOL_PINNED_IDLE_MS override) even while keepalive-fresh;
  entries without the stamp keep the legacy lastActiveAt clock. Killing
  is never user-observable: re-focusing the chat re-ensures idempotently
  via ensureBackend/ensureRegistryBackend entry reuse (#105239 req 3).

Note: #118246 already routes local profiles through one host backend;
this TTL bounds what still pools per-profile (isolated mode, unscopable
mutating REST requests, remote profiles' local descriptors aside).
2026-09-29 21:43:12 -05:00
Brooklyn Nicholson
b1027c635f test(desktop): approval.cancelled clears the parked prompt cards by request id 2026-09-29 21:42:20 -05:00
Brooklyn Nicholson
591e3a7ce1 fix(tui_gateway): broadcast approval.cancelled when reap/teardown drop pending prompts
The WS-orphan reap interrupts the turn (deny-resolving the pending approval
queue) and teardown pops it via unregister_gateway_notify — both silently.
A reconnecting client's parked prompt card then answers resolved: 0 (or a
bare 4001 on approval.pending), so the prompt looked lost rather than
cancelled and the user had no signal the command would never run (#106678).

- session_lifecycle: _announce_cancelled_gateway_approvals broadcasts the
  dropped entries (session ids, reason, request ids/count) before the
  deny-resolve drains the queue, from both _interrupt_session_turn and
  _teardown_session. Fail-open: a missing key, empty queue, or broadcast
  failure never blocks the deny-resolve or teardown.
- _interrupt_session_turn gains orphan= so the reaper labels its cancels
  ws_orphan_reap explicitly (never request_id prefix sniffing).
- contracts: ApprovalCancelledPayload event model + regenerated TS/OpenRPC.
- desktop: gateway-event lifecycle consumes approval.cancelled and clears
  the parked prompt cards by request id (mismatch is a no-op, so a newer
  prompt survives); the turn's BLOCKED tool result is the in-transcript
  signal, same as the timeout path.

Supersedes #106693 (KoNit-K) and #126455 (Finn763); design credit to both.

Co-authored-by: KoNit-K <konit.block@protonmail.com>

Fixes #106678
2026-09-29 21:42:20 -05:00
Brooklyn Nicholson
5d4d8aa6d3 fix(desktop): scope the remembered local workspace cwd per profile
Fixes #96834

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-29 21:41:35 -05:00
Brooklyn Nicholson
82bbc694b5 fix(desktop,gateway): proxy client-unreachable remote images through the gateway
An inline agent-generated image (FAL CDN URL) that the Desktop client
cannot reach renders as a collapsed frame with no usable fallback
(#74564): resolveMediaDisplaySrc passes inline https sources through
untouched, and a failed <img> load only records the broken-key.

- Gateway: GET /api/media/proxy fetches an allowlisted remote image URL
  on the client's behalf (the gateway generated the image and holds a
  working route to the CDN) and returns the same data_url shape as
  /api/media. Host-allowlist (fal.media/fal.run/storage.googleapis.com
  and subdomains), 20s timeout, image content-type check, 25MB cap.
- Renderer: gatewayImageProxyDataUrl wraps the new endpoint with
  owner-connection/profile pinning; useMediaImage retries a failed
  inline https load ONCE through the proxy and swaps in the returned
  data URL. The retry is latched per source: a failing proxied data URL
  or a remount never loops, and non-https sources never reach it.
2026-09-29 21:39:53 -05:00
Brooklyn Nicholson
063de859dd fix(auth): accept provider-verified session tokens in gated WS auth
Gated mode accepted only browser-minted ws-tickets and server-internal
credentials, but a token-mode Remote desktop connection bakes its
stored session token into ?token= (buildGatewayWsUrl) — so the WS
upgrade to /api/ws 403'd forever against a password-gated server even
though the same token authenticates REST fine. The REST bearer leg
already verifies session tokens via _verify_access_token; the WS leg
was the only inconsistent gate.

Gated ?token= is now verified against the dashboard auth session
providers (the same verify_session seam), stamps {user_id, provider}
identity, and audits success/failure (TOKEN_AUTH_SUCCESS/FAILURE). The
legacy in-process _SESSION_TOKEN stays rejected; a ProviderError (all
providers unreachable) rejects with token_unavailable instead of
crashing the upgrade; the bot-desktop display-ticket guard is kept.

Co-authored-by: salch-cred <141555468+salch-cred@users.noreply.github.com>
2026-09-29 21:39:17 -05:00
Brooklyn Nicholson
7b384285fa test(tui_gateway): pin materialized-row profile stamp against the #99222 contract
The fixture's _find_live_session_by_key/_child_run_active stubs predate the
profile_home parameter; the profile_name assertion compared against a bare
create_session (NULL) instead of the production stamp
(profile_name_for_home(...) or _current_profile_name()) that
_ensure_session_db_row applies on first-prompt persistence — the exact
contract materialization must match.

Co-authored-by: ruangraung <github@nadyahermes.anonaddy.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-09-29 21:33:22 -05:00
Brooklyn Nicholson
c48e05a9d0 fix(gateway): materialize session row on resume for minted-but-never-persisted keys
A Desktop/TUI session created from the client can become permanently
unresumable if the backend dies or is restarted before the session's
first prompt. session.create deliberately writes no session row until
the first prompt arrives (avoiding "Untitled" litter), so the minted
key exists only in the backend's in-memory live registry. The managed
update pipeline restarts hermes-gateway.service and
hermes-dashboard.service at the end of every update; any session
minted but not yet prompted in that window is orphaned: the key exists
client-side (pinned tile, stored session id) but has no row anywhere,
and after a restart session.resume looks up the live registry and the
DB, misses both, and returns 4007 permanently.

Fix: in _resume_locate, before returning 4007, when the target is a
well-formed server-minted key (^\d{8}_\d{6}_[0-9a-f]{6}$) with no
persisted row and no live-session claim (any profile), materialize the
row via db.create_session(...) and re-resolve, resuming into a fresh
empty session. Fail-closed: garbage ids, 8-hex runtime ids, titles,
lazy watch windows, and keys claimed by any live session under another
profile still 4007 — an unscoped resume must never mint a phantom row
in the launch store.

Rebased onto current main by the maintainer landing crew: the resume
handler is split into _Resume helpers (methods_session.py), so the
materialize block lives in _resume_locate; the helpers stay in
server.py and resolve through the method_ctx rebinding. Test adapted
to the current handler shape (source parity with the env resolver,
profile_name stamped by the store).

Co-authored-by: ruangraung <github@nadyahermes.anonaddy.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
2026-09-29 21:33:22 -05:00
ZenSchema
b592c82892 fix(desktop): add opacity-0 to hidden keep-alive pane layers
Hidden keep-alive pane layers stay mounted with visibility:hidden and
pointer-events:none so scroll position and state survive tab round-trips.
On some compositors these hidden layers can still paint for one frame during
a tab switch, causing accent-colored elements from the outgoing pane to flash
over the incoming pane (e.g. Notes ↔ Tokens).

Add opacity-0 so the compositor drops the hidden layer entirely while keeping
the DOM mounted. This fixes the flash without losing scroll/state preservation.

Closes #101476
2026-09-29 21:30:12 -05:00
Brooklyn Nicholson
8d30c4eaab fix(desktop): stop focusWindow() and ambient reveals from stealing the Windows foreground during streaming (#83998)
On Windows, BrowserWindow.show() and focus() both seize the OS foreground.
focusWindow() did show() + unconditional focus(), and the
sendOpenUpdatesRequested / deep-link handlers did the same — so any ambient
IPC reaching those paths mid-stream dismissed other apps' native save and
confirm dialogs (the reported Notepad repro).

focusWindow() now reveals with showInactive() and guards focus() with
isFocused() so a redundant call never pumps SetForegroundWindow; the
updates-toast and deep-link call sites get the same guarded pattern. The
policy lives in electron/window-focus-policy.ts (pure, unit-tested).
User-intentional raises (pet overlay toggle/open clicks, HUD/Quick Entry
reveals) keep show()+focus() — a click is consent.

Supersedes #84026 (frizikk), which guarded only the renderer side and
explicitly left the main-process focus paths unchanged.

Fixes #83998

Co-authored-by: Jakub Wolniewicz <4850809+frizikk@users.noreply.github.com>
2026-09-29 21:10:10 -05:00
Brooklyn Nicholson
38d3dbce4a test(update): adapt handoff regression to the other_pid fixture 2026-09-29 20:58:59 -05:00
Jack Lau
e28991e9c8 fix(update): decide ancestry link by link so an unreadable /proc/1 cannot hide the orchestrator
`_is_ancestor_pid` asked psutil for the whole parent chain at once:

    any(parent.pid == pid for parent in psutil.Process().parents())

`Process.parents()` is eager: it walks all the way to the lowest pid
*before* returning a list, so the generator expression cannot
short-circuit when it reaches the matching ancestor. Its per-link
`parent()` tolerates only `NoSuchProcess`, so any process we may not
inspect anywhere above us raises `AccessDenied` out of the whole call,
and the `except Exception` guard discards every ancestor already found,
including the orchestrator one link down.

Under firejail with `ptrace_scope=1`, and in hardened containers,
`/proc/1` is exactly that unreadable process. The hand-off child then
failed to recognize its own parent's fresh marker, `UpdateLock.acquire`
treated a live orchestrator as a foreign concurrent updater, and the
in-app update exited 2 ("Another Hermes update is already running",
naming the hand-off script's own pid) on every attempt, indefinitely.

Walk the chain one link at a time and test each ancestor as it is
discovered, so a failure above a match can no longer erase it. A
failure encountered before any match still returns False, keeping the
conservative refusal for genuinely unprovable ancestry. The walk is
bounded by `_MAX_ANCESTRY_DEPTH` and skips a repeated pid so an
unexpected chain cannot spin.

Fixes #87514
2026-09-29 20:58:59 -05:00
Brooklyn Nicholson
55f616694c fix(update): restore cron agent-job prompts degraded during the update window
A writer in the update's mutation window replaced every agent-job
prompt with the job's own name while the job count stayed identical,
so the count-based restore_cron_jobs_if_emptied net passed the loss
undetected (#82990). Add a field-level safety net mirroring the config
model-settings net: restore only the prompt field of a live agent job
whose id matches a snapshot job and whose live prompt is blank or
collapsed to the job name — a legitimate user edit that merely
differs is never stomped. Wired into the post-migration restore
sequence for the active profile and every sibling profile.
2026-09-29 20:58:24 -05:00
chelsealong
d5c20c3ebd fix(desktop): don't suppress the attach-monitor's own recovery claim
startAttachedBackendMonitor's dead/drifted-backend recovery called
invalidatePrimaryConnection(), which sets primaryRecoverySuppressed
before scheduleUnexpectedPrimaryRecovery() reads it back as an
intentional teardown and refuses to claim the respawn, leaving the
desktop app with no backend. Clear the slot via
backendConnectionState.invalidate() directly instead, since an
unexpected attach teardown is exactly what the supervisor's recovery
path exists for.
2026-09-29 20:58:03 -05:00
chelsealong
0029d25f47 fix(desktop): detect attached-backend token drift, not just liveness
startAttachedBackendMonitor's 15s poll only checked that the attached
backend answers its readiness probe. /api/health and /api/status are
public routes, so a backend recycled by an external supervisor
(launchd KeepAlive) into a new process serving a brand-new session
token still answers 200 — the probe passes while every authenticated
call 401s forever, since child-exit recovery never fires for a
backend this app did not spawn.

Re-read the served token (already exposed via resolveServedDashboardToken)
on every tick and treat drift the same as "gone", routing into the
existing recovery path that re-adopts the token on respawn.

Fixes #121988
2026-09-29 20:58:03 -05:00
finn763
d8dba2e687 fix(config): keep built-in model validation for settings-only provider blocks
A providers.<slug> block with no base_url/url/api of its own is settings,
not a user-defined endpoint. Only remap to the custom: validation branch
when the user-config entry declares a base_url; otherwise openai-codex and
other listing-less built-ins keep their normal catalog path.

Closes #120020
2026-09-29 20:57:57 -05:00
Bardioc1977
f284a05f1d fix(providers): a timeout-only providers.<name> block no longer shadows the builtin
providers.bedrock: {stale_timeout_seconds: 600} is the documented way to tune a
built-in provider's per-call timeout (agent/turn_recovery.py,
agent/thinking_timeout_guidance.py). resolve_user_provider() treated ANY dict
under providers.<name> as a full custom-endpoint definition, even with no
api/url/base_url — producing an empty openai_chat/api_key stub that shadowed
the real built-in overlay (bedrock_converse transport, aws_sdk auth).

For Bedrock this routed every /model switch through the generic
custom-endpoint /models probe against the Bedrock runtime host, which has no
such endpoint: the switch intermittently failed with "could not reach this
custom endpoint's model listing" depending on how fast the (irrelevant) probe
timed out.

resolve_user_provider() now requires a real endpoint (api/url/base_url) before
resolving an entry; a tuning-only block falls through to resolve_provider_full's
next rung, which picks up the built-in overlay correctly.
2026-09-29 20:57:57 -05:00
Brooklyn Nicholson
8b4b440ef3 fix(desktop,pm): stop the boot parking on a failed update receipt and bound completion retries (#122206)
Three gaps stranded the app through a legitimately long or failed
source-update completion:

- update-gate: a fourth gate signal 'failed-receipt' (hasFailedReceipt on
  UpdateGateDeps, optional) reports a FINISHED, failed update even when a
  stale marker survives; waitForUpdateClearance gains abandonOn so the
  boot path stops parking the full 20-minute budget on a receipt that
  already says failed and boots the current build instead.
- backend-ready: while the backend prints venv_sync's source-completion
  banners the 90s port-announce deadline is re-armed (5-min grace,
  30-min total cap, real wall clock) so a healthy multi-minute repair is
  not killed and re-run on every boot.
- venv_sync: relaunch-driven completion-tail retries are bounded — an
  attempts record beside the pending marker keeps the first retry
  immediate (the historical flaky-tail case), backs off after 2
  consecutive failures (15-min window), and caps at 6 attempts, past
  which the launch keeps the marker, skips the tail, and points at
  'hermes update'; success clears both the marker and the record.
2026-09-29 20:57:51 -05:00
Brooklyn Nicholson
0f24f8b8eb fix(skills-guard): detect bare-~ destructive rm and inline-shell auto-exec DSL
The skills security scanner had two blind spots (#63307 Part A):

1. destructive_home_rm only matched literal $HOME, not bare ~. A command
   like 'rm -rf ~/Documents' scanned as safe for every trust tier.

2. No pattern detected the !`cmd` inline-shell DSL that
   agent/skill_preprocessing.py expands via bash -c on skill view/load.
   A skill carrying !`rm -rf ~/Documents` scanned safe.

Fix: extend destructive_home_rm to match ~/~ /~* (bare tilde forms),
add an inline_shell_exec pattern for the !`...` auto-exec snippet
(requires a non-space payload so a skill explaining the DSL is not
flagged), and bump SCANNER_VERSION to skills-guard-v7 so cached safe
verdicts are invalidated.

Also stop swallowing the scan verdict on the TUI-gateway skills.manage
install RPC (#63307 Part B): _skills_install returned installed:true on
every path, including a security-scan block, because its console was a
sink. It now keys the response off do_install's verdict and answers an
error envelope (5031) whose message/data carry the printed tail — the
scan verdict, the ambiguity candidate table, or the fetch failure —
instead of a silent success.

The Desktop one-click hub install path gets the same treatment from the
spawned CLI's exit code (hub-actions.ts already parses it).

Original scanner patterns by the PR author.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-29 20:57:45 -05:00
Finn763
78b5b7abb8 fix(desktop): cold-start-tolerant timeouts for remote backend connections
Busy remote hosts routinely take 60-150s to cold-boot a pooled
`hermes serve --isolated` before it prints HERMES_BACKEND_READY, but the
desktop gave up at the hardcoded 45s budget and surfaced a timeout toast
milliseconds before the healthy backend announced ready (issue #94642).

- remote-lifecycle.ts: raise DEFAULT_READY_TIMEOUT_MS 45s -> 120s and add
  resolveReadyTimeoutMs(env) honoring HERMES_DESKTOP_REMOTE_READY_TIMEOUT_MS,
  clamped to a 45s floor (mirrors the local analog in backend-ready.ts).
  Wired into scrapeReadyPort and connect.
- windows-remote-lifecycle.ts: the Windows remote connect sibling used the
  same hardcoded 45s budget; it now resolves through the same helper.

Regression tests cover the env override, below-floor clamp, fractional
rounding, and malformed / non-positive fallback of the resolver.
2026-09-29 20:57:31 -05:00
mfwagner
1b62c4e442 fix(desktop): drop dead pooled remote backends with a cadence-sized failure window
Pooled remote backends are probed from the renderer's reconnect IPC on a
minutes-long cadence once the primary connection is healthy. The shared
liveness tracker's 60s failure window — calibrated for the primary's
sub-minute retry loop — expired between pooled probes, so a dead pooled
descriptor's failure streak reset on every tick, the drop path was never
reached, and the pool served the dead descriptor (ECONNRESET on every
routed call) until app restart.

Give pooled revalidation its own tracker with a 15-minute failure window:
wide enough for the observed ~4-minute probe cadence to accumulate the
3-failure streak, narrow enough that genuinely stale failures still reset.
recordSuccess() clears streaks on recovery, so the wider window cannot
produce false drops.

Closes #94381
2026-09-29 20:57:31 -05:00
Brooklyn Nicholson
eb8a1580c6 serve: retire desktop-owned local backends on code skew; guard every model-mutating path (#99859)
The code-skew watchdog only covered SSH-isolated backends; Desktop-owned
local serves share the same 'updater never restarts me' property, so they
served stale modules until the app happened to restart them (R1). Extend
start_code_skew_watchdog wiring to is_desktop_owned_backend() backends.
Centralize the skew refusal: POST /api/model/set and the tui_gateway
model.options / model.save_key RPCs now refuse with the same restart
message the picker and the gateway /model switch already return (R2).
2026-09-29 20:57:23 -05:00
Brooklyn Nicholson
0590ab25fe fix(marker): zombie-aware stale-marker self-heal parity across all readers
Rust live_marker_owner now REMOVES a stale marker (dead pid, past ceiling,
unparseable) on read — previously stale bytes were only ignored, so a crashed
updater whose Drop never ran refused every later acquire until the 20-minute
ceiling (#77259). pid_is_alive gains pid-0 and zombie handling (Linux /proc
state, macOS ps stat), closing the kill(pid,0) false-positive that kept a
dead-but-unreaped updater 'live'.

Python _pid_is_running gains the same zombie awareness via a new
_process_state probe (Linux /proc, macOS ps), which read_live_update inherits
through update_lock._pid_alive — so the CLI gate self-heals a zombie-owned
marker in seconds instead of 20 minutes (#120635, #125932).

Electron readLiveUpdateMarker consults a new posixProcessState probe
(injectable, fail-open to the signal-0 verdict) so the desktop boot gate no
longer parks on a zombie-owned marker.

Rust fix cherry-picked from #77885 (authorship kept); Python and Electron
are new parity work.
2026-09-29 20:57:17 -05:00
webtecnica
0ca79e3602 fix(installer): self-heal stale update marker after crash (#77259) 2026-09-29 20:57:17 -05:00
chelsealong
3bc6a022c9 fix(desktop): report the real POSIX drain refusal instead of a generic error
The managed-update termination probe (buildOwnedTerminationCommand) signals
REFUSED, DARWIN_UNAVAILABLE, UNAVAILABLE, and TIMEOUT via a non-zero process
exit code, but SshConnection.exec() throws on any non-zero remote exit before
the caller can inspect stdout. terminateOwnedDashboardForUpdate's status
classification (result === 'REFUSED' / 'DARWIN_UNAVAILABLE' / ...) therefore
never ran for any of those outcomes; every one collapsed into the generic
"Could not terminate the Desktop-owned remote serve for update." On a Darwin
remote this fires on every managed SSH update, since the script unconditionally
refuses to signal there.

Align the probe with this file's own established convention (used by
buildOwnedStaleTerminationCommand, pidIsOurDashboard, remoteProcessCreationTime):
always exit 0 and let stdout carry the status.
2026-09-29 20:56:59 -05:00
686f6c61
7ceda46ba4 fix(desktop): clear dead remote update markers on SSH startup
A dispatched hermes update that dies after backup/fetch left a marker
whose PID was gone, but startup still treated it as live. Unlink a
dead or recycled non-update PID and log the detached run's start/exit.
2026-09-29 20:56:46 -05:00
Siddharth Balyan
5dc1c084e9 fix(desktop): a reused tool call id no longer overwrites a finished tool row (#128009)
* fix(desktop): a reused tool call id no longer claims a finished tool row

A tool.start whose id repeats a call that already completed in the same
message was merged into that finished row: the args and error of the new
call overwrote the successful one, so a loaded skill rendered as a failed
one. The stable-id match now claims only an unresolved part, as
toolCallOwnerMessageId and applyStoredToolResultToParts already do. A
completion with no unresolved same-id part still lands on the finished
part when it repeats that part's result or carries none, so replayed and
metadata-only completions add no phantom row.

Refs NS-1029 B3.

* fix(desktop): a tool row shows only its own diff

The desktop kept a side store of inline diffs keyed by the raw tool call
id, and a tool row preferred that diff over the one on its own part. With
a reused id, the newest patch was stored under the shared id, so every row
that still carried that id showed the newest diff. Now that a reused id
gets its own row, that wrong diff became visible on the first row.

Every live completion already writes inline_diff into the part's
toolResultMetadata, and cold hydration never filled the side store. The
row now reads the diff from its own part, and the side store is removed.

Refs NS-1029 B3.
2026-09-30 01:49:32 +00:00
Austin Pickett
426853ab6a fix(desktop): ack inline-preview widget intents instead of truncating or dropping them
window.hermes.send() from a ::preview frame silently sliced prompts to 500
chars and silently dropped a second intent within the 1s throttle, so a
widget sending a JSON payload reported success while the agent got garbage
or nothing.

The parent now rejects over-length and throttled intents explicitly and
posts an ack back to the frame; send() returns a Promise of
{ ok: true } or { ok: false, error: too_long | throttled | invalid |
undelivered } (with maxLength / retryAfterMs). The cap and throttle stay;
nothing is truncated. The gate is a pure evaluateIntent() under test.

Covers the truncation, throttle, and delivery-ack findings of #118973; the
side-preview-pane bridge (finding 1) is untouched.
2026-09-29 21:45:55 -04:00
Faer1x
2f1d308850 fix(desktop): keep the first send when only the active runtime is retargeted
A background stream can change the active runtime ref while session.create
is in flight. That mismatch aborted every first send: no prompt.submit,
no stored row, and an empty tab. Abort only when route and selection
moved to a chat whose runtime is not the one create returned.
2026-09-30 03:16:01 +02:00
webtecnica
ac0cfa7db9 fix(desktop): add SSH keepalive to prevent tunnel drop after idle (#74998) 2026-09-29 20:13:52 -05:00
ethernet
c0c6fbaed1 Merge pull request #128442 from NousResearch/chore/catalog-hindsight-eb021da
chore(catalog): bump hindsight to eb021da, embedded mode works under PM
2026-09-29 21:10:35 -04:00
Brooklyn Nicholson
02e4118110 fix(desktop): distinguish transport failure classes in WS-ticket mint copy
The original 401-misclassification from #98647 (a rejected WS-ticket
mint reported as "Could not reach the remote Hermes gateway") was fixed
on main by #95701: the shared httpStatusError attaches err.statusCode
and gatewayTicketFailure routes 401/403 to the sign-in copy with
needsOauthLogin/isReauthRequired.

What remained live was the transport leg: resolveRemoteOauthTicket
wrapped EVERY non-auth mint failure - timeout, ERR_CONNECTION_REFUSED,
DNS failure, a reverse-proxy 502 - in one undifferentiated sentence
(remote-oauth-ticket.ts:38 passed a static transportMessage), so fleet
logs hid three different upstream causes behind identical copy
(~1,100 lines across 47 hours in the reporter's fleet, zero of which
named the actual failure).

- connection-config.ts: new exported gatewayTicketTransportMessage(error)
  names the failure class: timeout/unreachable/DNS -> "timed out or the
  host could not be resolved... check your network/VPN path";
  ECONNREFUSED -> "connection was refused... the gateway process is
  likely down"; anything else keeps the legacy one-liner.
- remote-oauth-ticket.ts: resolveRemoteOauthTicket derives its
  transportMessage from the cause instead of a static string. Auth
  classification is unchanged.

Refs #98647
2026-09-29 19:52:44 -05:00
Brooklyn Nicholson
5c66b2e02f test(desktop): make the lock-before-handoff test hermetic
The test passed on the author's macOS but exited 1 on CI: with
--skip-build the build lock is never acquired (the probe asserted a
release that never happened), and the unmocked platform launch fixups
ran against the fake empty executable — on Linux,
_packaged_desktop_launch_command's sandbox helper is missing and sudo
is unavailable, so cmd_gui exits 1 before the handoff.

- Run the real acquire path (no --skip-build) so the lock is genuinely
  held, then probe its release at the Electron handoff.
- Pin HERMES_HOME to tmp so the checkout-keyed lock path is isolated
  from the runner's home.
- Stub _desktop_linux_sandbox_fixup and _installed_desktop_launch_target:
  environment-dependent platform fixups are not this test's subject.
2026-09-29 19:49:52 -05:00
Brooklyn Nicholson
749ef18b16 fix(desktop): serialize the mutable build preflight behind a cross-process lock
Two concurrent `hermes desktop`/`hermes update` invocations mutate the same
checkout-scoped node_modules and apps/desktop/release with no serialization:
the check-then-build preflight (_desktop_build_needed → npm install → pack) runs
unguarded, so the loser trips over the winner's half-installed tree
(ENOTEMPTY) or packs against a missing tsc.

Lock the whole mutable window behind DesktopBuildLock, a checkout-keyed flock
under the profile-common Hermes root (kept out of the checkout so read-only
prebuilt launches still work, released by the OS if a builder crashes):

- `hermes desktop` acquires it before the freshness check and refuses a
  contended build with an explicit message (exit 2) instead of racing.
- `hermes update`'s desktop rebuild (build_update_products) waits for an
  in-flight build rather than failing the update.
- the lock is released before the Electron handoff (source, packaged, and
  --build-only paths), so an open Desktop window never blocks a rebuild,
  and after a build failure so the next run isn't deadlocked.

Supersedes #94101 (lock design; its main.py wiring no longer applies after the
cmd_gui extraction) and #96581 (wait/exit-2 UX; not checkout-keyed).

Co-authored-by: worlldz <cryptoworlldz@gmail.com>
Co-authored-by: lepetitprince716-prog <lepetitprince716-prog@users.noreply.github.com>
2026-09-29 19:49:52 -05:00