mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 20:44:50 +02:00
[aidan] chore/merge: merge eric dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -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
@@ -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">✓</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>'
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user