[aidan] chore/merge: merge eric dev

This commit is contained in:
abccodes
2026-06-15 21:33:52 -07:00
75 changed files with 4847 additions and 326 deletions
+6 -4
View File
@@ -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
+3
View File
@@ -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).
+27
View File
@@ -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';
+63
View File
@@ -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,
@@ -214,6 +216,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
<mcp_servers> 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)
@@ -806,11 +831,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(
@@ -1895,6 +1949,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}")
+14 -2
View File
@@ -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))
@@ -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,
+16 -3
View File
@@ -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
@@ -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"
+29 -14
View File
@@ -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():
@@ -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."
+43 -42
View File
@@ -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"},
@@ -236,6 +227,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)
@@ -244,6 +255,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)
@@ -256,18 +274,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",
}
@@ -275,26 +293,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))
@@ -414,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),
@@ -422,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),
+80 -37
View File
@@ -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)
@@ -438,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),
@@ -464,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),
+40 -5
View File
@@ -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
@@ -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}")
+4
View File
@@ -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):
+4
View File
@@ -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",
+18 -10
View File
@@ -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__)
@@ -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"
@@ -255,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:
@@ -302,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,
@@ -313,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={
+103 -28
View File
@@ -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/<prov>/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/<prov>/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()
+2 -2
View File
@@ -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,
+10 -10
View File
@@ -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",
View File
+376
View File
@@ -0,0 +1,376 @@
"""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, verify_checksum
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 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):
if has_member(raw, MANIFEST_NAME):
sandbox = unpack(raw)
try:
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
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 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:
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]] = {}
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
+160
View File
@@ -0,0 +1,160 @@
"""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_DIR, 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
@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))
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
+151
View File
@@ -0,0 +1,151 @@
"""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
@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:
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:
# 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:
try:
d = _load_session_data(sid)
if d is not None:
d["dashboard_id"] = dashboard_id
_save_session(sid, d)
except Exception:
pass
+79
View File
@@ -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
+118
View File
@@ -0,0 +1,118 @@
"""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
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"}
# 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:
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()
# 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",
"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": 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,
"parent_session_id": None,
"created_at": now,
"closed_at": now,
}
_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)
+106
View File
@@ -0,0 +1,106 @@
"""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
@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")
)
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}"
+129
View File
@@ -0,0 +1,129 @@
"""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
@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:
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
+52
View File
@@ -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: ...
+136
View File
@@ -0,0 +1,136 @@
"""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
# 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)
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)
+99
View File
@@ -0,0 +1,99 @@
"""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
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
+32
View File
@@ -0,0 +1,32 @@
"""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
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.
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)
+34
View File
@@ -0,0 +1,34 @@
"""Best-effort safety read of imported app code. AST flags risky Python via the
existing executor allow/deny lists, and we note when an app will run real code
on the importer's machine (a webapp_template app spawns `bash run.sh`). This is
advisory and surfaced in the import preflight; the actual execution gates are the
user choosing to open/run the app and the flat-app /execute HITL. A full semantic
LLM scan is the separate App Publishing feature, not this."""
from __future__ import annotations
from backend.apps.outputs.executor import get_code_warnings
from .models import ReviewSummary
def scan_app_files(files: dict[str, bytes]) -> ReviewSummary:
findings: list[str] = []
scanned: list[str] = []
runnable = False
for path, data in files.items():
low = path.lower()
if low.endswith("/run.sh") or low.endswith("package.json") or "/backend/" in low:
runnable = True
if low.endswith(".py"):
scanned.append(path)
try:
code = data.decode("utf-8", errors="replace")
except Exception:
continue
for w in get_code_warnings(code):
findings.append(f"{path}: {w}")
verdict = "warn" if findings else "clean"
if runnable:
verdict = "warn"
findings.insert(0, "This app runs code on your computer when you open it. Only import apps you trust.")
return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned)
+130
View File
@@ -0,0 +1,130 @@
"""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)
review = closure.review_bundle(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,
review=review,
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
],
)
+169
View File
@@ -0,0 +1,169 @@
"""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 hashlib
import io
import json
import os
import shutil
import tempfile
import zipfile
from .redact import find_denied_keys, find_secrets_in_files
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 _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/<bid>/payload.json).
files: full zip path -> bytes (e.g. entities/<bid>/files/<rel>)."""
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]}"
)
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")
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 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")
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")
+3 -2
View File
@@ -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
@@ -452,7 +453,7 @@ _SUCCESS_HTML = (
'<div style="text-align:center">'
'<div style="width:64px;height:64px;border-radius:50%;background:#22c55e20;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px">&#10003;</div>'
'<h2 style="margin:0 0 8px">Connected!</h2>'
'<p style="color:#888;margin:0">You can close this window</p>'
'<p style="color:#888;margin:0">You can close this tab, and any other Claude login tab still open.</p>'
'</div>'
'<script>setTimeout(()=>window.close(),1500)</script>'
'</body></html>'
+20
View File
@@ -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.
+83
View File
@@ -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()
+1 -1
View File
@@ -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
+488
View File
@@ -0,0 +1,488 @@
"""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 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 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 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 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": [
{"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)
# 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
# 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
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 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")
# 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 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:
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_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:
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")
+299
View File
@@ -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
# ===========================================================================
@@ -479,6 +548,162 @@ 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"
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"
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"
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"))
@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_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
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
# ===========================================================================
@@ -1482,6 +1707,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: "..."}}]`
+121 -25
View File
@@ -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 <webview> 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.
// <webview> 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. <webview> 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).
//
+10 -2
View File
@@ -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 {
@@ -20,6 +21,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';
@@ -221,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());
@@ -235,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]);
@@ -468,6 +475,7 @@ const ThemedApp: React.FC = () => {
<DefaultModelGuard>
<UpdateListener>
<CrashRecoveryChip />
<ImportEntryPoint />
<DeepLinkListener>
<ErrorBoundary scope="routes">
<Suspense fallback={null}>
@@ -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;
}
@@ -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());
@@ -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<string, (s: RootState) => boolean> = {
use_browser: hasAnyBrowserSpawned,
agent_use_browser: hasAnyBrowserSpawned,
install_skill: hasAnySkillInstalled,
};
const HINTS: Record<string, string> = {
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<string> {
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]);
}
@@ -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<DigestHandle, { color?: string }>(({ color = '#c4633a' }, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
width={SIZE}
height={SIZE}
style={{
position: 'fixed',
width: SIZE,
height: SIZE,
pointerEvents: 'none',
zIndex: 2100,
opacity: 0,
transition: 'opacity 160ms ease',
}}
/>
);
});
ImportDigest.displayName = 'ImportDigest';
export default ImportDigest;
@@ -0,0 +1,214 @@
// 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, (id: string) => 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<void>((r) => setTimeout(r, ms));
const ImportEntryPoint: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement | null>(null);
const digestRef = useRef<DigestHandle | null>(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const [confirm, setConfirm] = useState<ImportPreflight | null>(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 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();
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');
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();
void handleFile(f, e.clientX, e.clientY);
}
};
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);
};
}, [handleFile]);
return (
<>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
style={{ display: 'none' }}
onChange={(e) => {
void handleFile(e.target.files?.[0] || null, window.innerWidth / 2, window.innerHeight / 2);
e.target.value = '';
}}
/>
<ImportDigest ref={digestRef} color={c.accent.primary} />
<Fade in={dragging} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: 2000,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
bgcolor: `${c.bg.page}e6`,
border: `2px dashed ${c.accent.primary}`,
pointerEvents: 'none',
}}
>
<FileDownloadIcon sx={{ fontSize: 40, color: c.accent.primary }} />
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
Drop to add to OpenSwarm
</Typography>
</Box>
</Fade>
<ImportModal
preflight={confirm}
open={!!confirm}
committing={committing}
onConfirm={() => confirm && commitAndFinish(confirm)}
onClose={() => {
setConfirm(null);
confirmRef.current = false;
}}
/>
<Snackbar
open={!!toast}
autoHideDuration={3500}
onClose={() => setToast(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={toast?.sev || 'success'}
variant="outlined"
onClose={() => setToast(null)}
sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}` }}
>
{toast?.msg}
</Alert>
</Snackbar>
</>
);
};
export default ImportEntryPoint;
@@ -0,0 +1,103 @@
// 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 CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import IncludesList from './IncludesList';
import { ImportPreflight } from './shareTypes';
interface Props {
preflight: ImportPreflight | null;
open: boolean;
committing: boolean;
onConfirm: () => void;
onClose: () => void;
}
const ImportModal: React.FC<Props> = ({ preflight, open, committing, onConfirm, onClose }) => {
const c = useClaudeTokens();
return (
<Dialog
open={open && !!preflight}
onClose={onClose}
maxWidth={false}
PaperProps={{
sx: {
width: 440,
maxWidth: '92vw',
bgcolor: c.bg.page,
borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
},
}}
>
{preflight && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5, pb: 1 }}>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 700, color: c.text.primary }}>
Add {preflight.summary.root.name}?
</Typography>
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Box sx={{ px: 3, pb: 3 }}>
<IncludesList summary={preflight.summary} />
{preflight.review && preflight.review.findings.length > 0 && (
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.status.warning}55`, bgcolor: c.status.warningBg }}>
{preflight.review.findings.map((f, i) => (
<Typography key={`rv-${i}`} sx={{ fontSize: '0.78rem', color: c.text.secondary, lineHeight: 1.5 }}>
{f}
</Typography>
))}
</Box>
)}
{preflight.conflicts.length > 0 && (
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mt: 1.5 }}>
Some items already exist and will be added as copies.
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
<Button onClick={onClose} sx={{ textTransform: 'none', color: c.text.secondary }}>
Cancel
</Button>
<Button
variant="contained"
onClick={onConfirm}
disabled={committing}
startIcon={committing ? <CircularProgress size={14} sx={{ color: c.text.inverse }} /> : undefined}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.border.medium, color: c.text.muted },
textTransform: 'none',
borderRadius: `${c.radius.md}px`,
px: 2.5,
py: 0.6,
fontSize: '0.85rem',
fontWeight: 600,
boxShadow: 'none',
}}
>
Add to OpenSwarm
</Button>
</Box>
</Box>
</>
)}
</Dialog>
);
};
export default ImportModal;
@@ -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<string, string> = {
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,
}) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<Typography
sx={{
fontSize: '0.6rem',
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: c.text.tertiary,
minWidth: 60,
flexShrink: 0,
}}
>
{tag}
</Typography>
<Typography
sx={{
fontSize: '0.85rem',
color: faded ? c.text.muted : c.text.primary,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{name}
</Typography>
{detail && (
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, flexShrink: 0 }}>{detail}</Typography>
)}
</Box>
);
return (
<Box
sx={{
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.surface,
px: 1.5,
py: 0.5,
}}
>
<Row tag={KIND_LABEL[summary.root.type] || summary.root.type} name={summary.root.name} />
{summary.includes.map((it, i) => (
<Row key={`inc-${i}`} tag={KIND_LABEL[it.type] || it.type} name={it.name} detail={it.detail} />
))}
{summary.requirements.length > 0 && (
<Box sx={{ mt: 0.5, pt: 0.5, borderTop: `1px solid ${c.border.subtle}` }}>
{summary.requirements.map((r, i) => (
<Row key={`req-${i}`} tag="Needs" name={r.label} detail={r.detail} faded />
))}
</Box>
)}
</Box>
);
};
export default IncludesList;
@@ -0,0 +1,69 @@
// 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';
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<Props> = ({
target,
size = 'small',
variant = 'icon',
tone = 'plain',
iconFontSize = 18,
onOpen,
}) => {
const c = useClaudeTokens();
const [open, setOpen] = useState(false);
const start = (e: React.MouseEvent) => {
e.stopPropagation();
onOpen?.();
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' ? (
<MenuItem onClick={start} sx={{ fontSize: '0.85rem', color: c.text.primary, gap: 1 }}>
<ListItemIcon sx={{ minWidth: 0, color: c.text.tertiary }}>
<IosShareIcon sx={{ fontSize: 16 }} />
</ListItemIcon>
Share
</MenuItem>
) : (
<Tooltip title="Share">
<IconButton size={size} onClick={start} sx={iconSx}>
<IosShareIcon sx={{ fontSize: iconFontSize }} />
</IconButton>
</Tooltip>
)}
{open && <ShareModal target={target} open={open} onClose={() => setOpen(false)} />}
</>
);
};
export default ShareButton;
@@ -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<Props> = ({ target, open, onClose }) => {
const c = useClaudeTokens();
const [preflight, setPreflight] = useState<ExportPreflight | null>(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,
) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
p: 1.5,
mb: 1,
borderRadius: `${c.radius.md}px`,
border: `1px solid ${selected ? c.accent.primary : c.border.subtle}`,
bgcolor: selected ? `${c.accent.primary}0d` : 'transparent',
opacity: disabled ? 0.5 : 1,
cursor: disabled ? 'default' : 'default',
}}
>
<Box sx={{ color: selected ? c.accent.primary : c.text.tertiary, display: 'flex' }}>{icon}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontSize: '0.88rem', fontWeight: 600, color: c.text.primary }}>{title}</Typography>
{chip && (
<Chip
label={chip}
size="small"
sx={{ height: 18, fontSize: '0.62rem', bgcolor: c.bg.secondary, color: c.text.muted }}
/>
)}
</Box>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>{subtitle}</Typography>
</Box>
</Box>
);
return (
<>
<Dialog
open={open}
onClose={onClose}
maxWidth={false}
PaperProps={{
sx: {
width: 440,
maxWidth: '92vw',
bgcolor: c.bg.page,
borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`,
boxShadow: c.shadow.lg,
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2.5, pb: 1 }}>
<Typography sx={{ fontSize: '1.05rem', fontWeight: 700, color: c.text.primary }}>
Share {target.name}
</Typography>
<IconButton size="small" onClick={onClose} sx={{ color: c.text.tertiary }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
<Box sx={{ px: 3, pb: 3 }}>
<Box sx={{ mb: 2 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
<CircularProgress size={20} sx={{ color: c.accent.primary }} />
</Box>
) : error ? (
<Box sx={{ py: 1 }}>
<Typography sx={{ fontSize: '0.85rem', color: c.text.secondary, mb: 1 }}>{error}</Typography>
<Button size="small" onClick={load} sx={{ textTransform: 'none', color: c.accent.primary }}>
Try again
</Button>
</Box>
) : preflight ? (
<IncludesList summary={preflight.summary} />
) : null}
</Box>
{optionRow(true, <DownloadIcon sx={{ fontSize: 20 }} />, 'Download .swarm file', 'Save a file you can send to anyone.')}
{optionRow(
false,
<LinkIcon sx={{ fontSize: 20 }} />,
'Create share link',
'A link that opens straight in OpenSwarm.',
true,
'Coming soon',
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
onClick={handleDownload}
disabled={!preflight || downloading}
startIcon={
downloading ? (
<CircularProgress size={14} sx={{ color: c.text.inverse }} />
) : (
<DownloadIcon sx={{ fontSize: 16 }} />
)
}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
'&.Mui-disabled': { bgcolor: c.border.medium, color: c.text.muted },
textTransform: 'none',
borderRadius: `${c.radius.md}px`,
px: 2.5,
py: 0.6,
fontSize: '0.85rem',
fontWeight: 600,
boxShadow: 'none',
}}
>
Download .swarm
</Button>
</Box>
</Box>
</Dialog>
<Snackbar
open={!!toast}
autoHideDuration={3000}
onClose={() => setToast('')}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="success"
variant="outlined"
onClose={() => setToast('')}
sx={{ bgcolor: c.bg.surface, color: c.text.primary, border: `1px solid ${c.border.medium}`, fontSize: '0.82rem' }}
>
{toast}
</Alert>
</Snackbar>
</>
);
};
export default ShareModal;
@@ -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<string> {
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<ExportPreflight> {
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<void> {
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<ImportPreflight> {
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<ImportCommitResult> {
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();
}
@@ -0,0 +1,60 @@
// 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<string, number>;
}
export interface ExportPreflight {
ok: boolean;
summary: BundleSummary;
filename: string;
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[];
}
export interface ImportCommitResult {
ok: boolean;
root_type: ShareKind;
root_id: string;
created: Record<string, string[]>;
unresolved_requirements: RequirementView[];
}
+8 -11
View File
@@ -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<string> = [
'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 }) => {
@@ -671,6 +665,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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;
@@ -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;
@@ -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<ThinkingLabel> = [
{ 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' },
];
@@ -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<string, LucideIcon> = {
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<DashboardGlyphProps> = ({ name, size = 16 }) => {
const c = useClaudeTokens();
const title = (name || '').trim();
const Icon = useMemo(() => (title ? pickIcon(title) : null), [title]);
if (Icon) {
return <Icon size={size} strokeWidth={1.75} color={c.accent.primary} />;
}
// 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 <LayoutDashboard size={size} strokeWidth={1.75} color={c.accent.primary} />;
}
return (
<Box
sx={{
width: size,
height: size,
borderRadius: '4px',
bgcolor: `${c.accent.primary}1F`,
color: c.accent.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.62,
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
}}
>
{letter}
</Box>
);
};
export default DashboardGlyph;
@@ -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 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';
import type { Output } from '@/shared/state/outputsSlice';
@@ -111,20 +112,24 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
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` } : {},
}}
>
<DashboardIcon sx={{ fontSize: 'small', color: c.accent.primary }} />
<Box sx={{ display: 'flex', flexShrink: 0 }}>
<DashboardGlyph name={dashboardName} size={16} />
</Box>
<Typography
noWrap
sx={{
@@ -142,12 +147,20 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
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 && (
<Box sx={{ ml: 0.25, display: 'flex' }}>
<ShareButton
target={{ kind: 'dashboard', id: dashboardId, name: dashboardName || 'Dashboard' }}
iconFontSize={15}
/>
</Box>
)}
</Box>
{/* Dropdown overlay */}
@@ -167,10 +180,10 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
>
<Box
sx={{
mt: 0.5,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderTop: 'none',
borderRadius: `0 0 ${c.radius.lg}px ${c.radius.lg}px`,
borderRadius: `${c.radius.lg}px`,
boxShadow: c.shadow.md,
py: 0.75,
overflowY: 'auto',
@@ -303,7 +303,6 @@ const BrowserCard: React.FC<Props> = ({
// (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<Props> = ({
} 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,
},
}),
);
@@ -10,7 +10,7 @@ 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 { 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';
@@ -76,6 +76,23 @@ const DashboardViewCard: React.FC<Props> = ({
const dispatch = useAppDispatch();
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(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<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | null>(null);
@@ -315,7 +332,9 @@ const DashboardViewCard: React.FC<Props> = ({
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`
@@ -433,6 +452,8 @@ const DashboardViewCard: React.FC<Props> = ({
output={output}
inputData={inputData}
backendResult={backendResult}
interactive={interactive}
onAppClicked={() => dispatch(setActiveViewCardId(output.id))}
/>
<BuildingOverlay show={showBuildingOverlay} />
</Box>
@@ -558,7 +579,9 @@ const DashboardOutputPreview: React.FC<{
output: Output;
inputData: Record<string, any>;
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;
@@ -686,6 +709,8 @@ const DashboardOutputPreview: React.FC<{
inputData={inputData}
backendResult={backendResult}
onConsoleMessage={handleConsoleMessage}
interactive={interactive}
onAppClicked={onAppClicked}
/>
);
};
@@ -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 <webview>
// 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.
@@ -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();
@@ -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));
}
}
@@ -298,13 +298,16 @@ export function useDashboardLifecycle({
const namedOnFirstMessageRef = useRef<string | null>(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]);
}
@@ -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,
@@ -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); }
};
@@ -8,11 +8,12 @@ interface ConnectCtx {
setPollTimer: (v: any) => void;
fetchStatus: (opts?: { preserveTransient?: boolean }) => Promise<any>;
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<typeof setTimeout> | 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);
}
+13
View File
@@ -51,6 +51,9 @@ import {
RegistrySkillDetail,
} 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 {
@@ -314,6 +317,15 @@ const Skills: React.FC = () => {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', px: 2, pt: 2, pb: 1 }}>
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.text.primary }}>Skills</Typography>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<Tooltip title="Import .swarm">
<IconButton
size="small"
onClick={() => window.dispatchEvent(new CustomEvent(IMPORT_OPEN_EVENT))}
sx={{ color: c.text.tertiary, '&:hover': { color: c.text.primary } }}
>
<UploadFileIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="Search">
<IconButton
size="small"
@@ -619,6 +631,7 @@ const Skills: React.FC = () => {
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
<ShareButton target={{ kind: 'skill', id: selectedLocal.id, name: selectedLocal.name }} />
<Tooltip title="Edit">
<IconButton size="small" onClick={() => openEdit(selectedLocal)} sx={{ color: c.text.tertiary, '&:hover': { color: c.accent.primary } }}>
<EditIcon sx={{ fontSize: 18 }} />
@@ -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<Props> = ({ output, onClick, onDelete, onRun }) => {
<PlayArrowIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<ShareButton target={{ kind: 'app', id: output.id, name: output.name }} tone="chip" iconFontSize={16} />
<Tooltip title="Delete">
<IconButton
size="small"
@@ -34,6 +34,7 @@ import { createDraftSession, removeDraftSession, fetchSession } from '@/shared/s
import { createOutput, updateOutput, upsertOutput, fetchOutputs, Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { truncateForTitle } from '@/shared/state/sessionDisplay';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ShareButton from '@/app/components/share/ShareButton';
import AgentChat from '../AgentChat/AgentChat';
import RefreshIcon from '@mui/icons-material/Refresh';
import ViewPreview, { ViewPreviewHandle } from './ViewPreview';
@@ -1234,6 +1235,11 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
}}
/>
{effectiveId && (
<Box sx={{ ml: 'auto', alignSelf: 'center' }}>
<ShareButton target={{ kind: 'app', id: effectiveId, name: name || 'Untitled App' }} />
</Box>
)}
</Box>
{/* Tab bar */}
+64 -7
View File
@@ -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<ViewPreviewHandle, Props>(({
style,
onConsoleMessage,
onContentLoad,
interactive = false,
onAppClicked,
}, ref) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const webviewRef = useRef<any>(null);
@@ -219,22 +225,72 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}
}, [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<ViewPreviewHandle, Props>(({
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).
@@ -60,6 +60,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;
}
@@ -99,6 +100,8 @@ export interface DashboardLayoutState {
suspendedBrowserCards: Record<string, { dataUrl: string; capturedAt: number }>;
/** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */
endingBrowserCards: Record<string, { status: 'completed' | 'error'; at: number }>;
/** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */
activeViewCardId: string | null;
}
const initialState: DashboardLayoutState = {
@@ -117,6 +120,7 @@ const initialState: DashboardLayoutState = {
pendingFocusNoteId: null,
suspendedBrowserCards: {},
endingBrowserCards: {},
activeViewCardId: null,
};
interface LayoutPayload {
@@ -617,6 +621,11 @@ const dashboardLayoutSlice = createSlice({
removeViewCard(state, action: PayloadAction<string>) {
delete state.viewCards[action.payload];
if (state.activeViewCardId === action.payload) state.activeViewCardId = null;
},
setActiveViewCardId(state, action: PayloadAction<string | null>) {
state.activeViewCardId = action.payload;
},
addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) {
@@ -707,6 +716,14 @@ const dashboardLayoutSlice = createSlice({
delete state.endingBrowserCards[action.payload];
},
keepBrowserCardOpen(state, action: PayloadAction<string>) {
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] = {
@@ -1132,6 +1149,7 @@ export const {
setViewCardPosition,
setViewCardSize,
removeViewCard,
setActiveViewCardId,
addBrowserCard,
addBrowserCardFromBackend,
setBrowserCardPosition,
@@ -1141,6 +1159,7 @@ export const {
resumeBrowserCard,
markBrowserCardEnding,
cancelBrowserCardEnding,
keepBrowserCardOpen,
pasteBrowserCard,
updateBrowserCardUrl,
addBrowserTab,
@@ -51,6 +51,28 @@ const subscriptionsSlice = createSlice({
setSubscriptionStatus(state, action: PayloadAction<SubscriptionStatus | null>) {
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[] = [];
+9 -3
View File
@@ -23,7 +23,7 @@ import {
clearTurnLabel,
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards } from '../state/dashboardLayoutSlice';
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeInParentColumn, setBrowserCardPosition, setGlowingBrowserCards } 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