Commit Graph

49679 Commits

Author SHA1 Message Date
Austin Pickett
950ea4d51e test(desktop): shellSshDouble tolerates a child that exits before reading stdin
The spawn fixtures run commands that never read stdin, so on a fast Linux
runner the shell can exit before shellExec's stdin write lands and the
write fails with EPIPE. The stream had no error listener, so vitest
reported it as an unhandled error and failed the electron project even
though every test passed. The close verdict is the result; the EPIPE is
noise.
2026-10-06 14:07:07 -04:00
xxxigm
db6a9c65e6 test(desktop): prove the relaunch gate survives a transport that refuses big commands
A double that rejects any command over 4 KB - the shape of an OpenSSH mux
socket that cannot pass descriptors behind a large session request - must still
get a verdict, with the judge on stdin and out of argv. Red on base, where the
inlined program makes the gate raise "Could not prove that the remote Hermes
install is clear for SSH startup."

shellSshDouble forwards stdinData the way the real mux exec does, so the live
spawn and v2-marker fixtures now run `python3 -` against a real python3 and keep
proving the argument positions, lock behaviour and verdicts unchanged.
2026-10-06 14:07:07 -04:00
xxxigm
0630e548eb fix(desktop): ship the SSH relaunch marker gate on stdin, not in the ssh command
assertRemoteInstallUpdateClear inlined the ~7.5 KB marker gate program with
`python3 -c`, making the ssh command ~8.1 KB. ssh.exec rides the POSIX
ControlMaster mux socket, where ssh writes the session request and only then
hands our stdio descriptors over with sendmsg(SCM_RIGHTS); a request that still
fills the socket buffer - 8 KB for a unix-stream socket on macOS - leaves no
room for that descriptor message, so the pass fails with EMSGSIZE:

    mm_send_fd: sendmsg(0): Message too long
    mux_client_request_session: send fds failed

ssh exits 255 and the fail-closed gate reads it as "Could not prove that the
remote Hermes install is clear for SSH startup." on every attempt, so no remote
host connects while nothing is wrong on the remote. The window depends on the
client's socket buffer and the command's exact length, which is why a shorter
probe to the same host can still answer.

Hand the program to `python3 -` on stdin instead. Python compiles the whole
program before running it and the gate never reads stdin, so argv keeps the same
`<marker> [payload] [hermes...]` positions while the command stays a few dozen
bytes however the judge grows.
2026-10-06 14:07:07 -04:00
Teknium
945ffcd257 plugin-catalog: add tirith (opt-in pre-exec scanning, moved out of core)
Pins NousResearch/hermes-plugin-tirith@77405987: a pre_tool_call hook that runs
`tirith check --offline` on terminal commands and escalates findings to the normal
approval gate. Uses a tirith binary the user installs; nothing is downloaded.
Validated at the pin with `hermes plugins validate --install-deps` (scan: safe).
2026-10-06 11:00:51 -07:00
1052326311
c8ef24f8fe fix(review): preserve effective Git commit identity 2026-10-06 12:59:08 -05:00
kshitijk4poor
4ca6f9e91c fix(free-tier): a failed stdio announce still shows the challenge link
The stdio JSON-RPC transport deliberately re-raises host I/O errors
(ENOSPC, a locale that cannot encode the server's copy). _announce now
catches (OSError, ValueError) and returns False so present() falls back
to the terminal instead of losing the link and failing the run.

Also pins the ui-tui event -> presenter routing with a dispatch-level
test, and names the billing presenter factory like its sibling
(createBillingVerificationPresenter).
2026-10-06 23:14:10 +05:30
kshitijk4poor
69a12d2203 refactor(desktop,tui): free-tier challenge wiring lives in topical modules, not the facades
- desktop main.ts: the challenge IPC handler moves to
  electron/challenge-window-ipc.ts (registerChallengeWindowIpc), and
  resolvePortalBaseUrl moves next to the portal session that already
  consumed it (portal-session.ts), so the facade ends below its main
  line count; imports are sorted.
- desktop renderer: free_tier.challenge gets its own family handler
  (gateway-event/free-tier.ts) in the dispatcher table instead of a
  branch in handleLifecycleEvent.
- ui-tui: the challenge presenter and the existing billing step-up link
  presenter move to app/gatewayBrowserLinks.ts, keeping the event
  switch at or below its main size; the presenter test moves to its own
  file.

No behaviour change.
2026-10-06 23:14:10 +05:30
kshitijk4poor
9cb52daec2 refactor(free-tier): keep auth.py and anon_challenge.py inside the code-health ratchet
- _nous_portal_base_url moves from the auth.py facade to auth_nous.py
  (its sibling _nous_portal_env_override already lives there) and is
  re-exported with the rest of the Nous names, offsetting the
  run_with_challenge seam: auth.py ends below its main value.
- _announce no longer wraps the gateway broadcast in a blind
  `except Exception`: _broadcast_global_event already contains a wedged
  peer per transport, so anything else is a bug worth surfacing.
- _present_in_terminal catches what webbrowser.open can raise
  (webbrowser.Error, OSError) instead of Exception.

No behaviour change.
2026-10-06 23:14:10 +05:30
kshitijk4poor
8c0568ee73 test(free-tier): drop change-detector challenge tests; keep the invariants
Removed: the desktop/gateway surface-string tests (the gateway's
behaviour is pinned by "never waits", the desktop's by the inherited-flag
invariant), the full-payload-dict snapshot of a status read (the RPC test
covers the field), the server-copy whitespace/length detector, and the
TS "passes optional through as not-required" pass-through check.

Kept: wait outside the locks, background/gateway callers never block,
off-origin URLs refused, no loop on a second 428, the final mint is
authoritative, blips vs verdicts, PoW verdicts unchanged off the token
exchange, control characters never reach a terminal.

Also: FreeTierChallengePayload sorted into events.__all__.
2026-10-06 23:14:10 +05:30
kshitijk4poor
d0461ba6c3 refactor(desktop): free-tier challenge reuses the window-open policy and the contract's outcome type
- challenge-window.ts uses createWindowOpenHandler (deny + sanitised
  origin log, the link-title window's own hardening) instead of an
  inline deny.
- The renderer's outcome type is the generated contract's
  FreeTierChallengeResultParams['outcome']; global.d.ts uses the main
  process's ChallengeOutcome instead of a third hand-copied union, so a
  drift between the window and the contract fails the renderer typecheck.
- runFreeTierChallenge takes the gateway requester itself and builds the
  free_tier.challenge_result call, so lifecycle.ts and free-tier.ts stop
  each hand-rolling a reporter closure.

No behaviour change.
2026-10-06 23:14:10 +05:30
kshitijk4poor
d1f587331e fix(desktop): a required challenge ask never joins or inherits an optional run
The main process de-duplicated challenge windows by URL alone, and
remembered settled outcomes by url:attempt. If an optional (measuring,
never-revealed) run of a ticket was in flight, or had just ended
`failed` on #interactive, and the same ticket turned required, the
required ask got the optional run or its remembered `failed`, so no
window was ever shown for the human to clear. Key both the in-flight
run and the settled memory on URL + required.
2026-10-06 23:14:10 +05:30
kshitijk4poor
fa6ef8ba6d refactor(free-tier): one per-profile challenge record behind one lock
_pending (dict-of-dicts keyed "payload"/"deadline"/"outcome"),
_work_locks and _gave_up_until were three per-profile module globals,
two of them mutated outside the lock. They become one _ProfileChallenge
dataclass in a single _profiles map behind _state_lock; the record holds
the BrowserChallenge itself, so lookups are attribute reads and
as_payload() runs only when a status read asks for it. _set_pending
becomes _record(challenge, new_attempt=...). The test-only _presenter
seam goes: tests patch present() itself. _sleep stays, same idiom as
free_tier_bootstrap.

One edge differs, for the better: an EXPIRED same-URL record is now
treated as a new ticket (fresh deadline) instead of re-recorded with
min(new, old-expired) deadline, i.e. born expired.
2026-10-06 23:14:10 +05:30
kshitijk4poor
50b0082ed0 refactor(free-tier): build AnonChallengeRequired once; endpoint overrides table; codes live in anon_auth
- AnonChallengeRequired takes portal/credential/auth_state in its
  constructor. exchange_anon_jwt (given auth_state by its one caller,
  refresh_guest_state) builds it; the two catch-mutate-reraise blocks and
  the empty/None defaults are gone.
- _raise_for_anon_status no longer reads `portal_base_url=""` as an
  "is token exchange" flag. The exchange passes its verdicts as an
  `overrides` table keyed like _NAS_REFUSALS; 428 pow_* routes to an
  explicit proof-of-work verdict ahead of the 428 sign-in catch-all.
- ANON_CHALLENGE_REQUIRED / ANON_SIGNIN_REQUIRED and their copy are
  defined next to their siblings in anon_auth; anon_challenge imports
  them (and _anon_headers/_mint_memo_key) at module level, anon_auth
  imports anon_challenge lazily.
- client_info/user_agent read get_version_info().base_version instead
  of the old-updater __version__ shim.
- test: the stdio-TUI-gateway presentation test patches the real
  tui_gateway.server attributes; a SimpleNamespace stand-in broke the
  conftest server teardown.

No behaviour change.
2026-10-06 23:14:10 +05:30
kshitijk4poor
1eec4cf274 fix(free-tier): put the challenge link in front of ui-tui users
The TUI gateway child reports surface `cli`, so a 428 printed the link
to the child's stderr, which ui-tui keeps as a /logs tail and never
shows (createGatewayEventHandler: gateway.stderr). Over SSH or headless
that was a silent 120 s wait followed by "finish the check in your
browser" with no link. For a required challenge a foreground caller is
waiting on, the stdio TUI gateway now announces it on its JSON-RPC
channel, and ui-tui renders the message + link and opens one tab per
ticket. Background reads (keepalive, status paint) still never pop a
browser, and optional checks stay the desktop's to run hidden.
2026-10-06 23:14:10 +05:30
kshitijk4poor
4a370f1c76 fix(free-tier): only the desktop-spawned backend reports the desktop surface
client_surface() trusted raw HERMES_DESKTOP=1, which every terminal-pane
shell and agent child inherits (#116107 class). A `hermes` run in the
desktop terminal pane told NAS `surface: desktop` and suppressed its own
"finish the check in your browser" line. Use
process_identity.is_desktop_owned_backend(), which also needs the
per-spawn credential.
2026-10-06 23:14:10 +05:30
kshitijk4poor
e955022eb1 fix(proxy): never wait on a free-tier challenge under the Nous adapter lock
NousPortalAdapter._get_credential resolves credentials while holding
self._lock, which every proxied request queues on. With the challenge
gate on, a 428 parked that lock (and a default-executor worker per
queued request) for up to 120 s. Resolve as a background caller: the
challenge is recorded/announced and the retryable error returns at once.
2026-10-06 23:14:10 +05:30
kshitijk4poor
1507c8d0f7 fix(free-tier): a messaging gateway never waits on a browser challenge
The gateway resolves Nous credentials from async code with sync calls
(run_inbound._inbound_model_context_length -> runtime resolve, and
run_startup -> self_provision_relay -> resolve_nous_access_token). A 428
there parked the event loop for up to 120 s, and nobody sits at a
gateway's console to clear the check anyway. Treat the gateway surface
like a background caller: record/announce the challenge, raise the
retryable error at once.
2026-10-06 23:14:10 +05:30
kshitijk4poor
6dae8d059a fix(free-tier): keep the proof-of-work verdict for pow_* off a 428
The challenge commit dropped the `error == "pow_"` fallback in
_raise_for_anon_status. A pow_* error on a status with no wildcard row
(400/403/5xx) then became a retryable anon_server_error instead of the
terminal anon_pow_required, on every endpoint including sign-up and the
promotion paths the PR said were unchanged. Restore the fallback (kept on
purpose in 2a94ca80e7) and pin it on sign-up.
2026-10-06 23:14:10 +05:30
Robin Fernandes
90ff655c81 feat(free-tier): browser challenge primitive for the anonymous token exchange
NAS may answer POST /api/anonymous/token with 428 challenge_required and a
portal URL. Hermes implements the one `browser-v1` primitive: open the URL
(hidden where it can), poll /api/anonymous/challenge/status, exchange again.
Everything the page does (BotID, in-browser proof of work, an interactive
Turnstile fallback, its copy) belongs to the portal and changes without a
Hermes release. The mint stays authoritative throughout.

Python (hermes_cli/anon_challenge.py)
- the exchange reports surface (desktop / cli / gateway), version and the
  browser-v1 capability; a UA of the documented shape goes on every call
- the wait runs outside the auth-store and shared-store locks, under a
  per-profile work lock; only a caller someone is waiting on waits — the
  keepalive tick and a status paint announce the challenge and return
- 403 signin_required / access_denied and any 428 this version has no
  primitive for become a terminal sign-in error, scoped to the token
  exchange; sign-up and the promotion endpoints keep their previous verdicts
- status-poll 429 / 5xx / transport are blips; a 4xx is a verdict (the
  credential is gone); a second 428 starts a fail-fast cooldown so a boot
  burst of token readers does not each re-mint and reopen a window
- the pending challenge is published as the free_tier.challenge event and
  on free_tier.status for a client that connects later; the desktop reports
  how each window ended (free_tier.challenge_result) so the waiter re-mints
  promptly, and an attempt counter lets a fresh foreground attempt reopen a
  window the user closed without a status replay reopening it
- terminal: print the link, open a graphical browser once per ticket; a
  gateway process never opens one; control characters never reach the tty
- challenge URLs are only ever opened on the portal origin the exchange hit

Desktop (apps/desktop/electron/challenge-window.ts)
- the page loads in a hidden, sandboxed window of its own partition, pinned
  to the portal origin (will-navigate, will-redirect, off-origin commits end
  the run); a phase is read only from a main-frame portal URL fragment
- #interactive reveals the window (never for an optional challenge), #done
  and #failed close it; an HTTP error commit, a load failure and a crashed
  renderer are retryable, a closed window is not; timers are bounded
- the renderer relays the event and the status read to one window per
  attempt and reports the outcome once; a backend without the RPC is fine
2026-10-06 23:14:10 +05:30
Austin Pickett
80cf5183f8 chore: map contributor emails for #133991
Add contributors/emails/ mappings so check-attribution resolves the
author emails on this branch:
- GuerrillaMedia702@gmail.com -> AP3X-Dev
- zaclanzon@users.noreply.github.com -> zaclanzon
2026-10-06 13:23:15 -04:00
Austin Pickett
fd431bedbc test(dashboard): per-call model attribution regression for #71778 2026-10-06 13:23:15 -04:00
Zac Lanzon
0cc2bcdcee fix(dashboard): count distinct models over the merged row set
totals.distinct_models was COUNT(DISTINCT model) over sessions alone, so a
model reached only through auxiliary usage drew a card the header never
counted. Derive it from the same merged rows the cards come from (#89631).
2026-10-06 13:23:15 -04:00
AP3X
2c48f5ff6c fix(dashboard): fold aux usage into the model card it belongs to
/api/analytics/models appended one row per (model, task, provider) from
_aux_usage_rows next to the sessions-derived (model, provider) row, so a
model doing approval/compression/title/background_review work rendered as
four or five identical-looking cards, and their session counts summed past
totals.total_sessions.

Fold aux usage into the existing pair's row; only an aux-only pair (no
sessions-derived row) becomes a new row carrying its own session count,
so dedicated aux models stay visible (#23270).

Ported from #89707 (web_server.py -> web_routers/analytics.py after the
router decomposition). Same diagnosis as #77524.

Co-authored-by: Kumar Sarthak <kumarsarthak0712@gmail.com>
Co-authored-by: Zac Lanzon <zaclanzon@users.noreply.github.com>
2026-10-06 13:23:15 -04:00
Austin Pickett
d40794140b fix(dashboard): attribute models usage per API call from session_model_usage 2026-10-06 13:23:15 -04:00
unsupportedpastels
aac7ad880d fix(scratch): escape control characters in prune-log paths and names
Review of #133952 (ehz0ah) showed a legal POSIX name containing a newline was written across two lines of scratch-prune.log, so one record split and the tail of the name could pass for a record of its own. Paths and process names are now logged with %r, so control characters are written escaped and each record stays one line.

Refs #132401.
2026-10-06 13:22:50 -04:00
unsupportedpastels
788976407a fix(scratch): keep prune-log records for non-UTF-8 names and concurrent rollover
Review of #133952 (andrexibiza) reproduced two ways a deletion could leave no line in scratch-prune.log: a legal POSIX name that is not UTF-8 hit the strict utf-8 handler and the whole record was dropped, and when two prunes of one home rolled the log over at once, the second rename found the file gone and the record was dropped with the FileNotFoundError.

The handler now writes with errors=backslashreplace, and its rollover treats a source renamed away by a concurrent prune as already rotated and writes the record to the new file.

Refs #132401.
2026-10-06 13:22:50 -04:00
unsupportedpastels
67784d8a89 fix(scratch): record every pruned entry and reaped process in logs/scratch-prune.log
The idle prune deleted entries and TERM/KILLed processes with one aggregate INFO line, invisible at the default WARNING level and absent entirely at boot, before setup_logging() runs. It also counted an entry as removed even when rmtree (ignore_errors) left it behind.

Each removed entry (path, size), each signal sent (pid, name, cwd) and each entry that could not be removed now gets a line in <home>/logs/scratch-prune.log, through a RotatingFileHandler of its own (fixed 5 MB x 3, as tool_calls.log), so the boot-time prune is recorded too. The handler belongs to one prune and is closed when it ends: no process holds the file open between prunes (an open handle blocks rollover on Windows), and prunes of two homes never share it. Only entries that are really gone are counted. A process that exited but is not yet collected (zombie) is not sent KILL.

Refs #132401. Salvages #132455.

Co-authored-by: Justin Sharpe <2106314+justinsharpe@users.noreply.github.com>
2026-10-06 13:22:50 -04:00
Mark S.
970ba79fb4 Merge pull request #133975 from victor-kyriazakos/fix/own-patch-carries-write-baseline
fix(tools): a task's own patch keeps its full-file write baseline
2026-10-06 13:14:27 -04:00
Brooklyn Nicholson
cd94de7ea4 test(desktop): tool disclosure cache evicts on unmount
Regression test for #131121: after subscribe -> unsubscribe (a row's
mount -> unmount), a second call with the same id must return a fresh
atom — the entry was released instead of being retained for the
window's lifetime — while identity stays stable while subscribed.

Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-10-06 12:09:55 -05:00
Yuan Li
b8c844b99a fix(desktop): evict inactive tool disclosure atoms 2026-10-06 12:09:55 -05:00
Kuxin
0fa1fb600d fix(stt): normalize local command language hints 2026-10-06 12:08:04 -05:00
Kuxin
0d234e714c fix(stt): normalize local language hints 2026-10-06 12:08:04 -05:00
Austin Pickett
35f0b4b569 fix(dashboard): route native text paste through term.paste to stop last-char duplication
A native/DOM paste on the chat terminal (context-menu paste, middle-click,
or any Ctrl+V route that bypasses the keydown interception) fell through to
xterm's own paste listeners. xterm clears the hidden textarea during the
paste dispatch, but the browser's default action — inserting the pasted
text into that textarea — runs after dispatch, so a stale value survives
and the next typed character is duplicated (#52471: "when" → "whenn").

The host capture-phase paste handler now cancels the native paste for
plain text and delivers it via term.paste() exactly once — the same clean
route the Ctrl/Cmd+V keydown interception already uses. Image pastes keep
their upload path and are never routed as text.

Supersedes #52477 (credit: @liuhao1024 — same interception, extended with
the image route preserved and regression coverage).

Fixes #52471
2026-10-06 13:00:43 -04:00
Austin Pickett
7ed284fee0 fix(web): satisfy code-health ratchet and contributor attribution
- PtySession.close and _discard_active_session_file: waive the deliberate
  teardown catch-alls with reviewed '# health: allow' comments (BLE001+S110).
- pty_ws was over its main-pinned CC cap: extract the channel-marker prologue
  into _pty_channel_marker_state and the keep-alive writer loop into
  _pump_keepalive_input.
- Map sinh3543@gmail.com -> sinh2024 (check-attribution, PR #133936).
2026-10-06 12:46:06 -04:00
Austin Pickett
3178ef3e21 test(pty): the channel marker dict is empty after every PTY lifecycle end
Regression coverage for #63553: _active_session_file_for_channel
registered channel→tempfile entries that no code path popped, so
pty_active_session_files grew without bound and churned a forgotten
tempfile+inode per reconnect.

Proves the invariant across every session end/forget path:
- keep-alive close (close_all, same path as TTL reap / child EOF)
- repeated same-channel reconnects keep one mapping
- legacy 1:1 pump (no attach token) pops its marker on disconnect
- ?fresh=1 drops the stale breadcrumb yet keeps the mapping so the
  next same-channel reconnect resumes the fresh child (the regression
  flagged in the #63563 review)

All four are red on 85db7c3a68.
2026-10-06 12:46:06 -04:00
sinh2024
56227dd6fd fix(web): clean up PTY active session markers 2026-10-06 12:46:06 -04:00
Austin Pickett
ee2ed10ef5 fix(sessions): validate imported values before persistence
Session import used shallow type casts at the persistence boundary:
float() accepted inf/NaN (a JSON 1e309), int() accepted integers
SQLite cannot bind (outside signed 64-bit range), model_config was
only shape-checked after its intentional decode, and a literal string
carrying the internal \\x00json: content marker was decoded as
structured content on read. Bad values reached sqlite3 binding or
persisted as inf, breaking session APIs with HTTP 500.

- reject non-finite floats in session float/timestamp columns, message
  timestamps, and scalar message content, with the field path in the error
- re-check every intentionally-decoded JSON-backed field (model_config,
  reasoning_details, codex_* items) for non-finite values
- bound int columns, token_count, and int scalar content to SQLite's
  signed 64-bit range; coerce-with-default at the bind site as a backstop
- escape literal \\x00json:-prefixed strings via the JSON wrapper instead
  of storing them raw; decoding already fails closed on malformed marker rows
- all rejection stays inside the existing atomic validate-then-write
  400 contract; valid exports round-trip unchanged

Fixes #63954
2026-10-06 12:40:04 -04:00
Austin Pickett
3225f357a5 fix(dashboard): show broken links as non-actionable entries in Files page
Backend listings now emit broken_link: true placeholder entries with
nullable mtime for dangling symlinks. Update ManagedFileEntry to match,
render broken links with an Unlink icon and a destructive badge, disable
download for them, and keep delete enabled so users can remove the dead
link from the UI.
2026-10-06 12:30:01 -04:00
Austin Pickett
1ca192bc1b fix(dashboard): don't 500 file listings on dangling symlinks or vanished entries
The dashboard file browser's listing route built entries via a bare
comprehension over os.scandir, calling _managed_file_entry() per entry.
That helper raises HTTP 500 ("Could not stat path") on any OSError from
resolved.stat(), so a single dangling symlink (or an entry removed
between readdir and stat) aborted the whole directory listing with a 500
— e.g. a broken ~/.claude link or Steam's recreated linux32/steam made
the entire Files page unusable (#47154).

Whole-class fix, consolidated from the open contributor PRs:

- _managed_file_entry() classifies a symlink whose target is definitively
  missing (FileNotFoundError) before the resolved-target boundary check
  and returns a `broken_link: true` placeholder entry instead of 500ing.
  The placeholder is only emitted for entries inside the managed root
  even when the missing target resolves outside it. Permission and other
  OSErrors still surface as 403/500 as before.
- Symlink stat results are captured in one snapshot (no second stat), so
  a target disappearing mid-listing cannot flip a result into a 500.
- New skip_missing flag lets the listing scan skip an entry that
  vanished between readdir and stat (returns None); the write-result
  caller stays strict.
- The listing loop appends placeholders and skips vanished entries, so
  one dead entry never loses its siblings. The sibling fs_list route
  already tolerated ENOENT; /api/files now agrees.

Reading/downloading a broken link directly still returns 404; 403
boundary and sensitive-path behavior are unchanged.

Fixes #47154

Co-authored-by: gangpangpang <2564186295@qq.com>
Co-authored-by: ous fifty <ousfifty@gmail.com>

Incorporates work by @Lqg97 (PR 47159; co-contributors qinggangli and Shiqian Ma, commit emails not linked to GitHub accounts).
2026-10-06 12:30:01 -04:00
Austin Pickett
ca38caeea0 fix(dashboard+auth): cap HTTP JSON response body reads
Dashboard-side urllib readers (gateway health probe, ElevenLabs voices)
and self-hosted OIDC httpx readers (discovery, token exchange, revocation)
buffered remote response bodies with no size bound — a hostile or
compromised upstream could exhaust memory. Both halves now read with a
1 MiB hard cap: a shared _read_dashboard_json_response for urllib JSON
reads and a streaming _request_limited_response for IdP endpoints that
prechecks Content-Length and caps the chunked body regardless.

Adapted from #54809 and #55122 (credit @ooiuuii) to current main, where
the token exchange lives in plugins/dashboard_auth/_shared.py and the
health probe in hermes_cli/web_server_gateway.py.

Fixes #54808, Fixes #55121
2026-10-06 12:28:55 -04:00
Austin Pickett
e93446126d fix(dashboard): surface the messaging platform's auth posture in the Test result
The Channels 'Test' button stopped at connectivity: a platform whose gateway
authorization posture denies every sender (no allowlists, no allow-all flag, no
pairing grants) still answered '<Platform> is connected.' — a green checkmark
on a bot that ignores everyone (#66030).

When the adapter reports connected, the test result now carries the platform's
access posture for the ACTIVE profile, mirroring the decision ladder of
AuthorizationMixin._principal_authorized:

- open: {PLATFORM}_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS
- allowlist: {PLATFORM}_ALLOWED_USERS, GATEWAY_ALLOWED_USERS, the Telegram
  group allowlist vars, or a config-enforced dm_policy: allowlist
- pairing: approved pairing grants in the profile's pairing store
- deny_all: nothing configured — with pending pairing requests counted in
  the message

The check is profile-aware: under a named profile it reads that profile's
.env/config/pairing store, never the dashboard process's os.environ (which
carries the ROOT install's credentials). ok stays True (the connection is up);
the new auth_posture field lets the UI render yellow instead of green.

Reuses the idea from PR #66055 (authored by @webtecnica) with the evaluator
made profile-aware and extended to cover GATEWAY_ALLOWED_USERS, pairing grants
and adapter/config-enforced allowlists, per its review.

Fixes #66030
2026-10-06 12:28:06 -04:00
Austin Pickett
82f2dcb286 fix(gateway): let GET /api/model/options opt out of unconfigured skeleton rows
include_unconfigured was hardcoded True in _handle_model_options, so external
API consumers had no way to request the explicit-only view. Forward the
include_unconfigured query param (default true, back-compatible) to
build_model_options_payload. With _append_unconfigured_rows now honoring
model_catalog.excluded_providers, this endpoint no longer resurrects excluded
providers on any view (#68816).

Also lifts model_options decoding (_request_reasoning_config /
_request_service_tier) into gateway/platforms/api_server_request_options.py to
offset api_server.py's frozen line cap (moved code keeps its cap; the facade
re-exports both names).
2026-10-06 12:28:00 -04:00
PRATHAMESH75
93ca74c3bb fix(cli): honor excluded_providers in the TUI /model picker's unconfigured rows
build_models_payload(include_unconfigured=True) — the path the in-session TUI
/model command uses — appended CANONICAL_PROVIDERS skeleton rows via
_append_unconfigured_rows() without consulting model_catalog.excluded_providers.
So every excluded provider reappeared in the TUI picker even though hermes model
(list_authenticated_providers) hid it correctly.

Filter excluded providers in the skeleton loop, normalized the same way
list_authenticated_providers does ({str(p).strip().lower()}), and exclude
unconditionally — including the configured-current provider — to match that
path, which drops an excluded provider even when it is current.

Adds include_unconfigured-path coverage: excluded provider removed (others
intact), case-insensitive match, current-excluded-provider dropped, and
empty/None excluded is a no-op.

Fixes #68816
2026-10-06 12:28:00 -04:00
Austin Pickett
da303d1729 fix(dashboard): preserve CLI-configured custom endpoints on same-endpoint model/set
POST /api/model/set re-registered a named custom_providers row for any
custom/local main assignment, even when the endpoint was already a fact
in config.yaml (the CLI gateway setup writes provider: custom with an
inline base_url + api_mode). The picker then rewrote the CLI's shape into
custom:<slug> with base_url: '' (#76324).

Snapshot the pre-save config and skip registration when the endpoint is
already known (inline model.base_url, a providers: entry, or a
custom_providers row); only a genuinely new endpoint gets a named row.

Fixes #76324
2026-10-06 12:27:41 -04:00
Austin Pickett
5515c4be55 fix(dashboard): backfill /api/sessions/search with title matches
The search endpoint composed only search_sessions_by_id (id/lineage LIKE)
with search_messages (FTS5 over message content), so a term that lived
solely in a manually-set sessions.title returned zero results — renamed
sessions were invisible to search (#66242).

The DB layer already knew how to LIKE-match titles across the whole
compression chain: list_sessions_rich(search_query=...) via
_chain_search_where (the same helper the sidebar listing uses). Reuse it
rather than adding a second title query path: after the id and FTS lanes,
backfill remaining result slots through list_sessions_rich, feeding rows
through the same add_lineage_result dedup path so lineage, limits, and
result shape stay one contract (content hits keep BM25 priority; the
title lane is best-effort on top).

Fixes #66242
2026-10-06 12:27:29 -04:00
Austin Pickett
fbf261a33f fix(web): preserve repeat count when creating cron jobs from dashboard
CronJobCreate did not declare a repeat field, so Pydantic dropped it during
request parsing, and _create_cron_job_sync never forwarded one to
cron.jobs.create_job — a finite repeat count submitted via POST /api/cron/jobs
was silently stored as an unlimited recurring job.

- Add optional repeat field to CronJobCreate (int or CLI string form)
- Forward it from _create_cron_job_sync; non-positive counts 400, string
  forms coerce through the shared normalize_repeat_value the CLI uses
- Add repeat?: number to the desktop CronJobCreatePayload type
- Route-level regression coverage: finite survives create+list, omission
  stays unlimited, invalid counts 4xx with no job stored
2026-10-06 12:27:22 -04:00
Austin Pickett
0620913eac fix(dashboard): gate the chat WebGL renderer and fall back to DOM
The chat terminal loaded @xterm/addon-webgl for every wide host with
nothing but a constructor try/catch. Three failure classes escaped it:

- Safari's WebGL atlas garbles Unicode box-drawing glyphs (#18773)
- software rasterizers (llvmpipe/SwiftShader) advertise WebGL but regl
  throws '(regl) webgl not supported' from inside the addon (#45520)
- the per-glyph atlas cannot shape complex scripts, so Bengali
  conjuncts render broken (#58685)

Gate the addon behind a pre-flight probe (real context + non-software
unmasked renderer, released immediately) and a Safari UA check, both in
a new pure module; when shaped-script text arrives over the PTY, drop
the live addon so xterm re-renders through the DOM renderer, whose text
shaping handles conjuncts. Context loss already disposed the addon.
2026-10-06 12:26:53 -04:00
Austin Pickett
e30c6aecef fix(desktop): a session-picker click re-selects the active session
The picker door (openSessionFromPicker — /resume overlay, ⌘K, artifacts)
only ran openSession, whose in-place path treats the already-active
session as a front-only no-op. The sidebar's own door always queues an
explicit requestSessionResume, which use-route-resume honours via its
explicitlyRequested branch to re-run resumeSession even when the session
is already active. Give the picker the same explicit reselect so clicking
the active session re-attaches the transcript and re-focuses the composer
(#62045) instead of doing nothing.
2026-10-06 12:24:07 -04:00
JoaoMarcos44
70b8d815e6 test(desktop): pin caret rekey semantics 2026-10-06 11:19:00 -05:00
JoaoMarcos44
5beac8a5ae fix(desktop): preserve caret on same-draft scope rekey 2026-10-06 11:19:00 -05:00