diff --git a/api/routes.py b/api/routes.py index 6b6ddcbb..88d49776 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1568,6 +1568,75 @@ def _ensure_full_session_before_mutation(sid: str, session): return full_session +def _get_or_materialize_session(sid: str): + """Get a session, materializing from CLI/agent metadata if not in WebUI store. + + Mirrors the fallback logic in /api/session/archive (routes.py:~8530). + Raises: + KeyError: session not found in any store + PermissionError: session is read-only (messaging/Claude Code) + """ + try: + s = get_session(sid) + s = _ensure_full_session_before_mutation(sid, s) + return s + except KeyError: + pass + + # Fallback: try to materialize from CLI/agent session metadata + cli_meta = _lookup_cli_session_metadata(sid) + if not cli_meta: + raise KeyError(sid) + + # Read-only guard: messaging sessions and Claude Code imports cannot be mutated + if cli_meta.get("read_only"): + raise PermissionError("read-only imported session") + + # Preserve source metadata fields + def _apply_source_meta(s): + s.is_cli_session = True + s.source_tag = cli_meta.get("source_tag") + s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") + s.session_source = cli_meta.get("session_source") + s.source_label = cli_meta.get("source_label") + s.user_id = cli_meta.get("user_id") + s.chat_id = cli_meta.get("chat_id") + s.chat_type = cli_meta.get("chat_type") + s.thread_id = cli_meta.get("thread_id") + s.session_key = cli_meta.get("session_key") + s.platform = cli_meta.get("platform") + + if _is_messaging_session_record(cli_meta): + # Messaging sessions: lightweight Session with no messages (state.db is source of truth) + s = Session( + session_id=sid, + title=cli_meta.get("title") or title_from(get_cli_session_messages(sid), "CLI Session"), + workspace=get_last_workspace(), + model=cli_meta.get("model") or "unknown", + created_at=cli_meta.get("created_at"), + updated_at=cli_meta.get("updated_at"), + ) + _apply_source_meta(s) + s.save(touch_updated_at=False) + else: + # Regular CLI/agent sessions: import full message history + msgs = get_cli_session_messages(sid) + if not msgs: + raise KeyError(sid) + s = import_cli_session( + sid, + cli_meta.get("title") or title_from(msgs, "CLI Session"), + msgs, + cli_meta.get("model") or "unknown", + profile=cli_meta.get("profile"), + created_at=cli_meta.get("created_at"), + updated_at=cli_meta.get("updated_at"), + ) + _apply_source_meta(s) + + return s + + def _reconcile_stale_stream_state_for_session_rows(session_rows) -> bool: """Clear stale persisted stream fields before /api/sessions serializes rows.""" changed = False @@ -7418,10 +7487,11 @@ def handle_post(handler, parsed) -> bool: except ValueError as e: return bad(handler, str(e)) try: - s = get_session(body["session_id"]) - s = _ensure_full_session_before_mutation(body["session_id"], s) + s = _get_or_materialize_session(body["session_id"]) except KeyError: return bad(handler, "Session not found", 404) + except PermissionError: + return bad(handler, "Read-only imported sessions cannot be renamed from WebUI", 403) with _get_session_agent_lock(body["session_id"]): from api.session_ops import apply_session_title_rename apply_session_title_rename(s, body["title"]) @@ -7608,9 +7678,11 @@ def handle_post(handler, parsed) -> bool: except ValueError as e: return bad(handler, str(e)) try: - s = get_session(body["session_id"]) + s = _get_or_materialize_session(body["session_id"]) except KeyError: return bad(handler, "Session not found", 404) + except PermissionError: + return bad(handler, "Read-only imported sessions cannot be updated from WebUI", 403) old_ws = getattr(s, "workspace", "") old_model = getattr(s, "model", None) old_provider = getattr(s, "model_provider", None) @@ -8592,9 +8664,11 @@ def handle_post(handler, parsed) -> bool: except ValueError as e: return bad(handler, str(e)) try: - s = get_session(body["session_id"]) + s = _get_or_materialize_session(body["session_id"]) except KeyError: return bad(handler, "Session not found", 404) + except PermissionError: + return bad(handler, "Read-only imported sessions cannot be moved from WebUI", 403) # #1614: refuse moves into a project owned by another profile. target_pid = body.get("project_id") or None if target_pid: