[eric] swarm: app export/import (workspace tree, .env regen) + code-safety review

This commit is contained in:
ciregenz
2026-06-14 06:11:16 -07:00
parent 7ea461fc52
commit d2a7b79eaf
6 changed files with 232 additions and 0 deletions
+22
View File
@@ -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:
+151
View File
@@ -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
+2
View File
@@ -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.
+34
View File
@@ -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)
+2
View File
@@ -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,
)
+21
View File
@@ -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: