diff --git a/backend/chainlit/emitter.py b/backend/chainlit/emitter.py index 97a01106ee..4b3caeae09 100644 --- a/backend/chainlit/emitter.py +++ b/backend/chainlit/emitter.py @@ -13,6 +13,7 @@ from chainlit.mode import Mode from chainlit.session import BaseSession, WebsocketSession from chainlit.step import StepDict +from chainlit.thread_metadata import public_thread from chainlit.types import ( AskActionResponse, AskElementResponse, @@ -193,8 +194,14 @@ def emit_call(self): return self._get_session_property("emit_call") def resume_thread(self, thread_dict: ThreadDict): - """Send a thread to the UI to resume it""" - return self.emit("resume_thread", thread_dict) + """Send a thread to the UI with only public metadata. + + Args: + thread_dict: Internal thread whose session metadata must be preserved. + """ + return self.emit( + "resume_thread", public_thread(thread_dict, include_settings=True) + ) def send_resume_thread_error(self, error: str): """Send a thread resume error to the UI""" diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 68238cd254..931eac5e4e 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -60,6 +60,7 @@ from chainlit.markdown import get_markdown_str from chainlit.oauth_providers import get_oauth_provider from chainlit.secret import random_secret +from chainlit.thread_metadata import public_thread from chainlit.types import ( AskFileSpec, CallActionRequest, @@ -970,7 +971,13 @@ async def get_user_threads( payload: GetThreadsRequest, current_user: UserParam, ): - """Get the threads page by page.""" + """Get paginated threads with public metadata. + + Args: + request: Incoming HTTP request. + payload: Pagination and thread filters. + current_user: Authenticated owner of the requested threads. + """ data_layer = get_data_layer() @@ -989,7 +996,9 @@ async def get_user_threads( payload.filter.userId = current_user.id res = await data_layer.list_threads(payload.pagination, payload.filter) - return JSONResponse(content=res.to_dict()) + response = res.to_dict() + response["data"] = [public_thread(thread) for thread in response["data"]] + return JSONResponse(content=response) @router.get("/project/thread/{thread_id}") @@ -998,7 +1007,13 @@ async def get_thread( thread_id: str, current_user: UserParam, ): - """Get a specific thread.""" + """Get an owner's thread with public metadata. + + Args: + request: Incoming HTTP request. + thread_id: Identifier of the requested thread. + current_user: Authenticated thread owner. + """ data_layer = get_data_layer() if not data_layer: @@ -1010,7 +1025,7 @@ async def get_thread( await is_thread_author(current_user.identifier, thread_id) res = await data_layer.get_thread(thread_id) - return JSONResponse(content=res) + return JSONResponse(content=public_thread(res, include_settings=True)) @router.get("/project/share/{thread_id}") @@ -1024,6 +1039,11 @@ async def get_shared_thread( This endpoint is separate from the resume endpoint and does not require the caller to be the author of the thread. It only returns the thread if its metadata contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence. + + Args: + request: Incoming HTTP request. + thread_id: Identifier of the requested shared thread. + current_user: Viewer passed to the optional access callback. """ data_layer = get_data_layer() @@ -1061,11 +1081,7 @@ async def get_shared_thread( if (not user_can_view) and (not is_shared): raise HTTPException(status_code=404, detail="Thread not found") - metadata.pop("chat_profile", None) - metadata.pop("chat_settings", None) - metadata.pop("env", None) - thread["metadata"] = metadata - return JSONResponse(content=thread) + return JSONResponse(content=public_thread(thread)) @router.get("/project/thread/{thread_id}/element/{element_id}") diff --git a/backend/chainlit/thread_metadata.py b/backend/chainlit/thread_metadata.py new file mode 100644 index 0000000000..d7e121b9bc --- /dev/null +++ b/backend/chainlit/thread_metadata.py @@ -0,0 +1,37 @@ +"""Filter thread metadata at browser response boundaries.""" + +import json +from typing import Optional + +from chainlit.types import ThreadDict + + +def public_thread( + thread: Optional[ThreadDict], *, include_settings: bool = False +) -> Optional[ThreadDict]: + """Copy a thread with only browser-visible metadata, preserving stored state. + + Args: + thread: Internal thread returned by the data layer, or None. + include_settings: Include profile and settings for an owner's chat view. + """ + if thread is None: + return None + + metadata = thread.get("metadata") or {} + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except (TypeError, ValueError): + metadata = {} + if not isinstance(metadata, dict): + metadata = {} + + allowed = {"is_shared", "viewer_read_only"} + if include_settings: + allowed.update({"chat_profile", "chat_settings"}) + + return { + **thread, + "metadata": {key: value for key, value in metadata.items() if key in allowed}, + }