_session_is_subagent_view_only guard added to the remaining paths that could
mutate a stale persisted subagent sidecar:
- _handle_handoff_summary (appends tool msg to state.db)
- _handle_chat_sync (fallback POST /api/chat)
- /api/session/draft POST (composer_draft save)
- /api/personality/set (per-session personality save)
Fixes#5307
Enumerated every /api/session/<mutation> route and applied _session_is_subagent_view_only:
duplicate, branch, retry, undo, toolsets, compress, archive (existing-sidecar path)
now 400 for subagent children; delete/clear/truncate/pin already gated; rename/move/update
route through the gated _get_or_materialize_session (403). This closes the data-loss
paths (delete_cli_session, retry/undo truncate, branch/duplicate fork).
Also: /api/sessions list coercion now also honors state.db source='subagent' for rows
whose stale index says webui/fork, so they can't surface as writable/CLI sidebar rows.
Fixes#5307
The root non-CLI classification surfaced subagent rows in the sidebar without
read_only, exposing delete/truncate/pin — and /api/session/delete calls
delete_cli_session() which erases the child's state.db transcript (data loss).
- /api/sessions list: coerce subagent rows to read_only=True + is_cli_session=False
so the UI offers no mutation affordances.
- New _session_is_subagent_view_only() shared guard; applied to the direct
mutation routes that bypass _get_or_materialize_session(): delete / clear /
truncate / pin now 400 for subagent children. (rename/move already route
through the gated _get_or_materialize_session and 403.)
- tests: guard helper + static contract that all 4 routes + list coercion present.
Fixes#5307
- api/agent_sessions.py is_cli_session_row(): add 'subagent' to non_cli_sources
so EVERY consumer (sidebar rows, /api/sessions, etc.) classifies a delegated
child as non-CLI (the root the per-site fixes were compensating for).
- api/routes.py GET /api/session happy path: coerce is_cli_session=False +
read_only=True in the serialized payload for a subagent child before redaction,
so a stale writable sidecar can't be exposed as writable to the browser.
Fixes#5307
- _get_or_materialize_session happy path: reject an already-persisted subagent
sidecar (source_tag/raw_source=='subagent' or state.db subagent) even when it
was stored read_only=False (pre-fix materialization), so chat-start can't write it.
- import_cli existing-session refresh: coerce read_only=True on the persisted
sidecar (and response) when it's a subagent child.
- test: persisted read_only=False subagent sidecar is still refused by the helper.
Fixes#5307
- import_cli _read_only_view now also treats resolved cli_meta source_tag/raw_source
=='subagent' as view-only (all_profiles=true resolves cli_meta from a non-active
profile, which the active-profile state.db _sa_child check could miss).
- import_cli existing-session refresh branch no longer hardcodes is_cli_session=True
for a subagent child (both the persisted update and the response payload).
Fixes#5307
Codex round 4 found POST /api/session/archive's missing-sidecar fallback also
calls import_cli_session(). grep confirms exactly 3 import_cli_session() call
sites in routes.py; all 3 now refuse source='subagent' children:
- 4792 _get_or_materialize_session (chat-start) -> PermissionError
- 22382 import_cli endpoint -> read-only view payload
- 13885 archive fallback -> 400 'Subagent sessions cannot be archived'
So no path can materialize a delegated child as a writable WebUI sidecar.
Fixes#5307
Codex found a 3rd writable path: POST /api/chat/start -> _get_or_materialize_session()
materialized source='subagent' as a writable sidecar before the not_claimable guard.
Fix: refuse subagent children (PermissionError) at that shared chokepoint, checked via
_is_subagent_child_session_id(sid) (state.db source, independent of cli_meta) BEFORE the
materialize path — so all three entry points (GET synth, import_cli, chat-start) now
consistently keep a delegated child view-only. CLI/TUI/Desktop materialization preserved.
Tests: materialize helper refuses subagent child + still allows tui.
Fixes#5307
- api/routes.py GET /api/session synthesized response: stop hardcoding
is_cli_session=True; serialize bool(synth.is_cli_session) so a recovered
subagent child stays not-CLI-classified (was overriding the helper's False).
- api/routes.py POST /api/session/import_cli: gate source='subagent' into the
read-only view payload (is_cli_session=False, imported=False) BEFORE
import_cli_session(), so a subagent child can never be materialized as a
writable WebUI sidecar via this endpoint.
- tests: assert import_cli routes subagent children read-only (no materialize).
Both were paths that bypassed the _is_claimable_cli_source denylist. Fixes#5307
Reworked per the Codex gate finding: the first approach widened the client
import predicate, which (a) conflicted with #3603's intentional _isExternalSession
gate and (b) let import_cli persist the subagent child as a WRITABLE, CLI-classified
session that then passed the poll-skip/active-refresh gates.
Corrected to a server-side, view-only recovery:
- api/routes.py: mark source='subagent' as NON-claimable in _is_claimable_cli_source
(both cli_meta and state.db source denylists). A subagent child now resolves via
the not_claimable branch -> read-only Session with its state.db transcript, and
build_session takes is_cli_flag (False for subagent children) so the recovered
session is NOT CLI-classified and can't widen the frontend _isExternalSession gates.
- static/sessions.js: REVERTED to master (no client change needed; #3603 contract intact).
- tests: assert reason=not_claimable, read_only=True, is_cli_session!=True, transcript present;
#2782 deleted-webui 404 preserved; #3603 _isExternalSession contract preserved.
58 tests green (5307 + 3603 + claim-cli + core-data-loss). Fixes#5307
A delegated subagent child (source='subagent' in state.db) usually has no WebUI
sidecar but is registered in the WebUI index sharing the parent's lineage. That
made GET /api/session -> _claim_or_synthesize_cli_session return 'was_webui' ->
404, so the child pane opened empty despite state.db holding messages.
- api/routes.py: add _state_db_session_source() + _is_subagent_child_session_id();
exclude subagent children from the was_webui 404 gate so they recover their
state.db transcript (the #2782 self-heal 404 for deleted WebUI sessions is kept).
- static/sessions.js: add _isSubagentChildSession() + _sessionNeedsServerImportForLoad()
(kept separate from _isExternalSession to avoid widening refresh-gating); the
main session-tap, lineage-segment, and child-open handlers now trigger the
import/merge path for subagent children.
- tests: test_5307_subagent_child_transcript.py (7 tests).
Fixes#5307
#5306 (flicker): while a parent WebUI session is the active/streaming session,
a linked delegate subagent child that transiently reports message_count===0
between /api/sessions polls was dropped by _sidebarRowHasVisibleMessages BEFORE
_attachChildSessionsToSidebarRows could stack it under its parent. It never
entered sessionsRaw, so the row vanished, then reappeared on the next refresh
once its list metadata caught up — the flicker. Extend the visibility predicate
with an active-parent exception (mirroring the existing active-session
exception): a child_session whose parent_session_id is the active sidebar
session stays visible even at message_count 0. Scoped to the active parent so
truly-empty unrelated sessions are still hidden.
#5305 (orphan): a delegated subagent child whose WebUI parent is filtered out of
the current render (project/profile/source scope) was promoted to a contextless
top-level "Subagent Session" orphan. Suppress cross-surface child_session rows
whose parent row is absent from the render instead of orphaning them, mirroring
the existing archived-hidden-parent suppression (#4293). The genuinely-external
parent case (messaging/CLI) still orphans via the parentIsExternal branch.
Tests: tests/test_5306_subagent_sidebar_flicker.py (7 tests) locks both
invariants and the no-regression guards, executing the real sessions.js helper
regions under node like the existing lineage tests.
The agent source at /opt/hermes inside the container may contain a
.playwright/ directory with browser dependency files that have restricted
permissions. rsync fails with exit code 23 when attempting to read them,
which kills the container build ("Failed to stage hermes-agent source").
- Add --exclude=.playwright to rsync in docker_init.bash
- Add rm -rf .playwright to cp -a fallback path for symmetry
- Update test_docker_init_excludes_egg_info_during_staging to assert
both the rsync --exclude and a broad .playwright presence check
- Add a brief note in AGENTS.md Contribution style about mirroring
directory exclusions in both rsync and cp paths
Fixes#5315
Reapply footer control ordering on current upstream/master while preserving the required situational chip renderer. Persist composer_control_order with backend validation, keep the settings descriptions reorder-aware, and make primary/situational chip renderers participate in same-group drag ordering.
Verified with: node --check static/boot.js; node --check static/panels.js; git diff --check; ./scripts/test.sh tests/test_issue4598_composer_control_visibility.py
The embedded workspace terminal spawns a PTY shell that runs arbitrary
commands as the server-process user. check_auth() returns True
unconditionally when no password/passkey is configured (the default
out-of-the-box state), so without a network-scope gate the terminal
endpoints were reachable by any unauthenticated caller able to hit the
port — which on a passwordless public bind is remote code execution.
Apply the same local-origin gate the onboarding/bootstrap endpoints use
(_onboarding_gate_allows) to /api/terminal/{start,input,resize,close} and
/api/terminal/output: with auth disabled, accept only loopback/private
origins, ignore spoofable X-Forwarded-For/X-Real-IP unless
HERMES_WEBUI_TRUST_FORWARDED_FOR=1, and honor HERMES_WEBUI_ONBOARDING_OPEN=1
as the explicit opt-out for a deliberately-exposed server. Auth-enabled
servers (cookie already verified upstream) and genuine same-host clients
are unaffected.
Also fixes a latent test-isolation leak in
test_extension_route_remains_behind_webui_auth: it set HERMES_WEBUI_PASSWORD
but never invalidated the process-wide password-hash cache, so its result
depended on suite execution order (exposed when the new test file shifted
ordering). Invalidate before+after so it reads the env var deterministically.
12 new gate tests in tests/test_cvd3_terminal_local_origin_gate.py.