diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py index 847601e6..805f06fc 100644 --- a/backend/apps/swarm/entities/sessions.py +++ b/backend/apps/swarm/entities/sessions.py @@ -1,10 +1,12 @@ -"""SessionExportable: an agent card on a shared dashboard. We carry only the -recipe (name, model, mode, system prompt, allowed tools) and deliberately DROP -the chat transcript (privacy + size), runtime state, costs, the worktree path, -and active_mcps (importing must never silently grant tool access, per the gate). -Its MCP/actions, provider, and built-in mode become import requirements so the -importer is walked through enabling them. The dashboard re-points dashboard_id -after import.""" +"""SessionExportable: an agent card on a shared dashboard. We carry the recipe +(name, model, mode, system prompt, allowed tools) AND the chat transcript so a +shared agent arrives with the conversation that produced it, that's the whole +point of sharing one. The transcript rides through the same scrub layer as every +payload, so any secret-shaped string in it is redacted before it leaves. We still +DROP runtime state, costs, the worktree path, and active_mcps: importing must +never silently grant tool access, per the gate. Its MCP/actions, provider, and +built-in mode become import requirements so the importer is walked through +enabling them. The dashboard re-points dashboard_id after import.""" from __future__ import annotations from datetime import datetime, timezone @@ -14,7 +16,14 @@ from ..exportable import DepRef, ExportContext, RemapTable from ..models import EntityType, Requirement, RequirementKind _BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} -_KEEP = ("name", "provider", "model", "mode", "system_prompt", "allowed_tools", "max_turns", "thinking_level") +# Transcript fields ride along so the shared agent keeps its history; ids inside +# (message ids, branch ids, their parent/fork refs) are self-consistent within +# the one session file, so they carry verbatim with no remap. +_KEEP = ( + "name", "provider", "model", "mode", "system_prompt", "allowed_tools", + "max_turns", "thinking_level", + "messages", "branches", "active_branch_id", "tool_group_meta", +) class SessionExportable: @@ -70,6 +79,14 @@ class SessionExportable: from backend.apps.agents.manager.session.session_store import _save_session sid = uuid4().hex now = datetime.now(timezone.utc).isoformat() + # Older bundles (made before transcripts were carried) have no messages; + # fall back to a single empty main branch so the imported agent is valid. + branches = payload.get("branches") or { + "main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now} + } + active_branch_id = payload.get("active_branch_id") or "main" + if active_branch_id not in branches: + active_branch_id = next(iter(branches), "main") doc = { "id": sid, "name": payload.get("name") or "Agent", @@ -81,9 +98,10 @@ class SessionExportable: "allowed_tools": payload.get("allowed_tools") or [], "max_turns": payload.get("max_turns"), "thinking_level": payload.get("thinking_level") or "auto", - "messages": [], - "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}}, - "active_branch_id": "main", + "messages": payload.get("messages") or [], + "branches": branches, + "active_branch_id": active_branch_id, + "tool_group_meta": payload.get("tool_group_meta") or {}, "active_mcps": [], "dashboard_id": None, # the dashboard import re-points this "browser_id": None, diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fea9408c..9d08a595 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -168,23 +168,75 @@ def test_workflow_unavailable_on_this_branch(): WorkflowExportable.import_({"title": "x"}, {}, RemapTable()) -def test_session_export_strips_transcript_and_secrets(): +def test_session_export_carries_transcript_drops_runtime_and_secrets(): from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.redact import scrub_payload data = { "name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent", "system_prompt": "hi", "allowed_tools": ["Read"], - "messages": [{"role": "user", "content": "private chat"}], + "messages": [ + {"id": "m1", "role": "user", "content": "private chat", "branch_id": "main"}, + {"id": "m2", "role": "assistant", "content": "token is sk-ant-abcdefghij0123456789"}, + ], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}}, + "active_branch_id": "main", + "tool_group_meta": {"g1": {"label": "x"}}, "active_mcps": ["Gmail"], "cwd": "/Users/me/repo", "cost_usd": 9.9, "sdk_session_id": "x", } ex = SessionExportable("s1", "A", data) out = ex.serialize(None) - for gone in ("messages", "cwd", "active_mcps", "cost_usd", "sdk_session_id"): + # The transcript now rides along, that's the point of sharing an agent. + assert out["messages"][0]["content"] == "private chat" + assert out["active_branch_id"] == "main" and "main" in out["branches"] + assert out["tool_group_meta"] == {"g1": {"label": "x"}} + # Runtime, identity, and gate state still never leave. + for gone in ("cwd", "active_mcps", "cost_usd", "sdk_session_id"): assert gone not in out - assert out["model"] == "sonnet" and out["mode"] == "agent" + # The closure runs scrub_payload on every payload, so a secret-shaped + # string sitting in the transcript is redacted before it ships. + assert "sk-ant-" not in json.dumps(scrub_payload(out)) reqs = ex.requirements() assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs) +def test_session_import_restores_transcript_without_granting_mcp(monkeypatch): + from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.exportable import RemapTable + from backend.apps.agents.manager.session import session_store + saved: dict = {} + monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc})) + payload = { + "name": "A", "model": "sonnet", "mode": "agent", + "messages": [{"id": "m1", "role": "user", "content": "hi", "branch_id": "main"}], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}}, + "active_branch_id": "main", + "tool_group_meta": {"g1": {"label": "x"}}, + } + sid = SessionExportable.import_(payload, {}, RemapTable()) + doc = saved[sid] + assert doc["messages"][0]["content"] == "hi" + assert doc["active_branch_id"] == "main" + assert doc["tool_group_meta"] == {"g1": {"label": "x"}} + # The gate stays shut: a shared agent never arrives with MCP access. + assert doc["active_mcps"] == [] + # The dashboard import re-points this; it must never be the sharer's id. + assert doc["dashboard_id"] is None + + +def test_session_import_old_bundle_without_transcript(monkeypatch): + # A bundle made before transcripts were carried has no messages; it must + # still import as a valid empty-history agent (single main branch), not crash. + from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.exportable import RemapTable + from backend.apps.agents.manager.session import session_store + saved: dict = {} + monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc})) + sid = SessionExportable.import_({"name": "Old", "model": "sonnet"}, {}, RemapTable()) + doc = saved[sid] + assert doc["messages"] == [] + assert doc["active_branch_id"] == "main" and "main" in doc["branches"] + + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable from backend.apps.swarm.models import EntityType