Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ paths = [
'''tests/cli/test_show_config_credential\.py''', # literal MASKED display fixtures "sk-pro...1234"/"sk-pro...5678" (the masking-under-test itself)
'''tests/tools/test_stage2_hook_api_server_keygen\.py''', # obviously-fake operator/env key fixtures (operator-env-key-0123456789, env-key-9876543210fedcba)
'''website/docusaurus\.config\.ts''', # Algolia DocSearch SEARCH-ONLY apiKey — public by design in every Docusaurus site
# Mapped 2026-09-25 (t_899f0539, apps/desktop taken wholesale from upstream; upstream-identical test fixtures):
'''apps/desktop/electron/updater/channel\.test\.ts''', # fake channel identity token 'fedcba0987654321' (updater fixture)
'''apps/desktop/scripts/side-by-side\.windows\.test\.mjs''', # fake channel identity token 'ab12cd34ef56ab78' (side-by-side fixture)
]
# Regex allowlist — SOURCE files keep full scanning EXCEPT public OAuth *client
# IDs* (client IDs are sent in browser auth flows — public by design, not
Expand Down
81 changes: 80 additions & 1 deletion apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
it teaches the invariants and the reasoning behind them so a change fits the app
even as files move. Read it with the repository `AGENTS.md` (root rules still
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
apply), [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract, and
`src/AGENTS.md` for the backend contract, slash-palette curation, and Bot Mode.

When a rule here and the code disagree, trust the code and fix whichever is
wrong — but never break an invariant to make a change easier.
Expand Down Expand Up @@ -55,6 +56,28 @@ that must outlive compression keys off the lineage root. Keep the mapping betwee
them explicit and translate at the boundary rather than passing the wrong id
inward.

Two guarantees follow from this (#111868). The user's message is durable at
send: `prompt.submit` writes the session row AND the user row before the agent
build starts, and the turn adopts that row (`_adopt_submit_user_row` →
`agent._pending_cli_user_message`) instead of appending a second one, so a
freeze or force-quit during a slow first build leaves a resumable transcript
(user message present, no reply). And renderer state keyed by profile name —
persisted tabs (`tilesByProfile`), Bot tile owner routes, cached transcript
tails, remembered session/route, session owner hints — follows a profile
rename via `migrateTilesForProfile(old, new)` (`store/session-states.ts`), the
rename sibling of `dropTilesForProfile`. Add any new profile-keyed localStorage
family to BOTH, or a rename leaves it pointing at a backend that no longer
exists ("Couldn't open this session" on every restore).

When an id is verifiably gone anyway (`goneSessionVerdict` → `'draft'`), the
window drops to a fresh draft without toasting or looping — and the unsent
text stashed under the dead key follows it: the verdict calls
`announceGoneSessionDraft(id)` and the composer's swap onto the fresh scope
consumes it once (`adoptGoneSessionDraft`, `store/composer.ts`), seeding the
composer and publishing the inline, undoable `$restoredDraftNotice`. Offer,
don't hijack: no navigation beyond the drop itself, no focus steal, no toast,
and an already non-empty fresh draft is never clobbered.

## Server truth is cached, not owned

The renderer paints from a cache of backend truth, so it must reconcile, not
Expand Down Expand Up @@ -133,6 +156,40 @@ Two auth-flavored corollaries worth naming because they are easy to get wrong:
- **A connection test must exercise the leg you'll actually use.** An HTTP
status probe passing while the WebSocket/auth leg fails is a false positive
that ships as "it said connected but nothing works."
- **Cookie-jar partition names contain nothing Electron percent-escapes.** A
`persist:` partition becomes a `Partitions/<escaped name>` folder; a folder
name with `%3A` (an escaped `:`) gets a cookie store Windows can neither read
nor write, so the session silently never persists. `electron/oauth-partition.ts`
pins the invariant; renaming a partition signs its users out once — say so.

## Guest content never opens anything by itself

Untrusted HTML runs in two places: sandboxed `allow-scripts` iframes (artifact
previews) and the preview pane's `<webview>` (`persist:hermes-preview`). Neither
may drive the OS browser without the user's hand on it (GHSA-9f4c-93c8-jc8g):
`setWindowOpenHandler` denies everything and never opens a URL as a side
effect (`electron/window-open-policy.ts`), and the webview has no
`allowpopups` — do not add it.

A guest page's `target="_blank"` links (Streamlit's "Ask Google" traceback
button) reach the OS browser through one explicit bridge instead:

- `main.ts` installs `electron/preview-guest-preload-entry.ts` via
`will-attach-webview`, keyed on the `persist:hermes-preview` partition only.
It is the app's only guest preload; a new webview does not inherit it unless
it opts into that partition.
- The preload runs in the isolated world, exposes nothing to the page, and
forwards only a **trusted** (`event.isTrusted`) primary-button click on an
`a[target="_blank"]` to the host via `ipcRenderer.sendToHost`. A synthetic
`dispatchEvent(click)` from page script is dropped there; page `window.open`
stays blocked.
- `PreviewPane` admits `http:`/`https:` only (`src/lib/preview-external.ts`)
and hands the URL to the existing `hermes:openExternal` IPC, which applies
main's URL policy. `file:` is excluded on purpose: a guest must never reach
`shell.openPath`.

Widening any of those three (partition key, trusted-click gate, scheme set)
reopens the gesture-less forced-navigation class the advisory closed.

## Compatibility without carrying the past forever

Expand Down Expand Up @@ -195,6 +252,14 @@ boundaries, optimistic rollback and stale-response ordering, and both sides of a
local/remote adapter with its profile routing intact. Match how the suite is
actually run rather than inventing a command; when in doubt, read the scripts.

## Rehearsing the guided onboarding

From `apps/desktop`, use a fresh temporary directory for each rehearsal and run
`env -u NODE_ENV HERMES_GUEST_ONBOARDING=1 HERMES_HOME=<tmp>/.hermes HERMES_DESKTOP_USER_DATA_DIR=<tmp>/electron-user-data npm run dev`
(replace `<tmp>` with that directory). To use the portal stand-in, add
`HERMES_PORTAL_BASE_URL=http://127.0.0.1:8765 HERMES_ANON_API_SECRET=test-secret HERMES_SHARED_AUTH_DIR=<tmp>/.hermes/shared`
before `npm run dev`. Stop Electron and its dev server after the run.

## The taste test before you hand off

- Does every piece of state live with its authority, at the narrowest scope?
Expand All @@ -208,3 +273,17 @@ actually run rather than inventing a command; when in doubt, read the scripts.
locales?

If any answer is "not sure," that's the part to go verify.

## Nous free tier: state is pulled, never latched in the renderer

The free tier (a Nous identity with no account, `hermes_cli/anon_auth.py`) reaches the renderer
through one JSON-RPC pair: `free_tier.status` (has_guest, enabled, available,
notice_pending, model, label) read from local auth state with zero network, and
`free_tier.ack_notice`, which persists the one-time notice flag on the identity itself. The
first-launch ready screen and the own-key strip are the SAME state rendered for two situations,
keyed on `notice_pending`; there is no localStorage latch, so the CLI and the desktop cannot
disagree about whether the notice was shown. Sign-in goes through the existing
`POST /api/providers/oauth/nous/start` + poll route, which over a free-tier identity registers the
connector transfer and reports `reason`, `account_email` and `model` on completion; every entry
point (Billing, status chip, ready screen) opens the one free-tier sign-in dialog. Never branch on
provider display names: the picker row carries `free_tier_row`, status cards carry `free_tier`.
Loading
Loading