From d2a7b79eaf358ee651eb8debe824f2230893ddc0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:11:16 -0700 Subject: [PATCH] [eric] swarm: app export/import (workspace tree, .env regen) + code-safety review --- backend/apps/swarm/closure.py | 22 ++++ backend/apps/swarm/entities/apps.py | 151 ++++++++++++++++++++++++++++ backend/apps/swarm/registry.py | 2 + backend/apps/swarm/review.py | 34 +++++++ backend/apps/swarm/swarm.py | 2 + backend/tests/test_swarm_bundle.py | 21 ++++ 6 files changed, 232 insertions(+) create mode 100644 backend/apps/swarm/entities/apps.py create mode 100644 backend/apps/swarm/review.py diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index 937f3635..dba4e398 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -267,6 +267,28 @@ def _read_files(sandbox: str, ref: EntityRef) -> dict[str, bytes]: return out +def review_bundle(sandbox: str, manifest: Manifest): + """Safety read of any app code in the staged bundle. Returns None when the + bundle contains no apps (nothing to review).""" + from .models import ReviewSummary + from .review import scan_app_files + + findings: list[str] = [] + scanned: list[str] = [] + verdict = "clean" + any_app = False + for e in manifest.entities: + if e.type != EntityType.app: + continue + any_app = True + r = scan_app_files(_read_files(sandbox, e)) + findings.extend(r.findings) + scanned.extend(r.scanned_files) + if r.verdict != "clean": + verdict = r.verdict + return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned) if any_app else None + + def detect_conflicts(sandbox: str, manifest: Manifest) -> list[IncludeItem]: out: list[IncludeItem] = [] for e in manifest.entities: diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py new file mode 100644 index 00000000..a9a34544 --- /dev/null +++ b/backend/apps/swarm/entities/apps.py @@ -0,0 +1,151 @@ +"""AppExportable: an app is an Output record + its workspace file tree. We carry +the editable source (frontend/, backend/, run.sh, package.json, .env.example, +meta) but NOT node_modules/.venv/dist (skip dirs) and NOT the live `.env` (it +holds the source machine's absolute paths + pinned port). On import we mint a +fresh output id + workspace id, drop the builder session link, and regenerate a +local `.env` with a free port. The app stays inert until the user opens it.""" +from __future__ import annotations + +import os +import shutil +import socket +from uuid import uuid4 + +from backend.apps.outputs.models import Output +from backend.apps.outputs.workspace_io import _WALK_SKIP_DIRS, _save, load_output +from backend.config.paths import OUTPUTS_WORKSPACE_DIR + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + +_MAX_APP_FILE = 25 * 1024 * 1024 # matches ziputil per-entry cap + + +class AppExportable: + type = EntityType.app + + def __init__(self, output: Output): + self.output = output + self.local_id = output.id + self.name = output.name or "Untitled App" + + @classmethod + def load(cls, local_id: str) -> "AppExportable | None": + o = load_output(local_id) + return cls(o) if o else None + + def serialize(self, ctx: ExportContext) -> dict: + return { + "name": self.output.name, + "description": self.output.description, + "icon": self.output.icon, + "input_schema": self.output.input_schema, + "files": self.output.files, # flat-app inline source; webapp apps leave this empty + } + + def files(self) -> dict[str, bytes]: + out: dict[str, bytes] = {} + wsid = self.output.workspace_id + if not wsid: + return out + folder = os.path.join(OUTPUTS_WORKSPACE_DIR, wsid) + if not os.path.isdir(folder): + return out + for root, dirs, fnames in os.walk(folder): + dirs[:] = [d for d in dirs if d not in _WALK_SKIP_DIRS] + for fn in fnames: + # .env is install-specific (absolute paths + port); .env.example travels instead. + if fn == ".env": + continue + full = os.path.join(root, fn) + if os.path.islink(full): + continue + try: + if os.path.getsize(full) > _MAX_APP_FILE: + continue + with open(full, "rb") as f: + data = f.read() + except OSError: + continue + rel = os.path.relpath(full, folder).replace(os.sep, "/") + out[f"workspace/{rel}"] = data + return out + + def dependencies(self) -> list[DepRef]: + return [] + + def requirements(self) -> list[Requirement]: + return [] + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + new_wsid = uuid4().hex + folder = os.path.join(OUTPUTS_WORKSPACE_DIR, new_wsid) + wrote_workspace = False + for rel, data in files.items(): + if not rel.startswith("workspace/"): + continue + dest = _safe_join(folder, rel[len("workspace/"):]) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "wb") as f: + f.write(data) + wrote_workspace = True + if wrote_workspace: + _localize_env(folder) + + o = Output( + name=payload.get("name") or "Imported App", + description=payload.get("description", ""), + icon=payload.get("icon", "view_quilt"), + input_schema=payload.get("input_schema") or {"type": "object", "properties": {}, "required": []}, + files=payload.get("files") or {}, + workspace_id=new_wsid if wrote_workspace else None, + session_id=None, + ) + _save(o) + return o.id + + +def _safe_join(folder: str, rel: str) -> str: + dest = os.path.realpath(os.path.join(folder, rel)) + root = os.path.realpath(folder) + if dest != root and not dest.startswith(root + os.sep): + raise ValueError("app file path escapes the workspace") + return dest + + +def _free_port() -> int: + s = socket.socket() + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +def _localize_env(folder: str) -> None: + """Regenerate the workspace .env on the importer's machine: a fresh port plus + this install's absolute template/debugger paths (the source's were dropped).""" + env_path = os.path.join(folder, ".env") + example = os.path.join(folder, ".env.example") + if not os.path.exists(env_path): + if os.path.exists(example): + shutil.copyfile(example, env_path) + else: + return # flat app: no run.sh, no env needed + try: + from backend.apps.outputs.view_builder_templates import ( + _DEBUGGER_PATH, + _TEMPLATE_BACKEND_PATH, + _patch_env_port, + _warm_venv_dir, + ) + except Exception: + return + _patch_env_port(env_path, "FRONTEND_PORT", str(_free_port())) + _patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", _TEMPLATE_BACKEND_PATH) + _patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", _DEBUGGER_PATH) + try: + _patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", _warm_venv_dir()) + except Exception: + pass diff --git a/backend/apps/swarm/registry.py b/backend/apps/swarm/registry.py index e2a6281b..168a595a 100644 --- a/backend/apps/swarm/registry.py +++ b/backend/apps/swarm/registry.py @@ -1,10 +1,12 @@ """Maps an EntityType to the Exportable that handles it, and the leaves-first order import walks. Adding a shareable type is one entry here plus its module.""" +from .entities.apps import AppExportable from .entities.skills import SkillExportable from .models import EntityType REGISTRY: dict[EntityType, type] = { EntityType.skill: SkillExportable, + EntityType.app: AppExportable, } # Leaves first: a dependency must import before whatever references it. diff --git a/backend/apps/swarm/review.py b/backend/apps/swarm/review.py new file mode 100644 index 00000000..fb8f0d00 --- /dev/null +++ b/backend/apps/swarm/review.py @@ -0,0 +1,34 @@ +"""Best-effort safety read of imported app code. AST flags risky Python via the +existing executor allow/deny lists, and we note when an app will run real code +on the importer's machine (a webapp_template app spawns `bash run.sh`). This is +advisory and surfaced in the import preflight; the actual execution gates are the +user choosing to open/run the app and the flat-app /execute HITL. A full semantic +LLM scan is the separate App Publishing feature, not this.""" +from __future__ import annotations + +from backend.apps.outputs.executor import get_code_warnings + +from .models import ReviewSummary + + +def scan_app_files(files: dict[str, bytes]) -> ReviewSummary: + findings: list[str] = [] + scanned: list[str] = [] + runnable = False + for path, data in files.items(): + low = path.lower() + if low.endswith("/run.sh") or low.endswith("package.json") or "/backend/" in low: + runnable = True + if low.endswith(".py"): + scanned.append(path) + try: + code = data.decode("utf-8", errors="replace") + except Exception: + continue + for w in get_code_warnings(code): + findings.append(f"{path}: {w}") + verdict = "warn" if findings else "clean" + if runnable: + verdict = "warn" + findings.insert(0, "This app runs code on your computer when you open it. Only import apps you trust.") + return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned) diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py index 119e64f4..734677fc 100644 --- a/backend/apps/swarm/swarm.py +++ b/backend/apps/swarm/swarm.py @@ -90,6 +90,7 @@ async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightRespo try: sandbox, manifest, warnings = closure.stage_upload(raw, file.filename or "") conflicts = closure.detect_conflicts(sandbox, manifest) + review = closure.review_bundle(sandbox, manifest) except BundleError as e: raise HTTPException(status_code=400, detail=str(e)) _gc_staging() @@ -99,6 +100,7 @@ async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightRespo summary=closure.summarize(manifest), staging_token=token, conflicts=conflicts, + review=review, warnings=warnings, ) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 3e454ccc..19a562a4 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -99,6 +99,27 @@ def test_pack_refuses_denied_key(): 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 _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: