mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 03:09:41 +02:00
[eric] swarm: app export/import (workspace tree, .env regen) + code-safety review
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user