Opus ship-gate non-blocking observations (no MUST-FIX), applied to lower future-maintainer
risk on the crown-jewel profile-switch surface:
- clarified the belt-and-suspenders _sessionListSkeletonActive=false at the real-paint site
- added a FOUR-GUARD CONTRACT doc-comment at the top of switchToProfile naming the four
interacting state pieces (embargo / invalidation / skeleton-flag / _wsTreeGen + _switchGen)
so a future edit touching one without the others doesn't silently reopen a window.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate 5: the only remaining finding — my race-guard work grew switchToProfile
past the 6500-char window (applyBotName now at offset ~6609). Instead of bumping the
window again (it'll drift), bound the slice on the next top-level function
(openProfileCreate) so the test reads the whole body and can't break on growth.
No race/logic findings this pass — the profile-switch races are converged clean.
Two more edge cases from re-gate 4:
(1) SILENT: a mid-switch /api/sessions that FAILS bypassed the embargo (the drop-check
was only on the success path), so its catch cleared the skeleton + rendered old-profile
cache. Added 'if(_profileSwitchListEmbargo) return;' after the gen guard in the catch too.
(2) CORE: the embargo was set BEFORE the try, so a throw in the synchronous setup could
leak it (the guarded finally only covered the try). Moved the invalidate/embargo/skeleton/
ws-gen setup INSIDE the try so the _switchGen-guarded finally always lifts the embargo.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate 3 found a third clobber variant: point-in-time invalidation isn't enough
because a renderSessionList() can START after the skeleton shows but BEFORE
/api/profile/switch returns. Since the new-profile cookie is only set by the switch
response, that GET fetches the OLD profile's rows, passes the (now-current) generation
guard, and clobbers the skeleton.
Fix: a profile-switch session-list EMBARGO. _setProfileSwitchListEmbargo(true) before
showSessionListSkeleton(); _runRenderSessionListRefresh drops ALL payloads (after the
gen guard, before apply) while embargoed; the switch lifts it immediately before its own
authoritative renderSessionList() (both branches), in the failure-restore, and in a
_switchGen-guarded finally as a safety net so it can't freeze the sidebar on an
early-return/throw. + regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate 2 found a remaining CORE clobber variant: a renderSessionList() already
in flight BEFORE the switch (background poll fetching the OLD profile's /api/sessions)
would resolve after the skeleton appears but before the switch's own renderSessionList()
bumped _renderSessionListGen — so it passed the generation guard, cleared the skeleton
flag in _applySessionListPayload, and painted stale rows.
Fix: _invalidateSessionListRenders() bumps _renderSessionListGen + clears
_pendingSessionListPayload / _renderSessionListQueuedRequest, called at switch start
BEFORE showSessionListSkeleton(). Any outstanding pre-switch response is now stale
(its captured _gen < current) and discarded by the existing guard; deferred/queued
applies are dropped. + regression test asserting the helper exists and runs before the
skeleton.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Reworked from the Codex re-gate (rebased onto fresh master so #4730's gateway-poll
work is preserved, not reverted). Two CORE races + a test-window break:
(1) Skeleton-clobber (sessions.js): renderSessionListFromCache() now bails while
_sessionListSkeletonActive — _allSessions still holds the PREVIOUS profile until
/api/sessions resolves, so a gateway-poll/SSE/timer/cache render mid-switch would
repaint the wrong profile's rows over the skeleton. _applySessionListPayload clears
the flag right before painting (fresh data in hand; superseded responses already
discarded by the generation guard); the fetch-failure path and switch-failure catch
clear it too so it can't strand. NO signature change (kept renderSessionListFromCache()
and _applySessionListPayload(sessData,projData) so the ~10 static tests that anchor on
those markers stay green).
(2) Workspace wrong-profile tree (workspace.js + panels.js): an empty-session switch
reuses the same session_id, so loadDir()'s id guard can't reject a stale pre-switch
/api/list. Added _wsTreeGen generation token + bumpWorkspaceTreeGen(), bumped
UNCONDITIONALLY at switch start (even panel-closed, since loadDir('.') still runs),
captured in loadDir() and re-checked after both awaited /api/list points.
(3) test_issue1116 proximity window 5000->6500 (applyBotName now ~char 5618, still fires).
+ regression tests: skeleton-flag bail/clear lifecycle, ws generation token, failure
restore clears flag; updated #1785 stale-session guard test for the compound condition.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex gate found: when window.smd becomes available after a segment already
rendered through the renderMd() fallback, _smdNewParser resets _smdWrittenText
to '' but does NOT clear the element, so the following _smdWrite(displayText)
appends the full accumulated segment on top of the existing fallback render —
duplicating the live assistant text. Clear assistantBody.innerHTML before re-init.
Co-authored-by: akrhin <akrhin@users.noreply.github.com>
The registry-derived scrub union is best-effort and returns empty when
hermes_cli.auth can't be imported (WebUI-only CI/test env). That made
ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN scrubbing depend on the agent being
installed, so the quota-subprocess regression failed on CI (agent absent) while
passing locally (agent present). Add both token names to the fail-closed floor
set so the scrub covers them regardless of the registry import — strictly more
correct (defense-in-depth) and deterministic across environments.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Opus final gate reproduced two issues in the config-expansion hardening:
MUST-FIX — import-order forward reference. _expand_env_vars (config.py top) calls
_thread_local_env_value, but that fn + _thread_ctx were defined ~6900 lines later,
AFTER the import-time reload_config(). A ${VAR} in a startup config.yaml raised a
(swallowed) NameError, leaving import-time config consumers with empty config.
Fix: relocate _thread_ctx + _thread_local_env_value above the config-file section
(they depend only on os + threading). Verified: startup ${VAR} now expands, no
NameError.
SHOULD-FIX — reload_config baked block-scoped expansion into the process-global
_cfg_cache. If a reload fired while a readonly/worker scope was active (profile
alternation resolves _get_config_path to the named profile), under-expanded
literal ${VAR}s were written to the shared cache, starving concurrent readers of
the `cfg` alias. Fix: reload_config now expands the global cache with the
block flag forced off (process-env view), since the cache is process-global;
per-request scoped reads still go through _thread_local_env_value at their own
sites. Verified: global cache holds the process-expanded value, not the literal.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Final credential-surface item: agent/azure_identity_adapter.py treats
IDENTITY_ENDPOINT / MSI_ENDPOINT as ManagedIdentityCredential sources, so an
empty named profile could inherit the host's managed identity on the quota +
detached-worker paths. Add IDENTITY_ENDPOINT / IDENTITY_HEADER / MSI_ENDPOINT /
MSI_SECRET to the non-registry scrub set (config var AZURE_FOUNDRY_BASE_URL kept).
Detached-worker regression extended.
This completes the agent's enumerable credential-env read surface (verified from
source). Decision (with maintainer): stop the Codex deep-gate here — the
remaining theoretical surface is cloud-SDK-internal credential-source endpoints
on pre-existing infra, not vectors introduced by this PR; validated via Opus +
full suite. The PR's #3961 scope (os.environ mirror race on /api/providers +
/api/models) is fully closed plus the broader same-class hardening.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate found a config-template leak vector distinct from the env-name
scrub: _expand_env_vars() expanded ${VAR} references in config values via raw
os.environ, so a profile config.yaml of e.g. `api_key: ${ANTHROPIC_TOKEN}` let
_get_provider_api_key reconstruct the server-process token and pass it through as
the API key argument — even when the child-env scrub stripped the env var. This
bypassed the thread-local channel entirely.
_expand_env_vars now substitutes via _thread_local_env_value (the same
thread-local-first accessor with the block_process_env_fallback guard the PR
introduced): a ${VAR} resolves to the profile's own value, and refuses the
process-env fallback when a profile-scoped readonly/background scope set the block
flag (leaving the reference unexpanded). Cache integrity preserved — only the RAW
unexpanded dict is cached; expansion re-runs per read against the current thread's
scope. Normal (no-scope) behavior unchanged. + regression that fails without the fix.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
The #4720 done-handler comment (9 lines) pushed the tab-anchor localStorage
sync (`hermes-webui-session` setItem + _setActiveSessionUrl) past the 800-char
proximity window that test_session_rotate_url_sync asserts the #3018 session-id
sync lives within — so the PR's `test (3.x, 2)` shards were failing on master's
own regression guard. The full pytest suite caught it; the PR's gate slice did
not run it.
Fix (behavior unchanged — the _oldestIdx reset stays in the same place, before
the re-filter, guarded):
- Trim the inline comment to one line (the full rationale is in the #4720 commit
+ issue), so the completion block stops overflowing the guard window.
- The reset statement legitimately grew the completion block even bare, so widen
test_session_rotate_url_sync's proximity window 800 -> 1000 (still well within
the single done/settled handler; the guard's intent is preserved).
test_session_rotate_url_sync + test_issue4720_* both pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Round-3 gate found two more credential env vars the agent resolves cross-profile
that the scrub set missed, verified against the installed agent source:
- AWS_CONTAINER_CREDENTIALS_FULL_URI — read by model_switch._has_aws_creds:1360
(botocore container-credentials provider; RELATIVE_URI was present, FULL_URI
was not).
- Azure identity family — AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_TENANT_ID
/ AZURE_FEDERATED_TOKEN_FILE (agent/azure_identity_adapter.py service-principal
+ workload-identity model auth) and AZURE_FOUNDRY_API_KEY
(runtime_provider.py).
Enumerated the agent's full credential-env read surface from source to close the
whole class in one pass rather than one var per gate round. Deliberately EXCLUDES
config/region vars (AWS_REGION, AWS_DEFAULT_REGION, AZURE_FOUNDRY_BASE_URL) — the
child probe may legitimately need those. Regressions extended to pin FULL_URI +
the Azure family.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
After a turn completed on a long conversation that loaded truncated, the
viewport jumped to the first message. The done SSE handler replaced S.messages
with the full transcript payload and updated _messagesTruncated, but did NOT
reset _oldestIdx the way the canonical full-load paths do (sessions.js
_ensureMessagesLoaded, ui.js loadSession).
The read-position rework (#4613) keys the scroll anchor on an absolute index
(sessionIdx = _oldestIdx + rawIdx; rows tagged data-session-msg-idx). On done
the handler also expands the render window from the tail window (50) to all
messages, shifting every rawIdx. With _oldestIdx left stale, the captured
anchor resolved to an earlier row and the fallback used the raw scrollTop
against the now-taller full transcript, landing near the first message.
Reset _oldestIdx from d.session._messages_offset||0 alongside _messagesTruncated.
The done payload (_session_payload_with_full_messages) embeds the full transcript
and carries no offset, so this resets to 0 and keeps the absolute anchor aligned
with the rebuilt rows. No-op for non-truncated sessions; pinned-at-bottom readers
still follow to bottom (shouldFollowOnDone path unchanged).
Both gates clean (Codex SAFE TO SHIP, Opus ship-it) — verified the done payload
is always a full transcript with no offset, and every other _oldestIdx consumer
becomes more consistent.
Codex re-gate found the registry-derived scrub still missed credential env vars
the agent resolves via raw os.getenv() that aren't in PROVIDER_REGISTRY:
CUSTOM_API_KEY (hermes_cli/models.py), AWS_BEARER_TOKEN_BEDROCK
(hermes_cli/model_switch.py), AZURE_ANTHROPIC_KEY (runtime_provider.py), and the
boto3 AWS_* credential family (agent/bedrock_adapter.py). Same cross-profile leak
class on custom/AWS-backed provider quota + detached-worker paths.
- Add _NON_REGISTRY_AGENT_CREDENTIAL_ENV_NAMES fail-closed set, union into
_profile_secret_env_names so BOTH leak sites pick it up.
- _account_usage_subprocess_env now relies on the centralized scrub set (WebUI-
only fallback only if the api.profiles import fails), dropping the partial
local AWS pair.
- 2 more regressions (quota subprocess + detached worker) covering CUSTOM_API_KEY
+ AWS family, fail without the fix.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Addresses @rodboev + greptile review of #4671:
- panels.js switchToProfile(): pass timeoutToast:false to the /api/profile/switch
POST so a superseded or transient-but-successful switch no longer pops api()'s
generic 'Request timed out' error toast; the generation-guarded catch handler is
now the single source of truth for switch failure.
- panels.js: re-check _switchGen after the awaited renderSessionList() in the
in-progress-session branch so a rapid second switch can't have its workspace
skeleton cleared / a stale toast popped by a slower earlier switch (mirrors the
no-messages branch guard).
- style.css: group labels now fade in via skeletonFadeInDim (ends at opacity:.5)
instead of the shared skeletonFadeIn (ends at opacity:1 with fill-mode:both),
which was overriding their intended resting 50% opacity.
- 4 new static-source regression assertions locking the three fixes; CHANGELOG
Unreleased entry updated (fixed-shape skeleton note + the three review fixes).
Codex re-gate of #4544 reproduced a residual cross-profile leak: the PR closed
OPENROUTER_API_KEY but ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN still leaked the
server-process credential to an empty named profile on /api/provider/quota
(anthropic) and the detached-worker model-rebuild path, because the scrub set was
built only from the WebUI _PROVIDER_ENV_VAR map (which omits OAuth/token-flow
provider env vars).
Widen the shared _profile_secret_env_names() scrub source to union in the agent
registry's api_key_env_vars (hermes_cli.auth.PROVIDER_REGISTRY) so every
credential env var the agent runtime can resolve via raw os.getenv() is scrubbed
in profile-scoped reads. Fixes both leak sites at once (both route through
_profile_secret_env_names). + 2 regressions that fail without the fix.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Builds on Nathan's switch-failure workspace-skeleton-clear fix (which already
covered Greptile P1). Remaining two:
- [P2] Removed dead _clearSessionListSkeletonFlag() — the flag is reset inline in
renderSessionListFromCache(); the helper had no call site.
- [stagger] The .skeleton-row:nth-child(N) fade-in delays skipped most rows
because group-label siblings are interleaved with rows in the DOM (positions
shift: rows land at child 2, 4-6, 8-11, so 9/10/11 got no delay). Replaced the
CSS :nth-child rules with an inline per-row animationDelay set in JS (rowIndex
* 0.025s, capped 0.2s) so every row gets its correct stagger regardless of
group structure.
Tests: +1 behavioural (every row carries a real inline stagger delay; last row
not skipped). 26 pass.
The success path clears a stranded workspace-tree skeleton when the new
profile has no bound workspace (both switch branches), but the catch block
only restored the tree `if (S.session && S.session.workspace)` — with no
else. So a profile-switch failure while the workspace panel is open and the
(still-current) previous session has no workspace left the up-front skeleton
shimmering forever: the same skeleton-strand class the Codex/Opus gates
targeted, in the one path they didn't cover. Mirror the success-path
no-workspace handling in the failure path. Adds a static regression
assertion that the catch block clears the workspace skeleton.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On long sidebars over the virtualization threshold, showSessionListSkeleton()
cleared the DOM but left the data-session-virtual-* window state + the queued
scroll RAF active. A pending _scheduleSessionVirtualizedRender() could then
repaint the PREVIOUS profile's cached rows over the skeleton before the new
/api/sessions response landed (SILENT regression Codex caught).
Fix:
- showSessionListSkeleton(): cancelAnimationFrame(_sessionVirtualScrollRaf) and
delete the data-session-virtual-{total,start,end,filter,activeAnchor} markers
so no scroll-driven render targets the skeleton; the real render rebuilds them.
- _scheduleSessionVirtualizedRender(): early-return while
_sessionListSkeletonActive is true.
Tests: +2 static assertions; driver mock gains dataset + RAF stubs. 24 pass.
Codex (CORE regression): the parallelized loadDir('.') started BEFORE the
_switchGen stale-switch guard; empty-session switches reuse the session id, so a
superseded switch's /api/list could paint the wrong profile's tree. Reverted to
guard-before-workspace-render ordering (the up-front skeletons already give the
immediate cross-surface feedback; the parallel gain was marginal).
Opus findings:
- [blocker] Workspace skeleton stranded permanently when the new profile has no
bound workspace (loadDir skipped → nothing clears #fileTree). Added
clearWorkspaceTreeSkeleton() (only empties when a skeleton is present) and call
it on the no-workspace path in BOTH switch branches.
- [low-med] Session-list skeleton stranded if an inline-rename input or row
action menu was open (renderSessionListFromCache early-returns before the DOM
swap). Dismiss _renamingSid + closeSessionActionMenu() before showing the
skeleton.
- [low] No-op self-switch flashed skeleton→restore (activateCurrentProfile
doesn't pre-check). Added an early-return when name === S.activeProfile.
Tests: +4 static assertions for the self-switch guard, rename/menu dismissal,
no-workspace skeleton clear, and the clear-helper definition. 22 pass.
- behaviour: node-driven, runs the actual showSessionListSkeleton() /
showWorkspaceTreeSkeleton() builders against a mock DOM and asserts the
produced structure (3 group labels + 8 single-line rows with title+stamp;
8 tree rows with glyph+name, 7 file rows with a size bar, 1 dir without).
- static: asserts switchToProfile() shows both skeletons up front before the
/api/profile/switch POST, parallelizes the list+workspace refresh, restores
real content on failure; renderSessionListFromCache clears the skeleton flag;
style.css has the classes, sheen+fade keyframes, reduced-motion disable, and
light+dark theme tokens.
The initial sheen (15% sheen / 9% base, 1.4s) was too subtle to read as
animation. Bump to a 5-stop gradient (22-24% sheen / 8% base, 1.25s), define
explicit light-mode --skeleton-base/--skeleton-sheen tokens (was relying on
fallbacks only), and give the tree glyph the same shimmering gradient so it
animates with the bars. prefers-reduced-motion still disables the sweep.
Show polished loading skeletons the instant a profile switch begins instead of
leaving the previous profile's conversation list and workspace file tree visible
until their fetches resolve (~1s).
- static/style.css: theme-token skeleton system (single-line session rows with
group labels + timestamp bar; tree rows with glyph + name + size bar); subtle
shimmer sweep; dark-mode tokens; prefers-reduced-motion fallback.
- static/sessions.js: showSessionListSkeleton() mirrors real sidebar anatomy
(group headers + single-line rows); _sessionListSkeletonActive flag cleared on
real render.
- static/workspace.js: showWorkspaceTreeSkeleton() mirrors the file-tree row.
- static/panels.js switchToProfile(): clear stale content + show both skeletons
up front; parallelize the independent list + workspace refreshes (Promise.all);
restore real content from cache on switch failure so a skeleton never strands.
Verified live: switch fires -> skeleton instant (0 stale rows) -> resolves to new
profile's real rows. Polished across 1920/1440/1280/390 + dark mode (screenshots
pending Nathan's visual review before gate+PR).
Overlays live pending_started_at/updated_at/last_message_at onto cached /api/sessions rows
(when newer), sorts active/running sessions first then by freshest effective timestamp, applies
the same running-first order in the frontend sidebar renderer, and groups date buckets with the
same runtime-aware timestamp (no stale header on resumed running sessions). routes.py +
sessions.js + 81-line test. Composes with #4680 cache-freeze + #4597 (98 session-list tests pass).
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
1-line CSS: adds touch-action:manipulation + -webkit-tap-highlight-color:transparent to
button/.icon-btn/.send-btn/.approval-btn/[onclick] inside the touch/compact media block
(@media max-width:1399.98px), removing iOS Safari's ~300ms double-tap-zoom delay so taps
fire on first touch. Added a regression test pinning the rule.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Module-level _compact_for_echo_compare (strips ALL whitespace, \s+ -> '') so _is_visible_output_echo's
endswith() suffix-match detects interim_assistant prose that only differs from visible token prose by
line-break/paragraph formatting. Hoisted to module scope (importable) + behavior-based test (real
token vs interim text equality). Codex SAFE (exact suffix match, not substring). 103 echo/journal tests.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Freezes the session-list cache source-stamp on the SET of active stream ids (not per-write
state) so mid-stream sidebar polls hold the cache instead of rebuilding all_sessions() every
poll and contending for the global LOCK the streaming worker holds (the ~2 tok/s + 15s
/api/sessions cause). Structural mutations still invalidate via publish_session_list_changed;
only a streaming session's own title/message_count lags <=TTL. routes.py + 109-line test.
99 session-list/sidebar/cache tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>