From ace783f4ef45e914311f442a4863bf05a71f4bb6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 05:55:28 -0700 Subject: [PATCH 01/36] [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 00000000..e69de29b diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py new file mode 100644 index 00000000..937f3635 --- /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 00000000..e69de29b diff --git a/backend/apps/swarm/entities/skills.py b/backend/apps/swarm/entities/skills.py new file mode 100644 index 00000000..cb8fbf06 --- /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 00000000..506662c2 --- /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 00000000..65ff68ef --- /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 00000000..1a97d20c --- /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 00000000..e2a6281b --- /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 00000000..119e64f4 --- /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 00000000..d53033cd --- /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 d51267a8..42daf1d3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -39,6 +39,7 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry 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.middleware.cors import CORSMiddleware 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/36] [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 00000000..3e454ccc --- /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/36] [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 00000000..2cd6f290 --- /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 00000000..90d369e5 --- /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 00000000..5a8568a1 --- /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 00000000..daa244b1 --- /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 00000000..65ed258c --- /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 3ee42800..482208e8 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/36] [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 ab887c45..28e564f4 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 00000000..d5b604f6 --- /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 00000000..d5ea38d1 --- /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 482208e8..c34be184 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/36] [eric] swarm: app export/import (workspace tree, .env regen) + code-safety review --- backend/apps/swarm/closure.py | 22 ++++ backend/apps/swarm/entities/apps.py | 151 ++++++++++++++++++++++++++++ backend/apps/swarm/registry.py | 2 + backend/apps/swarm/review.py | 34 +++++++ backend/apps/swarm/swarm.py | 2 + backend/tests/test_swarm_bundle.py | 21 ++++ 6 files changed, 232 insertions(+) create mode 100644 backend/apps/swarm/entities/apps.py create mode 100644 backend/apps/swarm/review.py diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index 937f3635..dba4e398 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -267,6 +267,28 @@ def _read_files(sandbox: str, ref: EntityRef) -> dict[str, bytes]: return out +def review_bundle(sandbox: str, manifest: Manifest): + """Safety read of any app code in the staged bundle. Returns None when the + bundle contains no apps (nothing to review).""" + from .models import ReviewSummary + from .review import scan_app_files + + findings: list[str] = [] + scanned: list[str] = [] + verdict = "clean" + any_app = False + for e in manifest.entities: + if e.type != EntityType.app: + continue + any_app = True + r = scan_app_files(_read_files(sandbox, e)) + findings.extend(r.findings) + scanned.extend(r.scanned_files) + if r.verdict != "clean": + verdict = r.verdict + return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned) if any_app else None + + def detect_conflicts(sandbox: str, manifest: Manifest) -> list[IncludeItem]: out: list[IncludeItem] = [] for e in manifest.entities: diff --git a/backend/apps/swarm/entities/apps.py b/backend/apps/swarm/entities/apps.py new file mode 100644 index 00000000..a9a34544 --- /dev/null +++ b/backend/apps/swarm/entities/apps.py @@ -0,0 +1,151 @@ +"""AppExportable: an app is an Output record + its workspace file tree. We carry +the editable source (frontend/, backend/, run.sh, package.json, .env.example, +meta) but NOT node_modules/.venv/dist (skip dirs) and NOT the live `.env` (it +holds the source machine's absolute paths + pinned port). On import we mint a +fresh output id + workspace id, drop the builder session link, and regenerate a +local `.env` with a free port. The app stays inert until the user opens it.""" +from __future__ import annotations + +import os +import shutil +import socket +from uuid import uuid4 + +from backend.apps.outputs.models import Output +from backend.apps.outputs.workspace_io import _WALK_SKIP_DIRS, _save, load_output +from backend.config.paths import OUTPUTS_WORKSPACE_DIR + +from ..exportable import DepRef, ExportContext, RemapTable +from ..models import EntityType, Requirement + +_MAX_APP_FILE = 25 * 1024 * 1024 # matches ziputil per-entry cap + + +class AppExportable: + type = EntityType.app + + def __init__(self, output: Output): + self.output = output + self.local_id = output.id + self.name = output.name or "Untitled App" + + @classmethod + def load(cls, local_id: str) -> "AppExportable | None": + o = load_output(local_id) + return cls(o) if o else None + + def serialize(self, ctx: ExportContext) -> dict: + return { + "name": self.output.name, + "description": self.output.description, + "icon": self.output.icon, + "input_schema": self.output.input_schema, + "files": self.output.files, # flat-app inline source; webapp apps leave this empty + } + + def files(self) -> dict[str, bytes]: + out: dict[str, bytes] = {} + wsid = self.output.workspace_id + if not wsid: + return out + folder = os.path.join(OUTPUTS_WORKSPACE_DIR, wsid) + if not os.path.isdir(folder): + return out + for root, dirs, fnames in os.walk(folder): + dirs[:] = [d for d in dirs if d not in _WALK_SKIP_DIRS] + for fn in fnames: + # .env is install-specific (absolute paths + port); .env.example travels instead. + if fn == ".env": + continue + full = os.path.join(root, fn) + if os.path.islink(full): + continue + try: + if os.path.getsize(full) > _MAX_APP_FILE: + continue + with open(full, "rb") as f: + data = f.read() + except OSError: + continue + rel = os.path.relpath(full, folder).replace(os.sep, "/") + out[f"workspace/{rel}"] = data + return out + + def dependencies(self) -> list[DepRef]: + return [] + + def requirements(self) -> list[Requirement]: + return [] + + @classmethod + def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: + new_wsid = uuid4().hex + folder = os.path.join(OUTPUTS_WORKSPACE_DIR, new_wsid) + wrote_workspace = False + for rel, data in files.items(): + if not rel.startswith("workspace/"): + continue + dest = _safe_join(folder, rel[len("workspace/"):]) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "wb") as f: + f.write(data) + wrote_workspace = True + if wrote_workspace: + _localize_env(folder) + + o = Output( + name=payload.get("name") or "Imported App", + description=payload.get("description", ""), + icon=payload.get("icon", "view_quilt"), + input_schema=payload.get("input_schema") or {"type": "object", "properties": {}, "required": []}, + files=payload.get("files") or {}, + workspace_id=new_wsid if wrote_workspace else None, + session_id=None, + ) + _save(o) + return o.id + + +def _safe_join(folder: str, rel: str) -> str: + dest = os.path.realpath(os.path.join(folder, rel)) + root = os.path.realpath(folder) + if dest != root and not dest.startswith(root + os.sep): + raise ValueError("app file path escapes the workspace") + return dest + + +def _free_port() -> int: + s = socket.socket() + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +def _localize_env(folder: str) -> None: + """Regenerate the workspace .env on the importer's machine: a fresh port plus + this install's absolute template/debugger paths (the source's were dropped).""" + env_path = os.path.join(folder, ".env") + example = os.path.join(folder, ".env.example") + if not os.path.exists(env_path): + if os.path.exists(example): + shutil.copyfile(example, env_path) + else: + return # flat app: no run.sh, no env needed + try: + from backend.apps.outputs.view_builder_templates import ( + _DEBUGGER_PATH, + _TEMPLATE_BACKEND_PATH, + _patch_env_port, + _warm_venv_dir, + ) + except Exception: + return + _patch_env_port(env_path, "FRONTEND_PORT", str(_free_port())) + _patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", _TEMPLATE_BACKEND_PATH) + _patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", _DEBUGGER_PATH) + try: + _patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", _warm_venv_dir()) + except Exception: + pass diff --git a/backend/apps/swarm/registry.py b/backend/apps/swarm/registry.py index e2a6281b..168a595a 100644 --- a/backend/apps/swarm/registry.py +++ b/backend/apps/swarm/registry.py @@ -1,10 +1,12 @@ """Maps an EntityType to the Exportable that handles it, and the leaves-first order import walks. Adding a shareable type is one entry here plus its module.""" +from .entities.apps import AppExportable from .entities.skills import SkillExportable from .models import EntityType REGISTRY: dict[EntityType, type] = { EntityType.skill: SkillExportable, + EntityType.app: AppExportable, } # Leaves first: a dependency must import before whatever references it. diff --git a/backend/apps/swarm/review.py b/backend/apps/swarm/review.py new file mode 100644 index 00000000..fb8f0d00 --- /dev/null +++ b/backend/apps/swarm/review.py @@ -0,0 +1,34 @@ +"""Best-effort safety read of imported app code. AST flags risky Python via the +existing executor allow/deny lists, and we note when an app will run real code +on the importer's machine (a webapp_template app spawns `bash run.sh`). This is +advisory and surfaced in the import preflight; the actual execution gates are the +user choosing to open/run the app and the flat-app /execute HITL. A full semantic +LLM scan is the separate App Publishing feature, not this.""" +from __future__ import annotations + +from backend.apps.outputs.executor import get_code_warnings + +from .models import ReviewSummary + + +def scan_app_files(files: dict[str, bytes]) -> ReviewSummary: + findings: list[str] = [] + scanned: list[str] = [] + runnable = False + for path, data in files.items(): + low = path.lower() + if low.endswith("/run.sh") or low.endswith("package.json") or "/backend/" in low: + runnable = True + if low.endswith(".py"): + scanned.append(path) + try: + code = data.decode("utf-8", errors="replace") + except Exception: + continue + for w in get_code_warnings(code): + findings.append(f"{path}: {w}") + verdict = "warn" if findings else "clean" + if runnable: + verdict = "warn" + findings.insert(0, "This app runs code on your computer when you open it. Only import apps you trust.") + return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned) diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py index 119e64f4..734677fc 100644 --- a/backend/apps/swarm/swarm.py +++ b/backend/apps/swarm/swarm.py @@ -90,6 +90,7 @@ async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightRespo try: sandbox, manifest, warnings = closure.stage_upload(raw, file.filename or "") conflicts = closure.detect_conflicts(sandbox, manifest) + review = closure.review_bundle(sandbox, manifest) except BundleError as e: raise HTTPException(status_code=400, detail=str(e)) _gc_staging() @@ -99,6 +100,7 @@ async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightRespo summary=closure.summarize(manifest), staging_token=token, conflicts=conflicts, + review=review, warnings=warnings, ) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 3e454ccc..19a562a4 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -99,6 +99,27 @@ def test_pack_refuses_denied_key(): pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}) +def test_app_export_drops_machine_env(tmp_path, monkeypatch): + # The live .env holds the source machine's absolute paths + pinned port; it + # must never ride along. .env.example (portable) does. + from backend.apps.swarm.entities import apps as appmod + from backend.apps.outputs.models import Output + + ws = tmp_path / "ws" + (ws / "frontend").mkdir(parents=True) + (ws / ".env").write_text("FRONTEND_PORT=5\nOPENSWARM_TEMPLATE_BACKEND_PATH=/Users/SECRET/x\n") + (ws / ".env.example").write_text("BACKEND_PORT=NONE\nFRONTEND_PORT=4949\n") + (ws / "frontend" / "App.tsx").write_text("export default () => null") + monkeypatch.setattr(appmod, "OUTPUTS_WORKSPACE_DIR", str(tmp_path)) + + ex = appmod.AppExportable(Output(name="A", workspace_id="ws")) + files = ex.files() + assert "workspace/.env.example" in files + assert "workspace/.env" not in files + assert "workspace/frontend/App.tsx" in files + assert b"/Users/SECRET" not in b"".join(files.values()) + + def _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From c179d20c16f1364863861468ae29d281f618e7f1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 06:11:22 -0700 Subject: [PATCH 06/36] [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 d5ea38d1..ef3a1228 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 90d369e5..1e772ed8 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 65ed258c..271410e7 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 acf21de4..c8d564d9 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/36] [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 00000000..d7b2fb8f --- /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 168a595a..e771850f 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 19a562a4..d0a423ed 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/36] [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 00000000..eebb2507 --- /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 00000000..015d3eb9 --- /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 00000000..a028b77d --- /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 e771850f..73554543 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 @@ REGISTRY: dict[EntityType, type] = { 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 d0a423ed..fc6bb7a7 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/36] [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 8c1b4902..e9388d4d 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/36] [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 00000000..c08ecee8 --- /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 d5b604f6..33a56294 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 ef3a1228..9d8ab4f3 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/36] [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 00000000..c4356ada --- /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 e9388d4d..e1ac5859 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/36] [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 d581c616..ab084251 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 20d68a64..d9b20127 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 00000000..493efbd1 --- /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' }, +]; From f08f71313f5ef0e7517a237f34f97cf726a008e7 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 14 Jun 2026 07:55:24 -0700 Subject: [PATCH 13/36] [aidan] ui/ux: auth flow fixes (#84) * aidan] bug: fixed 2 auth claude login issue * [aidan] bug: claude login w email+pw * [aidan] ui: edited message for cases where anthropic redirects to enter email code even after login' * [aidan] ui/ux: fix connecting status accuracy --- backend/apps/agents/9router_gpt5_patch.js | 27 +++++++ backend/apps/agents/agents.py | 16 ++++- backend/apps/nine_router/oauth.py | 20 ++++-- backend/main.py | 2 +- .../subscription/SubscriptionCards.tsx | 7 +- .../subscription/subscriptionConnect.ts | 71 +++++++++++++++++-- .../src/shared/state/subscriptionsSlice.ts | 24 ++++++- 7 files changed, 151 insertions(+), 16 deletions(-) diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 75b035f4..885efebe 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -39,6 +39,33 @@ const _http = require('http'); } catch (_) {} })(); +// 9Router's /callback page is a client-side relay (postMessage/BroadcastChannel/ +// localStorage) that fails when the OAuth flow runs in the user's system browser: +// no opener, different cookie jar. 302 to the backend so the exchange happens +// server-side. Idempotent via _completed_oauth (backend/apps/oauth_state.py) so +// a racing renderer-driven exchange in popup mode dedups. +(function patchOauthCallbackRedirect() { + try { + const http = require('http'); + const origEmit = http.Server.prototype.emit; + http.Server.prototype.emit = function patchedEmit(event, req, res) { + if (event === 'request' && req && res) { + try { + const url = req.url || ''; + if (url.startsWith('/callback?')) { + const backendPort = process.env.OPENSWARM_PORT || '8324'; + const target = 'http://localhost:' + backendPort + '/api/subscriptions/callback' + url.slice('/callback'.length); + res.writeHead(302, { Location: target }); + res.end(); + return true; + } + } catch (_) {} + } + return origEmit.apply(this, arguments); + }; + } catch (_) {} +})(); + const TARGET_HOSTS = new Set(['api.openai.com']); const DEBUG = process.env.OPENSWARM_DEBUG_GPT5_PATCH === '1'; diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index cec4a8c6..96746f02 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -432,6 +432,11 @@ async def subscriptions_poll(body: dict): async def subscriptions_exchange(body: dict): """Exchange OAuth code for tokens via 9Router.""" from backend.apps.nine_router import exchange_oauth + from backend.apps.oauth_state import ( + _pending_oauth as pending_oauth, + _completed_oauth as completed_oauth, + _mark_oauth_completed as mark_completed, + ) provider = body.get("provider", "") code = body.get("code", "") redirect_uri = body.get("redirect_uri", "") @@ -444,11 +449,18 @@ async def subscriptions_exchange(body: dict): try: result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state) if result.get("success"): - from backend.apps.service.client import sync as _sync + # Claude races this path against /api/subscriptions/callback (popup + 9router patch + # 302 to backend); dedup so the loser sees the success page, not "Session expired". + if state: + pending_oauth.pop(state, None) + mark_completed(state) + from backend.apps.service.client import sync as do_sync from backend.apps.settings.settings import load_settings - _sync(load_settings().model_dump()) + do_sync(load_settings().model_dump()) return result except Exception as e: + if state and state in completed_oauth: + return {"success": True, "deduped": True} raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py index 55b7ba85..efcc5e75 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -205,9 +205,14 @@ async def _start_codex_callback_listener(timeout: float = 300.0) -> asyncio.base # own Desktop-app OAuth guidance both prescribe the system browser. # - codex: auth.openai.com renders blank in our popup on some machines (newer # embed detection + regional checks); system browser surfaces the real error. +# - claude: email magic-link opens in the user's default browser, which is a +# different cookie jar from the embedded popup, so the popup can never receive +# the auth. Forcing the OAuth flow into the system browser keeps everything +# in one cookie jar. # The callback for gemini-cli/antigravity lands on /api/subscriptions/callback -# and runs the exchange server-side; codex uses its fixed 1455 listener. -_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex"} +# and runs the exchange server-side; codex uses its fixed 1455 listener; claude +# is special-cased in _callback_uri_for_provider below. +_EXTERNAL_BROWSER_PROVIDERS: set[str] = {"gemini-cli", "antigravity", "codex", "claude"} def _should_use_external_browser(provider: str) -> bool: @@ -232,18 +237,21 @@ def _callback_uri_for_provider(provider: str) -> str: """Return the redirect URI to pass to 9Router's authorize endpoint. Most providers accept 9Router's built-in callback page at port 20128. - Two special cases: + Special cases: - Codex/OpenAI's OAuth client is bound to a fixed http://localhost:1455/auth/callback URI; handled by _start_codex_callback_listener above. - Gemini/Google's OAuth consent page rejects embedded browsers, so we route the callback through OpenSwarm's backend endpoint at - /api/subscriptions/callback (backend/main.py:138) which runs the - exchange itself. This is the only provider where the callback lands - on OpenSwarm's port rather than 9Router's. + /api/subscriptions/callback (backend/main.py) which runs the + exchange itself. """ if provider == "codex": return f"http://localhost:{_CODEX_CALLBACK_PORT}{_CODEX_CALLBACK_PATH}" + # Anthropic's OAuth client only whitelists localhost:20128/callback; + # 9router_gpt5_patch.js 302-rewrites the hit to the backend handler. + if provider == "claude": + return f"http://localhost:{NINE_ROUTER_PORT}/callback" if provider in _EXTERNAL_BROWSER_PROVIDERS: return f"http://localhost:{_backend_port()}/api/subscriptions/callback" return f"http://localhost:{NINE_ROUTER_PORT}/callback" diff --git a/backend/main.py b/backend/main.py index 42daf1d3..9aa7d69f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -453,7 +453,7 @@ _SUCCESS_HTML = ( '
' '
✓
' '

Connected!

' - '

You can close this window

' + '

You can close this tab, and any other Claude login tab still open.

' '
' '' '' diff --git a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx index 70d82e75..d3dba0d5 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/SubscriptionCards.tsx @@ -8,6 +8,7 @@ import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchSubscriptionStatus, setSubscriptionStatus, + markSubscriptionConnected, selectSubscriptionConnections, } from '@/shared/state/subscriptionsSlice'; import { API_BASE } from '@/shared/config'; @@ -37,6 +38,10 @@ const SubscriptionCards: React.FC = () => { // Refetch model picker after sub changes so newly-connected providers surface in the dropdown immediately. const refreshPickerModels = () => { dispatch(fetchModels()); }; + const markConnected = useCallback((provider: string) => { + dispatch(markSubscriptionConnected({ provider })); + }, [dispatch]); + useEffect(() => { let cancelled = false; (async () => { @@ -73,7 +78,7 @@ const SubscriptionCards: React.FC = () => { }); if (!r.ok) { setConnecting(null); return; } const data = await r.json(); - runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels }); + runConnectFlow({ providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels, markConnected }); } catch { setConnecting(null); } }; diff --git a/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts b/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts index cbd657e4..c63447c8 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts +++ b/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts @@ -8,11 +8,12 @@ interface ConnectCtx { setPollTimer: (v: any) => void; fetchStatus: (opts?: { preserveTransient?: boolean }) => Promise; refreshPickerModels: () => void; + markConnected: (provider: string) => void; } // Device-code OAuth flow: popup + dual poller (device-code + status) + focus-listener safety net + 5min hard timeout. function runDeviceCodeFlow(ctx: ConnectCtx) { - const { providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels } = ctx; + const { providerId, data, setConnecting, setUserCode, setPollTimer, fetchStatus, refreshPickerModels, markConnected } = ctx; const code = data.user_code || ''; setUserCode(code); // Named window + features so Electron's setWindowOpenHandler spawns a BrowserWindow popup, not a webview tab. @@ -31,6 +32,7 @@ function runDeviceCodeFlow(ctx: ConnectCtx) { setPollTimer(null); setConnecting(null); setUserCode(''); + markConnected(providerId); fetchStatus(); refreshPickerModels(); // Auto-close popup 2s after success so the "Congratulations" page is briefly visible then closes. @@ -131,7 +133,7 @@ function runDeviceCodeFlow(ctx: ConnectCtx) { // Authorization-code flow: external-browser or popup + status poller + postMessage/IPC relay + bounded timeout. function runAuthCodeFlow(ctx: ConnectCtx) { - const { providerId, data, setConnecting, setPollTimer, fetchStatus, refreshPickerModels } = ctx; + const { providerId, data, setConnecting, setPollTimer, fetchStatus, refreshPickerModels, markConnected } = ctx; // Gemini/Google block embedded browsers; backend sets use_external_browser and exchange happens server-side via /api/subscriptions/callback. Detect via status poller (no postMessage possible). const useExternal = !!data.use_external_browser; let popup: Window | null = null; @@ -141,16 +143,24 @@ function runAuthCodeFlow(ctx: ConnectCtx) { popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); } + let stopped = false; + let resetTimer: ReturnType | null = null; + // Status polling: primary for external-browser flow, secondary for popup flow (postMessage is faster). const statusPoller = setInterval(async () => { + if (stopped) return; try { const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); const sd = await sr.json(); const connections = sd.providers?.connections || []; if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { + stopped = true; + if (resetTimer) clearTimeout(resetTimer); clearInterval(statusPoller); setPollTimer(null); if (!useExternal) window.removeEventListener('message', msgHandler); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); setConnecting(null); fetchStatus(); refreshPickerModels(); @@ -162,15 +172,20 @@ function runAuthCodeFlow(ctx: ConnectCtx) { // Shared exchange helper invoked by whichever relay path delivers the code first. let exchanged = false; const runExchange = async (code: string, state?: string) => { - if (exchanged) return; + if (exchanged || stopped) return; exchanged = true; + stopped = true; + if (resetTimer) clearTimeout(resetTimer); window.removeEventListener('message', msgHandler); if (ipcUnsub) ipcUnsub(); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); clearInterval(statusPoller); setPollTimer(null); if (popup && !popup.closed) popup.close(); + let succeeded = false; try { - await fetch(`${API_BASE}/agents/subscriptions/exchange`, { + const r = await fetch(`${API_BASE}/agents/subscriptions/exchange`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: providerId, code, @@ -178,9 +193,13 @@ function runAuthCodeFlow(ctx: ConnectCtx) { state: state || data.state, }), }); + let body: any = null; + try { body = await r.json(); } catch {} + succeeded = r.ok && !!body?.success; } catch {} + // 9Router /providers lags /exchange; an immediate fetchStatus would clobber the UI. + if (succeeded) markConnected(providerId); setConnecting(null); - fetchStatus(); refreshPickerModels(); }; @@ -201,13 +220,55 @@ function runAuthCodeFlow(ctx: ConnectCtx) { }); } + // If the user comes back to openswarm without finishing OAuth (closed the browser, cancelled), + // 3s of sustained focus + no active connection means abandoned; clear Connecting so they can retry. + // A blur during the wait cancels, so brief tab-backs to check progress don't false-positive. + const onBlur = () => { + if (resetTimer) { clearTimeout(resetTimer); resetTimer = null; } + }; + const onFocus = () => { + if (stopped) return; + if (resetTimer) clearTimeout(resetTimer); + resetTimer = setTimeout(async () => { + resetTimer = null; + if (stopped) return; + try { + const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); + const sd = await sr.json(); + const conns = sd.providers?.connections || []; + if (conns.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) return; + } catch {} + if (stopped) return; + stopped = true; + clearInterval(statusPoller); + setPollTimer(null); + if (!useExternal) window.removeEventListener('message', msgHandler); + if (ipcUnsub) ipcUnsub(); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); + setConnecting(null); + }, 3000); + }; + // Delay attach; popup mode's window.open blurs/refocuses the parent and would false-trigger. + setTimeout(() => { + if (!stopped) { + window.addEventListener('blur', onBlur); + window.addEventListener('focus', onFocus); + } + }, 2000); + // 3min popup / 5min external-browser; bounds the Connecting indicator, safety-net poller is the real exit. const timeoutMs = useExternal ? 300_000 : 180_000; setTimeout(() => { + if (stopped) return; + stopped = true; + if (resetTimer) clearTimeout(resetTimer); clearInterval(statusPoller); setPollTimer(null); if (!useExternal) window.removeEventListener('message', msgHandler); if (ipcUnsub) ipcUnsub(); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); setConnecting(null); }, timeoutMs); } diff --git a/frontend/src/shared/state/subscriptionsSlice.ts b/frontend/src/shared/state/subscriptionsSlice.ts index ca5c5054..4fe46b6d 100644 --- a/frontend/src/shared/state/subscriptionsSlice.ts +++ b/frontend/src/shared/state/subscriptionsSlice.ts @@ -51,6 +51,28 @@ const subscriptionsSlice = createSlice({ setSubscriptionStatus(state, action: PayloadAction) { state.status = action.payload; }, + // Optimistic: 9Router /providers lags /exchange, so refetching right after would + // clobber the just-connected state with stale data. The 30s poller reconciles. + markSubscriptionConnected(state, action: PayloadAction<{ provider: string }>) { + if (!state.status) return; + const { provider } = action.payload; + const isArr = Array.isArray(state.status.providers); + const conns: SubscriptionConnection[] = isArr + ? (state.status.providers as SubscriptionConnection[]) + : ((state.status.providers as { connections?: SubscriptionConnection[] } | undefined)?.connections ?? []); + const existing = conns.find((c) => c.provider === provider); + if (existing) { + existing.isActive = true; + existing.testStatus = 'active'; + } else { + conns.push({ provider, isActive: true, testStatus: 'active' }); + } + if (isArr) { + state.status.providers = conns; + } else { + state.status.providers = { connections: conns }; + } + }, }, extraReducers: (builder) => { builder.addCase(fetchSubscriptionStatus.fulfilled, (state, action) => { @@ -59,7 +81,7 @@ const subscriptionsSlice = createSlice({ }, }); -export const { setSubscriptionStatus } = subscriptionsSlice.actions; +export const { setSubscriptionStatus, markSubscriptionConnected } = subscriptionsSlice.actions; // Stable empty ref so the selector doesn't hand back a fresh [] each call (forces needless rerenders). const EMPTY_CONNECTIONS: SubscriptionConnection[] = []; From fd10171ac104801d9d99b2fa353098d4ef4fd2e4 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 14 Jun 2026 07:55:34 -0700 Subject: [PATCH 14/36] [aidan] ui/ux: canvas navigation (#85) * dashboard: pan canvas via middle-drag and horizontal scroll over browser/chat - Middle-mouse drag inside a browser webview is forwarded as canvas pan (previously eaten silently by the guest compositor). - Horizontal-dominant wheel inside a browser webview pans the canvas when no ancestor in the guest page can absorb horizontal scroll; otherwise the page handles it. - Horizontal-dominant wheel over a chat panel pans the canvas (chat has no horizontal scroller). Vertical scroll is unchanged. Plain vertical scroll and ctrl/meta+wheel zoom over a browser stay with chromium's defaults. * browser: drop in-guest zoom locks so pinch and cmd+wheel zoom the page setVisualZoomLevelLimits(1, 1) blocked pinch-to-zoom inside the webview, and setZoomFactor(1) on every dom-ready reset any user page zoom. Both were added back when canvas zoom was supposed to take over for ctrl+wheel; with that reverted, chromium's native zoom handlers should run unimpeded. * [aidan] fix: prev. commit fix. restore ctrl+wheel canvas zoom over webview cards * [aidan] ui/ux: app navigation * [aidan] ui/ux: gate webview preload forwarding on app interactive mode When a view card is "interactive" (user clicked into the app), the preload stops forwarding ctrl+wheel, middle-mouse-drag, and horizontal-scroll-pan gestures so the embedded app gets every event. A mousedown notifier reports in-guest clicks to the host so it can flip the card into interactive mode without intercepting the click itself. --- electron/webview-preload.js | 146 +++++++++++++++--- .../src/app/pages/AgentChat/AgentChat.tsx | 3 + .../app/pages/Dashboard/cards/BrowserCard.tsx | 23 ++- .../Dashboard/cards/DashboardViewCard.tsx | 33 +++- .../hooks/interaction/useCanvasControls.ts | 32 +++- .../useOverlayScrollPassthrough.ts | 10 ++ frontend/src/app/pages/Views/ViewPreview.tsx | 71 ++++++++- .../src/shared/state/dashboardLayoutSlice.ts | 9 ++ 8 files changed, 280 insertions(+), 47 deletions(-) diff --git a/electron/webview-preload.js b/electron/webview-preload.js index ab4aa519..589878c8 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -136,40 +136,92 @@ try { } }); + // When the host marks this webview as "interactive" (user clicked into the + // app), the preload stops forwarding wheel/middle gestures to the canvas + // and lets the app handle everything. Host pushes via webview.send. + let isInteractive = false; + try { + ipcRenderer.on('openswarm:set-interactive', (_event, payload) => { + isInteractive = !!(payload && payload.interactive); + }); + } catch (_) {} + + // First in-guest mousedown tells the host to activate interact mode. Never + // preventDefault so the click still reaches the app (Minecraft etc). + const onMouseDownNotify = (e) => { + if (isInteractive) return; + try { ipcRenderer.sendToHost('app-clicked', { button: e.button }); } catch (_) {} + }; + window.addEventListener('mousedown', onMouseDownNotify, { capture: true }); + // --------------------------------------------------------------------------- - // Canvas zoom passthrough (ctrl/meta + wheel) + // Horizontal scroll passthrough to canvas pan // - // A is an out-of-process Chromium guest; wheel events that - // originate inside it never bubble to the embedding renderer. Without - // intercepting here, ctrl+wheel over a browser card just zooms the - // embedded page (Chromium's default) and the dashboard canvas never - // sees the gesture — issue #27. - // - // Capture-phase + passive:false so we run before the page's own listeners - // and can preventDefault to suppress the in-page page-zoom. We then - // forward the gesture (deltaY + guest-local cursor coords) to the host - // via sendToHost; BrowserCard's ipc-message handler turns it back into a - // synthetic WheelEvent dispatched from the webview element, which bubbles - // naturally to useCanvasControls' wheel listener. + // is an out-of-process guest; wheel events inside it never bubble + // to the embedding renderer. Vertical scroll and ctrl/meta+wheel zoom stay + // with the page (chromium default). A horizontal-dominant gesture, however, + // should pan the dashboard canvas if the guest page has nothing horizontal + // to scroll, to match the behavior over chat panels (which never have a + // horizontal scroller and always pan the canvas). + const pageCanScrollX = (node, dx) => { + let t = node; + while (t) { + const sw = t.scrollWidth || 0; + const cw = t.clientWidth || 0; + if (sw > cw) { + let style; + try { style = getComputedStyle(t); } catch (_) {} + const ox = style ? style.overflowX : 'visible'; + if (ox === 'auto' || ox === 'scroll') { + const atRight = t.scrollLeft + cw >= sw - 1; + const atLeft = t.scrollLeft <= 1; + const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft); + if (!atBoundary) return true; + } + } + t = t.parentElement; + } + const docEl = document.scrollingElement || document.documentElement; + if (docEl && docEl.scrollWidth > docEl.clientWidth) { + const atRight = docEl.scrollLeft + docEl.clientWidth >= docEl.scrollWidth - 1; + const atLeft = docEl.scrollLeft <= 1; + const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft); + if (!atBoundary) return true; + } + return false; + }; + const onWheelCapture = (e) => { - if (!(e.ctrlKey || e.metaKey)) return; + if (isInteractive) return; + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + const iw = window.innerWidth || 1; + const ih = window.innerHeight || 1; + try { + ipcRenderer.sendToHost('canvas-wheel-zoom', { + deltaY: e.deltaY, + deltaMode: e.deltaMode, + fracX: Math.max(0, Math.min(1, e.clientX / iw)), + fracY: Math.max(0, Math.min(1, e.clientY / ih)), + }); + } catch (_) {} + return; + } + // Vertical-dominant scroll stays with the page. + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + // Horizontal-dominant: defer to the page if anything inside can absorb + // it; otherwise forward to the host as a canvas pan. + if (pageCanScrollX(e.target, e.deltaX)) return; e.preventDefault(); e.stopPropagation(); try { - console.warn('[openswarm:webview-preload] ctrl+wheel intercept → sendToHost', { - deltaY: e.deltaY, - clientX: e.clientX, - clientY: e.clientY, - }); - ipcRenderer.sendToHost('canvas-wheel-zoom', { + ipcRenderer.sendToHost('canvas-wheel-pan', { + deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode, - clientX: e.clientX, - clientY: e.clientY, }); - } catch (err) { - console.warn('[openswarm:webview-preload] sendToHost failed', err); - } + } catch (_) {} }; // Listen on both window and document in capture phase so we run before any // page-level handler that might swallow the event. passive:false is required @@ -177,6 +229,50 @@ try { window.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); + // --------------------------------------------------------------------------- + // Middle-mouse-button drag → canvas pan + // + // Empty canvas and agent cards already get middle-button pan because the + // event bubbles to the dashboard's mousedown handler. is a + // separate compositor layer that eats mouse events, so middle-drag over a + // browser silently did nothing. Intercept here and forward the per-event + // movement as a pan delta through the existing canvas-wheel-pan channel + // (negated, since drag pans panX += dx while wheel pans panX -= dx). + // Always pans regardless of capture state — middle-drag is unambiguously + // a canvas gesture. + let middleDragging = false; + const onMouseDownMiddle = (e) => { + if (isInteractive) return; + if (e.button !== 1) return; + e.preventDefault(); + e.stopPropagation(); + middleDragging = true; + }; + const onMouseMoveMiddle = (e) => { + if (!middleDragging) return; + e.preventDefault(); + e.stopPropagation(); + const dx = e.movementX || 0; + const dy = e.movementY || 0; + if (dx === 0 && dy === 0) return; + try { + ipcRenderer.sendToHost('canvas-wheel-pan', { deltaX: -dx, deltaY: -dy, deltaMode: 0 }); + } catch (_) {} + }; + const onMouseUpMiddle = (e) => { + if (e.button !== 1) return; + middleDragging = false; + }; + // Chromium starts auxiliary-scroll on middle-click; auxclick prevents that. + const onAuxClickSuppress = (e) => { + if (isInteractive) return; + if (e.button === 1) { e.preventDefault(); e.stopPropagation(); } + }; + window.addEventListener('mousedown', onMouseDownMiddle, { capture: true }); + window.addEventListener('mousemove', onMouseMoveMiddle, { capture: true }); + window.addEventListener('mouseup', onMouseUpMiddle, { capture: true }); + window.addEventListener('auxclick', onAuxClickSuppress, { capture: true }); + // --------------------------------------------------------------------------- // Double-click to fit the browser card (parity with agent-chat dblclick). // diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index ab084251..b85750ab 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -665,6 +665,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // Without this early-out the unconditional stopPropagation below kills // ctrl+wheel and the canvas listener never fires. if (e.ctrlKey || e.metaKey) return; + // Horizontal-dominant gestures must also reach the canvas so a sideways + // swipe pans the dashboard (chat has no horizontal scroll to absorb). + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; const atTop = el.scrollTop <= 0; const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; const scrollingDown = e.deltaY > 0; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index d6b53b13..7fce9c06 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -303,7 +303,6 @@ const BrowserCard: React.FC = ({ // (the historical Windows mount segfault). Clear the crash-safety marker. if (isWindows) markWindowsWebviewSurvived(); wv.loadURL(targetUrl).catch(() => {}); - // Lock guest zoom at 1.0 so ctrl+wheel never triggers Chromium's in-page zoom; canvas zoom takes over (issue #27). try { (wv as any).setVisualZoomLevelLimits?.(1, 1); (wv as any).setZoomFactor?.(1); @@ -329,18 +328,30 @@ const BrowserCard: React.FC = ({ } else if (e?.channel === 'browser-dblclick') { onDoubleClickRef.current?.(browserId, 'browser'); } else if (e?.channel === 'canvas-wheel-zoom') { - // Convert guest coords to doc coords and dispatch a CustomEvent; synthetic WheelEvent bubble was unreliable through GuestView. const payload = e.args?.[0] || {}; const wvRect = wv.getBoundingClientRect(); - const docX = wvRect.left + (payload.clientX ?? 0); - const docY = wvRect.top + (payload.clientY ?? 0); + const fx = typeof payload.fracX === 'number' ? payload.fracX : 0.5; + const fy = typeof payload.fracY === 'number' ? payload.fracY : 0.5; window.dispatchEvent( new CustomEvent('openswarm:canvas-wheel-zoom', { detail: { deltaY: payload.deltaY ?? 0, deltaMode: payload.deltaMode ?? 0, - clientX: docX, - clientY: docY, + clientX: wvRect.left + fx * wvRect.width, + clientY: wvRect.top + fy * wvRect.height, + }, + }), + ); + } else if (e?.channel === 'canvas-wheel-pan') { + // Plain wheel inside an unselected webview never bubbles out; the + // preload forwards it here so the dashboard canvas can pan. + const payload = e.args?.[0] || {}; + window.dispatchEvent( + new CustomEvent('openswarm:canvas-wheel-pan', { + detail: { + deltaX: payload.deltaX ?? 0, + deltaY: payload.deltaY ?? 0, + deltaMode: payload.deltaMode ?? 0, }, }), ); diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 034b4341..6176dfe5 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -9,8 +9,8 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt'; import CloseIcon from '@mui/icons-material/Close'; import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; -import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice'; -import { useAppDispatch } from '@/shared/hooks'; +import { setViewCardPosition, setViewCardSize, removeViewCard, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { API_BASE, getAuthToken } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview'; @@ -75,6 +75,23 @@ const DashboardViewCard: React.FC = ({ const dispatch = useAppDispatch(); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const previewRef = useRef(null); + const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId); + const interactive = activeViewCardId === output.id; + + // Deselecting the card exits interact mode (click anywhere else on canvas). + useEffect(() => { + if (!isSelected && interactive) dispatch(setActiveViewCardId(null)); + }, [isSelected, interactive, dispatch]); + + // Escape exits interact mode. + useEffect(() => { + if (!interactive) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') dispatch(setActiveViewCardId(null)); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [interactive, dispatch]); const [inputData] = useState>(() => getDefault(output.input_schema)); const [backendResult] = useState | null>(null); @@ -288,7 +305,9 @@ const DashboardViewCard: React.FC = ({ borderRadius: `${c.radius.lg}px`, border: isHighlighted ? `2px solid ${c.accent.primary}` - : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`, + : interactive + ? `2px solid ${c.accent.primary}` + : isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`, bgcolor: c.bg.surface, boxShadow: isHighlighted ? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15` @@ -406,6 +425,8 @@ const DashboardViewCard: React.FC = ({ output={output} inputData={inputData} backendResult={backendResult} + interactive={interactive} + onAppClicked={() => dispatch(setActiveViewCardId(output.id))} />
@@ -487,7 +508,9 @@ const DashboardOutputPreview: React.FC<{ output: Output; inputData: Record; backendResult: any; -}> = ({ previewRef, output, inputData, backendResult }) => { + interactive: boolean; + onAppClicked: () => void; +}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked }) => { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; @@ -587,6 +610,8 @@ const DashboardOutputPreview: React.FC<{ frontendCode={output.files?.['index.html'] ?? ''} inputData={inputData} backendResult={backendResult} + interactive={interactive} + onAppClicked={onAppClicked} /> ); }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 89302524..6db17045 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -261,6 +261,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic. const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; + + // Horizontal-dominant gestures over a container that only scrolls + // vertically (e.g., chat) should pan the canvas instead of being + // silently absorbed by the child's no-op horizontal handling. + if (Math.abs(dx) > Math.abs(dy) && !canScrollX) { + target = target.parentElement; + continue; + } + const atYBoundary = !canScrollY || (dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) || (dy < 0 && target.scrollTop <= 1); @@ -302,11 +311,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: el.addEventListener('wheel', onWheel, { passive: false }); - // ctrl/meta+wheel events that originate inside an Electron - // never bubble out of the guest into the host DOM, so the wheel - // listener above can't see them. BrowserCard's preload-bridge - // forwards those gestures via this CustomEvent (issue #27); we run - // the same zoom-around-cursor math the wheel handler uses. const onForwardedZoom = (e: Event) => { const detail = (e as CustomEvent).detail || {}; const dy = detail.deltaMode === 1 ? detail.deltaY * 40 : detail.deltaY; @@ -324,9 +328,27 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: }; window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); + // Plain wheel inside a webview can't bubble out either; the preload + // forwards horizontal-dominant scrolls as a pan when the guest page + // has nothing to scroll horizontally, plus middle-mouse drag deltas. + const onForwardedPan = (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + const dy = detail.deltaMode === 1 ? (detail.deltaY ?? 0) * 40 : (detail.deltaY ?? 0); + const dx = detail.deltaMode === 1 ? (detail.deltaX ?? 0) * 40 : (detail.deltaX ?? 0); + if (inertiaFrameRef.current) { + cancelAnimationFrame(inertiaFrameRef.current); + inertiaFrameRef.current = null; + } + pendingPanDx += dx; + pendingPanDy += dy; + scheduleWheelFlush(); + }; + window.addEventListener('openswarm:canvas-wheel-pan', onForwardedPan); + return () => { el.removeEventListener('wheel', onWheel); window.removeEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); + window.removeEventListener('openswarm:canvas-wheel-pan', onForwardedPan); if (wheelRafId != null) cancelAnimationFrame(wheelRafId); if (wheelIdleTimer != null) clearTimeout(wheelIdleTimer); // Don't leave the flag stuck on if the canvas unmounts mid-gesture. diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts index e2294bdf..2bdd7ddb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts @@ -22,6 +22,8 @@ export function useOverlayScrollPassthrough(active: boolean) { dy *= 20; } + const horizontalDominant = Math.abs(dx) > Math.abs(dy); + let node = underneath as HTMLElement | null; while (node) { if (node.tagName === 'WEBVIEW') { @@ -52,6 +54,14 @@ export function useOverlayScrollPassthrough(active: boolean) { node.scrollWidth > node.clientWidth && (cs.overflowX === 'auto' || cs.overflowX === 'scroll'); + // Horizontal-dominant gesture over a vertically-only scrollable + // container: don't absorb it (scrollBy with dx would be a no-op). + // Let it bubble to the canvas wheel handler so the canvas pans. + if (horizontalDominant && !canScrollX) { + node = node.parentElement; + continue; + } + if (canScrollY || canScrollX) { e.stopPropagation(); e.preventDefault(); diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index 59e31b6b..e38ca116 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -55,6 +55,10 @@ interface Props { onConsoleMessage?: (level: string, text: string) => void; /** Fires once the embedded app has actually painted, so cold-start placeholders don't unmount during the vite-ready to first-paint gap. */ onContentLoad?: () => void; + /** True when the user has clicked into the app; preload stops forwarding canvas gestures and lets the app handle all events. */ + interactive?: boolean; + /** Fired when the preload reports a mousedown inside the guest, so the host can flip the card into interactive mode. */ + onAppClicked?: () => void; } function buildSrcdoc( @@ -92,6 +96,8 @@ const ViewPreview = forwardRef(({ style, onConsoleMessage, onContentLoad, + interactive = false, + onAppClicked, }, ref) => { const iframeRef = useRef(null); const webviewRef = useRef(null); @@ -219,22 +225,72 @@ const ViewPreview = forwardRef(({ } }, [srcdoc, useWebview]); - // Forward webview-console events (preload wraps console.*) to onConsoleMessage; iframe path has no equivalent. + // Listen for preload IPC: console forwarding, canvas wheel forwarding + // (matches BrowserCard so apps share the same dashboard pan/zoom defaults), + // and the app-clicked notification that flips the card into interact mode. useEffect(() => { - if (!useWebview || !onConsoleMessage) return; + if (!useWebview) return; const wv = webviewRef.current; if (!wv) return; const handler = (e: any) => { - if (e?.channel !== 'webview-console') return; - const arg = Array.isArray(e.args) ? e.args[0] : undefined; - if (!arg) return; - onConsoleMessage(arg.level || 'log', arg.text || ''); + if (e?.channel === 'webview-console') { + if (!onConsoleMessage) return; + const arg = Array.isArray(e.args) ? e.args[0] : undefined; + if (!arg) return; + onConsoleMessage(arg.level || 'log', arg.text || ''); + return; + } + if (e?.channel === 'canvas-wheel-zoom') { + const payload = e.args?.[0] || {}; + const wvRect = wv.getBoundingClientRect(); + const fx = typeof payload.fracX === 'number' ? payload.fracX : 0.5; + const fy = typeof payload.fracY === 'number' ? payload.fracY : 0.5; + window.dispatchEvent( + new CustomEvent('openswarm:canvas-wheel-zoom', { + detail: { + deltaY: payload.deltaY ?? 0, + deltaMode: payload.deltaMode ?? 0, + clientX: wvRect.left + fx * wvRect.width, + clientY: wvRect.top + fy * wvRect.height, + }, + }), + ); + return; + } + if (e?.channel === 'canvas-wheel-pan') { + const payload = e.args?.[0] || {}; + window.dispatchEvent( + new CustomEvent('openswarm:canvas-wheel-pan', { + detail: { + deltaX: payload.deltaX ?? 0, + deltaY: payload.deltaY ?? 0, + deltaMode: payload.deltaMode ?? 0, + }, + }), + ); + return; + } + if (e?.channel === 'app-clicked') { + onAppClicked?.(); + return; + } }; wv.addEventListener?.('ipc-message', handler); return () => { try { wv.removeEventListener?.('ipc-message', handler); } catch (_e) {} }; - }, [useWebview, onConsoleMessage, iframeSrc]); + }, [useWebview, onConsoleMessage, onAppClicked, iframeSrc]); + + // Mirror `interactive` into a ref so the once-per-load did-finish-load + // listener can read the latest value when it pushes initial state. + const interactiveRef = useRef(interactive); + interactiveRef.current = interactive; + useEffect(() => { + if (!useWebview) return; + const wv = webviewRef.current; + if (!wv) return; + try { wv.send?.('openswarm:set-interactive', { interactive }); } catch (_e) {} + }, [useWebview, interactive]); // Webviews use did-finish-load instead of onLoad; did-fail-load retries with 500ms to 5s backoff (Vite may not have bound yet when frontend_url arrives). useEffect(() => { @@ -256,6 +312,7 @@ const ViewPreview = forwardRef(({ retryDelay = 500; cancelRetry(); handleNavigationLoad(); + try { wv.send?.('openswarm:set-interactive', { interactive: interactiveRef.current }); } catch (_) {} }; const onFail = (e: any) => { // Guard on isMainFrame (subresource 404s fire too) and ERR_ABORTED (user-cancel). diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index fee90e3e..f3534ebc 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -98,6 +98,8 @@ export interface DashboardLayoutState { suspendedBrowserCards: Record; /** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */ endingBrowserCards: Record; + /** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */ + activeViewCardId: string | null; } const initialState: DashboardLayoutState = { @@ -116,6 +118,7 @@ const initialState: DashboardLayoutState = { pendingFocusNoteId: null, suspendedBrowserCards: {}, endingBrowserCards: {}, + activeViewCardId: null, }; interface LayoutPayload { @@ -565,6 +568,11 @@ const dashboardLayoutSlice = createSlice({ removeViewCard(state, action: PayloadAction) { delete state.viewCards[action.payload]; + if (state.activeViewCardId === action.payload) state.activeViewCardId = null; + }, + + setActiveViewCardId(state, action: PayloadAction) { + state.activeViewCardId = action.payload; }, addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) { @@ -1080,6 +1088,7 @@ export const { setViewCardPosition, setViewCardSize, removeViewCard, + setActiveViewCardId, addBrowserCard, addBrowserCardFromBackend, setBrowserCardPosition, From 7eeb864a29115cdb3c8a6b1720821115ec42ffcf Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 08:07:17 -0700 Subject: [PATCH 15/36] [eric] swarm: archive checksum (reject tampered) + transactional rollback on import --- backend/apps/swarm/closure.py | 36 ++++++++++++---- backend/apps/swarm/entities/apps.py | 11 ++++- backend/apps/swarm/entities/dashboards.py | 21 ++++++++-- backend/apps/swarm/entities/sessions.py | 5 +++ backend/apps/swarm/entities/skills.py | 11 +++++ backend/apps/swarm/entities/workflows.py | 9 ++++ backend/apps/swarm/models.py | 3 ++ backend/apps/swarm/ziputil.py | 50 +++++++++++++++++++++-- backend/tests/test_swarm_bundle.py | 50 +++++++++++++++++++++++ 9 files changed, 178 insertions(+), 18 deletions(-) diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index dba4e398..c7e7054d 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -28,7 +28,7 @@ from .models import ( ) 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 +from .ziputil import MANIFEST_NAME, BundleError, has_member, is_zip, pack, read_manifest, unpack, verify_checksum def _now() -> str: @@ -176,7 +176,9 @@ def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]: if has_member(raw, MANIFEST_NAME): sandbox = unpack(raw) try: - manifest = Manifest(**read_manifest(sandbox)) + raw_manifest = read_manifest(sandbox) + verify_checksum(sandbox, raw_manifest) + manifest = Manifest(**raw_manifest) except BundleError: shutil.rmtree(sandbox, ignore_errors=True) raise @@ -325,13 +327,29 @@ def _topo_order(manifest: Manifest) -> list[EntityRef]: 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) + trail: list[tuple] = [] # (impl_cls, new_local_id) for rollback, newest last + try: + 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) + trail.append((cls, new_id)) + except Exception as ex: + # All-or-nothing: undo whatever already landed so a failed import never + # leaves half a dashboard behind. + for cls, nid in reversed(trail): + rb = getattr(cls, "rollback", None) + if rb: + try: + rb(nid) + except Exception: + pass + if isinstance(ex, BundleError): + raise + raise BundleError("import failed and was rolled back") 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/apps.py b/backend/apps/swarm/entities/apps.py index a9a34544..ba3f2321 100644 --- a/backend/apps/swarm/entities/apps.py +++ b/backend/apps/swarm/entities/apps.py @@ -13,7 +13,7 @@ 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 backend.config.paths import OUTPUTS_DIR, OUTPUTS_WORKSPACE_DIR from ..exportable import DepRef, ExportContext, RemapTable from ..models import EntityType, Requirement @@ -105,6 +105,15 @@ class AppExportable: _save(o) return o.id + @classmethod + def rollback(cls, local_id: str) -> None: + o = load_output(local_id) + if o and o.workspace_id: + shutil.rmtree(os.path.join(OUTPUTS_WORKSPACE_DIR, o.workspace_id), ignore_errors=True) + p = os.path.join(OUTPUTS_DIR, f"{local_id}.json") + if os.path.exists(p): + os.remove(p) + def _safe_join(folder: str, rel: str) -> str: dest = os.path.realpath(os.path.join(folder, rel)) diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index eebb2507..71ee3d3f 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -105,6 +105,15 @@ class DashboardExportable: _retag_sessions(cards.keys(), new_did) return new_did + @classmethod + def rollback(cls, local_id: str) -> None: + import os + d = _dash_dir() + if d: + p = os.path.join(d, f"{local_id}.json") + if os.path.exists(p): + os.remove(p) + def _dash_dir() -> str | None: try: @@ -130,9 +139,13 @@ def _write(did: str, doc: dict) -> None: def _retag_sessions(session_ids, dashboard_id: str) -> None: + # Best-effort: a hiccup here must not orphan the just-written dashboard. 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) + try: + d = _load_session_data(sid) + if d is not None: + d["dashboard_id"] = dashboard_id + _save_session(sid, d) + except Exception: + pass diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py index a028b77d..847601e6 100644 --- a/backend/apps/swarm/entities/sessions.py +++ b/backend/apps/swarm/entities/sessions.py @@ -93,3 +93,8 @@ class SessionExportable: } _save_session(sid, doc) return sid + + @classmethod + def rollback(cls, local_id: str) -> None: + from backend.apps.agents.manager.session.session_store import _delete_session_file + _delete_session_file(local_id) diff --git a/backend/apps/swarm/entities/skills.py b/backend/apps/swarm/entities/skills.py index cb8fbf06..e02b41b2 100644 --- a/backend/apps/swarm/entities/skills.py +++ b/backend/apps/swarm/entities/skills.py @@ -76,6 +76,17 @@ class SkillExportable: return slug + @classmethod + def rollback(cls, local_id: str) -> None: + fpath = os.path.join(store.SKILLS_DIR, f"{local_id}.md") + if os.path.exists(fpath): + os.remove(fpath) + index = store._load_index() + if local_id in index: + index.pop(local_id, None) + store._save_index(index) + + 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") diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index d7b2fb8f..a8b1143c 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -103,6 +103,15 @@ class WorkflowExportable: store.save_workflow(wf) return wf.id + @classmethod + def rollback(cls, local_id: str) -> None: + store = _store() + if store is not None: + try: + store.delete_workflow(local_id) + except Exception: + pass + def _store(): try: diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py index 65ff68ef..b6c7779e 100644 --- a/backend/apps/swarm/models.py +++ b/backend/apps/swarm/models.py @@ -62,6 +62,9 @@ class Manifest(BaseModel): created_with: str = "OpenSwarm" created_at: str = "" bundle_id: str + # sha256 over every entity payload + file (not the manifest itself); set at + # pack time, re-checked on import to reject a corrupted or edited archive. + checksum: Optional[str] = None root: EntityRef entities: list[EntityRef] = Field(default_factory=list) edges: list[DependencyEdge] = Field(default_factory=list) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index d53033cd..b4682695 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -4,6 +4,7 @@ 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 hashlib import io import json import os @@ -25,6 +26,17 @@ class BundleError(Exception): """Bundle is malformed or unsafe. Message is safe to show the user.""" +def _content_digest(entries: dict[str, bytes]) -> str: + """Order-independent sha256 over every non-manifest entry (path + bytes).""" + h = hashlib.sha256() + for path in sorted(entries): + h.update(path.encode("utf-8")) + h.update(b"\0") + h.update(entries[path]) + h.update(b"\0") + return h.hexdigest() + + 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/).""" @@ -34,16 +46,46 @@ def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) + entries: dict[str, bytes] = {} + for bid, payload in payloads.items(): + entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") + for path, data in files.items(): + entries[path] = data + manifest = {**manifest, "checksum": _content_digest(entries)} 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) + for path in sorted(entries): + zf.writestr(path, entries[path]) return buf.getvalue() +def _sandbox_entries(sandbox: str) -> dict[str, bytes]: + """Every file under the sandbox except the manifest, keyed by forward-slash + relpath so it matches the keys pack() hashed (cross-platform).""" + out: dict[str, bytes] = {} + root = os.path.realpath(sandbox) + for base, _dirs, fnames in os.walk(root): + for fn in fnames: + full = os.path.join(base, fn) + rel = os.path.relpath(full, root).replace(os.sep, "/") + if rel == MANIFEST_NAME: + continue + with open(full, "rb") as f: + out[rel] = f.read() + return out + + +def verify_checksum(sandbox: str, manifest: dict) -> None: + """Reject an archive whose contents don't match the checksum the author + recorded (corruption or tampering). Older bundles without one are allowed.""" + expected = manifest.get("checksum") + if not expected: + return + if _content_digest(_sandbox_entries(sandbox)) != expected: + raise BundleError("this .swarm looks corrupted or was modified") + + 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") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fc6bb7a7..dcb5b733 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -217,6 +217,56 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped +def test_checksum_rejects_tampering(skill_store): + _make_skill(skill_store, "tmp", "Tmp", "# original") + raw, _ = closure.build_bundle(EntityType.skill, "tmp") + # Rebuild the zip with the same manifest (old checksum) but an edited payload. + src = zipfile.ZipFile(io.BytesIO(raw)) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as out: + for n in src.namelist(): + data = src.read(n) + if n.endswith("payload.json"): + d = json.loads(data) + d["content"] = "TAMPERED" + data = json.dumps(d, indent=2).encode("utf-8") + out.writestr(n, data) + with pytest.raises(BundleError): + closure.stage_upload(buf.getvalue(), "tmp.swarm") + + +def test_skill_rollback_removes_it(skill_store): + from backend.apps.swarm.entities.skills import SkillExportable + from backend.apps.swarm.exportable import RemapTable + sid = SkillExportable.import_({"slug": "rbk", "name": "Rbk", "content": "x"}, {}, RemapTable()) + assert (skill_store / f"{sid}.md").exists() + SkillExportable.rollback(sid) + assert not (skill_store / f"{sid}.md").exists() + assert sid not in store._load_index() + + +def test_commit_rolls_back_created_on_failure(skill_store, tmp_path): + # A bundle of [skill, workflow]: skill imports first, then the workflow import + # fails (no workflow store on this branch), so the skill must be rolled back. + from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest + + sb = tmp_path / "sb" + skill_ref = EntityRef(type=EntityType.skill, bundle_id="s1", name="S", path="entities/s1") + wf_ref = EntityRef(type=EntityType.workflow, bundle_id="w1", name="W", path="entities/w1") + for ref, payload in ((skill_ref, {"slug": "rollme", "name": "Rollme", "content": "hi"}), (wf_ref, {"title": "W"})): + d = sb / "entities" / ref.bundle_id + d.mkdir(parents=True) + (d / "payload.json").write_text(json.dumps(payload), encoding="utf-8") + manifest = Manifest( + bundle_id="b", root=skill_ref, entities=[skill_ref, wf_ref], + preview=BundlePreview(root_type=EntityType.skill, root_name="S"), + ) + with pytest.raises(BundleError): + closure.commit(str(sb), manifest, []) + assert "rollme" not in store._load_index() + assert not (skill_store / "rollme.md").exists() + + def _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From e47ac3bf67078ba88038e3b9fb773a51aa6c8edc Mon Sep 17 00:00:00 2001 From: cire <134991075+ciregenz@users.noreply.github.com> Date: Sun, 14 Jun 2026 08:58:49 -0700 Subject: [PATCH 16/36] Revert "[aidan] bug: dashboard not renaming during first run" (#83) This reverts commit 2d7be0e8ac11af4bb5cd8a75734a86aa1cfad6fe. --- backend/apps/dashboards/dashboards.py | 8 ++++---- backend/tests/test_disk_resilience.py | 2 +- .../app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts | 7 ++++++- .../Dashboard/hooks/lifecycle/useDashboardLifecycle.ts | 7 +++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 36a02648..81dbf0c1 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -61,8 +61,8 @@ def _delete(dashboard_id: str): os.remove(path) -def migrate_if_needed(): - """One-time migration: if no dashboards exist, create the default from old layout.""" +def _migrate_if_needed(): + """One-time migration: if no dashboards exist, create 'Dashboard 1' from old layout.""" existing = _load_all() if existing: return @@ -80,7 +80,7 @@ def migrate_if_needed(): except Exception: logger.exception("Failed to read old layout.json, using empty layout") - dashboard = Dashboard(name="Untitled Dashboard", layout=layout) + dashboard = Dashboard(name="Dashboard 1", layout=layout) _save(dashboard) logger.info(f"Created default dashboard: {dashboard.id}") @@ -105,7 +105,7 @@ def migrate_if_needed(): @asynccontextmanager async def dashboards_lifespan(): os.makedirs(DATA_DIR, exist_ok=True) - migrate_if_needed() + _migrate_if_needed() yield diff --git a/backend/tests/test_disk_resilience.py b/backend/tests/test_disk_resilience.py index f1993c34..9b37cbae 100644 --- a/backend/tests/test_disk_resilience.py +++ b/backend/tests/test_disk_resilience.py @@ -143,7 +143,7 @@ def test_migration_survives_corrupt_session(tmp_path, monkeypatch): (sess_dir / "good.json").write_text(json.dumps({"id": "good"})) (sess_dir / "bad.json").write_text("{ truncated ,,,") - dmod.migrate_if_needed() # must not raise despite the corrupt session + dmod._migrate_if_needed() # must not raise despite the corrupt session dashboards = dmod._load_all() assert len(dashboards) == 1 diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index e26fdfa0..7d67962a 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -215,7 +215,12 @@ export function useAgentSpawn({ (s) => s.status !== 'draft' && s.dashboard_id === dashboardId, ).length; const NAME_GEN_TRIGGERS = [1, 3, 6]; - if (NAME_GEN_TRIGGERS.includes(agentCount)) { + const currentDash = store.getState().dashboards.items[dashboardId]; + const canAutoName = + currentDash && + (currentDash.auto_named || currentDash.name === 'Untitled Dashboard'); + + if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) { dispatch(generateDashboardName(dashboardId)); } } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index cfc3a428..b0b51f65 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -255,13 +255,16 @@ export function useDashboardLifecycle({ const namedOnFirstMessageRef = useRef(null); useEffect(() => { - if (!dashboardId) return; + if (!dashboardId || !layoutInitialized) return; if (namedOnFirstMessageRef.current === dashboardId) return; + const dash = store.getState().dashboards.items[dashboardId]; + if (!dash) return; + if (!dash.auto_named && dash.name !== 'Untitled Dashboard') return; const hasUserMessage = Object.values(sessions).some( (s) => s.dashboard_id === dashboardId && s.messages?.some((m) => m.role === 'user'), ); if (!hasUserMessage) return; namedOnFirstMessageRef.current = dashboardId; dispatch(generateDashboardName(dashboardId)); - }, [sessions, dashboardId, dispatch]); + }, [sessions, dashboardId, layoutInitialized, dispatch]); } From 124b128ed134552d2694beef9a13deca875f4b8c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 16:58:26 -0700 Subject: [PATCH 17/36] [eric] proxy: fix non-Anthropic first-msg 400s (Gemini allowlist + gpt-5 sampling strip) --- .../apps/agents/core/openai_passthrough.py | 43 ++++--- backend/apps/agents/proxy/anthropic_proxy.py | 107 ++++++++++++------ backend/tests/test_v2_invariants.py | 74 ++++++++++++ 3 files changed, 175 insertions(+), 49 deletions(-) diff --git a/backend/apps/agents/core/openai_passthrough.py b/backend/apps/agents/core/openai_passthrough.py index 72a635f0..dc5ac2fd 100644 --- a/backend/apps/agents/core/openai_passthrough.py +++ b/backend/apps/agents/core/openai_passthrough.py @@ -42,26 +42,41 @@ def _is_gpt5(model: str) -> bool: return any(m.startswith(p) for p in _GPT5_PREFIXES) -def _scrub_max_tokens(body: bytes) -> bytes: - """Rename max_tokens to max_completion_tokens for GPT-5; bytes in/out, never raises.""" +# GPT-5 reasoning models reject sampling knobs: temperature must be the default +# (only 1 is allowed), and top_p / penalties / logprobs are unsupported outright. +# 9Router 0.3.60 is pinned and forwards whatever the user's picked model carried, +# so we strip them at this last hop before OpenAI or the whole request 400s. +_GPT5_UNSUPPORTED_PARAMS = ( + "top_p", "top_k", "frequency_penalty", "presence_penalty", + "logprobs", "top_logprobs", "logit_bias", +) + + +def _scrub_gpt5_params(body: bytes) -> bytes: + """For GPT-5: rename max_tokens→max_completion_tokens and drop the sampling + params the reasoning models reject. Bytes in/out, never raises.""" if not body: return body try: parsed = json.loads(body) except Exception: return body - if not isinstance(parsed, dict): + if not isinstance(parsed, dict) or not _is_gpt5(str(parsed.get("model") or "")): return body - model = str(parsed.get("model") or "") - if not _is_gpt5(model): - return body - if "max_tokens" in parsed and "max_completion_tokens" not in parsed: - parsed["max_completion_tokens"] = parsed.pop("max_tokens") - return json.dumps(parsed).encode("utf-8") - if "max_tokens" in parsed and "max_completion_tokens" in parsed: - parsed.pop("max_tokens", None) - return json.dumps(parsed).encode("utf-8") - return body + mutated = False + if "max_tokens" in parsed: + if "max_completion_tokens" not in parsed: + parsed["max_completion_tokens"] = parsed.pop("max_tokens") + else: + parsed.pop("max_tokens", None) + mutated = True + if "temperature" in parsed and parsed["temperature"] != 1: + parsed.pop("temperature", None) + mutated = True + for k in _GPT5_UNSUPPORTED_PARAMS: + if parsed.pop(k, None) is not None: + mutated = True + return json.dumps(parsed).encode("utf-8") if mutated else body @openai_passthrough.router.api_route( @@ -70,7 +85,7 @@ def _scrub_max_tokens(body: bytes) -> bytes: ) async def passthrough(rest: str, request: Request): body = await request.body() - body = _scrub_max_tokens(body) + body = _scrub_gpt5_params(body) forward_headers: dict[str, str] = {} for k, v in request.headers.items(): diff --git a/backend/apps/agents/proxy/anthropic_proxy.py b/backend/apps/agents/proxy/anthropic_proxy.py index 98ca7b9c..b20f2049 100644 --- a/backend/apps/agents/proxy/anthropic_proxy.py +++ b/backend/apps/agents/proxy/anthropic_proxy.py @@ -35,44 +35,72 @@ _GEMINI_MODEL_PREFIXES = ("gemini/", "gc/", "ag/") # Own-key Gemini ("gemini-3-flash-api" etc.) skips the gemini/ prefix; match bare names so $schema scrub still fires. _GEMINI_BARE_MODEL_PATTERNS = ("gemini-",) -# Keys 9Router 0.3.60 misses that Gemini's function_declarations validator 400s on. Each was caught in prod. -_GEMINI_FORBIDDEN_SCHEMA_KEYS = { - "$schema", - "$id", - "$ref", - "$defs", - "definitions", - "additionalProperties", - "propertyNames", - "patternProperties", - "exclusiveMinimum", - "exclusiveMaximum", - "const", - "prefill", - "enumTitles", - "title", - "examples", - "default", - "readOnly", - "writeOnly", - "deprecated", +# Gemini's function_declarations validator accepts only a small OpenAPI subset. +# A denylist was whack-a-mole: every new JSON Schema construct that slipped +# through (union `type`, anyOf, $comment, format, ...) was a fresh prod 400 with +# zero tokens in. We invert it: keep ONLY the keys Gemini is known to accept, and +# fold the two "optional" encodings Anthropic emits (a union `type` list, and an +# anyOf whose other branch is `{"type":"null"}`) into the `nullable` flag Gemini +# actually understands. Everything dropped is advisory; the model still reads it +# from `description`. The win is structural: an unknown future key can't 400 us. +_GEMINI_ALLOWED_SCHEMA_KEYS = { + "type", "description", "nullable", "enum", "items", "properties", + "required", "minimum", "maximum", "minItems", "maxItems", } +_GEMINI_NULL_TYPES = {"null", None} -def _scrub_gemini_schema(node): - """Recursive in-place strip of Gemini-rejected JSON Schema fields.""" - if isinstance(node, dict): - for k in list(node.keys()): - if k in _GEMINI_FORBIDDEN_SCHEMA_KEYS: - node.pop(k, None) - continue - node[k] = _scrub_gemini_schema(node[k]) - return node + +def _normalize_schema_for_gemini(node): + """Allowlist-rewrite a JSON Schema node into the subset Gemini accepts. + Returns a NEW node (callers must assign the result); folds union/anyOf + nullability into `nullable`. Never raises on odd input.""" if isinstance(node, list): - for i, v in enumerate(node): - node[i] = _scrub_gemini_schema(v) + return [_normalize_schema_for_gemini(v) for v in node] + if not isinstance(node, dict): return node - return node + + nullable = bool(node.get("nullable")) + + # Gemini can't represent unions; collapse anyOf/oneOf/allOf to one branch. + # A bare {"type": "null"} member just means the field is nullable. + for combiner in ("anyOf", "oneOf", "allOf"): + branches = node.get(combiner) + if isinstance(branches, list) and branches: + picked = None + for b in branches: + if isinstance(b, dict) and b.get("type") in _GEMINI_NULL_TYPES and len(b) == 1: + nullable = True + elif picked is None: + picked = b + base = _normalize_schema_for_gemini(picked) if isinstance(picked, dict) else {} + if nullable and isinstance(base, dict): + base["nullable"] = True + return base + + out = {} + t = node.get("type") + if isinstance(t, list): # ["string", "null"] -> "string" + nullable + non_null = [x for x in t if x not in _GEMINI_NULL_TYPES] + if len(non_null) != len(t): + nullable = True + t = non_null[0] if non_null else None + if t is not None: + out["type"] = t + + for k, v in node.items(): + if k in ("type", "nullable") or k not in _GEMINI_ALLOWED_SCHEMA_KEYS: + continue + if k == "properties" and isinstance(v, dict): + out[k] = {pk: _normalize_schema_for_gemini(pv) for pk, pv in v.items()} + elif k == "items": + out[k] = _normalize_schema_for_gemini(v) + else: + out[k] = v + + if nullable: + out["nullable"] = True + return out # GPT-5.x rejects max_tokens; needs max_completion_tokens. Anthropic-format wire still emits max_tokens; we rename on the way out. @@ -151,6 +179,15 @@ def _scrub_request_for_openai_gpt5(body: bytes) -> bytes: elif "max_tokens" in parsed and "max_completion_tokens" in parsed: parsed.pop("max_tokens", None) mutated = True + # GPT-5 reasoning models reject sampling knobs (temperature must be 1, top_p + # and penalties unsupported); the wire carries them for the user's picked model. + if "temperature" in parsed and parsed["temperature"] != 1: + parsed.pop("temperature", None) + mutated = True + for _k in ("top_p", "top_k", "frequency_penalty", "presence_penalty", + "logprobs", "top_logprobs", "logit_bias"): + if parsed.pop(_k, None) is not None: + mutated = True try: before = json.dumps(parsed.get("messages"), sort_keys=True) if "messages" in parsed else "" _rewrite_document_to_openai_file(parsed) @@ -272,9 +309,9 @@ def _scrub_request_for_gemini(body: bytes) -> bytes: if not isinstance(t, dict): continue if isinstance(t.get("input_schema"), (dict, list)): - _scrub_gemini_schema(t["input_schema"]) + t["input_schema"] = _normalize_schema_for_gemini(t["input_schema"]) if isinstance(t.get("parameters"), (dict, list)): - _scrub_gemini_schema(t["parameters"]) + t["parameters"] = _normalize_schema_for_gemini(t["parameters"]) try: if isinstance(parsed, dict): _rewrite_document_to_image(parsed) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 5ca346c8..9842674e 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -1482,6 +1482,80 @@ def test_gemini_translated_block_matches_9router_image_url_filter(): assert block["image_url"]["url"].startswith("data:application/pdf;base64,") +def test_gemini_schema_normalizer_allowlists_and_folds_nullable(): + """Gemini's function_declarations validator 400s (zero tokens in) on JSON + Schema constructs the old denylist kept missing: union `type`, anyOf/oneOf/ + allOf, $comment, format, additionalProperties, title. The normalizer keeps + only the keys Gemini accepts and folds the two nullable encodings Anthropic + emits (union type, anyOf-with-null) into the `nullable` flag Gemini groks. + Live-confirmed against the Gemini API 2026-06-14.""" + import json + from backend.apps.agents.proxy.anthropic_proxy import ( + _normalize_schema_for_gemini, _scrub_request_for_gemini, + ) + # union type -> single type + nullable + assert _normalize_schema_for_gemini({"type": ["string", "null"], "description": "d"}) == \ + {"type": "string", "description": "d", "nullable": True} + # anyOf-with-null -> chosen branch + nullable, allowed constraint preserved + assert _normalize_schema_for_gemini({"anyOf": [{"type": "integer", "minimum": 0}, {"type": "null"}]}) == \ + {"type": "integer", "minimum": 0, "nullable": True} + # forbidden keys dropped, enum kept + assert _normalize_schema_for_gemini({ + "type": "object", "additionalProperties": False, "title": "T", + "properties": {"u": {"type": "string", "format": "uri", "$comment": "x", "minLength": 2}, + "d": {"type": "string", "enum": ["a", "b"]}}, + "required": ["u"], + }) == {"type": "object", + "properties": {"u": {"type": "string"}, "d": {"type": "string", "enum": ["a", "b"]}}, + "required": ["u"]} + + # End to end: no Gemini-rejected key survives a realistic tool payload. + FORBIDDEN = {"$schema", "$ref", "additionalProperties", "title", "default", "$comment", + "format", "pattern", "minLength", "maxLength", "anyOf", "oneOf", "allOf", "const"} + body = json.dumps({"model": "gemini-3.1-pro-preview", "tools": [{ + "name": "q", "input_schema": { + "type": "object", "additionalProperties": False, "$schema": "x", + "properties": { + "filter": {"anyOf": [{"type": "object", "properties": {"q": {"type": "string"}}}, + {"type": "null"}]}, + "size": {"type": ["integer", "null"], "minimum": 1, "default": 10}, + "url": {"type": "string", "format": "uri", "$comment": "c"}}, + "required": ["filter"]}}]}).encode() + schema = json.loads(_scrub_request_for_gemini(body))["tools"][0]["input_schema"] + seen, stack = set(), [schema] + while stack: + n = stack.pop() + if isinstance(n, dict): + seen |= set(n.keys()); stack += list(n.values()) + elif isinstance(n, list): + stack += n + assert seen.isdisjoint(FORBIDDEN), f"forbidden keys survived: {seen & FORBIDDEN}" + + +def test_gpt5_param_scrub_drops_unsupported_sampling_knobs(): + """GPT-5 reasoning models 400 on max_tokens, temperature!=1, top_p, and the + penalty/logprobs family. Both the proxy and the passthrough must strip them. + Live-confirmed the 400s against the OpenAI API 2026-06-14.""" + import json + from backend.apps.agents.proxy.anthropic_proxy import _scrub_request_for_openai_gpt5 + from backend.apps.agents.core.openai_passthrough import _scrub_gpt5_params + dirty = json.dumps({"model": "gpt-5", "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 200, "temperature": 0, "top_p": 0.9, + "frequency_penalty": 0.5, "presence_penalty": 0.1, "logprobs": True}).encode() + for fn in (_scrub_request_for_openai_gpt5, _scrub_gpt5_params): + out = json.loads(fn(dirty)) + assert out.get("max_completion_tokens") == 200 and "max_tokens" not in out, fn.__name__ + for k in ("temperature", "top_p", "frequency_penalty", "presence_penalty", "logprobs"): + assert k not in out, f"{fn.__name__} left {k}" + # temperature==1 is the one allowed value; don't over-strip it + assert json.loads(_scrub_gpt5_params(json.dumps( + {"model": "gpt-5", "temperature": 1}).encode())).get("temperature") == 1 + # non-gpt-5 models are untouched + assert json.loads(_scrub_gpt5_params(json.dumps( + {"model": "gpt-4o", "temperature": 0, "top_p": 0.5}).encode())) == \ + {"model": "gpt-4o", "temperature": 0, "top_p": 0.5} + + def test_openrouter_plugin_array_matches_docs(): """Per https://openrouter.ai/docs/features/multimodal/pdfs, the plugins array shape is `[{id:"file-parser", pdf:{engine: "..."}}]` From f93d6e02d11b2605f528b8d2eedf21ee7bdb7d69 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 20:27:39 -0700 Subject: [PATCH 18/36] [eric] 9router: port the 0.4.x /api auth gate (x-9r-cli-token) to unblock a version bump --- backend/apps/nine_router/__init__.py | 4 + backend/apps/nine_router/oauth.py | 8 +- backend/apps/nine_router/process.py | 131 +++++++++++++++++++----- backend/apps/nine_router/sync.py | 4 +- backend/apps/nine_router/sync_custom.py | 20 ++-- 5 files changed, 123 insertions(+), 44 deletions(-) diff --git a/backend/apps/nine_router/__init__.py b/backend/apps/nine_router/__init__.py index ce818d3d..6e27ee33 100644 --- a/backend/apps/nine_router/__init__.py +++ b/backend/apps/nine_router/__init__.py @@ -24,6 +24,8 @@ from .process import ( NINE_ROUTER_PORT, NINE_ROUTER_URL, NINE_ROUTER_V1, + cli_auth_headers, + cli_auth_token, ensure_running, get_latest_reasoning_tokens, get_providers, @@ -67,6 +69,8 @@ __all__ = [ "NINE_ROUTER_OPENAI_KEYED_PREFIX", "NINE_ROUTER_OPENROUTER_KEYED_NAME", "NINE_ROUTER_CUSTOM_NAME_SUFFIX", + "cli_auth_headers", + "cli_auth_token", "ensure_running", "stop", "is_running", diff --git a/backend/apps/nine_router/oauth.py b/backend/apps/nine_router/oauth.py index efcc5e75..ebb8d1df 100644 --- a/backend/apps/nine_router/oauth.py +++ b/backend/apps/nine_router/oauth.py @@ -10,7 +10,7 @@ import os import httpx -from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1 +from .process import NINE_ROUTER_API, NINE_ROUTER_PORT, NINE_ROUTER_V1, cli_auth_headers from backend.apps.oauth_state import _pending_oauth, _mark_oauth_completed logger = logging.getLogger(__name__) @@ -263,7 +263,7 @@ async def start_oauth(provider: str) -> dict: For device_code providers (github, qwen, kiro): returns {user_code, verification_uri, device_code} For authorization_code providers (claude, codex, gemini-cli): returns {authUrl, codeVerifier, state} """ - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client: try: r = await client.get(f"{NINE_ROUTER_API}/oauth/{provider}/device-code") if r.status_code == 200: @@ -310,7 +310,7 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None if extra_data: body["extraData"] = extra_data - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client: r = await client.post( f"{NINE_ROUTER_API}/oauth/{provider}/poll", json=body, @@ -321,7 +321,7 @@ async def poll_oauth(provider: str, device_code: str, code_verifier: str | None async def exchange_oauth(provider: str, code: str, redirect_uri: str, code_verifier: str, state: str = "") -> dict: """Exchange OAuth code for tokens via 9Router.""" - async with httpx.AsyncClient(timeout=15.0) as client: + async with httpx.AsyncClient(timeout=15.0, headers=cli_auth_headers()) as client: r = await client.post( f"{NINE_ROUTER_API}/oauth/{provider}/exchange", json={ diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index 4117f108..eee3e4a1 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -11,8 +11,10 @@ API at localhost:20128/v1. """ import asyncio +import hashlib import logging import os +import secrets import shutil import subprocess import time @@ -27,34 +29,30 @@ NINE_ROUTER_URL = f"http://localhost:{NINE_ROUTER_PORT}" NINE_ROUTER_API = f"{NINE_ROUTER_URL}/api" NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1" -# Pinned 9router npm package version. Stays at 0.3.60. +# Pinned 9router npm package version. Prod default stays 0.3.60; set +# OPENSWARM_ROUTER_VERSION to stage a bump in dev (keys the dev cache by +# version, so the override pulls a clean install) without shipping it. # -# DO NOT bump to 0.4.x without porting 9Router API auth first. Tested 0.4.66 -# empirically (2026-06-01): it adds an auth gate to its internal /api/* routes, -# so the endpoints our connect/sync flow calls without a token now 401 instead -# of working: -# endpoint 0.3.60 0.4.66 -# /api/oauth//device-code 400 401 Unauthorized -# POST /api/providers 400 401 Unauthorized -# That 401 makes start_oauth() throw, which 500s EVERY subscription connect -# (Claude/Codex/Gemini). oauth.py + sync.py would each need to discover and -# send 9Router 0.4.x's API token on every /api/* call before a bump is viable. +# 0.4.x gates its internal /api/* routes behind auth (the old bump blocker): +# bare `POST /api/providers` / `/api/oauth//device-code` now 401 instead +# of working. That auth is now PORTED here: see cli_auth_token() / cli_auth_headers() +# below, which compute the `x-9r-cli-token` 9Router checks and which every +# /api/* call in this package attaches. The header is empty on 0.3.60 (no +# machine-id file), so the old auth-free path is untouched. # -# What the bump WOULD buy once auth is ported: cc/claude-opus-4-8 and cx/gpt-5.5 -# on the sub routes (gpt-5.5 404s on 0.3.60), and a reworked WebSearch behind a -# new /api/v1/search route. Gemini 3.5 Flash is Antigravity-only there -# (ag/gemini-3.5-flash-low), never on the gc/ Gemini-CLI lane. +# What the bump buys: cc/claude-opus-4-8 and cx/gpt-5.5 on the sub routes +# (gpt-5.5 404s on 0.3.60), a reworked WebSearch behind /api/v1/search, and +# 3 months of cross-provider translator robustness. # -# Original 0.3.60 pin reason (still holds): versions 0.3.60-0.3.96 regressed -# cross-provider WebSearch (a Codex/Gemini primary delegating WebSearch saw -# "claude-haiku-4-5-20251001 unavailable" or hallucinated output). -# -# Note: 0.3.60-0.4.20 ALL emit `max_tokens` (not max_completion_tokens) -# when translating Anthropic->OpenAI, which OpenAI's GPT-5 family rejects. -# The fix lives in our /api/openai-passthrough proxy; see core/openai_passthrough.py -# and sync_openai_api_key for how the translation lane is rerouted via an -# `openai-compatible` provider-node that honors `baseUrl`. -NINE_ROUTER_NPM_VERSION = "0.3.60" +# REMAINING gate before flipping the prod default to 0.4.x: re-qualify +# cross-provider WebSearch. The original 0.3.60 pin reason was that 0.3.60-0.3.96 +# regressed it (a Codex/Gemini primary delegating WebSearch saw +# "claude-haiku-4-5-20251001 unavailable" or hallucinated output); 0.4.x reworked +# it but that's unverified here. Also confirmed on 0.4.80: it STILL emits +# `max_tokens` (not max_completion_tokens) on Anthropic->OpenAI, so our +# /api/openai-passthrough rename (core/openai_passthrough.py + sync_openai_api_key, +# routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary. +NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60") _process: subprocess.Popen | None = None @@ -85,6 +83,83 @@ def is_running() -> bool: return False +def _nine_router_data_dir() -> str: + """Where 9Router persists machine-id + auth/cli-secret, the two files we + hash into the /api/* auth token on 0.4.x. Mirrors 9Router's own default + (DATA_DIR env, else ~/.9router on unix, %APPDATA%/9router on win) so we read + the exact files it writes. We never relocate it: that would orphan a user's + existing subscription connections.""" + env_dir = os.environ.get("DATA_DIR") + if env_dir: + return env_dir + if os.name == "nt": + base = os.environ.get("APPDATA") or os.path.join( + os.path.expanduser("~"), "AppData", "Roaming" + ) + return os.path.join(base, "9router") + return os.path.join(os.path.expanduser("~"), ".9router") + + +_cli_token_cache: str | None = None + + +def cli_auth_token() -> str | None: + """The token 9Router 0.4.x checks in `x-9r-cli-token` on /api/* calls: + sha256(machineId + "9r-cli-auth" + cliSecret)[:16]. machine-id is written + at 9Router boot, cli-secret only lazily on its first self-call, so we create + cli-secret ourselves (atomic O_EXCL, 0600, identical to 9Router's getter) + when missing so connect/sync can auth before that self-call. Returns None on + 0.3.60 (no machine-id) or when 9Router isn't up, so the caller sends no + header and the old auth-free path is untouched. Never raises.""" + global _cli_token_cache + if _cli_token_cache: + return _cli_token_cache + if not is_running(): + return None + try: + data_dir = _nine_router_data_dir() + try: + with open(os.path.join(data_dir, "machine-id"), encoding="utf-8") as f: + machine_id = f.read().strip() + except OSError: + return None # 0.3.60 layout, or 9Router hasn't written it yet + if not machine_id: + return None + secret_path = os.path.join(data_dir, "auth", "cli-secret") + try: + with open(secret_path, encoding="utf-8") as f: + cli_secret = f.read().strip() + except OSError: + cli_secret = "" + if not cli_secret: + cli_secret = secrets.token_hex(32) + try: + os.makedirs(os.path.dirname(secret_path), exist_ok=True) + # O_EXCL: if 9Router won the race and wrote first, read its value. + fd = os.open(secret_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, "w") as f: + f.write(cli_secret) + except FileExistsError: + with open(secret_path, encoding="utf-8") as f: + cli_secret = f.read().strip() + if not cli_secret: + return None + tok = hashlib.sha256( + (machine_id + "9r-cli-auth" + cli_secret).encode("utf-8") + ).hexdigest()[:16] + _cli_token_cache = tok + return tok + except Exception: + return None + + +def cli_auth_headers() -> dict[str, str]: + """`x-9r-cli-token` header for 9Router 0.4.x /api/* calls; empty dict on + 0.3.60 (no token), where the old auth-free endpoints still answer.""" + tok = cli_auth_token() + return {"x-9r-cli-token": tok} if tok else {} + + def _find_9router_dir() -> str | None: """Locate the bundled 9Router directory (works in both dev and packaged mode).""" _is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1" @@ -365,7 +440,7 @@ def stop(): async def get_usage_stats(period: str = "all") -> dict | None: """Get usage statistics from 9Router.""" try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/usage/stats", params={"period": period}) if r.status_code == 200: return r.json() @@ -391,7 +466,7 @@ async def get_latest_reasoning_tokens(model_hint: str | None = None) -> int | No if not is_running(): return None try: - async with httpx.AsyncClient(timeout=2.0) as client: + async with httpx.AsyncClient(timeout=2.0, headers=cli_auth_headers()) as client: params: dict[str, Any] = {"page": 1, "pageSize": 5} if model_hint: params["model"] = model_hint @@ -422,7 +497,7 @@ async def get_providers() -> list[dict]: unwrap so callers always see a plain list of connection dicts. """ try: - async with httpx.AsyncClient(timeout=5.0) as client: + async with httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/providers") if r.status_code == 200: data = r.json() diff --git a/backend/apps/nine_router/sync.py b/backend/apps/nine_router/sync.py index fe32ddab..f24053db 100644 --- a/backend/apps/nine_router/sync.py +++ b/backend/apps/nine_router/sync.py @@ -7,7 +7,7 @@ as OpenSwarm-managed apikey connections. Talks to the already-running import logging -from .process import NINE_ROUTER_API +from .process import NINE_ROUTER_API, cli_auth_headers logger = logging.getLogger(__name__) @@ -64,7 +64,7 @@ async def _sync_apikey_provider( existing = await _find_keyed_connection(provider, name) try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if api_key: payload = { "provider": provider, diff --git a/backend/apps/nine_router/sync_custom.py b/backend/apps/nine_router/sync_custom.py index 7696270c..d6439f3f 100644 --- a/backend/apps/nine_router/sync_custom.py +++ b/backend/apps/nine_router/sync_custom.py @@ -10,7 +10,7 @@ spawns the subprocess (that's process.py's job). import logging -from .process import NINE_ROUTER_API +from .process import NINE_ROUTER_API, cli_auth_headers from .sync import ( NINE_ROUTER_CLAUDE_PRO_NAME, NINE_ROUTER_OPENAI_KEYED_PREFIX, @@ -36,7 +36,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: managed_name = f"OpenAI{NINE_ROUTER_CUSTOM_NAME_SUFFIX}" try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] except Exception as e: @@ -50,7 +50,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: if not api_key: if existing_node: try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}") logger.info("9Router: removed OpenAI compat node (key cleared)") except Exception as e: @@ -66,7 +66,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: } node_id: str | None = existing_node.get("id") if existing_node else None try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if existing_node: await client.put( f"{NINE_ROUTER_API}/provider-nodes/{existing_node['id']}", @@ -100,7 +100,7 @@ async def _sync_openai_compat_node(api_key: str | None) -> None: "apiKey": api_key, "priority": 0, } - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if existing_conn: await client.patch( f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", @@ -161,7 +161,7 @@ async def sync_custom_providers(providers: list) -> None: return try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: r = await client.get(f"{NINE_ROUTER_API}/provider-nodes") existing_nodes = (r.json().get("nodes") if r.status_code == 200 else []) or [] except Exception as e: @@ -201,7 +201,7 @@ async def sync_custom_providers(providers: list) -> None: "type": "openai-compatible", } try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if node: await client.put( f"{NINE_ROUTER_API}/provider-nodes/{node['id']}", @@ -236,7 +236,7 @@ async def sync_custom_providers(providers: list) -> None: "apiKey": api_key, "priority": 0, } - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if existing_conn: await client.patch( f"{NINE_ROUTER_API}/providers/{existing_conn['id']}", @@ -259,7 +259,7 @@ async def sync_custom_providers(providers: list) -> None: if prefix in seen_prefixes: continue try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: await client.delete(f"{NINE_ROUTER_API}/provider-nodes/{node['id']}") logger.info(f"9Router: removed orphaned custom node {prefix}") except Exception as e: @@ -288,7 +288,7 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str # is the direct-API id. Use `anthropic`. existing = await _find_keyed_connection("anthropic", NINE_ROUTER_CLAUDE_PRO_NAME) try: - async with _nr().httpx.AsyncClient(timeout=5.0) as client: + async with _nr().httpx.AsyncClient(timeout=5.0, headers=cli_auth_headers()) as client: if bearer_token and proxy_url: payload = { "provider": "anthropic", From 235fa47d154ef1f14deca10c05826278dc19059a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 11:59:11 -0700 Subject: [PATCH 19/36] [eric] providers: openai own-key keeps cp-openai/ prefix so 9router can route it --- backend/apps/agents/providers/registry.py | 7 +++++++ backend/tests/test_v2_invariants.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 27f0c535..587b1ff7 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -244,6 +244,13 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: if entry.get("route") == "cc": return entry.get("router_model_id", entry.get("model_id", short_name)) if entry.get("route") == "api": + # OpenAI own-key still rides 9Router (the cp-openai node fixes max_tokens + # + translates Anthropic->OpenAI), so it MUST keep its cp-openai/ routing + # prefix or 9Router has no node to dispatch to. Anthropic own-key goes + # straight to api.anthropic.com and Gemini own-key via the local proxy, + # both on the bare id. + if entry.get("api") == "openai": + return entry.get("router_model_id", entry.get("model_id", short_name)) return entry.get("model_id", short_name) if entry.get("route") == "openrouter": return entry.get("router_model_id", short_name) diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 9842674e..f7c37a0c 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -479,6 +479,28 @@ async def test_resolve_aux_model_openrouter_priority_after_subs(): assert model_id == "cx/gpt-5.4-mini", f"got {model_id}" +def test_resolve_sdk_openai_own_key_keeps_cp_openai_prefix(): + """OpenAI own-key dispatch points the SDK at 9Router, which routes by + prefix to our cp-openai passthrough node; handing it the bare `gpt-5.5` + matched no node and silently dropped every request before it reached + OpenAI (0 requests on the dashboard). The resolver must keep the + cp-openai/ prefix for the openai route while Anthropic/Gemini own-key + stay on the bare id (they go direct / via the local proxy).""" + from backend.apps.agents.providers.registry import resolve_model_id_for_sdk + from backend.apps.settings.models import AppSettings + s = AppSettings() + s.openai_api_key = "sk-test" + for v, expected in ( + ("gpt-5.5-api", "cp-openai/gpt-5.5"), + ("gpt-5.4-api", "cp-openai/gpt-5.4"), + ("gpt-5.4-mini-api", "cp-openai/gpt-5.4-mini"), + ): + assert resolve_model_id_for_sdk(v, s) == expected, f"{v} -> {resolve_model_id_for_sdk(v, s)}" + # Non-OpenAI own-key lanes must NOT gain a 9Router prefix. + assert resolve_model_id_for_sdk("gemini-3.5-flash-api", s) == "gemini-3.5-flash" + assert resolve_model_id_for_sdk("opus-4-8-api", s) == "claude-opus-4-8" + + # =========================================================================== # Group E, 9Router-streamed 401 detection # =========================================================================== From 076c47617ac9fbd217ebacd4b080e010d979a1ee Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 12:36:29 -0700 Subject: [PATCH 20/36] [eric] gemini: prefer connected Antigravity over AI Studio key + fail-fast read timeout --- backend/apps/agents/providers/registry.py | 57 +++++++++++--------- backend/apps/agents/proxy/anthropic_proxy.py | 10 +++- backend/tests/test_v2_invariants.py | 24 +++++++++ 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 587b1ff7..619048e6 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -236,6 +236,26 @@ def get_api_type(short_name: str) -> str: return (entry or {}).get("api", "anthropic") +def _antigravity_connected() -> bool: + """True if a live Antigravity OAuth lane exists in 9Router. Synchronous + probe (this resolver is sync) with a tight timeout; any hiccup reads as + 'no' so a slow/absent 9Router never blocks model resolution for long.""" + try: + import httpx as _httpx + from backend.apps.nine_router.process import cli_auth_headers + r = _httpx.get("http://localhost:20128/api/providers", timeout=2.0, headers=cli_auth_headers()) + if r.status_code != 200: + return False + data = r.json() + conns = data.get("connections", []) if isinstance(data, dict) else (data if isinstance(data, list) else []) + return any( + isinstance(c, dict) and c.get("provider") == "antigravity" and c.get("isActive") + for c in conns + ) + except Exception: + return False + + def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: """Short model name → id string for ClaudeAgentOptions.""" entry = _find_builtin_model(short_name) @@ -263,18 +283,18 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: return entry.get("model_id", short_name) if getattr(settings, "anthropic_api_key", None): return entry.get("model_id", short_name) - # Gemini lane order: AI Studio apikey, Antigravity OAuth, Gemini CLI. - # AG bypasses the thoughtSignature validator that breaks multi-step tool - # turns on gc/. Without it, every Gemini turn 400s after the first tool - # call with "Thought signature is not valid". + # Gemini lane order: Antigravity OAuth (for the models it serves), then AI + # Studio apikey, then Gemini CLI. AG bypasses the thoughtSignature validator + # that breaks multi-step Gemini turns AND supports real reasoning, so a + # connected AG sub is preferred over the AI Studio key, which otherwise + # silently shadowed it. The map is AG's allowlist; pro variants 404/400 on + # AG and are deliberately absent, so they fall through to the key. _ANTIGRAVITY_MAP = { # gemini-3-pro-preview disabled: AG returns 404 even with active conn. # gemini-3.1-pro-preview disabled: AG's `gemini-3.1-pro-high` variant # 400s every request with "invalid argument" (the `-high` thinking- - # budget alias on AG requires a thinking_config the CLI doesn't - # emit). Falls through to gc/gemini-3.1-pro-preview, which works - # for non-tool turns; multi-step tool turns still hit the - # thoughtSignature validator but that's a separate fight. + # budget alias on AG requires a thinking_config the CLI doesn't emit). + # Falls through to the AI Studio key / gc/ instead. "gemini-3-flash-preview": "gemini-3-flash", "gemini-3.1-flash-lite-preview": "gemini-3-flash", } @@ -282,26 +302,11 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str: rid = entry.get("router_model_id", "") if isinstance(rid, str) and rid.startswith("gc/"): suffix = rid[len("gc/"):] + ag_suffix = _ANTIGRAVITY_MAP.get(suffix) + if ag_suffix and _antigravity_connected(): + return "ag/" + ag_suffix if getattr(settings, "google_api_key", None): return "gemini/" + suffix - ag_suffix = _ANTIGRAVITY_MAP.get(suffix) - if ag_suffix: - try: - import httpx as _httpx - r = _httpx.get("http://localhost:20128/api/providers", timeout=2.0) - if r.status_code == 200: - data = r.json() - conns = data.get("connections", []) if isinstance(data, dict) else (data if isinstance(data, list) else []) - has_ag = any( - isinstance(c, dict) - and c.get("provider") == "antigravity" - and c.get("isActive") - for c in conns - ) - if has_ag: - return "ag/" + ag_suffix - except Exception: - pass return entry.get("router_model_id", entry.get("model_id", short_name)) diff --git a/backend/apps/agents/proxy/anthropic_proxy.py b/backend/apps/agents/proxy/anthropic_proxy.py index b20f2049..5b324b2e 100644 --- a/backend/apps/agents/proxy/anthropic_proxy.py +++ b/backend/apps/agents/proxy/anthropic_proxy.py @@ -475,9 +475,15 @@ async def proxy(rest: str, request: Request): except Exception: pass + # Gemini (especially the AI Studio key) intermittently 503s and 9Router holds + # the retry, which hangs the whole turn for the full read window. Bound Gemini + # so a stalled first response fails fast (~2 min) instead of stalling ~10 min; + # other providers keep the generous window for long reasoning turns. + _read_timeout = 120.0 if _is_gemini_model(model) else 600.0 + try: if wants_stream: - client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) + client = httpx.AsyncClient(timeout=httpx.Timeout(_read_timeout, connect=30.0)) req = client.build_request( request.method, url, content=body, headers=forward_headers, params=dict(request.query_params), @@ -501,7 +507,7 @@ async def proxy(rest: str, request: Request): media_type=upstream.headers.get("content-type", "text/event-stream"), ) else: - async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) as client: + async with httpx.AsyncClient(timeout=httpx.Timeout(_read_timeout, connect=30.0)) as client: r = await client.request( request.method, url, content=body, headers=forward_headers, params=dict(request.query_params), diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index f7c37a0c..f5fa1dd6 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -501,6 +501,30 @@ def test_resolve_sdk_openai_own_key_keeps_cp_openai_prefix(): assert resolve_model_id_for_sdk("opus-4-8-api", s) == "claude-opus-4-8" +def test_resolve_sdk_gemini_prefers_antigravity_over_api_key(): + """A connected Antigravity sub must win over the AI Studio key for the + models AG serves (flash) since AG bypasses the thoughtSignature validator; + pro variants aren't AG-serveable so they fall back to the key. Before this, + the key was checked first and silently shadowed a connected AG sub (user had + AG connected but 100% of Gemini traffic still went through the key).""" + from backend.apps.agents.providers import registry + from backend.apps.settings.models import AppSettings + s = AppSettings() + s.google_api_key = "ai-studio-key" + with patch.object(registry, "_antigravity_connected", return_value=True): + # flash IS AG-serveable -> AG wins over the key + assert registry.resolve_model_id_for_sdk("gemini-3-flash", s) == "ag/gemini-3-flash" + # pro is NOT AG-serveable (404/400 on AG) -> falls back to the key + assert registry.resolve_model_id_for_sdk("gemini-3.1-pro", s) == "gemini/gemini-3.1-pro-preview" + with patch.object(registry, "_antigravity_connected", return_value=False): + # AG not connected -> key + assert registry.resolve_model_id_for_sdk("gemini-3-flash", s) == "gemini/gemini-3-flash-preview" + # No key, no AG -> gc/ subscription lane untouched + s2 = AppSettings() + with patch.object(registry, "_antigravity_connected", return_value=False): + assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview" + + # =========================================================================== # Group E, 9Router-streamed 401 detection # =========================================================================== From 62acc4b8c06a574280253fe89a9f040bd1ce4e3d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 13:44:22 -0700 Subject: [PATCH 21/36] [eric] models: drop banned Claude Fable + unrunnable Gemini 3.1 Pro from the picker --- backend/apps/agents/providers/registry.py | 21 +++++---------------- backend/tests/test_v2_invariants.py | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 619048e6..3b1f0d17 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -66,14 +66,8 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { {"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000, "model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"}, - # Fable 5 (released 2026-05-28): new flagship tier ABOVE Opus, 1M ctx, - # 128k out, $10/$50. The cc/ sub row is on trial: brand-new ids have 404'd - # our pinned 9Router 0.3.60 before (GPT-5.5's cx entry did) and Claude-sub - # serving of Fable is unverified, so pull this row if it errors live. - {"value": "fable-5-cc", "label": "Claude Fable 5", "context_window": 1_000_000, - "model_id": "claude-fable-5", "router_model_id": "cc/claude-fable-5", "api": "anthropic", "reasoning": True, "route": "cc"}, - {"value": "fable-5-api", "label": "Claude Fable 5 (API key)", "context_window": 1_000_000, - "model_id": "claude-fable-5", "router_model_id": "claude-fable-5", "api": "anthropic", "reasoning": True, "route": "api"}, + # Fable 5 pulled: the model got banned, so both its cc/ sub and api-key + # rows are gone. Don't re-add without confirming access is restored. {"value": "opus-4-8-api", "label": "Claude Opus 4.8 (API key)", "context_window": 1_000_000, "model_id": "claude-opus-4-8", "router_model_id": "claude-opus-4-8", "api": "anthropic", "reasoning": True, "route": "api"}, {"value": "opus-4-7-api", "label": "Claude Opus 4.7 (API key)", "context_window": 1_000_000, @@ -128,9 +122,9 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { # allowlists (every other shipped Gemini sub model IS in 0.3.60), so gc/ # gemini-3.5-flash would 404. Re-add the gc/ entry once 9Router is bumped # past 0.3.60 (gated by the WebSearch-translation regression; see CLAUDE.md). - {"value": "gemini-3.1-pro", "label": "Gemini 3.1 Pro", - "context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-pro-preview", - "api": "gemini-cli", "subscription_only": True, "reasoning": True}, + # gemini-3.1-pro pulled (both sub + api-key rows): Antigravity can't serve + # it (its -high variant 400s) and the AI Studio key 429s pro-preview hard, + # so it had no working lane and only sold a dead option. {"value": "gemini-3.1-flash-lite", "label": "Gemini 3.1 Flash Lite", "context_window": 1_000_000, "router_model_id": "gc/gemini-3.1-flash-lite-preview", "api": "gemini-cli", "subscription_only": True, "reasoning": True}, @@ -144,9 +138,6 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { {"value": "gemini-3.5-flash-api", "label": "Gemini 3.5 Flash (API key)", "context_window": 1_000_000, "router_model_id": "gemini-3.5-flash", "model_id": "gemini-3.5-flash", "api": "gemini", "reasoning": True, "route": "api"}, - {"value": "gemini-3.1-pro-api", "label": "Gemini 3.1 Pro (API key)", - "context_window": 1_000_000, "router_model_id": "gemini-3.1-pro-preview", "model_id": "gemini-3.1-pro-preview", - "api": "gemini", "reasoning": True, "route": "api"}, {"value": "gemini-3.1-flash-lite-api", "label": "Gemini 3.1 Flash Lite (API key)", "context_window": 1_000_000, "router_model_id": "gemini-3.1-flash-lite-preview", "model_id": "gemini-3.1-flash-lite-preview", "api": "gemini", "reasoning": True, "route": "api"}, @@ -426,7 +417,6 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = { ("Anthropic", "opus"): (5.0, 25.0), ("Anthropic", "opus-4-7"): (5.0, 25.0), ("Anthropic", "opus-4-8"): (5.0, 25.0), - ("Anthropic", "fable-5-api"): (10.0, 50.0), ("Anthropic", "haiku"): (1.0, 5.0), # OpenAI; Codex subscription path, user pays nothing per token ("OpenAI", "gpt-5.5"): (0.0, 0.0), @@ -434,7 +424,6 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = { ("OpenAI", "gpt-5.4-mini"): (0.0, 0.0), # Google; Gemini CLI subscription path, user pays nothing per token ("Google", "gemini-3.5-flash"): (0.0, 0.0), - ("Google", "gemini-3.1-pro"): (0.0, 0.0), ("Google", "gemini-3.1-flash-lite"): (0.0, 0.0), ("Google", "gemini-3-flash"): (0.0, 0.0), ("Google", "gemini-2.5-pro"): (0.0, 0.0), diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index f5fa1dd6..6b7f0bf3 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -514,8 +514,6 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key(): with patch.object(registry, "_antigravity_connected", return_value=True): # flash IS AG-serveable -> AG wins over the key assert registry.resolve_model_id_for_sdk("gemini-3-flash", s) == "ag/gemini-3-flash" - # pro is NOT AG-serveable (404/400 on AG) -> falls back to the key - assert registry.resolve_model_id_for_sdk("gemini-3.1-pro", s) == "gemini/gemini-3.1-pro-preview" with patch.object(registry, "_antigravity_connected", return_value=False): # AG not connected -> key assert registry.resolve_model_id_for_sdk("gemini-3-flash", s) == "gemini/gemini-3-flash-preview" @@ -525,6 +523,20 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key(): assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview" +def test_banned_models_not_offered(): + """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve + it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a + refactor can't silently re-list a model that can't run.""" + from backend.apps.agents.providers.registry import BUILTIN_MODELS + all_values = {m["value"] for models in BUILTIN_MODELS.values() for m in models} + for dead in ("fable-5-cc", "fable-5-api", "gemini-3.1-pro", "gemini-3.1-pro-api"): + assert dead not in all_values, f"{dead} is back in the picker" + # No 'fable' or '3.1 pro' label survives in any provider group either. + all_labels = " | ".join(m["label"].lower() for models in BUILTIN_MODELS.values() for m in models) + assert "fable" not in all_labels + assert "3.1 pro" not in all_labels + + # =========================================================================== # Group E, 9Router-streamed 401 detection # =========================================================================== From 578a31fdd45ca8e6c3f0ed796b7af78cb4238ecc Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 13:54:40 -0700 Subject: [PATCH 22/36] [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks --- .gitleaks.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 5437f1d5..37ec358f 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,6 +15,9 @@ description = "Project allowlist for gitleaks" # still trip. paths = [ '''frontend/src/app/pages/Settings/Settings\.tsx''', + # CDP-redaction test: its fixtures are deliberately fake `token=...` URLs it + # then asserts get redacted, so a full-history rescan keeps "finding" them. + '''electron/cdp-routes\.test\.js''', # Vendored Python venv — never our code. '''backend/\.venv/.*''', # Vendored MCP server bundles (esbuild output of upstream npm packages). From 2d82b13a1a57ebebb3f96388b842955ebc68fbc0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 13:54:40 -0700 Subject: [PATCH 23/36] [eric] ci: gitleaks fetches full main so renames scan incrementally not full-history --- .github/workflows/gitleaks.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index ef2e02de..f663a874 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -48,11 +48,13 @@ jobs: set -euo pipefail BEFORE="${{ github.event.before }}" AFTER="${{ github.sha }}" - # New-branch push: GH sends 40 zeros for `before`. Diff against - # main's merge-base instead so we only scan commits unique to the - # branch — fast and matches the gitleaks-action default. + # New-branch push (incl. a branch rename): GH sends 40 zeros for + # `before`. Diff against main's merge-base so we only scan commits + # unique to the branch. Fetch main at FULL depth, not --depth=1: a + # shallow main can't reach the fork point of a far-behind branch, so + # merge-base comes back empty and we'd full-rescan all of history. if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then - git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main 2>/dev/null || true + git fetch --no-tags origin main:refs/remotes/origin/main 2>/dev/null || true if git rev-parse --verify origin/main >/dev/null 2>&1; then BEFORE=$(git merge-base origin/main "$AFTER" 2>/dev/null || echo "") fi From c43da8950e2c455a913acea9569201d75edd7215 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 13:59:11 -0700 Subject: [PATCH 24/36] [eric] swarm: scan workspace file bytes for secrets on export, not just payload keys --- backend/apps/swarm/redact.py | 18 ++++++++++++++++++ backend/apps/swarm/ziputil.py | 8 +++++++- backend/tests/test_swarm_bundle.py | 13 +++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/apps/swarm/redact.py b/backend/apps/swarm/redact.py index 1a97d20c..cc39b063 100644 --- a/backend/apps/swarm/redact.py +++ b/backend/apps/swarm/redact.py @@ -79,3 +79,21 @@ def find_denied_keys(value: Any, _path: str = "") -> list[str]: for i, v in enumerate(value): found.extend(find_denied_keys(v, f"{_path}[{i}]")) return found + + +def _looks_secret(text: str) -> bool: + return any(pat.search(text) for pat in _CONTENT_PATTERNS) + + +def find_secrets_in_files(files: dict[str, bytes]) -> list[str]: + """Paths of any file whose text body holds a secret-shaped literal. Payloads + get scrubbed key-and-content, but raw workspace files (an app's source) were + only key-scanned, so a key hardcoded in a .js would slip. Binary files are + skipped (a null byte means it isn't text someone pasted a token into).""" + hits: list[str] = [] + for path, data in files.items(): + if b"\x00" in data[:4096]: + continue + if _looks_secret(data.decode("utf-8", errors="ignore")): + hits.append(path) + return hits diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index b4682695..bdf1aa5c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -12,7 +12,7 @@ import shutil import tempfile import zipfile -from .redact import find_denied_keys +from .redact import find_denied_keys, find_secrets_in_files MANIFEST_NAME = "manifest.json" @@ -46,6 +46,12 @@ def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) + leaky_files = find_secrets_in_files(files) + if leaky_files: + raise BundleError( + f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " + "remove it (use an environment variable) and try again" + ) entries: dict[str, bytes] = {} for bid, payload in payloads.items(): entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index dcb5b733..66fc20a4 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -99,6 +99,19 @@ def test_pack_refuses_denied_key(): pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}) +def test_pack_refuses_secret_in_workspace_file(): + # A key hardcoded in app source (not .env) must not ride along; pack scans + # file bytes, not just payload keys. + leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n" + with pytest.raises(BundleError): + pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}) + + +def test_pack_allows_clean_workspace_file(): + raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/app.js": b"export default 1"}) + assert zipfile.is_zipfile(io.BytesIO(raw)) + + 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 ad1a9b439e015db9162f9b1d93788aa124492258 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:00:19 -0700 Subject: [PATCH 25/36] [eric] swarm: validate manifest structure at stage time (root/dup-id/edge integrity, outside checksum) --- backend/apps/swarm/closure.py | 21 ++++++++++++++++++ backend/tests/test_swarm_bundle.py | 34 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index c7e7054d..ef15cfac 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -170,6 +170,26 @@ def swarm_filename(name: str) -> str: # ---------- import: staging ---------- +def validate_manifest(manifest: Manifest) -> None: + """Structural integrity of the untrusted part of a .swarm. The checksum + covers entity payloads + files but NOT the manifest itself, so an attacker + can rewrite root/edges/paths freely; catch the breakages that would import + silently wrong (a root pointing nowhere, a duplicate id that drops an + entity, an edge or path that doesn't resolve inside the bundle).""" + seen: set[str] = set() + for e in manifest.entities: + if e.bundle_id in seen: + raise BundleError("bundle manifest has duplicate entity ids") + seen.add(e.bundle_id) + if not e.path.startswith("entities/") or ".." in e.path.split("/"): + raise BundleError("bundle manifest has an out-of-tree entity path") + if manifest.root.bundle_id not in seen: + raise BundleError("bundle manifest root is not one of its entities") + for edge in manifest.edges: + if edge.from_ not in seen or edge.to not in seen: + raise BundleError("bundle manifest has an edge to an unknown entity") + + def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]: warnings: list[str] = [] if is_zip(raw): @@ -179,6 +199,7 @@ def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]: raw_manifest = read_manifest(sandbox) verify_checksum(sandbox, raw_manifest) manifest = Manifest(**raw_manifest) + validate_manifest(manifest) except BundleError: shutil.rmtree(sandbox, ignore_errors=True) raise diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 66fc20a4..1b997fd5 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -280,6 +280,40 @@ def test_commit_rolls_back_created_on_failure(skill_store, tmp_path): assert not (skill_store / "rollme.md").exists() +def test_manifest_duplicate_ids_rejected(): + # Two entities sharing a bundle_id silently collapse in the topo/summary + # dicts, dropping one; reject up front. (The manifest is outside the checksum.) + from backend.apps.swarm.closure import validate_manifest + from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest + ref = EntityRef(type=EntityType.skill, bundle_id="dup", name="A", path="entities/dup") + m = Manifest(bundle_id="b", root=ref, entities=[ref, ref], + preview=BundlePreview(root_type=EntityType.skill, root_name="A")) + with pytest.raises(BundleError): + validate_manifest(m) + + +def test_manifest_root_not_in_entities_rejected(): + from backend.apps.swarm.closure import validate_manifest + from backend.apps.swarm.models import BundlePreview, EntityRef, Manifest + root = EntityRef(type=EntityType.skill, bundle_id="root", name="A", path="entities/root") + other = EntityRef(type=EntityType.skill, bundle_id="other", name="B", path="entities/other") + m = Manifest(bundle_id="b", root=root, entities=[other], + preview=BundlePreview(root_type=EntityType.skill, root_name="A")) + with pytest.raises(BundleError): + validate_manifest(m) + + +def test_manifest_edge_to_unknown_entity_rejected(): + from backend.apps.swarm.closure import validate_manifest + from backend.apps.swarm.models import BundlePreview, DependencyEdge, EntityRef, Manifest + ref = EntityRef(type=EntityType.dashboard, bundle_id="d", name="D", path="entities/d") + m = Manifest(bundle_id="b", root=ref, entities=[ref], + edges=[DependencyEdge(**{"from": "d", "to": "ghost"})], + preview=BundlePreview(root_type=EntityType.dashboard, root_name="D")) + with pytest.raises(BundleError): + validate_manifest(m) + + def _zip_with(name, data=b"x"): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From 2bb5ee05f6dc8f2c8228fd87a0d9f621b124b61f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:01:50 -0700 Subject: [PATCH 26/36] [eric] swarm: generative dashboard remap-invariant test + symlink unpack rejection --- backend/tests/test_swarm_bundle.py | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 1b997fd5..fea9408c 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -230,6 +230,73 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch): assert L["expanded_session_ids"] == ["newsess"] # the dangling ref is dropped +def test_dashboard_remap_invariant_generative(monkeypatch): + # The hand-written remap tests only check the id-bearing fields I remembered. + # Generate random dashboards and assert the real invariant on a serialize -> + # import round-trip: no source-local id and no bundle id survives into the + # imported layout, and every card id is a freshly-minted local id. This is + # what catches "someone adds a new layout field holding a session id and + # forgets to remap it." + import random + + from backend.apps.swarm.entities import dashboards as dmod + from backend.apps.swarm.exportable import RemapTable + from backend.apps.swarm.models import EntityType + + written: dict = {} + monkeypatch.setattr(dmod, "_write", lambda did, doc: written.update({did: doc})) + monkeypatch.setattr(dmod, "_retag_sessions", lambda ids, did: None) + + rng = random.Random(1234) + for _ in range(60): + sess = [f"S{i}" for i in range(rng.randint(0, 5))] + apps = [f"A{i}" for i in range(rng.randint(0, 4))] + s_bid = {s: f"sbid{i}" for i, s in enumerate(sess)} + a_bid = {a: f"abid{i}" for i, a in enumerate(apps)} + + class Ctx: + def bundle_id_for(self, t, lid): + if t == EntityType.session: + return s_bid.get(lid) + if t == EntityType.app: + return a_bid.get(lid) + return None + + layout = { + "cards": {s: {"session_id": s, "x": rng.randint(0, 9)} for s in sess}, + "view_cards": {a: {"output_id": a} for a in apps}, + "browser_cards": { + f"b{i}": {"browser_id": f"b{i}", "url": "u", + "spawned_by": (rng.choice(sess) if sess and rng.random() < 0.7 else None)} + for i in range(rng.randint(0, 3)) + }, + "expanded_session_ids": (sess + ["ORPHAN"]) if rng.random() < 0.5 else list(sess), + } + payload = dmod.DashboardExportable("d-src", "D", {"name": "D", "layout": layout}).serialize(Ctx()) + + remap = RemapTable() + fresh_sess = {s: f"new-{s_bid[s]}" for s in sess} + fresh_apps = {a: f"new-{a_bid[a]}" for a in apps} + for s in sess: + remap.assign(s_bid[s], fresh_sess[s]) + for a in apps: + remap.assign(a_bid[a], fresh_apps[a]) + + did = dmod.DashboardExportable.import_(payload, {}, remap) + L = written[did]["layout"] + + forbidden = set(sess) | set(apps) | set(s_bid.values()) | set(a_bid.values()) + assert set(L["cards"]) == set(fresh_sess.values()) + assert set(L["view_cards"]) == set(fresh_apps.values()) + for cid, card in L["cards"].items(): + assert cid not in forbidden and card["session_id"] == cid + for oid, card in L["view_cards"].items(): + assert oid not in forbidden and card["output_id"] == oid + assert set(L["expanded_session_ids"]) <= set(fresh_sess.values()) + for card in L["browser_cards"].values(): + assert card["spawned_by"] is None or card["spawned_by"] in set(fresh_sess.values()) + + def test_checksum_rejects_tampering(skill_store): _make_skill(skill_store, "tmp", "Tmp", "# original") raw, _ = closure.build_bundle(EntityType.skill, "tmp") @@ -331,6 +398,18 @@ def test_absolute_path_rejected(): unpack(_zip_with("/etc/evil")) +def test_symlink_entry_rejected(): + # A symlink entry could point outside the sandbox once followed; unpack must + # refuse it before writing anything. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zi = zipfile.ZipInfo("link") + zi.external_attr = 0o120777 << 16 + zf.writestr(zi, "/etc/passwd") + with pytest.raises(BundleError): + unpack(buf.getvalue()) + + def test_too_many_entries_rejected(): buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: From ad00fd19aedc68770db152c3d96b004c14fba6cb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:05:21 -0700 Subject: [PATCH 27/36] [eric] error-classify: schema-translation 400s aren't auth; gemini RESOURCE_EXHAUSTED is transient --- backend/apps/agents/core/error_classify.py | 35 ++++++++++++++++++++++ backend/tests/test_v2_invariants.py | 27 +++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index d3ad8074..3a5d4aea 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -12,10 +12,30 @@ _TRANSIENT_CAPACITY_PATTERNS = re.compile( r"|internal\s+server\s+error" r"|rate[_\s-]?limit(?:_error)?" r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed" + r"|resource[_\s-]?exhausted" r"|upstream\s+connect\s+error)", re.IGNORECASE, ) +# A first message ships the full tool schema; 9Router rewrites Anthropic +# tools[].input_schema into Gemini function_declarations / OpenAI params, and a +# construct it can't translate makes the provider 400 (INVALID_ARGUMENT) with +# zero tokens. That is NOT auth, reconnecting won't help, the request shape is +# wrong, so we classify it apart and stop the catch-all from showing a +# "reconnect your subscription" card for a tool-schema 400. +_TRANSLATION_ERROR_PATTERNS = re.compile( + r"(?:function_declarations" + r"|invalid_argument" + r"|invalid\s+json\s+payload" + r"|unknown\s+name\b" + r"|cannot\s+find\s+field" + r"|proto\s+field" + r"|input_schema" + r"|\btools\[\d+\]" + r")", + re.IGNORECASE, +) + # Patterns that look rate-limit-ish but are actually non-transient (user quota, # auth, context-window tier gate). Must NOT retry, upgrading, reauthing, or # trimming context is required. The long-context-required variant is what @@ -69,6 +89,17 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool: )) +def _is_translation_error(exc: BaseException, extra_text: str = "") -> bool: + """True when the upstream 400 is a tool-schema / protocol translation + failure (9Router rewriting Anthropic tools into Gemini function_declarations + or OpenAI params), not auth or capacity. Kept distinct so the catch-all + stops mislabeling a schema 400 as an expired-subscription reconnect card.""" + combined = f"{exc!s}\n{extra_text}".strip() + if not combined: + return False + return bool(_TRANSLATION_ERROR_PATTERNS.search(combined)) + + def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: """True when the upstream error is a 401/403 auth failure. @@ -80,6 +111,10 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool: combined = f"{exc!s}\n{extra_text}".strip() if not combined: return False + # A tool-schema translation 400 can carry provider/connection wording that + # trips the auth regex below; it isn't auth, so don't claim it is. + if _is_translation_error(exc, extra_text): + return False return bool(re.search( r"\b(401|403)\b" r"|invalid\s+authentication\s+credentials" diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6b7f0bf3..51d146ed 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -523,6 +523,33 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key(): assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview" +def test_error_classify_schema_translation_400_is_not_auth(): + """A 9Router tool-schema translation 400 can carry provider/connection + wording that trips the auth regex, so it used to surface a misleading + 'reconnect your subscription' card for what is really a schema bug. The + translation guard must win: schema 400 -> not auth; a real auth failure + with no translation signature still reads as auth.""" + from backend.apps.agents.core.error_classify import _is_auth_error, _is_translation_error + both = Exception("provider not connected: 400 INVALID_ARGUMENT at " + "tools[0].function_declarations[0].parameters") + assert _is_translation_error(both) + assert not _is_auth_error(both), "schema-400 must not be classified as auth" + # Pure auth failures (no translation signature) still classify as auth. + assert _is_auth_error(Exception("provider not connected: gemini")) + assert _is_auth_error(Exception("401 invalid authentication credentials")) + assert not _is_translation_error(Exception("401 invalid authentication credentials")) + + +def test_error_classify_gemini_resource_exhausted_is_transient(): + """gemini-cli's free-tier 429 surfaces as RESOURCE_EXHAUSTED; it must count + as transient so the existing backoff/retry catches it instead of dying as a + hard first-message error. A 403 (hard auth/quota) must still NOT retry.""" + from backend.apps.agents.core.error_classify import _is_transient_capacity_error + assert _is_transient_capacity_error(Exception("429 RESOURCE_EXHAUSTED: Quota exceeded")) + assert _is_transient_capacity_error(Exception("RESOURCE_EXHAUSTED")) + assert not _is_transient_capacity_error(Exception("403 permission denied")) + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a From bd73d498285c727bc2dd5238b06c48625104e896 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:22:48 -0700 Subject: [PATCH 28/36] [eric] mcp-gate: property-test the activation gate (forwarded => activated) + Z3 formal proof --- backend/tests/formal/README.md | 20 +++++++ backend/tests/formal/mcp_gate_proof.py | 83 ++++++++++++++++++++++++++ backend/tests/test_v2_invariants.py | 44 ++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 backend/tests/formal/README.md create mode 100644 backend/tests/formal/mcp_gate_proof.py diff --git a/backend/tests/formal/README.md b/backend/tests/formal/README.md new file mode 100644 index 00000000..5bb7cf13 --- /dev/null +++ b/backend/tests/formal/README.md @@ -0,0 +1,20 @@ +# Formal proofs + +Machine-checked proofs of safety/security invariants that the unit/property +tests can only *sample*. A property test tries thousands of cases; an SMT proof +is exhaustive over the modeled domain (assert the negation, `unsat` => theorem). + +Not wired into prod or CI, and excluded from the packaged build (under `tests/`). +Run manually: + +``` +pip install z3-solver +python backend/tests/formal/mcp_gate_proof.py +``` + +- **`mcp_gate_proof.py`** , the MCP dispatch-gate invariant (`agent_manager._build_mcp_servers`): + a gated session forwards a server *only if* it was activated, an empty + activation list forwards zero, and a denied server is never forwarded. Sampled + by `tests/test_v2_invariants.py::test_mcp_gate_only_forwards_activated_servers`; + proven for all inputs here. The script also refutes a deliberately-buggy gate + (activation check dropped) so the proof can't be vacuous. diff --git a/backend/tests/formal/mcp_gate_proof.py b/backend/tests/formal/mcp_gate_proof.py new file mode 100644 index 00000000..6bf03dc6 --- /dev/null +++ b/backend/tests/formal/mcp_gate_proof.py @@ -0,0 +1,83 @@ +"""Formal proof (Z3 / SMT) of the MCP dispatch-gate security invariant. + +The product rule "MCP tools are reachable only after MCPActivate" is enforced at +dispatch in agent_manager._build_mcp_servers: for a gated session a server is +forwarded to the model only if its sanitized name is in session.active_mcps. + +tests/test_v2_invariants.py::test_mcp_gate_only_forwards_activated_servers +SAMPLES that contract (400 random cases). This SMT proof is exhaustive over the +modeled domain: we assert the negation of each property and ask Z3 for a +counterexample. `unsat` means none can exist, so the property is a theorem, +true for every possible input, not just the ones a test happened to try. + +Not wired into prod or CI. Run manually: + pip install z3-solver && python backend/tests/formal/mcp_gate_proof.py +""" + +from z3 import And, Bool, Implies, Not, Or, Solver, sat, unsat + + +def forwarded(installed, allowed, denied, active_is_none, active_t): + """Faithful model of the gate decision for one arbitrary server `t` + (agent_manager.py:165-203). A server ships to the model iff it is an + installed+configured MCP tool, passes the permission gate, isn't fully + denied, and EITHER the session is legacy (active_mcps is None) OR the + server is in active_mcps. Proving it for an arbitrary symbolic `t` proves + it for all servers.""" + return And(installed, allowed, Not(denied), Or(active_is_none, active_t)) + + +def buggy_forwarded(installed, allowed, denied, active_is_none, active_t): + """The same gate with the activation check dropped, used to show the proof + has teeth: Z3 must be able to refute the no-leak property for this variant.""" + return And(installed, allowed, Not(denied)) + + +def prove(name: str, claim) -> bool: + """`claim` should be valid (true for every input). Proven by showing its + negation is unsatisfiable.""" + s = Solver() + s.add(Not(claim)) + if s.check() == unsat: + print(f" PROVED: {name}") + return True + print(f" FAILED: {name} counterexample: {s.model()}") + return False + + +def main() -> None: + installed = Bool("installed") + allowed = Bool("allowed") + denied = Bool("denied") + active_is_none = Bool("active_is_none") # legacy session (no activation gate) + active_t = Bool("active_t") # server t is in active_mcps + fwd = forwarded(installed, allowed, denied, active_is_none, active_t) + gated = Not(active_is_none) + + print("Proving MCP dispatch-gate invariants (exhaustive over all inputs):") + ok = True + # A. No leak: a gated session never forwards a non-activated server. + ok &= prove("gated => (forwarded(t) -> activated(t))", + Implies(And(gated, fwd), active_t)) + # B. Empty activation => zero servers (no t is active, so none ship). + ok &= prove("gated & !activated(t) => !forwarded(t)", + Implies(And(gated, Not(active_t)), Not(fwd))) + # C. The permission gate still binds: a denied server is never forwarded. + ok &= prove("denied(t) => !forwarded(t)", Implies(denied, Not(fwd))) + + # Teeth: the buggy gate (activation check dropped) MUST be refutable, else + # the proof above would be vacuous. + print("Sanity-checking the proof has teeth (a buggy gate must be refuted):") + bug = buggy_forwarded(installed, allowed, denied, active_is_none, active_t) + s = Solver() + s.add(Not(Implies(And(gated, bug), active_t))) + assert s.check() == sat, "buggy gate should leak but Z3 couldn't refute it" + print(f" REFUTED (as expected): a gate without the activation check leaks; " + f"counterexample = {s.model()}") + + print("\nALL GATE PROPERTIES PROVED" if ok else "\nPROOF FAILED") + raise SystemExit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 51d146ed..6ea61908 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -550,6 +550,50 @@ def test_error_classify_gemini_resource_exhausted_is_transient(): assert not _is_transient_capacity_error(Exception("403 permission denied")) +@pytest.mark.asyncio +async def test_mcp_gate_only_forwards_activated_servers(): + """Dispatch-layer security invariant (the non-bypassable enforcement of + 'MCP tools only via MCPActivate'): for a GATED session (active_mcps is a + list), _build_mcp_servers forwards ONLY servers whose sanitized name is in + active_mcps; an empty list forwards ZERO; None is the legacy all-allowed + path. The model cannot reach an unactivated server no matter what it asks + for. Property-checked over random installed sets and random activation + subsets, plus the two boundary cases.""" + import random + from types import SimpleNamespace + from backend.apps.agents.agent_manager import AgentManager + mgr = AgentManager() + names = ["gmail", "drive", "slack", "reddit", "notion", "airtable"] + + def installed(): + return [SimpleNamespace(name=n, mcp_config={"x": 1}, enabled=True, + auth_status="configured", auth_type="apikey") for n in names] + + # allowed_tools == get_all_tool_names() bypasses the (separate) permission + # gate so we isolate the ACTIVATION gate. _sanitize_server_name -> identity. + with patch("backend.apps.agents.agent_manager.load_all_tools", side_effect=installed), \ + patch("backend.apps.agents.agent_manager.get_all_tool_names", return_value=["__ALL__"]), \ + patch("backend.apps.agents.agent_manager._sanitize_server_name", side_effect=lambda n: n), \ + patch("backend.apps.agents.agent_manager._is_fully_denied", return_value=False), \ + patch("backend.apps.agents.agent_manager.derive_mcp_config", side_effect=lambda t: {"command": "x"}): + allowed = ["__ALL__"] + # Boundary 1: empty activation list -> zero servers, always. + assert await mgr._build_mcp_servers(allowed, active_mcps=[]) == {} + # Boundary 2: None (legacy) -> permission gate only, all forwarded. + assert set((await mgr._build_mcp_servers(allowed, active_mcps=None)).keys()) == set(names) + # Property: forwarded set is ALWAYS a subset of the activated set, and + # equals exactly the activated-and-installed intersection. + rng = random.Random(1234) + for _ in range(400): + active = rng.sample(names, rng.randint(0, len(names))) + # throw in a bogus name the gate must never invent a server for + if rng.random() < 0.3: + active = active + ["ghost-not-installed"] + forwarded = set((await mgr._build_mcp_servers(allowed, active_mcps=active)).keys()) + assert forwarded <= set(active), f"leaked {forwarded - set(active)} for active={active}" + assert forwarded == (set(active) & set(names)), f"mismatch for active={active}" + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a From 46e92c9fe08b4f7976f59b2264c62b2e39e0197c Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:34:16 -0700 Subject: [PATCH 29/36] [eric] dashboards: drop orphan session cards from GET so a dead-session card stops 404ing on load --- backend/apps/dashboards/dashboards.py | 37 ++++++++++++++++++++++++++- backend/tests/test_v2_invariants.py | 27 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 81dbf0c1..f5fc346d 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -363,10 +363,45 @@ async def generate_name(dashboard_id: str): return {"name": dashboard.name, "auto_named": True} +def _strip_orphan_session_cards(data: dict) -> None: + """Drop layout cards (and expanded ids) whose agent session no longer exists + anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and + GETs its session; a card pointing at a vanished session (e.g. an empty + never-saved session) 404s on every load and flashes a dead "connect a model" + card before the client reconciles it away. The `gone()` test is the exact + condition that makes GET /sessions/{id} 404, so it removes precisely those + cards and nothing else. Filtering the RESPONSE (never the stored file) is + non-destructive: a wrong check can only hide a card for one response, not + delete it. Drafts have no backend session yet, so they're always kept.""" + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.manager.session.session_store import _load_session_data + layout = data.get("layout") + if not isinstance(layout, dict): + return + cards = layout.get("cards") + if not isinstance(cards, dict): + return + + def gone(sid: str) -> bool: + if sid.startswith("draft-") or sid in agent_manager.sessions: + return False + return _load_session_data(sid) is None + + orphans = [sid for sid in cards if gone(sid)] + for sid in orphans: + cards.pop(sid, None) + if orphans: + exp = layout.get("expanded_session_ids") + if isinstance(exp, list): + layout["expanded_session_ids"] = [s for s in exp if s not in orphans] + + @dashboards.router.get("/{dashboard_id}") async def get_dashboard(dashboard_id: str): dashboard = _load(dashboard_id) - return dashboard.model_dump(mode="json") + data = dashboard.model_dump(mode="json") + _strip_orphan_session_cards(data) + return data @dashboards.router.put("/{dashboard_id}") diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6ea61908..d9ba5094 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -594,6 +594,33 @@ async def test_mcp_gate_only_forwards_activated_servers(): assert forwarded == (set(active) & set(names)), f"mismatch for active={active}" +def test_dashboard_get_strips_only_orphan_session_cards(): + """A layout card whose session vanished (gone from memory AND disk) makes the + frontend GET /sessions/{id} 404 on every load and flash a dead card. The + dashboard GET filters those orphan cards out of the response, but must keep + live (in-memory) cards, on-disk cards, and drafts. Non-destructive: only the + response is filtered, never the stored layout.""" + from types import SimpleNamespace + from backend.apps.dashboards import dashboards as D + data = {"layout": { + "cards": { + "live": {"session_id": "live"}, # in memory + "ondisk": {"session_id": "ondisk"}, # closed but on disk + "draft-1": {"session_id": "draft-1"}, # unsent draft, no backend session yet + "ghost": {"session_id": "ghost"}, # gone from memory AND disk -> would 404 + }, + "expanded_session_ids": ["live", "ghost"], + }} + fake_mgr = SimpleNamespace(sessions={"live": object()}) + on_disk = {"ondisk": {"id": "ondisk"}} + with patch("backend.apps.agents.agent_manager.agent_manager", fake_mgr), \ + patch("backend.apps.agents.manager.session.session_store._load_session_data", + side_effect=lambda sid: on_disk.get(sid)): + D._strip_orphan_session_cards(data) + assert set(data["layout"]["cards"].keys()) == {"live", "ondisk", "draft-1"}, "only the ghost should be dropped" + assert data["layout"]["expanded_session_ids"] == ["live"], "ghost dropped from expanded too" + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a From b0401b06d0a8600de8e101c611d53d5abc42f84a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 14:46:48 -0700 Subject: [PATCH 30/36] [eric] openai: route the thinking-slider effort param to gpt/codex (not just claude) --- backend/apps/agents/agent_manager.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index cef90c76..a48e8a2a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1824,6 +1824,15 @@ class AgentManager: options_kwargs["thinking"] = {"type": "disabled"} elif level in ("low", "medium", "high"): options_kwargs["effort"] = level + elif api_type in ("openai", "codex"): + # GPT-5 family + Codex take reasoning_effort; 9Router carries + # the Anthropic-shaped `effort` across to it, so the slider + # works for OpenAI too, not just Claude. Every OpenAI/Codex + # model we expose is reasoning-capable (registry has no + # non-reasoning ones), so no per-model gate. No "disabled" + # form on these, so "off" just omits the param. + if level in ("low", "medium", "high"): + options_kwargs["effort"] = level except Exception as e: logger.debug(f"thinking_level param injection skipped: {e}") From 96f0ce78a75b8ce4e5fb80567320d1e5386db936 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 15:22:56 -0700 Subject: [PATCH 31/36] [eric] boot: fetch subscription status on launch (connected subs were stale until Settings) --- frontend/src/app/Main.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 28e564f4..2046427f 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -9,6 +9,7 @@ import Alert from '@mui/material/Alert'; import { store } from '../shared/state/store'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchSettings, updateSettings, markFreeTrialArmSettled } from '@/shared/state/settingsSlice'; +import { fetchSubscriptionStatus } from '@/shared/state/subscriptionsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; import { API_BASE } from '@/shared/config'; import { @@ -222,6 +223,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); + // Connected subscriptions live in their own slice; without this the dashboard + // (and the onboarding gate) think no model is connected until the user opens + // Settings > Models, so a fresh launch shows a false "connect a model" empty + // state and the welcome cursor never fires. Refetched after sync + on focus below. + dispatch(fetchSubscriptionStatus()); fetch(`${API_BASE}/subscription/sync`, { method: 'POST' }) .then((r) => { if (r.ok) dispatch(fetchSettings()); @@ -236,12 +242,12 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = // The backend arms server-side regardless of whether the browser can read the mint // response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch // unconditionally, the GET is the only reliable signal the UI gets that it armed. - .finally(() => { dispatch(fetchSettings()); dispatch(markFreeTrialArmSettled()); }); + .finally(() => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); dispatch(markFreeTrialArmSettled()); }); }); }, [dispatch]); useEffect(() => { - const onFocus = () => { dispatch(fetchSettings()); }; + const onFocus = () => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); }; window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); }, [dispatch]); From c9efbeca39889eeee910c05323e233aa5435a642 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 16:42:04 -0700 Subject: [PATCH 32/36] [eric] onboarding: restart-tour reload + cursor yields silently on off-script click --- .../app/components/Onboarding/OnboardingDirector.ts | 13 +++++++++++++ .../src/app/components/Onboarding/ac/acRuntime.ts | 13 +++++++++++++ .../Settings/sections/general/GeneralAdvanced.tsx | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts index 6522adf3..2dbaa7e7 100644 --- a/frontend/src/app/components/Onboarding/OnboardingDirector.ts +++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts @@ -98,8 +98,20 @@ class OnboardingDirector { controller.abort(); } }; + // Yield to the user: the runtime fires this when, during a wait for a + // SPECIFIC click target, the user instead clicks somewhere off-script. Back + // off silently (reason 'user-cancel' suppresses acRuntime's recovery popup) + // rather than nagging or auto-performing the action. It is scoped to + // click-target waits in the runtime, so it can't cancel free-interaction + // waits (e.g. connecting a model in Settings, where the user must click + // non-tour controls). + const onUserOffscript = () => { + report('step_aborted_user_offscript', { step_id: stepId }); + controller.abort('user-cancel'); + }; window.addEventListener('openswarm:onboarding:lost_target', onLost); window.addEventListener('hashchange', onRouteChange); + window.addEventListener('openswarm:onboarding:user_offscript', onUserOffscript); try { await runStep({ @@ -115,6 +127,7 @@ class OnboardingDirector { } finally { window.removeEventListener('openswarm:onboarding:lost_target', onLost); window.removeEventListener('hashchange', onRouteChange); + window.removeEventListener('openswarm:onboarding:user_offscript', onUserOffscript); if (this.currentAbort === controller) { this.currentAbort = null; } diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 92e9c955..eea5e174 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -860,12 +860,25 @@ function waitForCondition( ) ) { finish(false); + return; } + // Off-script click during a wait for a specific target: if it's not any + // tour control and not the cursor/popup, the user has gone their own + // way, so tell the director to back off (it aborts the step silently). + // Scoped here to click-target waits so free-interaction waits + // (redux_predicate / event_bus) never cancel on a stray click. + if (!(el instanceof Element)) return; + if (el.closest('[data-onboarding], [data-select-type]')) return; + for (let n: Element | null = el; n; n = n.parentElement) { + if (parseInt(window.getComputedStyle(n).zIndex || '0', 10) >= 10500) return; + } + window.dispatchEvent(new CustomEvent('openswarm:onboarding:user_offscript', { detail: { target: cond.target } })); }; document.addEventListener('click', handler, true); cleanup = () => document.removeEventListener('click', handler, true); return; } + case 'redux_predicate': { const check = () => { const value = cond.selector(store.getState()); diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx index 62803460..01ce5b94 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx @@ -117,6 +117,10 @@ const GeneralAdvanced: React.FC<{ dispatch(resetTour()); dispatch(closeSettingsModal()); onboardingBus.emit('settings:closed'); + // In-place reset can't re-arm the welcome cursor's once-per-mount + // guard, so the tour never re-fired without a reload; reload from the + // now-cleared storage is the reliable restart (matches the workaround). + window.location.reload(); }} sx={{ color: c.text.secondary, From 7a91abba8d260efa88dd7a7254a91233d09c4be5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 16:43:55 -0700 Subject: [PATCH 33/36] [eric] onboarding: drop linear step-gating so every roadmap step is freely explorable --- .../components/Onboarding/steps/stepUnlock.ts | 72 ++++--------------- 1 file changed, 12 insertions(+), 60 deletions(-) diff --git a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts index eef57122..209b87be 100644 --- a/frontend/src/app/components/Onboarding/steps/stepUnlock.ts +++ b/frontend/src/app/components/Onboarding/steps/stepUnlock.ts @@ -1,73 +1,25 @@ -// Soft, earned unlocks for the onboarding panel. A locked step is still fully -// usable in the app, this only gates the guided spotlight + shows a lock icon -// with a one-line teaser, so the tour reveals things ONE AT A TIME instead of -// dumping the whole feature surface at once. -// -// Tiers: -// - get_started (launch an agent, connect a model): unlocked from the start. -// - Tier 1 "the basics": the FIRST feature unlocks on your first agent win. -// - Tier 2 "going further": a CHAIN, each feature unlocks once you finish the -// previous tour step, so the panel only ever surfaces the NEXT thing. -// -// Off-script still counts: doing a thing yourself (opening a browser, installing -// a skill) unlocks its step immediately, so exploring is never punished. +// Onboarding is a playground, not homework: every roadmap step is freely +// explorable in any order. A linear FEATURE_CHAIN used to gate each step on +// finishing the one above it (the 🔒 "Finish the step above" teasers); that read +// as a chore, so the gating is gone and nothing is locked. The exported shapes +// are kept so the panel/roadmap callers don't change. import { useMemo } from 'react'; import type { RootState } from '@/shared/state/store'; import { useAppSelector } from '@/shared/hooks'; -import { - hasAnyAgentLaunched, - hasAnyBrowserSpawned, - hasAnySkillInstalled, -} from './skipPredicates'; import { STEPS } from './index'; -// Order features reveal in. Index 0 is tier 1 (first thing after the win); the -// rest are the tier-2 chain, each gated on finishing the one before it. -const FEATURE_CHAIN = [ - 'enable_actions', - 'use_browser', - 'agent_use_browser', - 'agent_control_agents', - 'install_skill', - 'make_app', -]; - -// A feature can ALSO unlock when its real-world milestone is met off-script. -const OFF_SCRIPT: Record boolean> = { - use_browser: hasAnyBrowserSpawned, - agent_use_browser: hasAnyBrowserSpawned, - install_skill: hasAnySkillInstalled, -}; - -const HINTS: Record = { - enable_actions: 'Run your first agent', - use_browser: 'Finish the step above', - agent_use_browser: 'Finish the step above', - agent_control_agents: 'Finish the step above', - install_skill: 'Finish the step above', - make_app: 'Finish the step above', -}; - -export function isStepUnlocked(stepId: string, s: RootState): boolean { - const idx = FEATURE_CHAIN.indexOf(stepId); - if (idx === -1) return true; // get_started entry points are always open - if (idx === 0) return hasAnyAgentLaunched(s); // tier 1 opens on the first win - const prevDone = (s.onboardingProgress?.completedSteps ?? []).includes( - FEATURE_CHAIN[idx - 1], - ); - return prevDone || (OFF_SCRIPT[stepId]?.(s) ?? false); +export function isStepUnlocked(_stepId: string, _s: RootState): boolean { + return true; } -export function unlockHintFor(stepId: string): string | null { - return HINTS[stepId] ?? null; +export function unlockHintFor(_stepId: string): string | null { + return null; } -/** Set of currently-unlocked step ids. Keyed on a stable string so the selector - * only re-renders when the unlock set actually changes. */ +/** Set of currently-unlocked step ids: every step, always. Selector form kept + * so callers' memoization is unchanged. */ export function useUnlockedStepIds(): Set { - const key = useAppSelector((s) => - STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'), - ); + const key = useAppSelector(() => STEPS.map((st) => st.id).join('|')); return useMemo(() => new Set(key ? key.split('|') : []), [key]); } From 39c837fee77dd41683147b0ffeac01f489c941f2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 18:58:44 -0700 Subject: [PATCH 34/36] [eric] swarm: carry the chat transcript when sharing an agent (was dropped, scrub layer guards secrets) --- backend/apps/swarm/entities/sessions.py | 40 ++++++++++++----- backend/tests/test_swarm_bundle.py | 60 +++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/backend/apps/swarm/entities/sessions.py b/backend/apps/swarm/entities/sessions.py index 847601e6..805f06fc 100644 --- a/backend/apps/swarm/entities/sessions.py +++ b/backend/apps/swarm/entities/sessions.py @@ -1,10 +1,12 @@ -"""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.""" +"""SessionExportable: an agent card on a shared dashboard. We carry the recipe +(name, model, mode, system prompt, allowed tools) AND the chat transcript so a +shared agent arrives with the conversation that produced it, that's the whole +point of sharing one. The transcript rides through the same scrub layer as every +payload, so any secret-shaped string in it is redacted before it leaves. We still +DROP 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 @@ -14,7 +16,14 @@ 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") +# Transcript fields ride along so the shared agent keeps its history; ids inside +# (message ids, branch ids, their parent/fork refs) are self-consistent within +# the one session file, so they carry verbatim with no remap. +_KEEP = ( + "name", "provider", "model", "mode", "system_prompt", "allowed_tools", + "max_turns", "thinking_level", + "messages", "branches", "active_branch_id", "tool_group_meta", +) class SessionExportable: @@ -70,6 +79,14 @@ class SessionExportable: from backend.apps.agents.manager.session.session_store import _save_session sid = uuid4().hex now = datetime.now(timezone.utc).isoformat() + # Older bundles (made before transcripts were carried) have no messages; + # fall back to a single empty main branch so the imported agent is valid. + branches = payload.get("branches") or { + "main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now} + } + active_branch_id = payload.get("active_branch_id") or "main" + if active_branch_id not in branches: + active_branch_id = next(iter(branches), "main") doc = { "id": sid, "name": payload.get("name") or "Agent", @@ -81,9 +98,10 @@ class SessionExportable: "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", + "messages": payload.get("messages") or [], + "branches": branches, + "active_branch_id": active_branch_id, + "tool_group_meta": payload.get("tool_group_meta") or {}, "active_mcps": [], "dashboard_id": None, # the dashboard import re-points this "browser_id": None, diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fea9408c..9d08a595 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -168,23 +168,75 @@ def test_workflow_unavailable_on_this_branch(): WorkflowExportable.import_({"title": "x"}, {}, RemapTable()) -def test_session_export_strips_transcript_and_secrets(): +def test_session_export_carries_transcript_drops_runtime_and_secrets(): from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.redact import scrub_payload data = { "name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent", "system_prompt": "hi", "allowed_tools": ["Read"], - "messages": [{"role": "user", "content": "private chat"}], + "messages": [ + {"id": "m1", "role": "user", "content": "private chat", "branch_id": "main"}, + {"id": "m2", "role": "assistant", "content": "token is sk-ant-abcdefghij0123456789"}, + ], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}}, + "active_branch_id": "main", + "tool_group_meta": {"g1": {"label": "x"}}, "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"): + # The transcript now rides along, that's the point of sharing an agent. + assert out["messages"][0]["content"] == "private chat" + assert out["active_branch_id"] == "main" and "main" in out["branches"] + assert out["tool_group_meta"] == {"g1": {"label": "x"}} + # Runtime, identity, and gate state still never leave. + for gone in ("cwd", "active_mcps", "cost_usd", "sdk_session_id"): assert gone not in out - assert out["model"] == "sonnet" and out["mode"] == "agent" + # The closure runs scrub_payload on every payload, so a secret-shaped + # string sitting in the transcript is redacted before it ships. + assert "sk-ant-" not in json.dumps(scrub_payload(out)) reqs = ex.requirements() assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs) +def test_session_import_restores_transcript_without_granting_mcp(monkeypatch): + from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.exportable import RemapTable + from backend.apps.agents.manager.session import session_store + saved: dict = {} + monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc})) + payload = { + "name": "A", "model": "sonnet", "mode": "agent", + "messages": [{"id": "m1", "role": "user", "content": "hi", "branch_id": "main"}], + "branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}}, + "active_branch_id": "main", + "tool_group_meta": {"g1": {"label": "x"}}, + } + sid = SessionExportable.import_(payload, {}, RemapTable()) + doc = saved[sid] + assert doc["messages"][0]["content"] == "hi" + assert doc["active_branch_id"] == "main" + assert doc["tool_group_meta"] == {"g1": {"label": "x"}} + # The gate stays shut: a shared agent never arrives with MCP access. + assert doc["active_mcps"] == [] + # The dashboard import re-points this; it must never be the sharer's id. + assert doc["dashboard_id"] is None + + +def test_session_import_old_bundle_without_transcript(monkeypatch): + # A bundle made before transcripts were carried has no messages; it must + # still import as a valid empty-history agent (single main branch), not crash. + from backend.apps.swarm.entities.sessions import SessionExportable + from backend.apps.swarm.exportable import RemapTable + from backend.apps.agents.manager.session import session_store + saved: dict = {} + monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc})) + sid = SessionExportable.import_({"name": "Old", "model": "sonnet"}, {}, RemapTable()) + doc = saved[sid] + assert doc["messages"] == [] + assert doc["active_branch_id"] == "main" and "main" in doc["branches"] + + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable from backend.apps.swarm.models import EntityType From 3bf1b0da79d08d4bf211b98d9e6e2661a748e9a7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 15 Jun 2026 18:58:50 -0700 Subject: [PATCH 35/36] [eric] mcp: break the ToolSearch loop, redirect a stuck agent to MCPActivate for gated servers --- backend/apps/agents/agent_manager.py | 54 +++++++++++++++ .../agents/manager/prompt/prompt_context.py | 35 ++++++++++ backend/tests/test_v2_invariants.py | 69 +++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index a48e8a2a..a12a1e56 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -66,6 +66,8 @@ from backend.apps.agents.manager.prompt.prompt_context import ( _resolve_attached_skills, _resolve_forced_tools, _resolve_mode, + TOOLSEARCH_LOOP_THRESHOLD, + toolsearch_loop_redirect, ) from backend.apps.agents.manager.prompt.attachments import ( _build_dir_tree, @@ -209,6 +211,29 @@ class AgentManager: logger.info(f"[MCP-DEBUG] Final mcp_servers: {list(mcp_servers.keys())}") return mcp_servers + def _gated_mcp_server_names(self, allowed_tools: list[str], active_mcps: list[str] | None) -> list[str]: + """Names of installed MCP servers withheld from the SDK because they're + not activated yet, exactly the servers the model sees in the + block but can't reach via ToolSearch. The only way in is + MCPActivate; used to steer a model looping on ToolSearch to the gate.""" + active_set = set(active_mcps or []) + names: list[str] = [] + try: + for tool in load_all_tools(): + if not (tool.mcp_config and tool.enabled and tool.auth_status in ("configured", "connected")): + continue + tool_ref = f"mcp:{tool.name}" + if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names(): + continue + if _is_fully_denied(tool): + continue + server_name = _sanitize_server_name(tool.name) + if server_name not in active_set: + names.append(server_name) + except Exception: + logger.exception("gated MCP server enumeration failed") + return names + def _build_connected_tools_context(self, allowed_tools: list[str]) -> str | None: return _build_connected_tools_context(allowed_tools, get_all_tool_names) @@ -801,11 +826,40 @@ class AgentManager: ) tool_start_times: dict[str, float] = {} + # Counts ToolSearch calls in a row (no other tool between them). A run + # of these with empty results is the "looping on ToolSearch" wedge. + _ts_loop = {"n": 0} async def pre_tool_hook(input_data, tool_use_id, context): tool_name = input_data.get("tool_name", "") hook_event = input_data.get("hook_event_name", "PreToolUse") + # ToolSearch loop-breaker. Gated MCP servers are withheld from the + # SDK until MCPActivate, so the CLI's native ToolSearch can never + # find them; small models thrash (empty ToolSearch, retry) for + # minutes until the user pauses. Let the first couple through, then + # redirect to the gate. Any non-ToolSearch call is real progress, so + # the counter resets. Gated-server lookup is deferred behind the + # threshold so the common (non-looping) path stays free. + if tool_name == "ToolSearch": + _ts_loop["n"] += 1 + if _ts_loop["n"] >= TOOLSEARCH_LOOP_THRESHOLD: + _reason = toolsearch_loop_redirect( + _ts_loop["n"], + self._gated_mcp_server_names(session.allowed_tools, session.active_mcps), + ) + if _reason: + logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {session_id} (n={_ts_loop['n']})") + return { + "hookSpecificOutput": { + "hookEventName": hook_event, + "permissionDecision": "deny", + "permissionDecisionReason": _reason, + } + } + else: + _ts_loop["n"] = 0 + if tool_name and tool_name != "AskUserQuestion": tool_input = input_data.get("tool_input", {}) policy, sensitive_pattern = _maybe_override_policy( diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index b6935496..4c6236cb 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -98,6 +98,35 @@ def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names: ) +# A run of this many ToolSearch calls with no other tool between them is the +# "looping on ToolSearch" wedge: the model hunts for a gated MCP server's tools, +# which ToolSearch can never see, gets empty results, and retries. Two free +# calls (a power user with many activated MCPs may legitimately ToolSearch to +# load a deferred tool); redirect on the third. +TOOLSEARCH_LOOP_THRESHOLD = 3 + + +def toolsearch_loop_redirect(consecutive_toolsearch: int, gated_servers: list[str]) -> str | None: + """The feedback to hand a model that's stuck calling ToolSearch in a row. + None until it crosses the threshold; then a steer toward MCPActivate (the + only path to a gated server) plus a reminder its other tools are already + loaded. Pure so the loop-break boundary is unit-testable.""" + if consecutive_toolsearch < TOOLSEARCH_LOOP_THRESHOLD: + return None + reason = ( + "ToolSearch can't load anything here, every tool you can use is already " + "active and callable by name, so there's nothing to search for. " + ) + if gated_servers: + reason += ( + "If you need an app you don't see yet (email, calendar, drive, etc.), " + "it's gated: call MCPActivate(server_name) with one of these and its " + f"tools become callable next turn: {', '.join(gated_servers)}. " + ) + reason += "Stop calling ToolSearch." + return reason + + def _build_browser_context(dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None: """Build a context block listing browser cards and delegation instructions. @@ -304,6 +333,12 @@ def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str] "Calendar/Drive, the equivalent OpenSwarm server is listed below; " "activate that one via MCPActivate instead." ) + sections.append( + "1b. The native `ToolSearch` tool CANNOT see these servers, they're " + "hidden from it until activated, so searching for them returns nothing " + "and just burns turns. Never ToolSearch for an app/integration; go " + "straight to MCPActivate." + ) sections.append( "2. After MCPActivate returns, end the turn, a follow-up turn fires " "automatically with the new tools available." diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index d9ba5094..1a240ae0 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -210,6 +210,75 @@ async def test_gate_stress_random_activations(): ) +# =========================================================================== +# Group A2, ToolSearch loop-breaker +# =========================================================================== +# Gated MCP servers are withheld from the SDK, so the CLI's native ToolSearch +# can never see them; small models loop (empty ToolSearch -> retry) until the +# user pauses. The break must (a) not fire on the first call or two (a power +# user may legitimately ToolSearch a deferred tool), (b) fire once it's clearly +# stuck, steering to MCPActivate, and (c) reset when any real tool runs. + + +def test_toolsearch_redirect_holds_below_threshold(): + from backend.apps.agents.manager.prompt.prompt_context import ( + toolsearch_loop_redirect, + TOOLSEARCH_LOOP_THRESHOLD, + ) + for n in range(1, TOOLSEARCH_LOOP_THRESHOLD): + assert toolsearch_loop_redirect(n, ["gmail"]) is None, f"must not redirect at n={n}" + + +def test_toolsearch_redirect_fires_at_threshold_and_names_gated_servers(): + from backend.apps.agents.manager.prompt.prompt_context import ( + toolsearch_loop_redirect, + TOOLSEARCH_LOOP_THRESHOLD, + ) + reason = toolsearch_loop_redirect(TOOLSEARCH_LOOP_THRESHOLD, ["google-workspace", "slack"]) + assert reason is not None + assert "MCPActivate" in reason + assert "google-workspace" in reason and "slack" in reason + assert "Stop calling ToolSearch" in reason + + +def test_toolsearch_redirect_works_with_no_gated_servers(): + # Even with nothing to activate, the steer must still tell the model its + # tools are already loaded so it stops searching (no crash on empty list). + from backend.apps.agents.manager.prompt.prompt_context import ( + toolsearch_loop_redirect, + TOOLSEARCH_LOOP_THRESHOLD, + ) + reason = toolsearch_loop_redirect(TOOLSEARCH_LOOP_THRESHOLD, []) + assert reason is not None + assert "MCPActivate" not in reason # nothing to point at + assert "Stop calling ToolSearch" in reason + + +@pytest.mark.asyncio +async def test_gated_server_names_surface_only_inactive_servers(): + """The steer list must mirror the gate: connected-but-not-active servers + only, never one that's already activated (callable) or denied.""" + from backend.apps.agents.agent_manager import AgentManager + fake_tools = [_fake_tool("Gmail"), _fake_tool("Slack"), _fake_tool("Notion")] + with patch("backend.apps.agents.agent_manager.load_all_tools", return_value=fake_tools): + mgr = AgentManager() + names = mgr._gated_mcp_server_names( + allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], + active_mcps=["gmail"], # already activated -> not "gated" + ) + assert "gmail" not in names, "activated server must not appear as gated" + assert "slack" in names and "notion" in names + + +@pytest.mark.asyncio +async def test_gated_server_names_empty_when_all_active(): + from backend.apps.agents.agent_manager import AgentManager + fake_tools = [_fake_tool("Gmail")] + with patch("backend.apps.agents.agent_manager.load_all_tools", return_value=fake_tools): + mgr = AgentManager() + assert mgr._gated_mcp_server_names(["mcp:Gmail"], ["gmail"]) == [] + + # =========================================================================== # Group B, needs_fresh_session soft-restart # =========================================================================== From de2e70ca8fed339ff1abd5d7eb9ad89c5018f6ab Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 15 Jun 2026 19:00:40 -0700 Subject: [PATCH 36/36] [aidan] fix/browser-early-close: let agent keep browser open when result lives on the page (#88) --- backend/apps/agents/browser/browser_agent.py | 24 +++++++++++++++++++ backend/apps/agents/browser/browser_schema.py | 19 ++++++++++++--- backend/apps/dashboards/models.py | 4 ++++ .../src/shared/state/dashboardLayoutSlice.ts | 10 ++++++++ frontend/src/shared/ws/WebSocketManager.ts | 12 +++++++--- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 14ab253d..12419b27 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -941,6 +941,7 @@ async def run_browser_agent( done_called = False done_message = "" done_success = True + done_keep_open = False # Completion detection: once an irreversible SEND has confirmed, the goal is # met. The model otherwise stalls re-verifying what the confirm already proved # (measured: send done at turn ~11, then ~12 wasted perception turns). We drive @@ -1427,6 +1428,7 @@ async def run_browser_agent( done_called = True done_message = (tu.input.get("message") or "").strip() done_success = tu.input.get("success", True) is not False + done_keep_open = tu.input.get("keep_open", False) is True tool_results.append({ "type": "tool_result", "tool_use_id": tu.id, "content": [{"type": "text", "text": "ok"}], @@ -2122,6 +2124,28 @@ async def run_browser_agent( }) except Exception as e: logger.debug(f"[browser-playbook] distill skipped: {e}") + # The model asked to leave the browser open because the deliverable lives + # on the page (a video playing, a page to read). Pin the card so the + # auto-close on parent finish skips it. Only on honest success: never pin + # a broken or ghost run open. The keep broadcast lands before the parent + # reaches terminal state (it awaits this run), so the frontend has the + # flag set before any close path runs. + if honest and done_keep_open and dashboard_id: + try: + from backend.apps.dashboards.dashboards import _load, _save + dashboard = _load(dashboard_id) + card = dashboard.layout.browser_cards.get(browser_id) + if card is not None: + card.keep_open = True + dashboard.updated_at = datetime.now() + _save(dashboard) + await ws_manager.broadcast_global("dashboard:browser_card_keep", { + "dashboard_id": dashboard_id, + "browser_id": browser_id, + }) + except Exception as e: + logger.warning(f"[browser-agent {session_id}] keep_open persist failed: {e}") + agent_manager._sync_session_close(session) await ws_manager.send_to_session(session_id, "agent:status", { "session_id": session_id, diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index afc14fac..b4e59f2d 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -117,6 +117,17 @@ BROWSER_TOOLS_SCHEMA = [ "(login wall, missing info, something blocked you). Default true." ), }, + "keep_open": { + "type": "boolean", + "description": ( + "Set true ONLY when the result IS the open page and the user will keep " + "using it right now: a video or audio playing, a page you opened for them " + "to read or watch, a download you started, or a place left ready for them " + "to take over. The browser then stays put instead of closing. Leave false " + "(default) for info tasks where you just look something up and report the " + "answer back, since there's nothing left to keep on screen." + ), + }, }, "required": ["message"], }, @@ -885,9 +896,11 @@ SYSTEM_PROMPT = ( "tool, never by typing a sentence. Put your reply to the user in Done's `message`, " "written like a normal chat reply: what got done plus the human proof (the name, the " "time, what's now on screen), in one or two plain sentences with zero interface words. " - "Set `success` false if you couldn't finish. For irreversible actions, only report " - "success with real proof you actually observed (the name and where/when you saw it), " - "just phrased for a person, not for a machine." + "Set `success` false if you couldn't finish. Set `keep_open` true when the result is the " + "open page itself and the user keeps using it now (a video playing, a page opened to " + "read, a download started), so the browser stays instead of closing. For irreversible " + "actions, only report success with real proof you actually observed (the name and " + "where/when you saw it), just phrased for a person, not for a machine." ) MAX_TURNS = 40 diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 52be0543..717fcf19 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -40,6 +40,10 @@ class BrowserCardPosition(BaseModel): # Used by the frontend to auto-remove the browser when its owner agent # reaches a terminal completed/error state. spawned_by: Optional[str] = None + # When the agent leaves the deliverable on the page (a video playing, a page + # to read), it sets this so the frontend's auto-close on parent finish skips + # the card and the browser stays put. + keep_open: bool = False class NotePosition(BaseModel): diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index f3534ebc..3e36cd1b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -59,6 +59,7 @@ export interface BrowserCardPosition { zOrder: number; /** Agent session that spawned this browser; auto-removed when its owner reaches terminal state. */ spawned_by?: string | null; + keep_open?: boolean; /** Dashboard this card belongs to; cards render and persist only on their owning dashboard. */ dashboard_id?: string; } @@ -663,6 +664,14 @@ const dashboardLayoutSlice = createSlice({ delete state.endingBrowserCards[action.payload]; }, + keepBrowserCardOpen(state, action: PayloadAction) { + const card = state.browserCards[action.payload]; + if (!card) return; + card.keep_open = true; + // Undo any in-flight ending mark in case a close path raced ahead. + delete state.endingBrowserCards[action.payload]; + }, + suspendBrowserCard(state, action: PayloadAction<{ browserId: string; dataUrl: string }>) { if (!state.browserCards[action.payload.browserId]) return; state.suspendedBrowserCards[action.payload.browserId] = { @@ -1098,6 +1107,7 @@ export const { resumeBrowserCard, markBrowserCardEnding, cancelBrowserCardEnding, + keepBrowserCardOpen, pasteBrowserCard, updateBrowserCardUrl, addBrowserTab, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 092c4a6b..258f62e4 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -23,7 +23,7 @@ import { clearTurnLabel, } from '../state/agentsSlice'; import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice'; -import { addBrowserCardFromBackend, markBrowserCardEnding, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; +import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice'; import { upsertOutput } from '../state/outputsSlice'; import { displaySessionName } from '../state/sessionDisplay'; import { getAuthToken } from '../config'; @@ -510,7 +510,7 @@ class WebSocketManager { ) { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { - if (card.spawned_by === session_id) { + if (card.spawned_by === session_id && !card.keep_open) { store.dispatch(markBrowserCardEnding({ browserId: card.browser_id, status: data.status, })); @@ -733,7 +733,7 @@ class WebSocketManager { if (closedStatus === 'completed' || closedStatus === 'error') { const browserCards = store.getState().dashboardLayout.browserCards; for (const card of Object.values(browserCards)) { - if (card.spawned_by === session_id) { + if (card.spawned_by === session_id && !card.keep_open) { store.dispatch(markBrowserCardEnding({ browserId: card.browser_id, status: closedStatus, })); @@ -743,6 +743,12 @@ class WebSocketManager { } break; + case 'dashboard:browser_card_keep': + if (data.browser_id) { + store.dispatch(keepBrowserCardOpen(data.browser_id)); + } + break; + case 'dashboard:browser_card_added': if (data.browser_card) { // Tag with origin dashboard so the card renders only on the dashboard