When a custom/proxy provider serves models whose IDs share the same base
name across vendor prefixes (e.g. vendor_a/deepseek/deepseek-v4-pro vs
vendor_b/deepseek/deepseek-v4-pro), several normalization functions use
split('/').pop() (or split('/')[-1]) which discards all segments except
the last. This causes three user-facing symptoms: (1) clicking one
model selects a different colliding model, (2) configured-model badges
attach to the wrong dropdown entry, and (3) the model-chip label in the
composer bar is truncated to just the base model name.
Root cause: all three callers take only the last slash-segment instead
of stripping only the first (provider) segment and preserving the
remaining vendor hierarchy.
Fix 1 — _findModelInDropdown (static/ui.js): Move the exact string match
before the provider-aware normalized match. Previously, when all models
share the same provider ID (common with LLM proxy setups), the normalized
match returned whichever colliding option appeared first in DOM order,
even though an exact match existed.
Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id
(api/config.py): Replace split('/').pop() / split('/')[-1] with a first-
segment-only strip (regex on frontend, split('/',1) on backend), matching
the strategy already used by _findModelInDropdown's norm lambda. This
prevents multi-slash IDs from colliding in badge assignment and the
configured-entry dedup set. Additionally, strip colon-qualified provider
prefixes (e.g. custom:name/) before the slash strip so badge-key variants
like 'custom:llm-proxy/opencode_go/model' merge correctly with the bare
'opencode_go/model' in the configured section dedup.
Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model
(api/config.py): Same split('/').pop() to first-segment-strip change so
the composer-bar model chip and backend label preserve vendor context
(e.g. shows 'opencode_go/deepseek-v4-pro' instead of 'deepseek-v4-pro').
Verification: 9 new regression tests (test_issue3360) covering exact-
match priority, multi-slash normalization, and backend/frontend parity.
Updated 1 existing test (test_norm_model_id_trailing_empty_guard) that
asserted the old split('/').pop() pattern. All 25 related tests pass.
AI Usage: Gemini (gemini-2.5-pro), via Antigravity IDE, pair-programmed.
(cherry picked from commit a454fecd2b3a83f7da34473c883be069171aeac9)
Nathan screenshot feedback on the Plugins card:
- Open button rendered as a yellow block with INVISIBLE text: --accent-text
resolves to the same gold as --accent in the default theme (text==bg). Switched
to a ghost/outline button (accent text + border on the card surface; fills on
hover) — always legible regardless of theme.
- Removed the redundant DOUBLE 'Enabled' badge (the dashboard-specific badge
duplicated the generic activation badge; kept the generic one).
- Toggle slider knob was hard to see on the gold 'on' state; added a drop shadow.
Also Opus SHOULD-FIX: _VALID_PLUGIN_TAB_PATH now rejects a leading '//'
(protocol-relative URL → remote origin in iframe.src). Test updated.
Codex CORE (verified live): _PLUGIN_STATIC_ROOTS points at dashboard/ and
serve_plugin_static() served ANY file beneath it — so /dashboard-plugins/<name>/
plugin_api.py leaked plugin backend SOURCE, and manifest.json / .env were
reachable too. Now constrained: served path must be under dist/ or static/, no
dotfiles in any path segment, and a static-extension allowlist (refuses .py/.json/
.env/.toml/.sh etc.). Verified: plugin_api.py, manifest.json, dist/.env,
dist/config.py all 404; dist/app.js still serves. Regression test added.
Codex found two more once the config-guard bug was fixed (Opus concurred on #2):
1. panels.js _buildPluginCard built the Open button + enable toggle with inline
onclick/onchange that interpolated tab.path / plugin.key into a JS-string-in-
attribute context — HTML-escaping is insufficient there (quote breakout).
Now rendered inert + bound via addEventListener with RAW closure values.
2. tab.path was unvalidated. Added _VALID_PLUGIN_TAB_PATH (^/[A-Za-z0-9._~/-]{0,255}$)
in load_plugins() — absolute, no quotes/query/fragment/control chars.
Also Opus nit: deep-merge now coerces dashboard_plugins values to bool + str keys.
Regression tests added for both.
CRITICAL (Opus HALT on prior commit): the PR's edit to save_settings() replaced
'if k in _SETTINGS_ALLOWED_KEYS' with 'if k=="dashboard_plugins": continue' and
orphaned the whole validation body under the continue. Effects: (a) settings save
broken for every key except dashboard_plugins; (b) the allowlist security boundary
gone -> any client key (password_hash, signing_key_*) became settable. Restored the
guard + correct indentation; dashboard_plugins handled by the deep-merge above.
Verified in-process: language persists, password_hash/signing_key injection
rejected, dashboard_plugins still deep-merges.
Also (Opus SHOULD-FIX #3): validate plugin name against ^[a-z][a-z0-9_-]{0,63}$
so a manifest name like '../foo' can't make the URL-space ambiguous.
Regression tests added for both (the allowlist bug had ZERO coverage).
Deep-review (Opus MUST-FIX + Codex kick-back) findings, all confirmed with repros:
1. Same-origin XSS via direct nav to a plugin's raw .html/.svg asset: the
/dashboard-plugins/<name>/ route served plugin-controlled HTML with text/html
at the WebUI origin and NO sandbox header (only the in-panel iframe + the
page route were sandboxed). Verified: <script>alert(document.cookie)</script>
in a plugin html ran same-origin. Fix: send 'Content-Security-Policy: sandbox
allow-scripts allow-forms allow-popups' + 'X-Content-Type-Options: nosniff'
on the asset response (null-origin, same as the page route).
2. 'Disabled' was UI-only: toggling a plugin off just hid the Open button; its
page + asset URLs kept serving. Fix: new _dashboard_plugin_enabled() gates
BOTH the asset route and the page route server-side (opt-in, default off,
disabled => 404).
3. i18n: panels.js referenced t('plugins_enable_toggle') but the PR defined a
mismatched English-only 'settings_plugins_enable_toggle' (dead key) — toggle
label fell back to literal AND tripped the locale-parity gate. Renamed to
plugins_enable_toggle and added to all 12 locales (zh-Hant gets Traditional
啟用, not Simplified).
- Remove unused imports (importlib, sys in api/plugins.py; pytest in test).
- Two tests asserted 'X or True' (always pass — meaningless). Rewrote them to
actually verify the /plugins/ route allowlists plugin.css + uses relative_to
traversal guard, and that manifest fields are html.escape()'d before IIFE-shell
interpolation. Dropped a dead 'original_plugins' local for a real dict assertion.
- The is_relative_to() check already covers the workspace==target equality case
(is_relative_to(A,A) is True), so the '!= workspace' prefix was redundant.
- Symlink test now asserts a hard 403 (proves the guard fires, not just that
nothing leaked) and cleans up the out-of-workspace escape dir in a finally.
test_sprint1's parse_multipart tests exec() the function's source in an isolated
namespace with only re/email.parser imported; referencing the MAX_UPLOAD_BYTES
module global there NameErrored. Import it locally inside the function (with a
defensive fallback) so the function is self-contained and the isolated-exec
tests pass.
Codex regression-gate findings on the shipped #3104 upload code, each verified
with a repro and fixed:
1. Negative Content-Length bypassed the size cap → unbounded rfile.read(-1).
The per-handler 'content_length > MAX_UPLOAD_BYTES' check is False for a
negative value, so the guard is now centralized in parse_multipart()
(validates [0, MAX_UPLOAD_BYTES]) — protects all four upload handlers.
2. .tar/.tbz2/.txz uploads silently skipped extraction (is_archive suffix set
was narrower than extract_archive's) → now matches.
3. Rejected archives (zip-slip/zip-bomb/corrupt/too-many-members) showed a
misleading 'Uploaded' success toast → workspace.js now surfaces extract_error.
4. An in-workspace symlink subpath let mkdir/writes escape the workspace root →
target_dir is now required to be is_relative_to(workspace) before mkdir.
Regression tests added (negative+oversize CL, .tar extraction, symlink target).
The PR silently removed 'console.error([hermes] boot failed, e)' from the
top-level boot .catch() that exists on master, so caught boot-path failures
(session restore / inflight recovery / gateway startup) would no longer surface
in the console or the browser-smoke gate. Restored to match master.
The 2 endpoint tests passed in isolation but flaked in the full suite: a prior
test can leave is_auth_enabled() True globally, so the no-cookie requests got a
401 before reaching the method/length/voice/rate-limit guards under test. The
autouse fixture now monkeypatches api.auth.is_auth_enabled -> False (the handler
imports it at call time) so the validation-path assertions are deterministic.
Both advisors (Opus MUST-FIX + Codex SHIP-ONLY-WITH-FIXES) caught that the Edge
TTS playback was broken despite the settings selector working:
1. ui.js _playEdgeTts used new Audio('/api/tts?text=...') — a GET — but /api/tts
is POST-only (405) and registered only in handle_post. The per-message speaker
button + auto-read silently failed in edge mode, and the GET leaked message
text into the query string/access log. Rewritten to POST JSON + blob object
URL (mirrors the working boot.js path), and now surfaces server errors
(503 not-installed, 429 rate-limit) via toast instead of silent dead air.
2. boot.js hands-free Edge audio was a local var never assigned to the shared
_playingEdgeAudio handle, so stopTTS() (from _deactivate) couldn't stop it.
Now registered + cleared on end/error.
3. Test isolation: the _tts_limiter function-attribute singleton persisted across
the whole suite, flaking 2 of my endpoint tests in the full run. Converted the
reset to an autouse fixture (before+after each) + unique per-test client IPs.
Maintainer review changes on the Edge TTS PR:
- edge-tts was added as a HARD base requirement, but the /api/tts handler is
designed optional (ImportError returns 503). Moved it out of requirements.txt
with an OPTIONAL comment + install hint; updated the 503 message from the
nonexistent 'see docs' to the actual install command. Keeps minimal base deps.
- PR shipped NO tests for a new auth+rate-limited+allowlisted network endpoint.
Added tests/test_issue2931_edge_tts_endpoint.py covering method (405), missing
text (400), over-length (400), voice allowlist (400), per-client rate limit
(429) — all in-process, no real synthesis/network.
The English-only addition broke the locale-parity tests (es/zh/ja/ru/tr/ko all
enforce full key coverage vs English). Added translated uploading/uploaded to
it/ja/ru/es/de/zh/zh-Hant/pt/ko/fr/tr so every locale covers the new keys.
The PR referenced t('uploading') and t('uploaded') in static/workspace.js with
JS fallbacks but never defined the keys, so test_static_literal_i18n_keys_exist_in_english_locale
(the i18n-key existence gate, also run in CI) went red. Added both to the English
locale (and the Korean block's English-placeholder upload keys for consistency).
Opus review SHOULD-FIX on the upload surface:
- Add _MAX_ARCHIVE_MEMBERS=10000 cap in extract_archive (both zip + tar loops):
a tiny archive with millions of members slips under the byte cap but can
exhaust inodes/fds. Trips before extraction, cleaned up via the existing
rmtree-on-exception. Regression test added.
- Bound the extraction-dir collision-suffix loop (was while-True) to 1000 tries.
Other Opus SHOULD-FIX items (member-count #1 done; #2 done) filed as follow-up
or N/A: same-field multi-file collapse doesn't apply (frontend sends one request
per file); .tar.gz stem cosmetic.
Two issues caught by the PR's own tests against the out-of-process test server:
1. Dedup reporting bug: after a filename collision the file was correctly
written to e.g. report-1.pdf, but the JSON response reported the ORIGINAL
name (safe_name) — now reports dest.name. (Real user-facing bug.)
2. Zip-bomb cap was untestable: the test monkeypatched _MAX_EXTRACTED_BYTES in
the pytest process, which has no effect on the separate server process where
extraction runs. Made the cap env-configurable (HERMES_WEBUI_MAX_EXTRACTED_MB,
read at call time via _max_extracted_bytes(); defaults to 10x upload cap),
set it to 5MB in the conftest server env, and rewrote the test to upload a
compressible archive that genuinely extracts past the cap. Also asserts no
partial extraction dir is left behind.
Plus lint: unused field_name loop var -> _field_name, unused os import in test.
Nathan feedback: the download icon read as 'oddly placed' on dark generated
images. Geometry was already correct (8px inset on the image corner) — the
issue was low contrast: a flat rgba(0,0,0,.55) button blends into a dark image.
Add a subtle 1px white border, soft shadow, and 3px backdrop-blur so the button
reads as a clear chip-on-image regardless of the underlying pixels (the standard
treatment for on-image controls). CSS-only.
The .msg-artifact-image span was stretching wider than the image (inline-block
in a block context filled the line), so the absolutely-positioned download
button (right:8px of the wrapper) floated ~240px to the right of the image.
Add width:fit-content + max-width so the wrapper hugs the image; the button now
correctly overlays the image's top-right corner. line-height:0 removes inline
descender gap.
Nathan feedback round 2:
- #3220: generated images were rendering at the 120x90 upload-thumbnail size
(too small for the subject of the message). Now render at natural aspect
ratio up to 360px (max-height 360, responsive max-width), lightbox preserved.
- #3223: drop the per-item subtitle/description lines from the session action
menu — show only icon + label (VS Code / browser / ChatGPT pattern). The
description is preserved as a hover tooltip (title=). Makes the menu ~40%
shorter, less crowded, and structurally less prone to viewport clipping.
Opus iter2 SHOULD-FIX (both non-blocking, applied for cleanliness):
- esc() the media_download title/aria-label so a future translator's quote
can't break out of the attribute (defense-in-depth).
- Remove the now-dead media_open key from all 11 locales + the ui.js fallback
dict (the Open button was dropped in the clean-image redesign).
#3220: redesign generated-image rendering from a permanent bordered card
(filename + Open/Download buttons) to a clean inline image with click-to-zoom
lightbox + a hover/focus-revealed Download overlay, matching ChatGPT/Claude/
Gemini. Drops redundant Open (lightbox already covers it). Tests updated.
#3337: fix two-tone code background — Prism's prism-tomorrow theme styled the
parent <pre> gray while the <code> was navy var(--code-bg), so dark theme showed
a gray frame around navy code. Override BOTH .preview-code[class*=language-] and
its <code> to var(--code-bg) (mirrors the chat code-block fix at .msg-body pre).
#3223: fix action-menu clipping — the new Regenerate-title row made the 9-item
menu tall enough to overflow the viewport bottom when opened on a top-anchored
row at short viewports (e.g. 1280x720). _positionSessionActionMenu now clamps
the menu within both viewport edges and caps max-height with scroll when the
menu is taller than the viewport.
Maintainer fix on stage (browser-test catch): Prism.highlightElement()
propagates the language-* class onto the parent <pre>, so previewing a
.css file then a .txt file rendered the plain text with CSS grammar.
Strip any stale language-* from #previewCode before each render and only
call highlightElement when a language was assigned. Adds regression test
(tests/test_issue3337_workspace_preview_highlight.py) and bumps the
_openSessionActionMenu scan window in test_1466 to cover the new
Regenerate-title action lines (#3223).
Closes#3106. Adds /api/session/title/regenerate endpoint + session-action
menu item. Preserves chronology (touch_updated_at=False), guards read-only
and imported sessions, syncs to state.db when Insights sync enabled.
Maintainer refinement (Opus SHOULD-FIX): scope the is_imported guard to the
regenerate action only instead of broadening the shared _isReadOnlySession()
helper, which also gates rename/pin/archive/move/fork. Matches the backend
403 guard. Test updated to assert the scoped shape.
Codex+Opus gate findings on the profile-scoping PR:
1. panels.js: retag S.session.profile on ANY profile switch (was inside the
if(data.default_model) block, so model-less profile switches left a stale chip).
2. sessions.js: project-picker filter now mirrors the server's root-alias
tolerance (default <-> renamed-root) so a server-approved 'default' project
isn't hidden for a renamed-root session.
3. routes.py /api/projects/create: validate the optional client-supplied profile
via _PROFILE_ID_RE before stamping (was trusting raw client input -> could
create hidden cross-profile rows). Updated the PR's string-assertion test.
Supersedes the v0.51.199 proximity-re-pin (#3330) and the #3250 upward-intent
timeout with a sticky-unpin model (ChatGPT/Claude/Codex behavior): scroll up =
stay put until you return to the bottom or click the scroll-to-bottom control.
Reconciled against the shipped #3330 code: removed the now-dead
_recentMessageUpwardIntent reference from the #3319 rAF retry, kept the
load-time -Infinity intent-init fix, kept the pinned-only >500 catch-up.
Co-authored-by: pamnard <pamnard@users.noreply.github.com>
Codex+Opus regression-gate finding: _isRecoveryControlMessage /
_streamRecoveryControlMessage fell back to matching
provider_details_label==='interruption details'. But a GENUINE 'Response
interrupted' card (Stop button, real provider crash) carries that exact label,
so the filter would drop a real user-facing interruption from the transcript on
the next render/restore — the inverse of the #3300 data-loss class. Require the
explicit server-set recovery_control marker; keep only the two fully-anchored
synthetic-text matches for pre-marker backward-compat. Adds a node-driven
regression test (revert-verified) asserting a label-only interruption card and a
real user turn stay visible while marker + strict text are filtered.