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.
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.
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.
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).
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).
- 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.
- _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.
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__.
- 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.
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.
_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.
- 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.
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.
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.
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.
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.
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.
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
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).
/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>
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.
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.
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>
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>
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
- 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).
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.
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
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.
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).
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
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
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).
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
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
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
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
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.
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.