release: fork read-only cron sessions into a new writable chat (#5555)

Relaxes the /api/session/branch read-only gate so a canonical-cron session can be forked into a new WebUI-owned session (source never saved). Gate hardening folded in: the read-only branch gate now covers BOTH the synthesized/missing-sidecar path AND the persisted/loaded path (Codex found a stored read-only non-cron session could be branched + saved); relaxation keys on the server-authoritative resolved source (source_tag/raw_source/source == 'cron'), never the id prefix. Full gate: Codex adversarial SAFE, 84 branch/claim tests + suite 12175/0, browser clean.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermesandRod Boev committed 2026-07-06 02:02:52 +00:00
1 parent f10cb2a35c
commit c601e11324
3 files changed
+51 -1

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
### Added
- **Fork a scheduled-job (cron) conversation into your own editable chat.** Cron-run sessions are read-only (the scheduler owns them), so you couldn't continue one. You can now branch/fork a cron session into a new WebUI-owned conversation — the original read-only session is never modified, and the fork picks up its history so you can carry on. Only genuine cron sessions qualify (verified by the server-side source, not a guessable id), and every other read-only session stays unbranchable. Thanks @rodboev. (#5555, #5477)
- **Pick the exact time and day for scheduled jobs — no cron syntax needed.** The Tasks "New job" form now builds the schedule as a plain sentence: choose a frequency (Hourly / Daily / Weekdays / Weekly / Monthly / Custom) and it shows just the controls that matter — a time picker, a day-of-week dropdown, or a day-of-month dropdown — e.g. "Weekly on Monday at 09:00." The generated cron expression is previewed inline, and the raw cron field now appears only under "Custom." Thanks @rodboev. (#5554, #5552)
- **Transparent Stream fades newly streamed words in.** In Transparent Stream mode, newly arriving assistant words now fade in at the prose level as they stream — without animating (or re-flickering) the thinking rows, tool rows, or the transparent event rows. It respects your OS "reduce motion" setting (no fade when reduced motion is on) and is part of the Transparent Stream experience regardless of the "Fade text effect" toggle. Thanks @rodboev. (#5506, #5367)
- **The Preferences default-model setting now uses the rich model picker.** The "Default Model" control in Settings → Preferences was a plain dropdown; it now uses the same searchable, provider-grouped picker as the composer (with a "Custom Model ID" field), so choosing a default model is consistent everywhere. On touch devices the picker no longer pops the keyboard the instant you open it (it still focuses search on desktop and while you're typing), and its label now activates the picker directly. Thanks @rodboev. (#5502, #5497)
+14 -1
View File
@@ -7560,7 +7560,7 @@ def _load_branch_source_or_refuse(handler, sid: str):
bad(handler, "Subagent sessions are view-only and cannot be branched from WebUI", 400)
return None
try:
return get_session(sid)
source = get_session(sid)
except KeyError:
_foreign_session, _reason = _claim_or_synthesize_cli_session(sid)
_source_kind = str((getattr(_foreign_session, "source_tag", None) or getattr(_foreign_session, "raw_source", None) or getattr(_foreign_session, "source", None) or "")).strip().lower() if _foreign_session is not None else ""
@@ -7569,6 +7569,19 @@ def _load_branch_source_or_refuse(handler, sid: str):
if _reason == "not_claimable": bad(handler, "Read-only sessions cannot be branched from WebUI", 403); return None
bad(handler, "Session not found", 404)
return None
# A PERSISTED (stored) session can also be read-only (e.g. a cron-owned or
# messaging-sourced sidecar). Apply the SAME read-only branch gate as the
# synthesized path: allow forking only a canonical-cron read-only source
# (server-authoritative source kind, not the id prefix), marking it so the fork
# never .save()s the read-only source; refuse every other read-only source.
if bool(getattr(source, "read_only", False)):
_source_kind = str((getattr(source, "source_tag", None) or getattr(source, "raw_source", None) or getattr(source, "source", None) or "")).strip().lower()
if _source_kind == "cron":
source._branch_source_readonly = True
return source
bad(handler, "Read-only sessions cannot be branched from WebUI", 403)
return None
return source
def _resolve_cli_import_metadata(session_id: str, *, requested_profile=None, allow_all_profiles: bool = False) -> dict:
+36
View File
@@ -230,6 +230,42 @@ def test_branch_endpoint_consults_foreign_session_guard_on_missing_sidecar():
"Helper should keep non-cron not_claimable sources refused"
def test_branch_helper_gates_persisted_read_only_sources_too():
"""A PERSISTED (stored) read-only session must hit the same branch gate as a
synthesized foreign one — not slip through `get_session(sid)` (#5555 gate fix).
Codex found that a stored `read_only=True, source_tag="messaging"` session could
be branched (200) and its source .save()d because the read-only check only lived
on the missing-sidecar (except KeyError) path. The loaded-session path must:
only allow a canonical-cron read-only source, mark it read-only-for-branch, and
403 every other read-only source.
"""
src = _read('api/routes.py')
helper_match = re.search(
r'def _load_branch_source_or_refuse\(.*?\)(.*?)(?=\ndef )',
src, re.DOTALL
)
assert helper_match, "Could not find _load_branch_source_or_refuse helper"
helper = helper_match.group(1)
# The loaded (non-KeyError) path assigns the session to a local, not a bare return.
assert 'source = get_session(sid)' in helper, \
"Loaded session must be captured so it can be gated (not returned unconditionally)"
# The loaded path applies the read-only gate.
assert 'getattr(source, "read_only", False)' in helper, \
"Loaded read-only sessions must be gated for branching"
# Only canonical cron read-only sources pass, marked so the fork won't save them.
assert 'source._branch_source_readonly = True' in helper, \
"Loaded read-only cron sources must be marked read-only-for-branch"
# Verify the read-only gate + 403 come BEFORE the final unconditional return.
ro_idx = helper.index('getattr(source, "read_only", False)')
final_return_idx = helper.rindex('return source')
assert ro_idx < final_return_idx, \
"The read-only gate must run before the loaded session is returned"
# The 403 refusal exists on the loaded path (two occurrences now: synth + loaded).
assert helper.count('bad(handler, "Read-only sessions cannot be branched from WebUI", 403)') >= 2, \
"Both the synthesized and persisted read-only non-cron paths must 403"
def test_branch_endpoint_returns_new_session_id():
"""Verify the branch endpoint returns session_id and title."""
src = _read('api/routes.py')