Commit graph

96 commits

Author SHA1 Message Date
brooklyn!
c8cdeb435f
fix(desktop): allow renderer camera capture (#73558)
* fix(desktop): allow camera capture through the permission handlers

The session permission hooks were written for the voice composer and denied
video outright, so any renderer getUserMedia({video}) failed with
NotAllowedError before the OS was ever consulted.

Rename isAudioCapturePermission to isMediaCapturePermission and accept video
alongside audio in both the request and check handlers. The OS capture
permission still applies, so the user keeps a real allow/deny.

* build(desktop): declare camera usage for signed macOS builds

A hardened-runtime build needs the camera entitlement and an
NSCameraUsageDescription string, or the packaged app is killed on first
camera access instead of prompting.
2026-07-28 14:50:52 -05:00
atakan g
d4221b2736 fix(desktop): validate WebSocket token before backend ready 2026-07-28 12:35:57 -07:00
Brooklyn Nicholson
47cb4ea1fe fix(desktop): allow camera capture through the permission handlers
The session permission hooks were written for the voice composer and denied
video outright, so any renderer getUserMedia({video}) failed with
NotAllowedError before the OS was ever consulted.

Rename isAudioCapturePermission to isMediaCapturePermission and accept video
alongside audio in both the request and check handlers. The OS capture
permission still applies, so the user keeps a real allow/deny.
2026-07-28 13:40:44 -05:00
hermes-seaeye[bot]
2faac36866
fmt(js): npm run fix on merge (#73552)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 18:30:19 +00:00
Adolanium
b5dc471152
feat(desktop): make attachment data-URL size limit configurable (#73221)
Hard 16 MB cap on readFileDataUrl blocked larger local attaches with no way to raise it. Settings -> Chat now has a free-form MB field. Main process owns the persisted value and clamps only absurd inputs.
2026-07-28 09:10:21 -04:00
brooklyn!
7ddf3f1500
Merge pull request #73161 from NousResearch/bb/default-zoom-out
feat(desktop): default UI zoom to Appearance 90% preset
2026-07-28 04:08:15 -05:00
Brooklyn Nicholson
5b32f9dc36 feat(desktop): default UI zoom to five steps out (~91%)
Ship Ctrl/Cmd+- ×5 from Chromium 0 as DEFAULT_ZOOM_LEVEL so fresh
installs open tighter. Actual Size / Ctrl+0 and mandatory garbage
fallbacks all land on the same level; existing zoom-state.json is
left alone.
2026-07-28 01:35:31 -05:00
Brooklyn Nicholson
070093a318 feat(desktop): on by default for dev-server runs
Gating this behind an opt-in was the wrong call. A dev server already
executes arbitrary local JS — vite's module graph, every postinstall in
node_modules — so a loopback debugging port does not meaningfully widen
what a `npm run dev` session can already do, and `perf:serve` has opened
one unconditionally all along.

Requiring the variable also defeated the point: the tooling exists to be
reached for mid-task, and a capability you must remember to enable before
launching is one you don't have when you need it.

So the port opens on 9222 — the same port scripts/eval.mjs and
scripts/perf/lib/cdp.mjs already default to — for any dev-server run.
HERMES_DESKTOP_CDP_PORT stops being an on-switch and becomes an
override: a different port, or `off` to disable.

The hard gate is unchanged and still checked first: a packaged build
never opens the port, and no env value talks it into it. Neither does an
unpackaged `electron .` against dist/, which is how the packaged app
gets smoke tested.

Refusals only log when they contradict something the developer asked for
(a typo'd port, an explicit `off`). Packaged and dist runs are closed by
design and stay quiet.
2026-07-27 23:52:33 -05:00
Brooklyn Nicholson
6254c568c8 feat(desktop): opt-in renderer debugging port for dev runs
The renderer is a Chromium page, and apps/desktop already carries a whole
CDP toolkit for it — scripts/eval.mjs, scripts/perf/lib/cdp.mjs with its
shared SELECTORS map, and the diag-*/probe-* family. None of it can
attach to `hgui` or `npm run dev`, because neither passes
--remote-debugging-port. The only launcher that opens one is
`npm run perf:serve`, which is a separate isolated instance rather than
the app you're looking at.

Add HERMES_DESKTOP_CDP_PORT. When set, the shell opens a CDP port on
loopback so that existing tooling can read the live DOM: computed
styles, geometry, which rule actually won.

Three independent gates, all required, resolved by a pure function in
electron/dev-cdp.ts so the policy is testable without an Electron app:

  1. not packaged — a shipped build never opens the port, and this is
     checked first so no env combination can talk it into doing so;
  2. HERMES_DESKTOP_DEV_SERVER present — an unpackaged `electron .`
     against dist/ is how the packaged app gets smoke tested, so it
     behaves like the packaged app here;
  3. the port explicitly requested and a valid integer.

Default `npm run dev` is unchanged and silent: no port, no nag. An
opt-in that gets refused always logs why, so nobody loses an hour
wondering what isn't listening.

The address is pinned to 127.0.0.1 rather than left to Chromium's
default, and is deliberately not configurable — there's no reason to
expose a renderer debugger off-host and offering the knob invites
someone to try.

scripts/eval.mjs hardcoded :9222 and threw a raw ECONNREFUSED stack when
nothing was there. It now honours the same variable and explains itself.
2026-07-27 23:23:47 -05:00
Brooklyn Nicholson
579b66336f fix(desktop): let automated teardown quit past the active-work prompt
Playwright closes the app with a turn still in flight, so the new quit
confirmation waited on a click nobody was there to make and the E2E
worker died on a 90s teardown timeout.
2026-07-27 16:19:27 -05:00
Brooklyn Nicholson
9ae3bd73c9 feat(desktop): confirm before quitting with a turn in flight
Cmd-Q went straight through to teardown, killing the backend mid-tool-call
— the turn is gone and whatever the agent was part-way through writing
stays part-way written, with nothing on screen to warn about it.

Renderers now report which chats are mid-turn; before-quit merges the
reports and asks, naming them, defaulting to Keep Running. Update, swap,
and uninstall relaunches skip the prompt: those are the app replacing
itself, and a modal there would strand the detached script waiting on a
PID that never exits.
2026-07-27 15:47:57 -05:00
Brooklyn Nicholson
97a8034dfd refactor(desktop): resolve profile backend routing from one table
Three helpers each re-derived part of the same decision: which backend
serves profile P, and does its REST path need a `?profile=` scope.
profileUsesPrimaryBackend answered the first half, pathWithGlobalRemoteProfile
answered the second, and ensureBackend re-checked globalRemoteActive() around
both. Splitting one table across three predicates is how the global-remote
case ended up registering reapable pool entries for a backend it never owned.

resolveProfileBackendRoute() states the four routes in one place and returns
the backend, the descriptor scope, and whether the path needs a query
parameter. The call sites read the answer instead of recomputing it.

One behavior change falls out: `hermes:api` now passes the primary profile
through, so the primary no longer sends itself a redundant `?profile=<self>`
on a global remote that already serves it.
2026-07-27 13:44:11 -05:00
Brooklyn Nicholson
f18e50a070 fix(desktop): record main-process faults in desktop.log
Electron pre-installs its own uncaughtException listener and only warns on
unhandled rejections, so a main-process fault usually leaves the app running
with the reason on stderr — which nothing captures when the app is launched
from Finder or the Start menu. The fault never reaches desktop.log, so it is
absent from `hermes debug share` and the user can only describe symptoms.

Record both to desktop.log and flush synchronously, since a fault that does
prove fatal leaves no chance for the batched async flush. Five loadURL calls
were also unhandled, each able to leave a blank window with no explanation
anywhere the user can send us; they now name the surface that failed.

Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
2026-07-27 13:41:59 -05:00
Brooklyn Nicholson
d7e738af90 fix(desktop): retire pooled remote backends whose host went away
A pooled backend entry pointing at a remote host has no child process, so
the 'exit' handler that clears a dead local backend never fires. The
renderer's 60s keepalive touch also spares it from the idle reaper. Nothing
was left to retire the descriptor, so once the host went away the pool kept
serving it and every profile bound to that host stayed broken until restart.

Pooled remote descriptors now share the primary's liveness policy: probed on
the same revalidate tick, keyed per base URL, and dropped only after the
same consecutive-failure limit, so the next ensureBackend() rebuilds.

Co-authored-by: Rodrigo Fernandez <rod@nxtlevel.dev>
2026-07-27 13:41:59 -05:00
Gille
3884e0eea0 fix(desktop): reuse global remote backend across profiles
Keep non-primary profiles that inherit the app-global remote on the primary connection descriptor instead of creating processless pool entries that the idle reaper repeatedly removes.

Preserve per-profile remote overrides and local pooled backends, and cover the routing policy with behavioral tests.

Co-authored-by: Rodrigo Fernandez <rodrigo@nxtlevelsaas.com>
2026-07-27 13:41:59 -05:00
Vitor Cepeda Lopes
704a321870 fix(desktop): preserve OAuth sessions in sidebar 2026-07-27 13:41:56 -05:00
hermes-seaeye[bot]
8c36cd4670
fmt(js): npm run fix on merge (#72411)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 02:52:53 +00:00
Andy
651a313d2b fix(desktop): suspend decorative work when inactive 2026-07-26 21:05:55 -05:00
Ho Lim
7a5d534f5b fix(desktop): gate idle renderer loops
Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
2026-07-26 21:05:55 -05:00
teknium1
d6fdae6746 feat(desktop): quick-entry window — global hotkey capture to any session
Parity with Claude Desktop quick-entry-window / ChatGPT Quick Chat.
2026-07-26 17:48:19 -07:00
teknium1
aa3121bb34 feat(desktop): Quick Entry — global-hotkey mini composer window 2026-07-26 17:48:19 -07:00
David Metcalfe
7bbb063c71 feat(desktop): port find-in-page bridge to TypeScript Electron
The original PR targeted the CJS Electron files
(apps/desktop/electron/main.cjs and preload.cjs), but commit
39d09453f "feat(desktop): ts-ify everything" renamed them to
main.ts and preload.ts on current main. The PR's diff therefore
targeted files that no longer exist on main.

Brings the bridge forward to the current TypeScript Electron
files and extracts the IPC bridge helpers into a focused
pure-helpers module:

- apps/desktop/electron/find-in-page.ts (new):
  - performFind(webContents, query, options) — wraps
    webContents.findInPage with default-coercing options.
  - stopFind(webContents, action) — clears highlights.
  - formatFoundInPage(result) — pure projection of
    Electron's FoundInPageResult onto the wire payload shape
    ({ activeMatchOrdinal, count }).
  - installFoundInPageForwarder(webContents) — wires a
    sender-scoped 'found-in-page' forwarder; returns an
    uninstall function. Returns a no-op uninstall for null
    or destroyed webContents so callers don't need guards.

- apps/desktop/electron/main.ts:
  - ipcMain.handle('hermes:find-in-page', event => ...)
    resolves the requesting window via
    BrowserWindow.fromWebContents(event.sender) and routes
    the search to THAT window, not the global primary. This
    fixes a multi-window bug where Cmd+F pressed in a
    secondary session window (one per chat, spawned via
    hermes🪟openSession) searched the primary window
    instead of the focused surface.
  - ipcMain.handle('hermes:stop-find-in-page', event => ...)
    routes stopFind through the requesting window for
    multi-window correctness.
  - A per-sender lazy forwarder registry
    (foundInPageForwarders: Map<webContentsId, () => void>)
    installs installFoundInPageForwarder on first
    findInPage call, scoped to the sender's webContents.
    Cleans up automatically via webContents.once('destroyed',
    ...). The forwarder sends results back to the SAME
    renderer that initiated the search, never the global
    primary — so a secondary session window's Cmd+F shows
    matches from THAT window and the match counter reports
    matches from THAT window's DOM.

- apps/desktop/electron/preload.ts:
  - hermesDesktop.findInPage(query, options) — invokes
    the IPC handler.
  - hermesDesktop.stopFindInPage() — invokes the IPC
    handler.
  - hermesDesktop.onFoundInPage(callback) — subscribes to
    'hermes:found-in-page' results from the sender;
    returns an unsubscribe function so the FindBar can
    clean up on unmount.

- apps/desktop/src/global.d.ts:
  - Three new hermesDesktop method declarations:
    findInPage, stopFindInPage, onFoundInPage. The new
    forwarder install registers a 'found-in-page' listener
    bound to the sender's webContents and emits
    'hermes:found-in-page' results back to the sender.

The multi-window fix is part of the same port — the old
PR's behavior (Cmd+F in a secondary session window searched
the global primary) was a bug present in the CJS files,
not a design constraint we wanted to preserve. The new
helper module uses event.sender by design, so the
multi-window correctness lands with the TS port.

Fixes #46169
2026-07-26 17:48:05 -07:00
Brooklyn Nicholson
53707e405c fix(desktop): stop repair from deleting a healthy install's marker
Repair signalled "reinstall me" by deleting the bootstrap marker. That was
already destructive -- repair is reachable from a transient backend error on a
fully working install -- and it stranded users in first-run setup with no way
back short of hand-writing the marker file.

Carry the intent in an explicit flag instead. Repair forces the next resolve
through the installer and clears itself once the reinstall starts, so a forced
reinstall still works without destroying provenance about how the install was
created.

Closes #72166
2026-07-26 16:18:01 -05:00
Brooklyn Nicholson
048e9b2150 fix(desktop): launch a usable active runtime without a bootstrap marker
Marker presence was the launch gate, but the marker is provenance about who
ran the install -- not proof the runtime works. A CLI-installed repo+venv, or
a healthy install whose marker a repair deleted, both read as "never
installed" and dropped the user into first-run bootstrap on every launch.

Split the two questions: classifyActiveRuntime() reports marker validity and
runtime usability separately, and the resolver launches whenever the runtime
is usable, logging when it proceeds without a marker. An unusable runtime
still falls through to bootstrap even with a valid marker, so an interrupted
install can't spawn a dead backend.

Drops isBootstrapComplete(), which had no callers left once the gate moved.

Co-authored-by: iveywest <iveywest@users.noreply.github.com>
Co-authored-by: lihengming <lihengming@users.noreply.github.com>
2026-07-26 16:12:14 -05:00
Brooklyn Nicholson
75456183b6 fix(desktop): wire the credentialed readiness probe and reauth latch into boot
Connects the preceding seams to the boot path:

- buildReadinessHealthProbe picks the probe credentials from the connection's
  authMode via resolveReadinessProbeAuth, and reports whether the probe is
  credentialed so waitForHermesReady can read a 401 correctly. The bearer
  goes through fetchJson's `bearer` option — a raw `headers` object is
  ignored there, which would have silently probed uncredentialed.
- authMode is threaded into all three waitForHermes call sites: the primary
  remote connect, the profile pool backend, and the SSH tunnel (loopback
  forward, token auth), so an old backend behind a tunnel gets the same
  compatibility path.
- The confirmed reauth failure latches in remoteReauthFailure and
  short-circuits startHermes, and is cleared on every recovery path —
  resetHermesConnection (soft/hard apply and reconnect), bootstrap reset,
  repair, and a CONFIRMED oauth sign-in. A cancelled or closed login window
  leaves the latch set so the overlay stays actionable.
- gatewayAuthProviders reads the public /api/auth/providers (cached per base
  URL, failures return []) so the oauth pre-flight guard can skip its hard
  failure for an all-password gateway while keeping the strict guard
  everywhere else.
2026-07-25 21:52:51 -05:00
izumi0uu
23cb26c2e3 fix(desktop): probe /api/health for boot readiness, and survive a stalled loop
Desktop boot polls /api/status, so readiness waits on gateway config and a
cold plugin import tree. On Windows that regularly outlives the probe and
Desktop kills a backend that is already listening, respawns it, and re-pays
the same import cost — the reported crash loop.

Probe /api/health instead, falling back to /api/status only for the
missing-route shapes the fetch helpers emit (404, or HTML from the SPA), so
an older remote backend still connects. Timeouts and server errors keep
polling health rather than dropping to the heavyweight route.

A cheap route is not enough on its own. Warming the gateway import holds the
GIL, so the event loop can stall for tens of seconds and starve /api/health
too. At the default 15s socket timeout only three attempts fit in the 45s
budget; give each probe 5s so the loop keeps retrying across the stall.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: DESXIE <78300229+DESXIE@users.noreply.github.com>
Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com>
2026-07-24 19:44:48 -05:00
hermes-seaeye[bot]
bb4765d21c
fmt(js): npm run fix on merge (#71099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 00:35:03 +00:00
webtecnica
d68e043ba7 fix(desktop,update): prevent silent state.db zeroing during Windows update (#68474)
Problem:
On Windows, state.db could be silently replaced with 95MB of null bytes
during a desktop update (v0.19.0). The pre-update snapshot was valid, but
the live file was destroyed and the update reported exit code 0, masking
the data loss. Sessions between the snapshot and the update were
irrecoverable.

Root cause analysis:
The update flow (Desktop Electron → hermes-setup.exe → hermes update)
kills the backend process tree via taskkill /T /F, then pauses Windows
gateways, creates a pre-update snapshot, runs git pull + pip install, and
resumes gateways. On Windows, a force-killed process holding state.db
(SQLite WAL mode) can leave the file open to races with antivirus/NTFS
filter drivers, or the gateway resume can encounter a partially-recovered
WAL state that results in a zeroed file — all while exit code 0 reports
success.

Fix — three layers of defense:

1. Emergency desktop-side backup (pre-flight):
   - New  function in Electron main.ts reads the
     SQLite header, logs it, and takes a timestamped emergency copy of
     state.db BEFORE the backend is killed or the updater is spawned.
     Runs in both the Tauri-updater path (Windows) and the in-app update
     path (Posix). Prunes to the 2 most recent emergency backups.

2. Pre-update integrity verification (Python CLI):
   - After  creates the pre-update snapshot,
      checks the LIVE state.db file (header +
     PRAGMA integrity_check). If corrupted, checks whether the snapshot
     copy is valid and warns the user. The update still proceeds because
     the snapshot is the recovery path.

3. Post-update auto-restore (Python CLI):
   - After the update completes (both git-pull and ZIP paths), verify
     state.db integrity. If corrupted/zeroed, automatically restore from
     the pre-update snapshot and re-verify. This catches the exact case
     where state.db was destroyed mid-update but the snapshot was valid.

New functions in hermes_cli/backup.py:
  - verify_sqlite_integrity(path, check_header, run_pragma, max_bytes)
    → Three-stage check: file size, SQLite header magic, PRAGMA
      integrity_check. Configurable max_bytes to avoid reading huge DBs.
  - copy_db_and_verify(src, dst)
    → Like _safe_copy_db() but verifies the destination after backup.

Fixes #68474
2026-07-24 15:59:32 -07:00
cat-that's-fat
7dd2b2dc77 feat(desktop): add "Connect to existing Hermes" option to first-run onboarding
Adds a first-run Desktop choice between installing Hermes locally and
connecting to an existing remote Hermes gateway. The choice appears after
backend resolution but before ensureRuntime(), so selecting remote cannot
accidentally trigger local bootstrap.

New modules:
- first-run-setup-gate: concurrent first-run decision gate and reset semantics
- primary-backend-startup: Electron-free orchestration seam (saved remote
  resolution, gate decision, remote re-resolution, local continuation)
- primary-connection-rehome: prevents dual-owner race where both cold boot()
  and renderer softSwitch() could connect simultaneously
- first-run-remote-form: extracted remote form with stale-result guards

Reuses existing connection-config IPC, encrypted token storage, OAuth
session partition, and primary backend resolution.

Fixes #38602
Fixes #36970
2026-07-24 12:55:06 -05:00
seamusmore
6b278eeccc fix(desktop): respect HERMES_GIT_BASH_PATH in findGitBash()
Port the HERMES_GIT_BASH_PATH env var check from main.cjs to main.ts
after the TS conversion. Also extract findGitBash to a dedicated module
for testability and add focused regression tests for override precedence
and invalid-override fallback.
2026-07-23 17:59:58 -07:00
ethernet
3651627d88
fix(desktop): skip bootstrap for explicit Nix backend (#69688)
Treat HERMES_DESKTOP_HERMES as an authoritative deployment override.
This keeps the Nix desktop package on its matching immutable Hermes CLI
instead of falling through to install.sh when a best-effort version probe
fails or times out.
2026-07-22 20:00:48 -04:00
hermes-seaeye[bot]
bcc3396b25
fmt(js): npm run fix on merge (#69503)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-22 16:30:14 +00:00
Ben Barclay
49423f8084 fix(desktop-auth): make RFC 8252 native login work end-to-end at runtime
The native-app (RFC 8252) login passed its unit tests but failed in real
Electron runtime — the tests mocked the exact seams that were broken. Four
runtime defects, each proven against a live gated gateway:

1. Lockfile drift: apps/desktop declared @assistant-ui/react +
   @assistant-ui/react-streamdown but package-lock.json didn't place them, so
   `npm ci` (CI + every fresh checkout) failed to install them → Vite
   "Failed to resolve @assistant-ui/react". Reconcile the lockfile.

2. Double JSON encoding: postJsonNoAuth pre-JSON.stringify'd the body before
   fetchJson (which stringifies again), so /auth/native/token received a JSON
   string, not an object → gateway 422 "Input should be a valid dictionary" →
   native login silently fell back to the embedded webview.

3. Cookie-only liveness gate: buildRemoteConnection (and the Settings
   connected indicator) treated "signed in" as "has OAuth cookie". The native
   flow stores a bearer and sets no cookie, so a completed native login looped
   the UI into needsOauthLogin. Accept native token OR cookie.

4. Cookie-only REST path: the hermes:api handler routed oauth-mode REST through
   the cookie partition only. A cookieless native session → 401 no_cookie on
   every API call. Prefer the native bearer (with transparent refresh), else
   cookie — mirroring mintGatewayWsTicket, which was already bearer-aware.

The three decision points (2–4) are extracted into a pure
native-auth-decisions.ts with regression tests, since the mocked flow tests
could not catch them. Verified live: system-browser login → cookieless bearer
→ connected chat, no embedded webview.
2026-07-22 06:50:50 -07:00
Ben Barclay
7857d8737c feat(dashboard-auth): RFC 8252 native desktop sign-in (system browser + PKCE, no webview/cookies)
The Desktop app can now sign in to a gated gateway using the user's SYSTEM
browser and OAuth 2.0 for Native Apps (RFC 8252) instead of an embedded
Electron BrowserWindow, and authenticates with bearer tokens it holds itself
instead of relying on HttpOnly browser session cookies.

Why brokered: the upstream IDP (Nous Portal) binds client_id to the gateway
instance and only permits redirect_uris on the gateway's own origin, so a
desktop loopback redirect can't be a direct Portal client. The gateway
therefore acts as the authorization server TO the desktop and an OAuth client
TO the Portal, reusing the existing PKCE start_login/complete_login provider
path unchanged.

Server (Ben's dashboard-auth lane):
- native_flow.py: in-memory broker — binds the desktop's PKCE challenge to a
  completed Session, mints a single-use, short-TTL, PKCE-verified gateway
  authorization code. Constant-time compare, single-use (consumed before the
  PKCE check so a wrong verifier can't be retried), capacity-bounded.
- routes.py: GET /auth/native/authorize (starts the brokered PKCE login,
  loopback-only redirect_uri, S256-only), POST /auth/native/token (loopback
  code + verifier -> tokens in the JSON body, never Set-Cookie), POST
  /auth/native/refresh (desktop-held RT rotation). /auth/callback branches to
  mint a loopback code + 302 to 127.0.0.1 when a broker_state rides the PKCE
  cookie; the cookie/SPA path is untouched.
- middleware.py: the gate accepts Authorization: Bearer <access_token>,
  verified via the same verify_session provider stack (no cookie set/read),
  with the same "provider unreachable -> 503, not logout" semantics.
- web_server.py /api/status: advertise auth_flows (["cookie","native_pkce"])
  so clients can detect the capability; native_pkce only when a brokerable
  OAuth provider is registered.

Desktop (Ben's lane):
- native-oauth.ts: pure PKCE/capability/URL/callback/token helpers.
- native-oauth-login.ts: loopback-listener orchestration (system browser via
  openExternal, ephemeral 127.0.0.1 listener, state/PKCE verification), all
  I/O injected for testability.
- main.ts: capability-gated oauth-login IPC — native flow when advertised,
  automatic fallback to the existing embedded-webview cookie flow otherwise;
  tokens stored encrypted (safeStorage/OS keychain), REST + ws-ticket
  authenticated by bearer, transparent refresh, logout clears both shapes.

Tests: 18 server pytest (broker unit + full authorize->callback->token E2E +
cookieless bearer auth of a gated route + ws-ticket mint + capability
advertisement + refresh); desktop node --test/vitest for both pure modules
(PKCE, capability detection, callback CSRF, loopback round trip, timeout,
browser-open failure). Electron project typechecks clean.

Docs: website/docs/guides/desktop-native-signin.md.
2026-07-22 06:50:50 -07:00
yoniebans
25f4ba7b3c Merge remote-tracking branch 'origin/main' into feat/desktop-remote-ssh-current
# Conflicts:
#	apps/desktop/electron/connection-config.test.ts
#	apps/desktop/electron/connection-config.ts
#	apps/desktop/electron/main.ts
2026-07-21 10:16:01 +02:00
Gille
272bbaf792
fix(desktop): avoid false remote gateway reauthentication (#68250)
* fix(desktop): avoid false remote gateway reauthentication

Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>

* fix(desktop): harden remote revalidation state

---------

Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>
2026-07-20 20:54:36 -04:00
Brooklyn Nicholson
fb0c6d9ee1 fix(desktop): de-dupe cross-window cues so peers don't spam
With multiple full windows, each renderer independently reacts to the
same backend event, so one-shot cues fired N times: OS notifications
(the per-renderer throttle can't see other windows), the turn-end sound
(playCompletionSound runs on every message.complete, ungated by focus),
and auto-spoken replies (double voice when a chat is open in two windows).

Add a single race-free owner in the main process (electron/event-dedupe.ts):
main handles IPC serially, so the first window to claim a key within a
short window wins and peers stay quiet. Notifications collapse at the
hermes:notify choke point; the sound and spoken replies claim via a new
hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the
claim falls back to "emit", preserving single-window behavior.

The sound's mute check runs before the claim so a muted window can't win
the cue and silence an audible peer.
2026-07-20 18:33:14 -05:00
Brooklyn Nicholson
b586e4eff2 feat(desktop): open multiple full app windows (electron)
Add createInstanceWindow() — a full-chrome peer of the primary that
renders the complete app (sidebar, routing, its own draft) against the
shared backend, so several GUI windows can run at once. Mirrors the
primary's window options + chatWindowWebPreferences (backgroundThrottling
stays off so a streamed answer never stalls when blurred) but never
overwrites the mainWindow global and doesn't respawn the backend — the
renderer's getConnection() joins the running one. New windows cascade off
their source via the pure, tested instanceWindowBounds().

Exposed via the hermes🪟openInstance IPC and a "New Window" File
menu item. Per-window fullscreen state now targets the window itself, and
titlebar/native-theme repaints reach every open chat window instead of
only the primary.

Retires the now-orphaned compact new-session pop-out (its only caller was
⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow,
the hermes🪟openNewSession handler, and the newSession/new=1 URL
flag.
2026-07-20 18:02:44 -05:00
yoniebans
faa3bce6dc style(desktop): satisfy merged eslint/prettier config
The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts).
2026-07-20 23:01:49 +02:00
yoniebans
578374675e Merge origin/main into feat/desktop-remote-ssh-current
Sync with main after the contribution-shell refactor (#60638) and the backendConnectionState extraction (#65885) landed. Seven conflicts, all resolved in favor of main's new architecture with the SSH lifecycle ported on top:

- electron/main.ts: adopt backendConnectionState (attempt tokens, attachProcess, clearPromiseForAttempt) as the sole owner of the primary backend; drop the branch's raw hermesProcess/connectionPromise globals and commitConnectionFailure call site. SSH liveness-streak classification, teardown, and the SSH quit path are layered onto the new ownership model; before-quit combines SSH transactional teardown with the Windows sandbox marker (#38216).
- desktop-controller.tsx: deleted on main; its SSH beforeConnectionSwitch cleanup (preserved-route fresh draft, overlay return-route reset, project-tree reset, close-all-terminals) moves to contrib/wiring.tsx's useGatewayBoot call.
- use-session-actions/index.ts: keep main's onFreshDraftRouteIntent (fires unconditionally); preserveRoute gates only the navigate.
- gateway-settings.tsx: keep main's acceptSavedConfig/connectedCloudUrl; every save/sign-in/sign-out success path routes through acceptSavedConfig with the branch's stale-async seq guards intact.
- boot-failure-reauth.ts: keep both sshFailureMessage and main's isRemoteReauthFailure formatting.
- Both test harnesses take the union of props.

Validation: tsc -b clean; desktop suite 1704 passed / 1 skipped; electron SSH/connection modules 225 passed; python SSH + web_server suites 508 passed.
2026-07-20 22:46:39 +02:00
brooklyn!
67e73ae958
Merge pull request #68140 from NousResearch/bb/desktop-keep-awake
feat(desktop): keep-computer-awake toggle
2026-07-20 14:29:04 -05:00
Brooklyn Nicholson
e0028410ee Merge remote-tracking branch 'origin/main' into bb/desktop-keep-awake
# Conflicts:
#	apps/desktop/src/app/settings/config-settings.tsx
2026-07-20 14:14:13 -05:00
Brooklyn Nicholson
3ef5249558 refactor(desktop): drop keep-awake statusbar toggle; persist in main
Keep-awake lives only in Settings → Advanced now. Remove the statusbar
quick-toggle (+ its Sun icon, store toggle helper, and keepAwakeOn/Off
strings across locales). Since the statusbar was what eagerly loaded the
store at boot, move persistence to the main process (keep-awake.json,
re-applied on app ready — same pattern as translucency), so a cold launch
restores the blocker without the renderer opening Settings.
2026-07-20 13:57:41 -05:00
ethernet
2e10d7b942 fix(desktop): address review — overlay a11y, e2e typecheck, nits
Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression:
the top `if (reduce) setPhase('gone')` fired unconditionally on mount
whenever reduce-motion was on, so every OS reduced-motion user lost the
CONNECTING overlay during cold boot entirely (jumped to 'gone' before the
gateway was even open). The intent was to skip the exit *choreography*,
not to skip showing the overlay. Removed the unconditional top block and
the redundant nested preview block; kept only the third branch
(`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' :
'text-out'`) which correctly gates the short-circuit on connect. Also
fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line
comment pasted three times.

Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI.
Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts,
adds @playwright/test types) and wired it into the typecheck script. This
surfaced three latent type errors that are fixed in the same commit:
  - fix-electron-tracing.ts: `app._context` and `electron._playwright` are
    private APIs — added `as any` on the access before the existing cast.
  - playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:`
    is not a valid UseOptions property in playwright 1.58; it's a
    BrowserContextOption accessed via `contextOptions: { reducedMotion:
    'reduce' }`. The old form was silently ignored at runtime, so
    reduced-motion emulation wasn't actually active — screenshots could
    catch overlays mid-fade (exactly what the comment warned about).

Nit #2 — fix-electron-tracing.ts reaches into Playwright internals
(_playwright, _allContexts, _context) with no public contract. Added a
header comment calling out the `@playwright/test` exact pin (=1.58.2) so a
future bump knows to re-verify the private symbols still exist.

Nit #3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation.

Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors;
vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass;
npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass.
2026-07-20 14:37:39 -04:00
Brooklyn Nicholson
9399839dd4 feat(desktop): keep-computer-awake toggle + System settings section
Add a "keep computer awake" toggle (Claude-style) for long/overnight runs:
the renderer owns the device-local pref and mirrors it to the Electron main
process, which holds a single `powerSaveBlocker('prevent-app-suspension')` —
the same authority split as translucency. Surfaced as a statusbar quick-toggle
and a Settings row.

Introduce a dedicated System settings section (device-local machine prefs) and
de-crowd Appearance by moving Window Translucency + UI Scale into it (both are
main-process/window-owned, not visual theme). Give Haptic Feedback its first
Settings home there too (the titlebar quick-toggle stays). Relocated i18n copy
into `settings.system` across en/zh/zh-hant/ja; wired the `system` route into
the SettingsView union + allowlist + nav.
2026-07-20 11:50:22 -05:00
ethernet
b2857110b4 fix(desktop): kill loading bar in boot-failure e2e screenshots
The boot-failure screenshot showed a progress bar because of two bugs:

1. waitForBootFailure matched on "Let's get you setup" (the onboarding
   header that mounts from frame 1 during normal boot), so the screenshot
   fired at ~86% progress while the Preparing component's progress bar was
   still painted.

2. The Preparing component kept rendering the progress bar even after
   boot.error was set — it just turned the bar red and appended the error
   text below it.

Fixes:
- Preparing bails out (returns null) when boot.error is set, so
  BootFailureOverlay (z-1400) owns the screen exclusively.
- applyDesktopBootProgress no longer clobbers a previously-set boot.error
  when a late progress event arrives with error: null — failDesktopBoot is
  terminal for the boot cycle.
- waitForBootFailure guards against progress bars being visible and matches
  on actual failure signals (error toast, Retry/Repair buttons), not the
  onboarding header.
- setupDeadBackend now accepts { fakeError: true } which injects
  HERMES_DESKTOP_BOOT_FAKE_ERROR to trigger a real boot failure — the
  previous dead-provider fixture never actually caused a boot failure
  (hermes serve starts fine; the dead endpoint only matters at chat time).
- boot-failure.spec.ts updated to use { fakeError: true }.

Verified: e2e test passes with 0 progress bars in the DOM at screenshot
time (confirmed via DOM inspection), 16/16 vitest tests pass, typecheck
clean.
2026-07-20 11:44:41 -04:00
ethernet
c5111388c7 fix(desktop): minor type fixes and devShell cage dep
- Type gatewayState in session store
- Electron main.ts: force-show window for e2e test workers
- tsconfig: include e2e test types
- nix/devShell.nix: add cage for headless visual testing on tiling WMs
2026-07-20 11:44:40 -04:00
yoniebans
45acb62c6d feat(desktop): route Desktop SSH to the Windows lifecycle
Detect the remote platform on connect and Test SSH: uname first (Linux/Darwin
keep the POSIX lifecycle), else an encoded-PowerShell probe that routes a
Windows host through connectWindowsRemote. The Windows lifecycle mirrors the
POSIX one — probe, one-shot token upload, spawn, readiness scrape, tunnel,
reuse-by-exact-ownership, and teardown — over a PowerShell helper dialect.

Preserve a backend on indeterminate process state (never destroy on unknowns);
a thrown terminate aborts before remove-lock so a live backend is never
orphaned. Interactive terminals on a Windows backend open PowerShell in the
requested cwd instead of a POSIX login shell.
2026-07-20 17:19:22 +02:00
Ben Barclay
f0aae14c68
fix(desktop): retry OAuth cookie read on cold-start jar race (#67769)
A `persist:` partition's cookie store hydrates lazily, so the first
cookies.get() on a fresh launch can return empty for a signed-in user.
That false-negative made hasLiveOauthSession() throw "not signed in",
which on the no-retry initial boot path surfaced as the transient
"Hermes couldn't start" OAuth overlay that always cleared on Retry.

hasLiveOauthSession now reads once (no added latency on the happy path);
only on an empty read does it warm the store (flushStorageData + a
throwaway get, memoized) and re-read with a bounded ~180ms backoff
before trusting the negative. Genuinely signed-out users still resolve
false quickly and get the overlay. Fixes the whole class: the same
function backs the reconnect path and the Settings connected indicator.
2026-07-20 16:01:38 +10:00
Brooklyn Nicholson
40160e2a04 perf(desktop): batch sidebar session slices into one profile-DB pass
The sidebar refresh fired three /api/profiles/sessions calls (recents, cron,
messaging), and each one reopened every selected profile's state.db and re-ran
list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every
turn/broadcast/reconnect.

Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB
once and runs the three source-scoped queries together (recents scoped to the
active profile; cron + messaging cross-profile), returning the three windows in
one payload. Same read-only projection, 300s active heuristic, and caller-
supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as
the per-slice endpoint.

Renderer refreshSessions now makes one listSidebarSessions call and distributes
recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs
API). Electron splices remote profiles per slice via fetchProfilesSessionSlice
(reusing the proven per-slice merge) so remote correctness is preserved; the
no-remote common case gets the single-open fast path.

From the Desktop performance audit (P1: "Batch sidebar session slices").
2026-07-18 21:45:18 -04:00