Regression test for #131121: after subscribe -> unsubscribe (a row's
mount -> unmount), a second call with the same id must return a fresh
atom — the entry was released instead of being retained for the
window's lifetime — while identity stays stable while subscribed.
Co-authored-by: Yuan Li <dskwelmcy@163.com>
A native/DOM paste on the chat terminal (context-menu paste, middle-click,
or any Ctrl+V route that bypasses the keydown interception) fell through to
xterm's own paste listeners. xterm clears the hidden textarea during the
paste dispatch, but the browser's default action — inserting the pasted
text into that textarea — runs after dispatch, so a stale value survives
and the next typed character is duplicated (#52471: "when" → "whenn").
The host capture-phase paste handler now cancels the native paste for
plain text and delivers it via term.paste() exactly once — the same clean
route the Ctrl/Cmd+V keydown interception already uses. Image pastes keep
their upload path and are never routed as text.
Supersedes #52477 (credit: @liuhao1024 — same interception, extended with
the image route preserved and regression coverage).
Fixes#52471
- PtySession.close and _discard_active_session_file: waive the deliberate
teardown catch-alls with reviewed '# health: allow' comments (BLE001+S110).
- pty_ws was over its main-pinned CC cap: extract the channel-marker prologue
into _pty_channel_marker_state and the keep-alive writer loop into
_pump_keepalive_input.
- Map sinh3543@gmail.com -> sinh2024 (check-attribution, PR #133936).
Regression coverage for #63553: _active_session_file_for_channel
registered channel→tempfile entries that no code path popped, so
pty_active_session_files grew without bound and churned a forgotten
tempfile+inode per reconnect.
Proves the invariant across every session end/forget path:
- keep-alive close (close_all, same path as TTL reap / child EOF)
- repeated same-channel reconnects keep one mapping
- legacy 1:1 pump (no attach token) pops its marker on disconnect
- ?fresh=1 drops the stale breadcrumb yet keeps the mapping so the
next same-channel reconnect resumes the fresh child (the regression
flagged in the #63563 review)
All four are red on 85db7c3a68.
Session import used shallow type casts at the persistence boundary:
float() accepted inf/NaN (a JSON 1e309), int() accepted integers
SQLite cannot bind (outside signed 64-bit range), model_config was
only shape-checked after its intentional decode, and a literal string
carrying the internal \\x00json: content marker was decoded as
structured content on read. Bad values reached sqlite3 binding or
persisted as inf, breaking session APIs with HTTP 500.
- reject non-finite floats in session float/timestamp columns, message
timestamps, and scalar message content, with the field path in the error
- re-check every intentionally-decoded JSON-backed field (model_config,
reasoning_details, codex_* items) for non-finite values
- bound int columns, token_count, and int scalar content to SQLite's
signed 64-bit range; coerce-with-default at the bind site as a backstop
- escape literal \\x00json:-prefixed strings via the JSON wrapper instead
of storing them raw; decoding already fails closed on malformed marker rows
- all rejection stays inside the existing atomic validate-then-write
400 contract; valid exports round-trip unchanged
Fixes#63954
Backend listings now emit broken_link: true placeholder entries with
nullable mtime for dangling symlinks. Update ManagedFileEntry to match,
render broken links with an Unlink icon and a destructive badge, disable
download for them, and keep delete enabled so users can remove the dead
link from the UI.
The dashboard file browser's listing route built entries via a bare
comprehension over os.scandir, calling _managed_file_entry() per entry.
That helper raises HTTP 500 ("Could not stat path") on any OSError from
resolved.stat(), so a single dangling symlink (or an entry removed
between readdir and stat) aborted the whole directory listing with a 500
— e.g. a broken ~/.claude link or Steam's recreated linux32/steam made
the entire Files page unusable (#47154).
Whole-class fix, consolidated from the open contributor PRs:
- _managed_file_entry() classifies a symlink whose target is definitively
missing (FileNotFoundError) before the resolved-target boundary check
and returns a `broken_link: true` placeholder entry instead of 500ing.
The placeholder is only emitted for entries inside the managed root
even when the missing target resolves outside it. Permission and other
OSErrors still surface as 403/500 as before.
- Symlink stat results are captured in one snapshot (no second stat), so
a target disappearing mid-listing cannot flip a result into a 500.
- New skip_missing flag lets the listing scan skip an entry that
vanished between readdir and stat (returns None); the write-result
caller stays strict.
- The listing loop appends placeholders and skips vanished entries, so
one dead entry never loses its siblings. The sibling fs_list route
already tolerated ENOENT; /api/files now agrees.
Reading/downloading a broken link directly still returns 404; 403
boundary and sensitive-path behavior are unchanged.
Fixes#47154
Co-authored-by: gangpangpang <2564186295@qq.com>
Co-authored-by: ous fifty <ousfifty@gmail.com>
Incorporates work by @Lqg97 (PR 47159; co-contributors qinggangli and Shiqian Ma, commit emails not linked to GitHub accounts).
Dashboard-side urllib readers (gateway health probe, ElevenLabs voices)
and self-hosted OIDC httpx readers (discovery, token exchange, revocation)
buffered remote response bodies with no size bound — a hostile or
compromised upstream could exhaust memory. Both halves now read with a
1 MiB hard cap: a shared _read_dashboard_json_response for urllib JSON
reads and a streaming _request_limited_response for IdP endpoints that
prechecks Content-Length and caps the chunked body regardless.
Adapted from #54809 and #55122 (credit @ooiuuii) to current main, where
the token exchange lives in plugins/dashboard_auth/_shared.py and the
health probe in hermes_cli/web_server_gateway.py.
Fixes#54808, Fixes#55121
The Channels 'Test' button stopped at connectivity: a platform whose gateway
authorization posture denies every sender (no allowlists, no allow-all flag, no
pairing grants) still answered '<Platform> is connected.' — a green checkmark
on a bot that ignores everyone (#66030).
When the adapter reports connected, the test result now carries the platform's
access posture for the ACTIVE profile, mirroring the decision ladder of
AuthorizationMixin._principal_authorized:
- open: {PLATFORM}_ALLOW_ALL_USERS / GATEWAY_ALLOW_ALL_USERS
- allowlist: {PLATFORM}_ALLOWED_USERS, GATEWAY_ALLOWED_USERS, the Telegram
group allowlist vars, or a config-enforced dm_policy: allowlist
- pairing: approved pairing grants in the profile's pairing store
- deny_all: nothing configured — with pending pairing requests counted in
the message
The check is profile-aware: under a named profile it reads that profile's
.env/config/pairing store, never the dashboard process's os.environ (which
carries the ROOT install's credentials). ok stays True (the connection is up);
the new auth_posture field lets the UI render yellow instead of green.
Reuses the idea from PR #66055 (authored by @webtecnica) with the evaluator
made profile-aware and extended to cover GATEWAY_ALLOWED_USERS, pairing grants
and adapter/config-enforced allowlists, per its review.
Fixes#66030
include_unconfigured was hardcoded True in _handle_model_options, so external
API consumers had no way to request the explicit-only view. Forward the
include_unconfigured query param (default true, back-compatible) to
build_model_options_payload. With _append_unconfigured_rows now honoring
model_catalog.excluded_providers, this endpoint no longer resurrects excluded
providers on any view (#68816).
Also lifts model_options decoding (_request_reasoning_config /
_request_service_tier) into gateway/platforms/api_server_request_options.py to
offset api_server.py's frozen line cap (moved code keeps its cap; the facade
re-exports both names).
build_models_payload(include_unconfigured=True) — the path the in-session TUI
/model command uses — appended CANONICAL_PROVIDERS skeleton rows via
_append_unconfigured_rows() without consulting model_catalog.excluded_providers.
So every excluded provider reappeared in the TUI picker even though hermes model
(list_authenticated_providers) hid it correctly.
Filter excluded providers in the skeleton loop, normalized the same way
list_authenticated_providers does ({str(p).strip().lower()}), and exclude
unconditionally — including the configured-current provider — to match that
path, which drops an excluded provider even when it is current.
Adds include_unconfigured-path coverage: excluded provider removed (others
intact), case-insensitive match, current-excluded-provider dropped, and
empty/None excluded is a no-op.
Fixes#68816
POST /api/model/set re-registered a named custom_providers row for any
custom/local main assignment, even when the endpoint was already a fact
in config.yaml (the CLI gateway setup writes provider: custom with an
inline base_url + api_mode). The picker then rewrote the CLI's shape into
custom:<slug> with base_url: '' (#76324).
Snapshot the pre-save config and skip registration when the endpoint is
already known (inline model.base_url, a providers: entry, or a
custom_providers row); only a genuinely new endpoint gets a named row.
Fixes#76324
The search endpoint composed only search_sessions_by_id (id/lineage LIKE)
with search_messages (FTS5 over message content), so a term that lived
solely in a manually-set sessions.title returned zero results — renamed
sessions were invisible to search (#66242).
The DB layer already knew how to LIKE-match titles across the whole
compression chain: list_sessions_rich(search_query=...) via
_chain_search_where (the same helper the sidebar listing uses). Reuse it
rather than adding a second title query path: after the id and FTS lanes,
backfill remaining result slots through list_sessions_rich, feeding rows
through the same add_lineage_result dedup path so lineage, limits, and
result shape stay one contract (content hits keep BM25 priority; the
title lane is best-effort on top).
Fixes#66242
CronJobCreate did not declare a repeat field, so Pydantic dropped it during
request parsing, and _create_cron_job_sync never forwarded one to
cron.jobs.create_job — a finite repeat count submitted via POST /api/cron/jobs
was silently stored as an unlimited recurring job.
- Add optional repeat field to CronJobCreate (int or CLI string form)
- Forward it from _create_cron_job_sync; non-positive counts 400, string
forms coerce through the shared normalize_repeat_value the CLI uses
- Add repeat?: number to the desktop CronJobCreatePayload type
- Route-level regression coverage: finite survives create+list, omission
stays unlimited, invalid counts 4xx with no job stored
The chat terminal loaded @xterm/addon-webgl for every wide host with
nothing but a constructor try/catch. Three failure classes escaped it:
- Safari's WebGL atlas garbles Unicode box-drawing glyphs (#18773)
- software rasterizers (llvmpipe/SwiftShader) advertise WebGL but regl
throws '(regl) webgl not supported' from inside the addon (#45520)
- the per-glyph atlas cannot shape complex scripts, so Bengali
conjuncts render broken (#58685)
Gate the addon behind a pre-flight probe (real context + non-software
unmasked renderer, released immediately) and a Safari UA check, both in
a new pure module; when shaped-script text arrives over the PTY, drop
the live addon so xterm re-renders through the DOM renderer, whose text
shaping handles conjuncts. Context loss already disposed the addon.
The picker door (openSessionFromPicker — /resume overlay, ⌘K, artifacts)
only ran openSession, whose in-place path treats the already-active
session as a front-only no-op. The sidebar's own door always queues an
explicit requestSessionResume, which use-route-resume honours via its
explicitlyRequested branch to re-run resumeSession even when the session
is already active. Give the picker the same explicit reselect so clicking
the active session re-attaches the transcript and re-focuses the composer
(#62045) instead of doing nothing.
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