Commit Graph

28266 Commits

Author SHA1 Message Date
David Metcalfe d1c6d5bab6 fix(desktop): reset keychain entry after macOS re-sign to prevent prompt on every launch
The self-updater rebuilds the desktop app locally via electron-builder after
every update (4aa9f738ce).  On macOS, the rebuilt app gets ad-hoc signed,
producing a different cdhash than the original CI-signed build.  macOS ties
the 'Hermes Safe Storage' keychain item's ACL to the code signature, so
the new signature doesn't match → macOS re-prompts for keychain access on
every launch.

After re-signing, delete the existing keychain item so Electron recreates
it with the correct ACL for the newly-signed app on next launch.  The
trade-off: previously encrypted tokens become unreadable (the user
re-enters the gateway token once), but the keychain prompt stops appearing
on every launch.

The delete-generic-password command doesn't require reading the secret
(no ACL check), so it runs without prompting.
2026-08-25 23:23:11 -07:00
hermes-seaeye[bot] bc943d5672 fmt(js): npm run fix on merge (#95299)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-26 06:20:59 +00:00
Teknium 71d804d005 fix(desktop): sort titlebar-overlay-width import before window-connection-route (lint) 2026-08-25 23:15:49 -07:00
Teknium a7e1410faf chore: map Kavyrocom attribution 2026-08-25 23:15:49 -07:00
Jeremy cab6c4f70b fix(desktop): fail closed when messaging DELETE cannot resolve an owner
A listed profile-less row in a multi-profile setup must not DELETE/archive against the primary backend. Unresolved ownership keeps the row, pins, and unread state and never calls the mutation.
2026-08-25 23:15:49 -07:00
Jeremy add24666d5 fix(desktop): prefer messaging/cron slice when restoring a dual-listed session 2026-08-25 23:15:49 -07:00
Jeremy 718654a9a2 fix(desktop): route messaging session DELETE/archive to owning profile 2026-08-25 23:15:49 -07:00
Mabolla 7944ad2168 fix(desktop): isolate SSH routing by window 2026-08-25 23:15:49 -07:00
Mabolla d77114d0d0 fix(desktop): align SSH terminal routing precedence 2026-08-25 23:15:49 -07:00
Mabolla a70e7b0ca7 fix(desktop): route terminals through registry SSH 2026-08-25 23:15:49 -07:00
Mabolla 24ab1acbe0 test(desktop): cover v2 registry SSH terminal scope 2026-08-25 23:15:49 -07:00
Mabolla d2c8727db5 fix(desktop): resolve active v2 registry SSH scope 2026-08-25 23:15:49 -07:00
Shaq Wu 120430be47 fix(desktop): retain routed socket through turn completion 2026-08-25 23:15:49 -07:00
chelsealong 6b3977b577 fix(desktop): decide image/file byte-upload by the session's own connection, not ambient
uploadComposerAttachment's remote/local decision (image.attach vs
image.attach_bytes) read $connection.get()?.mode — the window's ambient
connection — at all three call sites. Since the multi-connection
routing work landed, a session can be owned by a different, registered
connection than the ambient one (Bot Mode, the unified Sessions list):
the RPC itself already routes to that owner via requestForSessionProfile,
but the byte-vs-path decision did not, so a local-ambient window chatting
in a remote-owned session shipped a client-local composer-images path to
a backend that can't read it (#94640).

isSessionRemote() resolves the session's own owner route (falling back to
ambient only when no owner route is known, matching the existing RPC
routing behavior in session-states.ts) and replaces the three ambient
reads in use-prompt-actions/index.ts, session-tile-actions.ts, and
user-edit-composer.tsx.
2026-08-25 23:15:49 -07:00
Kavyrocom 76af834ddd fix(desktop): route ambient SSH profile deletion to remote 2026-08-25 23:15:49 -07:00
Teknium 2664599644 test(gateway): prove the profile_route_rejected sentinel is observable
Review follow-up for the salvaged #94848: the ProfileRouteRejected marker
looked write-only inside the primary handler. Document that
_handle_message's ingress gate reads the same marker to drop the message
fail-closed, and add a regression test showing the rejected route is
stamped once, dispatch falls back to the default home, and routing is not
re-run on redelivery.
2026-08-25 23:15:15 -07:00
GarrettGlass 2afed50863 fix(gateway): authorize routed messages in transport scope 2026-08-25 23:15:15 -07:00
GarrettGlass 9ab748abb9 fix(gateway): scope routed history before session lookup 2026-08-25 23:15:15 -07:00
Teknium 85bb6515c0 docs: document the no_overlay auto-detect and new Linux X11 default 2026-08-25 23:14:34 -07:00
YappLeCunt c6ae9325ec fix(computer-use): default the cursor overlay off on Linux X11
cua-driver maps the agent-cursor overlay as a fullscreen, always-on-top,
all-workspaces X11 window (save-unders composited). When a computer-use
session ends uncleanly — an agent interrupted mid-capture, a stale target
window, a driver error — that window can be left stuck above every app on
every workspace, wedging desktop input until the app is restarted. This
is the same failure class as the HUD's transparent always-on-top window on
Mutter/X11 (#83473), and it bit a real user: an interrupted capture froze
the desktop, the app had to be force-restarted, and the overlay window was
still mapped fullscreen afterwards.

The overlay is cosmetic (a tinted cursor sprite); the driver, captures,
and synthetic input all work without it. `_cua_no_overlay()` already
defaulted it off on macOS (idle CPU redraw loop, #28152/#47032) and
headless/WSL2 Linux; this extends the same auto-detect to X11 desktop
sessions, where raw X11 stacking has no compositor-owned surface to tear
down with the driver's connection. Wayland keeps the overlay: the
compositor owns the layer-surface lifecycle, so a dead driver cannot leave
a stuck top window.

Behavior contract unchanged: an explicit `computer_use.no_overlay: false`
still restores the cursor on any platform, and `true` forces it off.

Tests: X11 (DISPLAY set, no Wayland env) and XDG_SESSION_TYPE=x11
auto-detect off; Wayland keeps the overlay; explicit false overrides
auto-detection on X11.
2026-08-25 23:14:34 -07:00
ruangraung dce4abe917 fix(gateway): log secondary startup-reconnect handoff failures instead of dropping them
Review follow-up to the AI code-review pass on PR #92074: the bridge task's
handoff into _schedule_secondary_profile_reconnect was unguarded at both call
sites inside the parked coroutine. The scheduler touches live registries
(_profile_failed_platforms slot creation, background-task registration), so an
unexpected raise there would kill the parked task as an unretrieved-task
exception — logged only at GC time via "Task exception was never retrieved",
where no operator ever looks. A fix whose entire purpose is to stop a platform
dying silently should not contain its own silent-death path; both handoff sites
now wrap the scheduler call with logger.exception so the failure lands in
gateway.log with profile and platform context.

The early-exit branch (gateway already _running when the bridge starts) had the
identical exposure and is guarded the same way — same bug class, fixed together.
Regression test drives a handoff raise end-to-end through the real bridge task:
the await completes cleanly, the error is captured in gateway.run's logger, and
no adapter or failed-platform slot leaks behind the failed handoff.
2026-08-25 22:55:07 -07:00
ruangraung 96489f3c1b fix(gateway): schedule secondary-profile reconnect when initial adapter connect fails
When gateway.multiplex_profiles is active, a secondary profile whose platform
adapter fails its initial connect at startup was silently given up on: the
failure branches in _start_one_profile_adapters() logged and disconnected, but
never scheduled recovery. One unlucky connect window during a Telegram API
outage left the profile permanently silent until manual restart (~80 min in
the observed incident), while the mid-run fatal path already recovers via
_handle_profile_adapter_fatal_error() -> _schedule_secondary_profile_reconnect().
The same gap hit both failure shapes: a clean False return from
_connect_initial_adapter_with_timeout() and an exception escaping it.

Fix: call _schedule_secondary_profile_startup_reconnect() from both startup
failure branches after _safe_adapter_disconnect(). Because secondary adapters
are started mid-start(), before self._running flips True, the regular
scheduler's not-self._running guard would silently drop the request — so the
new bridge parks a background task until startup completes (or shutdown
begins) and then hands off to _schedule_secondary_profile_reconnect()
verbatim: backoff, fresh-adapter rebuild under the profile runtime scope,
slot dedupe, and shutdown cancellation all come from the existing path.
Non-retryable failures are dropped at scheduling time exactly as the regular
scheduler drops them, keeping duplicate-credential/auth-failed startups dead
instead of looping.

Fixes #92064
2026-08-25 22:55:07 -07:00
Ben Barclay 055d58ba33 test(telemetry): add the live staging E2E script
Sends real packages through the real sender to the real staging ingest
service and reports what came back. Uses a throwaway HERMES_HOME so an
operator's own telemetry state is never touched, and asserts the local
install_id did not cross the wire.

Kept as a script rather than a pytest case on purpose: it needs live
network and a deployed staging service, so it must not run in CI.
2026-08-26 15:55:06 +10:00
Teknium 682a34a7c6 test(desktop): isolate warm-cache mapping describe from prior gateway mock traffic
Follow-up for salvaged PR #93451: main grew branchStoredSession tests (#93444)
that drive the same hoisted requestGatewayForProfile/ForAgent mocks, so the
not-called assertions in the warm-cache describe saw stale recorded calls.
2026-08-25 22:51:31 -07:00
Teknium 1f78750280 chore: add contributor email mappings for salvaged PRs 2026-08-25 22:51:31 -07:00
Leonid Skorobogatyy 2a4460b58b fix(desktop): guard gateway-open resume against active fresh-draft transition (#68594)
When switching profiles, useRouteResume() could treat the target profile
gateway opening as a resume command for the old routed session before
React Router commits the /new pathname. The gatewayBecameOpen trigger
was not guarded by the existing freshDraftReady discriminator, unlike
the stuckOnRoutedSession guard on line 137 which already uses it.

Fix: add && !freshDraftReady to the gatewayBecameOpen condition in
shouldResume (line 143). This is consistent with the existing pattern
and preserves reconnect behavior (freshDraftReady=false during normal
reconnects).

Adds a regression test that simulates the exact race: profile switch
clears refs and sets freshDraftReady=true, gateway closes, then profile
B gateway opens while pathname is still /session-a. Asserts no resume
fires.
2026-08-25 22:51:31 -07:00
echoes666 4b21f970aa fix(desktop): release profile activation lease on dial failure
Clear the profile-door activation lease in a finally block whether the
secondary dial succeeds or rejects. Preserve reconnect scheduling and the
original error propagation.

Add a regression proving a rejected activation can be pruned immediately.
2026-08-25 22:51:31 -07:00
echoes666 c06d3c0bd9 fix(desktop): surface profile-switch dial failures instead of silently activating
Rebase of #81165 onto post-#87600 main, slimmed to the error-surfacing UX
half as requested - the silent-misroute half already landed via #87600.

- ensureGatewayForProfile: keep scheduleReconnect() on a failed secondary
  dial (transient failures still self-heal) but rethrow so the caller can
  surface the failure instead of falling through to setActive() with a
  closed socket (#81094). openSecondary logs the dial target and rethrows
  the ORIGINAL error so reconnectSecondary's message-based fail-stop
  classification ("No connection with id", "no longer exists") keeps working.
- profile.ensureGatewayProfile: propagate the rejection to await-callers
  (session actions, slash commands surface it in their own flows); the three
  fire-and-forget call sites (selectProfile, newSessionInProfile, voice
  wiring) get explicit .catch(notifyError) so the failure is always visible.
- Tests: gateway.test.ts pins rethrow-without-activation plus backoff
  self-heal; profile-switch-failure.test.ts pins the profile-door
  propagation; gateway-shared-remote's pooled-reconnect test updated to
  the new contract (reject first, publish once the backend returns).

Verified: tsc --noEmit clean; 65 tests across the gateway*/profile*
suites pass; eslint and prettier clean on touched files.
2026-08-25 22:51:31 -07:00
cmoiccool f3ae1a0c3a refactor(desktop): use shared local connection id 2026-08-25 22:51:31 -07:00
cmoiccool 7d6bf56c7b fix(desktop): preserve profile overrides on local source picks
Treat the explicit local registry source like the legacy profile-only path so per-profile remote overrides still resolve. Keep non-local registry sources connection-scoped and cover both profile selection entry points.
2026-08-25 22:51:31 -07:00
Tom Reinelt 613822afff fix(desktop): invalidate stale runtimes before profile reopen
Remember secondary scopes across renderer pool pruning and clear their process-local runtime bindings before a later socket can publish open. This prevents eager session RPCs from racing a respawned profile backend with an ID minted by the previous process.
2026-08-25 22:51:31 -07:00
Luke Roberts 893c8b1fdd fix(desktop): preserve remote session routing 2026-08-25 22:51:31 -07:00
arya abe584288b fix(desktop): preserve registry owner for session sends 2026-08-25 22:51:31 -07:00
arya 26932ea0bf fix(desktop): retain registry session ownership 2026-08-25 22:51:31 -07:00
arya 1ec32e7385 fix(desktop): reuse primary during owned route activation 2026-08-25 22:51:31 -07:00
arya 6c0ddecf66 fix(desktop): reuse registry primary for owned session RPCs 2026-08-25 22:51:31 -07:00
Zeus-Deus 502298ef62 fix(desktop): preserve recovery refresh ownership 2026-08-25 22:51:31 -07:00
Zeus-Deus 693dd5d042 fix(desktop): guard stale refresh ownership 2026-08-25 22:51:31 -07:00
Zeus-Deus b3bdf0c816 fix(desktop): guard async switch publications 2026-08-25 22:51:31 -07:00
Zeus-Deus ee3dc554f5 fix(desktop): enforce gateway switch publication ownership 2026-08-25 22:51:31 -07:00
Zeus-Deus f23b3c805c fix(desktop): preserve switch loading ownership 2026-08-25 22:51:31 -07:00
Zeus-Deus 559a56c360 fix(desktop): preserve gateway switch recovery ownership 2026-08-25 22:51:31 -07:00
Zeus-Deus c2bce09d48 fix(desktop): recover failed gateway switch setup 2026-08-25 22:51:31 -07:00
Zeus-Deus b687c6b2c1 fix(desktop): cancel timed-out gateway activations 2026-08-25 22:51:31 -07:00
Zeus-Deus 300fcdbfbd fix(desktop): harden gateway-switch commit ordering for stalled and overlapping switches (#93937)
Review follow-up. The two-phase switch closed the runtime-id leak for a
single switch, but two shapes still broke the wipe→publish ordering:

Overlapping: click A commits (wipes) and waits for its activation behind
the profile-store mutex; click B finishes its dial and wipes too; then A's
activation lands and publishes A AFTER B's destructive wipe, and A's
endGatewaySwitch() dropped the (boolean) barrier while B was mid-commit.
Now the wipe runs INSIDE the serialized section via a `beforeActivate`
commit hook on ensureGatewayAgent, synchronously right before the socket
is activated; the hook re-checks the click revision and declines when
superseded, so a queued-then-superseded switch neither wipes nor
activates. The barrier is token-owned (latest switch wins), and the mutex
wait is a loop so waiters that wake together can't run interleaved.

Stalled: a wedged spawn / ticket mint / IPC (the #93454 class) left the
spinner up, swallowed later clicks on the same source, and — inside
ensureGatewayAgent — latched the mutex and the barrier. Every await is now
bounded (dial, activation, descriptor lookups, setLastUsed) via a shared
withTimeout helper extracted from use-gateway-boot. A commit that timed
out after the new source was already published counts as committed; one
that stalls after the wipe lowers the barrier and repaints the source
that is still active.

Tests: queued-then-superseded switch, dial that never answers (and retry
not swallowed), activation stalled after/before publication, barrier
ownership, commit-hook ordering inside the mutex, bounded descriptor
lookup releasing the mutex. All fail against the previous revision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:51:31 -07:00
Zeus-Deus 5472d1d66c fix(desktop): commit gateway switches before publishing the new source (#93937)
The Sessions sidebar switcher (selectConnection) activated the target
gateway first and wiped session bindings afterwards — across an IPC
round-trip — so route/session effects saw the new source while
$activeSessionId still named the previous backend's runtime id, sent it
to a backend that never minted it, and got "session not found". The
Settings → Gateway apply (softSwitch) never had this problem: it raises
$gatewaySwitching, runs beforeConnectionSwitch and wipes before dialing.

Give both doors one commit point. store/gateway-switch gains
beginGatewaySwitch()/endGatewaySwitch(): barrier up, the registered
machine-context reset, session wipe — in one synchronous step.
useGatewayBoot registers its beforeConnectionSwitch/refreshSessions with
the store and softSwitch goes through the same entry point.

selectConnection becomes two-phase: dial the target WITHOUT activating
(openGatewayAgent → openGatewayForAgent with an activation lease, so a
live-work recompute can't prune it mid-spawn), then beginGatewaySwitch()
and activate the already-open socket with no await between the wipe and
the publication. A dead target fails with the current workspace intact;
a superseded click never activates its target; an activation that fails
after the wipe repaints the still-active source instead of leaving the
sidebar skeleton.

Tests: real-store end-to-end regression (real useGatewayBoot + gateway
registry + selectConnection over fake sockets) — red on the previous
code with the leaked runtime id observed at publication — plus store
ordering/failure-path, commit-point and prune-lease coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 22:51:31 -07:00
Ben Barclay 6fdf6f4d4a feat(telemetry): run the send pass off the export hook
Step 6 of the shared-metrics exporter, plus a loopback E2E.

_export now triggers an opt-in send pass on a daemon thread. The hook
runs on finish_task — the user's interactive path — so a 30s network
timeout there would be felt directly; the thread keeps that latency off
the caller. A test asserts _export returns in under a second while a
send is deliberately blocked.

At most one pass is in flight per process: a queued second pass would
add nothing, because the next hook fire picks up whatever is still
pending. Shutdown joins the thread for at most two seconds, then lets
it go — the packages remain in SQLite and go out on the next run, so
blocking a user's exit on a slow network is the wrong trade.

Sending is resolved per pass from the profile's own config, so turning
it off takes effect at the next hook fire without a restart.

E2E (tests/hermes_cli/test_shared_metrics_sender_e2e.py): the real
sender against a real HTTPServer on loopback — actual urllib, gzip,
headers and sockets rather than an injected fake. Covers delivery and
sent-state, 400/429/5xx handling, a retry sending byte-identical
bytes, gzip shrinking a realistic 120-metric package and the server
parsing it back, install_id never crossing the wire, the outbox file
staying untouched, and a dead server deferring without raising.

Wiring tests: 12, all negative-space properties — no send without
opt-in, no blocking, no pile-up, no crash propagation.
2026-08-26 15:49:25 +10:00
Ben Barclay 00c75cea33 feat(telemetry): send exported packages to the ingest service
Steps 4, 5 and 7 of the shared-metrics exporter: the send logic, the
consent gate, and backoff plus multi-process claiming. These arrive
together because the sender is not correct without all three.

Contract handling: 202 marks sent; 400 is permanent and never retried;
429 honours Retry-After (clamped to a day so a bogus value cannot park a
package); 5xx, timeouts and transport errors retry three times in-process
with 1s/5s/25s full-jitter backoff, then defer to a later pass.

Consent is gated on the package's PERIOD, not its creation time. A period
is split across packages created on different days, so a created-at gate
would send a period's tail while dropping its head and silently
undercount the opt-in day — data that looks complete and is wrong. The
opt-in day is recorded once and never moves, so toggling sending off and
on does not re-open the pre-consent backlog.

Rows are claimed in a write transaction, which is what stops two Hermes
processes sharing one database from sending the same package twice.
next_attempt_at persists backoff across restarts, so a hard-down service
is not retried on every task completion.

The body is recomputed from payload_json rather than stored a second
time: json.dumps is deterministic here (verified against the real outbox
— 11 of 11 files reproduce byte-for-byte), and the only mutable input,
the derived identity, is frozen on the row at first attempt. That keeps
retries byte-identical across a salt rotation for ~36 bytes instead of a
duplicate ~11 KB payload.

The outbox directory is never written to or deleted from. A 202 updates
SQLite only, because those files are the user's 30-day local history and
retention already owns their lifecycle.

Tests: 33. Two of them caught real defects in this commit — an
unreadable row aborted the claim transaction and blocked every package
behind it, and the compression assertions were passing through an
injected fake that bypassed the code under test.
2026-08-26 15:45:02 +10:00
Ben Barclay 7ffd454df6 feat(telemetry): derive the transmitted install identity via keyed HMAC
Step 3 of the shared-metrics exporter.

The shared-metrics doc commits that a remote exporter 'must not reuse
the persistent local identifier by default'. install_id is therefore
never transmitted: each package carries
HMAC-SHA256(local-only rotation salt, install_id) instead.

Within a 30-day rotation window the value is stable, so distinct
installs remain countable — the first question the data has to answer.
Across windows it changes, bounding long-term linkability. The
derivation is one-way, so the service cannot recover install_id.

The salt lives in telemetry_state next to install_id, so removing the
shared-metrics directory resets both together and the documented reset
behaviour keeps working with no second cleanup path.

Rotation is deliberately not a bare 'age > interval' check: a clock
that jumps backwards must not read as an expired salt, and an
unparseable issued-at reissues instead of raising.

substitute_install_id replaces exactly one field and copies rather than
mutating, so payload schema evolution stays a sender-side concern.

Tests: 19, including that install_id never survives substitution, that
no other field changes, and — the property that keeps retries
contract-compliant — that a package rebuilt from a FROZEN derived id is
byte-stable across a salt rotation while a fresh derivation is not.
2026-08-26 15:41:18 +10:00
Ben Barclay e5180ab3df feat(telemetry): add opt-in send config and send-state columns
Step 1+2 of the shared-metrics exporter.

Config: telemetry.shared_metrics.send (default false) and .endpoint
(default production), resolved by a new shared_metrics_send_config
module. Precedence is HERMES_TELEMETRY_ENDPOINT > config > default; the
env var exists so the live staging E2E never has to mutate a user's
config. send requires enabled and never implies it — that combination
is a misconfiguration the user believes is working, so it logs an ERROR
once per process rather than silently doing nothing. Plaintext
endpoints are refused unless the host is loopback, so a typo cannot
send telemetry in clear text.

Per AGENTS.md, outbound telemetry needs a user-facing opt-in, so
setup_telemetry now prompts for sending as a second, separate question
and force-disables send when collection is turned off.

Storage: six additive nullable columns on package_outbox for send
bookkeeping. The store schema version deliberately does NOT move —
_ensure_schema_in_transaction raises on any version it does not
recognise and has no forward-compatibility branch, so bumping it would
hard-fail an older Hermes, a second profile on an older build, or a
rollback, against the same file. Old readers select named columns and
never SELECT *, so the additions are invisible to them.

Also corrects the two places that promised telemetry is never uploaded
(config_defaults comment and cli-config.yaml.example); leaving them
would make them false privacy statements once sending ships.

Tests: 26 covering config precedence, the enabled/send relationship,
transport safety, fresh-database creation, upgrade from a pre-send
database (rows preserved, version pinned, idempotent), and that the
shipped export query still runs. Mutation-checked: bumping the schema
version fails 5 of them.
2026-08-26 15:39:47 +10:00