From 3ecb2625aac50cc8df69cbd88549fa74ab6a8a22 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 1 Jul 2026 07:35:48 +0000 Subject: [PATCH] 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 --- api/routes.py | 23 +++++++++++++++--- tests/test_5307_subagent_child_transcript.py | 25 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/api/routes.py b/api/routes.py index 2da545c4..ec76f37a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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, diff --git a/tests/test_5307_subagent_child_transcript.py b/tests/test_5307_subagent_child_transcript.py index 61f71e29..f5333977 100644 --- a/tests/test_5307_subagent_child_transcript.py +++ b/tests/test_5307_subagent_child_transcript.py @@ -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" + )