POST /api/model/set re-registered a named custom_providers row for any
custom/local main assignment, even when the endpoint was already a fact
in config.yaml (the CLI gateway setup writes provider: custom with an
inline base_url + api_mode). The picker then rewrote the CLI's shape into
custom:<slug> with base_url: '' (#76324).
Snapshot the pre-save config and skip registration when the endpoint is
already known (inline model.base_url, a providers: entry, or a
custom_providers row); only a genuinely new endpoint gets a named row.
Fixes#76324
The search endpoint composed only search_sessions_by_id (id/lineage LIKE)
with search_messages (FTS5 over message content), so a term that lived
solely in a manually-set sessions.title returned zero results — renamed
sessions were invisible to search (#66242).
The DB layer already knew how to LIKE-match titles across the whole
compression chain: list_sessions_rich(search_query=...) via
_chain_search_where (the same helper the sidebar listing uses). Reuse it
rather than adding a second title query path: after the id and FTS lanes,
backfill remaining result slots through list_sessions_rich, feeding rows
through the same add_lineage_result dedup path so lineage, limits, and
result shape stay one contract (content hits keep BM25 priority; the
title lane is best-effort on top).
Fixes#66242
CronJobCreate did not declare a repeat field, so Pydantic dropped it during
request parsing, and _create_cron_job_sync never forwarded one to
cron.jobs.create_job — a finite repeat count submitted via POST /api/cron/jobs
was silently stored as an unlimited recurring job.
- Add optional repeat field to CronJobCreate (int or CLI string form)
- Forward it from _create_cron_job_sync; non-positive counts 400, string
forms coerce through the shared normalize_repeat_value the CLI uses
- Add repeat?: number to the desktop CronJobCreatePayload type
- Route-level regression coverage: finite survives create+list, omission
stays unlimited, invalid counts 4xx with no job stored
The chat terminal loaded @xterm/addon-webgl for every wide host with
nothing but a constructor try/catch. Three failure classes escaped it:
- Safari's WebGL atlas garbles Unicode box-drawing glyphs (#18773)
- software rasterizers (llvmpipe/SwiftShader) advertise WebGL but regl
throws '(regl) webgl not supported' from inside the addon (#45520)
- the per-glyph atlas cannot shape complex scripts, so Bengali
conjuncts render broken (#58685)
Gate the addon behind a pre-flight probe (real context + non-software
unmasked renderer, released immediately) and a Safari UA check, both in
a new pure module; when shaped-script text arrives over the PTY, drop
the live addon so xterm re-renders through the DOM renderer, whose text
shaping handles conjuncts. Context loss already disposed the addon.
The picker door (openSessionFromPicker — /resume overlay, ⌘K, artifacts)
only ran openSession, whose in-place path treats the already-active
session as a front-only no-op. The sidebar's own door always queues an
explicit requestSessionResume, which use-route-resume honours via its
explicitlyRequested branch to re-run resumeSession even when the session
is already active. Give the picker the same explicit reselect so clicking
the active session re-attaches the transcript and re-focuses the composer
(#62045) instead of doing nothing.
The marker probe (~8.3k chars encoded) was the second Windows SSH
command still over cmd.exe's 8191-char limit after the platform probe
moved to stdin: it died with 'The command line is too long' and the
relaunch gate could never observe CLEAR. Ride the same stdin transport
(powerShellStdinCommand) instead of splitting the probe, keeping the
C# no-follow marker reader and its fail-closed semantics intact, and
assert every emitted Windows probe command stays under the limit.
Co-authored-by: nikkoxgonzales <nikkoxgonzales@users.noreply.github.com>
Fixes#106716
Refs #106903
Avoid the Windows cmd.exe command-line limit by sending the PowerShell platform probe over SSH stdin instead of embedding a long encoded command. Keep probe statements newline-separated so PowerShell try/catch blocks parse correctly, and cover the real PowerShell path with regression tests.
Dashboard sessions endpoints treated compression-continuation children as
independent rows in two places:
- /api/sessions/stats counted every physical row, so total/active_store/
archived were inflated by the hidden chain links while the paired
list_sessions_rich page collapses each chain to one row (#54298).
- The bulk and single delete endpoints removed only the selected physical
row. A list row carries the chain tip's id, so the root survived and the
next list reload re-projected it onto the previous link — deleted
conversations reappeared (#57543).
delete_session/delete_sessions gain include_compression_chain (opt-in; the
dashboard endpoints pass True). Lineage expansion walks compression edges in
both directions with the same edge definition as get_compression_tip and the
list-projection CTE (branch/reset/delegate/tool children excluded), collects
stale sibling continuations, threads the write-guard pass through the
expanded lineage, sweeps transcript files of every chain member, and keeps
the returned count equal to the selected rows. The default stays chain-blind
for existing callers (CLI /exit --delete, ACP, gateway API).
Fixes#54298, Fixes#57543
Salvaged from #54300 (stats exclude_children; @liuhao1024) and #57996
(lineage expansion; @draix), adapted onto the web_routers split and the
guard-aware delete paths.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: draix <draixagent@gmail.com>
Two skills sharing a frontmatter display name must not resolve by that
name in skill_manage (silently mutating the wrong skill); a unique
display name resolves and the directory names keep working. Mirrors
skill_view's same-tier collision refusal semantics in the manager.
skills_list displays a skill's frontmatter name: when it diverges from
the directory name, every mutating skill_manage action (and the
/skills approve replay) called with that displayed name failed with a
misleading 'not found in active profile' error — nothing about profiles
was actually wrong (#99609).
_find_skill now falls back to matching the SKILL.md frontmatter name
after the directory-name/categorized scan finds nothing, with the same
MAX_NAME_LENGTH truncation skills_list applies when displaying it.
Directory-name matches keep priority (recorded only, returned after the
full scan), so one skill's frontmatter cannot shadow another skill's
directory. This also makes _create_skill's duplicate check catch a new
directory name colliding with an existing skill's displayed name — a
collision skills_list's seen-names dedup would silently hide.
ChatPage.tsx crossed its FILE_LINES cap and the ChatPage function its
FUNC_LINES cap once the #36784 accessibility lines landed. Move the
module-level theme/font-tier helpers to web/src/lib/terminal-appearance.ts
(the ratchet's sanctioned remedy — moved code keeps its cap) and tighten
the new comments. No behaviour change: same helpers, same values.
Replace the static source-text assertions with behavioural tests that
render ChatPage against a capturing FakeTerminal and observe what
assistive technology observes: the Terminal is constructed with
screenReaderMode: true, the host div is a labelled role=region, and the
single registered key handler lets Tab escape (WCAG 2.1.2) while other
keys still reach the terminal. Proven RED on base, GREEN with the fix.
Teknium's review of the original PR flagged that the new
`term.attachCustomKeyEventHandler` at line 540 silently overwrote
the clipboard handler at line 464 — xterm.js stores a single
`_customKeyEventHandler` per Terminal
(node_modules/@xterm/xterm/src/browser/CoreBrowserTerminal.ts:914,
`this._customKeyEventHandler = customKeyEventHandler;`) and assigns
overwrites later registrations, so the Tab escape branch never
executed.
The rework folds the Tab branch into the existing handler at the
top (right after the type guard) so only one handler is registered.
The ARIA region and screenReaderMode additions remain untouched.
Adds web/src/pages/ChatPage.a11y.test.ts — regression test asserting
that `screenReaderMode: true` is set, the host div carries
`role="region"` + `aria-label=`, exactly one
`term.attachCustomKeyEventHandler(` call exists, and the single
handler contains the Tab-escape branch + keydown guard. Verified the
test fails on the original PR diff (catches the regression) and
passes on the rework.
Closes inline review thread at #59091.
The web UI chat composer uses xterm.js, which renders to a canvas
invisible to VoiceOver/NVDA. Add screenReaderMode: true to create
the offscreen textarea screen readers need, a Tab escape handler to
prevent keyboard traps (WCAG 2.1.2), and an ARIA region label on
the host div.
The channel-override baseline from #131295 skipped the session /model tier and
normalized the expected model with the agent's current provider. Both disagree with
how the agent was built, which turned three cases main handled into wrong verdicts:
- a recorded Nous switch off a session /model choice was evicted (the baseline was
the channel model, so the (alias, backing) record never matched);
- a session-primary fallback that landed on the channel's model was kept;
- an OpenRouter primary that fell back to native Anthropic was kept, because the
channel slug normalized under the fallback provider equals the fallback model.
Resolve the baseline in the same order as _resolve_session_agent_runtime (session
/model, channel_overrides, global) and normalize it for the agent's primary provider,
the same route the welcome-host pin already uses. A session override whose provider
was unavailable this turn ran on the default route (the resolver drops it for that
turn and retries next turn), so it is the baseline only when its provider is the
agent's primary provider.
A model-name comparison cannot see a fallback that serves the configured model on
another endpoint; with a channel model that differs from the global one, #131295
turned that case from evicted into kept. A turn whose agent has
_provider_fallback_active (set only by _try_activate_fallback, cleared by
restore_primary_runtime) now evicts before any name comparison.
The baseline lives in GatewayAgentCacheMixin._fallback_baseline_model beside
_is_intentional_model_switch, which keeps gateway/run_turn.py under its code-health
line cap (the channel lookup from #131295 had grown it past main).
Session tier taken from #132238; both regressions diagnosed in review by @andrexibiza.
Co-authored-by: salch-cred <141555468+salch-cred@users.noreply.github.com>
Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Review fix-up for #131295. The committed cases call _run_agent_evict_on_fallback
directly with a SimpleNamespace and a mocked _evict_cached_agent, so they prove the
classifier's verdict but not the cache/pin lifecycle the incident is about. This drives
the real handler -> _run_agent -> TurnRunner.run_sync path (only the AIAgent class and
config reads are substituted) through human -> internal wake -> human and asserts one
agent instance, the override model in _agent_cache, and a surviving ephemeral pin.
Red/green across the branch's history:
origin/main -> named-channel-model fails (the #131294 eviction)
7a3e9f5 (1st fix) -> welcome-host-pins-over-channel-model fails (the R1 regression)
cdf5e12 (head) -> both pass
Co-authored-by: Guerilla-ops <307102608+Guerilla-ops@users.noreply.github.com>
(cherry picked from commit 46e9f20e8c562dfcddf1c11b4d08874d5cd842a2)
The channel_overrides comparison in _run_agent_evict_on_fallback took the
channel's model as the expected one, but AIAgent.__init__ pins an agent on
the Nous welcome host to nous/welcome (pin_model_for_route) whatever the
chat configured. With a global nous/welcome and a channel override, that
looked like a fallback and evicted the cached agent after every turn
(reported in review by andrexibiza).
Apply the same pin to the expected model, on the agent's primary route
(_primary_runtime) so a fallback that lands on the welcome host still
evicts. The recorded alias->backing switch keeps matching
_is_intentional_model_switch.
(cherry picked from commit cdf5e12666a501eb02940ccab90ea05ce65d0b96)
_run_agent_evict_on_fallback compared the agent's model with the global config model, so a
chat, thread or parent with channel_overrides.<id>.model looked like a fallback after every
successful turn: the cached agent was evicted, rebuilt next turn, and its context pin cleared.
d6c53dcdcb fixed two other causes of the same per-turn eviction.
Compare with the channel's model, resolved through _resolve_model_for_channel with the
thread/parent lookup _resolve_session_agent_runtime uses. Without an override it is the
global model, as before. Tests cover a chat and a thread of an overridden parent, and a
fallback off the channel model still evicts.
(cherry picked from commit 7a3e9f5754bbb2597267ce499eac2658d7c9f17a)
Inline `!`cmd`` expansion in SKILL.md bodies ran for every skill whenever the
global skills.inline_shell flag was on — a community hub skill and a builtin
were expanded identically. The hub's install gate (INSTALL_POLICY) blocks a
community install on the caution/dangerous verdicts the inline-shell DSL scans
as, but --force or a pre-scanner install puts that skill on disk anyway, and
the next view/slash/preload would auto-execute it on the host.
Gate expansion at the single preprocess_skill_content choke point (all three
surfaces route through it): community-trust hub lock entries never expand;
trusted/builtin hub entries and anything without a hub lock entry (bundled-
synced, user-created, project/external) keep the flag's contract. Lock read
failures skip the gate, like every other provenance consumer.
Fixes#63307
The confinement guard reads only env/home state, so the Request the PR
threaded through fs_read_text/fs_download is unused. Make it optional
(request: Request = None keeps FastAPI injection while direct test
callers stay source-compatible) and drop the mandatory argument from
_hosted_fs_read_guard/_managed_files_policy.
The managed-files API (#57505) confines locked-dashboard reads through
_resolve_managed_path, but the file-preview and git-review routes resolve
through unconfined _fs_path / _git_path. On a locked deployment those
routes still returned auth.json / .env / mcp-tokens and any path outside
the managed root (including via git diff --no-index on an absolute file
argument).
Apply the same locked-root and sensitive-path checks to:
- /api/fs/list, /api/fs/read-text, /api/fs/read-data-url
- /api/git/review/diff and /api/git/file-diff (resolved file target)
- /api/git/review/commit-context (caller-supplied repo cwd; this route
returns git diff --cached / git diff HEAD with no per-file arg, so the
cwd itself is the read target)
Local dashboards with no locked root are unchanged. Regression tests cover
credential and outside-root blocks plus in-root allow paths, including a
locked-mode outside-root case for commit-context.
(cherry picked from commit b6ed59823f22fc537f123407a01915135dd6d5fe)
Every root-absolute URL the server-rendered auth pages emit now honours
X-Forwarded-Prefix: @font-face url('/fonts/...') in both the login page
and the RFC 8252 native provider chooser, the chooser's data-prefix
injection, and the SPA logout navigation target. Empty-prefix output is
byte-identical to before.
When the dashboard is mounted at a sub-path via X-Forwarded-Prefix,
auth_callback and auth_password_login returned bare "/" as the
post-login landing URL instead of prepending the prefix. This caused
the browser to navigate outside the proxy mount after a successful
login.
Add _externalize_post_login_target() helper that prepends the prefix
to the validated landing path, and apply it in both auth_callback and
auth_password_login. Paths that already carry the prefix are returned
unchanged; empty prefix (direct deploy) is a no-op.
Reported-by: LeDominik
When the dashboard is behind a reverse proxy at a sub-path (e.g. /hermes),
the post-login redirect next= parameter must include the prefix so the
browser lands at /hermes/sessions instead of bare /sessions.
Auth-route and API-path rejection still operates on the original path
(before prefix) so detection works regardless of the mount point.
Addresses feedback from @grunjol and @LeDominik on #48625.
The logout handler used a hardcoded `/login` path for the post-logout
redirect. When the dashboard is served under a URL prefix (via
X-Forwarded-Prefix), this navigates to the wrong path, breaking the
logout flow for reverse-proxied deployments.
Use `${BASE}/login` to match the prefix-aware pattern already used
by the API calls in the same file.
The login page emitted root-relative URLs (/auth/login, /auth/password-login)
for provider buttons and the password-login JS, breaking the OAuth and
password flows when the dashboard is served behind a reverse proxy at a
sub-path (e.g. /hermes/).
Now render_login_html() accepts a prefix parameter (resolved from the
X-Forwarded-Prefix header via the existing prefix_from_request() helper)
and prepends it to all auth URLs. The password-login JS reads the prefix
from a data-prefix attribute on the <main> element.
Fixes#48623
On Windows, mimetypes.init() reads the registry, where .js/.mjs can be
mapped to text/plain; starlette StaticFiles/FileResponse then serve the
dashboard bundle as text/plain, browsers reject the ESM module script
under strict MIME checking and the SPA stays blank (#28987).
Pin the served extensions (.js/.mjs/.cjs/.css/.json/.map/.svg/.wasm/
.webp/.webmanifest/.woff/.woff2/.ico) with explicit mimetypes.add_type
calls at web_server_dashboard import, so Content-Type is
platform-independent regardless of the host map. Idempotent.
Reuses the approach of closed PRs #28996 (xxxigm) and #29254
(Jiahui-Gu).
app.add_middleware(CORSMiddleware, ...) was registered before the
@app.middleware(http) auth decorators, making CORS the innermost
layer in Starlette's onion model. A cross-origin OPTIONS preflight
hit auth first → 401, and the CORS headers were never sent.
Moving the add_middleware call after all @app.middleware registrations
makes CORS outermost, so preflights are answered before auth runs.
Fixes#59052
Both dashboard-auth JWT verification paths (self-hosted OIDC ID token and the
Nous Portal access token, via the shared verify_jwt) called PyJWT jwt.decode
with the default leeway=0 while requiring iat. Any host whose clock lags the
issuer's — unsynced containers/VMs, NTP jitter — failed login with
ImmatureSignatureError, and the failure was folded into ProviderError, i.e.
HTTP 503 'provider unreachable', discarding already-rotated refresh tokens
and forcing unrecoverable re-logins.
- verify_jwt gains leeway (default 60s, RFC 7519 §4.1.4-4.1.6) and classifies
ImmatureSignatureError as InvalidCodeError (validation failure -> 401 /
refresh) instead of ProviderError/503.
- New parse_leeway helper: fail-closed parsing (unparseable / negative / NaN /
inf -> 0, never widens the window); 0 honours strict mode.
- Self-hosted OIDC: id_token_leeway kwarg + dashboard.oauth.self_hosted.id_token_leeway.
- Nous: token_leeway kwarg + dashboard.oauth.token_leeway.
- No new env vars (config.yaml-only knobs).
Fixes#47815
audit_log() appended with a plain open(path, 'a') and no size cap, so
dashboard-auth.log grew unbounded for the life of the install — the one
log in the auth path outside hermes_logging's rotation.
Add self-contained size-check-and-rename rotation in audit.py matching
the agent.log convention (maxBytes=5MB, backupCount=3, stdlib
.1/.2/.3 naming). The pending record's UTF-8 bytes count toward the
threshold, so a crossing write rotates before appending. Implemented
locally (stdlib os.replace only) to keep the module import-light for
early-loading middleware; reusing _ManagedRotatingFileHandler would
pull in hermes_constants and break that guarantee.
Fixes#57749
The smoke driver created .desktop-smoke-home inside the predicted Hermes
home before checking that the home was empty. Bundles with stamped env
that retained the explicit HERMES_HOME failed before Electron launched.
Resolve and admit the predicted home before any sandbox writes. Keep the
existing-profile refusal and provider seeding intact. Extend the seeding
contract for onboarding defaults and forced home clears.
Verified the old failure and fixed launch boundary on native Windows ARM64
with real driver imports and filesystem writes. Full package install/chat
acceptance still needs a fresh hosted smoke run.
Select the expected archive and embedded app version from the admitted
channel request, or the release tag for tagged builds. Channel builds
have no tag, so indexing it crashed before macOS packages could stage.
Keep filename, identity and provenance validation intact.
Regression coverage reproduces the crash on both architectures and checks
that wrong filenames, embedded versions and commits remain rejected.
Session ids are only unique within a profile's store, and the keep-alive
registry is process-wide, so matching stranded PTYs on the key's resume
segment alone would let a resume in one profile close a detached PTY on the
same session id in another profile — somebody's reconnect window, with
whatever turn it had in flight. The key already carries the profile
segment; the resume arm now requires both to match the requested chat.
The lease-holder arm is unchanged: it reads the lease from the chat's own
home, so it already names the right process.
Found by Copilot's review.
Two gaps kept the targeted release from firing on the reported repro:
- The lease pid is never the bridge's pid. ptyprocess forks the leader
(`node entry.js`); the `tui_gateway` CHILD of that leader is what claims
the single-writer lease, so an equality match on `bridge.pid` matched
nothing in a real deployment (reporter's table: node 840, lease pid 850).
`hosts_pid` now matches the leader itself or any PTY whose leader is an
ancestor of the holder (psutil). A holder with no PTY ancestor — a plain
`hermes` terminal — still closes nothing, so that refusal stands.
- Leases live in the home the chat runs in. A profile-scoped chat's child
claims under the profile's HERMES_HOME, so reading the dashboard's launch
home found no holder. The lookup now takes the child's HERMES_HOME.
The matched sessions are popped under the registry lock and closed outside
it (a close can wait out the helper SIGHUP grace and the lock serializes
every new chat), still before the spawn so the lease is released first —
the close discipline main's teardown series adopted, carried from #131179.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
New chat rotates the browser's attach token, so the chat it left behind
stays registered under the previous token while its TUI child keeps the
single-writer lease on that session. close_other_sessions() is keyed on
the incoming token and never sees it; returning to the chat spawns a
second child that single-writer ownership refuses.
PtySessionRegistry.close_orphaned_sessions(resume, keep_key, holder_pid)
closes the stranded keep-alive PTY before the spawn: match by the key's
resume segment, or by the process holding the session's lease (a fresh
chat's key carries no resume target). A PTY with a live viewer is never
a candidate.
Carried from #131181 (squashed PR delta; original author preserved).
Address @antifragileer's review suggestions on #44498:
1. test_partial_write_does_not_mutate_existing_list: pins that a failed
write with mixed valid/invalid items leaves the prior list untouched
(acceptance criterion #3 from #44496).
2. test_merge_new_item_rejects_whitespace_content: verifies whitespace-only
content is rejected for new items in merge mode, guarding against a
future code change that special-cases merge.