[eric] swarm: carry the chat transcript when sharing an agent (was dropped, scrub layer guards secrets)

This commit is contained in:
ciregenz
2026-06-15 18:58:44 -07:00
parent 7a91abba8d
commit 39c837fee7
2 changed files with 85 additions and 15 deletions
+29 -11
View File
@@ -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,
+56 -4
View File
@@ -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