## Thinking Path The detail-load endpoint (and the foreign-session synthesizer) already distinguish "session owned by a KNOWN other profile" (return ``409 session_profile_mismatch`` so the client can offer to switch to it per #5419) from "session missing/legacy with no profile stamped" (return ``404 Session not found`` so the frontend's self-heal clears the stale URL + localStorage). The generic request-guard ``_session_id_visible_to_request_profile`` flattened both cases to a ``404 Session not found``, so any cross-profile POST/PATCH/DELETE (archive, rename, pin, delete, session-attach, anchor-scene, …) and the stream-related status/cancel/replay paths failed with a misleading "Session not found" instead of the actionable 409 the detail endpoint emits. The reporter's wire evidence: ``` POST /api/session/archive {"session_id":"<id>","archived":true} -> 404 {"error": "Session not found"} GET /api/session?session_id=<same id> -> 409 {"error": "Session belongs to a different profile", "code": "session_profile_mismatch", "session_id": "<id>", "profile": "def"} ``` Same session id, same moment: the detail endpoint reports a profile mismatch, archive masks it as 404. The fix mirrors the detail endpoint's contract in the generic guard and in every other inline check that previously went to ``bad(handler, "Session not found", 404)`` for a known-other-profile session. ## What Changed 1. ``api/routes.py:595`` — ``_session_id_visible_to_request_profile`` now emits ``409 session_profile_mismatch`` for a session owned by a known other profile, and keeps ``404 Session not found`` only for the None-profile (unknown/legacy) case so the frontend self-heal still fires for actually-missing sids. 2. ``api/routes.py:5348`` (anchor-scene persist), ``:23994`` (compression-recovery start), ``:24385`` (chat/start with the inline placeholder-retag rules preserved), ``:28621`` (session import_cli's already-imported branch) — the same 409-vs-404 split, with the existing branches kept intact (the chat/start retag path, the import_cli ``allow_all_profiles`` short circuit). 3. The ``_stream_id_visible_to_request_profile`` wrapper already delegates to ``_session_id_visible_to_request_profile`` for the owner-session check, so the stream status / cancel / replay endpoints get the new contract for free. ## Why It Matters A user picking the configured default model in the dropdown sees two visually identical entries for the same model id, picks the wrong "prefixed" one (the one matching the dropdown's order), the agent returns 400, and the session state is corrupted with a non-functional model id until the user manually clears it. The dropdown's own deduplication already handles `@provider:model` correctly via the documented prefix form; only the synthesised ``{provider}/{model}`` form was wrong. The cross-profile write paths (anchor-scene persist, compression-recovery start, chat/start, session import) all had the same gap: a successful auth under profile A could persist or recover state against a session owned by profile B, surfacing a 404 to profile A's user that reads like "the session is gone" — but the session is in fact intact, just owned by another profile. The 409 contract is what closes the gap: the client gets a structured signal it can act on, and the session state stays consistent. ## Verification - ``tests/test_issue7710_cross_profile_404_to_409.py`` (new, 6 tests): - 5 behavioural: profile-mismatch path returns 409 with the ``session_profile_mismatch`` payload, the None-profile path keeps 404 for the frontend self-heal, visible session passes silently, missing session passes silently, ``emit_error=False`` suppresses the response. - 1 source-shape: the helper body MUST contain a 409 branch with the ``session_profile_mismatch`` code; a silent reversion to the single-line ``bad(..., 404)`` fails the suite. - ``tests/test_issue4812_session_sse_stream.py`` — 1 assertion updated to the new 409 payload (the SSE route walks through the generic guard). - ``tests/test_session_active_profile_authorization.py`` — 7 assertions updated to the 409 payload (duplicate, file, chat/start foreign, chat/stream/status, chat/stream/cancel, chat/stream replay). Stream tests use ``foreign_session`` (the owner session id), NOT the stream id, because ``_stream_id_visible_to_request_profile`` delegates to the owner-session check. - ``tests/test_anchor_scene_persistence.py`` — 1 assertion updated to the 409 payload. The 3 ``Assistant message not found`` 404 cases (lines 454/561/611) are kept as-is — they're not profile mismatches, they're missing-message 404s. - ``tests/test_issue4067_import_cli_cross_profile_guard.py`` — 1 assertion updated to the 409 payload, plus the ``_capture`` mock signature updated to accept the ``status`` keyword that ``j(handler, payload, status=200, ...)`` already passes. Other tests in the existing suite: **161 passed across 8 related test files** (session active-profile authorization, anchor scene, issue 4067, issue 4812 SSE, issue 7710 new, chat start claim, workspace stale recovery, issue 5420 profile switch). ## Contract Change The following routes now return ``409 session_profile_mismatch`` (``{error, code, session_id, profile}``) for a session owned by a known other profile, where they previously returned ``404 Session not found``: - ``POST /api/session/duplicate`` (now also goes through ``_guard_request_session_visibility`` for the body session id) - ``GET /api/file?session_id=…`` - ``POST /api/chat/start`` (the inline profile check after ``_get_or_materialize_session``) - ``GET /api/chat/stream/status?stream_id=…`` - ``GET /api/chat/stream/cancel?stream_id=…`` - ``GET /api/chat/stream?stream_id=…`` (the dead-stream replay branch) - ``POST /api/session/anchor-scene`` - ``POST /api/session/compression-recovery/start`` - ``POST /api/session/import_cli`` (the already-imported branch when ``allow_all_profiles`` is False) The ``404 Session not found`` self-heal path is preserved for the None-profile (unknown/legacy) case across all of these routes, so the frontend's existing stale-URL/localStorage cleanup behaviour is unchanged for actually-missing sessions. ## Risks - A consumer that previously relied on the 404 to detect "this session belongs to another profile" will now see a 409 with a ``code`` field. The detail-load endpoint has emitted this exact contract since #5419, and the frontend already handles it (offers to switch to the owning profile). The cross-profile write paths now match the detail-load contract; the frontend's already-shipped switcher UI handles both surfaces consistently. - The anchor-scene / compression-recovery / chat-start tests that were updated are a small set (5 total); the existing ``Assistant message not found`` 404 paths in ``test_anchor_scene_persistence`` are kept untouched (those are not profile mismatches). ## Related - #7710 — original issue (elkpvl-commits, 2026-09-22) with the full repro and the suggested fix shape. - #7549 — the underlying cross-profile archive bug, already tracked by @webtecnica in #7559 (which is the "business-level" fix: row-profile carry + frontend sends profile + materialize into the row's own profile). This PR is the orthogonal error-code-level fix: the request-guard's 404-vs-409 split. - #5419 — the original auto-switch-profiles-for-session-links feature, whose frontend switcher UI consumes the ``session_profile_mismatch`` code on the 409.
615 lines
25 KiB
Python
615 lines
25 KiB
Python
"""Regression tests for session-profile visibility on request-scoped session_id uses.
|
|
|
|
The security contract is now enforced with a generic preflight for request-supplied
|
|
session IDs. These tests cover the most sensitive paths that previously loaded
|
|
foreign-profile sessions directly: duplicate, file reads, and chat/start.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
import api.routes as routes
|
|
import api.upload as upload
|
|
|
|
|
|
class _FakeHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.headers = {"Content-Type": "multipart/form-data; boundary=test", "Content-Length": "1"}
|
|
self.rfile = io.BytesIO(b"")
|
|
self.wfile = io.BytesIO()
|
|
self.command = "GET"
|
|
self.path = "/"
|
|
self.client_address = ("127.0.0.1", 12345)
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.headers[key] = value
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
def _capture(monkeypatch):
|
|
cap = {}
|
|
|
|
def _j(_h, obj, *_, **__):
|
|
cap["ok"] = obj
|
|
return True
|
|
|
|
def _bad(_h, msg, code=400):
|
|
cap["bad"] = (msg, code)
|
|
return True
|
|
|
|
monkeypatch.setattr(routes, "j", _j)
|
|
monkeypatch.setattr(routes, "bad", _bad)
|
|
return cap
|
|
|
|
|
|
def _capture_upload(monkeypatch):
|
|
cap = {}
|
|
|
|
def _j(_h, obj, *_, **kwargs):
|
|
cap["ok"] = obj
|
|
cap["status"] = kwargs.get("status", 200)
|
|
return True
|
|
|
|
monkeypatch.setattr(upload, "j", _j)
|
|
return cap
|
|
|
|
|
|
class _SimpleSession:
|
|
def __init__(self, sid, profile="default", workspace="/workspace", messages=None, context_messages=None, pending_user_message=None):
|
|
self.session_id = sid
|
|
self.profile = profile
|
|
self.workspace = workspace
|
|
self.model = "test-model"
|
|
self.model_provider = None
|
|
self.title = "Test"
|
|
self.messages = messages or []
|
|
self.tool_calls = []
|
|
self.project_id = None
|
|
self.context_messages = context_messages
|
|
self.pending_user_message = pending_user_message
|
|
self.personality = None
|
|
self.enabled_toolsets = None
|
|
self.context_length = None
|
|
self.threshold_tokens = None
|
|
self.truncation_watermark = None
|
|
self.truncation_boundary = None
|
|
self.gateway_routing = None
|
|
self.gateway_routing_history = []
|
|
self.llm_title_generated = False
|
|
self.manual_title = False
|
|
self.composer_draft = {}
|
|
self.context_engine = None
|
|
self.context_engine_state = {}
|
|
self.input_tokens = 0
|
|
self.output_tokens = 0
|
|
self.estimated_cost = 0.0
|
|
self.cache_read_tokens = 0
|
|
self.cache_write_tokens = 0
|
|
|
|
|
|
def test_session_duplicate_foreign_profile_session_blocked_by_visibility_guard(monkeypatch):
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_duplicate", profile="other")
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: foreign)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "foreign_duplicate"})
|
|
monkeypatch.setattr(routes.Session, "load", staticmethod(lambda sid: (_ for _ in ()).throw(AssertionError("duplicate should not materialize foreign session"))))
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/session/duplicate"))
|
|
|
|
# #7710: the generic request-guard mirrors the detail-load endpoint's
|
|
# contract — a session owned by a KNOWN other profile yields
|
|
# 409 ``session_profile_mismatch`` so the client can offer to
|
|
# switch to it (#5419). The 404 self-heal path is preserved for
|
|
# the None-profile (unknown/legacy) case.
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "foreign_duplicate",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_session_duplicate_same_profile_still_duplicates(monkeypatch):
|
|
handler = _FakeHandler()
|
|
source = _SimpleSession("session_visible", profile="default")
|
|
calls = {"load": 0, "save": 0}
|
|
|
|
def _load(_sid):
|
|
calls["load"] += 1
|
|
return source
|
|
|
|
monkeypatch.setattr(routes.Session, "load", staticmethod(_load))
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: source)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "session_visible"})
|
|
|
|
def _session_save(_self, *_, **__):
|
|
calls["save"] += 1
|
|
|
|
monkeypatch.setattr(routes.Session, "save", _session_save)
|
|
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *_, **__: None)
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/session/duplicate"))
|
|
|
|
assert calls["load"] == 1
|
|
assert calls["save"] == 1
|
|
assert "bad" not in cap
|
|
assert cap["ok"]["session"]["session_id"] != "session_visible"
|
|
|
|
|
|
def test_file_read_foreign_profile_session_returns_404_before_file_ops(monkeypatch):
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_file", profile="other", workspace="/workspace")
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: foreign)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "get_session_for_file_ops", lambda sid: (_ for _ in ()).throw(AssertionError("file ops should not run")))
|
|
cap = _capture(monkeypatch)
|
|
|
|
routes.handle_get(handler, urlparse("/api/file?session_id=foreign_file&path=notes.txt"))
|
|
|
|
# #7710: see line 111 above — same generic-guard contract.
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "foreign_file",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_chat_start_foreign_persisted_session_returns_404_before_start_run(monkeypatch):
|
|
handler = _FakeHandler()
|
|
persisted_foreign = _SimpleSession(
|
|
"chat_foreign",
|
|
profile="other",
|
|
messages=[{"role": "user", "content": "first"}],
|
|
)
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": "chat_foreign", "message": "hello"})
|
|
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid, **_kwargs: persisted_foreign)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "_start_run", lambda *_, **__: (_ for _ in ()).throw(AssertionError("_start_run should not run")))
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/chat/start"))
|
|
|
|
# #7710: see line 111 above — same generic-guard contract.
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "chat_foreign",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_chat_start_body_profile_cannot_retag_visible_empty_session_without_active_profile(monkeypatch):
|
|
handler = _FakeHandler()
|
|
empty_visible = _SimpleSession("chat_empty", profile="default", messages=[])
|
|
captured = {}
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"read_body",
|
|
lambda _handler: {"session_id": "chat_empty", "message": "hello", "profile": "other"},
|
|
)
|
|
monkeypatch.setattr(routes, "_get_or_materialize_session", lambda sid, **_kwargs: empty_visible)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda *_args, **_kwargs: "/workspace")
|
|
monkeypatch.setattr(routes, "_read_profile_model_config", lambda *_args, **_kwargs: (None, None, None))
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_resolve_compatible_session_model_state",
|
|
lambda *_args, **_kwargs: ("test-model", None, "test-model"),
|
|
)
|
|
|
|
def _start_run(session, **_kwargs):
|
|
captured["profile"] = session.profile
|
|
return {"ok": True, "stream_id": "stream-test", "session_id": session.session_id}
|
|
|
|
monkeypatch.setattr(routes, "_start_run", _start_run)
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/chat/start"))
|
|
|
|
assert "bad" not in cap
|
|
assert captured["profile"] == "default"
|
|
|
|
|
|
def test_attachment_upload_foreign_profile_session_returns_404_before_write(monkeypatch):
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("upload_foreign", profile="other")
|
|
monkeypatch.setattr(upload, "parse_multipart", lambda *_args: ({"session_id": "upload_foreign"}, {"file": ("note.txt", b"x")}))
|
|
monkeypatch.setattr(upload, "get_session", lambda sid: foreign)
|
|
monkeypatch.setattr(upload, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(
|
|
upload,
|
|
"_upload_destination",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("upload destination should not be resolved")),
|
|
)
|
|
|
|
cap = _capture_upload(monkeypatch)
|
|
upload.handle_upload(handler)
|
|
|
|
assert cap == {"ok": {"error": "Session not found"}, "status": 404}
|
|
|
|
|
|
def test_archive_upload_extract_foreign_profile_session_returns_404_before_extract(monkeypatch):
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("extract_foreign", profile="other")
|
|
monkeypatch.setattr(upload, "parse_multipart", lambda *_args: ({"session_id": "extract_foreign"}, {"file": ("archive.zip", b"zip")}))
|
|
monkeypatch.setattr(upload, "get_session", lambda sid: foreign)
|
|
monkeypatch.setattr(upload, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(
|
|
upload,
|
|
"extract_archive",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("archive extraction should not run")),
|
|
)
|
|
|
|
cap = _capture_upload(monkeypatch)
|
|
upload.handle_upload_extract(handler)
|
|
|
|
assert cap == {"ok": {"error": "Session not found"}, "status": 404}
|
|
|
|
|
|
def test_workspace_upload_foreign_profile_session_returns_404_before_workspace_resolution(monkeypatch):
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("workspace_foreign", profile="other", workspace="/workspace/foreign")
|
|
monkeypatch.setattr(upload, "parse_multipart", lambda *_args: ({"session_id": "workspace_foreign"}, {"file": ("note.txt", b"x")}))
|
|
monkeypatch.setattr(upload, "get_session", lambda sid: foreign)
|
|
monkeypatch.setattr(upload, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(
|
|
upload,
|
|
"resolve_trusted_workspace",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("workspace should not be resolved")),
|
|
)
|
|
|
|
cap = _capture_upload(monkeypatch)
|
|
upload.handle_workspace_upload(handler)
|
|
|
|
assert cap == {"ok": {"error": "Session not found"}, "status": 404}
|
|
|
|
|
|
def test_chat_stream_status_blocks_foreign_active_stream(monkeypatch):
|
|
from api import config
|
|
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_session", profile="other")
|
|
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: foreign if sid == "foreign_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
previous = dict(config.ACTIVE_RUNS)
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS["stream-foreign"] = {
|
|
"session_id": "foreign_session",
|
|
"started_at": time.time(),
|
|
"phase": "running",
|
|
}
|
|
|
|
try:
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_get(handler, urlparse("/api/chat/stream/status?stream_id=stream-foreign"))
|
|
finally:
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS.update(previous)
|
|
|
|
# #7710: see line 111 above — same generic-guard contract.
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "foreign_session",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_chat_stream_status_blocks_foreign_registered_stream_before_worker_start(monkeypatch):
|
|
from api import config
|
|
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_session", profile="other")
|
|
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda sid, metadata_only=False: foreign
|
|
if sid == "foreign_session"
|
|
else (_ for _ in ()).throw(KeyError("Session not found")),
|
|
)
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"find_run_summary",
|
|
lambda _stream_id: (_ for _ in ()).throw(AssertionError("journal fallback should not run")),
|
|
)
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
previous_runs = dict(config.ACTIVE_RUNS)
|
|
config.ACTIVE_RUNS.clear()
|
|
with config.STREAM_SESSION_OWNERS_LOCK:
|
|
previous_owners = dict(config.STREAM_SESSION_OWNERS)
|
|
config.STREAM_SESSION_OWNERS.clear()
|
|
config.register_stream_owner("stream-registered", "foreign_session")
|
|
|
|
try:
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_get(handler, urlparse("/api/chat/stream/status?stream_id=stream-registered"))
|
|
finally:
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS.update(previous_runs)
|
|
with config.STREAM_SESSION_OWNERS_LOCK:
|
|
config.STREAM_SESSION_OWNERS.clear()
|
|
config.STREAM_SESSION_OWNERS.update(previous_owners)
|
|
|
|
# #7710: see line 111 above — same generic-guard contract.
|
|
# The helper resolves the stream to its OWNER session id (not the
|
|
# stream id), so the mismatch payload carries the owner session id.
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "foreign_session",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_chat_stream_status_keeps_same_profile_stream_visible(monkeypatch):
|
|
from api import config
|
|
|
|
handler = _FakeHandler()
|
|
visible = _SimpleSession("visible_session", profile="default")
|
|
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: visible if sid == "visible_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
previous = dict(config.ACTIVE_RUNS)
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS["stream-visible"] = {
|
|
"session_id": "visible_session",
|
|
"started_at": time.time(),
|
|
"phase": "running",
|
|
}
|
|
|
|
try:
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_get(handler, urlparse("/api/chat/stream/status?stream_id=stream-visible"))
|
|
finally:
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS.update(previous)
|
|
|
|
assert cap["ok"]["stream_id"] == "stream-visible"
|
|
assert cap["ok"]["active"] is False
|
|
|
|
|
|
def test_chat_cancel_blocks_foreign_owned_stream_before_cancel_call(monkeypatch):
|
|
from api import runtime_adapter
|
|
from api import config
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_session", profile="other")
|
|
calls = {"cancel": 0}
|
|
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: foreign if sid == "foreign_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
previous = dict(config.ACTIVE_RUNS)
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS["stream-foreign"] = {
|
|
"session_id": "foreign_session",
|
|
"started_at": time.time(),
|
|
"phase": "running",
|
|
}
|
|
monkeypatch.setattr(runtime_adapter, "runtime_adapter_enabled", lambda: False)
|
|
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: calls.__setitem__("cancel", calls["cancel"] + 1) or True)
|
|
|
|
cap = _capture(monkeypatch)
|
|
try:
|
|
routes.handle_get(
|
|
handler,
|
|
urlparse("/api/chat/cancel?stream_id=stream-foreign"),
|
|
)
|
|
finally:
|
|
with config.ACTIVE_RUNS_LOCK:
|
|
config.ACTIVE_RUNS.clear()
|
|
config.ACTIVE_RUNS.update(previous)
|
|
|
|
assert calls["cancel"] == 0
|
|
# #7710: see line 111 above — same generic-guard contract.
|
|
# The helper resolves the stream to its OWNER session id (not the
|
|
# stream id), so the mismatch payload carries the owner session id.
|
|
# This test's stream is owned by ``foreign_session`` (see the
|
|
# ACTIVE_RUNS fixture above).
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "foreign_session",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_chat_cancel_same_profile_stream_still_passes_through(monkeypatch):
|
|
from api import runtime_adapter
|
|
handler = _FakeHandler()
|
|
visible = _SimpleSession("visible_session", profile="default")
|
|
calls = {"cancel": 0}
|
|
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: visible if sid == "visible_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(runtime_adapter, "runtime_adapter_enabled", lambda: False)
|
|
monkeypatch.setattr(routes, "cancel_stream", lambda _stream_id: calls.__setitem__("cancel", calls["cancel"] + 1) or True)
|
|
|
|
cap = _capture(monkeypatch)
|
|
|
|
with routes.ACTIVE_RUNS_LOCK:
|
|
previous = dict(routes.ACTIVE_RUNS)
|
|
routes.ACTIVE_RUNS.clear()
|
|
routes.ACTIVE_RUNS["stream-visible"] = {
|
|
"session_id": "visible_session",
|
|
"started_at": time.time(),
|
|
"phase": "running",
|
|
}
|
|
|
|
try:
|
|
routes.handle_get(handler, urlparse("/api/chat/cancel?stream_id=stream-visible"))
|
|
finally:
|
|
with routes.ACTIVE_RUNS_LOCK:
|
|
routes.ACTIVE_RUNS.clear()
|
|
routes.ACTIVE_RUNS.update(previous)
|
|
|
|
assert calls["cancel"] == 1
|
|
assert cap["ok"]["cancelled"] is True
|
|
|
|
|
|
def test_chat_stream_blocks_foreign_owned_dead_stream_before_replay(monkeypatch):
|
|
from api import runtime_adapter
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_session", profile="other")
|
|
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: foreign if sid == "foreign_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(runtime_adapter, "runtime_adapter_enabled", lambda: False)
|
|
monkeypatch.setattr(routes, "_stream_runner_run_events", lambda *_args, **_kwargs: False)
|
|
monkeypatch.setattr(routes, "find_run_summary", lambda _stream_id: {"session_id": "foreign_session", "terminal": False})
|
|
monkeypatch.setattr(routes, "_replay_run_journal", lambda *_, **__: (_ for _ in ()).throw(AssertionError("replay should not run")))
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_get(handler, urlparse("/api/chat/stream?stream_id=stream-dead-foreign"))
|
|
|
|
# #7710: see line 111 above — same generic-guard contract.
|
|
# The helper resolves the stream to its OWNER session id (not the
|
|
# stream id), so the mismatch payload carries the owner session id.
|
|
# This test's stream is owned by ``foreign_session`` (see the
|
|
# ``find_run_summary`` mock above).
|
|
assert cap["ok"] == {
|
|
"error": "Session belongs to a different profile",
|
|
"code": "session_profile_mismatch",
|
|
"session_id": "foreign_session",
|
|
"profile": "other",
|
|
}
|
|
|
|
|
|
def test_chat_stream_allows_unknown_dead_stream_fallback_replay_path(monkeypatch):
|
|
handler = _FakeHandler()
|
|
calls = {"replay": 0}
|
|
|
|
monkeypatch.setattr(routes, "get_session", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("session lookup should be avoided")))
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "_stream_runner_run_events", lambda *_args, **_kwargs: False)
|
|
monkeypatch.setattr(routes, "find_run_summary", lambda _stream_id: None)
|
|
monkeypatch.setattr(routes, "_replay_run_journal", lambda *_args, **_kwargs: calls.__setitem__("replay", calls["replay"] + 1))
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_get(handler, urlparse("/api/chat/stream?stream_id=does-not-exist"))
|
|
|
|
assert cap["ok"] == {"error": "stream not found"}
|
|
assert calls["replay"] == 0
|
|
|
|
|
|
def test_session_new_skips_prev_session_commit_from_other_profile(monkeypatch):
|
|
"""Cross-profile prev_session_id after a profile switch must not 404 (#5420)."""
|
|
import api.session_lifecycle as session_lifecycle
|
|
handler = _FakeHandler()
|
|
foreign = _SimpleSession("foreign_session", profile="other")
|
|
calls = {"commit": 0, "new": 0}
|
|
|
|
class _NewSession:
|
|
def __init__(self):
|
|
self.session_id = "new_session"
|
|
self.messages = []
|
|
|
|
def compact(self):
|
|
return {"session_id": self.session_id}
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"prev_session_id": "foreign_session"})
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: foreign if sid == "foreign_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(session_lifecycle, "commit_session_memory", lambda sid, agent=None: calls.__setitem__("commit", calls["commit"] + 1))
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"new_session",
|
|
lambda **_kwargs: calls.__setitem__("new", calls["new"] + 1) or _NewSession(),
|
|
)
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/session/new"))
|
|
|
|
assert calls["commit"] == 0
|
|
assert calls["new"] == 1
|
|
assert "bad" not in cap
|
|
assert cap["ok"]["session"]["session_id"] == "new_session"
|
|
|
|
|
|
def test_session_new_keeps_prev_session_commit_for_same_profile(monkeypatch):
|
|
import api.session_lifecycle as session_lifecycle
|
|
handler = _FakeHandler()
|
|
visible = _SimpleSession("visible_session", profile="default")
|
|
calls = {"commit": 0, "new": 0}
|
|
|
|
class _NewSession:
|
|
def __init__(self):
|
|
self.session_id = "new_session"
|
|
self.messages = []
|
|
|
|
def compact(self):
|
|
return {"session_id": self.session_id}
|
|
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"prev_session_id": "visible_session"})
|
|
monkeypatch.setattr(routes, "_get_active_profile_name", lambda: "default")
|
|
monkeypatch.setattr(routes, "get_session", lambda sid, metadata_only=False: visible if sid == "visible_session" else (_ for _ in ()).throw(KeyError("Session not found")))
|
|
monkeypatch.setattr(session_lifecycle, "commit_session_memory", lambda sid, agent=None: calls.__setitem__("commit", calls["commit"] + 1))
|
|
monkeypatch.setattr(routes, "new_session", lambda **_kwargs: calls.__setitem__("new", calls["new"] + 1) or _NewSession())
|
|
|
|
cap = _capture(monkeypatch)
|
|
routes.handle_post(handler, urlparse("/api/session/new"))
|
|
|
|
assert calls["commit"] == 1
|
|
assert calls["new"] == 1
|
|
assert cap["ok"]["session"]["session_id"] == "new_session"
|
|
|
|
|
|
def test_stream_owner_unregistered_on_worker_early_return(monkeypatch):
|
|
"""A stream cancelled before the worker starts (q is None) must not leak its owner entry.
|
|
|
|
The route layer registers the stream owner synchronously before launching the
|
|
worker. If the stream is cancelled before the worker reaches `q = STREAMS.get(...)`,
|
|
the worker early-returns before register_active_run / the teardown finally — so the
|
|
owner entry must be released on that early-return path or STREAM_SESSION_OWNERS leaks.
|
|
"""
|
|
from api import config
|
|
import api.streaming as streaming
|
|
|
|
with config.STREAM_SESSION_OWNERS_LOCK:
|
|
previous = dict(config.STREAM_SESSION_OWNERS)
|
|
config.STREAM_SESSION_OWNERS.clear()
|
|
# Owner registered by the route layer, but the stream was already cancelled
|
|
# (never placed in STREAMS), so the worker will hit `q is None`.
|
|
config.register_stream_owner("leak-stream", "some_session")
|
|
try:
|
|
with config.STREAMS_LOCK:
|
|
config.STREAMS.pop("leak-stream", None)
|
|
streaming._run_agent_streaming(
|
|
"some_session", "hi", "m", "/tmp", "leak-stream",
|
|
)
|
|
with config.STREAM_SESSION_OWNERS_LOCK:
|
|
assert "leak-stream" not in config.STREAM_SESSION_OWNERS
|
|
finally:
|
|
with config.STREAM_SESSION_OWNERS_LOCK:
|
|
config.STREAM_SESSION_OWNERS.clear()
|
|
config.STREAM_SESSION_OWNERS.update(previous)
|