A header cut short raised struct.error, which no caller of
read_gguf_header catches (they skip a model on ValueError or OSError),
so one truncated file escaped the whole preset pass. A short read now
raises ValueError.
The status row sized a side-loaded split by its first file, which can
be a metadata stub of a few MB; it now sums every part on disk. The
split fixture writes model metadata into part 1 only, as gguf-split
does: a published Flash Next split carries 67 keys in part 1 and only
its 3 split.* keys in parts 2-3.
A split model was priced by its first shard alone, so the physics check
and the residency cap admitted giants the card cannot hold: on a 256 GB
box a 175 GB model priced as 6.7 GiB and the router booted with room for
three residents, two of which then co-resided and OOM'd every session.
Sum the tensor table, the host-side embedding duplicate and the per-block
FFN map across every part on disk; architecture metadata still comes from
the first part. The truncated FFN map also disabled spill placement,
which is the lever that would have relieved the pressure.
A part that cannot be parsed is priced out rather than fatal — refusing an
incomplete split to serve remains staged_in's job.
The lazy import in _normalize_provider_id is wrapped in a broad except that
silently returns value.lower(), disabling alias collapse with no signal. A
future import cycle/refactor could regress the same-backend dedup invisibly.
Now logs at WARNING (with exc_info) when the fallback path is taken, so the
degradation is visible.
`_entry_identity` compared the fallback entry's provider id as a raw,
lowercased string. A provider reachable under two ids — the canonical
models.dev id (`opencode`) and a legacy rename alias (`opencode-zen`) —
was therefore kept as two separate slots in the fallback chain. That shifts
the chain by one slot and lets an alias slip past the same-backend skip,
so a turn can hit the alias slot, recover from a re-seeded rate-limit pool
against it, and exhaust retries without ever reaching the real paid
fallback.
Normalize the provider id (lazy `normalize_provider` import to avoid
cycles) before computing the entry identity, so aliases collapse onto the
canonical id for dedup. The returned chain still carries each entry's
original provider id — only the identity used for dedup is normalized.
Fixes#85235
Inside a packaged desktop build, bare `hermes` in the terminal tool resolved
to <payload>/venv/Scripts/hermes.exe. That venv was built on CI, and its
Windows redirector reads pyvenv.cfg `home`, which still names the CI path,
so every call failed with "did not find executable at C:\a\hermes-agent\...".
The working launcher in <payload>/bin carries the channel name and was never
on the agent's PATH.
- activate_dependencies puts the payload's own launcher dir (from the
manifest's runtime.commands) first on PATH, and on Windows no longer adds
the shipped venv's Scripts dir: every redirector in it names the CI
interpreter.
- _resolve_hermes_bin_dir answers with that launcher dir in a sealed payload,
so terminal and cron children do not depend on PATH order.
- stageDesktopLaunchers copies the canonical launchers to their qualified
names instead of renaming them. Only the qualified names are published.
- The "plugin already exists" hint names the real CLI command.
- Every manifest-relative path (venv, store, hermes launcher) goes through one
resolve-and-contain check, and a manifest.json that is unreadable or not a
payload manifest reads as "not a payload" instead of raising, since every
install now asks.
Source and dev installs are unchanged: they have no payload manifest.
Offset the FILE_LINES growth the moa preset fixes add to hermes_cli/config.py
(4283 > its 4274 cap): the version-stamp trio (raw read of _config_version
plus check_config_version) moves verbatim into topical sibling
hermes_cli/config_version_stamp.py; config.py re-exports the two public
names (callers and tests import them from hermes_cli.config), the internal
_read_config_version_stamp callers now use read_config_version_stamp, and
scripts/docker_config_migrate.py imports from the new module directly.
CI has no PyYAML and the repo is ruamel-only: the new deletion tests'
seed/parse helpers now use ruamel.yaml (round-trip) instead of yaml.
hermes_cli/config.py FILE_LINES ratchet: the new _apply_user_moa_presets
moves verbatim into the topical MoA sibling hermes_cli/moa_config.py as
apply_user_moa_presets; both call sites (last-known-good fallback and the
load merge) late-import it, keeping no facade growth.
Adapt the rebased MoA raw read/merge/save to current fail-closed config reads and hold the existing reentrant config lock across the full update. Preserve concurrent partial writes from non-HTTP callers.
Exercise transient read refusal, HTTP autosave/restart/fallback merging, sparse YAML preservation, and deterministic named/legacy concurrent saves. Shared backup timestamp freshness remains a separately documented upstream limitation.
Voice input was record-then-upload on every surface: nothing showed while the user
talked, and the transcript arrived only after the whole take uploaded. With
`stt.streaming: true` the openai, xai and elevenlabs providers now stream microphone
PCM to their realtime endpoints while the user speaks. Partial text renders live and
the final transcript is ready at end of speech.
- tools/transcription_streaming.py: one session API (push_audio / end_audio /
finalize / cancel, an on_partial callback, a worker thread that owns the socket)
with three wires, all verified live: the OpenAI Realtime `type: transcription`
session (gpt-live-transcribe, 16->24 kHz resample since the endpoint refuses
< 24 kHz), xAI wss /v1/stt with interim results, and ElevenLabs Scribe v2
realtime. Plugin providers opt in through
TranscriptionProvider.streaming_capable / open_stream_session.
- tools/voice_mode.py: AudioRecorder taps its capture callback into the session and
parks it under the WAV path. transcribe_recording uses the live transcript and
falls back to the file on any failure or empty result, so CLI push-to-talk, the
continuous VAD loop and the TUI gateway gain streaming with no call-site changes.
- Classic CLI and Ink TUI show partial text in the input placeholder while recording
(new `voice.partial` gateway event, contract regenerated). Desktop dictation
streams PCM to the new /api/audio/transcribe-stream socket (provider keys stay on
the host, and the host resamples) and shows partial text in the dictation pill.
The recorded blob stays the fallback.
The plugin streaming contract (streaming_capable / open_stream_session /
TranscriptionStreamSession) and the transcribe-stream socket shape come from #103173.
Co-authored-by: Vinicius <timelord1025@gmail.com>
* fix(agent): free-tier rate limits end an attended turn at once
A free-tier (no sign-in) session that hit the service-wide rate limit slept
up to 600 s, retried into the same refusal, slept again, and then sent its
third attempt on a guest JWT that had expired during the wait. The welcome
host answered 403 "invalid jwt", which read as the tier being switched off.
The user watched a spinner for about 18 minutes and then saw the wrong card.
- An attended session (desktop, TUI, the interactive CLI, ACP) on the free
model ends the attempt cycle at once when the cooldown is longer than
60 s: fallback first, else the reset time and the sign-in / model doors.
"Attended" is keyed on the session source, so `hermes chat -q`, cron and
messaging sessions keep the long wait. The source vocabulary moves from
run_agent.py into agent/session_source.py, which adds is_attended().
- on_free_model() (welcome host + anonymous credential) is the one free-tier
check, used by the cutoff, the "free model is busy" wait copy and the
outage copy.
- A welcome-host 403 "invalid jwt" is its own route kind, session_expired:
the existing one-shot route heal re-exchanges the credential and retries;
if that fails the card is the retryable outage card.
- interruptible_backoff_sleep adopts a fresh Nous key after each wait.
_adopt_nous_key_before_expiry owns its refresh failure, so both call
sites drop their try/except.
- A retry wait over 60 s is announced in the transcript when it starts, for
every provider; shorter waits stay buffered. Every wait line names the
attempt that runs after it.
- max_retries_exhausted_result's max_retries becomes attempts (what it
carries); a one-attempt rate-limit end gets its own lead.
- One Retry-After reader, retry_utils.provider_retry_after_seconds, shared
by compute_error_backoff and the recovery ladder.
- _session_default_route replaces _session_default_model everywhere in
tui_gateway, so a not-yet-built free-tier session reports nous/welcome on
create, reuse, resume, branch and row persist, not the silent default.
* fix(agent): free-tier cutoff no longer ends subagent or library turns, paid waits look like main (review)
A delegated child of a TUI/Desktop session and a library caller (no platform) are unattended, so they keep waiting.
Rate-limit wait lines keep main's attempt numbers and stay buffered for every provider; only a non-rate-limit Retry-After over 60 s is announced, as on main.
The unattended free-tier wait line keeps the reset time beside "The free model is busy".
The free-tier cutoff names the reset time for any cooldown it fires on (over 60 s), not only from 120 s.
session.info for an env-seeded model off the free tier carries the model alone again, as on main.
Behaviour tests for the cutoff, the subagent and unattended waits, and a paid long wait.
An agent stores its session platform's gated toolsets in disabled_toolsets at load, so the tool_search listing, the bridge's search, MCP refreshes and delegate children drop manage_catalog too.
InstallPhase moves to the leaf hermes_cli/plugin_install_phase.py; the gateway contracts no longer import the installer.
The per-home enable lock moves from the plugins_cmd facade to plugins_cmd_install._enable_placed.
Reusing an installed catalog tree reads the install record through catalog_install_record, so older top-level catalog_name records match.
A failed row no longer redacts the Advanced choices (profile name, commit); only credentials are redacted.
The profiles.py role comment no longer says a role grants the setup toolset.
Catalog plugin installs pass the commit approved under Advanced to the clone; a disabled tree on disk is reused only when it is at that commit.
A settled Try again on a plugin row draws the tools, setup notes and enabled state from that retry's install result.
A manage_catalog search row that reuses an install's call id no longer draws the install card.
Connection cards kept for a session are cleared when its runtime is dropped and on a gateway re-home; turnHasReply moves to lib/chat-messages to keep session-states.ts under its size cap.
Toolsets that exist only for one kind of session now say so on their spec:
`platforms=frozenset({"desktop"})`. `TOOLSET_SESSION_PLATFORMS` is derived
from the specs that carry the key, `all` / `*` / no selection skip those
toolsets, and `toolsets.agent_tool_drops(agent)` removes their tools from any
agent whose own session platform is not listed. `agent_init._load_tools` and
`tools/mcp_tool_agent.py` (MCP refresh and prefix restore) call that one
helper. `tool_search._DIRECT_SURFACE_TOOLSETS` is derived from
`CLIENT_SURFACE_TOOLSETS` and `TOOLSET_SESSION_PLATFORMS`.
This replaces the profile-role gate: `profile_role_toolsets`, the role code in
`_with_session_toolsets` and the explicit-pin branch, the strip step in
`_select_tool_names`, and main's `setup` toolset (whose only tool moves out)
are deleted. `manage_catalog` moves into a new `catalog` toolset, which the
desktop client folds into every desktop chat like `project` and `desktop_ui`.
It is deferred behind tool_search by default (`config_defaults` defer list).
The shared-metrics schemas' toolset enum follows (`setup` out, `catalog` in).
The install card works outside the setup chat (#129650, with review fixes):
- Parallel installs: `_set_plugin_enabled` holds a per-home lock around the
read-then-admit section, so rows enabled at once no longer refuse each other.
- A catalog plugin already on disk and not enabled is enabled in place, so Try
again after a refused enable can finish.
- A failed catalog row counts as resolved (`contract.resolves`), so it no longer
holds the turn to the 300 s deadline. The snapshot carries `resolved`, and the
card reads it instead of a hand copy of the backend's rule.
- Each card draws its own operation by op id, because models reuse call ids.
- The row carries the approved non-secret choices (`approved`: force, enable,
ref) and the chosen profile, so Try again after the card settled repeats them.
- Install phases are ids (`InstallPhase`, wire `CatalogInstallPhase`) and the
installed-row notes are fields (`enabled`, `missing_env`, `server_errors`,
`already_installed`, `requires_hermes`); the card words them in English
strings (translations follow later). Failure reasons stay raw text, and
`detail` keeps the English notes for the model.
- Installs and search target the calling chat's own home, carried as a path;
the row's profile name is only a label, with no `'default'` fallback in
Python or TypeScript.
- `dashboard_install_plugin` is split into `_resolve_source`, `_place_tree` and
`_enable_placed`.
Desktop English strings for the card move to `en_catalog_install.ts` /
`types_catalog_install.ts`, which offsets the growth of `en.ts` and `types.ts`.
Resuming a dispatcher-spawned Kanban worker session from the ordinary
CLI, /resume, /sessions, oneshot or the Desktop gateway would create a
write-capable writer the board cannot observe (and unblocking the card
can dispatch a competing worker into the same workspace).
- hermes_cli/kanban_resume_guard.py: kanban_resume_refusal() refuses a
session whose Kanban ownership markers are present; fails OPEN only
on probe errors. HERMES_KANBAN_TASK (the dispatcher's own env)
exempts the legitimate dispatcher-driven resume.
- SessionDB.is_kanban_owned_session() probes the transcript for the
worker markers without loading history.
- Wired into: CLI --resume/-c (falls back to a fresh session id),
/resume + /sessions (keep current session, print refusal), oneshot
quiet resume, gateway session.resume (error code 4132).
- locales: cli.resume.kanban_owned in en + 16 languages.
- Two latent F811 redefinitions fixed in moved handler code.
Fixes#68779
Pure code moves, no behaviour change. The session-interrupt gateway
handlers (methods_session_interrupt.py) and the CLI session-tool
handlers (cli_commands_session_tools.py) leave the two ratchet-offset
monoliths they lived in, so the module-size ratchet offsets can be
reclaimed. cli_commands_mixin re-imports the moved names so existing
'from cli_commands_mixin import _t' consumers keep working.
The marker discard ran after `await _legacy_pump(...)`. When the client goes away,
the handler task is cancelled while the pump is still awaiting bridge.close in a
worker thread, so the discard never ran and the channel kept a stale marker. It
now runs in a finally. test_legacy_channel_connect_pops_marker_on_disconnect hit
this on CI: TestClient cancels the app task on context exit.
STT_XAI_MODEL is read per call (a module constant froze the launch profile env),
the xAI client-direct form builder moves out of transcribeAudioClientDirect (CC 21),
the silencedetect probe catches only what subprocess raises, and the stt.xai
defaults comment folds into one line so config_defaults.py does not grow.
STT_MODEL_CATALOG moves to tools/transcription_common.py and becomes the one table
behind the `hermes tools` picker, the dashboard selects and the auto-correction
sets. Before this, the dashboard and Desktop offered models for only four providers,
Mistral and xAI had no select, DeepInfra had no model field, and Groq still listed
distil-whisper-large-v3-en, which Groq shut down on 2025-08-23. A config naming it
now remaps to whisper-large-v3-turbo.
Mistral Voxtral STT comes back to the picker and the dashboard provider list. It had
been hidden since the 2026-05-12 mistralai quarantine, and the pin is a verified-clean
2.4.8 again. DeepInfra's picker reads its live STT catalog.
POST /v1/stt still defaults to grok-voice-transcribe-1.0 when model is
omitted. Hermes logged "grok-stt" and never sent the field, so voice notes
stayed on 1.0. Send stt.xai.model (default 2.0, overridable via STT_XAI_MODEL)
on the gateway and the desktop client-direct path.
(cherry picked from commit c869631946e9a9d84b1738bfdd278f926300da00)
An explicitly-set empty allowlist meant 'nothing allowed' everywhere in
prose, but every boundary normalized it away:
- tui_gateway _load_enabled_toolsets converted an empty resolved set to
None (= no restriction), so platform_toolsets.cli: [] handed the
session every default toolset (#82010).
- cron create/update normalized enabled_toolsets=[] to null, and the
dispatch resolver treated a stored [] as absent (falsy), falling back
to platform_toolsets.cron.
Distinguish unset (None = no restriction, unchanged) from explicit
empty ([] = zero toolsets) end to end: the gateway returns [] bare (no
client-surface fold-in), cron persists and reloads [] verbatim, and
dispatch resolves [] to zero toolsets with no MCP merge. Absent keys
keep today's defaults.
A 200 from /models proved nothing: an SPA catch-all serves 200 + HTML for
every route, and _parse_model_entries is lenient by design, so validation
returned ok:true with an empty model list and the Desktop Test button
showed a green success toast for an unusable endpoint. The newer transport
probe (#93622) only flags 404/405/501, which a catch-all never answers.
When no models parse, classify the answer instead of reporting success:
non-JSON bodies (naming the content type), JSON without a model list, and
a genuinely empty list each get ok:false + reachable:true with a distinct,
actionable message — a warning in the UI, so Save stays possible for hosts
that legitimately expose no model list (#47006).
Fixes#83128
An IPv6 literal followed by :port is only a valid URL authority inside
brackets; http://::1:9119 is unparseable by browsers and urllib. Add
hermes_cli/url_utils.format_url_host and use it at every site that
builds an http URL from a (possibly IPv6) bind host: the named-profile
dashboard attach URL, the browser auto-open URL, the startup banner,
the plugin-activation loopback request, the proxy listening banner, and
the gateway-migration listener URLs. Zone identifiers (%eth0) are
percent-encoded per RFC 6874.
Fixes#67367
Co-authored-by: sjungwon03 <sjungwon03@gmail.com>
- `skills_hub._registry_prefixes` builds the identifier prefixes from the adapters' own
SOURCE_IDs and `SkillsShSource._ID_PREFIX_ALIASES`, case-insensitively, so `skills.sh/...`,
`skils-sh/...` and `OFFICIAL/...` stop reading `unresolved`. A matched source is named by
`source_id()`, the method the router and the contract test use; two test fakes gain it.
- `update_failure_class`: exception type -> class and fixed stop-reason prefix -> class are
tables instead of an if-ladder. `Windows gateway recovery failed: ...`, the fixed prefix
update_completion writes when the post-update gateway resume fails, now reads
`restart_failed` instead of `other` (prefix only, never the message).
- `mcp.servers.add` records through `mcp_rpc_helpers.record_mcp_add`, dropping the 175-column
noqa line that kept methods_tools.py at its cap (2011 lines now).
- `_publish_failure_class`, `probe_failure_class` and `exception_failure_class` read a raise
site's `failure_class` with `inspect.getattr_static` (`tagged_failure_class`), and the MCP
type walk falls back to `connect_failed`. A third-party exception whose `__getattr__` or
property raised replaced the user's PluginOperationError with RuntimeError (they run at the
raise site, collection on or off), and the metric row was dropped; it now reads `exception`.
- `counter_dimensions_are_valid` adds the cross-field rule the per-field enums cannot state:
failure_class in the kind's own set, `none` exactly on success, registry only on skill rows.
Pre-split rows (no failure_class/registry) still validate.
- A refused dependency consent returns its reason as `_ConsentRefusal`, a str carrying the
closed class (`non_interactive`, `deps_declined`, `manifest_invalid`), so publication no
longer classifies by matching the user-facing copy. The text the user reads is unchanged.
- `extension_registry` imports the hub's `_SOURCE_ID_ALIASES` instead of a hand copy.
- test file: schema read uses utf-8-sig (windows-footguns).
The fresh-install receipt is kept from before the consent answer and counted
after an opt-in, so "data from before you opt in stays on this machine" was
false for it on the CLI send explainer and Desktop's consentWindow line in all
9 locales; only the pre-chat offer named the exception. Both now carry the same
"apart from the fresh-install note (noted on this machine and counted only once
you opt in)" clause. No i18n key changes; the CLI lines stay under 80 columns.
update_cmd_fleet._verify_fleet_after_update writes `partial` for a failed build
(`not update_complete`) and for an incomplete restart as well as for a
verification gap. The relabel marked the synthetic verify stage failed for every
`partial`, so failed_stage moved from build/restart to verify and every such run
added a failed hermes.update.stage verify row. Verify is now failed by `partial`
only when no stage mark already failed (a stale/down fleet row still fails it).
The appendix row states the rule.
report_pending_installs recorded the fresh-install row at the first start,
usually the opt-in day itself (setup asks inside the install). The sender's
CONSENT_GATE_SQL needs period_start >= opened_at, so that day's package is
never sent: the metric mostly measured re-runs over already-consented homes.
- A receipt is recorded only when collection is on AND (sending is off, or an
open send consent window opened at or before today 00:00Z); otherwise it is
kept for the first start on a later day. Tested against the real store,
real reconcile_send_consent and the real CONSENT_GATE_SQL: kept on the
opt-in day, recorded the next day, and that package passes the gate.
- Every opt-out answer purges pending_installs AND pending_updates:
save_consent(enabled=False) (setup, pre-chat offer, dashboard banner,
`hermes setup telemetry`) and the tui_gateway shared_metrics.set RPC, via
one shared_metrics_process.purge_pending_receipts used by begin_process too.
- Receipts are claimed by a plain rename, so an unparseable or non-dict
receipt is deleted instead of sitting forever; a receipt no start counted
within 7 days is deleted unreported (it would count under a later version).
- With Relay instrumentation off the store "settles" a row without recording
it; the reporter now leaves the receipts for a start that records, instead
of latching and deleting them uncounted.
- Docs: the hermes.install.run row states the consented-day rule, the 7-day
cap, opt-out deletion and the interrupted rules.
Invariant tests red at 6246dd0f69d (5 in test_shared_metrics_install_run,
1 in test_shared_metrics_consent_rpc).
CLI setup, the pre-chat consent offer and the Desktop collectedReliability line (all 9 locales,
no new key) now say update and install results carry a fixed-list reason and stage, and that a
fresh install is noted on this machine and counted only once the user opts in. The offer's
'data from before you opt in is never sent' sentence names that one exception.
install.sh: a run id per ladder, first writer wins (a parent-only SIGTERM no longer
leaves two receipts); Ctrl-C / SIGTERM write failure_class=interrupted.
install.ps1: same once-only guard, finally{} records an interrupted run.
Reporter: latch a receipt id only after its row is saved, so a short-lived start
whose daemon reporter is cut off at exit re-reports on the next start.
install.sh / install.ps1 leave one closed-token receipt per full ladder run
(installer, outcome, failed_stage, failure_class, start/finish epoch) under
$HERMES_HOME/telemetry/shared_metrics/pending_installs. Every fail()/Fail call
site passes a closed failure class; the installer never sends anything.
The next Hermes start records each receipt once (latched by id, deleted after
the row is saved) only while collection is on; a start with collection off
purges the receipts unreported. Out-of-contract receipts are dropped.
hermes.extension.install.count gains failure_class (closed per-kind set named at each failure exit
of the skills hub, plugin installer and MCP catalog emitters; raised exceptions classify by their
tagged class or TYPE, exception:<Type> collapses to exception, never the message) and registry
(the skills-hub adapter SOURCE_ID that resolved/served a skill; none/unresolved/other otherwise).
hermes.update.run gains failure_class derived from the FINAL receipt only (admission step,
stop_reason exception type, exit_code, stage marks, fleet states, gateway_restart). failed_stage no
longer reads 'other' for runs whose every mark passed: a 'partial' receipt (written only by the
post-restart verification) synthesizes a failed verify stage, and a failed run ending on a skipped
restart reads restart.
Package schema v4 is extended in place (canary-only): the pre-split field sets stay valid as the
second oneOf shape and in _LEGACY_METRIC_FIELDS so pending rows drain.
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).
- _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.
_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