From ace783f4ef45e914311f442a4863bf05a71f4bb6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 05:55:28 -0700 Subject: [PATCH 01/12] [eric] swarm: .swarm bundle engine + skill export/import endpoints --- backend/apps/swarm/__init__.py | 0 backend/apps/swarm/closure.py | 315 ++++++++++++++++++++++++ backend/apps/swarm/entities/__init__.py | 0 backend/apps/swarm/entities/skills.py | 95 +++++++ backend/apps/swarm/exportable.py | 52 ++++ backend/apps/swarm/models.py | 133 ++++++++++ backend/apps/swarm/redact.py | 81 ++++++ backend/apps/swarm/registry.py | 22 ++ backend/apps/swarm/swarm.py | 128 ++++++++++ backend/apps/swarm/ziputil.py | 121 +++++++++ backend/main.py | 3 +- 11 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 backend/apps/swarm/__init__.py create mode 100644 backend/apps/swarm/closure.py create mode 100644 backend/apps/swarm/entities/__init__.py create mode 100644 backend/apps/swarm/entities/skills.py create mode 100644 backend/apps/swarm/exportable.py create mode 100644 backend/apps/swarm/models.py create mode 100644 backend/apps/swarm/redact.py create mode 100644 backend/apps/swarm/registry.py create mode 100644 backend/apps/swarm/swarm.py create mode 100644 backend/apps/swarm/ziputil.py diff --git a/backend/apps/swarm/__init__.py b/backend/apps/swarm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py new file mode 100644 index 000000000..937f3635b --- /dev/null +++ b/backend/apps/swarm/closure.py @@ -0,0 +1,315 @@ +"""Export = walk the dependency closure from a root, scrub, pack. Import = stage +into a sandbox, topo-sort leaves-first, assign fresh local ids, rewrite cross +refs through a RemapTable. The single-skill staging path lets a bare .md or a +zip-of-SKILL.md come in through the same commit machinery as a full .swarm.""" +from __future__ import annotations + +import io +import json +import os +import shutil +import tempfile +import zipfile +from datetime import datetime, timezone +from uuid import uuid4 + +from .exportable import RemapTable +from .models import ( + FORMAT_VERSION, + BundlePreview, + BundleSummary, + DependencyEdge, + EntityRef, + EntityType, + IncludeItem, + Manifest, + Requirement, + RequirementView, +) +from .redact import scrub_payload +from .registry import IMPORT_ORDER, get_exportable +from .ziputil import MANIFEST_NAME, BundleError, has_member, is_zip, pack, read_manifest, unpack + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _created_with() -> str: + return os.environ.get("OPENSWARM_VERSION") or "OpenSwarm" + + +class _Ctx: + def __init__(self, local_to_bundle: dict[tuple, str]): + self._m = local_to_bundle + + def bundle_id_for(self, etype: EntityType, local_id: str) -> str | None: + return self._m.get((etype, local_id)) + + +# ---------- export ---------- + +def _assemble(root_type: EntityType, root_id: str): + root_cls = get_exportable(root_type) + if root_cls is None: + raise BundleError(f"can't share a {root_type.value} yet") + root = root_cls.load(root_id) + if root is None: + raise BundleError("nothing found to share") + + nodes: dict[tuple, object] = {} + order: list[tuple] = [] + queue: list[tuple] = [(root_type, root_id, root)] + while queue: + etype, lid, inst = queue.pop(0) + key = (etype, lid) + if key in nodes: + continue + nodes[key] = inst + order.append(key) + for dep in inst.dependencies(): + dkey = (dep.type, dep.local_id) + if dkey in nodes: + continue + dcls = get_exportable(dep.type) + if dcls is None: + raise BundleError(f"can't bundle a dependency of type {dep.type.value} yet") + dinst = dcls.load(dep.local_id) + if dinst is not None: + queue.append((dep.type, dep.local_id, dinst)) + + local_to_bundle = {key: uuid4().hex for key in order} + ctx = _Ctx(local_to_bundle) + payloads: dict[str, dict] = {} + files: dict[str, bytes] = {} + entities: list[EntityRef] = [] + edges: list[DependencyEdge] = [] + requirements: list[Requirement] = [] + counts: dict[str, int] = {} + + for key in order: + etype, _lid = key + inst = nodes[key] + bid = local_to_bundle[key] + payloads[bid] = scrub_payload(inst.serialize(ctx)) + for rel, data in inst.files().items(): + files[f"entities/{bid}/files/{rel}"] = data + entities.append(EntityRef(type=etype, bundle_id=bid, name=inst.name, path=f"entities/{bid}")) + counts[etype.value] = counts.get(etype.value, 0) + 1 + for dep in inst.dependencies(): + dkey = (dep.type, dep.local_id) + if dkey in local_to_bundle: + edges.append(DependencyEdge(from_=bid, to=local_to_bundle[dkey], relation=dep.relation)) + requirements.extend(inst.requirements()) + + requirements = _dedupe_requirements(requirements) + root_bid = local_to_bundle[(root_type, root_id)] + manifest = Manifest( + created_with=_created_with(), + created_at=_now(), + bundle_id=uuid4().hex, + root=EntityRef(type=root_type, bundle_id=root_bid, name=root.name, path=f"entities/{root_bid}"), + entities=entities, + edges=edges, + requirements=requirements, + preview=BundlePreview( + root_type=root_type, + root_name=root.name, + counts=counts, + requirement_summary=[r.label for r in requirements], + ), + ) + return manifest, payloads, files + + +def build_manifest(root_type: EntityType, root_id: str) -> Manifest: + return _assemble(root_type, root_id)[0] + + +def build_bundle(root_type: EntityType, root_id: str) -> tuple[bytes, str]: + manifest, payloads, files = _assemble(root_type, root_id) + raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files) + return raw, manifest.root.name + + +def _dedupe_requirements(reqs: list[Requirement]) -> list[Requirement]: + out: dict[tuple, Requirement] = {} + for r in reqs: + k = (r.kind, r.key) + if k in out: + for ref in r.referenced_by: + if ref not in out[k].referenced_by: + out[k].referenced_by.append(ref) + else: + out[k] = r + return list(out.values()) + + +# ---------- summary (shared by export + import preflight) ---------- + +def summarize(manifest: Manifest) -> BundleSummary: + includes = [ + IncludeItem(type=e.type, name=e.name) + for e in manifest.entities + if e.bundle_id != manifest.root.bundle_id + ] + reqs = [RequirementView(kind=r.kind, key=r.key, label=r.label, detail=r.detail) for r in manifest.requirements] + return BundleSummary( + root=IncludeItem(type=manifest.root.type, name=manifest.root.name), + includes=includes, + requirements=reqs, + counts=manifest.preview.counts, + ) + + +def swarm_filename(name: str) -> str: + keep = "".join(c if (c.isalnum() or c in " -_") else "" for c in (name or "bundle")).strip() + slug = keep.replace(" ", "-").lower() or "bundle" + return f"{slug}.swarm" + + +# ---------- import: staging ---------- + +def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]: + warnings: list[str] = [] + if is_zip(raw): + if has_member(raw, MANIFEST_NAME): + sandbox = unpack(raw) + try: + manifest = Manifest(**read_manifest(sandbox)) + except BundleError: + shutil.rmtree(sandbox, ignore_errors=True) + raise + except Exception: + shutil.rmtree(sandbox, ignore_errors=True) + raise BundleError("bundle manifest is invalid") + if manifest.format_version > FORMAT_VERSION: + shutil.rmtree(sandbox, ignore_errors=True) + raise BundleError("this .swarm was made by a newer OpenSwarm; please update") + return sandbox, manifest, warnings + return _stage_skill_from_zip(raw, filename, warnings) + return _stage_skill_from_markdown(raw, filename, warnings) + + +def _name_from_filename(filename: str) -> str: + base = os.path.splitext(os.path.basename(filename or "skill"))[0] + return base.replace("-", " ").replace("_", " ").strip().title() or "Imported Skill" + + +def _stage_skill_from_markdown(raw: bytes, filename: str, warnings: list[str]): + try: + content = raw.decode("utf-8") + except UnicodeDecodeError: + raise BundleError("unrecognized file; expected a .swarm or a .md skill") + return _synth_single_skill(content, _name_from_filename(filename), warnings) + + +def _stage_skill_from_zip(raw: bytes, filename: str, warnings: list[str]): + with zipfile.ZipFile(io.BytesIO(raw)) as zf: + mds = [n for n in zf.namelist() if n.lower().endswith(".md") and not n.endswith("/")] + target = next((n for n in mds if os.path.basename(n).lower() == "skill.md"), None) + if target is None and mds: + target = mds[0] + if target is None: + raise BundleError("zip has no SKILL.md") + content = zf.read(target).decode("utf-8", errors="replace") + others = [n for n in zf.namelist() if not n.endswith("/") and n != target] + if others: + warnings.append("supporting files were not imported (a skill is a single markdown file)") + return _synth_single_skill(content, _name_from_filename(filename), warnings) + + +def _synth_single_skill(content: str, name: str, warnings: list[str]): + bid = uuid4().hex + sandbox = tempfile.mkdtemp(prefix="swarm-import-") + edir = os.path.join(sandbox, "entities", bid) + os.makedirs(edir, exist_ok=True) + slug = name.lower().replace(" ", "-") + payload = {"slug": slug, "name": name, "description": "", "command": slug, "content": content, "builtin": False} + with open(os.path.join(edir, "payload.json"), "w", encoding="utf-8") as f: + json.dump(payload, f) + ref = EntityRef(type=EntityType.skill, bundle_id=bid, name=name, path=f"entities/{bid}") + manifest = Manifest( + bundle_id=uuid4().hex, + root=ref, + entities=[ref], + preview=BundlePreview(root_type=EntityType.skill, root_name=name, counts={"skill": 1}), + ) + return sandbox, manifest, warnings + + +# ---------- import: commit ---------- + +def _safe_join(sandbox: str, rel: str) -> str: + dest = os.path.realpath(os.path.join(sandbox, rel)) + root = os.path.realpath(sandbox) + if dest != root and not dest.startswith(root + os.sep): + raise BundleError("bundle manifest references a path outside the bundle") + return dest + + +def _read_payload(sandbox: str, ref: EntityRef) -> dict: + path = _safe_join(sandbox, os.path.join(ref.path, "payload.json")) + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _read_files(sandbox: str, ref: EntityRef) -> dict[str, bytes]: + base = _safe_join(sandbox, os.path.join(ref.path, "files")) + out: dict[str, bytes] = {} + if not os.path.isdir(base): + return out + for root, _dirs, fnames in os.walk(base): + for fn in fnames: + full = os.path.join(root, fn) + with open(full, "rb") as f: + out[os.path.relpath(full, base)] = f.read() + return out + + +def detect_conflicts(sandbox: str, manifest: Manifest) -> list[IncludeItem]: + out: list[IncludeItem] = [] + for e in manifest.entities: + cls = get_exportable(e.type) + check = getattr(cls, "conflict", None) if cls else None + if not check: + continue + msg = check(_read_payload(sandbox, e)) + if msg: + out.append(IncludeItem(type=e.type, name=e.name, detail=msg)) + return out + + +def _topo_order(manifest: Manifest) -> list[EntityRef]: + entities = {e.bundle_id: e for e in manifest.entities} + deps: dict[str, set[str]] = {bid: set() for bid in entities} + for edge in manifest.edges: + if edge.from_ in entities and edge.to in entities: + deps[edge.from_].add(edge.to) + tier = {t: i for i, t in enumerate(IMPORT_ORDER)} + result: list[EntityRef] = [] + done: set[str] = set() + remaining = set(entities) + while remaining: + ready = [b for b in remaining if deps[b] <= done] or list(remaining) + ready.sort(key=lambda b: tier.get(entities[b].type, 99)) + nxt = ready[0] + result.append(entities[nxt]) + done.add(nxt) + remaining.discard(nxt) + return result + + +def commit(sandbox: str, manifest: Manifest, accept_requirements: list[str]): + remap = RemapTable() + created: dict[str, list[str]] = {} + for e in _topo_order(manifest): + cls = get_exportable(e.type) + if cls is None: + raise BundleError(f"can't import a {e.type.value} yet") + new_id = cls.import_(_read_payload(sandbox, e), _read_files(sandbox, e), remap) + remap.assign(e.bundle_id, new_id) + created.setdefault(e.type.value, []).append(new_id) + accepted = set(accept_requirements) + unresolved = [r for r in manifest.requirements if r.key not in accepted] + return manifest.root.type, remap.local(manifest.root.bundle_id), created, unresolved diff --git a/backend/apps/swarm/entities/__init__.py b/backend/apps/swarm/entities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/apps/swarm/entities/skills.py b/backend/apps/swarm/entities/skills.py new file mode 100644 index 000000000..cb8fbf06f --- /dev/null +++ b/backend/apps/swarm/entities/skills.py @@ -0,0 +1,95 @@ +"""SkillExportable: skills are leaves (no deps, no requirements). An installed +skill is just a markdown file plus index metadata, so this also powers the +generic "import a .md or a zip-of-SKILL.md" path. Nothing here is secret, but +the body still rides the central scrub in case someone pasted a token into it.""" +from __future__ import annotations + +import os + +from backend.apps.skills import skills as store +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + + +class SkillExportable: + type = EntityType.skill + + def __init__(self, local_id: str, name: str, payload: dict): + self.local_id = local_id + self.name = name + self._payload = payload + + @classmethod + def load(cls, local_id: str) -> "SkillExportable | None": + fpath = os.path.join(store.SKILLS_DIR, f"{local_id}.md") + if not os.path.isfile(fpath): + return None + with open(fpath, encoding="utf-8") as f: + content = f.read() + meta = store._load_index().get(local_id, {}) + name = meta.get("name") or local_id.replace("-", " ").replace("_", " ").title() + payload = { + "slug": local_id, + "name": name, + "description": meta.get("description", ""), + "command": meta.get("command", local_id), + "content": content, + "builtin": bool(meta.get("built_in", False)), + } + return cls(local_id, name, payload) + + def serialize(self, ctx: ExportContext) -> dict: + return dict(self._payload) + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + return [] + + def requirements(self) -> list[Requirement]: + return [] + + @classmethod + def conflict(cls, payload: dict) -> str | None: + slug = payload.get("slug") or "" + if slug and _slug_taken(slug): + return "already exists; will be added as a copy" + return None + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + base = (payload.get("slug") or payload.get("name") or "skill").lower().replace(" ", "-") + slug = _free_slug(base) + os.makedirs(store.SKILLS_DIR, exist_ok=True) + fpath = os.path.join(store.SKILLS_DIR, f"{slug}.md") + with open(fpath, "w", encoding="utf-8") as f: + f.write(payload.get("content", "")) + index = store._load_index() + # Imported skills are never builtin, even if the source tagged them so. + index[slug] = { + "name": payload.get("name", slug), + "description": payload.get("description", ""), + "command": payload.get("command", slug), + } + store._save_index(index) + return slug + + +def _slug_taken(slug: str) -> bool: + return slug in store._load_index() or os.path.isfile( + os.path.join(store.SKILLS_DIR, f"{slug}.md") + ) + + +def _free_slug(base: str) -> str: + base = base or "skill" + if not _slug_taken(base): + return base + cand = f"{base}-imported" + if not _slug_taken(cand): + return cand + i = 2 + while _slug_taken(f"{base}-imported-{i}"): + i += 1 + return f"{base}-imported-{i}" diff --git a/backend/apps/swarm/exportable.py b/backend/apps/swarm/exportable.py new file mode 100644 index 000000000..506662c29 --- /dev/null +++ b/backend/apps/swarm/exportable.py @@ -0,0 +1,52 @@ +"""The one abstraction every shareable thing implements. Export walks +dependencies() into a closure; import calls import_() leaves-first, rewiring +cross-refs through the RemapTable. Secret redaction is centralized in closure + +ziputil so a new entity physically can't forget to scrub itself.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Protocol, runtime_checkable + +from .models import EntityType, Requirement + + +@dataclass +class DepRef: + """A local reference one entity holds to another, before bundling.""" + type: EntityType + local_id: str + relation: str = "" + + +class ExportContext(Protocol): + # Lets an entity rewrite its own cross-refs from local ids to bundle ids. + def bundle_id_for(self, etype: EntityType, local_id: str) -> str | None: ... + + +class RemapTable: + """bundle_id -> fresh local id, filled as import walks entities leaves-first.""" + + def __init__(self) -> None: + self._m: dict[str, str] = {} + + def assign(self, bundle_id: str, local_id: str) -> None: + self._m[bundle_id] = local_id + + def local(self, bundle_id: str) -> str | None: + return self._m.get(bundle_id) + + +@runtime_checkable +class Exportable(Protocol): + type: ClassVar[EntityType] + local_id: str + name: str + + @classmethod + def load(cls, local_id: str) -> "Exportable | None": ... + def serialize(self, ctx: ExportContext) -> dict: ... + def files(self) -> dict[str, bytes]: ... + def dependencies(self) -> list[DepRef]: ... + def requirements(self) -> list[Requirement]: ... + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: ... diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py new file mode 100644 index 000000000..65ff68ef5 --- /dev/null +++ b/backend/apps/swarm/models.py @@ -0,0 +1,133 @@ +"""Schema for the .swarm bundle: a hardened zip whose manifest.json is a +dependency graph of entities with one designated root. The manifest never +carries secrets or payloads (payloads live as files in the zip). The *View +models are the lighter, frontend-facing shapes the share/import modals read.""" +from enum import Enum +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +FORMAT_VERSION = 1 + + +class EntityType(str, Enum): + skill = "skill" + app = "app" + workflow = "workflow" + dashboard = "dashboard" + mode = "mode" + session = "session" + + +class RequirementKind(str, Enum): + mcp_action = "mcp_action" # an MCP/Action that must be reconnected (never auto) + setting = "setting" # a safe settings fragment the user confirms + builtin_mode = "builtin_mode" # a builtin mode that must already exist locally + api_key = "api_key" # a provider key the bundle needs but can't carry + custom_provider = "custom_provider" # OpenAI-compatible endpoint (URL ssrf-checked) + + +class EntityRef(BaseModel): + type: EntityType + bundle_id: str # uuid4 hex, stable only within this bundle + name: str + path: str # dir inside the zip holding this entity + + +class DependencyEdge(BaseModel): + model_config = ConfigDict(populate_by_name=True) + from_: str = Field(alias="from") + to: str + relation: str = "" + + +class Requirement(BaseModel): + kind: RequirementKind + key: str + label: str + detail: str = "" + referenced_by: list[str] = Field(default_factory=list) + proposal: dict[str, Any] = Field(default_factory=dict) # safe, non-secret hint only + + +class BundlePreview(BaseModel): + root_type: EntityType + root_name: str + counts: dict[str, int] = Field(default_factory=dict) + requirement_summary: list[str] = Field(default_factory=list) + + +class Manifest(BaseModel): + format_version: int = FORMAT_VERSION + created_with: str = "OpenSwarm" + created_at: str = "" + bundle_id: str + root: EntityRef + entities: list[EntityRef] = Field(default_factory=list) + edges: list[DependencyEdge] = Field(default_factory=list) + requirements: list[Requirement] = Field(default_factory=list) + preview: BundlePreview + + +# ---- frontend-facing summary (export + import preflight) ---- + +class IncludeItem(BaseModel): + type: EntityType + name: str + detail: str = "" + + +class RequirementView(BaseModel): + kind: RequirementKind + key: str + label: str + detail: str = "" + + +class BundleSummary(BaseModel): + root: IncludeItem + includes: list[IncludeItem] = Field(default_factory=list) + requirements: list[RequirementView] = Field(default_factory=list) + counts: dict[str, int] = Field(default_factory=dict) + + +class ReviewSummary(BaseModel): + verdict: Literal["clean", "warn", "block"] = "clean" + findings: list[str] = Field(default_factory=list) + scanned_files: list[str] = Field(default_factory=list) + + +# ---- endpoint request/response ---- + +class ExportRequest(BaseModel): + type: EntityType + id: str + + +class ExportPreflightResponse(BaseModel): + ok: bool = True + summary: BundleSummary + filename: str + link_supported: bool = False + + +class ImportPreflightResponse(BaseModel): + ok: bool = True + summary: BundleSummary + staging_token: str + conflicts: list[IncludeItem] = Field(default_factory=list) + review: Optional[ReviewSummary] = None + warnings: list[str] = Field(default_factory=list) + + +class ImportCommitRequest(BaseModel): + staging_token: str + accept_requirements: list[str] = Field(default_factory=list) + + +class ImportCommitResponse(BaseModel): + ok: bool = True + root_type: EntityType + root_id: str + created: dict[str, list[str]] = Field(default_factory=dict) + unresolved_requirements: list[RequirementView] = Field(default_factory=list) diff --git a/backend/apps/swarm/redact.py b/backend/apps/swarm/redact.py new file mode 100644 index 000000000..1a97d20c4 --- /dev/null +++ b/backend/apps/swarm/redact.py @@ -0,0 +1,81 @@ +"""Strip secrets before anything enters a .swarm. Two layers: closure scrubs +every payload + text body, and ziputil.pack refuses to write if anything denied +slipped through. Over-redacting a bundle is fine; shipping a stranger your API +key is not.""" +from __future__ import annotations + +import re +from typing import Any + +# Substrings that mark a field name as secret (matched case-insensitively). +_DENY_SUBSTRINGS = ( + "api_key", "apikey", "secret", "password", "passwd", "credential", "oauth", + "bearer", "subscription_token", "access_token", "refresh_token", + "session_token", "auth_token", "private_key", +) + +# Exact field names that are sensitive or per-install identity (the substring +# pass alone would miss these). +_DENY_EXACT = { + "token", "installation_id", "user_id", "free_trial_token", + "free_trial_remaining", "free_trial_runs_limit", "openswarm_bearer_token", + "openswarm_usage_cached", "connected_account_email", "oauth_tokens", + "credentials", "sdk_session_id", +} + +REDACTED = "[redacted]" + +# Literal-secret shapes someone might paste into a file or skill body. +_CONTENT_PATTERNS = ( + re.compile(r"sk-ant-[A-Za-z0-9_\-]{16,}"), + re.compile(r"sk-[A-Za-z0-9_\-]{16,}"), + re.compile(r"AIza[A-Za-z0-9_\-]{20,}"), # Google API key shape + re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), # GitHub tokens + re.compile(r"Bearer\s+[A-Za-z0-9._\-]{16,}"), +) + + +def is_denied_key(key: str) -> bool: + k = key.lower() + if k in _DENY_EXACT: + return True + return any(sub in k for sub in _DENY_SUBSTRINGS) + + +def scrub_text(text: str) -> str: + for pat in _CONTENT_PATTERNS: + text = pat.sub(REDACTED, text) + return text + + +def scrub_payload(value: Any) -> Any: + """Recursively drop denied keys and redact secret-shaped strings in a + JSON-able structure. Returns a new structure; never mutates the input.""" + if isinstance(value, dict): + out: dict[str, Any] = {} + for k, v in value.items(): + if isinstance(k, str) and is_denied_key(k): + continue + out[k] = scrub_payload(v) + return out + if isinstance(value, list): + return [scrub_payload(v) for v in value] + if isinstance(value, str): + return scrub_text(value) + return value + + +def find_denied_keys(value: Any, _path: str = "") -> list[str]: + """Audit used by ziputil.pack as the last line of defense: the paths of any + denied key still present. Empty list means clean.""" + found: list[str] = [] + if isinstance(value, dict): + for k, v in value.items(): + here = f"{_path}.{k}" if _path else str(k) + if isinstance(k, str) and is_denied_key(k): + found.append(here) + found.extend(find_denied_keys(v, here)) + elif isinstance(value, list): + for i, v in enumerate(value): + found.extend(find_denied_keys(v, f"{_path}[{i}]")) + return found diff --git a/backend/apps/swarm/registry.py b/backend/apps/swarm/registry.py new file mode 100644 index 000000000..e2a6281b0 --- /dev/null +++ b/backend/apps/swarm/registry.py @@ -0,0 +1,22 @@ +"""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.skills import SkillExportable +from .models import EntityType + +REGISTRY: dict[EntityType, type] = { + EntityType.skill: SkillExportable, +} + +# Leaves first: a dependency must import before whatever references it. +IMPORT_ORDER = [ + EntityType.skill, + EntityType.mode, + EntityType.session, + EntityType.app, + EntityType.workflow, + EntityType.dashboard, +] + + +def get_exportable(etype: EntityType) -> type | None: + return REGISTRY.get(etype) diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py new file mode 100644 index 000000000..119e64f46 --- /dev/null +++ b/backend/apps/swarm/swarm.py @@ -0,0 +1,128 @@ +"""SubApp for .swarm sharing. Three endpoints: export (returns the bundle bytes +as a download), import/preflight (parse + stage in a sandbox, no writes), and +import/commit (write the staged entities with fresh ids). Staging is in-process +with a TTL; a lost token just means re-open the file.""" +import logging +import shutil +import time +import uuid +from contextlib import asynccontextmanager + +from fastapi import File, HTTPException, Response, UploadFile + +from backend.config.Apps import SubApp + +from . import closure +from .models import ( + ExportPreflightResponse, + ExportRequest, + ImportCommitRequest, + ImportCommitResponse, + ImportPreflightResponse, + RequirementView, +) +from .ziputil import MAX_TOTAL_BYTES, BundleError + +logger = logging.getLogger(__name__) + +_STAGING: dict[str, dict] = {} +_STAGING_TTL = 30 * 60 # 30 minutes + + +def _gc_staging() -> None: + now = time.time() + for token in list(_STAGING): + if now - _STAGING[token]["created_at"] > _STAGING_TTL: + _discard(token) + + +def _discard(token: str) -> None: + entry = _STAGING.pop(token, None) + if entry: + shutil.rmtree(entry["sandbox"], ignore_errors=True) + + +@asynccontextmanager +async def swarm_lifespan(): + _gc_staging() + try: + yield + finally: + for token in list(_STAGING): + _discard(token) + + +swarm = SubApp("swarm", swarm_lifespan) + + +@swarm.router.post("/export/preflight") +async def export_preflight(body: ExportRequest) -> ExportPreflightResponse: + try: + manifest = closure.build_manifest(body.type, body.id) + except BundleError as e: + raise HTTPException(status_code=400, detail=str(e)) + return ExportPreflightResponse( + summary=closure.summarize(manifest), + filename=closure.swarm_filename(manifest.root.name), + link_supported=False, + ) + + +@swarm.router.post("/export") +async def export_bundle(body: ExportRequest) -> Response: + try: + raw, name = closure.build_bundle(body.type, body.id) + except BundleError as e: + raise HTTPException(status_code=400, detail=str(e)) + fname = closure.swarm_filename(name) + return Response( + content=raw, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + +@swarm.router.post("/import/preflight") +async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightResponse: + raw = await file.read() + if len(raw) > MAX_TOTAL_BYTES: + raise HTTPException(status_code=400, detail="file is too large") + try: + sandbox, manifest, warnings = closure.stage_upload(raw, file.filename or "") + conflicts = closure.detect_conflicts(sandbox, manifest) + except BundleError as e: + raise HTTPException(status_code=400, detail=str(e)) + _gc_staging() + token = uuid.uuid4().hex + _STAGING[token] = {"sandbox": sandbox, "manifest": manifest, "created_at": time.time()} + return ImportPreflightResponse( + summary=closure.summarize(manifest), + staging_token=token, + conflicts=conflicts, + warnings=warnings, + ) + + +@swarm.router.post("/import/commit") +async def import_commit(body: ImportCommitRequest) -> ImportCommitResponse: + entry = _STAGING.get(body.staging_token) + if not entry: + raise HTTPException(status_code=404, detail="import session expired; please re-open the file") + try: + root_type, root_id, created, unresolved = closure.commit( + entry["sandbox"], entry["manifest"], body.accept_requirements + ) + except BundleError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + _discard(body.staging_token) + if root_id is None: + raise HTTPException(status_code=400, detail="bundle has no root entity") + return ImportCommitResponse( + root_type=root_type, + root_id=root_id, + created=created, + unresolved_requirements=[ + RequirementView(kind=r.kind, key=r.key, label=r.label, detail=r.detail) for r in unresolved + ], + ) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py new file mode 100644 index 000000000..d53033cdb --- /dev/null +++ b/backend/apps/swarm/ziputil.py @@ -0,0 +1,121 @@ +"""Hardened zip <-> bytes for .swarm bundles. The zip arrives from an untrusted +party, so unpack defends against zip-slip, zip-bombs, symlinks, and lying size +headers, and only ever writes into a throwaway sandbox dir (never a real store). +pack re-checks that no secret slipped past redaction before writing a byte.""" +from __future__ import annotations + +import io +import json +import os +import shutil +import tempfile +import zipfile + +from .redact import find_denied_keys + +MANIFEST_NAME = "manifest.json" + +MAX_ENTRIES = 5000 +MAX_TOTAL_BYTES = 200 * 1024 * 1024 # 200 MB uncompressed +MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB per entry +MAX_RATIO = 200 # uncompressed / compressed per entry + + +class BundleError(Exception): + """Bundle is malformed or unsafe. Message is safe to show the user.""" + + +def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes: + """payloads: bundle_id -> JSON payload (-> entities//payload.json). + files: full zip path -> bytes (e.g. entities//files/).""" + for bid, payload in payloads.items(): + leaked = find_denied_keys(payload) + if leaked: + raise BundleError( + f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2)) + for bid, payload in sorted(payloads.items()): + zf.writestr(f"entities/{bid}/payload.json", json.dumps(payload, indent=2)) + for path, data in sorted(files.items()): + zf.writestr(path, data) + return buf.getvalue() + + +def _safe_member_path(name: str, sandbox: str) -> str: + if name.startswith(("/", "\\")) or (len(name) > 1 and name[1] == ":"): + raise BundleError("bundle contains an absolute path") + dest = os.path.realpath(os.path.join(sandbox, name)) + root = os.path.realpath(sandbox) + if dest != root and not dest.startswith(root + os.sep): + raise BundleError("bundle contains a path-traversal entry") + return dest + + +def is_zip(raw: bytes) -> bool: + return zipfile.is_zipfile(io.BytesIO(raw)) + + +def has_member(raw: bytes, name: str) -> bool: + with zipfile.ZipFile(io.BytesIO(raw)) as zf: + return name in zf.namelist() + + +def unpack(raw: bytes) -> str: + """Extract into a fresh sandbox temp dir and return it. Caller deletes it.""" + if len(raw) > MAX_TOTAL_BYTES: + raise BundleError("bundle is too large") + try: + zf = zipfile.ZipFile(io.BytesIO(raw)) + except zipfile.BadZipFile: + raise BundleError("not a valid .swarm file") + infos = zf.infolist() + if len(infos) > MAX_ENTRIES: + raise BundleError("bundle has too many entries") + total = 0 + for zi in infos: + if zi.file_size > MAX_FILE_BYTES: + raise BundleError("bundle has an oversized entry") + total += zi.file_size + if total > MAX_TOTAL_BYTES: + raise BundleError("bundle is too large uncompressed") + if zi.compress_size and zi.file_size / zi.compress_size > MAX_RATIO: + raise BundleError("bundle entry is suspiciously compressed") + mode = (zi.external_attr >> 16) & 0o170000 + if mode == 0o120000: + raise BundleError("bundle contains a symlink") + + sandbox = tempfile.mkdtemp(prefix="swarm-import-") + try: + written = 0 + for zi in infos: + if zi.is_dir(): + continue + dest = _safe_member_path(zi.filename, sandbox) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with zf.open(zi) as src, open(dest, "wb") as out: + while True: + chunk = src.read(65536) + if not chunk: + break + written += len(chunk) + if written > MAX_TOTAL_BYTES: + raise BundleError("bundle exceeded size during extraction") + out.write(chunk) + except Exception: + shutil.rmtree(sandbox, ignore_errors=True) + raise + return sandbox + + +def read_manifest(sandbox: str) -> dict: + path = os.path.join(sandbox, MANIFEST_NAME) + if not os.path.isfile(path): + raise BundleError("bundle has no manifest") + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, UnicodeDecodeError): + raise BundleError("bundle manifest is unreadable") diff --git a/backend/main.py b/backend/main.py index d51267a84..42daf1d34 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,6 +39,7 @@ from backend.apps.skill_registry.skill_registry import skill_registry from backend.apps.outputs.outputs import outputs from backend.apps.dashboards.dashboards import dashboards +from backend.apps.swarm.swarm import swarm from backend.apps.service.service import service from backend.apps.subscription.router import subscription from backend.apps.auth.router import auth @@ -48,7 +49,7 @@ from fastapi import WebSocket, WebSocketDisconnect import json -main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, service, subscription, auth, web, anthropic_proxy]) +main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, swarm, service, subscription, auth, web, anthropic_proxy]) app = main_app.app # Generate per-install auth token BEFORE we bind the HTTP port. By the From d7b2dfce7baac92c5faf6cd5047830d713788f39 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 05:55:33 -0700 Subject: [PATCH 02/12] [eric] swarm: backend tests for redaction, zip-slip/bomb, skill round-trip --- backend/tests/test_swarm_bundle.py | 142 +++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 backend/tests/test_swarm_bundle.py diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py new file mode 100644 index 000000000..3e454ccc4 --- /dev/null +++ b/backend/tests/test_swarm_bundle.py @@ -0,0 +1,142 @@ +"""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 _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") From 9b6e0f818fd3d20e9a75ab4a37044e652e5fe80a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 05:55:33 -0700 Subject: [PATCH 03/12] [eric] swarm: Share button + modal, wired into Skills detail header --- .../src/app/components/share/IncludesList.tsx | 87 +++++++ .../src/app/components/share/ShareButton.tsx | 60 +++++ .../src/app/components/share/ShareModal.tsx | 213 ++++++++++++++++++ frontend/src/app/components/share/shareApi.ts | 72 ++++++ .../src/app/components/share/shareTypes.ts | 53 +++++ frontend/src/app/pages/Skills/Skills.tsx | 2 + 6 files changed, 487 insertions(+) create mode 100644 frontend/src/app/components/share/IncludesList.tsx create mode 100644 frontend/src/app/components/share/ShareButton.tsx create mode 100644 frontend/src/app/components/share/ShareModal.tsx create mode 100644 frontend/src/app/components/share/shareApi.ts create mode 100644 frontend/src/app/components/share/shareTypes.ts diff --git a/frontend/src/app/components/share/IncludesList.tsx b/frontend/src/app/components/share/IncludesList.tsx new file mode 100644 index 000000000..2cd6f2909 --- /dev/null +++ b/frontend/src/app/components/share/IncludesList.tsx @@ -0,0 +1,87 @@ +// The "what's inside this bundle" panel, shared by the Share and Import modals: +// the root entity, the dependencies pulled in with it, and any environment +// requirements (an Action the importer must enable themselves). +import React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import { BundleSummary } from './shareTypes'; + +const KIND_LABEL: Record = { + skill: 'Skill', + app: 'App', + dashboard: 'Dashboard', + mode: 'Mode', + workflow: 'Workflow', + session: 'Agent', +}; + +const IncludesList: React.FC<{ summary: BundleSummary }> = ({ summary }) => { + const c = useClaudeTokens(); + + const Row: React.FC<{ tag: string; name: string; detail?: string; faded?: boolean }> = ({ + tag, + name, + detail, + faded, + }) => ( + + + {tag} + + + {name} + + {detail && ( + {detail} + )} + + ); + + return ( + + + {summary.includes.map((it, i) => ( + + ))} + {summary.requirements.length > 0 && ( + + {summary.requirements.map((r, i) => ( + + ))} + + )} + + ); +}; + +export default IncludesList; diff --git a/frontend/src/app/components/share/ShareButton.tsx b/frontend/src/app/components/share/ShareButton.tsx new file mode 100644 index 000000000..90d369e53 --- /dev/null +++ b/frontend/src/app/components/share/ShareButton.tsx @@ -0,0 +1,60 @@ +// The reusable top-right Share affordance. Drop it on any modality's surface. +// 'icon' is the Anthropic-style header icon; 'menuItem' is for a sidebar "..." +// overflow menu. Click always stops propagation so card/header parents that own +// their own onClick don't also fire. +import React, { useState } from 'react'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import IosShareIcon from '@mui/icons-material/IosShare'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import ShareModal from './ShareModal'; +import { ShareTarget } from './shareTypes'; + +interface Props { + target: ShareTarget; + size?: 'small' | 'medium'; + variant?: 'icon' | 'menuItem'; + iconFontSize?: number; + onOpen?: () => void; // let a parent close its overflow menu when we take over +} + +const ShareButton: React.FC = ({ target, size = 'small', variant = 'icon', iconFontSize = 18, onOpen }) => { + const c = useClaudeTokens(); + const [open, setOpen] = useState(false); + + const start = (e: React.MouseEvent) => { + e.stopPropagation(); + onOpen?.(); + setOpen(true); + }; + + return ( + <> + {variant === 'menuItem' ? ( + + + + + Share + + ) : ( + + + + + + )} + {open && setOpen(false)} />} + + ); +}; + +export default ShareButton; diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx new file mode 100644 index 000000000..5a8568a19 --- /dev/null +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -0,0 +1,213 @@ +// Anthropic-style Share modal. v1 ships one real action, Download .swarm; the +// "Create share link" row is shown but disabled (that hosted-link flow is v2). +import React, { useCallback, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Chip from '@mui/material/Chip'; +import CircularProgress from '@mui/material/CircularProgress'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import CloseIcon from '@mui/icons-material/Close'; +import DownloadIcon from '@mui/icons-material/Download'; +import LinkIcon from '@mui/icons-material/Link'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import IncludesList from './IncludesList'; +import { downloadSwarm, exportPreflight } from './shareApi'; +import { ExportPreflight, ShareTarget } from './shareTypes'; + +interface Props { + target: ShareTarget; + open: boolean; + onClose: () => void; +} + +const ShareModal: React.FC = ({ target, open, onClose }) => { + const c = useClaudeTokens(); + const [preflight, setPreflight] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [downloading, setDownloading] = useState(false); + const [toast, setToast] = useState(''); + + const load = useCallback(() => { + setPreflight(null); + setError(''); + setLoading(true); + let alive = true; + exportPreflight(target) + .then((pf) => alive && setPreflight(pf)) + .catch((e) => alive && setError(e?.message || "We couldn't read this for sharing.")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [target.kind, target.id]); + + useEffect(() => { + if (!open) return; + return load(); + }, [open, load]); + + const handleDownload = async () => { + if (!preflight) return; + setDownloading(true); + try { + await downloadSwarm(target, preflight.filename); + setToast(`Saved ${preflight.filename}`); + onClose(); + } catch (e: any) { + setError(e?.message || "We couldn't build the file."); + } finally { + setDownloading(false); + } + }; + + const optionRow = ( + selected: boolean, + icon: React.ReactNode, + title: string, + subtitle: string, + disabled?: boolean, + chip?: string, + ) => ( + + {icon} + + + {title} + {chip && ( + + )} + + {subtitle} + + + ); + + return ( + <> + + + + Share {target.name} + + + + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + + ) : preflight ? ( + + ) : null} + + + {optionRow(true, , 'Download .swarm file', 'Save a file you can send to anyone.')} + {optionRow( + false, + , + 'Create share link', + 'A link that opens straight in OpenSwarm.', + true, + 'Coming soon', + )} + + + + + + + + setToast('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast('')} + sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}`, fontSize: '0.82rem' }} + > + {toast} + + + + ); +}; + +export default ShareModal; diff --git a/frontend/src/app/components/share/shareApi.ts b/frontend/src/app/components/share/shareApi.ts new file mode 100644 index 000000000..daa244b14 --- /dev/null +++ b/frontend/src/app/components/share/shareApi.ts @@ -0,0 +1,72 @@ +// Thin fetch helpers for the .swarm endpoints. The global interceptor in +// shared/config.ts attaches the bearer token, so we never set it here. Errors +// surface the backend's short detail message (those are already user-facing) or +// a friendly fallback; callers translate to a toast. +import { API_BASE } from '@/shared/config'; + +import { + ExportPreflight, + ImportCommitResult, + ImportPreflight, + ShareTarget, +} from './shareTypes'; + +async function _detail(res: Response, fallback: string): Promise { + try { + const data = await res.json(); + if (data && typeof data.detail === 'string' && data.detail) return data.detail; + } catch { + /* non-JSON error body */ + } + return fallback; +} + +export async function exportPreflight(target: ShareTarget): Promise { + const res = await fetch(`${API_BASE}/swarm/export/preflight`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: target.kind, id: target.id }), + }); + if (!res.ok) throw new Error(await _detail(res, "We couldn't read this for sharing.")); + return res.json(); +} + +export async function downloadSwarm(target: ShareTarget, filename: string): Promise { + const res = await fetch(`${API_BASE}/swarm/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: target.kind, id: target.id }), + }); + if (!res.ok) throw new Error(await _detail(res, "We couldn't build the file.")); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export async function importPreflight(file: File): Promise { + const form = new FormData(); + form.append('file', file); + // No Content-Type header: the browser sets the multipart boundary itself. + const res = await fetch(`${API_BASE}/swarm/import/preflight`, { method: 'POST', body: form }); + if (!res.ok) throw new Error(await _detail(res, "We couldn't read this file.")); + return res.json(); +} + +export async function importCommit( + stagingToken: string, + acceptRequirements: string[] = [], +): Promise { + const res = await fetch(`${API_BASE}/swarm/import/commit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ staging_token: stagingToken, accept_requirements: acceptRequirements }), + }); + if (!res.ok) throw new Error(await _detail(res, "We couldn't finish the import.")); + return res.json(); +} diff --git a/frontend/src/app/components/share/shareTypes.ts b/frontend/src/app/components/share/shareTypes.ts new file mode 100644 index 000000000..65ed258c4 --- /dev/null +++ b/frontend/src/app/components/share/shareTypes.ts @@ -0,0 +1,53 @@ +// Shared types for the .swarm share/import UI. The *Response shapes mirror the +// backend pydantic models in backend/apps/swarm/models.py; keep them in sync. + +export type ShareKind = 'skill' | 'app' | 'workflow' | 'dashboard'; + +export interface ShareTarget { + kind: ShareKind; + id: string; + name: string; +} + +export interface IncludeItem { + type: string; + name: string; + detail?: string; +} + +export interface RequirementView { + kind: string; + key: string; + label: string; + detail?: string; +} + +export interface BundleSummary { + root: IncludeItem; + includes: IncludeItem[]; + requirements: RequirementView[]; + counts: Record; +} + +export interface ExportPreflight { + ok: boolean; + summary: BundleSummary; + filename: string; + link_supported: boolean; +} + +export interface ImportPreflight { + ok: boolean; + summary: BundleSummary; + staging_token: string; + conflicts: IncludeItem[]; + warnings: string[]; +} + +export interface ImportCommitResult { + ok: boolean; + root_type: ShareKind; + root_id: string; + created: Record; + unresolved_requirements: RequirementView[]; +} diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 3ee42800d..482208e84 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -51,6 +51,7 @@ import { RegistrySkillDetail, } from '@/shared/state/skillRegistrySlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; +import ShareButton from '@/app/components/share/ShareButton'; import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat'; interface SkillForm { @@ -619,6 +620,7 @@ const Skills: React.FC = () => { )} + openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}> From 7ea461fc52d241bfbf912ad99e969eb28a1b0d44 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:00:12 -0700 Subject: [PATCH 04/12] [eric] swarm: .swarm import flow (drag-drop + file picker, preflight/commit modal) --- frontend/src/app/Main.tsx | 2 + .../app/components/share/ImportEntryPoint.tsx | 120 ++++++++++++ .../src/app/components/share/ImportModal.tsx | 171 ++++++++++++++++++ frontend/src/app/pages/Skills/Skills.tsx | 11 ++ 4 files changed, 304 insertions(+) create mode 100644 frontend/src/app/components/share/ImportEntryPoint.tsx create mode 100644 frontend/src/app/components/share/ImportModal.tsx diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index ab887c451..28e564f41 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -20,6 +20,7 @@ import { setUpdateError, } from '@/shared/state/updateSlice'; import AppShell from './components/Layout/AppShell'; +import ImportEntryPoint from './components/share/ImportEntryPoint'; import DashboardSelection from './pages/DashboardSelection/DashboardSelection'; import ErrorBoundary from './components/feedback/ErrorBoundary'; import { setPanelMode, disableOnboardingAfterCrash } from '@/shared/state/onboardingProgressSlice'; @@ -468,6 +469,7 @@ const ThemedApp: React.FC = () => { + diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx new file mode 100644 index 000000000..d5b604f67 --- /dev/null +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -0,0 +1,120 @@ +// The one global import affordance: a hidden file picker plus a window-wide +// drag-and-drop overlay. Mount once near the app root. A sidebar/page button +// opens the picker by dispatching IMPORT_OPEN_EVENT, so there's a single owner +// of the ImportModal (no duplicate modals). +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Box from '@mui/material/Box'; +import Fade from '@mui/material/Fade'; +import Typography from '@mui/material/Typography'; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import ImportModal from './ImportModal'; + +export const IMPORT_OPEN_EVENT = 'openswarm:import-open'; + +const ACCEPT = '.swarm,.md,.zip'; + +function looksImportable(name: string): boolean { + const n = name.toLowerCase(); + return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip'); +} + +const ImportEntryPoint: React.FC = () => { + const c = useClaudeTokens(); + const inputRef = useRef(null); + const [pending, setPending] = useState(null); + const [dragging, setDragging] = useState(false); + const depth = useRef(0); + + const take = useCallback((f: File | null) => { + if (f && looksImportable(f.name)) setPending(f); + }, []); + + useEffect(() => { + const openPicker = () => inputRef.current?.click(); + window.addEventListener(IMPORT_OPEN_EVENT, openPicker); + return () => window.removeEventListener(IMPORT_OPEN_EVENT, openPicker); + }, []); + + useEffect(() => { + const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files'); + // Webviews are a separate compositor layer; ignore drops landing on one. + const onWebview = (t: EventTarget | null) => (t as HTMLElement)?.tagName === 'WEBVIEW'; + + const onEnter = (e: DragEvent) => { + if (!hasFiles(e) || onWebview(e.target)) return; + depth.current += 1; + setDragging(true); + }; + const onLeave = () => { + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setDragging(false); + }; + const onOver = (e: DragEvent) => { + if (hasFiles(e)) e.preventDefault(); + }; + const onDrop = (e: DragEvent) => { + depth.current = 0; + setDragging(false); + if (onWebview(e.target)) return; + const f = e.dataTransfer?.files?.[0]; + if (f) { + e.preventDefault(); + take(f); + } + }; + + window.addEventListener('dragenter', onEnter); + window.addEventListener('dragleave', onLeave); + window.addEventListener('dragover', onOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onEnter); + window.removeEventListener('dragleave', onLeave); + window.removeEventListener('dragover', onOver); + window.removeEventListener('drop', onDrop); + }; + }, [take]); + + return ( + <> + { + take(e.target.files?.[0] || null); + e.target.value = ''; + }} + /> + + + + + Drop to import into OpenSwarm + + + + setPending(null)} /> + + ); +}; + +export default ImportEntryPoint; diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx new file mode 100644 index 000000000..d5ea38d16 --- /dev/null +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -0,0 +1,171 @@ +// Import side of .swarm: preflight shows what's inside (and any environment +// requirements as informational "Needs X" rows), then commit writes the +// entities with fresh ids and we navigate to the imported root. Requirements in +// v1 are informational only; the live "enable this Action" walkthrough lands +// with the app/dashboard slices, so we never imply a grant we don't perform. +import React, { useCallback, useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Dialog from '@mui/material/Dialog'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import CircularProgress from '@mui/material/CircularProgress'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import CloseIcon from '@mui/icons-material/Close'; +import { useNavigate } from 'react-router-dom'; + +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +import IncludesList from './IncludesList'; +import { importCommit, importPreflight } from './shareApi'; +import { ImportPreflight } from './shareTypes'; + +interface Props { + file: File | null; + open: boolean; + onClose: () => void; +} + +const DEST: Record string> = { + app: (id) => `/apps/${id}`, + dashboard: (id) => `/dashboard/${id}`, + skill: () => '/skills', +}; + +const ImportModal: React.FC = ({ file, open, onClose }) => { + const c = useClaudeTokens(); + const navigate = useNavigate(); + const [preflight, setPreflight] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [committing, setCommitting] = useState(false); + + const load = useCallback(() => { + if (!file) return undefined; + setPreflight(null); + setError(''); + setLoading(true); + let alive = true; + importPreflight(file) + .then((pf) => alive && setPreflight(pf)) + .catch((e) => alive && setError(e?.message || "We couldn't read this file.")) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [file]); + + useEffect(() => { + if (!open) return; + return load(); + }, [open, load]); + + const handleCommit = async () => { + if (!preflight) return; + setCommitting(true); + try { + const result = await importCommit(preflight.staging_token); + const dest = (DEST[result.root_type] || (() => '/skills'))(result.root_id); + onClose(); + navigate(dest); + } catch (e: any) { + setError(e?.message || "We couldn't finish the import."); + } finally { + setCommitting(false); + } + }; + + return ( + <> + + + + Import {preflight ? preflight.summary.root.name : ''} + + + + + + + + {loading ? ( + + + + ) : error ? ( + + {error} + + + ) : preflight ? ( + <> + + {preflight.conflicts.length > 0 && ( + + Some items already exist and will be added as copies. + + )} + {preflight.warnings.map((w, i) => ( + + {w} + + ))} + + + + + ) : null} + + + + setError('')} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {error} + + + + ); +}; + +export default ImportModal; diff --git a/frontend/src/app/pages/Skills/Skills.tsx b/frontend/src/app/pages/Skills/Skills.tsx index 482208e84..c34be184a 100644 --- a/frontend/src/app/pages/Skills/Skills.tsx +++ b/frontend/src/app/pages/Skills/Skills.tsx @@ -52,6 +52,8 @@ import { } from '@/shared/state/skillRegistrySlice'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; import ShareButton from '@/app/components/share/ShareButton'; +import { IMPORT_OPEN_EVENT } from '@/app/components/share/ImportEntryPoint'; +import UploadFileIcon from '@mui/icons-material/UploadFile'; import SkillBuilderChat, { SkillPreviewData } from './SkillBuilderChat'; interface SkillForm { @@ -315,6 +317,15 @@ const Skills: React.FC = () => { Skills + + window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))} + sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }} + > + + + Date: Sun, 14 Jun 2026 06:11:16 -0700 Subject: [PATCH 05/12] [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 937f3635b..dba4e3980 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 000000000..a9a345440 --- /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 e2a6281b0..168a595a4 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 000000000..fb8f0d009 --- /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 119e64f46..734677fc7 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 3e454ccc4..19a562a4e 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: From c179d20c16f1364863861468ae29d281f618e7f1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:11:22 -0700 Subject: [PATCH 06/12] [eric] swarm: Share button on app card + editor, import review/trust warning --- .../src/app/components/share/ImportModal.tsx | 17 +++++++++++++++ .../src/app/components/share/ShareButton.tsx | 21 +++++++++++++------ .../src/app/components/share/shareTypes.ts | 7 +++++++ frontend/src/app/pages/Views/ViewCard.tsx | 2 ++ frontend/src/app/pages/Views/ViewEditor.tsx | 6 ++++++ 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx index d5ea38d16..ef3a1228a 100644 --- a/frontend/src/app/components/share/ImportModal.tsx +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -117,6 +117,23 @@ const ImportModal: React.FC = ({ file, open, onClose }) => { ) : preflight ? ( <> + {preflight.review && preflight.review.findings.length > 0 && ( + + {preflight.review.findings.map((f, i) => ( + + {f} + + ))} + + )} {preflight.conflicts.length > 0 && ( Some items already exist and will be added as copies. diff --git a/frontend/src/app/components/share/ShareButton.tsx b/frontend/src/app/components/share/ShareButton.tsx index 90d369e53..1e772ed8f 100644 --- a/frontend/src/app/components/share/ShareButton.tsx +++ b/frontend/src/app/components/share/ShareButton.tsx @@ -18,11 +18,19 @@ interface Props { target: ShareTarget; size?: 'small' | 'medium'; variant?: 'icon' | 'menuItem'; + tone?: 'plain' | 'chip'; // 'chip' matches floating card-action buttons iconFontSize?: number; onOpen?: () => void; // let a parent close its overflow menu when we take over } -const ShareButton: React.FC = ({ target, size = 'small', variant = 'icon', iconFontSize = 18, onOpen }) => { +const ShareButton: React.FC = ({ + target, + size = 'small', + variant = 'icon', + tone = 'plain', + iconFontSize = 18, + onOpen, +}) => { const c = useClaudeTokens(); const [open, setOpen] = useState(false); @@ -32,6 +40,11 @@ const ShareButton: React.FC = ({ target, size = 'small', variant = 'icon' setOpen(true); }; + const iconSx = + tone === 'chip' + ? { bgcolor: c.bg.surface, color: c.accent.primary, boxShadow: c.shadow.sm, '&:hover': { bgcolor: c.bg.elevated } } + : { color: c.text.tertiary, '&:hover': { color: c.accent.primary } }; + return ( <> {variant === 'menuItem' ? ( @@ -43,11 +56,7 @@ const ShareButton: React.FC = ({ target, size = 'small', variant = 'icon' ) : ( - + diff --git a/frontend/src/app/components/share/shareTypes.ts b/frontend/src/app/components/share/shareTypes.ts index 65ed258c4..271410e72 100644 --- a/frontend/src/app/components/share/shareTypes.ts +++ b/frontend/src/app/components/share/shareTypes.ts @@ -36,11 +36,18 @@ export interface ExportPreflight { link_supported: boolean; } +export interface ReviewSummary { + verdict: 'clean' | 'warn' | 'block'; + findings: string[]; + scanned_files: string[]; +} + export interface ImportPreflight { ok: boolean; summary: BundleSummary; staging_token: string; conflicts: IncludeItem[]; + review?: ReviewSummary | null; warnings: string[]; } diff --git a/frontend/src/app/pages/Views/ViewCard.tsx b/frontend/src/app/pages/Views/ViewCard.tsx index acf21de4b..c8d564d92 100644 --- a/frontend/src/app/pages/Views/ViewCard.tsx +++ b/frontend/src/app/pages/Views/ViewCard.tsx @@ -9,6 +9,7 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import Icon from '@mui/material/Icon'; import { Output } from '@/shared/state/outputsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import ShareButton from '@/app/components/share/ShareButton'; interface Props { output: Output; @@ -106,6 +107,7 @@ const ViewCard: React.FC = ({ output, onClick, onDelete, onRun }) => { + = ({ output }) => { }} /> + {effectiveId && ( + + + + )} {/* Tab bar */} From 9b80d0417a3b378a9647dc1c0614a4ec6cae5415 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:27:52 -0700 Subject: [PATCH 07/12] [eric] swarm: workflow export/import entity (lazy store, schedule-off + PII-strip on import) --- backend/apps/swarm/entities/workflows.py | 120 +++++++++++++++++++++++ backend/apps/swarm/registry.py | 2 + backend/tests/test_swarm_bundle.py | 35 +++++++ 3 files changed, 157 insertions(+) create mode 100644 backend/apps/swarm/entities/workflows.py diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py new file mode 100644 index 000000000..d7b2fb8f6 --- /dev/null +++ b/backend/apps/swarm/entities/workflows.py @@ -0,0 +1,120 @@ +"""WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule +shape, actions, model). The workflow store lives on the eric/workflow branch and +is NOT on eric/dev yet, so every store touch is lazy: on a build without it, +export finds nothing and import fails with a clear message, and the module still +imports cleanly. It lights up the moment the workflow forward-port lands. + +Safety: an imported workflow must never silently start running on someone else's +machine, so the schedule is forced off on import (the importer re-arms it). The +sharer's phone numbers (text/call escalation) are stripped as PII, and run +history / session + dashboard linkage are dropped.""" +from __future__ import annotations + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement, RequirementKind + +_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} + +# Run-state, machine-linkage, and identifiers that must not ride along. +_DROP_FIELDS = { + "id", "source_session_id", "dashboard_id", "edit_agent_session_id", + "last_run_at", "last_run_status", "last_run_id", "next_run_at", + "created_at", "updated_at", "cost_cap_usd_monthly", +} + + +def _sanitize_workflow(data: dict) -> dict: + out = {k: v for k, v in data.items() if k not in _DROP_FIELDS} + sched = dict(out.get("schedule") or {}) + if sched: + sched["enabled"] = False + sched["runs_count"] = 0 + sched["next_run_at"] = None + sched["ends_at"] = None + out["schedule"] = sched + perms = [] + for tier in out.get("permissions") or []: + t = dict(tier) + t["phone"] = None # the sharer's number; the importer sets their own + perms.append(t) + if perms: + out["permissions"] = perms + return out + + +class WorkflowExportable: + type = EntityType.workflow + + def __init__(self, local_id: str, name: str, data: dict): + self.local_id = local_id + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "WorkflowExportable | None": + store = _store() + if store is None: + return None + wf = store.get_workflow(local_id) + if wf is None: + return None + data = wf.model_dump(mode="json") + return cls(local_id, data.get("title") or "Untitled workflow", data) + + def serialize(self, ctx: ExportContext) -> dict: + return _sanitize_workflow(self._data) + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + return [] + + def requirements(self) -> list[Requirement]: + reqs: list[Requirement] = [] + for name in (self._data.get("actions") or {}).get("configured_sets") or []: + reqs.append(Requirement( + kind=RequirementKind.mcp_action, key=name, label=name, + detail="This workflow uses this action.", + )) + mode = self._data.get("mode") or "agent" + if mode in _BUILTIN_MODES and mode != "agent": + reqs.append(Requirement( + kind=RequirementKind.builtin_mode, key=mode, label=f"{mode} mode", + detail="A built-in mode this workflow runs in.", + )) + provider = self._data.get("provider") or "anthropic" + reqs.append(Requirement( + kind=RequirementKind.api_key, key=provider, label=f"A {provider} model", + detail="Set up this provider to run the workflow.", + )) + return reqs + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + store = _store() + model = _model() + if store is None or model is None: + from ..ziputil import BundleError + raise BundleError("this build doesn't support workflows yet; please update OpenSwarm") + clean = _sanitize_workflow(payload) + clean.pop("id", None) # fresh id via the model's default_factory + wf = model(**clean) + store.save_workflow(wf) + return wf.id + + +def _store(): + try: + from backend.apps.workflows import storage + return storage + except Exception: + return None + + +def _model(): + try: + from backend.apps.workflows.models import Workflow + return Workflow + except Exception: + return None diff --git a/backend/apps/swarm/registry.py b/backend/apps/swarm/registry.py index 168a595a4..e771850f1 100644 --- a/backend/apps/swarm/registry.py +++ b/backend/apps/swarm/registry.py @@ -2,11 +2,13 @@ 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 .entities.workflows import WorkflowExportable from .models import EntityType REGISTRY: dict[EntityType, type] = { EntityType.skill: SkillExportable, EntityType.app: AppExportable, + EntityType.workflow: WorkflowExportable, } # Leaves first: a dependency must import before whatever references it. diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 19a562a4e..d0a423eda 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -120,6 +120,41 @@ def test_app_export_drops_machine_env(tmp_path, monkeypatch): 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 _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From 6899ea5bd1f6a049c48961ec0b03addf136a6d6d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:44:16 -0700 Subject: [PATCH 08/12] [eric] swarm: dashboard export/import (closure of agents+apps+modes, two-class deps) --- backend/apps/swarm/entities/dashboards.py | 138 ++++++++++++++++++++++ backend/apps/swarm/entities/modes.py | 79 +++++++++++++ backend/apps/swarm/entities/sessions.py | 95 +++++++++++++++ backend/apps/swarm/registry.py | 6 + backend/tests/test_swarm_bundle.py | 62 ++++++++++ 5 files changed, 380 insertions(+) create mode 100644 backend/apps/swarm/entities/dashboards.py create mode 100644 backend/apps/swarm/entities/modes.py create mode 100644 backend/apps/swarm/entities/sessions.py diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py new file mode 100644 index 000000000..eebb25078 --- /dev/null +++ b/backend/apps/swarm/entities/dashboards.py @@ -0,0 +1,138 @@ +"""DashboardExportable: the bundling showcase. A dashboard's agent cards and app +cards are pulled into the closure as sessions + apps (each session pulls its +custom mode); the layout's entity-keyed dicts are rewritten local->bundle on +export and bundle->fresh-local on import via the RemapTable. Mirrors the in-app +duplicate_dashboard remap. Browser cards keep their url/tabs but get fresh ids; +after writing the dashboard we re-point each imported session at it.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + + +class DashboardExportable: + type = EntityType.dashboard + + def __init__(self, did: str, name: str, data: dict): + self.local_id = did + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "DashboardExportable | None": + data = _read(local_id) + if data is None: + return None + return cls(local_id, data.get("name") or "Dashboard", data) + + def serialize(self, ctx: ExportContext) -> dict: + layout = dict(self._data.get("layout") or {}) + cards = {} + for sid, card in (layout.get("cards") or {}).items(): + bid = ctx.bundle_id_for(EntityType.session, sid) + if bid: + cards[bid] = {**card, "session_id": bid} + view_cards = {} + for oid, card in (layout.get("view_cards") or {}).items(): + bid = ctx.bundle_id_for(EntityType.app, oid) + if bid: + view_cards[bid] = {**card, "output_id": bid} + browser_cards = {} + for bkey, card in (layout.get("browser_cards") or {}).items(): + c = dict(card) + spawn = c.get("spawned_by") + c["spawned_by"] = ctx.bundle_id_for(EntityType.session, spawn) if spawn else None + browser_cards[bkey] = c + expanded = [b for b in (ctx.bundle_id_for(EntityType.session, s) for s in (layout.get("expanded_session_ids") or [])) if b] + return {"name": self._data.get("name") or "Dashboard", "layout": { + **layout, "cards": cards, "view_cards": view_cards, + "browser_cards": browser_cards, "notes": layout.get("notes") or {}, + "expanded_session_ids": expanded, + }} + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + layout = self._data.get("layout") or {} + deps = [DepRef(EntityType.session, sid, "has_agent") for sid in (layout.get("cards") or {})] + deps += [DepRef(EntityType.app, oid, "has_app") for oid in (layout.get("view_cards") or {})] + return deps + + def requirements(self) -> list[Requirement]: + return [] + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + new_did = uuid4().hex + layout = dict(payload.get("layout") or {}) + cards = {} + for bid, card in (layout.get("cards") or {}).items(): + nsid = remap.local(bid) + if nsid: + cards[nsid] = {**card, "session_id": nsid} + view_cards = {} + for bid, card in (layout.get("view_cards") or {}).items(): + noid = remap.local(bid) + if noid: + view_cards[noid] = {**card, "output_id": noid} + browser_cards = {} + for _bkey, card in (layout.get("browser_cards") or {}).items(): + nbid = "browser-" + uuid4().hex[:10] + c = dict(card) + c["browser_id"] = nbid + spawn = c.get("spawned_by") + c["spawned_by"] = remap.local(spawn) if spawn else None + browser_cards[nbid] = c + expanded = [e for e in (remap.local(b) for b in (layout.get("expanded_session_ids") or [])) if e] + now = datetime.now(timezone.utc).isoformat() + doc = { + "id": new_did, + "name": payload.get("name") or "Imported Dashboard", + "auto_named": False, + "created_at": now, + "updated_at": now, + "layout": { + **layout, "cards": cards, "view_cards": view_cards, + "browser_cards": browser_cards, "notes": layout.get("notes") or {}, + "expanded_session_ids": expanded, + }, + } + _write(new_did, doc) + _retag_sessions(cards.keys(), new_did) + return new_did + + +def _dash_dir() -> str | None: + try: + from backend.config.paths import DASHBOARDS_DIR + return DASHBOARDS_DIR + except Exception: + return None + + +def _read(did: str) -> dict | None: + import os + from backend.config.json_store import read_json_or_none + d = _dash_dir() + return read_json_or_none(os.path.join(d, f"{did}.json")) if d else None + + +def _write(did: str, doc: dict) -> None: + import os + from backend.config.json_store import atomic_write_json + d = _dash_dir() + if d: + atomic_write_json(os.path.join(d, f"{did}.json"), doc) + + +def _retag_sessions(session_ids, dashboard_id: str) -> None: + from backend.apps.agents.manager.session.session_store import _load_session_data, _save_session + for sid in session_ids: + d = _load_session_data(sid) + if d is not None: + d["dashboard_id"] = dashboard_id + _save_session(sid, d) diff --git a/backend/apps/swarm/entities/modes.py b/backend/apps/swarm/entities/modes.py new file mode 100644 index 000000000..015d3eb90 --- /dev/null +++ b/backend/apps/swarm/entities/modes.py @@ -0,0 +1,79 @@ +"""ModeExportable: a user-created mode (system prompt + allowed tools). Pulled in +as a dependency when a shared dashboard's agent runs in a custom mode. Built-in +modes (agent/ask/plan/...) ship with every install, so they're never bundled, +they surface as requirements instead. Modes are referenced by slug, so import +reuses an existing same-slug mode rather than clobbering it (keeps the session's +`mode` pointer valid without rewriting it).""" +from __future__ import annotations + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + +# Machine-relative or install-owned fields that must not ride along. +_DROP = {"is_builtin", "default_folder"} + + +class ModeExportable: + type = EntityType.mode + + def __init__(self, mode_id: str, name: str, data: dict): + self.local_id = mode_id + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "ModeExportable | None": + store = _store() + if store is None: + return None + m = store.load_mode(local_id) + if m is None: + return None + d = m.model_dump() + return cls(local_id, d.get("name") or local_id, d) + + def serialize(self, ctx: ExportContext) -> dict: + return {k: v for k, v in self._data.items() if k not in _DROP} + + def files(self) -> dict[str, bytes]: + return {} + + 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: + store = _store() + model = _model() + if store is None or model is None: + from ..ziputil import BundleError + raise BundleError("can't import this mode on this build") + mid = payload.get("id") or (payload.get("name") or "mode").lower().replace(" ", "-") + # Reuse a same-slug mode (incl. built-ins) instead of overwriting it; + # sessions point at modes by this slug. + if store.load_mode(mid) is not None: + return mid + data = {k: v for k, v in payload.items() if k != "is_builtin"} + data["id"] = mid + data["is_builtin"] = False + store._save(model(**data)) + return mid + + +def _store(): + try: + from backend.apps.modes import modes + return modes + except Exception: + return None + + +def _model(): + try: + from backend.apps.modes.models import Mode + return Mode + except Exception: + return None diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py new file mode 100644 index 000000000..a028b77d4 --- /dev/null +++ b/backend/apps/swarm/entities/sessions.py @@ -0,0 +1,95 @@ +"""SessionExportable: an agent card on a shared dashboard. We carry only the +recipe (name, model, mode, system prompt, allowed tools) and deliberately DROP +the chat transcript (privacy + size), runtime state, costs, the worktree path, +and active_mcps (importing must never silently grant tool access, per the gate). +Its MCP/actions, provider, and built-in mode become import requirements so the +importer is walked through enabling them. The dashboard re-points dashboard_id +after import.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement, RequirementKind + +_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} +_KEEP = ("name", "provider", "model", "mode", "system_prompt", "allowed_tools", "max_turns", "thinking_level") + + +class SessionExportable: + type = EntityType.session + + def __init__(self, sid: str, name: str, data: dict): + self.local_id = sid + self.name = name + self._data = data + + @classmethod + def load(cls, local_id: str) -> "SessionExportable | None": + from backend.apps.agents.manager.session.session_store import _load_session_data + d = _load_session_data(local_id) + if d is None: + return None + return cls(local_id, d.get("name") or "Agent", d) + + def serialize(self, ctx: ExportContext) -> dict: + return {k: self._data.get(k) for k in _KEEP if k in self._data} + + def files(self) -> dict[str, bytes]: + return {} + + def dependencies(self) -> list[DepRef]: + mode = self._data.get("mode") + if mode and mode not in _BUILTIN_MODES: + return [DepRef(EntityType.mode, mode, "uses_mode")] + return [] + + def requirements(self) -> list[Requirement]: + reqs: list[Requirement] = [] + for mcp in self._data.get("active_mcps") or []: + reqs.append(Requirement( + kind=RequirementKind.mcp_action, key=mcp, label=mcp, + detail="An agent here uses this action.", + )) + mode = self._data.get("mode") or "agent" + if mode in _BUILTIN_MODES and mode != "agent": + reqs.append(Requirement( + kind=RequirementKind.builtin_mode, key=mode, label=f"{mode} mode", + detail="A built-in mode an agent runs in.", + )) + provider = self._data.get("provider") or "anthropic" + reqs.append(Requirement( + kind=RequirementKind.api_key, key=provider, label=f"A {provider} model", + detail="Set up this provider so the agents can run.", + )) + return reqs + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + from backend.apps.agents.manager.session.session_store import _save_session + sid = uuid4().hex + now = datetime.now(timezone.utc).isoformat() + doc = { + "id": sid, + "name": payload.get("name") or "Agent", + "status": "completed", + "provider": payload.get("provider") or "anthropic", + "model": payload.get("model") or "sonnet", + "mode": payload.get("mode") or "agent", + "system_prompt": payload.get("system_prompt"), + "allowed_tools": payload.get("allowed_tools") or [], + "max_turns": payload.get("max_turns"), + "thinking_level": payload.get("thinking_level") or "auto", + "messages": [], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}}, + "active_branch_id": "main", + "active_mcps": [], + "dashboard_id": None, # the dashboard import re-points this + "browser_id": None, + "parent_session_id": None, + "created_at": now, + "closed_at": now, + } + _save_session(sid, doc) + return sid diff --git a/backend/apps/swarm/registry.py b/backend/apps/swarm/registry.py index e771850f1..735545430 100644 --- a/backend/apps/swarm/registry.py +++ b/backend/apps/swarm/registry.py @@ -1,6 +1,9 @@ """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.dashboards import DashboardExportable +from .entities.modes import ModeExportable +from .entities.sessions import SessionExportable from .entities.skills import SkillExportable from .entities.workflows import WorkflowExportable from .models import EntityType @@ -9,6 +12,9 @@ EntityType.skill: SkillExportable, EntityType.app: AppExportable, EntityType.workflow: WorkflowExportable, + EntityType.mode: ModeExportable, + EntityType.session: SessionExportable, + EntityType.dashboard: DashboardExportable, } # Leaves first: a dependency must import before whatever references it. diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index d0a423eda..fc6bb7a70 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -155,6 +155,68 @@ def test_workflow_unavailable_on_this_branch(): 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: From cff334a02bcdf6e03dc9d164cf171b246e6233f0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:44:16 -0700 Subject: [PATCH 09/12] [eric] swarm: Share button on the dashboard header --- .../Dashboard/canvas/DashboardHeader.tsx | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx index 8c1b4902c..e9388d4d7 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx @@ -1,12 +1,13 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import DashboardIcon from '@mui/icons-material/Dashboard'; +import { LayoutDashboard } from 'lucide-react'; import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import LanguageIcon from '@mui/icons-material/Language'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import ShareButton from '@/app/components/share/ShareButton'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; @@ -111,20 +112,24 @@ const DashboardHeader: React.FC = ({ sx={{ display: 'flex', alignItems: 'center', - gap: 1, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - borderRadius: expanded ? `${c.radius.lg}px ${c.radius.lg}px 0 0` : `${c.radius.lg}px`, - boxShadow: c.shadow.sm, - py: 0.75, - px: 1.5, + gap: 0.75, + // macOS-toolbar vibrancy: a faint translucent material + blur so the + // title stays legible over the dot grid without a hard box. + bgcolor: expanded ? c.bg.surface : `${c.bg.surface}40`, + backdropFilter: 'blur(16px) saturate(180%)', + WebkitBackdropFilter: 'blur(16px) saturate(180%)', + borderRadius: '6px', + py: 0.5, + px: 0.75, cursor: hasItems ? 'pointer' : 'default', userSelect: 'none', - transition: 'border-radius 0.2s', - '&:hover': hasItems ? { bgcolor: c.bg.secondary } : {}, + transition: 'background-color 0.12s ease', + '&:hover': hasItems ? { bgcolor: `${c.bg.surface}99` } : {}, }} > - + + + = ({ sx={{ fontSize: 18, color: c.text.tertiary, - transition: 'transform 0.2s', + transition: 'transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1)', transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)', ml: 0.25, }} /> )} + {dashboardId && ( + + + + )} {/* Dropdown overlay */} @@ -167,10 +180,10 @@ const DashboardHeader: React.FC = ({ > Date: Sun, 14 Jun 2026 07:22:12 -0700 Subject: [PATCH 10/12] [eric] swarm: drop-to-import with GPU-safe pixel digest; modal only for code/action bundles --- .../src/app/components/share/ImportDigest.tsx | 114 +++++++++ .../app/components/share/ImportEntryPoint.tsx | 130 ++++++++-- .../src/app/components/share/ImportModal.tsx | 239 ++++++------------ 3 files changed, 303 insertions(+), 180 deletions(-) create mode 100644 frontend/src/app/components/share/ImportDigest.tsx diff --git a/frontend/src/app/components/share/ImportDigest.tsx b/frontend/src/app/components/share/ImportDigest.tsx new file mode 100644 index 000000000..c08ecee86 --- /dev/null +++ b/frontend/src/app/components/share/ImportDigest.tsx @@ -0,0 +1,114 @@ +// The "digest" flash that plays where you drop a .swarm: an expanding ring of +// brand-tinted dithered pixels, evoking PixelBlast WITHOUT any WebGL. PixelBlast +// is a single shared WebGL2 context (one canvas, reparented) and reusing it here +// would fight an app's loading animation over that one canvas, plus rapid +// WebGL-context churn is the exact thing that crashed the GPU process. So this is +// plain Canvas2D on ONE pooled canvas, and play() refuses to start while a burst +// is already running, so drop-spam can never pile up work. +import React, { forwardRef, useImperativeHandle, useRef } from 'react'; + +export interface DigestHandle { + // Returns false if a burst is already playing (caller should ignore the drop). + play: (x: number, y: number) => boolean; +} + +const SIZE = 240; +const CELL = 6; +const DURATION = 680; +const RADIUS_MAX = 132; + +function dither(gx: number, gy: number): number { + const v = Math.sin(gx * 12.9898 + gy * 78.233) * 43758.5453; + return v - Math.floor(v); +} + +const ImportDigest = forwardRef(({ color = '#c4633a' }, ref) => { + const canvasRef = useRef(null); + const busyRef = useRef(false); + const rafRef = useRef(0); + + useImperativeHandle(ref, () => ({ + play(x: number, y: number): boolean { + if (busyRef.current) return false; + const canvas = canvasRef.current; + if (!canvas) return false; + + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + busyRef.current = true; + canvas.style.left = `${x - SIZE / 2}px`; + canvas.style.top = `${y - SIZE / 2}px`; + canvas.style.opacity = '1'; + + const finish = () => { + busyRef.current = false; + canvas.style.opacity = '0'; + }; + if (reduce) { + // Honor reduced-motion: no flashing pixels, just a brief, calm beat. + window.setTimeout(finish, 200); + return true; + } + + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = SIZE * dpr; + canvas.height = SIZE * dpr; + const ctx = canvas.getContext('2d'); + if (!ctx) { + finish(); + return true; + } + ctx.scale(dpr, dpr); + const cells = Math.ceil(SIZE / CELL); + const center = SIZE / 2; + const start = performance.now(); + + const frame = () => { + const t = Math.min(1, (performance.now() - start) / DURATION); + const eased = 1 - Math.pow(1 - t, 3); + const ring = eased * RADIUS_MAX; + ctx.clearRect(0, 0, SIZE, SIZE); + ctx.fillStyle = color; + for (let gy = 0; gy < cells; gy++) { + for (let gx = 0; gx < cells; gx++) { + const px = gx * CELL + CELL / 2; + const py = gy * CELL + CELL / 2; + const dist = Math.hypot(px - center, py - center); + const band = 1 - Math.abs(dist - ring) / 34; // bright at the expanding front + if (band <= 0) continue; + const a = band * (0.35 + 0.65 * dither(gx, gy)) * (1 - t * 0.25); + if (a <= 0) continue; + ctx.globalAlpha = a > 1 ? 1 : a; + ctx.fillRect(gx * CELL, gy * CELL, CELL - 1, CELL - 1); + } + } + if (t < 1) { + rafRef.current = requestAnimationFrame(frame); + } else { + finish(); + } + }; + rafRef.current = requestAnimationFrame(frame); + return true; + }, + })); + + return ( + + ); +}); + +ImportDigest.displayName = 'ImportDigest'; +export default ImportDigest; diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index d5b604f67..33a562944 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -1,36 +1,108 @@ -// The one global import affordance: a hidden file picker plus a window-wide -// drag-and-drop overlay. Mount once near the app root. A sidebar/page button -// opens the picker by dispatching IMPORT_OPEN_EVENT, so there's a single owner -// of the ImportModal (no duplicate modals). +// The one global import affordance. Drop a .swarm anywhere (or pick it): a +// GPU-safe pixel "digest" flash plays where you dropped it WHILE the preflight +// runs underneath, then it resolves straight into the import for safe bundles or +// a short confirm for ones that carry code/actions. Mount once near the app root. import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import { useNavigate } from 'react-router-dom'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import ImportDigest, { DigestHandle } from './ImportDigest'; import ImportModal from './ImportModal'; +import { importCommit, importPreflight } from './shareApi'; +import { ImportPreflight } from './shareTypes'; export const IMPORT_OPEN_EVENT = 'openswarm:import-open'; - const ACCEPT = '.swarm,.md,.zip'; +const DIGEST_MS = 700; + +const DEST: Record string | null> = { + app: (id) => `/apps/${id}`, + dashboard: (id) => `/dashboard/${id}`, +}; function looksImportable(name: string): boolean { const n = name.toLowerCase(); return n.endsWith('.swarm') || n.endsWith('.md') || n.endsWith('.zip'); } +// A bundle needs a confirm only if it can run code (an app) or wants actions +// connected; everything else is inert data and imports straight away. +function needsConfirm(pf: ImportPreflight): boolean { + const s = pf.summary; + const hasApp = s.root.type === 'app' || s.includes.some((i) => i.type === 'app'); + const hasAction = s.requirements.some((r) => r.kind === 'mcp_action'); + const risky = !!pf.review && pf.review.verdict !== 'clean'; + return hasApp || hasAction || risky; +} + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const ImportEntryPoint: React.FC = () => { const c = useClaudeTokens(); + const navigate = useNavigate(); const inputRef = useRef(null); - const [pending, setPending] = useState(null); - const [dragging, setDragging] = useState(false); + const digestRef = useRef(null); const depth = useRef(0); + const [dragging, setDragging] = useState(false); + const [confirm, setConfirm] = useState(null); + const [committing, setCommitting] = useState(false); + const [toast, setToast] = useState<{ msg: string; sev: 'success' | 'error' } | null>(null); + const confirmRef = useRef(false); // ignore new drops while a confirm is up - const take = useCallback((f: File | null) => { - if (f && looksImportable(f.name)) setPending(f); - }, []); + const finish = useCallback( + (rootType: string, rootId: string, name: string) => { + setToast({ msg: `Added ${name}`, sev: 'success' }); + const to = DEST[rootType]?.(rootId); + if (to) navigate(to); + }, + [navigate], + ); + + const commitAndFinish = useCallback( + async (pf: ImportPreflight) => { + setCommitting(true); + try { + const res = await importCommit(pf.staging_token); + finish(res.root_type, res.root_id, pf.summary.root.name); + setConfirm(null); + confirmRef.current = false; + } catch (e: any) { + setToast({ msg: e?.message || "We couldn't finish the import.", sev: 'error' }); + } finally { + setCommitting(false); + } + }, + [finish], + ); + + const handleFile = useCallback( + async (file: File | null, x: number, y: number) => { + if (!file || !looksImportable(file.name) || confirmRef.current) return; + // The digest doubles as the spam guard: it refuses to start while busy. + if (!digestRef.current?.play(x, y)) return; + let pf: ImportPreflight; + try { + [, pf] = await Promise.all([delay(DIGEST_MS), importPreflight(file)]); + } catch (e: any) { + setToast({ msg: e?.message || "We couldn't read this file.", sev: 'error' }); + return; + } + if (needsConfirm(pf)) { + confirmRef.current = true; + setConfirm(pf); + } else { + commitAndFinish(pf); + } + }, + [commitAndFinish], + ); useEffect(() => { const openPicker = () => inputRef.current?.click(); @@ -40,9 +112,7 @@ const ImportEntryPoint: React.FC = () => { useEffect(() => { const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files'); - // Webviews are a separate compositor layer; ignore drops landing on one. const onWebview = (t: EventTarget | null) => (t as HTMLElement)?.tagName === 'WEBVIEW'; - const onEnter = (e: DragEvent) => { if (!hasFiles(e) || onWebview(e.target)) return; depth.current += 1; @@ -62,10 +132,9 @@ const ImportEntryPoint: React.FC = () => { const f = e.dataTransfer?.files?.[0]; if (f) { e.preventDefault(); - take(f); + void handleFile(f, e.clientX, e.clientY); } }; - window.addEventListener('dragenter', onEnter); window.addEventListener('dragleave', onLeave); window.addEventListener('dragover', onOver); @@ -76,7 +145,7 @@ const ImportEntryPoint: React.FC = () => { window.removeEventListener('dragover', onOver); window.removeEventListener('drop', onDrop); }; - }, [take]); + }, [handleFile]); return ( <> @@ -86,10 +155,11 @@ const ImportEntryPoint: React.FC = () => { accept={ACCEPT} style={{ display: 'none' }} onChange={(e) => { - take(e.target.files?.[0] || null); + void handleFile(e.target.files?.[0] || null, window.innerWidth / 2, window.innerHeight / 2); e.target.value = ''; }} /> + { > - Drop to import into OpenSwarm + Drop to add to OpenSwarm - setPending(null)} /> + confirm && commitAndFinish(confirm)} + onClose={() => { + setConfirm(null); + confirmRef.current = false; + }} + /> + setToast(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setToast(null)} + sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}` }} + > + {toast?.msg} + + ); }; diff --git a/frontend/src/app/components/share/ImportModal.tsx b/frontend/src/app/components/share/ImportModal.tsx index ef3a1228a..9d8ab4f30 100644 --- a/frontend/src/app/components/share/ImportModal.tsx +++ b/frontend/src/app/components/share/ImportModal.tsx @@ -1,187 +1,102 @@ -// Import side of .swarm: preflight shows what's inside (and any environment -// requirements as informational "Needs X" rows), then commit writes the -// entities with fresh ids and we navigate to the imported root. Requirements in -// v1 are informational only; the live "enable this Action" walkthrough lands -// with the app/dashboard slices, so we never imply a grant we don't perform. -import React, { useCallback, useEffect, useState } from 'react'; +// Confirmation surface shown only for bundles that carry something with a +// consequence (an app that runs code, or actions that must be connected). Safe +// bundles never reach here; the entry point auto-imports them. This is purely +// presentational: the entry point owns preflight, commit, and navigation. +import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Dialog from '@mui/material/Dialog'; import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import CircularProgress from '@mui/material/CircularProgress'; -import Snackbar from '@mui/material/Snackbar'; -import Alert from '@mui/material/Alert'; import CloseIcon from '@mui/icons-material/Close'; -import { useNavigate } from 'react-router-dom'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import IncludesList from './IncludesList'; -import { importCommit, importPreflight } from './shareApi'; import { ImportPreflight } from './shareTypes'; interface Props { - file: File | null; + preflight: ImportPreflight | null; open: boolean; + committing: boolean; + onConfirm: () => void; onClose: () => void; } -const DEST: Record string> = { - app: (id) => `/apps/${id}`, - dashboard: (id) => `/dashboard/${id}`, - skill: () => '/skills', -}; - -const ImportModal: React.FC = ({ file, open, onClose }) => { +const ImportModal: React.FC = ({ preflight, open, committing, onConfirm, onClose }) => { const c = useClaudeTokens(); - const navigate = useNavigate(); - const [preflight, setPreflight] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [committing, setCommitting] = useState(false); - - const load = useCallback(() => { - if (!file) return undefined; - setPreflight(null); - setError(''); - setLoading(true); - let alive = true; - importPreflight(file) - .then((pf) => alive && setPreflight(pf)) - .catch((e) => alive && setError(e?.message || "We couldn't read this file.")) - .finally(() => alive && setLoading(false)); - return () => { - alive = false; - }; - }, [file]); - - useEffect(() => { - if (!open) return; - return load(); - }, [open, load]); - - const handleCommit = async () => { - if (!preflight) return; - setCommitting(true); - try { - const result = await importCommit(preflight.staging_token); - const dest = (DEST[result.root_type] || (() => '/skills'))(result.root_id); - onClose(); - navigate(dest); - } catch (e: any) { - setError(e?.message || "We couldn't finish the import."); - } finally { - setCommitting(false); - } - }; - return ( - <> - - - - Import {preflight ? preflight.summary.root.name : ''} - - - - - - - - {loading ? ( - - - - ) : error ? ( - - {error} - + - ) : preflight ? ( - <> - - {preflight.review && preflight.review.findings.length > 0 && ( - - {preflight.review.findings.map((f, i) => ( - - {f} - - ))} - - )} - {preflight.conflicts.length > 0 && ( - - Some items already exist and will be added as copies. - - )} - {preflight.warnings.map((w, i) => ( - - {w} - - ))} - - - - - ) : null} - - - - setError('')} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - - {error} - - - + + + )} + ); }; From 8246f03b2705765767698a9b4273f667cb2dfe9a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 07:26:50 -0700 Subject: [PATCH 11/12] [eric] dashboard: title-derived header glyph (curated icon + monogram fallback) --- .../pages/Dashboard/canvas/DashboardGlyph.tsx | 120 ++++++++++++++++++ .../Dashboard/canvas/DashboardHeader.tsx | 6 +- 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx new file mode 100644 index 000000000..c4356ada4 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardGlyph.tsx @@ -0,0 +1,120 @@ +import React, { useMemo } from 'react'; +import Box from '@mui/material/Box'; +import type { LucideIcon } from 'lucide-react'; +import { + Timer, Clock, Calendar, ListChecks, Wallet, BarChart3, Megaphone, Code, + Terminal, Palette, PenLine, FileText, BookOpen, FlaskConical, Mail, + MessageSquare, Plane, Map, Dumbbell, HeartPulse, Utensils, ChefHat, Coffee, + Music, Video, Image, Camera, ShoppingCart, Bot, Gamepad2, Home, Shield, + Users, Scale, Building2, Newspaper, Briefcase, Rocket, Globe, Database, + Wrench, Lightbulb, Target, Trophy, Bell, Folder, Package, Truck, + LayoutDashboard, +} from 'lucide-react'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +// Whole-word keyword -> icon. Looked up per title token (never substring), so +// "admin" can't trip the "ad" rule. Keep keys lowercase + singular; plurals +// are handled by the trailing-s strip in pickIcon. Add gerunds explicitly. +const KEYWORDS: Record = { + timer: Timer, pomodoro: Timer, stopwatch: Timer, countdown: Timer, break: Timer, + clock: Clock, reminder: Clock, alarm: Clock, deadline: Clock, + calendar: Calendar, schedule: Calendar, planner: Calendar, planning: Calendar, agenda: Calendar, event: Calendar, booking: Calendar, + todo: ListChecks, task: ListChecks, checklist: ListChecks, kanban: ListChecks, backlog: ListChecks, sprint: ListChecks, chore: ListChecks, + money: Wallet, budget: Wallet, finance: Wallet, financial: Wallet, expense: Wallet, invoice: Wallet, payment: Wallet, billing: Wallet, wallet: Wallet, accounting: Wallet, + sales: BarChart3, revenue: BarChart3, growth: BarChart3, metric: BarChart3, kpi: BarChart3, analytics: BarChart3, stats: BarChart3, dashboard: BarChart3, report: BarChart3, reporting: BarChart3, + marketing: Megaphone, market: Megaphone, campaign: Megaphone, ad: Megaphone, promo: Megaphone, brand: Megaphone, branding: Megaphone, seo: Megaphone, + code: Code, coding: Code, dev: Code, developer: Code, engineer: Code, engineering: Code, build: Code, api: Code, backend: Code, frontend: Code, repo: Code, git: Code, software: Code, + terminal: Terminal, shell: Terminal, cli: Terminal, script: Terminal, command: Terminal, devops: Terminal, + design: Palette, designing: Palette, ui: Palette, ux: Palette, figma: Palette, mockup: Palette, wireframe: Palette, prototype: Palette, + write: PenLine, writing: PenLine, blog: PenLine, content: PenLine, copy: PenLine, copywriting: PenLine, essay: PenLine, note: PenLine, journal: PenLine, + doc: FileText, document: FileText, documentation: FileText, paper: FileText, pdf: FileText, spec: FileText, + research: BookOpen, study: BookOpen, learning: BookOpen, course: BookOpen, education: BookOpen, school: BookOpen, exam: BookOpen, thesis: BookOpen, + science: FlaskConical, lab: FlaskConical, experiment: FlaskConical, chemistry: FlaskConical, biology: FlaskConical, physics: FlaskConical, + mail: Mail, email: Mail, inbox: Mail, outreach: Mail, newsletter: Mail, + chat: MessageSquare, message: MessageSquare, messaging: MessageSquare, support: MessageSquare, dm: MessageSquare, + travel: Plane, trip: Plane, flight: Plane, vacation: Plane, tour: Plane, itinerary: Plane, + map: Map, location: Map, geo: Map, route: Map, navigation: Map, + fitness: Dumbbell, workout: Dumbbell, gym: Dumbbell, exercise: Dumbbell, training: Dumbbell, + health: HeartPulse, medical: HeartPulse, doctor: HeartPulse, patient: HeartPulse, clinic: HeartPulse, wellness: HeartPulse, therapy: HeartPulse, + food: Utensils, recipe: Utensils, cooking: Utensils, cook: Utensils, kitchen: Utensils, meal: Utensils, diet: Utensils, nutrition: Utensils, + restaurant: ChefHat, chef: ChefHat, menu: ChefHat, + coffee: Coffee, cafe: Coffee, brew: Coffee, + music: Music, song: Music, audio: Music, playlist: Music, podcast: Music, + video: Video, film: Video, movie: Video, stream: Video, streaming: Video, youtube: Video, + photo: Image, photography: Image, gallery: Image, picture: Image, + camera: Camera, shoot: Camera, + shop: ShoppingCart, shopping: ShoppingCart, store: ShoppingCart, ecommerce: ShoppingCart, cart: ShoppingCart, order: ShoppingCart, product: ShoppingCart, retail: ShoppingCart, + ai: Bot, agent: Bot, bot: Bot, swarm: Bot, llm: Bot, gpt: Bot, automation: Bot, + game: Gamepad2, gaming: Gamepad2, gamedev: Gamepad2, + home: Home, house: Home, apartment: Home, household: Home, + security: Shield, auth: Shield, login: Shield, password: Shield, secure: Shield, privacy: Shield, + team: Users, people: Users, community: Users, hr: Users, customer: Users, user: Users, crm: Users, contacts: Users, + law: Scale, legal: Scale, contract: Scale, policy: Scale, compliance: Scale, regulation: Scale, + property: Building2, estate: Building2, building: Building2, office: Building2, + news: Newspaper, article: Newspaper, press: Newspaper, media: Newspaper, journalism: Newspaper, + work: Briefcase, job: Briefcase, career: Briefcase, business: Briefcase, client: Briefcase, project: Briefcase, portfolio: Briefcase, + launch: Rocket, startup: Rocket, rocket: Rocket, release: Rocket, roadmap: Rocket, + web: Globe, site: Globe, website: Globe, domain: Globe, browser: Globe, internet: Globe, + data: Database, database: Database, sql: Database, warehouse: Database, pipeline: Database, etl: Database, + fix: Wrench, repair: Wrench, maintenance: Wrench, tool: Wrench, utility: Wrench, + idea: Lightbulb, brainstorm: Lightbulb, inspiration: Lightbulb, + goal: Target, target: Target, okr: Target, objective: Target, + award: Trophy, trophy: Trophy, achievement: Trophy, leaderboard: Trophy, contest: Trophy, + notification: Bell, alert: Bell, + archive: Folder, collection: Folder, library: Folder, + inventory: Package, stock: Package, package: Package, supply: Package, + delivery: Truck, shipping: Truck, logistics: Truck, truck: Truck, fleet: Truck, +}; + +function pickIcon(title: string): LucideIcon | null { + const words = title.toLowerCase().match(/[a-z]+/g) || []; + for (const w of words) { + const hit = KEYWORDS[w] || (w.endsWith('s') ? KEYWORDS[w.slice(0, -1)] : undefined); + if (hit) return hit; + } + return null; +} + +interface DashboardGlyphProps { + name: string | undefined; + size?: number; +} + +const DashboardGlyph: React.FC = ({ name, size = 16 }) => { + const c = useClaudeTokens(); + const title = (name || '').trim(); + const Icon = useMemo(() => (title ? pickIcon(title) : null), [title]); + + if (Icon) { + return ; + } + + // No keyword hit: a tinted monogram of the first letter. Honest identity, + // never a misleading icon. A title with no latin letters falls back to the glyph. + const letter = title.match(/[a-z0-9]/i)?.[0]?.toUpperCase(); + if (!letter) { + return ; + } + return ( + + {letter} + + ); +}; + +export default DashboardGlyph; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx index e9388d4d7..e1ac5859f 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardHeader.tsx @@ -1,12 +1,12 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; -import { LayoutDashboard } from 'lucide-react'; import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import LanguageIcon from '@mui/icons-material/Language'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import DashboardGlyph from './DashboardGlyph'; import ShareButton from '@/app/components/share/ShareButton'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { CardPosition, ViewCardPosition, BrowserCardPosition } from '@/shared/state/dashboardLayoutSlice'; @@ -127,8 +127,8 @@ const DashboardHeader: React.FC = ({ '&:hover': hasItems ? { bgcolor: `${c.bg.surface}99` } : {}, }} > - - + + Date: Sun, 14 Jun 2026 07:27:00 -0700 Subject: [PATCH 12/12] [eric] chat: unify thinking labels into one shared list, add quirkier verbs --- .../src/app/pages/AgentChat/AgentChat.tsx | 16 ++--- .../pages/AgentChat/bubbles/MessageBubble.tsx | 21 +----- .../src/app/pages/AgentChat/thinkingLabels.ts | 64 +++++++++++++++++++ 3 files changed, 70 insertions(+), 31 deletions(-) create mode 100644 frontend/src/app/pages/AgentChat/thinkingLabels.ts diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index d581c616e..ab084251b 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -47,6 +47,7 @@ import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws import StreamingBubble from './bubbles/StreamingBubble'; import WelcomeQuickReplies from './WelcomeQuickReplies'; import { useWelcomeGreeting } from './useWelcomeGreeting'; +import { THINKING_LABELS } from './thinkingLabels'; import MessageBubble from './bubbles/MessageBubble'; import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure'; import CompactionMarker from './bubbles/CompactionMarker'; @@ -166,22 +167,15 @@ const thinkingShimmerKeyframes = ` } `; -// Single-word labels picked deterministically per session-turn so the pill -// has variety without flickering between renders. Mirrors MessageBubble's list. -const STREAMING_LABELS: ReadonlyArray = [ - 'Thinking', 'Pondering', 'Cooking', 'Marinating', 'Deliberating', - 'Reasoning', 'Reflecting', 'Untangling', 'Stewing', 'Locking-in', - 'Considering', 'Processing', 'Vibing', 'Calculating', 'Chefing', - 'Geeking', 'Brewing', -]; - +// Pick a label deterministically per session-turn so the pill has variety +// without flickering between renders. Shared list with MessageBubble. function streamingLabelFor(seedKey: string | undefined): string { - if (!seedKey) return STREAMING_LABELS[0]; + if (!seedKey) return THINKING_LABELS[0].live; let h = 0; for (let i = 0; i < seedKey.length; i++) { h = ((h << 5) - h + seedKey.charCodeAt(i)) | 0; } - return STREAMING_LABELS[Math.abs(h) % STREAMING_LABELS.length]; + return THINKING_LABELS[Math.abs(h) % THINKING_LABELS.length].live; } const ThinkingBubble: React.FC<{ label?: string | null; seedKey?: string }> = ({ label, seedKey }) => { diff --git a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx index 20d68a641..d9b201273 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx @@ -20,6 +20,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import WindowedMarkdown from './WindowedMarkdown'; import { estimateRenderedTextHeight, oversizedCharThreshold, RECHECK_VISIBILITY_EVENT } from './markdownMeasure'; +import { THINKING_LABELS } from '../thinkingLabels'; import { AgentMessage } from '@/shared/state/agentsSlice'; import { openSettingsModal } from '@/shared/state/settingsSlice'; import { shallowEqual } from 'react-redux'; @@ -559,26 +560,6 @@ const MessageImageThumbnails: React.FC<{ ); }; -const THINKING_LABELS: ReadonlyArray<{ live: string; past: string }> = [ - { live: 'Thinking', past: 'Thought' }, - { live: 'Pondering', past: 'Pondered' }, - { live: 'Cooking', past: 'Cooked' }, - { live: 'Marinating', past: 'Marinated' }, - { live: 'Deliberating', past: 'Deliberated' }, - { live: 'Reasoning', past: 'Reasoned' }, - { live: 'Reflecting', past: 'Reflected' }, - { live: 'Untangling', past: 'Untangled' }, - { live: 'Stewing', past: 'Stewed' }, - { live: 'Locking-in', past: 'Locked-in' }, - { live: 'Considering', past: 'Considered' }, - { live: 'Processing', past: 'Processed' }, - { live: 'Vibing', past: 'Vibed' }, - { live: 'Calculating', past: 'Calculated' }, - { live: 'Chefing', past: 'Chefed' }, - { live: 'Geeking', past: 'Geeked' }, - { live: 'Brewing', past: 'Brewed' }, -]; - /** Stable hash of message id to label index; reload, scroll-back, and resume keep the same label. */ function labelIndexFromId(id: string | undefined): number { if (!id) return 0; diff --git a/frontend/src/app/pages/AgentChat/thinkingLabels.ts b/frontend/src/app/pages/AgentChat/thinkingLabels.ts new file mode 100644 index 000000000..493efbd11 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/thinkingLabels.ts @@ -0,0 +1,64 @@ +// One source of truth for the agent's whimsical "busy" verbs, shared by the +// streaming pill (AgentChat) and the per-message thinking bubble (MessageBubble). +// `live` shows while the agent works; `past` shows once the step is done +// ("Marinated for 3s"). Keep them fun but never self-deprecating (no +// "hallucinating") so they read as personality, not a malfunction. +export interface ThinkingLabel { + live: string; + past: string; +} + +// Index 0 is the safe default the pill falls back to with no seed, so keep it +// the plain one. Everything after is fair game for chaos. +export const THINKING_LABELS: ReadonlyArray = [ + { live: 'Thinking', past: 'Thought' }, + { live: 'Tokenmaxing', past: 'Tokenmaxed' }, + { live: 'Pondering', past: 'Pondered' }, + { live: 'Cooking', past: 'Cooked' }, + { live: 'Grokking', past: 'Grokked' }, + { live: 'Marinating', past: 'Marinated' }, + { live: 'Galaxy-braining', past: 'Galaxy-brained' }, + { live: 'Reasoning', past: 'Reasoned' }, + { live: 'Noodling', past: 'Noodled' }, + { live: 'Percolating', past: 'Percolated' }, + { live: 'Reflecting', past: 'Reflected' }, + { live: 'Untangling', past: 'Untangled' }, + { live: 'Crunching', past: 'Crunched' }, + { live: 'Stewing', past: 'Stewed' }, + { live: 'Locking-in', past: 'Locked-in' }, + { live: 'Manifesting', past: 'Manifested' }, + { live: 'Big-braining', past: 'Big-brained' }, + { live: 'Vibing', past: 'Vibed' }, + { live: 'Scheming', past: 'Schemed' }, + { live: 'Riffing', past: 'Riffed' }, + { live: 'Calculating', past: 'Calculated' }, + { live: 'Tinkering', past: 'Tinkered' }, + { live: 'Finessing', past: 'Finessed' }, + { live: 'Chefing', past: 'Chefed' }, + { live: 'Min-maxing', past: 'Min-maxed' }, + { live: 'Geeking', past: 'Geeked' }, + { live: 'Ruminating', past: 'Ruminated' }, + { live: 'Simmering', past: 'Simmered' }, + { live: 'Brewing', past: 'Brewed' }, + { live: 'Wrangling', past: 'Wrangled' }, + { live: 'Spelunking', past: 'Spelunked' }, + { live: 'Conjuring', past: 'Conjured' }, + { live: 'Synthesizing', past: 'Synthesized' }, + { live: 'Overclocking', past: 'Overclocked' }, + { live: 'Caffeinating', past: 'Caffeinated' }, + { live: 'Sleuthing', past: 'Sleuthed' }, + { live: 'Larping', past: 'Larped' }, + { live: 'Speedrunning', past: 'Speedran' }, + { live: 'Theorycrafting', past: 'Theorycrafted' }, + { live: 'Sussing', past: 'Sussed' }, + { live: 'Hyperfixating', past: 'Hyperfixated' }, + { live: 'Nerd-sniping', past: 'Nerd-sniped' }, + { live: 'Promptmaxing', past: 'Promptmaxed' }, + { live: 'Pontificating', past: 'Pontificated' }, + { live: 'Vibe-checking', past: 'Vibe-checked' }, + { live: 'Mogging', past: 'Mogged' }, + { live: 'Goblin-moding', past: 'Goblin-moded' }, + { live: 'Side-questing', past: 'Side-quested' }, + { live: 'Tryharding', past: 'Tryharded' }, + { live: 'Grinding', past: 'Grinded' }, +];