#4022 now calls resolve_runtime_provider(requested=..., target_model=...) so
per-model base_url normalization applies; the sprint42 test pinned the old
single-arg signature. Updated the assertion to expect target_model (pre-existing
test, not a behavior regression — confirmed it passes on master only because the
call signature changed here).
Codex caught a regression: the contributor's _runtime_preferred_base_url preferred
the runtime base_url for ALL non-custom providers, which would clobber an explicit
providers.<id>.base_url override pointing at a different host/port (LM Studio at a
LAN IP, an OpenRouter mirror). Now only prefer the runtime URL when it's the SAME
scheme+host+port as the configured one (the #3895 /v1-dedup case is path-only on
the same host); a genuinely different configured endpoint wins. Added
_same_base_url_endpoint() + 2 regression tests (different-endpoint preserved,
same-endpoint normalization).
Opus independent security review concurred SAFE and surfaced 2 LOW defense-in-depth
items, both applied: (1) verify_profile_cookie_value now validates the profile name
against _PROFILE_ID_RE itself (not only in get_profile_cookie) so a future second
caller can't return an unvalidated name; (2) build_profile_cookie raises when auth is
enabled and no handler is passed, so a future call site can't silently emit an
unsigned (session-unbound) profile cookie. +3 regression tests.
The stale-load guard (#3993 Codex race fix) adds an early at the top
of the loadSession catch block; the test helper anchored on the first
after the catch, which now truncated at the guard. Anchor the body
extraction past the guard (from ) so it captures the 404/non-404
self-heal body as before. Behavior unchanged.
currentSid is snapshotted before the awaited /api/session fetch; if the user
clicks a healthy session while a boot-time restore is in flight and that boot
load then fails non-404, _clearStuckSessionOnBoot(sid, null) would wipe the
healthy session's localStorage/URL. Guard the catch block on
_loadingSessionId !== sid (a newer load superseded this one) — re-arm the active
stream and bail before any self-heal/DOM mutation. Protects both the non-404 and
404 inline self-heal paths. + regression test.
Refined the Codex-CORE fix: the happy-path (already-stored session) guard rejects
only an explicit read_only flag — a stored messaging session already owns its
sidecar, so the messaging-fork risk is specific to the materialize FALLBACK
(which creates a sidecar) where the _is_messaging_session_record check stays.
Also completed test_issue1436's _stub_session MagicMock (read_only=False,
_loaded_metadata_only=False) — bare MagicMock auto-attrs were truthy, tripping
the new read-only guard + _ensure_full_session_before_mutation reload.
Codex caught two data-integrity gaps in the contributor's guard: (1) the
get_session() happy path returned a stored session without checking read_only/
messaging, so an already-imported read-only session could be mutated via
rename/update/move; (2) the fallback only checked cli_meta.read_only, but agent
rows normalize messaging sources WITHOUT setting read_only — materializing a
writable sidecar for a state.db-owned messaging session forks its title/state.
Now reject getattr(s,'read_only') OR _is_messaging_session_record on the happy
path, and cli_meta.read_only OR _is_messaging_session_record(cli_meta) in the
fallback. Replaced the messaging-stub test with 3 regression tests (stored
read-only, stored messaging, messaging cli_meta without read_only flag).
If startSessionStream(sid) runs while the tab is ALREADY hidden (session loaded/
restored in a background tab), it set _sessionStreamSessionId then returned at the
hidden-skip WITHOUT recording _sessionStreamHiddenSid — so the visibility handler
(which reopens only from the holder) never reattached, silently dropping
bg_task_complete/server_turn_started until the next explicit startSessionStream.
Set _sessionStreamHiddenSid = sid on the hidden-skip return. Adds a regression test.
Add HERMES_WEBUI_COOKIE_NAME so multiple WebUI instances sharing a
hostname (different ports) can use distinct auth cookie names. Browsers
scope cookies by host, not host+port (RFC 6265), so same-host instances
otherwise trample each other's `hermes_session` cookie and log users out.
- Resolve the cookie name via _resolve_cookie_name(): env > default,
mirroring the existing _resolve_session_ttl() pattern.
- Keep `hermes_session` as the default for backwards compatibility.
- Validate against the RFC 6265 token grammar; fall back to the default
with a logged warning on empty or malformed values.
- Cover default, override, empty, invalid, and Set-Cookie paths in
tests/test_auth_sessions.py::TestCookieNameResolution.
#3964 [security] gate first-password bootstrap (_set_password on POST /api/settings
while auth disabled) to local clients — blocks remote unauth first-run ownership.
Uses request-start auth snapshot (auth_enabled_before), so no mid-request TOCTOU.
Self-rebased onto v0.51.357 (8-behind, 3-dot fidelity verified byte-identical).
#3970 (oauth single-flight) DROPPED from this stage: Codex+Opus both caught a
check-then-insert race — _pending_oauth_flow_for releases the lock before the
device-code request + flow insertion, so concurrent unauth starts still spawn
multiple workers (Codex empirically reproduced w/ 2 threads). Returned to author
w/ the atomic per-(provider,home) start-lock fix. Re-gating the (N-1) stage.
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
Codex round 2 caught that _structuralCount() only enumerated .tool-call-group/
.tool-card-row/.tool-worklog-group but NOT the live Worklog shell
(.live-worklog[data-live-worklog-shell=1]) or its .wl-reason content (built by
_flushPendingSegmentRender / ensureLiveWorklogContainer, see messages.js:2386 +
ui.js:6712/6655). So a turn whose live-ahead structure is a Worklog (not a bare
tool-call-group) undercounted preserved structure → wrongly took the segment-swap
path → detached the visible live Worklog container for a frame.
Fix: add .live-worklog[data-live-worklog-shell=1] and .wl-reason to the structural
count (mirrors the canonical 'has current worklog content' selector set at
sessions.js:1125-1131). Added a structural-test assertion + verified live: a
preserved turn with a live worklog .wl-reason the rebuild lacks now takes the
whole-turn restore path and the worklog + parser both survive.
Convergence: Codex findings narrowing each round (tie-guard → tail-segment →
worklog-selector), all real, all fixed + empirically verified.
Opus flagged a tool-card-frame-loss edge in the segment-only swap: the premise of
#3877 is that the live DOM can be AHEAD of S.messages, so a tool/worklog group can
land in the live turn between the last throttled persist and a mid-stream rebuild.
The rebuild (built from the lagging S.messages) would then lack that group, and a
segment-only swap would drop it for a frame (empirically confirmed: a live-only
tool card present before the rebuild was absent after).
Fix: count structural blocks ([data-live-assistant=1] + tool/worklog/thinking
groups) on both turns. Segment-swap only when the rebuild is the structural
SUPERSET (rebuilt >= preserved); otherwise restore the WHOLE preserved turn so
live-only structure the user already saw never vanishes. Verified live: tool-card-
ahead → whole-turn restore keeps the card + parser; single-segment tie → segment
swap, parser connected; multi-live-segment tail → tail parser connected, both
segments kept.
Also addresses Opus's dead-code note implicitly — the dataset.sessionId stamp now
matters on the whole-turn restore path (it's the branch that actually inserts the
preserved wrapper).
Codex caught an asymmetry in the first cut: the rebuilt side selected the tail
live segment ([...].pop()) but the preserved side used querySelector() = the
FIRST [data-live-assistant=1] segment. In a multi-live-segment turn (reconnect /
post-tool activity boundaries — see messages.js ensureAssistantRow re-attaching
to the LAST live segment), the smd parser writes into the tail segment, so
swapping the first preserved segment would move the wrong node and leave the
parser-owned tail detached — re-introducing the flicker for multi-segment turns.
Fix: select the preserved segment from querySelectorAll, defaulting to the LAST
(tail), and prefer the one whose data-live-segment-seq matches the rebuilt tail;
compute _preservedLen from that segment. Added a structural regression test
pinning the tail selection.
Verified live: a 2-live-segment turn (seq 1 + seq 2, parser on seq 2) keeps the
parser-owned tail connected across a mid-stream rebuild (was orphaned with the
first-segment selection).
The #3877 fix (#3892) preserves the live assistant turn's DOM node across a
mid-stream renderMessages() rebuild and swaps it back in when the rebuilt turn
has less streamed text than the preserved (smd-parser-referenced) node. But the
guard was strict: _rebuiltLen < _preservedLen. At the throttled session
write-back boundary the rebuilt turn's live segment can carry EXACTLY the same
text length as the preserved node, so the strict guard skipped the swap and left
the streaming parser writing into the now-detached original node — the residual
'disappears, then reappears' frame the reporter still saw on v0.51.347.
Fix:
- Relax the guard to _rebuiltLen <= _preservedLen so the equal-length tie also
restores the parser-referenced node (on a tie the preserved node is strictly
preferable: it holds the live parser reference and nothing is lost). When the
rebuilt turn genuinely has MORE content (reconnect where S.messages caught up
past the parser) the guard still skips and the parser re-resolves to the fuller
node.
- Swap at the SEGMENT level (replace only the rebuilt live segment with the
preserved one) instead of a whole-turn replaceWith, so a multi-segment turn
(earlier settled segments + tool/worklog groups built by the rebuild) keeps
that rebuilt-only structure. Whole-turn replace remains the fallback when the
rebuilt turn has no live segment to target.
Verified live (isolated server on the edited ui.js, real shipped renderMessages):
- single-segment tie: parser node stays connected (was orphaned on master);
- multi-segment tie: parser connected AND both assistant segments preserved;
- rebuilt-longer: correctly keeps the fuller rebuilt node, no content loss.
Updated the #3877 structural tests to pin the <= guard + segment-level swap.
Closes#3877.
- _load_models_cache_from_disk: resolve _get_models_cache_path() once
(was called twice — exists() then open()) (greptile P2).
- conftest: use a _MISSING sentinel for the _active_profile restore guard so
it restores whenever api.profiles was importable pre-test, independent of the
value (greptile P2; _active_profile defaults to 'default' so the prior
is-not-None guard was functionally fine but technically value-dependent).
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Codex + Opus both independently caught a CORE gap in the first cut: the
bounded /api/models rebuild runs on a detached 'models-catalog-rebuild' daemon
thread that inherits neither the request-profile thread-local (#798) nor
os.environ. So on a non-default profile the worker probed the DEFAULT profile's
credentials and, when the 4s budget was exceeded, published the rebuilt catalog
to the DEFAULT profile's disk cache (cross-contamination) — exactly the slow
path a non-default cold rebuild takes.
Fix:
- profile_scope_for_detached_worker(profile_name): sets the request-profile TLS
AND applies the profile .env on the worker thread, restoring both on exit
(no-op for default). Distinct from profile_env_for_active_request (which reads
the current thread's TLS and must not clear it).
- get_available_models() captures the active profile on the request thread and
wraps the rebuild worker body (probe + over-budget publish + disk save +
fingerprint) in that scope; the legacy synchronous rebuild applies the profile
env on the foreground. /api/models route no longer wraps (the work moved into
get_available_models so ALL callers — chat/start, resolution — are fixed).
- 2 new regression tests incl. the worker-thread before/inside/after assertion.
Empirically verified: a fresh worker thread resolved models_cache.json/default
WITHOUT the scope (the bug) and models_cache.work.json + the work .env + the
work auth.json WITH it.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
On a non-default profile, Settings → Providers timed out and the model
picker showed only the default profile's models. WebUI profile switching is
per-client/cookie-scoped (#798), but two read-only paths resolved from the
process-global default profile:
- Facet A: /api/providers + /api/models did not apply the active profile's
.env around the read, so get_auth_status() / provider_model_ids() / custom
key lookups resolved the default profile's credentials. On a non-default
profile the auth probes could stall past the 30s frontend abort.
- Facet B: the /api/models disk cache was a single import-time
STATE_DIR/models_cache.json shared across every profile, while the cache
fingerprint is profile-specific -> a non-default profile rejected the shared
snapshot every read and cold-rebuilt (the slow serial-probe path).
Fix:
- api.profiles.profile_env_for_active_request(): applies the active
per-request profile's .env for the duration of the read (delegates to the
existing profile_env_for_background_worker used by streaming). No-op for the
default/root profile, so single-profile deployments are byte-identical.
- api.config._get_models_cache_path(): profile-keys the disk cache filename
(models_cache.<profile>.json) derived from the default path; default profile
keeps models_cache.json unchanged (no file migration).
- routes.py: wrap both GET handlers in profile_env_for_active_request.
- conftest: restore api.profiles._active_profile + clear request-profile TLS
after each test (a pre-existing isolation hole that profile-keyed cache
paths newly surface under sharding).
Tested: 10 new regression tests; live before/after on isolated servers shows
a non-default profile now surfaces its configured provider (deepseek) with
its own credentials + its own cache file, where master showed only the
default profile's providers and one shared cache.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>