45240 Commits

Author SHA1 Message Date
Brooklyn Nicholson
929de15c73 docs(desktop): correct WorkspacePageHeaderControl older-host note
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions
A named import of a missing SDK export fails to link through the runtime
shim, so the plugin never loads; it is not undefined. Point authors at a
namespace-import feature-detect or the raw Contribute form.

Refs #123597

Originally authored by Justin Haynes (@jhaynes).
2026-09-27 13:18:46 -05:00
Brooklyn Nicholson
aa25f9e85f fix(desktop): Kanban board switcher outside the full-page layout
A Kanban board opened in a split route tile rendered no board switcher:
the board contributed it to WORKSPACE_PAGE_HEADER_AREA unconditionally,
and only the workspace pane paints that area. The tile's contribution
also leaked into another page's header and shared its id with the full
page's, so closing the tile removed the page's switcher.

Add WorkspacePageHeaderControl (exported via the plugin SDK). The
workspace pane's render provides a private host context; inside it the
control projects into the page header, anywhere else it renders inline.
The board mounts BoardSwitcher once, through it, in its own header row.

Fixes #123597

Originally authored by Justin Haynes (@jhaynes).
2026-09-27 13:18:46 -05:00
teknium1
14c4b62e6a website: catalog card banner slot at the 2:1 catalog ratio instead of a 120px crop
The fixed 120px strip cropped ~50px off the top and bottom of every authored
banner at 3 columns. The catalog spec (plugin-catalog/README.md) asks for 2:1
images and 48 of the 56 shipped banners are exactly that, so size the slot by
aspect ratio: the same width on every card in the grid keeps heights uniform
(probe: 9/9 grids single height) and 49/57 loaded banners now render whole.
The placeholder glyph scales up with the taller slot.
2026-09-27 11:18:17 -07:00
Brooklyn Nicholson
c04e9a1d0d fix(desktop): honour a user-set voice.silence_duration in the voice loop
The desktop voice conversation hardcoded its silence hold at 1,250 ms and
ignored the voice.silence_duration config key the CLI, TUI and gateway
capture paths already honour, so users who pause mid-thought were cut off
with no way to raise the threshold, and fast local STT/TTS stacks sat
through a rigid extra second of dead air.

Seed a $voiceSilenceMs atom from /api/config on every refresh (the same
useHermesConfig path as stop_phrases / barge-in sensitivity): a value the
user actually changed (differing from /api/config/defaults) overrides the
tuned desktop hold; an untouched install keeps 1.25 s, because /api/config
merges DEFAULT_CONFIG and would otherwise raise the hold to 3 s for
everyone. Malformed or non-positive values fall back like the gateway's
shape-safe lookup, and booleans never coerce to 1 s.

The barge-in utterance endpoint reads the same atom live per frame, so
barge-in capture and the voice loop stay matched across config refreshes.

Supersedes #83572 (live-atom read, no backend-default comparison, would
triple the hold to 3 s for untouched installs) and #85772 (same, plus
accepts booleans as 1 s).
Fixes https://github.com/NousResearch/hermes-agent/issues/83518
Fixes https://github.com/NousResearch/hermes-agent/issues/124760
2026-09-27 13:15:32 -05:00
Brooklyn Nicholson
ac9a850eb9 fix(desktop): don't offer Rename on canonical Bot Chat tabs
Fixes #124857
2026-09-27 13:11:35 -05:00
Adolan
9b17e80970 fix(desktop): keep single line breaks in assistant replies 2026-09-27 13:11:25 -05:00
Brooklyn Nicholson
8c180e3fa2 fix(desktop): paint OS-dropped images as thumbnails in sent messages
Fixes #123368

Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
2026-09-27 13:10:00 -05:00
Brooklyn Nicholson
a776efb06a fix(desktop): show saved key previews without the backend's redaction sentinel
Fixes #124378

Co-authored-by: Adolan <94890352+Adolanium@users.noreply.github.com>
2026-09-27 13:08:44 -05:00
PRATHAMESH75
b07a3b46ed style(desktop): satisfy curly + import-sort lint on personality folding
The new foldPersonalityName call sites and the @/lib/personalities import
tripped the curly and perfectionist/sort-imports rules, reddening the
desktop check:lint gate. Brace the single-statement ifs and move the
value import into its natural-sorted internal position.
2026-09-27 12:59:34 -05:00
PRATHAMESH75
b6b3708db5 fix(desktop): fold personality names like the runtime so only resolvable rows are offered
Both dropdown readers (personalityOptions, personalityNamesFromConfig) listed
config keys verbatim via Object.keys, but the runtime folds every key
(available_personalities: str(name).strip().lower(), skipping the neutral
spellings none/default/neutral). So a root/agent case clash (Catgirl vs catgirl),
a whitespace-padded name, or a neutral spelling surfaced a row the runtime never
resolves — the user could pick it and get a different (or no) definition than the
one shown, with the widened root-block read (#123297) making mixed case the likely
input.

Add a shared foldPersonalityName + NEUTRAL_PERSONALITY_NAMES in @/lib/personalities
mirroring hermes_cli/personality.py, fold+skip in both readers, and route
normalizePersonalityValue through it (which also folds the 'neutral' spelling it
previously missed). Reuse the existing isPlainObject guard in helpers. Pin the
behavior with case-variant + whitespace + neutral-name cases in both test files.

Reported by @Enough1122.
2026-09-27 12:59:34 -05:00
PRATHAMESH75
afb924d841 test(desktop): cover personalityOptions' root read and pin GUI ordering
Address review on #123378:
- Add enumOptionsFor('display.personality', …) tests with a root-level
  `personalities` block, deriving expected built-ins from
  BUILTIN_PERSONALITIES (change-detector rule). Reverting the new root
  read now fails these, closing the coverage gap the reviewer flagged.
- Replace the Set-based clash assertion in personalityNamesFromConfig
  with direct array equality so it pins membership, dedupe, and the
  root-before-agent ordering the CLI listing uses.
2026-09-27 12:59:34 -05:00
PRATHAMESH75
9992d70f0d fix(desktop): list root-level personalities in the Settings dropdown (#123297)
The Desktop Settings → Chat → Personality dropdown and the config-driven
personality name list read only `agent.personalities`, so a persona
registered under the root-level `personalities` block — which the Python
runtime, CLI `/personality`, and gateway all honour via
`available_personalities()` — never appeared in the GUI and could not be
selected.

Read both blocks in `personalityOptions` (settings/helpers.ts) and
`personalityNamesFromConfig` (lib/chat-runtime.ts), mirroring the runtime's
merge order (root `personalities`, then `agent.personalities`), so the two
surfaces list the same personas.
2026-09-27 12:59:34 -05:00
finn763
9fa23760bc fix(desktop): never report the 0.0.0 placeholder in About
The desktop package.json carries a 0.0.0 placeholder on main (real versions come from channel builds and the live backend), but two About paths repeated it verbatim: appVersionInfo fell back to packageVersion when no display version was baked, and the native panel was seeded - and could be refreshed - with an empty applicationVersion, which macOS renders as the bundle's 0.0.0. Skip placeholder values ('', 0.0.0, unknown) across every candidate, and map the native panel string through nativeAboutVersion so unknown becomes an explicit label (backend spelling git.<short>[.dirty]) instead of 0.0.0. The renderer keeps its ''-means-unavailable contract. Closes #124581.
2026-09-27 12:50:11 -05:00
Adolan
861893da2f fix(desktop): show provider retry and auto-recovery waits in the status row 2026-09-27 12:50:03 -05:00
Adolanium
d1167fdff7 fix(desktop): match custom:<key> providers in the settings and bot model pickers
model.info and saved profiles report a user-defined provider as custom:<key>, but the catalog row uses the bare key as its slug. Settings > Model and the Bot Mode picker compared the two with ===, so a saved custom provider never found its row. Settings showed a duplicate custom:<key> entry and a Set up provider button, and the bot editor fell back to the manual form.

Both now match rows with catalogProviderMatches, like the composer picker already does. Settings uses a small findCatalogProvider helper for every row lookup, including the aux and MoA slots and the endpoint passed on Set to main. catalogProviderMatches is now exported through the plugin SDK so the bot picker can use it.
2026-09-27 12:49:56 -05:00
Adolan
0559c367ef fix(desktop): offer New cron from the empty scheduled-jobs detail 2026-09-27 12:49:39 -05:00
Adolan
6b512010ec fix(desktop): show one error card when the agent fails to start 2026-09-27 12:49:32 -05:00
Adolan
a65641b2e6 fix(desktop): let messaging credential inputs fill their row 2026-09-27 12:49:26 -05:00
Adolan
42592179ed fix(desktop): confirm when the main model is applied 2026-09-27 12:49:18 -05:00
funky-xamarin
52276088c9 fix(desktop): expire unknown readiness notices and clear on recovery 2026-09-27 12:49:09 -05:00
Adolan
4c3da4fbe5 fix(desktop): stop calling unreachable providers a timed-out reply 2026-09-27 12:49:02 -05:00
funky-xamarin
fd2bf1c15d fix(desktop): measure rail after locale direction is applied 2026-09-27 12:48:54 -05:00
funky-xamarin
44a0fe0359 fix(desktop): respect RTL profile rail edges and wheel direction 2026-09-27 12:48:54 -05:00
funky-xamarin
24bbdab4c7 fix(desktop): reveal clipped profile rail edges and pin actions 2026-09-27 12:48:54 -05:00
Adolan
a88dce6e2d fix(desktop): label the messaging platform enable switch 2026-09-27 12:48:49 -05:00
Cursor Agent
c3b24b74ba test(desktop): pin backslash math and the CJK-variable tradeoff
A span whose body is a backslash command is real math, and a CJK
variable inside one equation must still escape while the next span stays.

Co-authored-by: Yun. <fangyun1008@gmail.com>
2026-09-27 12:48:18 -05:00
Cursor Agent
f77bf679cb fix(desktop): keep inline math spans when CJK prose follows them
escapeCjkProseDollars treated a real closing dollar as the next opener,
so CJK text between two formulas escaped the first equation's closer.

Co-authored-by: Yun. <fangyun1008@gmail.com>
2026-09-27 12:48:18 -05:00
chelsealong
b9e2ddde05 fix(desktop): read inline preview color-scheme from the same source as its tokens
InlineHtmlFrame derived colorScheme from useIsDark(), which reads the
.dark class through React state that only updates a render after
applyTheme() has already mutated the DOM (use-theme-epoch.ts's own
comment documents this ordering: "a child's effect runs before the
provider's applyTheme"). collectThemeBridge() reads the CSS token
values live via getComputedStyle() on every render, so a render caught
between the DOM mutation and the epoch-triggered re-render built a
frame with fresh dark tokens but a stale light color-scheme -- a
transparent iframe with color-scheme:light still paints its canvas
white, so the widget showed near-white text on a white canvas (#123048).

Move the color-scheme read into collectThemeBridge() itself, off
document.documentElement.dataset.hermesMode (the same attribute
applyTheme() sets the token values from, and the pattern
lib/selection-copy-colors.ts's renderedMode() already uses for the
same reason), so the scheme and the tokens it decorates always come
from one synchronous read.
2026-09-27 12:48:11 -05:00
kshitijk4poor
6f7a7991bb test(email): pin both spf clause orders and name the auth-results test for what it covers 2026-09-27 20:49:08 +05:30
kshitijk4poor
3f4533b9de fix(email): read spf/dkim verdicts from their own Authentication-Results clause
The r3 fold scoped only the dmarc verdict to its clause. SPF and DKIM
still came from a whole-string, last-match-wins regex scan, so the same
quoted/comment smuggle closed for dmarc still authenticated a spoofed
From (GHSA-rxqh-5572-8m77), e.g.
  spf=fail smtp.mailfrom="x spf=pass smtp.mailfrom=example.com "@evil.test
  spf=fail (spf=pass) smtp.mailfrom=a@example.com
  spf=fail smtp.mailfrom=a.spf=pass@example.com
  dkim=pass header.d=evil.test header.i="x header.d=example.com y"@evil.test

Every verdict now comes from the leading method=result token of its
own clause (from _ar_clauses, comments dropped), and its domains only
from that clause. Properties are read by a token scanner that consumes
quoted-strings and other key=value tokens whole, so quoted contents are
never read as properties while a quoted value still is
(header.from="example.com"). SPF fails closed on more than one spf
clause; DKIM accepts any single dkim=pass clause whose own header.d
aligns (multi-signature mail is normal), never mixing clauses. The
whole-string methods/props (methods["dmarc"] was dead) are gone.

Also: a stray ')' at depth 0 is now unbalanced (it split header.from
out of the dmarc clause); a From with more than 64 '(' takes the silent
empty-sender drop instead of a parseaddr RecursionError logged as an
error; the cap test asserts the cap directly instead of wall-clock
timing; the empty-From drop assertion moves next to the other
_extract_email_address rejects; and the untested >1-dmarc, unbalanced
and backslash-escape rules get reject strings.
2026-09-27 20:49:08 +05:30
kshitijk4poor
517a97cd31 fix(email): cap From length, quote/comment-aware dmarc clauses, keep commented names
Round-3 review of the #124322 salvage:

- stdlib parseaddr is pure Python and superlinear on hostile input: a
  100KB `From: <a@a@...` held the GIL ~1s per message (main ~0), and any
  remote sender reaches it before auth. Refuse values over _MAX_FROM_LEN
  (2048; RFC 5322 lines cap at 998) right after unfolding so they take the
  existing empty-sender drop. The timing assertion now times
  _extract_email_address itself instead of a private regex.

- The dmarc clause split ignored quoted-strings and stripped comments one
  level only, so an attacker-controlled SPF-passing envelope sender like
  smtp.mailfrom="x;dmarc=pass header.from=example.com x"@evil.test planted
  a fake dmarc=pass ahead of the real dmarc=fail and authenticated
  admin@example.com. Split clauses with a quote- and nested-comment-aware
  scanner (comments dropped, quoted values kept so header.from="x" is still
  read and unquoted), and fail closed on an unbalanced value or when more
  than one clause starts with dmarc=. Tests pin the smuggled clause,
  reason="a;b", the nested comment, a quoted aligned header.from, and that
  every header.from in the dmarc clause must align (all->any goes red).

- `Doe, John (CEO) <j@x>` was dropped because the fallback refused any
  paren. Strip (comments) from the display part first; `attacker@evil.test
  (c) <victim>` stays rejected by the '@' rule.

- Results without '@' (`John` -> `john`, `a\"b <v@x>` -> `a\`) were
  dispatched as sender ids; return "" so they take the drop path.
2026-09-27 20:49:08 +05:30
kshitijk4poor
b821f8637b fix(email): linear, mailbox-safe From fallback; one-clause dmarc verdict
The r1 malformed-From fallback regex ([^<>\s]+@[^<>\s]+) backtracked
catastrophically on `From: <a@a@a@...`: 32KB held the GIL ~23s, and any
remote sender reaches it before auth. Capture <([^<>\s]+)> instead and
check the '@' in Python (same accepted set, 100KB in ~3ms).

The fallback also mapped multi-mailbox / group / comment-prefixed From
values (`attacker@evil.test, <victim@x>`, `Grp: a@evil; <victim@x>`,
`attacker@evil.test (c) <victim@x>`) to the bracketed victim, which a
dmarc=pass without header.from then authenticated. Only fall back when the
display part has no ; : ( ) and no '@' unless it is exactly the bracketed
address; `Doe, John <j@x>` and `j@x <j@x>` still resolve. The rule now lives
only in the docstring (the old inline comment was wrong).

dmarc: strip (comments) before splitting clauses, and take the verdict and
header.from from the same first clause that starts with dmarc=, so
`dmarc=pass (p=none; sp=none) header.from=evil.test`, a later dmarc=pass
clause after dmarc=fail, and duplicate misaligned header.from are rejected,
while `arc=pass (dmarc=fail ...); dmarc=pass header.from=<ours>` passes.
Property clean-up is shared via _auth_props.

The empty-sender drop now runs right after address parsing and gets an
assertion (it was untested). Tests extend existing ones (no new tests).
2026-09-27 20:49:08 +05:30
kshitijk4poor
2d44f5512b fix(email): keep malformed-but-real From values, drop empty senders, scope dmarc header.from
Strict parseaddr returns '' for common RFC-invalid From values that the
old regex resolved: an unquoted address as display name
(john@example.com <john@example.com>), an unquoted comma (Doe, John
<j@example.com>), or a@x.test <b@y.test>. Allowlisted senders using such
clients were silently dropped, and under open access every one of them
shared an empty chat_id. Fall back to the bracketed address only for the
unambiguous shape (no quotes, exactly one <...> pair), so the quoted
display-name spoof still resolves to the attacker. _parse_fetched_message
now drops messages whose From yields no address instead of dispatching
an empty identity.

The dmarc header.from alignment check read header.from from props merged
across all clauses, so a later dkim clause's header.from could override
the dmarc clause's value. Read it from the dmarc clause only, and pin the
misaligned dmarc=pass rejection in the existing dmarc test (previously
no test covered it). Also drop a redundant _domain_of ternary, trim the
_extract_email_address docstring and a duplicate test assertion.
2026-09-27 20:49:08 +05:30
kshitijk4poor
54b0f5db25 fix(email): only trust dmarc=pass when header.from matches the parsed From
_verify_sender_authentication accepted any dmarc=pass verdict, even one
issued for a different domain than the From we parsed. That is what let
the quoted-display-name spoof ride on the attacker's own truthful DMARC
pass. When the trusted Authentication-Results names header.from, require
it to align with the From domain; otherwise fall through to the aligned
SPF/DKIM checks. Defense in depth for the #124322 parser fix.
2026-09-27 20:49:08 +05:30
kshitijk4poor
4556de4128 test(email): keep two From-parsing invariants from the #124322 salvage
The salvaged PR added seven overlapping tests. Keep the two invariants:
the quoted-display-name spoof resolves to the real addr-spec (red on
base), and a folded display name plus the ordinary forms (Name <addr>,
bare, mixed case) still resolve to the same mailbox. The E2E
parse->authenticate->dispatch tests only re-assert the parser result.
2026-09-27 20:49:08 +05:30
KeelTrace
d0c588972e fix(email): parse the From mailbox with the stdlib, not a first angle-bracket match
_extract_email_address took the FIRST <...> pair, so
From: "Victim <victim@example.com>" <attacker@evil.test> resolved to the
victim. The attacker's own domain passes DMARC truthfully, so the
allowlist (EMAIL_ALLOWED_USERS), pairing and session identity were all
evaluated against an address the sender does not control.

Use email.utils.parseaddr, which keeps the quoted text as the display
name and returns the real addr-spec. RFC 5322 folding is unfolded first
so a folded quoted display name is not mistaken for the mailbox.

Salvages #124322.
2026-09-27 20:49:08 +05:30
kshitijk4poor
230f89b47b fix(sessions): share the write-guard filter and trim repeated walks
prune_sessions hand-rolled the same "guarded by a live lease/lock"
comprehension as the new _guarded_ids helper, so the two could drift on
the next guard change. Move _guarded_ids next to _write_guards_reject in
the maintenance mixin and have prune call it.

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

The CLI export --delete message repeated "session 'X'" because the
exception text already names the session.
2026-09-27 20:49:04 +05:30
kshitijk4poor
e78e7ccdeb fix(web): translate the skipped-active toast and show one toast
selectedSessionsSkippedActive is required in Translations, but only en.ts
had it. Every locale typed `: Translations` failed tsc -b with TS2741, which
breaks scripts/build/web.mjs. Add a translation next to
selectedSessionsDeleted in each of them (ar.ts goes through defineLocale and
falls back to English).

useToast holds one toast and only supports success|error, so the skipped
toast replaced the success toast in the same tick and the deleted count was
lost. Show a single toast instead: success with the count when nothing was
skipped, otherwise one error toast that carries both counts. Also drop the
duplicated comment.
2026-09-27 20:49:04 +05:30
kshitijk4poor
d1849547e9 fix(tui): import SessionActiveWriteGuardError inside session.delete
session.delete's body is rebound onto tui_gateway/server.py's globals by
bind_module, so the module-level import in methods_session.py was never
visible to it. Any exception in the try block then raised NameError while
evaluating the except clause: a live-turn delete crashed instead of
returning 4023, and a plain DB error that used to map to 5036 crashed too.
Import it in the handler body, the same way the module's other rebound
handlers pull in their dependencies.

Also update the test stubs whose delete_session signature predates the
exclude_active_write_guards kwarg, and the bulk-delete endpoint test that
now receives skipped_active: [].
2026-09-27 20:49:04 +05:30
kshitijk4poor
173770144f fix(sessions): tell the user when a delete is refused for a live turn
The browse picker swallowed SessionActiveWriteGuardError into a generic
"Delete failed.", and the dashboard bulk delete only reported the deleted
count, silently keeping rows a live turn owns. Surface both: the picker
flashes that the session is active, and SessionsPage shows a toast with the
skipped_active count (new en key; other locales fall back to English via
defineLocale). Also move the api_server import into its sorted slot.
2026-09-27 20:49:04 +05:30
kshitijk4poor
1a6a9b66b9 fix(sessions): guard delegate children that a delete would cascade
delete_session/delete_sessions cascade-delete delegate children, but the
write-guard check only looked at the root. A guarded delegate child could be
removed out from under its live turn, and in bulk delete an active id that
was also another selected root's delegate child was reported in
skipped_active while the cascade deleted it anyway.

Check {root, *delegate children} via a small _guarded_ids helper: single
delete refuses, bulk delete skips the root, so the cascade never touches a
guarded row. Ports the delegate-protection idea from #124496.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 20:49:04 +05:30
kshitijk4poor
adacbcc5fc fix(sessions): let guarded delete remove idle compression-ended rows
The new entry-side guard in delete_session/delete_sessions called
_write_guards_reject without allow_closed_compression_parent=True, so
_check_transcript_write_guards raised CompressionSessionClosedError for any
row with end_reason='compression'. That type is not caught by
_write_guards_reject, so every user-facing delete of a compressed parent
500'd and a bulk delete containing one rolled back the whole batch.

Pass the flag at both sites, matching prune (hermes_state_maintenance.py).
The lease is keyed on the lineage root, so a live turn on the tip still
blocks deleting its ancestor. Test 1 gains a compression-ended case (red on
the pre-fold file).
2026-09-27 20:49:04 +05:30
kshitijk4poor
3402644764 test(web): expect skipped_active in bulk-delete response
The endpoint now reports rows refused for a live turn; update the
event-loop test stub expectation to the new response shape.
2026-09-27 20:49:04 +05:30
kshitijk4poor
907e3188da fix(sessions): report bulk-delete rows skipped for a live turn
delete_sessions(exclude_active_write_guards=True) dropped guarded rows
silently: the web bulk-delete endpoint returned only a count and the
dashboard removed every selected row optimistically, so refused rows
reappeared on the next reload with no explanation.

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

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

(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
2026-09-27 20:49:04 +05:30
kshitijk4poor
a7f146fd15 chore: map shali10 for salvage of #123725 2026-09-27 20:49:04 +05:30
kshitijk4poor
eb4c8efe60 test(simplex): fail loudly if the warned-set name drifts 2026-09-27 20:47:41 +05:30
kshitijk4poor
f9771a445d fix(simplex): warn about name allowlist entries once per process, as authz reads them
Both reconnect paths (gateway/run_adapters.py watcher and multiplex
secondary) build a FRESH SimplexAdapter before connect(is_reconnect=True),
so the per-instance _allowlist_warned flag never suppressed anything: a
daemon-down cold boot re-logged the warning on every backoff retry. Gating
on `not is_reconnect` would instead lose the warning when the first connect
fails. Dedup at module level keyed on (hermes_home_key(), frozenset(names)),
still checked before the connectivity probe. No shared warn-once helper
exists (plugin_compat.warn_once is compat-specific).

Read the value with platform_gate_env (the reader authz uses; differs from
get_scoped_secret when a scope is installed with multiplex off) and decode
JSON list literals with decode_json_list_literal like _coerce_allow_set, so
'["4","9"]' written by `hermes config set` no longer warns that valid IDs
are ignored.

The caplog test now builds two fresh adapters (first connect fails, second
succeeds) and asserts exactly one warning naming only 'alice'; it fails with
2 warnings against the pre-fold adapter.
2026-09-27 20:47:41 +05:30
kshitijk4poor
d00fb8b20b test(simplex): parametrize the contactId-vs-display-name authz test
The two SimpleX allowlist tests differed only in the allowlist value and
expected verdict; one parametrized test keeps both invariants and holds
the stack at two tests after the connect()-warning test was added.
2026-09-27 20:47:41 +05:30
kshitijk4poor
3a47e65ef3 fix(simplex): read allowlist name-warning profile-scoped, before the probe
The name-entry warning in connect() read SIMPLEX_ALLOWED_USERS via raw
os.getenv, while authz reads it profile-scoped. Under multiplexing a
secondary profile would warn about (or stay silent on) the default
profile's list rather than the one actually enforced. Use the module's
_get_scoped_secret + _parse_comma_list like __init__ does.

It also only fired on a successful non-reconnect connect: if the daemon
was down at cold boot the first connect() failed and every retry came in
with is_reconnect=True, so the warning never appeared. Evaluate it before
the connectivity probe, once per adapter via an instance flag.

Test: two connects (first fails) -> exactly one warning naming only
'alice' for scoped '4, alice' while os.environ holds 'bob'. Red on the
pre-fold adapter (0 warnings) and on a raw-os.getenv variant (names bob).
2026-09-27 20:47:41 +05:30