mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-25 14:02:22 +02:00
261 lines
10 KiB
Python
261 lines
10 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 _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):
|
|
_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()
|
|
assert (skill_store / f"{root_id}.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"
|
|
_t, root_id, created, _u = closure.commit(sandbox, manifest, [])
|
|
finally:
|
|
import shutil
|
|
shutil.rmtree(sandbox, ignore_errors=True)
|
|
assert (skill_store / f"{root_id}.md").read_text(encoding="utf-8") == "# Just markdown"
|
|
|
|
|
|
def test_content_secret_redacted_in_bundle(skill_store):
|
|
secret = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA"
|
|
_make_skill(skill_store, "leaky", "Leaky", f"use this key: {secret}")
|
|
raw, _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_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_unavailable_on_this_branch():
|
|
# The workflow store isn't on eric/dev, so load() degrades gracefully and
|
|
# importing a workflow bundle fails with a clear message (no half-write).
|
|
from backend.apps.swarm.entities.workflows import WorkflowExportable
|
|
from backend.apps.swarm.exportable import RemapTable
|
|
assert WorkflowExportable.load("anything") is None
|
|
with pytest.raises(BundleError):
|
|
WorkflowExportable.import_({"title": "x"}, {}, RemapTable())
|
|
|
|
|
|
def test_session_export_strips_transcript_and_secrets():
|
|
from backend.apps.swarm.entities.sessions import SessionExportable
|
|
data = {
|
|
"name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent",
|
|
"system_prompt": "hi", "allowed_tools": ["Read"],
|
|
"messages": [{"role": "user", "content": "private chat"}],
|
|
"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"):
|
|
assert gone not in out
|
|
assert out["model"] == "sonnet" and out["mode"] == "agent"
|
|
reqs = ex.requirements()
|
|
assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs)
|
|
|
|
|
|
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}},
|
|
"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"
|
|
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, "_write", lambda did, doc: written.update({did: doc}))
|
|
monkeypatch.setattr(dmod, "_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"}},
|
|
"browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}},
|
|
"expanded_session_ids": ["SBID", "ORPHAN"],
|
|
}}
|
|
did = dmod.DashboardExportable.import_(payload, {}, remap)
|
|
L = written[did]["layout"]
|
|
assert L["cards"]["newsess"]["session_id"] == "newsess"
|
|
assert "newapp" in L["view_cards"]
|
|
assert list(L["browser_cards"].values())[0]["spawned_by"] == "newsess"
|
|
assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped
|
|
|
|
|
|
def _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(_zip_with("../escape.txt"))
|
|
|
|
|
|
def test_absolute_path_rejected():
|
|
with pytest.raises(BundleError):
|
|
unpack(_zip_with("/etc/evil"))
|
|
|
|
|
|
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")
|