fix(#5307): close 2 more subagent-child writable-session holes (Codex round 2)

- 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
This commit is contained in:
nesquena-hermes 2026-07-01 07:35:48 +00:00
parent a823ab5ff8
commit 3ecb2625aa
2 changed files with 45 additions and 3 deletions

View file

@ -11320,7 +11320,13 @@ def handle_get(handler, parsed) -> bool:
"archived": bool(getattr(synth, "archived", False)),
"project_id": getattr(synth, "project_id", None),
"profile": synth.profile,
"is_cli_session": True,
# Read is_cli_session from the synthesized Session, not a
# hardcoded True: delegated subagent children (#5307) are
# recovered read-only with is_cli_session=False so they don't
# pass the frontend _isExternalSession poll-skip / active-refresh
# gates (#3603). Every other synthesized foreign session keeps
# is_cli_session=True so its source badge renders.
"is_cli_session": bool(getattr(synth, "is_cli_session", False)),
"source_tag": synth.source_tag,
"raw_source": synth.raw_source,
"session_source": synth.session_source,
@ -22313,6 +22319,14 @@ def _handle_session_import_cli(handler, body):
cli_platform = cli_meta.get("platform") if cli_meta else None
cli_parent_session_id = cli_meta.get("parent_session_id") if cli_meta else None
cli_read_only = bool((cli_meta or {}).get("read_only"))
# Delegated subagent children (#5307) are recovered VIEW-ONLY: they must
# never be materialized as a writable WebUI sidecar via this endpoint, or a
# subsequent chat-start/composer write would take ownership of a session
# that belongs to the delegate runner. Treat them like an explicitly
# read-only source (return the read-only stub payload, do not import), and
# keep them out of the _isExternalSession frontend gates (is_cli_session=False).
_sa_child = _is_subagent_child_session_id(sid)
_read_only_view = cli_read_only or _sa_child
# Use the CLI session title if available (e.g., cron job name), otherwise derive from messages
title = cli_title or title_from(msgs, "CLI Session")
@ -22322,7 +22336,7 @@ def _handle_session_import_cli(handler, body):
if is_cron_session(sid, cli_source_tag):
cron_project_id = ensure_cron_project()
if cli_read_only:
if _read_only_view:
session_payload = {
"session_id": sid,
"title": title,
@ -22336,7 +22350,10 @@ def _handle_session_import_cli(handler, body):
"archived": False,
"project_id": None,
"profile": profile,
"is_cli_session": True,
# Subagent children (#5307) are recovered view-only and must NOT be
# CLI-classified (keeps them out of the frontend _isExternalSession
# gates); other explicitly-read-only sources keep is_cli_session=True.
"is_cli_session": (False if _sa_child else True),
"source_tag": cli_source_tag,
"raw_source": cli_raw_source or cli_source_tag,
"session_source": cli_session_source,

View file

@ -257,3 +257,28 @@ def test_state_db_source_helper_reads_subagent(routes_module, isolated_state_db)
assert routes_module._state_db_session_source("sa-1") == "subagent"
assert routes_module._is_subagent_child_session_id("sa-1") is True
assert routes_module._is_subagent_child_session_id("does-not-exist") is False
def test_import_cli_endpoint_does_not_materialize_subagent_child():
"""Codex hardening: POST /api/session/import_cli must NOT create a writable
sidecar for a subagent child — the handler gates source='subagent' into the
read-only view payload (is_cli_session=False, imported=False) before ever
calling import_cli_session(). Pinned as a static-source contract check."""
src = ROUTES_PY.read_text(encoding="utf-8")
start = src.index("def _handle_session_import_cli(")
m = re.search(r"\n(?:def |class )", src[start + 1:])
block = src[start:(start + 1 + m.start()) if m else len(src)]
assert "_is_subagent_child_session_id(sid)" in block, (
"import_cli handler must detect subagent children"
)
assert "_read_only_view" in block, (
"import_cli must route subagent children through the read-only view "
"payload (no writable import_cli_session materialization)"
)
# the read-only view branch must return before import_cli_session()
ro_idx = block.index("_read_only_view")
import_idx = block.index("import_cli_session(")
assert ro_idx < import_idx, (
"the read-only-view gate must precede import_cli_session() so subagent "
"children never materialize a writable sidecar"
)