The /api/status gateway_updated_at field and the gateway /health/detailed
updated_at field passed through whatever gateway_state.json contained,
untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy
gateways wrote unix epoch floats, and a corrupt or hand-edited state file
can inject numbers or arbitrary garbage — while the frontend types
(web/src/lib/api.ts) declare string | null.
Add normalize_updated_at() in gateway/status.py as the single funnel:
- str: accepted iff datetime.fromisoformat parses (trailing Z tolerated);
naive timestamps coerced to UTC; canonical isoformat returned
- int/float: treated as unix epoch seconds -> UTC ISO string, with a
plausibility guard (reject < 2000-01-01, > now+1day, non-finite)
- bool: rejected explicitly (int subclass, but never a timestamp)
- anything else: None
Apply it at both emit sites: the dashboard /api/status handler (covers
both the local read_runtime_status() branch and the remote
/health/detailed cross-container fallback branch) and the gateway API
server's /health/detailed response.
Contract tests: parametrized /api/status normalization (epoch float/int,
garbage string, None, bool, dict, absent key), remote-health numeric and
garbage bodies, dashboard shape test round-trip assertion, direct
normalize_updated_at units (range guards, Z suffix, naive coercion,
non-finite floats), and a write_runtime_status -> read_runtime_status
round-trip proving the writer side stays tz-aware parseable.
The dashboard's own liveness surface could report healthy while every
authenticated request 500'd (e.g. wedged state DB) — /api/status carried
gateway-only fields, no storage/dashboard signal, and no middleware
counted unhandled exceptions.
- DashboardHealth state holder: rolling 5-min deque of unhandled-error
timestamps + last self-test result. last_error_type/last_error_path
are internal-only diagnostics — snapshot() exports counts/enums/
timestamps exclusively (PUBLIC_API_PATHS no-secrets contract).
- Outermost @app.middleware('http') (registered last) wraps call_next
in try/except: records + re-raises unhandled exceptions, and records
responses with status >= 500.
- /api/status gains 'components' {gateway, storage, dashboard,
platforms} + top-level 'overall' ok|degraded. storage reuses the
gateway readiness state_db probe (read-only, 1s-bounded) in an
executor; platforms derive ok/degraded from existing
gateway_platforms states.
- Authenticated self-test task started in the lifespan: every 60s an
in-process httpx ASGITransport GET of /api/sessions?limit=1 with the
real _SESSION_TOKEN, feeding the dashboard component. Skips cleanly
when httpx is unavailable and while the OAuth gate is engaged (the
legacy token is not honoured there).
Tests: middleware increments on raising route and on 5xx, window
expiry, components shape + overall, storage degraded when the state_db
probe fails, dashboard degraded after an error, no secret-bearing
fields in the public payload, self-test pass/fail recording (mocked
client) + a real ASGI round trip.
mount_spa degrades to a JSON 404 catch-all when the dist directory is
fully missing, but _serve_index read index.html unguarded — so a dist
dir that exists while index.html is missing (partial build, wiped dist,
permissions) raised FileNotFoundError on EVERY request instead of
returning a useful error.
Catch OSError around the read and return the same JSON 404 payload the
fully-missing-dist path uses, so clients get a consistent signal. The
route recovers automatically once a rebuild restores the file.
TestPtyWebSocket's two python-resolution tests and the sibling fixture in
test_tui_resume_flow.py hard-linked sys.executable into pytest's tmp_path.
On machines where /tmp is a different filesystem than the venv (tmpfs vs
disk home) os.link raises OSError EXDEV and the tests fail before reaching
any assertion. copy2 preserves the executable bit and works across devices.
c253b0738 added clear_model_endpoint_credentials() to scrub an old endpoint's
inline secret (api_key, the legacy `api` alias, api_mode) when the web UI
switches the main model to a different provider. But _apply_main_model_assignment
gates the key-scrub path on model_cfg["api_key"] being truthy, so when the stale
secret lives only under the legacy `api` alias (no api_key), a provider switch
never clears it — the secret survives in config.yaml.
model.api is a live credential read path (_resolve_openrouter_runtime reads
`for k in ("api_key", "api")`), so the old endpoint's key contaminates a later
custom resolution — the exact harm clear_model_endpoint_credentials documents.
The sibling persistence sites (the gateway model-picker paths and the aux-slot
path) call the helper unconditionally on a non-custom switch and already scrub
`api`; only this caller had the api_key-only gate.
Widen the guard to fire on either field. The same-provider re-pick and
explicit-new-key paths are unchanged. Adds the api-alias case to the assignment
test (it fails without the fix).
activate_custom_endpoint copies the endpoint's base_url and api_key onto
cfg["model"]. delete_custom_endpoint pops the providers entry and saves —
it never touches that mirror.
So deleting the endpoint the agent is currently using leaves both behind:
DELETE /api/providers/custom-endpoints/acme -> 200
providers entry gone : True
model.api_key : sk-CUSTOM-ENDPOINT-SECRET
model.base_url : https://llm.acme.corp/v1
Two consequences, both silent:
* The agent keeps authenticating to the deleted host with the deleted key.
model.api_key outranks the environment at client construction, so this
also shadows whatever the operator configures next — the persistent-401
shape credential_lifecycle.py documents as #62269.
* A credential the operator just removed through the dashboard stays
sitting in config.yaml.
Scrub the main-slot mirror on delete, but only when it actually names the
deleted provider — an endpoint deleted while a different one is active must
leave that active assignment untouched. Both directions are pinned by tests.
_write_custom_endpoint builds a fresh entry dict from the request body and
assigns it over providers[endpoint_id], carrying nothing forward but api_key.
A providers.<name> block is not owned by that panel. It can carry keys the
dashboard has no field for, all of them load-bearing:
api_mode the protocol the endpoint speaks
key_env where the credential comes from
extra_headers per-provider HTTP headers (may carry credentials)
request_overrides extra body params
and a models map with more than the one model the panel names.
So an edit that only changes the default model destroys the rest:
BEFORE api_mode, base_url, extra_headers, key_env, model, models,
name, request_overrides
AFTER base_url, discover_models, model, models, name
FIELDS DESTROYED: ['api_mode', 'extra_headers', 'key_env',
'request_overrides']
The provider is left with no credential wiring (key_env gone, no api_key),
talking the wrong protocol, missing its proxy auth header — from a UI action
that said nothing about any of that. The models map also collapses to the one
named model, dropping the others and their context_length.
Merge onto the existing entry instead of replacing it, and merge the models
map rather than overwriting it. Managed fields still win, so the edit itself
still applies; a brand-new endpoint is unchanged. api_key keeps its previous
semantics — a supplied key overwrites, an omitted one leaves the stored key
in place (now via the merge rather than an explicit carry-forward branch).
POST /api/model/set accepts an api_key and threads it into
_apply_main_model_assignment. The custom-endpoint work then added a
provider-entry fallback right after it — but unconditionally:
if not base_url and provider_entry.get("base_url"):
base_url = provider_entry["base_url"] # explicit wins
model_cfg = _apply_main_model_assignment(..., base_url, api_key)
if provider_entry.get("api_key"):
model_cfg["api_key"] = provider_entry["api_key"] # explicit LOSES
The two lines disagree about precedence. base_url fills only a gap; api_key
overwrites whatever the caller sent.
So rotating a key through this endpoint returns 200 and silently keeps the
old one:
request api_key : sk-NEW-ROTATED-KEY
stored api_key : sk-STORED-OLD-KEY
That matters beyond the write itself: model.api_key outranks the environment
at client construction, so the stale key keeps authenticating and shadows
anything the operator configures next — the persistent-401 shape
credential_lifecycle.py documents as #62269.
A regression, not long-standing. Against 3d9789357^ the same request stores
sk-NEW-ROTATED-KEY.
Gate the fallback on `not api_key`, matching the base_url line directly above
it. Switching to a configured provider with no key in the request still adopts
the entry's key — pinned by its own test so the feature's intent doesn't
regress in the other direction.
* feat(desktop): inline TTS voice/model settings in the Capabilities tab
The Capabilities > Tools > Text-to-Speech panel only surfaced API keys per
provider — voice and model settings (e.g. tts.openai.voice) lived exclusively
in Settings > Voice, so users couldn't select or type a voice/model name where
they configure the backend.
- web_server: TTS provider rows now carry their tts_provider config key
(the section holding that backend's voice/model settings)
- desktop: new VoiceProviderFields renders the provider's config fields
inline in the toolset panel, deriving the key list from the curated
Settings > Voice section so the two surfaces can't drift; shared
ConfigField extracted to config-field.tsx
- voice/model name fields are now free-input comboboxes (Input + datalist)
instead of closed Selects — custom voice IDs (ElevenLabs cloned voices,
xAI custom voices, Edge's 400+ catalog) are typeable, known values remain
suggestions
- refreshed the stale OpenAI voice list (adds ash/ballad/cedar/coral/marin/
sage/verse) and added suggestion lists for edge/gemini/minimax/mistral/
kittentts/piper/neutts models and voices
- config.py: added missing tts.minimax and tts.kittentts default blocks and
deepinfra model/voice fields to the Voice section so those providers are
configurable from the GUI at all
* test(desktop): await effect-driven panel content in post-setup CTA tests
The auto-expand effect renders the provider's inner panel one re-render
after the row; with the QueryClientProvider wrapper the extra provider
tick made the synchronous getByRole race it (~10% local flake, failed on
CI). Await the panel content with findBy* instead.
* feat(desktop): add custom endpoint settings (supersedes #42745)
Salvages PR #42745 (elashera:custom-endpoints-desktop), which could no
longer merge cleanly against main. Re-integrated the work onto current
main and reconciled the conflicts:
- Settings nav: wired the new 'Custom Endpoints' provider sub-view into
main's data-driven navGroups/OverlayNav layout (PR predated that
refactor) and added it to PROVIDER_VIEWS.
- providers-settings: kept BOTH main's LocalEndpointRow affordance and
the PR's fuller CRUD panel; unified ProvidersSettingsProps to carry
onClose + onConfigSaved + onMainModelChanged.
- web_server: kept main's _normalize_main_model_assignment + api_key
propagation AND the PR's provider base_url lookup in
_apply_model_assignment_sync.
- model_switch: dropped the PR's bare direct-custom-config picker block;
main already implements it (source='model-config', with live model
discovery). Updated the salvaged test to assert main's behavior.
- Merged additive import/type blocks in hermes.ts and types/hermes.ts.
Backend endpoints, i18n labels (en/ja/zh/zh-hant), and the
custom-endpoints-settings.tsx panel carried over. 28 custom-endpoint
tests pass.
Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
* chore(contributors): map elashera's commit email
Salvage of #42745 (superseded by #67759) preserves @elashera's
authorship, whose corporate commit email had no contributor mapping.
Adds contributors/emails/ mapping so check-attribution passes.
Verified: GitHub user 'elashera' id=135239963 matches their own
noreply commit email (135239963+elashera@users.noreply.github.com).
---------
Co-authored-by: elashera <emilio.jesus.lasheras.romero@nttdata.com>
* fix(desktop): stop contradicting the Ready pill with the one-time-install hint
When a provider's server-computed status is 'ready' (post_setup install
verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row
still said 'This backend needs a one-time install (…)'. Swap the copy for
a muted installed-confirmation one-liner and keep the Run setup button for
repair re-runs. Gated purely on the provider status prop so it composes
with the server-driven resting state work in the sibling lane.
* feat(tools): surface the web search/extract capability split in the Capabilities UI
The runtime has dispatched web_search and web_extract to independently
configurable backends for a long time (web.search_backend /
web.extract_backend overrides with web.backend as the shared fallback),
but the Capabilities tab still presented one monolithic 'Web Search &
Extract' choice that only wrote web.backend.
Backend:
- GET /api/tools/toolsets/web/config now returns active_search_backend /
active_extract_backend resolved via the REAL runtime getters
(tools.web_tools._get_search_backend/_get_extract_backend), plus each
provider row's web_backend key and supported capabilities (from the
registry's supports_search/supports_extract flags).
- PUT /api/tools/toolsets/web/provider accepts an optional capability
('search'|'extract') that writes web.<capability>_backend without
touching web.backend; validates the provider actually supports the
requested capability (ddgs/brave-free are search-only). Omitted →
unchanged legacy apply_provider_selection path.
- New tools_config.web_provider_capabilities() helper reads the plugin
registry's capability flags.
Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web
provider matrix, per-row 'Search backend'/'Extract backend' assignment
pills, and 'Use for Search'/'Use for Extract' actions gated on each
backend's declared capabilities.
Tests: endpoint tests assert the runtime getters resolve to the written
backend (searxng for search, firecrawl for extract) after the endpoint
write; vitest covers badges, capability-gated buttons, and non-web
toolsets staying untouched.
* feat(desktop): deep-link Capabilities key rows to Settings → API Keys
Set env-var rows in the toolset config panel now offer 'Manage in API
Keys' in the row actions menu — an internal route change to
/settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param
via the shared useDeepLinkHighlight hook (same mechanism as the command
palette's ?field= config deep links and ?session= archived-session
links): scrolls the credential card into view, flashes it, and expands
it. Applies generically to every env-var row, and only when the key is
set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja.
* feat(desktop): point the vision Capabilities detail at Settings → Models
The vision toolset has no TOOL_CATEGORIES provider matrix — its
provider/model resolution runs through the auxiliary model config
(agent/auxiliary_client.py), so the Capabilities detail pane looked
empty with no hint of where the model choice lives.
Add a short explainer + an internal deep link
(/settings?tab=config:model&aux=vision) rendered only for
toolset.name === 'vision'. ModelSettings consumes the ?aux= param via
the shared useDeepLinkHighlight hook and scrolls/flashes the matching
auxiliary task row (rows now carry aux-task-<key> anchor ids). No
external URLs. i18n in en/zh/zh-hant/ja.
* test(desktop): use type-alias imports for the react-router mock (lint)
* chore: drop accidentally committed node_modules symlinks
* chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared)
Refactor the cherry-picked #40338 backend half:
- Move option merging from import-time _SCHEMA_OVERRIDES mutation to a
per-request overlay in GET /api/config/schema — options now reflect the
current config.yaml (no restart needed) and the module-level
CONFIG_SCHEMA is never mutated. The endpoint gains an optional
?profile= param scoped via _config_profile_scope.
- Keep builtin display order first, customs appended (drop the
sorted(set(...)) re-sort) — matches desktop enumOptionsFor.
- Only command-type provider blocks count (type absent or 'command' plus
non-empty command string), enumerated from the canonical
<kind>.providers.* location AND the legacy top-level <kind>.<name>
fallback — the same dual resolution as _get_named_provider_config /
_get_named_stt_provider_config. Builtin-name collisions are excluded
case-insensitively against the RUNTIME builtin sets (not the display
shortlist), mirroring apps/desktop/src/app/settings/helpers.ts
commandProviderNames (#67209).
- Drop the plugin.yaml 'provides: [tts]' manifest scan — that convention
does not exist (manifests carry provides_tools/provides_hooks only);
plugin TTS/STT providers register at runtime via
ctx.register_tts_provider(). Instead, opportunistically include names
from agent.tts_registry / agent.transcription_registry when plugins
happen to be loaded in this process.
- Current tts.provider/stt.provider value preserved in options.
- Tests: custom command provider merge (tts+stt), builtin-order
preservation, EDGE collision exclusion, non-command block exclusion,
current-value preservation, per-request freshness, legacy top-level
block support.
* fix(windows): suppress console-window flash in tools post-setup subprocess spawns
The desktop GUI runs post-setup hooks via a detached, console-less
'hermes tools post-setup <key>' child (spawned with windows_detach_flags).
But the hook implementations in tools_config.py ran their inner installers
(npm install, agent-browser install, uv/pip installs, ensurepip, cua-driver
version probes and installer) without Windows creationflags — and on
Windows a console-less parent spawning a console/.cmd child materializes a
brand-new console window, the 'terminal flash' reported on the
Capabilities > Browser Automation setup journey.
Add _post_setup_no_window_flags(), a local wrapper around
windows_hide_flags() (CREATE_NO_WINDOW only — DETACHED_PROCESS would sever
stdio and break capture_output), and pass it at every post-setup subprocess
call site. Spawns that stream live output to the user's console
(verbose cua-driver install) only hide when stdout is not a tty, so
interactive CLI installs keep their output. POSIX behavior is unchanged
(the helper returns 0 off-Windows).
* fix(desktop): make Capabilities post-setup idempotent — Installed state instead of unconditional Run setup
The GUI panel rendered the primary 'Run setup' CTA whenever a provider
declared post_setup, ignoring the server-computed readiness status the
config endpoint already serves. Users on Windows clicked 'Run setup' on
an already-installed Local Browser and watched it 'install' again.
Frontend: PostSetupRunner now takes installed (provider.status === 'ready')
and renders an 'Installed' pill + small 'Re-run setup' text button in that
state; onComplete still refetches the toolset config, so a fresh install
flips the row to Installed once the endpoint reports ready.
Backend:
- _POST_SETUP_READY extended: agent_browser now tracks the FULL local
install (_local_browser_runnable: CLI + Chromium-or-Lightpanda) instead
of the bare CLI check; new entries for the cloud 'browserbase' hook
(CLI only — cloud rows host their own Chromium) and camofox (npm
package present).
- _run_post_setup prints distinct 'already installed, nothing to do'
messages for the agent-browser/Chromium/Camofox early-exits so the GUI
action log tells the truth on re-runs vs fresh installs.
i18n: new postSetupInstalled/postSetupRerun/postSetupInstalledHint strings
in en, ja, zh, zh-hant + types.
* fix(desktop): let managed Nous Subscription rows activate from the GUI via the Portal sign-in flow
PUT /api/tools/toolsets/{name}/provider intentionally skips the Nous
Portal auth gate the CLI runs inline (ensure_nous_portal_access) — but no
desktop surface handled it. Selecting 'Nous Subscription (Browser Use
cloud)' from Capabilities wrote browser.cloud_provider=browser-use +
use_gateway=true and then silently never activated: _is_provider_active
requires feature.managed_by_nous, which stays false without the
entitlement, and the credential was never used.
Backend: after apply_provider_selection, the endpoint now checks the
managed row's entitlement (get_nous_subscription_features force_fresh +
the same per-category coverage gate the CLI applies) and reports the gap
with additive response fields {needs_nous_auth: true, feature}. The
selection is still persisted — activation is what's gated.
Frontend: handleSelect surfaces a 'Sign in to Nous Portal' warning toast
with a Sign-in action instead of the misleading success toast. The action
drives the EXISTING Nous Portal OAuth device-code flow (provider id
'nous' in _OAUTH_PROVIDER_CATALOG): POST /api/providers/oauth/nous/start,
open verification_url, poll /poll/{session}; on approval the panel
refetches the toolset config so is_active/status flip.
i18n: nousAuthNeeded*/nousAuthSignIn/nousAuthDone*/nousAuthFailed strings
in en, ja, zh, zh-hant + types.
* fix(desktop): compute truthful per-provider readiness for Capabilities tool config
Backend: GET /api/tools/toolsets/{name}/config now sends a per-provider
'status' field ('ready' | 'needs_keys' | 'needs_auth' | 'needs_setup')
computed by the new provider_readiness_status() in tools_config:
- env vars declared: all set -> ready, else needs_keys
- Nous-managed rows: Portal login + per-category tool-gateway entitlement
(MANAGED_FEATURE_COVERAGE_CATEGORY) -> ready, else needs_auth
- post_setup 'xai_grok' rows: Grok OAuth or XAI_API_KEY -> ready, else
needs_auth
- other keyless post_setup rows: installed-state predicate
(_POST_SETUP_READY: kittentts/piper/ddgs/langfuse via find_spec,
agent_browser via _has_agent_browser, cua_driver via PATH probe);
unknown hooks fall back to is_active as the setup-completed signal
- genuinely-free keyless rows (Edge TTS) stay ready
Existing fields are untouched; 'status' is additive so older desktops
keep working.
* fix(desktop): render server readiness status in Capabilities provider pills
The panel's providerConfigured() heuristic pilled every zero-env-var
provider 'Ready' — including logged-out Nous Subscription rows, xAI TTS
without Grok OAuth, and never-installed KittenTTS/Piper. Render the
backend's per-provider 'status' instead:
- ready -> existing 'Ready' pill
- needs_auth -> warn pill 'Needs sign-in'
- needs_setup -> warn pill 'Needs setup'
- needs_keys -> no pill (env-var fields are the signal)
Keyed rows keep deriving ready/needs_keys from local envState so saving
or clearing a key updates the pill without a refetch. Older backends
without 'status' fall back to the legacy env-var heuristic (narrow
compat path, desktop/runtime update on separate clocks).
Adds the warn tone to the settings Pill primitive and needsSignIn /
needsSetup strings to all locale catalogs (en, zh, zh-hant, ja).
The sidebar refresh fired three /api/profiles/sessions calls (recents, cron,
messaging), and each one reopened every selected profile's state.db and re-ran
list_sessions_rich + session_count — ~3N DB opens/counts per refresh, on every
turn/broadcast/reconnect.
Add GET /api/profiles/sessions/sidebar: one pass that opens each profile DB
once and runs the three source-scoped queries together (recents scoped to the
active profile; cron + messaging cross-profile), returning the three windows in
one payload. Same read-only projection, 300s active heuristic, and caller-
supplied source taxonomy (recents_exclude / messaging_exclude / source=cron) as
the per-slice endpoint.
Renderer refreshSessions now makes one listSidebarSessions call and distributes
recents/cron/messaging to their stores (cron *jobs* stay a separate getCronJobs
API). Electron splices remote profiles per slice via fetchProfilesSessionSlice
(reusing the proven per-slice merge) so remote correctness is preserved; the
no-remote common case gets the single-open fast path.
From the Desktop performance audit (P1: "Batch sidebar session slices").
Built-in memory (MEMORY.md/USER.md) is controlled by memory_enabled, not
memory.provider — but the desktop dropdown offered 'builtin' as a normal
provider-plugin value and gave it plugin-shaped affordances (config panel,
OAuth connect row), and the empty sentinel rendered as '(none)' even though
built-in memory was active.
- Label the empty memory.provider option 'Built-in only' (all locales).
- Drop the literal 'builtin' option from the desktop ENUM_OPTIONS and the
backend config-schema select; _normalize_memory_provider_name already maps
legacy builtin/built-in/none values to ''. A stored legacy literal stays
visible via enumOptionsFor's current-value passthrough.
- Gate MemoryConnect and ProviderConfigPanel behind a new
isExternalMemoryProvider() helper so built-in aliases never get
provider-plugin affordances.
The suite-wide conftest now pins HERMES_HONCHO_HOST=hermes (597615ade, after
this branch was written), which preempts profile-driven host resolution and
made the salvaged test write to the 'hermes' host key instead of
'hermes_worker'. Drop the override inside the test like the other custom
host-resolution tests do.
* fix(dashboard): resolve dashboard-owned assets from the process launch home
Profile-scoped chat / ?profile= requests install a context-local
HERMES_HOME override, which made custom dashboard themes AND user
dashboard-plugin extensions disappear once the embedded /chat started
under a different profile than the dashboard process.
Add get_process_hermes_home() (sharing _hermes_home_from_env() with
get_hermes_home() so the two can't drift, and splitting the profile
fallback warning into _warn_profile_fallback_once()) and use it for both
the theme YAML scan and the user dashboard-plugin scan — machine-level
assets that belong to the server's launch home and must not follow a
transient per-request override.
Genuinely profile-scoped callers (memories/backups/checkpoints/provider
config) and the paired _merged_plugins_hub classification are left
untouched so they keep following the override.
* test(dashboard): cover process-home asset discovery under profile override
- get_process_hermes_home(): env set returns that path, unset falls back
to the platform default, and an active context-local override is ignored.
- _discover_user_themes() and _discover_dashboard_plugins() keep returning
launch-home assets while a profile override scopes the request elsewhere.
Route the provider config endpoints on the surface query param exactly
as main does: surface=declared serves the curated schema (now sourced
from the plugins' config_schema.py instead of the deleted
hermes_cli/memory_providers.py), while the default surface keeps
serving the raw plugin schema that the web dashboard parses. Both
surfaces honor the profile query param.
The declared PUT returns {ok: true} to match main's contract; only the
raw-surface PUT reports the activated provider. The desktop client
requests surface=declared again, and the undeclared-provider tests use
builtin now that honcho has a declared schema.
Server-side coverage for the critical-CSS shim (PR #36024 salvage):
- user theme → style block emitted with ONLY real bundle variable names
(--background-base/--midground-base from layerVars(),
--theme-font-sans/--theme-base-size from typographyVars()/index.css),
and an html,body rule expressed via those vars so runtime theme
switches never leave a stale canvas/font
- built-in / unknown / non-string active theme → no block
- malformed theme YAML and load_config() exceptions → no crash, index
still serves
- </style> breakout attempt in a theme value stays escaped
- mount_spa integration: block present in <head> for user themes,
absent for built-ins
Conflict resolutions:
- web_server.py: keep the branch's unified provider-config handlers
(declared schema served by default, profile-scoped) over main's
surface=declared query param. Main's declared-surface helpers and the
hermes_cli.memory_providers import are dropped — the branch deleted
that module when provider schemas moved into their plugins, so main's
code path could no longer import.
- hermes.ts: keep profileScoped() calls without ?surface=declared to
match the unified backend.
- constants.ts: take main's new reasoning effort values (max, ultra),
keep the branch's memory provider ordering.
- config-settings.tsx: take main's FallbackModelsField import, drop the
now-unused SECTIONS import (branch replaced it with
sectionFieldEntries).
- provider-config-panel.test.tsx: file moved to settings/memory/ on the
branch; main's act() fixes targeted the old tests, and the rewritten
suite produces no act() warnings, so the old path stays deleted.
- test_web_server.py: keep both sides' new tests (main's MoA endpoint
tests plus the branch's Honcho provider tests).
Follow-up hardening on top of #64158 (@DavidMetcalfe):
Backend (the root-cause fix):
- hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time
counterpart to the deliberately tolerant normalize_moa_config(). Flags
half-filled slots, empty reference lists, recursive moa slots, naming the
exact preset/slot.
- hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing
and returns 422 with the specific problems instead of silently swapping the
user's preset for hardcoded defaults (#64156). Also declares
fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so
client round-trips no longer erase hand-set values.
Desktop:
- Replace sanitize-then-send with hold-while-incomplete: the debounced
autosave is deferred (not repaired) while any slot is half-filled, and
flushes once the model pick completes the edit. Mid-edit UI state is never
repainted by a save response (generation guard covers held edits too).
- updateMoaSlot only clears the model when the provider actually changed.
- Explicit preset ops (set default / add / delete) cancel the pending
autosave and invalidate in-flight responses so the two writers can't race.
- Stable row keys (preset+index) so mid-edit rows don't remount; cleared
model shows the 'Model' placeholder instead of vanishing.
Both TS clients' MoaConfigResponse types now declare the round-tripped
fields (fanout, reference_max_tokens, reasoning_effort).
Tests: 12 new backend unit tests (validate_moa_payload contract incl.
validate/normalize agreement), 3 new web_server endpoint tests (422 on
half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases
(autosave held while half-filled, flush on completion, same-provider
reselect no-op). E2E validated against a live TestClient with isolated
HERMES_HOME: bug sequence now 422s with config untouched.
Fixes#64156
The desktop provider panel previously rendered the curated declarations
from hermes_cli/memory_providers.py: five hindsight fields, and no panel
at all for undeclared providers like honcho (OAuth connect only). The
dashboard provider-switching rework re-pointed the shared config route
at raw plugin schemas, so the desktop began dumping every internal field
(35 for hindsight) and grew a bespoke honcho panel.
Serve both surfaces from the same route: ?surface=declared returns the
curated schema (empty for undeclared providers) with the original
config-file + env-store write semantics; the dashboard keeps the raw
plugin schema unchanged. The desktop client opts into declared.
The dashboard's dedicated memory-provider UI (4b184cbe5) excluded
memory.provider from /api/config/schema server-side. Desktop's settings
page builds its field list from that schema, so the Memory Provider
dropdown silently vanished from Desktop after v0.18.1.
- web_server.py: restore memory.provider as a select in _SCHEMA_OVERRIDES,
with options built from plugins.memory discovery (was a stale hardcoded
[builtin, honcho] list before the removal)
- plugins/memory: add list_memory_provider_names() — directory-scan-only
name listing, safe at module import time (no provider imports)
- web ConfigPage: hide memory.provider client-side instead — the Plugins
page owns the dedicated provider-switching UI there
- tests: schema contract (select present, category memory, builtin
sentinel) + invariant that every discoverable provider is selectable
Reject metadata that would make session queries fail, bound import work, and detach cyclic lineage links. Guard lineage traversal against pre-existing corrupt cycles.
The web UI CONFIG_SCHEMA showed ['ask', 'yolo', 'deny'] for the
approvals.mode select field. These don't match any real config values
and 'smart' mode was entirely unreachable from the dashboard.
Correct the options to ['manual', 'smart', 'off'] which match the
values defined and documented in hermes_cli/config.py.
Adds a regression test to TestBuildSchemaFromConfig to pin the correct
option names and guard against future drift.
Fixes#31925
No bundled provider declares actions, and the motivating request
(openviking, #56309) needs dynamic select options rather than actions.
Removing the unconsumed POST dispatch surface keeps this PR focused on
the config panel; the extension point can return as its own PR with its
first real consumer.
Dashboard Chat is an xterm mirror of a TUI inside the gateway, so
server-side clipboard.paste never sees the browser clipboard. Upload
pasted/dropped images to the profile's images/ dir (same place
clipboard.paste / image.attach use), then drive /image over the PTY.
Uses a dedicated /api/chat/image-upload endpoint (magic-byte check,
25MB cap, profile scope) instead of relative managed-files uploads that
400 on local dashboards without a locked root. Ctrl/Cmd+Shift+V also
tries clipboard.read() for images before falling back to text, since
preventDefault on that chord suppresses the DOM paste event.
Salvages #57912 (client composition + /image PTY drive) and folds in
#48563's upload endpoint + drop path.
Co-authored-by: bird <6666242+bird@users.noreply.github.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
Rebase reconciliation with #60884: _count_status_active_sessions (from
#58238) now passes compact_rows=True (this branch's #47437 projection),
so the fake asserts both.
Review finding: SessionDB(read_only=True) requires the DB file to exist
(its documented contract says callers guard on db_path.exists()); on a
fresh install every /api/status poll paid an OperationalError until the
first session was written. Short-circuit to 0 when state.db is absent.
Tests: fresh-install guard + existing read_only test adjusted.
The #39140 CTE used UNION ALL, which recurses forever if a corrupted
parent chain loops (a -> b -> a) — reproduced: query never returns. The
old Python walk was cycle-safe via a seen-set. UNION dedups the working
set and terminates. Regression test added and mutation-verified (UNION
ALL hangs the test, UNION passes).
Main's #60569 (dashboard memory provider switching) rewrote the same
GET/PUT /api/memory/providers/{name}/config routes this PR owns — the
dashboard and desktop share one backend. Resolve by dispatching: providers
that declare a config_schema.py get the declared path (host-block storage,
locked honcho writes, profile scoping, actions); everything else keeps
main's instance get_config_schema()/save_config path unchanged, so the
dashboard's PluginsPage behavior is preserved for instance providers.
Setup manifests, the /setup endpoint, provider switching, and name
validation from #60569 apply to both paths. Instance payloads gain the
docs_url/actions keys the desktop panel expects; declared payloads gain
the setup block the dashboard expects. Main's hindsight/honcho instance-
schema tests are updated for the dispatch, and its status test gets the
HOME pin it was missing (it read the developer's real ~/.honcho).
Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.
Headless/hosted deploys run the dashboard server without COLORTERM in
the process environment, so chalk inside the PTY-spawned TUI child
downgraded every skin hex color to the xterm 256 palette — the default
skin's bronze banner border (#CD7F32) snapped to palette 173 (#D7875F,
salmon red) and the gold caduceus rendered red/yellow on fresh cloud
instances. Local launches never reproduced it because the operator's
interactive terminal leaks COLORTERM=truecolor into the server env.
xterm.js always renders 24-bit RGB, so the dashboard PTY child should
always advertise truecolor: backfill COLORTERM=truecolor in
_resolve_chat_argv via setdefault (an explicit operator value wins).
Verified with a clean-env PTY probe of the real TUI binary:
no COLORTERM -> 0 truecolor SGRs / 165 palette-256 (salmon 38;5;173);
with the backfill -> 166 truecolor SGRs, exact bronze 38;2;205;127;50.