mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
447 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7857d8737c |
feat(dashboard-auth): RFC 8252 native desktop sign-in (system browser + PKCE, no webview/cookies)
The Desktop app can now sign in to a gated gateway using the user's SYSTEM browser and OAuth 2.0 for Native Apps (RFC 8252) instead of an embedded Electron BrowserWindow, and authenticates with bearer tokens it holds itself instead of relying on HttpOnly browser session cookies. Why brokered: the upstream IDP (Nous Portal) binds client_id to the gateway instance and only permits redirect_uris on the gateway's own origin, so a desktop loopback redirect can't be a direct Portal client. The gateway therefore acts as the authorization server TO the desktop and an OAuth client TO the Portal, reusing the existing PKCE start_login/complete_login provider path unchanged. Server (Ben's dashboard-auth lane): - native_flow.py: in-memory broker — binds the desktop's PKCE challenge to a completed Session, mints a single-use, short-TTL, PKCE-verified gateway authorization code. Constant-time compare, single-use (consumed before the PKCE check so a wrong verifier can't be retried), capacity-bounded. - routes.py: GET /auth/native/authorize (starts the brokered PKCE login, loopback-only redirect_uri, S256-only), POST /auth/native/token (loopback code + verifier -> tokens in the JSON body, never Set-Cookie), POST /auth/native/refresh (desktop-held RT rotation). /auth/callback branches to mint a loopback code + 302 to 127.0.0.1 when a broker_state rides the PKCE cookie; the cookie/SPA path is untouched. - middleware.py: the gate accepts Authorization: Bearer <access_token>, verified via the same verify_session provider stack (no cookie set/read), with the same "provider unreachable -> 503, not logout" semantics. - web_server.py /api/status: advertise auth_flows (["cookie","native_pkce"]) so clients can detect the capability; native_pkce only when a brokerable OAuth provider is registered. Desktop (Ben's lane): - native-oauth.ts: pure PKCE/capability/URL/callback/token helpers. - native-oauth-login.ts: loopback-listener orchestration (system browser via openExternal, ephemeral 127.0.0.1 listener, state/PKCE verification), all I/O injected for testability. - main.ts: capability-gated oauth-login IPC — native flow when advertised, automatic fallback to the existing embedded-webview cookie flow otherwise; tokens stored encrypted (safeStorage/OS keychain), REST + ws-ticket authenticated by bearer, transparent refresh, logout clears both shapes. Tests: 18 server pytest (broker unit + full authorize->callback->token E2E + cookieless bearer auth of a gated route + ws-ticket mint + capability advertisement + refresh); desktop node --test/vitest for both pure modules (PKCE, capability detection, callback CSRF, loopback round trip, timeout, browser-open failure). Electron project typechecks clean. Docs: website/docs/guides/desktop-native-signin.md. |
||
|
|
9acc4b47f5
|
perf(state): external-content FTS + tool-row-free trigram index (schema v23) (#65798)
* fix(desktop): refresh repo status on session switch with unchanged cwd (#68208) fix(desktop): refresh repo status on session switch with unchanged cwd * fix(checkpoints): honor gateway config and task cwd (#68195) * fix(gateway): wire checkpoint config into agents * fix(checkpoints): resolve gateway file paths by task cwd * ci: live-updating PR review comment with structured job statuses Replace the static comment-pending + comment-results two-job pattern with a live-updating comment system that polls the GitHub Actions API every 15s, re-assembles the review comment from whatever results are available, and upserts it via the <!-- hermes-ci-review-bot --> marker. The comment updates in real time as each job finishes — no waiting for the full pipeline. Every CI job that wants to appear in the review comment emits a review_status output — a JSON array of objects, each with a source and a results array: [ { "source": "review-label-gate", "results": [ {"kind": "action_required", "title": "...", "summary": "...", "how_to_fix": "..."}, {"kind": "info", "title": "...", "summary": "..."} ] }, { "source": "ci timing", "results": [ {"kind": "warning", "title": "CI timings", "summary": "...", "detail": "...", "link": "..."} ] } ] One job can emit multiple results of different kinds. The source field is used to exclude the corresponding job from the synthesized error list (case-insensitive, hyphen-normalized matching against GitHub Actions job display names). | job | source | kind (on failure) | section | |----------------------------|--------------------------|---------------------------|----------------------| | review-labels | review label gate | action_required / info | Action required | | lockfile-diff | lockfile-diff | action_required | Action required | | ci-timings | ci timing | warning / info | Warnings | | supply-chain scan | supply chain | error / (none) | Job failures | | supply-chain dep-bounds | supply chain | action_required / (none) | Action required | | osv-scanner | osv scan | warning / (none) | Warnings | | uv-lockfile-check | uv.lock check | action_required / (none) | Action required | | history-check | unrelated histories | action_required | Action required | | contributor-check | contributor attribution | action_required | Action required | Jobs that find nothing emit [] (empty array) — no noise info items. A single comment-live job polls the GitHub Actions API every 15s, classifies jobs into (completed, pending), assembles the comment, and upserts it. Merges review_status outputs from all needs jobs via toJSON(needs.*.outputs.review_status), and downloads the ci-timings artifact when it becomes available. Shows commit SHA + message below the header. The assembler has ZERO job-specific knowledge. It just: 1. collect_from_statuses() — flattens all nested status objects into ReviewItems 2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status 3. _attach_job_urls() — fills in per-job log links for ALL items 4. render_comment() — groups by severity, renders with group headers Each item shows links inline next to the title: View report (job-emitted URL) and View job (auto-attached logs link). Each info item is its own collapsible <details> block. # ૮ >ﻌ< ა ci review running on abc1234 — commit message first line ## ❌ Job failures ### {title} · [View job](url) {summary} ## ⚠️ Action required ### {title} · [View job](url) {summary} **How to fix:** {how_to_fix} ## ⚠️ Warnings ### {title} · [View report](url) · [View job](url) {summary} {detail} <details><summary>{title}</summary> {content} </details> Still running 3 jobs: ci-timings, docker - test_assemble_review_comment.py (48 tests): collect_from_statuses, collect_failed_jobs with exclude_sources, _attach_job_urls, render_comment (group headers, inline links, commit info, per-item details, pending footer), assemble integration - test_live_comment.py (16 tests): classify_jobs pure function - test_timings_report.py (10 tests): generate_review_status nested format - test_lockfile_diff.py (6 tests) - test_classify_changes.py (32 tests, pre-existing) * ci: migrate AUTOFIX_BOT_PAT to GitHub App token Replace the long-lived fine-grained PAT (AUTOFIX_BOT_PAT) with short-lived (1-hour) installation access tokens minted via a new get-app-token composite action wrapping actions/create-github-app-token@v3.2.0. The PAT was used in 13 spots across 8 workflow files for gh CLI / GitHub API calls. The per-repo GITHUB_TOKEN (1,000 req/hr) was getting rate-limited when multiple workflows fire concurrently (deploy-site, skills-index, ci-timings, supply-chain-audit, js-autofix). App installation tokens get 5,000 req/hr per installation and are scoped to the App's permissions, not a user account. New composite action: .github/actions/get-app-token/ - Wraps actions/create-github-app-token@bcd2ba49 (v3.2.0, SHA-pinned) - Reads APP_ID + APP_PRIVATE_KEY repo secrets - Outputs a 1hr installation token via steps.app-token.outputs.token Requires two new repo secrets (set after creating the GitHub App): - APP_ID: the App's numeric ID - APP_PRIVATE_KEY: the PEM private key App installation permissions needed: contents: write (js-autofix push, pypi release upload) pull-requests: write (js-autofix PR create/merge, supply-chain comment) issues: write (skills-index-freshness issue creation) actions: write (skills-index workflow trigger) workflows: write (skills-index triggers deploy-site.yml) The AUTOFIX_BOT_PAT secret can be deleted once CI passes on this PR. The comment in js-autofix.yml noting that PAT pushes trigger downstream workflows is updated — App tokens have the same property (they are not GITHUB_TOKEN), so the concurrency-cancel loop logic is unchanged. * style(desktop): satisfy merged eslint/prettier config The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts). * fix(ci): pass App secrets as inputs to composite action Composite actions cannot access the secrets context — the runner's template engine rejects secrets.* references at load time with 'Unrecognized named-value: secrets'. Move APP_ID and APP_PRIVATE_KEY from direct secrets.* references inside the composite action to inputs passed by each calling workflow. The fallback logic (GITHUB_TOKEN when APP_ID is empty, for fork PRs) stays in the composite action's check step. * fix(ci): add detect to all-checks-pass needs so its failure blocks merge If detect fails, all downstream sub-workflows get SKIPPED (they have needs: detect). all-checks-pass used if: always() and only checked the sub-workflows — which all showed as 'skipped' (= success) — so it passed even though the root cause (detect) failed. This made the PR mergeable despite a broken CI pipeline. Add detect to all-checks-pass needs so its failure propagates to the gate job and blocks the merge. * fix(desktop): bump skills test timeout to fix cold-start flake (#68235) Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env init + module transform + the @/hermes/@/store/profile import graph), which pushed past vitest's 5000ms default under load — caught at 8871ms on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms each because all that setup is already cached, so only test 1 was at risk of timing out. Bump the describe-level timeout to 15s. Verified with 10 consecutive runs, 4 of which took 5.5-6.6s of test time and would have hard-failed under the old 5s default. * feat(desktop): open multiple full app windows (electron) Add createInstanceWindow() — a full-chrome peer of the primary that renders the complete app (sidebar, routing, its own draft) against the shared backend, so several GUI windows can run at once. Mirrors the primary's window options + chatWindowWebPreferences (backgroundThrottling stays off so a streamed answer never stalls when blurred) but never overwrites the mainWindow global and doesn't respawn the backend — the renderer's getConnection() joins the running one. New windows cascade off their source via the pure, tested instanceWindowBounds(). Exposed via the hermes🪟openInstance IPC and a "New Window" File menu item. Per-window fullscreen state now targets the window itself, and titlebar/native-theme repaints reach every open chat window instead of only the primary. Retires the now-orphaned compact new-session pop-out (its only caller was ⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow, the hermes🪟openNewSession handler, and the newSession/new=1 URL flag. * feat(desktop): wire New Window to ⌘⇧N + command palette Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to openNewWindow(), which opens a full peer instance via the new openWindow bridge, and add a "New Window" entry to the ⌘K palette (shown with its hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window". Drops the retired openNewSessionWindow bridge and the vestigial isNewSessionWindow()/new=1 flag; renames the shared opener helper. * fix(desktop): de-dupe cross-window cues so peers don't spam With multiple full windows, each renderer independently reacts to the same backend event, so one-shot cues fired N times: OS notifications (the per-renderer throttle can't see other windows), the turn-end sound (playCompletionSound runs on every message.complete, ungated by focus), and auto-spoken replies (double voice when a chat is open in two windows). Add a single race-free owner in the main process (electron/event-dedupe.ts): main handles IPC serially, so the first window to claim a key within a short window wins and peers stay quiet. Notifications collapse at the hermes:notify choke point; the sound and spoken replies claim via a new hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the claim falls back to "emit", preserving single-window behavior. The sound's mute check runs before the claim so a muted window can't win the cue and silence an audible peer. * refactor(desktop): tidy the cross-window deduper Drop the unused DEDUPE_WINDOW_MS export and rename its interval so "window" isn't overloaded against BrowserWindow in a multi-window feature (windowMs → intervalMs). DRY the completion-sound play path. No behavior change. * nix: add cage to devDeps * fix(desktop): avoid false remote gateway reauthentication (#68250) * fix(desktop): avoid false remote gateway reauthentication Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> * fix(desktop): harden remote revalidation state --------- Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com> Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai> * fix(desktop): keep composer draft across compression tip rotation (#68079) * fix(desktop): keep composer draft across compression tip rotation Auto-compression swaps the live stored session id while the user may still be typing. Scope the composer/queue key on the lineage root and migrate any tip-keyed draft/queue entries onto that durable key when the tip rotates so the in-progress prompt does not vanish when the response lands. * test(desktop): cover draft survival across compression tip rotation Add regression coverage for migrateSessionDraft, lineage-scoped composer keys, and the rotation path that previously wiped an in-progress draft. * fmt(js): `npm run fix` on merge (#68305) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(desktop): Stop parks the queue instead of firing the next queued prompt Interrupting a busy turn with the Stop button (or Esc) settles the session to idle, and the edge-independent auto-drain immediately submits the head of the composer queue. The user pressed Stop to halt the agent, but it looks like Stop skipped the current turn and kept going — and the queued text is hard to find, since its only surface is the collapsed 'N queued' pill above the composer. The old userInterruptedRef latch (a23728dcc) fixed this but was removed in #40221 because it also suppressed the drain that send-now-while-busy depends on. This reintroduces the halt with source awareness instead of a blanket latch: - Explicit halts (Stop button, composer Esc, chat-focus Esc, the streaming message's hover Stop, runtime cancel) park the session's queue before interrupting. Parked queues are skipped by both auto-drain paths (mounted ChatBar + background drainer). - Interrupts that exist to advance the queue (send-now-while-busy) unpark first, so the settle drain they rely on still flows. - The park lifts on any renewed intent: resume, a manual drain (Enter on empty composer or the per-row send arrow), queueing a new prompt, or emptying the queue. It migrates with entries on a runtime re-key and is deliberately not persisted (a fresh process starts unparked). - The queue panel expands on park, switches to 'N Queued — paused' with a pause icon, and grows a Resume action, so the held prompts are visible instead of reading as vanished. Store contract, hook wiring, and background-drain coverage included; docs updated. * fix(cli,tui): recall real paste content on up-arrow Large pastes collapse to a placeholder in the composer, but input history stored the placeholder — so up-arrow recall showed a truncated reference (CLI) or lost the content entirely (TUI, where the `[[…]]` label has no backing snip after submit). Store the expanded content in history instead: - CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer before `reset(append_to_history=True)`; also reused by the external editor (dedup). History nav suppresses re-collapse of recalled content. - TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent on label-free text so re-submitting a recalled entry stays stable. * fix(cli): suppress CPR on POSIX local TTYs under load Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent load. Suppress CPR on non-Windows platforms (layout hint only); keep native Windows on prompt_toolkit's default pending native coverage. Wire selection through _select_classic_cli_pt_output. * test(cli): prove local CPR leak and Application CPR-disabled wiring Add a delayed-CPR PTY harness (no SSH) plus selection/Application assertions for POSIX local and Windows preserve-default. Update the gating unit test to the new contract. * refactor: drop platform kwarg, fix PTY test cleanup - Remove redundant platform= test seam from _terminal_may_leak_cpr(); use monkeypatch.setattr(sys, 'platform', ...) consistently in both test files. - Wrap PTY tests in try/finally for fd cleanup on assertion failure. - Guard select.select() in terminal thread against OSError after fd close (fixes PytestUnhandledThreadExceptionWarning). - Trim PR-number reference from test module docstring. * docs(portal): remove retired Nous Chat references * fix(web/ddgs): isolate DuckDuckGo search in a disposable process ThreadPoolExecutor timeouts cannot fire when primp holds the GIL in native code (#68096). Run each search in a child process the parent can terminate/kill, and honor tools.interrupt between polls. * test(web/ddgs): cover GIL-hold timeout, interrupt, and worker reap Regression tests for #68096: native GIL-hold and sleep hooks must time out or interrupt promptly with no orphaned search workers. * fix: sanitize subprocess env for DDGS worker os.environ.copy() passes all Hermes secrets (gateway tokens, API keys, dashboard session tokens) into the DDGS child process. Use _sanitize_subprocess_env() to strip Hermes-managed secrets before spawning the worker. * fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196) The legacy rotation branch in agent/conversation_compression.py flushes the current turn to the OLD session before ending it (#47202) via _flush_messages_to_session_db(messages) with no conversation_history boundary. On the first turn after a cold Desktop resume, the restored transcript rows live in the message list as plain dicts that have not yet been stamped with _DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after preflight compression. With no boundary, _flush_messages_to_session_db builds an empty history_ids set and treats every restored row as new, durably re-appending the whole transcript to the parent session. Repeated restart/resume + threshold compression keeps growing the parent transcript. Pass messages[:_persist_user_message_idx] (the already-durable prefix that turn_context anchors before preflight runs, guarded for int/bounds) as conversation_history so the flush skips the persisted rows by identity and writes only the current turn's new messages. Adds a regression test that pre-populates SQLite, cold-loads the transcript, appends one current user row, and forces rotating compression: it fails before this change (parent grows to 5 rows) and passes after (parent holds the two originals plus the single new turn). * fix(desktop): prevent contentEditable composer input from visually collapsing to near-zero height Fix #68095 The composer input box (contentEditable div) randomly shrank to a tiny/pixelated size when typing character-by-character (paste worked fine). Root cause: during per-keystroke input, the normalizeComposerEditorDom cleanup could briefly leave the contentEditable with zero child nodes, and without intrinsic content the browser collapsed it visually despite the CSS min-height. Two-pronged fix: 1. Add min-h-[1.625rem] bracket syntax alongside the CSS variable min-height to ensure the minimum height is enforced even if the CSS variable resolution is delayed or overridden by browser defaults. 2. In normalizeComposerEditorDom, ensure the contentEditable always has at least one <br> child when empty, giving it intrinsic height that the browser cannot collapse. This is a belt-and-suspenders approach with the CSS min-height. Closes #68095 * fix(agent): circuit-break AttributeError from commit-splice and detect code skew Fix #68178 The git-install auto-updater rewrites source while the desktop backend is live. Because agent/conversation_loop.py is imported lazily on the first API call, a process can end up running two different commits spliced together — one commit's AIAgent against another commit's conversation_loop. When the interface differs, every turn fails permanently with an AttributeError, and the loop retries indefinitely, burning provider API calls (576 failures, 149 wasted API calls observed). Three-prong fix: 1. Circuit-break AttributeError on agent objects: the outer-loop error classifier now detects AttributeError targeting agent/run_agent modules and breaks immediately instead of continuing the retry loop. 2. Code skew detection for desktop/serve backend: run_agent.py now snapshots the checkout revision at import time and exposes a cheap per-iteration check that the conversation loop uses to refuse new work with a clear 'restart required' message before the lazy import can crash. 3. Informative error message: when code skew is detected, the user gets a clear explanation of the mismatch (boot revision vs current revision) and actionable guidance to restart the application. * fix(telegram): preserve fatal recovery handoff Release the current polling-recovery task's ownership before invoking the fatal-error handler. The runner bounds adapter cleanup in a child task; disconnect() cancels the tracked polling-recovery task, so retaining the current notifier in _polling_error_task would cancel the fatal callback before the runner can finish its reconnect-queue or shutdown decision. The new _handoff_polling_fatal_error() helper clears _polling_error_task only when it is the current notifier. Other recovery tasks remain tracked and are still cancelled and awaited during teardown. Covers both network retry exhaustion and polling-conflict exhaustion. Replaces the misleading "Restarting gateway" message with "Escalating to gateway recovery". Fixes #68406. * fix(telegram): widen fatal handoff to heartbeat watchdog path The wedged-recovery heartbeat watchdog (line 2526) calls _notify_fatal_error() directly from the heartbeat task. disconnect() cancels _polling_heartbeat_task unconditionally (no current_task guard, unlike _polling_error_task). Same bug class as #68406: the child disconnect cancels the heartbeat parent before the runner can queue reconnect. Widen _handoff_polling_fatal_error() to also clear _polling_heartbeat_task when it is the current task, and route the heartbeat watchdog call site through the handoff helper. Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com> * fix(tests): make the live-system-guard canary fail closed tests/test_live_system_guard_self_test.py executes real kill primitives (os.kill(-1, SIGTERM), os.killpg, pkill -f python) and depends entirely on the autouse _live_system_guard fixture in tests/conftest.py to intercept them. That makes the canary fail-OPEN: in any collection context where the file is present but its home conftest is not — a published sdist that ships tests/ but not tests/conftest.py, a tree assembled by copying test*.py (that glob does not match conftest.py), pytest --noconftest, or a foreign rootdir — the primitives fire for real, and os.kill(-1, SIGTERM) SIGTERMs every process the invoking user owns (a full desktop-session kill was reported in the field). Add an autouse fixture that refuses to run any canary test unless the guard is provably active. The one thing the canary can detect about its own safety is that the guard monkeypatches os.kill with a plain Python function, whereas the unguarded primitive is a C builtin — so the probe keys off that. Tests marked @pytest.mark.live_system_guard_bypass still opt out, matching the guard's own bypass contract (e.g. test_bypass_marker_disables_guard). With the guard loaded every canary test behaves exactly as before; without it each test refuses at setup with zero side effects. Fixes #68311 * fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355) * fix(billing): rename user-facing "terminal billing" copy to Remote Spending The capability was renamed Remote Spending on the portal (consent CTA: "Allow Remote Spending"; per-terminal states Granted/Stopped), but the terminal, desktop, and docs still said "terminal billing" everywhere. - Feature name: Remote Spending in titles/labels, lowercase mid-sentence. - Step-up action verb is now "allow", matching the portal consent CTA. - Kill-switch-off recovery copy points at the actual control ("a billing admin can turn it on from the portal's Hermes Agent page") instead of the dead-end "manage it on the portal". - Per-terminal revoke copy uses the portal vocabulary ("stopped"). - Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are unchanged; copy, comments, docs, and test expectations only. * fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename Adversarial review findings: (1) a repeated insufficient_scope after a successful step-up is a per-terminal authorization failure, but the copy blamed the org kill-switch and pointed at the wrong recovery control — now: "Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal." (2) the desktop step-up flow started in Remote Spending vocabulary but finished in "billing management access" — renamed both end states. (3) prettier formatting on the touched files (matches the post-merge fmt bot). * feat(tui): show the plan catalog in /subscription on Free (#68357) * feat(tui): show the plan catalog in /subscription on Free The server returns the tier list even with no subscription, but the overlay hid the picker behind can_change_plan && !isFree, so a Free account got only "Start a subscription" with no idea what the plans cost. Now: - Overview on Free offers "Choose a plan" whenever the catalog has enabled paid tiers. - The picker on Free lists each plan as name · price · monthly credits (no upgrade/downgrade hints — there is nothing to move from), and picking one opens the portal, where starting a subscription actually happens (card capture + checkout live there; the upgrade RPC requires an existing subscription). - Paid-plan behavior (preview → confirm → apply) is unchanged. * refactor(tui): compute the picker row suffix once Review feedback: the isFree fork duplicated the label template and run handler; only the suffix differs. * fix(tui): arm the busy guard before the Free portal handoff Adversarial review: the Free branch returned before setting busyRef, so a double-Enter could open the portal twice; and the picker narrated a handoff that openManageLink already narrates (duplicate on success, contradictory on failure). Guard first, let the helper do the talking. * fix(tui): monthly credits are dollars — label them as such The Free picker showed "1000 credits/mo" for what is $1,000 of monthly credit — render "$1,000 credits/mo" (grouped, dollar-signed). * feat(tui): render the Free-plan catalog inline in the /subscription overview Sid ruling: the upsell belongs where the user already is — no intermediate "Choose a plan" hop. On Free the overview lists each paid plan (name · $/mo · $credits/mo) as a pickable row; picking opens the portal (openManageLink narrates). The generic "Start a subscription" row survives only when the catalog is empty. The picker reverts to its original change-only form (Free never reaches it). * feat(desktop): tier catalog chips on the Subscription row Desktop parity with the TUI inline catalog (Sid ruling): accounts that can act see the plans where they already are — Free gets the upsell list (every chip opens the portal), a subscriber sees all tiers with the current one marked inert. Members and team contexts see no chips. Chips learn an optional url (portal handoff) in the shared row model. * chore(tui): fixture harness mirrors the live tier catalog The dev screenshot fixtures showed invented plans ($50 Super / $99 Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200 with $22/$110/$220 monthly credits) so fixture renders cannot be mistaken for product truth. The overlay itself always reads tiers from the subscription API. * chore: trim narration comments * fmt(js): `npm run fix` on merge (#68462) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(relay): attach metadata.user_id on guild replies for egress fallback (#68320) The relay adapter re-attaches an egress discriminator on outbound replies so the connector can resolve the owning tenant. It captured scope_id for scoped (guild) messages and user_id for DMs, but as MUTUALLY EXCLUSIVE: a scoped inbound hit an early return, so the author's user_id was never recorded, and _with_scope only attached user_id when there was no scope_id. Guild replies therefore went out with scope_id only. That's fine while the guild has a provision-time route row. But a MANAGED Discord agent joins guilds dynamically (the shared bot is added to / removed from servers at runtime), and GATEWAY_RELAY_ROUTE_KEYS — the only thing that writes guild route rows — is a self-hosted, static field never stamped for managed agents. So their guild has no route row, the connector's guild-route lookup misses, and with no user_id on the frame there's nothing to fall back to → every guild reply is declined "discord egress declined: target not routed to an onboarded tenant" even though INBOUND resolved the same guild fine (via the author-first SharedSocketRouter.targets() fallback). Fix: capture the authentic author user_id for EVERY inbound (DM and scoped alike) and re-attach it on the outbound reply alongside scope_id. The connector consults it only on a route/scope miss, so carrying both never overrides routing-table resolution. This is the gateway half of the paired gateway-gateway change (makeDiscordTenantOf guild-route-miss author-binding fallback); together they make guild replies resolve the same observed-author way inbound already does. Tests (tests/gateway/relay/test_relay_adapter.py): a guild reply now carries both scope_id AND user_id; a scoped inbound with no author still yields scope_id only (never invents one). Verified fail-without / pass-with. * build: declare pywin32 as a direct win32 dependency hermes_cli/windows_ssh_runtime.py imports win32security/win32file/etc. directly but pywin32 only arrived transitively via concurrent-log-handler -> portalocker. Declare it with a sys_platform gate so the Windows SSH runtime doesn't depend on the logging dep chain. Review follow-up on PR #68130. * fix(desktop): preserve dragging with empty titlebar slots * Revert "fix(agent): circuit-break AttributeError from commit-splice and detect code skew" This reverts commit |
||
|
|
e89216e7f5 | fix(secrets): harden encrypted Bitwarden cache | ||
|
|
a2c2ec6332
|
fix(status): make gateway_updated_at a stable RFC3339-or-null contract (#68657)
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. |
||
|
|
8fd5b25898
|
feat(dashboard): component-level health rollup on /api/status (#68662)
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.
|
||
|
|
87e31cb7d1 | fix(dashboard): cap gateway health probe timeout | ||
|
|
ad235d95a7 |
fix(web): guard _serve_index against a missing or unreadable index.html
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. |
||
|
|
578374675e |
Merge origin/main into feat/desktop-remote-ssh-current
Sync with main after the contribution-shell refactor (#60638) and the backendConnectionState extraction (#65885) landed. Seven conflicts, all resolved in favor of main's new architecture with the SSH lifecycle ported on top: - electron/main.ts: adopt backendConnectionState (attempt tokens, attachProcess, clearPromiseForAttempt) as the sole owner of the primary backend; drop the branch's raw hermesProcess/connectionPromise globals and commitConnectionFailure call site. SSH liveness-streak classification, teardown, and the SSH quit path are layered onto the new ownership model; before-quit combines SSH transactional teardown with the Windows sandbox marker (#38216). - desktop-controller.tsx: deleted on main; its SSH beforeConnectionSwitch cleanup (preserved-route fresh draft, overlay return-route reset, project-tree reset, close-all-terminals) moves to contrib/wiring.tsx's useGatewayBoot call. - use-session-actions/index.ts: keep main's onFreshDraftRouteIntent (fires unconditionally); preserveRoute gates only the navigate. - gateway-settings.tsx: keep main's acceptSavedConfig/connectedCloudUrl; every save/sign-in/sign-out success path routes through acceptSavedConfig with the branch's stale-async seq guards intact. - boot-failure-reauth.ts: keep both sshFailureMessage and main's isRemoteReauthFailure formatting. - Both test harnesses take the union of props. Validation: tsc -b clean; desktop suite 1704 passed / 1 skipped; electron SSH/connection modules 225 passed; python SSH + web_server suites 508 passed. |
||
|
|
28028cce55 |
fix(web): clear stale api-alias credential on provider switch in main-model assignment
|
||
|
|
b520f507cc |
fix(dashboard): clear the model mirror when its custom endpoint is deleted
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.
|
||
|
|
6bedec4734 |
fix(dashboard): don't wipe hand-written provider fields on custom-endpoint edit
_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).
|
||
|
|
38a274b297 |
fix(dashboard): let an explicit api_key win over the provider entry's stored one
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
|
||
|
|
a1813c1ef4
|
feat(desktop): inline TTS voice/model settings in the Capabilities tab (#68017)
* 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. |
||
|
|
da519ebc5c |
fix(dashboard): fold one-field mcp category into agent tab
The new top-level mcp: config section surfaces exactly one field (auto_reload_on_config_change) in the dashboard settings schema, which tripped the no-single-field-categories invariant. Merge it into the agent tab like onboarding/computer_use. |
||
|
|
3d97893571
|
feat(desktop): custom endpoint settings (supersedes #42745) (#67759)
* 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> |
||
|
|
786df3ca6c |
fix(cron): resolve provider with the job's effective model; default dashboard cron creates to the backend's own profile
Two follow-ups to the per-job model pin surface (#67472 / #49948 review): - cron/scheduler.py: pass target_model=<effective job model> to resolve_runtime_provider() on the primary path, so providers with model-specific api_mode routing derive the mode from the model the job actually runs (per-job pin > env > config default) instead of the stale persisted default. The auth-fallback path already did this for its fb_model. - hermes_cli/web_server.py: POST /api/cron/jobs (and its sync worker) no longer hardcodes profile="default" when the request carries no profile param. A pool backend scoped to a named profile now resolves its own profile via get_active_profile_name(), so pre-profileScoped desktop clients can't write a named profile's job into ~/.hermes. Unscoped / custom HERMES_HOME keeps the legacy default fallback. Tests: target_model capture test on run_job; two profile-default tests on the create endpoint. |
||
|
|
2ae0d67f63
|
feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482)
* 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)
|
||
|
|
3fc006ebe1 |
fix(web): compute voice provider schema options per-request, align guards with desktop (#40338 follow-up)
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. |
||
|
|
1e17492784 | feat(config): surface custom and plugin voice providers in config schema | ||
|
|
aa1ad32191
|
fix(desktop): Windows browser-setup journey — console flash, idempotent setup, Nous Portal activation (#67473)
* 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.
|
||
|
|
027243eb46
|
fix(credentials): suppress re-seeding when a pool entry is deleted via API (#55217) (#67429) | ||
|
|
5c6499ce4d |
feat: surface all xAI TTS params in desktop GUI config
- Add speed, auto_speech_tags, text_normalization, optimize_streaming_latency, sample_rate, bit_rate to DEFAULT_CONFIG tts.xai block (backend schema source) - Add field labels, descriptions, and section keys in frontend constants.ts for all 7 xAI TTS fields - Update i18n translations (ja, zh, zh-hant) - Fix stale tts.provider options in web_server.py schema overrides (was missing xai, minimax, mistral, gemini, kittentts, piper) |
||
|
|
9a987f142d
|
fix(credentials): unified provider key delete/update across .env, auth.json, config.yaml (#67213)
* fix(env): recognize export-prefixed .env lines in save/remove (#40041) load_env() parses bash-compatible 'export KEY=value' lines (#6659), so a hand-added 'export GITHUB_TOKEN=ghp_...' shows as set (green light) in the desktop Tools & Keys page. But save_env_value/remove_env_value only matched plain 'KEY=' lines: - DELETE /api/env 404'd ('not found in .env') — the token could not be removed through the UI - PUT /api/env appended a SECOND line; a later delete removed the new line while the export line silently resurrected the old value Both writers now match assignments through a shared _env_line_defines_key() helper that understands the export prefix. Commented-out lines are still ignored. Regression tests drive the real dashboard endpoint handlers against a temp HERMES_HOME with runtime-constructed classic-PAT-shaped fixtures, covering save-does-not-500, export-line remove, export-line replace-without-duplicate, and the plain-line path staying intact. Fixes #40041 * fix(credentials): unify provider key delete/update across .env, auth.json, config.yaml (#51071 #59761 #62269) A provider API key can live in three stores at once: ~/.hermes/.env, auth.json credential_pool (env-seeded 'env:<VAR>' entries persisted by the pool loader), and config.yaml mirrors (model.api_key, auxiliary.*.api_key, custom_providers[*].api_key). The desktop/dashboard endpoints and the TUI gateway RPCs only ever mutated .env, so the stores diverged: - #51071/#59761: DELETE /api/env removed the key from .env but left the credential_pool entry (the loader is additive-only and never prunes), so the provider kept appearing in the model picker — surviving restart via the stale pool entry + provider_models_cache.json row. - #62269: PUT /api/env rewrote .env but left the OLD key in config.yaml (model.api_key wins over env at client construction), producing 401s with a key the UI no longer showed. New hermes_cli/credential_lifecycle.py is the single choke point: - remove_provider_env_credential(): clears the .env entry, prunes env:<VAR> pool entries across ALL providers (a shared var like GITHUB_TOKEN can seed several), suppresses the env source so a lingering shell export can't re-seed it (matching 'hermes auth remove' semantics), drops the affected providers' model-cache rows, and scrubs value-matched config.yaml api_key mirrors. Returns 'found' spanning every store so a stale pool-only entry is cleanable through the same delete button. - save_provider_env_credential(): writes .env, rotates any config.yaml mirror that held the PREVIOUS value (value-matched — an unrelated inline key is untouched), and lifts a prior env-source suppression so re-adding behaves like 'hermes auth add'. OAuth preservation: only entries with source == 'env:<VAR>' are pruned. OAuth/device-code/manual/borrowed pool entries and providers.<id> OAuth token blocks are never touched by a key-only delete. (model.disconnect in the TUI gateway still clears OAuth via clear_provider_auth — that surface is a full provider disconnect, which is the documented intent there.) Rerouted call sites: PUT/DELETE /api/env (dashboard + desktop), tui_gateway model.save_key / model.disconnect, save_env_value_secure (TUI/gateway secret capture), and hermes config set/unset for env-shaped keys. E2E tests drive the real endpoint handlers against temp-HERMES_HOME fixtures (.env + auth.json + config.yaml with runtime-constructed fake keys) and assert cross-store consistency after delete/update, pool-reload survival ('restart'), OAuth preservation, models-cache invalidation, and the suppress/unsuppress round-trip. Fixes #51071 Fixes #59761 Fixes #62269 |
||
|
|
833bae3203
|
Merge pull request #67206 from NousResearch/lane/c3-memory-panel
feat(desktop): declarative memory provider panel + built-in fix (salvage #51020, fixes #49513) |
||
|
|
8b6714556b
|
feat(desktop): terminal execution backend picker with health probes in Capabilities (#67203) | ||
|
|
c372c4220b
|
fix(desktop): truthful per-provider readiness in Capabilities (no more false Ready) (#67201)
* 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).
|
||
|
|
40160e2a04 |
perf(desktop): batch sidebar session slices into one profile-DB pass
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"). |
||
|
|
651cff4273
|
fix(desktop): treat built-in memory as built-in in provider panel (#49513)
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. |
||
|
|
c84c0c5277
|
Merge branch 'pr-51020' into lane/c3-memory-panel | ||
|
|
5b44b65887 |
feat(dashboard): schema override for browser.headed toggle
Salvaged from PR #25653 by @Black0Fox0 — the config-key and env-wiring halves of that PR landed via #67018; this carries the surviving dashboard schema override so browser.headed renders as a labeled boolean toggle. Description updated to reflect the merged cleanup-skip behavior. |
||
|
|
bf517f9301
|
fix(dashboard): keep custom themes visible after embedded chat starts (#60601)
* 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. |
||
|
|
5122ddd478
|
fix(cli): pass TUI Python env from dashboard chat (salvage #44797) (#66581)
* fix: pass TUI Python env from dashboard chat * fix: share TUI Python env setup * fix: preserve TUI Python path semantics * chore: map contributor email for releases --------- Co-authored-by: AI on behalf of Álvaro Sánchez-Mariscal <alvaro.sanchez-mariscal@oracle.com> |
||
|
|
4dc2b7be0f | fix(mcp): preserve concurrent OAuth manager refresh | ||
|
|
cf3ae7c59c | fix(mcp): preserve live OAuth state during reauth | ||
|
|
ebd737f4d9 | fix(mcp): close hosted OAuth lifecycle gaps | ||
|
|
6045529724 | fix(mcp): harden hosted OAuth across profiles and clients | ||
|
|
11eaa77daf | fix(mcp): serialize hosted oauth reauthorization | ||
|
|
b09f1ba770 | fix(mcp): reject invalid dashboard oauth callbacks | ||
|
|
05dea7be04 | fix(mcp): complete OAuth through hosted dashboards | ||
|
|
6dcbcd0277
|
refactor(console): remove hosted-context command blocking from Hermes Console (#66144)
The dashboard console previously ran under a 'hosted' context that blocked most commands (auth add, config set model.*, mcp add --command, cron --script, ...) behind an allowlist + line-policy layer. With the full Hermes CLI now built into the dashboard, that policy layer is redundant gatekeeping: the console gets the same command surface everywhere. Removed: - ConsoleContext/contexts plumbing on ConsoleCommand + engine - EXPECTED_HOSTED_PATHS allowlist + _mark_hosted - _enforce_hosted_line_policy + HOSTED_CONFIG_* allow/block tables - _dashboard_console_context() and the context field on the ready frame - hosted-context tests; context badge in HermesConsoleModal Kept (mechanical, not policy): shell-syntax rejection, the interactive/server command blocks (gateway, dashboard, mcp serve, ...), mutating-command confirmations, output caps, and command timeouts. |
||
|
|
bd00212337
|
fix(dashboard): drop _HERMES_GATEWAY when spawning hermes actions (#52482)
The web dashboard runs inside the gateway process, so `os.environ` carries `_HERMES_GATEWAY=1`. `_spawn_hermes_action` spread that into the subprocess env, so a spawned `hermes gateway restart` (dashboard "Enable webhooks", Telegram QR apply) tripped the in-process restart-loop guard and exited 1 — the gateway never restarted, but the dashboard reported `restart_started: true` because it only checks that the spawn succeeded. Scrub `_HERMES_GATEWAY` from the spawned action's env, matching what the gateway's own restart watcher already does (gateway/run.py). Fixes #52470. Adds a test asserting the spawned env drops the loop-guard var while keeping HERMES_NONINTERACTIVE. |
||
|
|
d4c3f98140
|
fix(dashboard): unblock basic auth plugin when setting password interactively (#54489) (#63786)
* fix(dashboard): unblock basic auth plugin during interactive password setup When the dashboard prompts for username/password on a non-loopback bind, also remove the bundled basic provider from plugins.disabled so discover_plugins(force=True) can register it (#54489). * test(dashboard): cover basic auth plugin blocked by plugins.disabled Regression harness for #54489: credentials in config are not enough when the bundled basic provider is on the deny-list. |
||
|
|
921c17af88
|
fix(dashboard): scope chat attach tokens by session (#60745) | ||
|
|
91ed8e4a99
|
Merge pull request #65893 from NousResearch/bb/salvage-63082-sessiondb-offload
fix(dashboard): offload blocking SessionDB handlers (supersedes #63082) |
||
|
|
9cb0c62e65 |
feat(memory): restore the surface=declared routing from main
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.
|
||
|
|
01bab394cd |
fix(dashboard): theme bootstrap emits real bundle CSS vars; canvas rule flows through vars
Review fixes for the inline critical-CSS bootstrap (PR #36024): 1. Variable names now match what the bundle actually consumes. --color-background, --color-midground, --font-sans and --font-base-size appear nowhere in web/src; the real tokens are: --background-base / --midground-base (layerVars(), context.tsx) --theme-font-sans / --theme-base-size (typographyVars(), and index.css html{font-family:var(--theme-font-sans); font-size:var(--theme-base-size)}) 2. Stale-rule bug: the injected html,body rule previously baked in literal hex/font values. Because the <style> block sits after the bundle's <link> at equal specificity and is never removed, switching themes in the picker left the old canvas/font until reload. The rule now references the same CSS variables instead of literals — applyTheme() writes those vars as inline styles on documentElement, which outrank this block in the cascade, so runtime theme switches re-resolve the rule automatically. No frontend change needed. |
||
|
|
72562be961 |
fix(dashboard): inline critical-CSS bootstrap for user themes to mitigate flash
User themes (`~/.hermes/dashboard-themes/*.yaml`) reach the SPA only
after `/api/dashboard/themes` resolves at React mount. The bundle paints
the first frame with the default Hermes Teal canvas — the
`<link rel="stylesheet">` carries `:root{--background-base:#041c1c}`,
the bundled `presets.ts` defines the same surfaces in JS — and then
`ThemeProvider.applyTheme(<user theme>)` flips the inline CSS variables
on `documentElement` once the API response lands. Visible to the user
as a green canvas behind the loading SPA on every reload when the active
theme is non-default.
Built-in themes do not suffer the same effect because their full
definitions ship inside the bundle, so the SPA already has the palette
before first paint.
This patch closes the gap on the backend side: `_serve_index()` injects
a `<style id="hermes-theme-bootstrap">` block inside `<head>` with the
six critical CSS variables (`--background-base`, `--color-background`,
`--midground-base`, `--color-midground`, `--font-sans`,
`--font-base-size`) plus an `html, body` rule painting the body in the
target palette. Because the inline `<style>` follows the bundle's
`<link>` in DOM order and matches the same `:root` specificity, the
later declaration wins the cascade — the static canvas behind the SPA is
already the right colour before any JavaScript runs.
`_render_active_theme_bootstrap_css()` looks up the active theme through
the existing `_discover_user_themes()` helper. No-op for built-in
active themes (empty string returned, no `<style>` injected). No new
API endpoints, no config flags, no frontend changes.
After `ThemeProvider` mounts and `applyTheme()` writes the same
variables as inline styles on `documentElement`, the values match what
the bootstrap block set, so there is no second-paint discrepancy on the
critical CSS variables.
|
||
|
|
e984a61306 |
fix(dashboard): reject port-binding channels on secondary multiplexed profiles
The Channels API (PUT /api/messaging/platforms/{id}) accepted and persisted
enabling a port-binding platform on a secondary profile while
gateway.multiplex_profiles is on — a config the gateway only rejects on its
next start, aborting startup with MultiplexConfigError for every multiplexed
profile.
Validate before any .env/config.yaml write and return 409 for the enable
attempt. Disabling and clearing env stay allowed so an already-invalid
profile can be repaired. The port-binding platform set moves to
gateway/config.py (PORT_BINDING_PLATFORM_VALUES) as the single source of
truth shared by gateway startup validation and the dashboard, so the two
policies cannot drift. Platform config mutations now get a names-only audit
log line.
Fixes #62791
|
||
|
|
ae2175a584 |
feat(serve): add secure Desktop SSH bootstrap contract
Accept descriptor-safe one-shot token files and exact owner nonces, expose an authenticated ownership proof endpoint, and preserve the process-local contract across parser and server startup paths. |
||
|
|
eb6aa03609
|
feat(analytics): record auxiliary model usage per task in session accounting (#65537)
* feat(analytics): record auxiliary model usage per task in session accounting Auxiliary LLM calls (vision, compression, title_generation, web_extract, session_search, ...) discarded their token usage, leaving dashboard analytics blind to aux model spend (issue #23270). - hermes_state.py: session_model_usage gains a task PK dimension (''=main loop) via v22 table-rebuild migration (SQLite can't alter a PK); record_auxiliary_usage() writes per-(model,provider,task) deltas WITHOUT touching sessions counters (gateway overwrites those with absolute main-loop totals — folding aux in would double-count or be clobbered). Aux rows never inherit the session's main-loop route. - agent/aux_accounting.py: ContextVar ambient accounting context (mirrors the portal_tags conversation context); record_aux_usage() normalizes usage via usage_pricing.normalize_usage, estimates cost, and is strictly best-effort. moa_reference/moa_aggregator excluded — conversation_loop already folds MoA usage+cost into the main delta. - agent/auxiliary_client.py: _validate_llm_response is the recording chokepoint — every successful non-streaming aux response passes through it exactly once, sync and async, including fallback paths (model read from the response itself stays accurate across fallbacks). - run_agent.py: run_conversation publishes/resets the accounting context; agent/title_generator.py republishes on its bare thread. - hermes_cli/web_server.py: /api/analytics/usage folds aux rows into by_model (aux-only models finally appear) and adds a by_task summary; /api/analytics/models surfaces aux rows on the Models page. Design per review of PR #62850 by @eeksock (thread-local + separate auxiliary_usage table): rebuilt on ContextVar (async-safe — thread-local cross-attributes concurrent coroutines on one event loop) and the existing session_model_usage table instead of a parallel accounting path, extended beyond vision to every aux task, and wired the analytics endpoints so the dashboard actually shows it. Credit to @eeksock for the approach and @tboatman for the detailed root-cause analysis. * test(moa): match _validate_llm_response mock to new accounting-hint signature * test(aux): accept accounting-hint kwargs in remaining _validate_llm_response mocks |