mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
641 lines
29 KiB
Python
641 lines
29 KiB
Python
"""Tests for the .swarm bundle engine: skill round-trip, secret redaction, and
|
|
the zip-hardening rejections. The skills store writes to ~/.claude/skills, so we
|
|
monkeypatch it into a temp dir per test (the conftest only isolates browser
|
|
state)."""
|
|
import io
|
|
import json
|
|
import os
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from backend.apps.skills import skills as store
|
|
from backend.apps.swarm import closure
|
|
from backend.apps.swarm.models import EntityType
|
|
from backend.apps.swarm.redact import find_denied_keys, scrub_payload
|
|
from backend.apps.swarm.ziputil import BundleError, pack, unpack
|
|
|
|
|
|
@pytest.fixture
|
|
def skill_store(tmp_path, monkeypatch):
|
|
d = tmp_path / "skills"
|
|
d.mkdir()
|
|
monkeypatch.setattr(store, "SKILLS_DIR", str(d))
|
|
monkeypatch.setattr(store, "INDEX_PATH", str(d / ".skills_index.json"))
|
|
return d
|
|
|
|
|
|
def p_make_skill(d, slug, name, content, description="desc"):
|
|
(d / f"{slug}.md").write_text(content, encoding="utf-8")
|
|
index = store.load_index()
|
|
index[slug] = {"name": name, "description": description, "command": slug}
|
|
store.save_index(index)
|
|
|
|
|
|
def test_skill_export_import_round_trip(skill_store):
|
|
p_make_skill(skill_store, "my-skill", "My Skill", "# hello\nbody text")
|
|
raw, name = closure.build_bundle(EntityType.skill, "my-skill")
|
|
assert name == "My Skill"
|
|
assert zipfile.is_zipfile(io.BytesIO(raw))
|
|
|
|
sandbox, manifest, warnings = closure.stage_upload(raw, "My Skill.swarm")
|
|
try:
|
|
assert manifest.root.type == EntityType.skill
|
|
root_type, root_id, created, unresolved = closure.commit(sandbox, manifest, [])
|
|
finally:
|
|
import shutil
|
|
shutil.rmtree(sandbox, ignore_errors=True)
|
|
|
|
# Original is untouched, import lands under a fresh, non-clobbering slug.
|
|
assert root_type == EntityType.skill
|
|
assert root_id != "my-skill"
|
|
assert (skill_store / "my-skill.md").exists() # original flat skill untouched
|
|
assert (skill_store / root_id / "SKILL.md").read_text(encoding="utf-8") == "# hello\nbody text"
|
|
assert created == {"skill": [root_id]}
|
|
|
|
|
|
def test_bare_markdown_import(skill_store):
|
|
sandbox, manifest, warnings = closure.stage_upload(b"# Just markdown", "Cool Trick.md")
|
|
try:
|
|
assert manifest.root.type == EntityType.skill
|
|
assert manifest.root.name == "Cool Trick"
|
|
p_t, root_id, created, p_u = closure.commit(sandbox, manifest, [])
|
|
finally:
|
|
import shutil
|
|
shutil.rmtree(sandbox, ignore_errors=True)
|
|
assert (skill_store / root_id / "SKILL.md").read_text(encoding="utf-8") == "# Just markdown"
|
|
|
|
|
|
def test_content_secret_redacted_in_bundle(skill_store):
|
|
secret = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA"
|
|
p_make_skill(skill_store, "leaky", "Leaky", f"use this key: {secret}")
|
|
raw, p_name = closure.build_bundle(EntityType.skill, "leaky")
|
|
# Inspect the actual packed payload (zip entries are compressed, so grepping
|
|
# the raw bytes proves nothing).
|
|
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
|
payload_name = next(n for n in zf.namelist() if n.endswith("payload.json"))
|
|
payload = json.loads(zf.read(payload_name))
|
|
assert secret not in payload["content"]
|
|
assert "[redacted]" in payload["content"]
|
|
|
|
|
|
def test_redaction_drops_denied_keys():
|
|
payload = {
|
|
"name": "ok",
|
|
"anthropic_api_key": "sk-ant-secret",
|
|
"nested": {"openswarm_bearer_token": "abc", "keep": 1},
|
|
"list": [{"oauth_tokens": {"x": 1}}, {"fine": 2}],
|
|
}
|
|
cleaned = scrub_payload(payload)
|
|
assert find_denied_keys(cleaned) == []
|
|
assert cleaned["name"] == "ok"
|
|
assert cleaned["nested"]["keep"] == 1
|
|
assert cleaned["list"][1]["fine"] == 2
|
|
|
|
|
|
def test_pack_refuses_denied_key():
|
|
# Defense in depth: even if redaction were skipped, pack must not ship a secret.
|
|
with pytest.raises(BundleError):
|
|
pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {})
|
|
|
|
|
|
def test_pack_refuses_secret_in_workspace_file():
|
|
# A key hardcoded in app source (not .env) must not ride along; pack scans
|
|
# file bytes, not just payload keys.
|
|
leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n"
|
|
with pytest.raises(BundleError):
|
|
pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak})
|
|
|
|
|
|
def test_pack_allows_clean_workspace_file():
|
|
raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/app.js": b"export default 1"})
|
|
assert zipfile.is_zipfile(io.BytesIO(raw))
|
|
|
|
|
|
def test_app_export_drops_machine_env(tmp_path, monkeypatch):
|
|
# The live .env holds the source machine's absolute paths + pinned port; it
|
|
# must never ride along. .env.example (portable) does.
|
|
from backend.apps.swarm.entities import apps as appmod
|
|
from backend.apps.outputs.models import Output
|
|
|
|
ws = tmp_path / "ws"
|
|
(ws / "frontend").mkdir(parents=True)
|
|
(ws / ".env").write_text("FRONTEND_PORT=5\nOPENSWARM_TEMPLATE_BACKEND_PATH=/Users/SECRET/x\n")
|
|
(ws / ".env.example").write_text("BACKEND_PORT=NONE\nFRONTEND_PORT=4949\n")
|
|
(ws / "frontend" / "App.tsx").write_text("export default () => null")
|
|
monkeypatch.setattr(appmod, "OUTPUTS_WORKSPACE_DIR", str(tmp_path))
|
|
|
|
ex = appmod.AppExportable(Output(name="A", workspace_id="ws"))
|
|
files = ex.files()
|
|
assert "workspace/.env.example" in files
|
|
assert "workspace/.env" not in files
|
|
assert "workspace/frontend/App.tsx" in files
|
|
assert b"/Users/SECRET" not in b"".join(files.values())
|
|
|
|
|
|
def test_workflow_sanitize_disables_schedule_and_strips_pii():
|
|
from backend.apps.swarm.entities.workflows import sanitize_workflow
|
|
raw = {
|
|
"id": "wf123",
|
|
"title": "Daily digest",
|
|
"steps": [{"id": "s1", "text": "do thing"}],
|
|
"schedule": {"enabled": True, "runs_count": 5, "next_run_at": "2026-01-01T00:00:00", "hour": 9},
|
|
"permissions": [{"kind": "text", "after_minutes": 30, "phone": "+15551234567"}],
|
|
"source_session_id": "sess1",
|
|
"dashboard_id": "dash1",
|
|
"last_run_status": "success",
|
|
"mode": "agent",
|
|
"provider": "anthropic",
|
|
}
|
|
out = sanitize_workflow(raw)
|
|
# An imported workflow must not auto-run or carry the sharer's identity.
|
|
assert out["schedule"]["enabled"] is False
|
|
assert out["schedule"]["runs_count"] == 0
|
|
assert out["schedule"]["hour"] == 9 # cadence shape preserved
|
|
assert out["permissions"][0]["phone"] is None
|
|
for dropped in ("id", "source_session_id", "dashboard_id", "last_run_status"):
|
|
assert dropped not in out
|
|
assert out["title"] == "Daily digest"
|
|
|
|
|
|
def test_workflow_round_trips_through_the_store(isolated_workflows_data):
|
|
# The workflow store landed on this branch, so a workflow bundle imports into an
|
|
# (isolated) store: an unknown id loads as None, import_ creates a fresh row with its
|
|
# schedule forced OFF (so an imported workflow never auto-runs on someone else's machine),
|
|
# and load reads it back. Supersedes test_workflow_unavailable_on_this_branch, which dated
|
|
# from before the workflow store was on eric/dev.
|
|
from backend.apps.swarm.entities.workflows import WorkflowExportable
|
|
from backend.apps.swarm.exportable import RemapTable
|
|
from backend.apps.workflows import storage
|
|
assert WorkflowExportable.load("nonexistent") is None
|
|
new_id = WorkflowExportable.import_(
|
|
{"title": "Shared WF", "schedule": {"enabled": True}}, {}, RemapTable()
|
|
)
|
|
assert new_id
|
|
loaded = WorkflowExportable.load(new_id)
|
|
assert loaded is not None
|
|
assert loaded.name == "Shared WF"
|
|
# Read the persisted row back through the store's public API (not the entity's
|
|
# private data) to confirm the schedule was forced off on import.
|
|
saved = storage.get_workflow(new_id)
|
|
assert saved is not None and saved.schedule.enabled is False
|
|
|
|
|
|
def test_session_export_carries_transcript_drops_runtime_and_secrets():
|
|
from backend.apps.swarm.entities.SessionExportable 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": [
|
|
{"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)
|
|
# 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
|
|
# 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.SessionExportable 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.SessionExportable 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_session_load_prefers_live_memory_over_stale_disk(tmp_path, monkeypatch):
|
|
# The freshest transcript lives in memory; a disk-only load would ship a
|
|
# stale one. load() must read the live session first, disk only as fallback.
|
|
from backend.apps.agents import agent_manager as am
|
|
from backend.apps.swarm.entities.SessionExportable import SessionExportable
|
|
sdir = tmp_path / "sessions"
|
|
sdir.mkdir()
|
|
monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
|
|
(sdir / "s1.json").write_text(json.dumps(
|
|
{"name": "Stale", "messages": [{"id": "old", "role": "user", "content": "old"}]}))
|
|
|
|
class FakeSess:
|
|
def model_dump(self, mode="json"):
|
|
return {"name": "Live", "messages": [
|
|
{"id": "old", "role": "user", "content": "old"},
|
|
{"id": "new", "role": "assistant", "content": "fresh turn"},
|
|
]}
|
|
|
|
monkeypatch.setattr(am.agent_manager, "sessions", {"s1": FakeSess()})
|
|
out = SessionExportable.load("s1").serialize(None)
|
|
assert out["name"] == "Live" # not the stale disk copy
|
|
assert len(out["messages"]) == 2 # the unflushed turn is included
|
|
|
|
|
|
def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, monkeypatch):
|
|
# The path the single-session tests missed: a whole dashboard with agent
|
|
# cards + a browser card. Both agents (with their transcripts) and the
|
|
# browser must survive export -> import. An empty-history import is the bug
|
|
# the user hit ("the chats didn't even show up, let alone the history").
|
|
import shutil
|
|
from backend.apps.agents import agent_manager as am
|
|
import backend.config.paths as paths
|
|
sdir = tmp_path / "sessions"
|
|
ddir = tmp_path / "dashboards"
|
|
sdir.mkdir()
|
|
ddir.mkdir()
|
|
monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
|
|
monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir))
|
|
monkeypatch.setattr(am.agent_manager, "sessions", {}) # nothing live -> disk path
|
|
|
|
did, sid1, sid2, bkey = "d1", "sA", "sB", "browser-1"
|
|
|
|
def sess(sid, name, text):
|
|
return {
|
|
"id": sid, "name": name, "status": "completed", "provider": "anthropic",
|
|
"model": "sonnet", "mode": "agent", "allowed_tools": [],
|
|
"messages": [{"id": "m1", "role": "user", "content": text, "branch_id": "main"}],
|
|
"branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": "2026-01-01"}},
|
|
"active_branch_id": "main", "tool_group_meta": {}, "active_mcps": [], "dashboard_id": did,
|
|
}
|
|
|
|
(sdir / f"{sid1}.json").write_text(json.dumps(sess(sid1, "Agent One", "from one")))
|
|
(sdir / f"{sid2}.json").write_text(json.dumps(sess(sid2, "Agent Two", "from two")))
|
|
(ddir / f"{did}.json").write_text(json.dumps({"id": did, "name": "Board", "layout": {
|
|
"cards": {sid1: {"session_id": sid1}, sid2: {"session_id": sid2}},
|
|
"view_cards": {},
|
|
"browser_cards": {bkey: {"browser_id": bkey, "url": "u", "spawned_by": None}},
|
|
"notes": {}, "expanded_session_ids": [sid1],
|
|
}}))
|
|
|
|
raw, _ = closure.build_bundle(EntityType.dashboard, did)
|
|
sandbox, manifest, p_w = closure.stage_upload(raw, "board.swarm")
|
|
try:
|
|
p_rt, root_id, p_created, p_u = closure.commit(sandbox, manifest, [])
|
|
finally:
|
|
shutil.rmtree(sandbox, ignore_errors=True)
|
|
|
|
L = json.loads((ddir / f"{root_id}.json").read_text())["layout"]
|
|
assert len(L["cards"]) == 2, "both agent cards must survive import"
|
|
assert len(L["browser_cards"]) == 1, "the browser card must survive too"
|
|
total_msgs = 0
|
|
for sid in L["cards"]:
|
|
doc = json.loads((sdir / f"{sid}.json").read_text())
|
|
total_msgs += len(doc.get("messages") or [])
|
|
assert doc["active_mcps"] == [], "import must not grant MCP access"
|
|
assert total_msgs == 2, "each agent's transcript must carry through"
|
|
|
|
# The bug behind "the chats didn't even show up": after import the sessions
|
|
# are on disk but not in memory, and the dashboard-open fetch
|
|
# (get_all_sessions) was memory-only, so the cards rendered blank. The fetch
|
|
# must now see the freshly-imported sessions straight off disk.
|
|
found = am.agent_manager.get_all_sessions(dashboard_id=root_id)
|
|
assert len(found) == 2, f"dashboard-open fetch must see imported agent sessions, got {len(found)}"
|
|
assert sum(len(s.messages) for s in found) == 2, "and with their transcripts"
|
|
|
|
|
|
def test_get_all_sessions_does_not_resurrect_deleted_cards(tmp_path, monkeypatch):
|
|
# Deleting a card removes it from the layout but the session keeps its
|
|
# dashboard_id on disk. get_all_sessions must surface only sessions the
|
|
# layout still has a card for, or deleted chats come back on every reopen.
|
|
from backend.apps.agents import agent_manager as am
|
|
import backend.config.paths as paths
|
|
sdir = tmp_path / "sessions"
|
|
ddir = tmp_path / "dashboards"
|
|
sdir.mkdir()
|
|
ddir.mkdir()
|
|
monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
|
|
monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir))
|
|
monkeypatch.setattr(am.agent_manager, "sessions", {})
|
|
|
|
did = "d1"
|
|
|
|
def sess(sid):
|
|
return {
|
|
"id": sid, "name": sid, "status": "completed", "model": "sonnet",
|
|
"mode": "agent", "messages": [], "branches": {}, "active_branch_id": "main",
|
|
"dashboard_id": did,
|
|
}
|
|
|
|
(sdir / "kept.json").write_text(json.dumps(sess("kept")))
|
|
(sdir / "deleted.json").write_text(json.dumps(sess("deleted"))) # still tagged, card gone
|
|
# The layout has a card only for "kept" (the user deleted "deleted"'s card).
|
|
(ddir / f"{did}.json").write_text(json.dumps({"id": did, "layout": {"cards": {"kept": {"session_id": "kept"}}}}))
|
|
|
|
ids = {s.id for s in am.agent_manager.get_all_sessions(dashboard_id=did)}
|
|
assert "kept" in ids, "a session the layout still has a card for must surface"
|
|
assert "deleted" not in ids, "a session whose card was deleted must NOT resurrect"
|
|
|
|
|
|
def test_dashboard_serialize_rewrites_refs_to_bundle_ids():
|
|
from backend.apps.swarm.entities.dashboards import DashboardExportable
|
|
from backend.apps.swarm.models import EntityType
|
|
|
|
class Ctx:
|
|
def bundle_id_for(self, t: EntityType, lid: str):
|
|
return {("session", "S"): "SBID", ("app", "A"): "ABID"}.get((t.value, lid))
|
|
|
|
data = {"name": "D", "layout": {
|
|
"cards": {"S": {"session_id": "S", "x": 1}},
|
|
"view_cards": {"A": {"output_id": "A", "x": 2, "parent_session_id": "S"}},
|
|
"browser_cards": {"b1": {"browser_id": "b1", "url": "u", "spawned_by": "S"}},
|
|
"expanded_session_ids": ["S"],
|
|
}}
|
|
L = DashboardExportable("d1", "D", data).serialize(Ctx())["layout"]
|
|
assert L["cards"]["SBID"]["session_id"] == "SBID"
|
|
assert L["view_cards"]["ABID"]["output_id"] == "ABID"
|
|
# the app card's tether to its builder agent is a session id, so it remaps too
|
|
assert L["view_cards"]["ABID"]["parent_session_id"] == "SBID"
|
|
assert L["browser_cards"]["b1"]["spawned_by"] == "SBID"
|
|
assert L["expanded_session_ids"] == ["SBID"]
|
|
|
|
|
|
def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
|
|
from backend.apps.swarm.entities import dashboards as dmod
|
|
from backend.apps.swarm.exportable import RemapTable
|
|
|
|
written: dict = {}
|
|
monkeypatch.setattr(dmod, "p_write", lambda did, doc: written.update({did: doc}))
|
|
monkeypatch.setattr(dmod, "p_retag_sessions", lambda ids, did: None)
|
|
remap = RemapTable()
|
|
remap.assign("SBID", "newsess")
|
|
remap.assign("ABID", "newapp")
|
|
payload = {"name": "D", "layout": {
|
|
"cards": {"SBID": {"session_id": "SBID"}},
|
|
"view_cards": {
|
|
"ABID": {"output_id": "ABID", "parent_session_id": "SBID"},
|
|
"ABID2": {"output_id": "ABID2", "parent_session_id": "GONE"},
|
|
},
|
|
"browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}},
|
|
"expanded_session_ids": ["SBID", "ORPHAN"],
|
|
}}
|
|
remap.assign("ABID2", "newapp2")
|
|
did = dmod.DashboardExportable.import_(payload, {}, remap)
|
|
L = written[did]["layout"]
|
|
assert L["cards"]["newsess"]["session_id"] == "newsess"
|
|
assert L["view_cards"]["newapp"]["parent_session_id"] == "newsess"
|
|
assert L["view_cards"]["newapp2"]["parent_session_id"] is None # parent not in bundle
|
|
assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess"
|
|
assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped
|
|
|
|
|
|
def test_dashboard_remap_invariant_generative(monkeypatch):
|
|
# The hand-written remap tests only check the id-bearing fields I remembered.
|
|
# Generate random dashboards and assert the real invariant on a serialize ->
|
|
# import round-trip: no source-local id and no bundle id survives into the
|
|
# imported layout, and every card id is a freshly-minted local id. This is
|
|
# what catches "someone adds a new layout field holding a session id and
|
|
# forgets to remap it."
|
|
import random
|
|
|
|
from backend.apps.swarm.entities import dashboards as dmod
|
|
from backend.apps.swarm.exportable import RemapTable
|
|
from backend.apps.swarm.models import EntityType
|
|
|
|
written: dict = {}
|
|
monkeypatch.setattr(dmod, "p_write", lambda did, doc: written.update({did: doc}))
|
|
monkeypatch.setattr(dmod, "p_retag_sessions", lambda ids, did: None)
|
|
|
|
rng = random.Random(1234)
|
|
for _ in range(60):
|
|
sess = [f"S{i}" for i in range(rng.randint(0, 5))]
|
|
apps = [f"A{i}" for i in range(rng.randint(0, 4))]
|
|
s_bid = {s: f"sbid{i}" for i, s in enumerate(sess)}
|
|
a_bid = {a: f"abid{i}" for i, a in enumerate(apps)}
|
|
|
|
class Ctx:
|
|
def bundle_id_for(self, t, lid):
|
|
if t == EntityType.session:
|
|
return s_bid.get(lid)
|
|
if t == EntityType.app:
|
|
return a_bid.get(lid)
|
|
return None
|
|
|
|
layout = {
|
|
"cards": {s: {"session_id": s, "x": rng.randint(0, 9)} for s in sess},
|
|
"view_cards": {
|
|
a: {"output_id": a,
|
|
"parent_session_id": (rng.choice(sess + ["ORPHAN"]) if sess and rng.random() < 0.7 else None)}
|
|
for a in apps
|
|
},
|
|
"browser_cards": {
|
|
f"b{i}": {"browser_id": f"b{i}", "url": "u",
|
|
"spawned_by": (rng.choice(sess) if sess and rng.random() < 0.7 else None)}
|
|
for i in range(rng.randint(0, 3))
|
|
},
|
|
"expanded_session_ids": (sess + ["ORPHAN"]) if rng.random() < 0.5 else list(sess),
|
|
}
|
|
payload = dmod.DashboardExportable("d-src", "D", {"name": "D", "layout": layout}).serialize(Ctx())
|
|
|
|
remap = RemapTable()
|
|
fresh_sess = {s: f"new-{s_bid[s]}" for s in sess}
|
|
fresh_apps = {a: f"new-{a_bid[a]}" for a in apps}
|
|
for s in sess:
|
|
remap.assign(s_bid[s], fresh_sess[s])
|
|
for a in apps:
|
|
remap.assign(a_bid[a], fresh_apps[a])
|
|
|
|
did = dmod.DashboardExportable.import_(payload, {}, remap)
|
|
L = written[did]["layout"]
|
|
|
|
forbidden = set(sess) | set(apps) | set(s_bid.values()) | set(a_bid.values())
|
|
assert set(L["cards"]) == set(fresh_sess.values())
|
|
assert set(L["view_cards"]) == set(fresh_apps.values())
|
|
for cid, card in L["cards"].items():
|
|
assert cid not in forbidden and card["session_id"] == cid
|
|
for oid, card in L["view_cards"].items():
|
|
assert oid not in forbidden and card["output_id"] == oid
|
|
p = card["parent_session_id"]
|
|
assert p is None or (p in set(fresh_sess.values()) and p not in forbidden)
|
|
assert set(L["expanded_session_ids"]) <= set(fresh_sess.values())
|
|
for card in L["browser_cards"].values():
|
|
assert card["spawned_by"] is None or card["spawned_by"] in set(fresh_sess.values())
|
|
|
|
|
|
def test_checksum_rejects_tampering(skill_store):
|
|
p_make_skill(skill_store, "tmp", "Tmp", "# original")
|
|
raw, _ = closure.build_bundle(EntityType.skill, "tmp")
|
|
# Rebuild the zip with the same manifest (old checksum) but an edited payload.
|
|
src = zipfile.ZipFile(io.BytesIO(raw))
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as out:
|
|
for n in src.namelist():
|
|
data = src.read(n)
|
|
if n.endswith("payload.json"):
|
|
d = json.loads(data)
|
|
d["content"] = "TAMPERED"
|
|
data = json.dumps(d, indent=2).encode("utf-8")
|
|
out.writestr(n, data)
|
|
with pytest.raises(BundleError):
|
|
closure.stage_upload(buf.getvalue(), "tmp.swarm")
|
|
|
|
|
|
def test_skill_rollback_removes_it(skill_store):
|
|
from backend.apps.swarm.entities.skills import SkillExportable
|
|
from backend.apps.swarm.exportable import RemapTable
|
|
sid = SkillExportable.import_({"slug": "rbk", "name": "Rbk", "content": "x"}, {}, RemapTable())
|
|
assert (skill_store / sid / "SKILL.md").exists()
|
|
SkillExportable.rollback(sid)
|
|
assert not (skill_store / sid).exists()
|
|
assert sid not in store.load_index()
|
|
|
|
|
|
def test_commit_rolls_back_created_on_failure(skill_store, tmp_path, monkeypatch):
|
|
# A bundle of [skill, workflow]: the skill imports first and lands, then the workflow
|
|
# import fails, so the skill must be rolled back (all-or-nothing, no half-write). The
|
|
# failure used to come for free (no workflow store on this branch); now the store exists,
|
|
# so force it deterministically by making the workflow import raise.
|
|
from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
|
|
from backend.apps.swarm.entities.workflows import WorkflowExportable
|
|
|
|
def p_boom(*a, **k):
|
|
raise BundleError("simulated workflow import failure")
|
|
monkeypatch.setattr(WorkflowExportable, "import_", p_boom)
|
|
|
|
sb = tmp_path / "sb"
|
|
skill_ref = EntityRef(type=EntityType.skill, bundle_id="s1", name="S", path="entities/s1")
|
|
wf_ref = EntityRef(type=EntityType.workflow, bundle_id="w1", name="W", path="entities/w1")
|
|
for ref, payload in ((skill_ref, {"slug": "rollme", "name": "Rollme", "content": "hi"}), (wf_ref, {"title": "W"})):
|
|
d = sb / "entities" / ref.bundle_id
|
|
d.mkdir(parents=True)
|
|
(d / "payload.json").write_text(json.dumps(payload), encoding="utf-8")
|
|
manifest = Manifest(
|
|
bundle_id="b", root=skill_ref, entities=[skill_ref, wf_ref],
|
|
preview=BundlePreview(root_type=EntityType.skill, root_name="S"),
|
|
)
|
|
with pytest.raises(BundleError):
|
|
closure.commit(str(sb), manifest, [])
|
|
assert "rollme" not in store.load_index()
|
|
assert not (skill_store / "rollme").exists() # the imported folder was rolled back
|
|
|
|
|
|
def test_manifest_duplicate_ids_rejected():
|
|
# Two entities sharing a bundle_id silently collapse in the topo/summary
|
|
# dicts, dropping one; reject up front. (The manifest is outside the checksum.)
|
|
from backend.apps.swarm.closure import validate_manifest
|
|
from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
|
|
ref = EntityRef(type=EntityType.skill, bundle_id="dup", name="A", path="entities/dup")
|
|
m = Manifest(bundle_id="b", root=ref, entities=[ref, ref],
|
|
preview=BundlePreview(root_type=EntityType.skill, root_name="A"))
|
|
with pytest.raises(BundleError):
|
|
validate_manifest(m)
|
|
|
|
|
|
def test_manifest_root_not_in_entities_rejected():
|
|
from backend.apps.swarm.closure import validate_manifest
|
|
from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest
|
|
root = EntityRef(type=EntityType.skill, bundle_id="root", name="A", path="entities/root")
|
|
other = EntityRef(type=EntityType.skill, bundle_id="other", name="B", path="entities/other")
|
|
m = Manifest(bundle_id="b", root=root, entities=[other],
|
|
preview=BundlePreview(root_type=EntityType.skill, root_name="A"))
|
|
with pytest.raises(BundleError):
|
|
validate_manifest(m)
|
|
|
|
|
|
def test_manifest_edge_to_unknown_entity_rejected():
|
|
from backend.apps.swarm.closure import validate_manifest
|
|
from backend.apps.swarm.models import BundlePreview, DependencyEdge, EntityRef, Manifest
|
|
ref = EntityRef(type=EntityType.dashboard, bundle_id="d", name="D", path="entities/d")
|
|
m = Manifest(bundle_id="b", root=ref, entities=[ref],
|
|
edges=[DependencyEdge(**{"from": "d", "to": "ghost"})],
|
|
preview=BundlePreview(root_type=EntityType.dashboard, root_name="D"))
|
|
with pytest.raises(BundleError):
|
|
validate_manifest(m)
|
|
|
|
|
|
def p_zip_with(name, data=b"x"):
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr(name, data)
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_zip_slip_rejected():
|
|
with pytest.raises(BundleError):
|
|
unpack(p_zip_with("../escape.txt"))
|
|
|
|
|
|
def test_absolute_path_rejected():
|
|
with pytest.raises(BundleError):
|
|
unpack(p_zip_with("/etc/evil"))
|
|
|
|
|
|
def test_symlink_entry_rejected():
|
|
# A symlink entry could point outside the sandbox once followed; unpack must
|
|
# refuse it before writing anything.
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zi = zipfile.ZipInfo("link")
|
|
zi.external_attr = 0o120777 << 16
|
|
zf.writestr(zi, "/etc/passwd")
|
|
with pytest.raises(BundleError):
|
|
unpack(buf.getvalue())
|
|
|
|
|
|
def test_too_many_entries_rejected():
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
for i in range(5001):
|
|
zf.writestr(f"f{i}.txt", b"x")
|
|
with pytest.raises(BundleError):
|
|
unpack(buf.getvalue())
|
|
|
|
|
|
def test_newer_format_version_rejected(skill_store):
|
|
# A bundle from a future OpenSwarm should fail clearly, not half-import.
|
|
buf = io.BytesIO()
|
|
manifest = {
|
|
"format_version": 999,
|
|
"bundle_id": "b",
|
|
"root": {"type": "skill", "bundle_id": "x", "name": "n", "path": "entities/x"},
|
|
"entities": [{"type": "skill", "bundle_id": "x", "name": "n", "path": "entities/x"}],
|
|
"preview": {"root_type": "skill", "root_name": "n"},
|
|
}
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr("manifest.json", json.dumps(manifest))
|
|
zf.writestr("entities/x/payload.json", json.dumps({"slug": "n", "name": "n", "content": "c"}))
|
|
with pytest.raises(BundleError):
|
|
closure.stage_upload(buf.getvalue(), "x.swarm")
|