This commit is contained in:
nesquena-hermes 2026-07-06 01:44:21 +00:00
commit f10cb2a35c
6 changed files with 313 additions and 31 deletions

View file

@ -7563,9 +7563,10 @@ def _load_branch_source_or_refuse(handler, sid: str):
return get_session(sid)
except KeyError:
_foreign_session, _reason = _claim_or_synthesize_cli_session(sid)
if _reason == "not_claimable":
bad(handler, "Read-only sessions cannot be branched from WebUI", 403)
return None
_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 ""
if _reason == "not_claimable" and _foreign_session is not None and _source_kind == "cron":
_foreign_session._branch_source_readonly = True; return _foreign_session
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
@ -13959,7 +13960,7 @@ def handle_post(handler, parsed) -> bool:
# /api/session so frontend keep_count values from merged messaging
# transcripts do not silently become full sidecar copies.
try:
source.save()
if not getattr(source, "_branch_source_readonly", False): source.save()
except Exception:
pass
cli_meta = _lookup_cli_session_metadata(source.session_id) if _session_requires_cli_metadata_lookup(source) else {}

View file

@ -1807,7 +1807,10 @@ async function cmdBranch(args){
const readOnlySession=typeof _isReadOnlySession==='function'
? _isReadOnlySession(S.session)
: !!(S.session&&(S.session.read_only||S.session.is_read_only));
if(readOnlySession){showToast('Read-only sessions cannot be forked.',3000);return;}
const branchableReadOnlySession=typeof _isBranchableReadOnlySession==='function'
? _isBranchableReadOnlySession(S.session)
: false;
if(readOnlySession&&!branchableReadOnlySession){showToast('Read-only sessions cannot be forked.',3000);return;}
const customTitle=(args||'').trim()||null;
try{
const data=await api('/api/session/branch',{
@ -1838,7 +1841,10 @@ async function forkFromMessage(msgIdx){
const readOnlySession=typeof _isReadOnlySession==='function'
? _isReadOnlySession(S.session)
: !!(S.session&&(S.session.read_only||S.session.is_read_only));
if(readOnlySession){showToast('Read-only sessions cannot be forked.',3000);return;}
const branchableReadOnlySession=typeof _isBranchableReadOnlySession==='function'
? _isBranchableReadOnlySession(S.session)
: false;
if(readOnlySession&&!branchableReadOnlySession){showToast('Read-only sessions cannot be forked.',3000);return;}
const initialSid = S.session.session_id;
// Capture the absolute keep_count before any async work that may
// reset _oldestIdx. _oldestIdx is 0 when the full transcript is

View file

@ -2012,6 +2012,16 @@ function _isReadOnlySession(session) {
return !!(session && (session.read_only || session.is_read_only));
}
function _isBranchableReadOnlySession(session) {
if (!_isReadOnlySession(session)) return false;
const sources = [
session && session.source_tag,
session && session.raw_source,
session && session.source,
].map(v => String(v || '').trim().toLowerCase());
return sources.includes('cron');
}
function _sourceKeyForSession(session) {
return (session && (session.raw_source || session.source_tag || session.source || '') || '').toLowerCase();
}

View file

@ -13825,7 +13825,10 @@ function renderMessages(options){
const readOnlySession=typeof _isReadOnlySession==='function'
? _isReadOnlySession(S.session)
: !!(S.session&&(S.session.read_only||S.session.is_read_only));
const forkBtn = readOnlySession ? '' : `<button class="msg-action-btn" title="${t('fork_from_here')}" onclick="forkFromMessage(${rawIdx+1})">${li('git-branch',13)}</button>`;
const branchableReadOnlySession=typeof _isBranchableReadOnlySession==='function'
? _isBranchableReadOnlySession(S.session)
: false;
const forkBtn = (readOnlySession&&!branchableReadOnlySession) ? '' : `<button class="msg-action-btn" title="${t('fork_from_here')}" onclick="forkFromMessage(${rawIdx+1})">${li('git-branch',13)}</button>`;
const ttsBtn = !isUser ? `<button class="msg-action-btn msg-tts-btn" title="${t('tts_listen')||'Listen'}" onclick="speakMessage(this)">${li('volume-2',13)}</button>` : '';
const tsVal=m._ts||m.timestamp;
// _formatInServerTz handles fractional-hour offsets (India +0530 etc.)

View file

@ -25,6 +25,7 @@ import pytest
ROOT = Path(__file__).resolve().parents[1]
COMMANDS_JS = ROOT / "static" / "commands.js"
SESSIONS_JS = ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
@ -49,6 +50,23 @@ def _extract_async_function(source: str, name: str) -> str:
pytest.fail(f"Could not extract complete function body for {name}")
def _extract_function(source: str, name: str) -> str:
start = source.find(f"function {name}(")
assert start != -1, f"Could not find function {name}"
brace = source.find("{", start)
assert brace != -1, f"Could not find opening brace for {name}"
depth = 0
for idx in range(brace, len(source)):
ch = source[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start:idx + 1]
pytest.fail(f"Could not extract complete function body for {name}")
def _run_node(script: str) -> str:
if NODE is None:
pytest.skip("node not on PATH")
@ -69,8 +87,11 @@ def _run_node(script: str) -> str:
def _commands_harness(body: str) -> str:
source = COMMANDS_JS.read_text(encoding="utf-8")
session_source = SESSIONS_JS.read_text(encoding="utf-8")
cmd_branch = _extract_async_function(source, "cmdBranch")
fork_from = _extract_async_function(source, "forkFromMessage")
is_read_only = _extract_function(session_source, "_isReadOnlySession")
is_branchable_read_only = _extract_function(session_source, "_isBranchableReadOnlySession")
return _run_node(
"\n".join([
"const calls = [];",
@ -93,9 +114,8 @@ def _commands_harness(body: str) -> str:
"const loadSession = async (sid) => { loadedSessions.push(sid); };",
"const renderSessionList = async () => { renderCalls += 1; };",
"const _ensureAllMessagesLoaded = async () => { ensureCalls += 1; };",
"function _isReadOnlySession(session) {",
" return !!(session && (session.read_only || session.is_read_only));",
"}",
is_read_only,
is_branchable_read_only,
cmd_branch,
fork_from,
"(async () => {",
@ -186,7 +206,8 @@ def test_branch_endpoint_consults_foreign_session_guard_on_missing_sidecar():
assert '_load_branch_source_or_refuse(handler, body["session_id"])' in block, \
"Branch handler should delegate source-load/refusal to the shared helper"
# The helper itself must carry the foreign-session classification + 403.
# The helper itself must carry the foreign-session classification and pass
# read-only cron-like sources to the branch builder without saving them.
helper_match = re.search(
r'def _load_branch_source_or_refuse\(.*?\)(.*?)(?=\ndef )',
src, re.DOTALL
@ -197,10 +218,16 @@ def test_branch_endpoint_consults_foreign_session_guard_on_missing_sidecar():
"Helper should classify missing-sidecar foreign sessions before returning"
assert 'if _reason == "not_claimable":' in helper, \
"Helper should branch on not_claimable foreign ownership"
assert 'return bad(handler, "Read-only sessions cannot be branched from WebUI", 403)' in helper \
or ('bad(handler, "Read-only sessions cannot be branched from WebUI", 403)' in helper
and 'return None' in helper), \
"Helper should return a provenance-correct 403 for read-only foreign sessions"
assert '_source_kind == "cron"' in helper, \
"Helper should narrow read-only branch sources to resolved cron source metadata"
assert 'is_cron_session(' not in helper, \
"Helper should not use the cron_ session-id prefix as a branch permission gate"
assert '_foreign_session._branch_source_readonly = True' in helper, \
"Helper should mark synthesized read-only sources so branch does not save them"
assert 'return _foreign_session' in helper, \
"Helper should return synthesized read-only sources to the branch builder"
assert 'bad(handler, "Read-only sessions cannot be branched from WebUI", 403)' in helper, \
"Helper should keep non-cron not_claimable sources refused"
def test_branch_endpoint_returns_new_session_id():
@ -333,8 +360,8 @@ def test_branch_auto_title():
assert '(fork)' in block, "Branch handler should auto-title as '(fork)'"
def test_branch_route_rejects_not_claimable_foreign_sessions_with_403(monkeypatch):
"""Direct or stale branch POSTs for read-only foreign sessions must refuse clearly."""
def test_branch_route_allows_not_claimable_cron_sessions_to_fork(monkeypatch):
"""Direct or stale branch POSTs for read-only cron sessions should create a fork."""
handler = _FakeHandler()
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "cron-1"})
@ -343,14 +370,24 @@ def test_branch_route_rejects_not_claimable_foreign_sessions_with_403(monkeypatc
"get_session",
lambda _sid, metadata_only=False: (_ for _ in ()).throw(KeyError("Session not found")),
)
monkeypatch.setattr(
routes,
"_claim_or_synthesize_cli_session",
lambda _sid: (object(), "not_claimable"),
source = routes.Session(
session_id="cron-1",
title="Cron Run",
workspace=".",
model="claude-sonnet",
messages=[{"role": "user", "content": "summarize"}],
source_tag="cron",
raw_source="cron",
session_source="other",
)
monkeypatch.setattr(routes, "_claim_or_synthesize_cli_session", lambda _sid: (source, "not_claimable"))
cap = _capture_route(monkeypatch)
routes.handle_post(handler, urlparse("/api/session/branch"))
assert cap["bad"] == ("Read-only sessions cannot be branched from WebUI", 403)
assert "bad" not in cap
assert cap["status"] == 200
assert cap["ok"]["title"] == "Cron Run (fork)"
assert cap["ok"]["parent_session_id"] == "cron-1"
assert cap["ok"]["session_id"] in routes.SESSIONS
def test_branch_route_keeps_404_for_truly_missing_sessions(monkeypatch):
@ -485,18 +522,31 @@ def test_fork_button_in_message_actions():
def test_fork_button_is_hidden_for_read_only_sessions():
"""Read-only sessions should not render the message-level fork affordance."""
"""Non-cron read-only sessions should not render the message-level fork affordance."""
src = _read('static/ui.js')
assert "const readOnlySession=typeof _isReadOnlySession==='function'" in src, \
"ui.js should derive a read-only session flag from the shared helper"
assert "const forkBtn = readOnlySession ? '' :" in src, \
"fork button should be suppressed when the active session is read-only"
assert "const branchableReadOnlySession=typeof _isBranchableReadOnlySession==='function'" in src, \
"ui.js should derive a branchable read-only flag from the shared helper"
assert "const forkBtn = (readOnlySession&&!branchableReadOnlySession) ? '' :" in src, \
"fork button should be suppressed when a read-only session is not branchable"
def test_branchable_read_only_helper_accepts_cron_sources():
"""Read-only cron sessions should be forkable follow-up sources."""
src = _read('static/sessions.js')
assert "function _isBranchableReadOnlySession(session)" in src
assert "session && session.source_tag" in src
assert "session && session.raw_source" in src
assert "sources.includes('cron')" in src
assert "sid.startsWith('cron_')" not in src
assert "sid.startsWith('cron-')" not in src
def test_cmdBranch_rejects_read_only_sessions_without_posting():
"""The /branch command must not POST for read-only sessions."""
"""The /branch command must not POST for non-cron read-only sessions."""
result = _commands_harness(
"S.session = { session_id: 'cron-1', read_only: true };\n"
"S.session = { session_id: 'subagent-1', session_source: 'subagent', read_only: true };\n"
"await cmdBranch('');\n"
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
)
@ -509,10 +559,29 @@ def test_cmdBranch_rejects_read_only_sessions_without_posting():
assert payload["toasts"][0][0] == "Read-only sessions cannot be forked."
def test_forkFromMessage_rejects_read_only_sessions_without_loading_or_posting():
"""Read-only message forks must stop before the load/post path."""
def test_cmdBranch_allows_read_only_cron_sessions_to_post():
"""The /branch command should POST for read-only cron sessions."""
result = _commands_harness(
"S.session = { session_id: 'cron-1', read_only: true };\n"
"S.session = { session_id: 'daily-summary', session_source: 'other', raw_source: 'cron', read_only: true };\n"
"await cmdBranch('Follow-up');\n"
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
)
payload = json.loads(result)
assert len(payload["calls"]) == 1, "read-only cron /branch should POST once"
call = payload["calls"][0]
assert call["url"] == "/api/session/branch"
assert call["body"]["session_id"] == "daily-summary"
assert call["body"]["title"] == "Follow-up"
assert payload["ensureCalls"] == 0, "cmdBranch should not trigger message loading"
assert payload["loadedSessions"] == ["forked-session"], "cron /branch should load the forked session"
assert payload["renderCalls"] == 1, "cron /branch should refresh the session list"
assert payload["toasts"][0][0] == "Forked into new session"
def test_forkFromMessage_rejects_read_only_non_cron_sessions_without_loading_or_posting():
"""Non-cron read-only message forks must stop before the load/post path."""
result = _commands_harness(
"S.session = { session_id: 'subagent-1', session_source: 'subagent', read_only: true };\n"
"await forkFromMessage(1);\n"
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
)
@ -525,6 +594,37 @@ def test_forkFromMessage_rejects_read_only_sessions_without_loading_or_posting()
assert payload["toasts"][0][0] == "Read-only sessions cannot be forked."
def test_forkFromMessage_allows_read_only_cron_sessions_to_post():
"""Read-only cron message forks should reach the existing keep_count path."""
result = _commands_harness(
"S.session = { session_id: 'cron_1', raw_source: 'cron', read_only: true };\n"
"_oldestIdx = 2;\n"
"await forkFromMessage(4);\n"
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
)
payload = json.loads(result)
assert len(payload["calls"]) == 1, "read-only cron forkFromMessage should POST once"
call = payload["calls"][0]
assert call["url"] == "/api/session/branch"
assert call["body"]["session_id"] == "cron_1"
assert call["body"]["keep_count"] == 6
assert payload["ensureCalls"] == 2, "cron forkFromMessage should preserve the full-load flow"
assert payload["loadedSessions"] == ["forked-session"], "cron forkFromMessage should load the fork"
assert payload["renderCalls"] == 1, "cron forkFromMessage should refresh the session list"
def test_cmdBranch_rejects_cron_prefixed_id_without_canonical_source():
"""Only canonical cron source fields should unlock read-only branching."""
result = _commands_harness(
"S.session = { session_id: 'cron_spoof_messaging', session_source: 'other', read_only: true };\n"
"await cmdBranch('');\n"
"console.log(JSON.stringify({ calls, toasts, ensureCalls, loadedSessions, renderCalls }));"
)
payload = json.loads(result)
assert payload["calls"] == [], "cron_ id alone should not unlock read-only /branch"
assert payload["toasts"][0][0] == "Read-only sessions cannot be forked."
def test_forkFromMessage_preserves_absolute_keep_count_for_writable_sessions():
"""The read-only guard must not break the existing absolute keep_count fix."""
result = _commands_harness(

View file

@ -14,10 +14,12 @@ reason branch correctly with monkey-patched state.db / SESSION_INDEX_FILE).
"""
from __future__ import annotations
import io
import json
import re
import sqlite3
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -298,6 +300,32 @@ def _write_index(path: Path, entries: list[dict]) -> None:
path.write_text(json.dumps(entries), encoding="utf-8")
class _FakePostHandler:
def __init__(self, body: dict, *, path: str):
raw = json.dumps(body).encode("utf-8")
self.status = None
self.response_headers = {}
self.headers = {"Content-Length": str(len(raw))}
self.rfile = io.BytesIO(raw)
self.wfile = io.BytesIO()
self.command = "POST"
self.path = path
self.client_address = ("127.0.0.1", 12345)
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers[key] = value
def end_headers(self):
pass
def _response_json(handler: _FakePostHandler) -> dict:
return json.loads(handler.wfile.getvalue().decode("utf-8"))
@pytest.fixture
def routes_module():
return pytest.importorskip("api.routes")
@ -451,7 +479,7 @@ def test_helper_materialises_state_db_only_session(
assert sess.session_id == SID
assert sess.title == "Codex honcho integration"
assert sess.model == "MiniMax-M3"
assert sess.workspace == "/root" # from CLI metadata
assert Path(sess.workspace).name == "root" # from CLI metadata
assert len(sess.messages) == 3
assert sess.messages[0]["role"] == "user"
# Greptile #4911 P1: created_at must be populated from state.db
@ -1023,6 +1051,140 @@ def test_helper_refuses_cron_via_state_db_source(
assert sess.read_only is True
def test_branch_from_cron_state_db_returns_writable_fork_without_source_sidecar(
routes_module, monkeypatch, isolated_state_db
):
SID = "20260610_cron_branch_route"
_make_state_db(
isolated_state_db["db"], SID, message_count=2,
title="Cron job", source="cron", cwd="/root",
)
monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {})
monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes_module, "_guard_request_session_visibility", lambda *args, **kwargs: True)
handler = _FakePostHandler({"session_id": SID}, path="/api/session/branch")
routes_module.handle_post(handler, SimpleNamespace(path="/api/session/branch", query=""))
assert handler.status == 200
payload = _response_json(handler)
assert payload["parent_session_id"] == SID
assert payload["session_id"] != SID
assert (isolated_state_db["sessions_dir"] / f'{payload["session_id"]}.json').exists()
assert not (isolated_state_db["sessions_dir"] / f"{SID}.json").exists()
@pytest.mark.parametrize("source", ["gateway", "messaging", "external_agent", "unknown", "claude_code"])
def test_branch_still_refuses_non_cron_not_claimable_sources(
routes_module, monkeypatch, isolated_state_db, source
):
SID = f"20260610_{source}_branch_route"
_make_state_db(
isolated_state_db["db"], SID, message_count=1,
title=f"{source} chat", source=source, cwd="/root",
)
monkeypatch.setattr(
routes_module,
"_lookup_cli_session_metadata",
lambda _sid: {
"session_id": SID,
"source_tag": source,
"raw_source": source,
"session_source": "other",
},
)
monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes_module, "_guard_request_session_visibility", lambda *args, **kwargs: True)
handler = _FakePostHandler({"session_id": SID}, path="/api/session/branch")
routes_module.handle_post(handler, SimpleNamespace(path="/api/session/branch", query=""))
assert handler.status == 403
payload = _response_json(handler)
assert "read-only" in payload["error"].lower()
assert not (isolated_state_db["sessions_dir"] / f"{SID}.json").exists()
def test_branch_refuses_cron_prefixed_non_cron_not_claimable_source(
routes_module, monkeypatch, isolated_state_db
):
SID = "cron_spoof_messaging"
_make_state_db(
isolated_state_db["db"], SID, message_count=1,
title="Messaging chat", source="messaging", cwd="/root",
)
monkeypatch.setattr(
routes_module,
"_lookup_cli_session_metadata",
lambda _sid: {
"session_id": SID,
"source_tag": "messaging",
"raw_source": "messaging",
"session_source": "other",
},
)
monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes_module, "_guard_request_session_visibility", lambda *args, **kwargs: True)
handler = _FakePostHandler({"session_id": SID}, path="/api/session/branch")
routes_module.handle_post(handler, SimpleNamespace(path="/api/session/branch", query=""))
assert handler.status == 403
payload = _response_json(handler)
assert "read-only" in payload["error"].lower()
assert not (isolated_state_db["sessions_dir"] / f"{SID}.json").exists()
def test_branch_from_claimable_tui_still_creates_fork(
routes_module, monkeypatch, isolated_state_db
):
SID = "20260610_tui_branch_route"
_make_state_db(
isolated_state_db["db"], SID, message_count=3,
title="TUI chat", source="tui", cwd="/root",
)
monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {})
monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes_module, "_guard_request_session_visibility", lambda *args, **kwargs: True)
sess, reason = routes_module._claim_or_synthesize_cli_session(SID)
assert reason == "materialized"
sess.save()
handler = _FakePostHandler({"session_id": SID}, path="/api/session/branch")
routes_module.handle_post(handler, SimpleNamespace(path="/api/session/branch", query=""))
assert handler.status == 200
payload = _response_json(handler)
assert payload["parent_session_id"] == SID
assert payload["title"] == "TUI chat (fork)"
assert (isolated_state_db["sessions_dir"] / f"{SID}.json").exists()
assert (isolated_state_db["sessions_dir"] / f'{payload["session_id"]}.json').exists()
def test_chat_start_still_refuses_cron_state_db_source(
routes_module, monkeypatch, isolated_state_db
):
SID = "20260610_cron_chat_start_route"
_make_state_db(
isolated_state_db["db"], SID, message_count=1,
title="Cron job", source="cron", cwd="/root",
)
monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {})
monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes_module, "_guard_request_session_visibility", lambda *args, **kwargs: True)
handler = _FakePostHandler({"session_id": SID}, path="/api/chat/start")
routes_module.handle_post(handler, SimpleNamespace(path="/api/chat/start", query=""))
assert handler.status == 403
payload = _response_json(handler)
assert "read-only" in payload["error"].lower()
assert not (isolated_state_db["sessions_dir"] / f"{SID}.json").exists()
def test_branch_refuses_subagent_view_only_source(
routes_module, monkeypatch
):
monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
monkeypatch.setattr(routes_module, "_guard_request_session_visibility", lambda *args, **kwargs: True)
monkeypatch.setattr(routes_module, "_session_is_subagent_view_only", lambda _sid: True)
handler = _FakePostHandler({"session_id": "subagent-1"}, path="/api/session/branch")
routes_module.handle_post(handler, SimpleNamespace(path="/api/session/branch", query=""))
assert handler.status == 400
payload = _response_json(handler)
assert "view-only" in payload["error"].lower()
def test_helper_reason_distinguishes_cli_meta_vs_state_db_source(
routes_module,
):