mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 03:09:41 +02:00
[eric] swarm: carry the chat transcript when sharing an agent (was dropped, scrub layer guards secrets)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user