mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[aidan] chore/merge: resolve conflicts merging eric/dev
This commit is contained in:
@@ -39,24 +39,45 @@ 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() {
|
||||
// Claude OAuth completion. Anthropic only whitelists localhost:20128/callback as the
|
||||
// redirect, so Claude's callback HAS to land here on 9Router (unlike Gemini, which goes
|
||||
// straight to the backend, and Codex, which has its own :1455 listener). We previously
|
||||
// 302'd the user's browser across ports to the backend, but a cross-port plain-http
|
||||
// localhost redirect silently fails in browsers that HTTPS-upgrade or block it, which
|
||||
// hung "Connecting…" for some users (browser-dependent, Claude-only). Fix: run the code
|
||||
// exchange server-to-server (9Router -> backend, same machine, no browser in the loop)
|
||||
// and hand the browser a static close-page. The browser only ever talks to :20128.
|
||||
// Idempotent via the backend's _pending_oauth.pop + _completed_oauth.
|
||||
(function patchOauthCallbackExchange() {
|
||||
try {
|
||||
const http = require('http');
|
||||
const origEmit = http.Server.prototype.emit;
|
||||
const closePage =
|
||||
'<!doctype html><meta charset="utf-8"><body style="font-family:-apple-system,system-ui;' +
|
||||
'text-align:center;color:#888;padding-top:80px;background:#1a1a1a">' +
|
||||
'You can close this tab, and any other Claude login tab still open.</body>';
|
||||
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();
|
||||
const path = '/api/subscriptions/callback' + url.slice('/callback'.length);
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(closePage); } catch (_) {}
|
||||
};
|
||||
try {
|
||||
const proxyReq = http.request(
|
||||
{ host: '127.0.0.1', port: backendPort, path: path, method: 'GET' },
|
||||
(proxyRes) => { proxyRes.resume(); proxyRes.on('end', finish); }
|
||||
);
|
||||
proxyReq.on('error', finish);
|
||||
proxyReq.setTimeout(5000, () => { try { proxyReq.destroy(); } catch (_) {} finish(); });
|
||||
proxyReq.end();
|
||||
} catch (_) { finish(); }
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -16,6 +16,7 @@ from backend.apps.agents.core.ws_manager import ws_manager
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.tools_lib.tools_lib import (
|
||||
_load_all as load_all_tools,
|
||||
_save as save_tool,
|
||||
_sanitize_server_name,
|
||||
derive_mcp_config,
|
||||
load_builtin_permissions,
|
||||
@@ -23,6 +24,8 @@ from backend.apps.tools_lib.tools_lib import (
|
||||
refresh_airtable_token,
|
||||
refresh_google_token,
|
||||
refresh_hubspot_token,
|
||||
resolve_policy_slot,
|
||||
save_builtin_permissions,
|
||||
save_trusted_sensitive_paths,
|
||||
)
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
@@ -36,6 +39,8 @@ from backend.apps.agents.core.error_classify import (
|
||||
p_is_transient_capacity_error,
|
||||
p_is_unknown_model_error,
|
||||
p_extract_reset_hint,
|
||||
parse_retry_after,
|
||||
redact_for_telemetry,
|
||||
)
|
||||
from backend.apps.agents.manager.session.session_store import (
|
||||
_delete_session_file,
|
||||
@@ -62,12 +67,15 @@ from backend.apps.agents.manager.session.history_compaction import (
|
||||
from backend.apps.agents.manager.prompt.prompt_context import (
|
||||
_build_browser_context,
|
||||
_build_selected_app_context,
|
||||
_build_selected_settings_context,
|
||||
_build_connected_tools_context,
|
||||
_build_mcp_registry_summary,
|
||||
_compose_system_prompt,
|
||||
_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,
|
||||
@@ -134,6 +142,11 @@ def get_workflow_step_usage(session_id: str) -> dict[str, dict[str, bool]]:
|
||||
return mem.step_usage if mem is not None else {}
|
||||
|
||||
|
||||
p_VIEW_BUILDER_RENDER_MAX_RETRIES = 2
|
||||
p_view_builder_render_retry_counts: dict[str, int] = {}
|
||||
p_view_builder_dirty_sessions: set[str] = set()
|
||||
|
||||
|
||||
def _apply_context_window(session, settings=None) -> None:
|
||||
"""Set session.context_window from the registry for its (provider, model).
|
||||
|
||||
@@ -187,7 +200,11 @@ class AgentManager:
|
||||
def __init__(self):
|
||||
self.sessions: dict[str, AgentSession] = {}
|
||||
self.tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
# Live mirror of the in-flight streamed assistant text per session, so a
|
||||
# stop can persist the partial reply instantly instead of waiting out the
|
||||
# multi-second SDK teardown the cancel handler sits behind.
|
||||
self._live_partial: dict[str, dict] = {}
|
||||
|
||||
def _resolve_mode(self, mode_id: str) -> tuple[list[str], str | None, str | None]:
|
||||
return _resolve_mode(mode_id, get_all_tool_names)
|
||||
|
||||
@@ -242,8 +259,9 @@ class AgentManager:
|
||||
continue
|
||||
|
||||
if tool.auth_type == "oauth2" and tool.auth_status == "connected":
|
||||
if tool.name.lower() == "discord":
|
||||
# Discord uses a shared bot token from .env, not user OAuth tokens.
|
||||
if tool.name.lower() in ("discord", "github"):
|
||||
# Discord uses a shared bot token; GitHub OAuth-app tokens don't
|
||||
# expire and carry no refresh_token. Nothing to refresh either way.
|
||||
refreshed = True
|
||||
elif tool.name.lower() == "airtable":
|
||||
refreshed = await refresh_airtable_token(tool)
|
||||
@@ -264,6 +282,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)
|
||||
|
||||
@@ -471,7 +512,7 @@ class AgentManager:
|
||||
def _resolve_context_paths(self, context_paths: list | None) -> str:
|
||||
return _resolve_context_paths(context_paths)
|
||||
|
||||
async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None):
|
||||
async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None, selected_setting_ids: list[str] | None = None):
|
||||
"""Run the Claude Agent SDK query loop for a session."""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
@@ -758,30 +799,39 @@ class AgentManager:
|
||||
return policy, None
|
||||
|
||||
def _get_effective_policy(tool_name: str) -> str:
|
||||
"""Return 'always_allow', 'deny', or 'ask' for any tool."""
|
||||
if tool_name in _builtin_perms:
|
||||
return _builtin_perms[tool_name]
|
||||
|
||||
import re as _re
|
||||
|
||||
bm = _re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name)
|
||||
if bm:
|
||||
return _builtin_perms.get(bm.group(1), _default_for(bm.group(1)))
|
||||
|
||||
im = _re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name)
|
||||
if im:
|
||||
return _builtin_perms.get(im.group(1), _default_for(im.group(1)))
|
||||
|
||||
m = _re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
|
||||
if m:
|
||||
server_slug, mcp_tool_name = m.group(1), m.group(2)
|
||||
for t in load_all_tools():
|
||||
if not t.mcp_config or not t.enabled:
|
||||
continue
|
||||
if _sanitize_server_name(t.name) == server_slug:
|
||||
return t.tool_permissions.get(mcp_tool_name, "ask")
|
||||
"""Return 'always_allow', 'deny', or 'ask' for any tool. Keyed through
|
||||
the shared resolver so the read slot matches the write slot exactly."""
|
||||
tools = load_all_tools()
|
||||
slot = resolve_policy_slot(tool_name, tools)
|
||||
if slot.store == "builtin":
|
||||
return _builtin_perms.get(slot.key, _default_for(slot.key))
|
||||
if slot.key is not None:
|
||||
for t in tools:
|
||||
if t.id == slot.key:
|
||||
return t.tool_permissions.get(slot.action, "ask")
|
||||
return _default_for(tool_name)
|
||||
|
||||
def _set_tool_policy(tool_name: str, policy: str) -> None:
|
||||
"""Inverse of _get_effective_policy: persist `policy` into the SAME slot
|
||||
the gate reads, AND update the live in-memory snapshot, so an 'Always
|
||||
approve' takes effect for this running agent, not only after a restart.
|
||||
(The old code wrote the raw tool name to the file and never touched the
|
||||
captured _builtin_perms, so it behaved like a one-time accept.)"""
|
||||
tools = load_all_tools()
|
||||
slot = resolve_policy_slot(tool_name, tools)
|
||||
if slot.store == "builtin":
|
||||
_builtin_perms[slot.key] = policy
|
||||
perms = load_builtin_permissions()
|
||||
perms[slot.key] = policy
|
||||
save_builtin_permissions(perms)
|
||||
return
|
||||
if slot.key is not None:
|
||||
for t in tools:
|
||||
if t.id == slot.key:
|
||||
t.tool_permissions[slot.action] = policy
|
||||
save_tool(t)
|
||||
return
|
||||
|
||||
async def _request_user_approval(
|
||||
tool_name: str,
|
||||
tool_input,
|
||||
@@ -839,6 +889,15 @@ class AgentManager:
|
||||
except Exception:
|
||||
logger.exception("Failed to persist trusted sensitive path")
|
||||
|
||||
# "Always approve" button: persist the tool's policy so it stops
|
||||
# prompting. The guards above (sensitive/catastrophic) re-fire even
|
||||
# on always_allow, so this can't disarm an rm -rf or a key-path write.
|
||||
if decision.get("behavior") == "allow" and decision.get("set_always_allow"):
|
||||
try:
|
||||
_set_tool_policy(tool_name, "always_allow")
|
||||
except Exception:
|
||||
logger.exception("Failed to persist always-allow for %s", tool_name)
|
||||
|
||||
approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000)
|
||||
try:
|
||||
# Append to the session's approval log so a reload
|
||||
@@ -945,11 +1004,84 @@ 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}
|
||||
# One mid-run connect offer per session: a stuck agent fires the loop-breaker repeatedly,
|
||||
# but the user should see the "connect this MCP" card once, not on every retry.
|
||||
_mcp_offer_sent = {"done": False}
|
||||
|
||||
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:
|
||||
_gated = self._gated_mcp_server_names(session.allowed_tools, session.active_mcps)
|
||||
_reason = toolsearch_loop_redirect(_ts_loop["n"], _gated)
|
||||
if _reason:
|
||||
logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {session_id} (n={_ts_loop['n']})")
|
||||
# 2B-MCP: also surface a one-click connect offer to the USER for the vetted
|
||||
# gated servers the agent keeps reaching for. Suggest-only: this just shows a
|
||||
# card on the same channel the preflight uses; activation still requires
|
||||
# MCPActivate + the dispatch gate, so it opens no side channel. Once per run,
|
||||
# fail-open (an offer hiccup must never block the agent).
|
||||
if not _mcp_offer_sent["done"]:
|
||||
try:
|
||||
from backend.apps.agents.core.mcp_preflight import offer_for_gated_server
|
||||
_s = load_settings()
|
||||
_offers = [o for o in (offer_for_gated_server(n, _s) for n in _gated) if o]
|
||||
if _offers:
|
||||
_mcp_offer_sent["done"] = True
|
||||
await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", {
|
||||
"session_id": session_id,
|
||||
"suggestions": _offers,
|
||||
"is_vague": False,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("mid-run MCP connect offer skipped", exc_info=True)
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": hook_event,
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": _reason,
|
||||
}
|
||||
}
|
||||
else:
|
||||
_ts_loop["n"] = 0
|
||||
|
||||
# MCPSearch is the agent saying "I need an integration I don't have" (e.g. "no email
|
||||
# connected"). Don't make the user read a wall of options: fire the same curated connect
|
||||
# card the launch preflight uses, keyed to their original request. Non-blocking (the search
|
||||
# proceeds) and once per run; covers the common path the ToolSearch-loop branch misses
|
||||
# because a capable model does one MCPSearch instead of thrashing. Suggest-only as ever.
|
||||
if (tool_name.endswith("MCPSearch") or tool_name.endswith("MCPList")) and not _mcp_offer_sent["done"]:
|
||||
_mcp_offer_sent["done"] = True
|
||||
|
||||
async def _offer_from_prompt():
|
||||
try:
|
||||
from backend.apps.agents.core.mcp_preflight import run_preflight
|
||||
result = await run_preflight(prompt, task_id=session_id, require_vague=False)
|
||||
offers = result.get("suggestions", [])
|
||||
if offers:
|
||||
await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", {
|
||||
"session_id": session_id,
|
||||
"suggestions": offers,
|
||||
"is_vague": False,
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("MCPSearch-triggered connect offer skipped", exc_info=True)
|
||||
|
||||
asyncio.create_task(_offer_from_prompt())
|
||||
|
||||
if tool_name and tool_name != "AskUserQuestion":
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
if _is_claude_schedule_skill(tool_name, tool_input):
|
||||
@@ -1072,26 +1204,38 @@ class AgentManager:
|
||||
except Exception:
|
||||
content = str(raw_response)
|
||||
|
||||
# When the agent writes/edits a file inside a live App
|
||||
# Builder workspace, surface any build-server errors
|
||||
# (vite/babel/tsc/uvicorn) that landed in the runtime's
|
||||
# stderr in the moments after the write. Without this the
|
||||
# agent walks away from broken JSX, the iframe shows a red
|
||||
# overlay, and the user has to copy-paste the error back.
|
||||
# ~400ms gives vite's file watcher + babel parse enough
|
||||
# time to react; the post_tool_hook runs once per tool so
|
||||
# the added latency is acceptable for the win.
|
||||
hook_tool_name_for_errors = input_data.get("tool_name", "")
|
||||
if hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit"):
|
||||
tool_in = input_data.get("tool_input") or {}
|
||||
file_path = tool_in.get("file_path") or tool_in.get("path") or ""
|
||||
wrote_files = hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit")
|
||||
tool_in = input_data.get("tool_input") or {}
|
||||
file_path = tool_in.get("file_path") or tool_in.get("path") or ""
|
||||
wrote_frontend_file = wrote_files and "/frontend/" in file_path
|
||||
installed_pkg = False
|
||||
if hook_tool_name_for_errors == "Bash":
|
||||
bash_in = input_data.get("tool_input") or {}
|
||||
cmd = (bash_in.get("command") or "").lower()
|
||||
installed_pkg = any(s in cmd for s in (
|
||||
"npm install", "npm i ", "npm uninstall", "npm ci",
|
||||
"pnpm add", "pnpm install", "pnpm remove",
|
||||
"yarn add", "yarn install", "yarn remove",
|
||||
))
|
||||
|
||||
if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg):
|
||||
p_view_builder_dirty_sessions.add(session.id)
|
||||
try:
|
||||
from backend.apps.outputs.runtime import (
|
||||
manager as outputs_runtime_manager,
|
||||
)
|
||||
outputs_runtime_manager.reset_render_state_for_workspace(session.id)
|
||||
except Exception:
|
||||
pass
|
||||
elif wrote_files:
|
||||
if file_path:
|
||||
try:
|
||||
await asyncio.sleep(0.4)
|
||||
from backend.apps.outputs.runtime import (
|
||||
manager as _outputs_runtime_manager,
|
||||
manager as outputs_runtime_manager,
|
||||
)
|
||||
errs = _outputs_runtime_manager.drain_errors_for_path(file_path)
|
||||
errs = outputs_runtime_manager.drain_errors_for_path(file_path)
|
||||
except Exception:
|
||||
errs = []
|
||||
if errs:
|
||||
@@ -1131,6 +1275,9 @@ class AgentManager:
|
||||
usage = raw_response.get("usage", {})
|
||||
if isinstance(usage, dict):
|
||||
sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
|
||||
# Pill-only lane: NEW (uncached) input, excludes the cached
|
||||
# static prefix so the bubble shows what this turn added.
|
||||
sub_tokens["input_fresh"] = usage.get("input_tokens", 0)
|
||||
sub_tokens["output"] = usage.get("output_tokens", 0)
|
||||
if raw_response.get("total_cost_usd"):
|
||||
sub_cost = raw_response["total_cost_usd"]
|
||||
@@ -1333,6 +1480,12 @@ class AgentManager:
|
||||
if app_ctx:
|
||||
composed_prompt = f"{composed_prompt}\n\n{app_ctx}" if composed_prompt else app_ctx
|
||||
|
||||
# The user can point the agent at specific Settings rows. Targeting
|
||||
# aid only; the settings tools are always on regardless.
|
||||
settings_ctx = _build_selected_settings_context(selected_setting_ids)
|
||||
if settings_ctx:
|
||||
composed_prompt = f"{composed_prompt}\n\n{settings_ctx}" if composed_prompt else settings_ctx
|
||||
|
||||
# Per-turn estimate of framework overhead (subtracted from displayed
|
||||
# input). Conservative on purpose so honest over-shows beat lies.
|
||||
# 16K Claude Code preset, 12K base+deferred tools, ~3K/MCP (real
|
||||
@@ -1451,6 +1604,27 @@ class AgentManager:
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
# Always-on settings-meta server: SettingsRead / SettingsWrite let the
|
||||
# agent read and edit its own OpenSwarm Settings autonomously. The
|
||||
# backend (/api/settings-meta) enforces the only two guardrails: it
|
||||
# can't disconnect the credential powering this run, and reads come
|
||||
# back with secrets redacted. No activation gate, Settings is the
|
||||
# agent's own house, not a third-party MCP.
|
||||
settings_meta_server_path = os.path.join(
|
||||
os.path.dirname(__file__), "settings_meta_server.py"
|
||||
)
|
||||
from backend.auth import get_auth_token as _get_auth_token4
|
||||
mcp_servers["openswarm-settings-meta"] = {
|
||||
"command": sys.executable,
|
||||
"args": [settings_meta_server_path],
|
||||
"env": {
|
||||
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
|
||||
"OPENSWARM_AUTH_TOKEN": _get_auth_token4(),
|
||||
"OPENSWARM_PARENT_SESSION_ID": session.id,
|
||||
},
|
||||
"type": "stdio",
|
||||
}
|
||||
|
||||
|
||||
# The CLI's built-in WebSearch/WebFetch wraps Anthropic's
|
||||
# web_search_20250305. For non-Claude primaries the CLI
|
||||
@@ -1695,6 +1869,59 @@ class AgentManager:
|
||||
if len(_stderr_buffer) > 500:
|
||||
del _stderr_buffer[:250]
|
||||
|
||||
async def stop_hook(input_data, tool_use_id, context):
|
||||
"""End-of-turn render gate for App Builder sessions. Reads the
|
||||
browser-reported render-state of the preview; if the app fails
|
||||
to render, blocks with the error so the agent fixes it, up to
|
||||
MAX_RETRIES then lets the stop through."""
|
||||
if session.mode != "view-builder":
|
||||
return {}
|
||||
if session.id not in p_view_builder_dirty_sessions:
|
||||
return {}
|
||||
from backend.apps.outputs.runtime import (
|
||||
manager as outputs_runtime_manager,
|
||||
)
|
||||
if outputs_runtime_manager.get(session.id) is None:
|
||||
return {}
|
||||
state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id)
|
||||
waited = 0.0
|
||||
while state is None and waited < 5.0:
|
||||
await asyncio.sleep(0.25)
|
||||
waited += 0.25
|
||||
state, error_text = outputs_runtime_manager.get_render_state_for_workspace(session.id)
|
||||
|
||||
if state != "error":
|
||||
p_view_builder_render_retry_counts.pop(session.id, None)
|
||||
p_view_builder_dirty_sessions.discard(session.id)
|
||||
return {}
|
||||
|
||||
attempts = p_view_builder_render_retry_counts.get(session.id, 0)
|
||||
if attempts >= p_VIEW_BUILDER_RENDER_MAX_RETRIES:
|
||||
logger.warning(
|
||||
"view-builder preview still failing after %s attempts for session %s; allowing stop",
|
||||
attempts, session.id,
|
||||
)
|
||||
p_view_builder_render_retry_counts.pop(session.id, None)
|
||||
p_view_builder_dirty_sessions.discard(session.id)
|
||||
return {}
|
||||
|
||||
p_view_builder_render_retry_counts[session.id] = attempts + 1
|
||||
logger.info(
|
||||
"view-builder render block (attempt %s/%s) for session %s",
|
||||
attempts + 1, p_VIEW_BUILDER_RENDER_MAX_RETRIES, session.id,
|
||||
)
|
||||
trimmed = error_text[-3000:] if len(error_text) > 3000 else error_text
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": (
|
||||
f"The preview failed to render (attempt {attempts + 1}/"
|
||||
f"{p_VIEW_BUILDER_RENDER_MAX_RETRIES}):\n\n"
|
||||
f"{trimmed}\n\n"
|
||||
"Fix this so the app renders before finishing; the user "
|
||||
"currently sees an error instead of the app."
|
||||
),
|
||||
}
|
||||
|
||||
options_kwargs = {
|
||||
"model": resolved_model,
|
||||
# 64 MB ceiling on the SDK <-> CLI JSON-RPC channel. The
|
||||
@@ -1710,6 +1937,7 @@ class AgentManager:
|
||||
"hooks": {
|
||||
"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])],
|
||||
"PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])],
|
||||
"Stop": [HookMatcher(matcher=None, hooks=[stop_hook])],
|
||||
},
|
||||
"allowed_tools": effective_allowed,
|
||||
"disallowed_tools": effective_disallowed,
|
||||
@@ -2041,6 +2269,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}")
|
||||
|
||||
@@ -2171,6 +2408,11 @@ class AgentManager:
|
||||
stream_text_msg_id = None
|
||||
stream_tool_msg_ids_ordered = []
|
||||
stream_block_index_map = {}
|
||||
# Mirror of the streamed assistant text. The SDK envelope that
|
||||
# normally commits a reply never lands when a turn is stopped
|
||||
# mid-stream, so without this the text the user just watched
|
||||
# appear would evaporate. Cleared the instant a block commits.
|
||||
_stream_text_accum = ""
|
||||
# Per-turn aggregate trackers for the consolidated thinking
|
||||
# message. We accumulate across every AssistantMessage in the
|
||||
# turn (think → tool → think → tool → answer) and stream
|
||||
@@ -2381,10 +2623,13 @@ class AgentManager:
|
||||
# baseline to get THIS TURN'S delta. Without subtracting,
|
||||
# the second turn's pill would show turn-1 work added
|
||||
# to turn-2 work, the third would show all three, etc.
|
||||
# Pill uses the FRESH lane (uncached input only). session.tokens
|
||||
# ["input"] stays full for the context-fullness bar + cost; the
|
||||
# bubble shows the NEW tokens this turn, not the cached re-reads.
|
||||
_cum_in = 0
|
||||
_cum_out = 0
|
||||
if isinstance(session.tokens, dict):
|
||||
_cum_in = int(session.tokens.get("input", 0) or 0)
|
||||
_cum_in = int(session.tokens.get("input_fresh", 0) or 0)
|
||||
_cum_out = int(session.tokens.get("output", 0) or 0)
|
||||
_cum_children_in = 0
|
||||
_cum_children_out = 0
|
||||
@@ -2395,7 +2640,7 @@ class AgentManager:
|
||||
_ct = getattr(_child, "tokens", None)
|
||||
if not isinstance(_ct, dict):
|
||||
continue
|
||||
_cum_children_in += int(_ct.get("input", 0) or 0)
|
||||
_cum_children_in += int(_ct.get("input_fresh", 0) or 0)
|
||||
_cum_children_out += int(_ct.get("output", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2413,18 +2658,14 @@ class AgentManager:
|
||||
_children_in = _cum_children_in
|
||||
_children_out = _cum_children_out
|
||||
|
||||
# Fresh input + output = the NEW tokens this turn. The old
|
||||
# framework-overhead subtraction is gone on purpose: it was an
|
||||
# estimate to strip the cached static prefix out of the full
|
||||
# input number, and the fresh lane already excludes that prefix
|
||||
# exactly, so subtracting it again would double-discount to ~0.
|
||||
_turn_total_tokens: int | None = (
|
||||
_parent_in + _parent_out + _children_in + _children_out
|
||||
)
|
||||
# Strip framework overhead so bubble shows what the user
|
||||
# actually controls. Floor at output so over-estimates can't
|
||||
# render absurdly small.
|
||||
if _turn_total_tokens and session.framework_overhead_tokens > 0:
|
||||
_adjusted = _turn_total_tokens - session.framework_overhead_tokens
|
||||
_floor = _parent_out + _children_out
|
||||
if _adjusted < _floor:
|
||||
_adjusted = _floor
|
||||
_turn_total_tokens = _adjusted
|
||||
if not _turn_total_tokens or _turn_total_tokens <= 0:
|
||||
_turn_total_tokens = None
|
||||
consolidated = Message(
|
||||
@@ -2469,6 +2710,7 @@ class AgentManager:
|
||||
|
||||
async def _run_streaming_turn():
|
||||
nonlocal stream_text_msg_id, stream_tool_msg_ids_ordered, stream_block_index_map
|
||||
nonlocal _stream_text_accum
|
||||
nonlocal _turn_number, _first_event, _current_turn_emitted
|
||||
# Per-turn thinking aggregation trackers (added for the
|
||||
# "Thought for Ns · M tokens" persisted label). Without
|
||||
@@ -2500,8 +2742,10 @@ class AgentManager:
|
||||
# Snapshot cumulative tokens at turn start;
|
||||
# subtracted at emit time for per-turn deltas.
|
||||
try:
|
||||
# Baselines track the SAME fresh lane the pill reads,
|
||||
# so the per-turn delta is fresh-minus-fresh.
|
||||
if isinstance(session.tokens, dict):
|
||||
_turn_baseline_session_in = int(session.tokens.get("input", 0) or 0)
|
||||
_turn_baseline_session_in = int(session.tokens.get("input_fresh", 0) or 0)
|
||||
_turn_baseline_session_out = int(session.tokens.get("output", 0) or 0)
|
||||
_ch_in = 0
|
||||
_ch_out = 0
|
||||
@@ -2511,7 +2755,7 @@ class AgentManager:
|
||||
_ct = getattr(_child, "tokens", None)
|
||||
if not isinstance(_ct, dict):
|
||||
continue
|
||||
_ch_in += int(_ct.get("input", 0) or 0)
|
||||
_ch_in += int(_ct.get("input_fresh", 0) or 0)
|
||||
_ch_out += int(_ct.get("output", 0) or 0)
|
||||
_turn_baseline_children_in = _ch_in
|
||||
_turn_baseline_children_out = _ch_out
|
||||
@@ -2635,6 +2879,12 @@ class AgentManager:
|
||||
if msg_id and delta_type == "text_delta":
|
||||
_text_chunk = delta.get("text", "")
|
||||
_turn_assistant_text_chars += len(_text_chunk)
|
||||
_stream_text_accum += _text_chunk
|
||||
self._live_partial[session_id] = {
|
||||
"msg_id": stream_text_msg_id,
|
||||
"text": _stream_text_accum,
|
||||
"branch_id": session.active_branch_id,
|
||||
}
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
@@ -2840,7 +3090,9 @@ class AgentManager:
|
||||
content=_asst_text,
|
||||
branch_id=session.active_branch_id,
|
||||
)
|
||||
session.messages.append(asst_msg)
|
||||
self._upsert_message(session, asst_msg)
|
||||
_stream_text_accum = ""
|
||||
self._live_partial.pop(session_id, None)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": asst_msg.model_dump(mode="json"),
|
||||
@@ -2849,7 +3101,7 @@ class AgentManager:
|
||||
for i, tu in enumerate(tool_uses):
|
||||
msg_id = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex
|
||||
tool_msg = Message(id=msg_id, role="tool_call", content=tu, branch_id=session.active_branch_id)
|
||||
session.messages.append(tool_msg)
|
||||
self._upsert_message(session, tool_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": tool_msg.model_dump(mode="json"),
|
||||
@@ -2903,6 +3155,9 @@ class AgentManager:
|
||||
_pre_out = int(_pre_usage.get("output_tokens", 0) or 0)
|
||||
if _pre_total_in > 0:
|
||||
session.tokens["input"] = _pre_total_in
|
||||
# Pill reads the fresh lane: uncached input only,
|
||||
# so re-read/cached context doesn't inflate it.
|
||||
session.tokens["input_fresh"] = _pre_in
|
||||
if _pre_out > 0:
|
||||
session.tokens["output"] = _pre_out
|
||||
except Exception:
|
||||
@@ -2970,6 +3225,7 @@ class AgentManager:
|
||||
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
||||
total_input = inp + cache_create + cache_read
|
||||
session.tokens["input"] = total_input
|
||||
session.tokens["input_fresh"] = inp
|
||||
session.tokens["output"] = out
|
||||
|
||||
cost = getattr(message, "total_cost_usd", None)
|
||||
@@ -3113,6 +3369,8 @@ class AgentManager:
|
||||
"message_id": stream_text_msg_id,
|
||||
})
|
||||
stream_text_msg_id = None
|
||||
_stream_text_accum = ""
|
||||
self._live_partial.pop(session_id, None)
|
||||
for _tool_msg_id in stream_tool_msg_ids_ordered:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
@@ -3155,7 +3413,24 @@ class AgentManager:
|
||||
except Exception:
|
||||
logger.exception("auto-continuation dispatch failed")
|
||||
except asyncio.CancelledError:
|
||||
session.status = "stopped"
|
||||
# Only act if we're still the session's live task. A user stop pops
|
||||
# this task (stop_agent already finalized status + partial), and a
|
||||
# follow-up message may have started a newer turn; either way this
|
||||
# dying task must NOT clobber the live status or pop the new turn's
|
||||
# in-flight partial mirror.
|
||||
if self.tasks.get(session_id) is asyncio.current_task():
|
||||
session.status = "stopped"
|
||||
# A cancelled turn desyncs the CLI's resume transcript from
|
||||
# session.messages (the SDK never recorded the interrupted
|
||||
# turn), so force the next turn to rebuild history from
|
||||
# session.messages, else resume/follow-ups replay a transcript
|
||||
# with no trace of the stopped reply ("nothing to continue").
|
||||
session.needs_fresh_session = True
|
||||
# Persist whatever streamed before the cancel (edit / branch
|
||||
# switch paths; the user-stop path already did this in stop_agent).
|
||||
await self._commit_partial_now(session)
|
||||
stream_text_msg_id = None
|
||||
_stream_text_accum = ""
|
||||
except Exception as e:
|
||||
logger.exception(f"Agent {session_id} error: {e}")
|
||||
session.status = "error"
|
||||
@@ -3232,10 +3507,29 @@ class AgentManager:
|
||||
"framework_overhead_tokens": session.framework_overhead_tokens,
|
||||
"active_mcps_count": len(session.active_mcps),
|
||||
"messages_count": len(session.messages),
|
||||
"error_preview": (str(e) or "")[:500],
|
||||
"error_preview": redact_for_telemetry(str(e), limit=500),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
|
||||
elif p_is_transient_capacity_error(e, extra_text=_stderr_tail):
|
||||
# A genuine throttle (429/overload/capacity) that already burned
|
||||
# the whole silent-backoff budget (the only way one reaches here).
|
||||
# It's a limit, not a failure, so don't append a system-message
|
||||
# card; emit a transient signal for the muted pill and mark the
|
||||
# turn completed so it doesn't read as an error.
|
||||
session.status = "completed"
|
||||
if stream_text_msg_id:
|
||||
try:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": stream_text_msg_id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
await ws_manager.send_to_session(session_id, "agent:rate_limited", {
|
||||
"session_id": session_id,
|
||||
"retry_after_s": parse_retry_after(e, _stderr_tail),
|
||||
})
|
||||
elif p_is_free_trial_exhausted(e, extra_text=_stderr_tail):
|
||||
# Free runs spent. Flip back to own_key and show a friendly
|
||||
# "connect a model" upsell instead of a raw 402.
|
||||
@@ -3362,7 +3656,8 @@ class AgentManager:
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
|
||||
"error_preview": (str(e) or "")[:400],
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
"stderr_tail": redact_for_telemetry(_stderr_tail),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic model_error failed", exc_info=True)
|
||||
@@ -3407,7 +3702,8 @@ class AgentManager:
|
||||
"model": session.model,
|
||||
"provider": session.provider,
|
||||
"connection_mode": getattr(load_settings(), "connection_mode", "own_key"),
|
||||
"error_preview": (str(e) or "")[:400],
|
||||
"error_preview": redact_for_telemetry(str(e), limit=400),
|
||||
"stderr_tail": redact_for_telemetry(_stderr_tail),
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic model_error failed", exc_info=True)
|
||||
@@ -3430,7 +3726,15 @@ class AgentManager:
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
finally:
|
||||
if session_id in self.sessions:
|
||||
# Only the session's live task finalizes. A stopped task (popped by
|
||||
# stop_agent, which already finalized status + saved) or one
|
||||
# superseded by a newer turn must not pop the new turn's partial
|
||||
# mirror, broadcast a stale terminal status, or overwrite the
|
||||
# snapshot the live turn is writing.
|
||||
_is_live_task = self.tasks.get(session_id) is asyncio.current_task()
|
||||
if _is_live_task:
|
||||
self._live_partial.pop(session_id, None)
|
||||
if session_id in self.sessions and _is_live_task:
|
||||
# For canvas-launched App Builder sessions, the workspace
|
||||
# folder IS the session_id (see launch_agent), so meta.json
|
||||
# lives at outputs_workspace/<session_id>/meta.json. Read it
|
||||
@@ -3616,6 +3920,7 @@ class AgentManager:
|
||||
hidden: bool = False,
|
||||
selected_browser_ids: list[str] | None = None,
|
||||
selected_app_output_ids: list[str] | None = None,
|
||||
selected_setting_ids: list[str] | None = None,
|
||||
client_message_id: str | None = None,
|
||||
prepend_context: str | None = None,
|
||||
):
|
||||
@@ -3752,7 +4057,7 @@ class AgentManager:
|
||||
if fast_verdict != "no":
|
||||
task = asyncio.create_task(self._run_browser_fast_path(session_id, model_prompt, selected_browser_ids, fast_brief, fast_verdict))
|
||||
else:
|
||||
task = asyncio.create_task(self._run_agent_loop(session_id, model_prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids))
|
||||
task = asyncio.create_task(self._run_agent_loop(session_id, model_prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids, selected_setting_ids=selected_setting_ids))
|
||||
self.tasks[session_id] = task
|
||||
|
||||
async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None, brief: str = "", verdict: str = "act"):
|
||||
@@ -3909,21 +4214,88 @@ class AgentManager:
|
||||
session.pending_approvals = []
|
||||
|
||||
session.status = "stopped"
|
||||
session.needs_fresh_session = True
|
||||
if not session.closed_at:
|
||||
session.closed_at = datetime.now()
|
||||
# Persist the partial reply NOW, before tearing down the SDK. The
|
||||
# cancel handler also does this, but it sits behind the generator's
|
||||
# teardown, which can take several seconds; doing it here means the
|
||||
# streamed text stays put the instant Stop is pressed instead of
|
||||
# blinking out and reappearing once teardown finishes.
|
||||
await self._commit_partial_now(session)
|
||||
await ws_manager.send_to_session(session_id, "agent:status", {
|
||||
"session_id": session_id,
|
||||
"status": "stopped",
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
# Snapshot now: the cancelled task's finally skips the save (it's no
|
||||
# longer the live task once we pop it below), so persist the partial
|
||||
# here or it'd live only in memory until the next turn / shutdown.
|
||||
try:
|
||||
_save_session(session_id, session.model_dump(mode="json"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task = self.tasks.get(session_id)
|
||||
# Drop the task from the registry immediately so a follow-up message
|
||||
# isn't rejected as "still running" while the cancelled task slowly
|
||||
# tears down (that window was eating user messages). Drain it in the
|
||||
# background; we've already captured the partial above.
|
||||
task = self.tasks.pop(session_id, None)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
asyncio.create_task(self._drain_task(task))
|
||||
|
||||
async def _commit_partial_now(self, session) -> bool:
|
||||
"""Persist the in-flight streamed assistant text as a real message and
|
||||
push it to the client, idempotently. Lets a stop show the partial
|
||||
instantly instead of waiting out the SDK teardown the cancel handler
|
||||
sits behind. Returns True if it committed something."""
|
||||
live = self._live_partial.pop(session.id, None)
|
||||
if not live:
|
||||
return False
|
||||
text = live.get("text") or ""
|
||||
msg_id = live.get("msg_id")
|
||||
if not msg_id or not text.strip():
|
||||
return False
|
||||
if any(getattr(m, "id", None) == msg_id for m in session.messages):
|
||||
return False
|
||||
partial = Message(
|
||||
id=msg_id,
|
||||
role="assistant",
|
||||
content=text,
|
||||
branch_id=live.get("branch_id") or session.active_branch_id,
|
||||
)
|
||||
self._upsert_message(session, partial)
|
||||
try:
|
||||
await ws_manager.send_to_session(session.id, "agent:message", {
|
||||
"session_id": session.id,
|
||||
"message": partial.model_dump(mode="json"),
|
||||
})
|
||||
await ws_manager.send_to_session(session.id, "agent:stream_end", {
|
||||
"session_id": session.id,
|
||||
"message_id": msg_id,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
async def _drain_task(self, task) -> None:
|
||||
"""Await a cancelled task's (possibly slow) teardown off the hot path."""
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
def _upsert_message(self, session, msg) -> None:
|
||||
"""Append msg, or replace it in place if its id is already present.
|
||||
Makes a duplicate-id row unrepresentable when a stream commit races a
|
||||
stop's early partial commit (both carry the same stream message id).
|
||||
Same pattern the consolidated-thinking pill already uses inline."""
|
||||
for i, existing in enumerate(session.messages):
|
||||
if getattr(existing, "id", None) == msg.id:
|
||||
session.messages[i] = msg
|
||||
return
|
||||
session.messages.append(msg)
|
||||
|
||||
def handle_approval(self, request_id: str, decision: dict):
|
||||
"""Resolve a pending HITL approval."""
|
||||
@@ -4412,9 +4784,19 @@ class AgentManager:
|
||||
"dashboard_id": session.dashboard_id,
|
||||
})
|
||||
|
||||
self._purge_session_memory(session_id)
|
||||
logger.info(f"Session {session_id} closed and persisted")
|
||||
|
||||
def _purge_session_memory(self, session_id: str) -> None:
|
||||
"""Drop a session from EVERY in-memory structure keyed by its id, so a
|
||||
close or delete can't strand stale per-session state that lives until
|
||||
the process dies. One chokepoint on purpose: a new per-session cache
|
||||
wires its eviction in HERE and both removal paths get it for free."""
|
||||
self.sessions.pop(session_id, None)
|
||||
self.tasks.pop(session_id, None)
|
||||
logger.info(f"Session {session_id} closed and persisted")
|
||||
self._live_partial.pop(session_id, None)
|
||||
p_view_builder_render_retry_counts.pop(session_id, None)
|
||||
p_view_builder_dirty_sessions.discard(session_id)
|
||||
|
||||
async def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete a session: remove from memory and JSON file.
|
||||
@@ -4434,8 +4816,7 @@ class AgentManager:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
self.sessions.pop(session_id, None)
|
||||
self.tasks.pop(session_id, None)
|
||||
self._purge_session_memory(session_id)
|
||||
|
||||
_delete_session_file(session_id)
|
||||
logger.info(f"Session {session_id} permanently deleted")
|
||||
@@ -4774,9 +5155,44 @@ class AgentManager:
|
||||
}
|
||||
|
||||
def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]:
|
||||
if dashboard_id:
|
||||
return [s for s in self.sessions.values() if s.dashboard_id == dashboard_id]
|
||||
return list(self.sessions.values())
|
||||
if not dashboard_id:
|
||||
return list(self.sessions.values())
|
||||
# Memory first, then promote on-disk sessions for this dashboard, but
|
||||
# ONLY ones the dashboard's layout still has a card for. A session keeps
|
||||
# its dashboard_id when its card is deleted, so promoting by tag alone
|
||||
# resurrected deleted chats on every reopen; the layout's cards are the
|
||||
# real source of truth for what's on the board. Imported sessions ARE in
|
||||
# the layout, so they still surface, and this bounds the disk read to
|
||||
# once per session per run, like resume_session.
|
||||
result = [s for s in self.sessions.values() if s.dashboard_id == dashboard_id]
|
||||
seen = {s.id for s in result}
|
||||
card_ids = self._dashboard_card_ids(dashboard_id)
|
||||
for sid, data in _load_all_session_data():
|
||||
if sid in seen or sid not in card_ids:
|
||||
continue
|
||||
if data.get("dashboard_id") != dashboard_id:
|
||||
continue
|
||||
try:
|
||||
sess = AgentSession(**data)
|
||||
except Exception:
|
||||
logger.warning(f"get_all_sessions: skipping unloadable session {sid}", exc_info=True)
|
||||
continue
|
||||
_apply_context_window(sess)
|
||||
self.sessions[sid] = sess
|
||||
result.append(sess)
|
||||
return result
|
||||
|
||||
def _dashboard_card_ids(self, dashboard_id: str) -> set[str]:
|
||||
"""Session ids the dashboard's layout currently has agent cards for.
|
||||
Read straight off disk (no dashboards-module import, avoids a cycle)."""
|
||||
try:
|
||||
import os
|
||||
import backend.config.paths as _paths
|
||||
from backend.config.json_store import read_json_or_none
|
||||
d = read_json_or_none(os.path.join(_paths.DASHBOARDS_DIR, f"{dashboard_id}.json")) or {}
|
||||
return set((d.get("layout", {}).get("cards") or {}).keys())
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[AgentSession]:
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
@@ -112,6 +112,7 @@ async def send_message(session_id: str, body: dict):
|
||||
hidden=body.get("hidden", False),
|
||||
selected_browser_ids=body.get("selected_browser_ids"),
|
||||
selected_app_output_ids=body.get("selected_app_output_ids"),
|
||||
selected_setting_ids=body.get("selected_setting_ids"),
|
||||
client_message_id=body.get("client_message_id"),
|
||||
)
|
||||
return {"ok": True}
|
||||
@@ -128,6 +129,7 @@ async def handle_approval(response: ApprovalResponse):
|
||||
"message": response.message,
|
||||
"updated_input": response.updated_input,
|
||||
"trust_pattern": response.trust_pattern,
|
||||
"set_always_allow": response.set_always_allow,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1013,6 +1014,9 @@ async def run_browser_agent(
|
||||
_in = response.usage.input_tokens or 0
|
||||
out_tokens_total += _out
|
||||
session.tokens["input"] = session.tokens.get("input", 0) + _in
|
||||
# Already-uncached here (cache tracked separately below), so the
|
||||
# fresh lane that feeds the parent's pill mirrors it 1:1.
|
||||
session.tokens["input_fresh"] = session.tokens.get("input_fresh", 0) + _in
|
||||
session.tokens["output"] = session.tokens.get("output", 0) + _out
|
||||
_cr = getattr(response.usage, "cache_read_input_tokens", 0) or 0
|
||||
_cw = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
|
||||
@@ -1427,6 +1431,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 +2127,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
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import re
|
||||
|
||||
# Secret shapes that must never ride along when we ship a stderr tail or an
|
||||
# error string to telemetry. own_key mode means the subprocess stderr can echo
|
||||
# the user's OWN provider key, so this scrub is the wall between a diagnostic
|
||||
# and a key leak; over-redacting is fine, leaking is not.
|
||||
_TELEMETRY_SECRET_PATTERNS = (
|
||||
re.compile(r"sk-ant-[A-Za-z0-9_\-]{12,}"),
|
||||
re.compile(r"sk-[A-Za-z0-9_\-]{16,}"),
|
||||
re.compile(r"AIza[A-Za-z0-9_\-]{20,}"),
|
||||
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
|
||||
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{12,}"),
|
||||
re.compile(r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password|authorization)\b[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9._\-]{6,}"),
|
||||
)
|
||||
|
||||
|
||||
def redact_for_telemetry(text: str, *, limit: int = 2000) -> str:
|
||||
"""Scrub secret-shaped substrings, then keep the tail (where the real error
|
||||
lands), bounded so a runaway log can't bloat the payload. Every raw
|
||||
error/stderr string goes through here before it leaves the machine."""
|
||||
if not text:
|
||||
return ""
|
||||
for pat in _TELEMETRY_SECRET_PATTERNS:
|
||||
text = pat.sub("[redacted]", text)
|
||||
return text[-limit:]
|
||||
|
||||
|
||||
# Patterns that indicate an upstream transient problem (overload / rate limit /
|
||||
# infra blip), safe to silently retry with backoff. Checked against the
|
||||
# stringified exception from claude_agent_sdk / Claude CLI.
|
||||
@@ -12,10 +37,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
|
||||
@@ -87,6 +132,17 @@ def p_is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def p_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 p_extract_reset_hint(text: str) -> str:
|
||||
"""Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of
|
||||
a provider usage error so we can tell the user when their limit comes back.
|
||||
@@ -109,6 +165,10 @@ def p_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 p_is_translation_error(exc, extra_text):
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"\b(401|403)\b"
|
||||
r"|invalid\s+authentication\s+credentials"
|
||||
@@ -142,6 +202,24 @@ def p_is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None:
|
||||
"""Best-effort seconds-until-retry pulled from a throttle error; None if the
|
||||
upstream didn't say. Only used to label the rate-limit pill, so a miss just
|
||||
means the pill shows no countdown, never anything load-bearing."""
|
||||
combined = f"{exc!s}\n{extra_text}"
|
||||
# "1m 59s" / "2m" / "45s" (reset-window phrasing Codex/Anthropic use).
|
||||
m = re.search(r"\b(?:(\d{1,2})\s*m(?:in)?)?\s*(\d{1,3})\s*s(?:ec)?\b", combined, re.IGNORECASE)
|
||||
if m and (m.group(1) or m.group(2)):
|
||||
return int(m.group(1) or 0) * 60 + int(m.group(2) or 0)
|
||||
# "retry-after: 30" / "try again in 2 minutes".
|
||||
m = re.search(r"(?:retry[-\s]?after|try\s+again\s+in)\D{0,8}(\d{1,4})\s*(m|min|minute|s|sec|second)?", combined, re.IGNORECASE)
|
||||
if m:
|
||||
n = int(m.group(1))
|
||||
unit = (m.group(2) or "s").lower()
|
||||
return n * 60 if unit.startswith("m") else n
|
||||
return None
|
||||
|
||||
|
||||
def p_is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
# The Claude CLI's underlying ProcessError stringifies to a generic
|
||||
# "Command failed with exit code 1 / Check stderr output for details";
|
||||
|
||||
@@ -12,6 +12,7 @@ from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.settings.settings import load_settings
|
||||
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
|
||||
from backend.apps.tools_lib.mcp_config import _sanitize_server_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,6 +56,11 @@ CURATED_SHORTLIST: list[CuratedEntry] = [
|
||||
"title": "Airtable",
|
||||
"description": "Read and write records, manage bases, tables, and fields in the user's Airtable.",
|
||||
},
|
||||
{
|
||||
"id": "GitHub",
|
||||
"title": "GitHub",
|
||||
"description": "Repos, issues, pull requests, Actions, code search, gists; when the task involves the user's GitHub.",
|
||||
},
|
||||
{
|
||||
"id": "Reddit",
|
||||
"title": "Reddit",
|
||||
@@ -85,8 +91,10 @@ def _is_obviously_local(prompt: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def run_preflight(prompt: str, timeout_s: float = 2.0, task_id: str | None = None) -> dict:
|
||||
"""Classify the prompt and return {is_vague, suggestions}; never raises."""
|
||||
async def run_preflight(prompt: str, timeout_s: float = 8.0, task_id: str | None = None, require_vague: bool = True) -> dict:
|
||||
"""Classify the prompt and return {is_vague, suggestions}; never raises. require_vague=False
|
||||
keeps suggestions even on a concrete prompt: used when the agent already proved it needs an
|
||||
integration (it called MCPSearch), so the "don't interrupt concrete tasks" guard no longer applies."""
|
||||
default: dict[str, Any] = {"is_vague": False, "suggestions": []}
|
||||
|
||||
if not prompt or not prompt.strip():
|
||||
@@ -112,7 +120,7 @@ async def run_preflight(prompt: str, timeout_s: float = 2.0, task_id: str | None
|
||||
result["suggestions"] = [s for s in result["suggestions"] if s is not None]
|
||||
result["is_vague"] = bool(result.get("is_vague"))
|
||||
# Suppress on concrete prompts; false-positives feel broken (interrupting "refactor foo.ts" to suggest GitHub MCP).
|
||||
if not result["is_vague"]:
|
||||
if require_vague and not result["is_vague"]:
|
||||
result["suggestions"] = []
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
@@ -138,6 +146,26 @@ def _build_available_shortlist(settings) -> list[CuratedEntry]:
|
||||
]
|
||||
|
||||
|
||||
def offer_for_gated_server(server_name: str, settings) -> CuratedEntry | None:
|
||||
"""Mid-run a running agent may reach for a vetted MCP it isn't granted; this maps that
|
||||
server to a one-click connect offer to SHOW the user. Suggest-only by construction: it
|
||||
returns data to display, never an action that grants access, so it cannot widen the MCP
|
||||
surface (activation stays behind MCPActivate + the dispatch gate). Returns None unless the
|
||||
server is vetted AND inactive AND not dismissed, reusing the same filter as the preflight."""
|
||||
if not server_name or not isinstance(server_name, str):
|
||||
return None
|
||||
# The hot-path hands us a sanitized slug ("google-workspace"); curated ids are display names
|
||||
# ("Google Workspace"). Match on the slug of both sides so neither form is a load-bearing string.
|
||||
slug = _sanitize_server_name(server_name)
|
||||
entry = next(
|
||||
(e for e in _build_available_shortlist(settings) if _sanitize_server_name(e["id"]) == slug),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
return None
|
||||
return {"id": entry["id"], "title": entry["title"], "description": entry["description"], "reason": ""}
|
||||
|
||||
|
||||
def _decorate(llm_suggestion: dict, available: list[CuratedEntry]) -> dict | None:
|
||||
"""Expand an LLM-returned {id, reason} into the full frontend shape."""
|
||||
entry = next((e for e in available if e["id"] == llm_suggestion["id"]), None)
|
||||
|
||||
@@ -42,6 +42,10 @@ class ApprovalResponse(BaseModel):
|
||||
# (from ApprovalRequest.sensitive_pattern) to disk so future writes
|
||||
# against the same pattern skip the modal.
|
||||
trust_pattern: bool = False
|
||||
# "Always approve" button: persist this tool's policy to always_allow so
|
||||
# the same tool stops prompting (the catastrophic/sensitive guards still
|
||||
# fire, so this can't blanket-approve an rm -rf or a sensitive-path write).
|
||||
set_always_allow: bool = False
|
||||
|
||||
class Message(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -233,6 +262,28 @@ def _build_selected_app_context(selected_app_output_ids: list[str] | None) -> st
|
||||
)
|
||||
|
||||
|
||||
def _build_selected_settings_context(selected_setting_ids: list[str] | None) -> str | None:
|
||||
"""Context block when the user points the agent at specific Settings rows.
|
||||
|
||||
A targeting aid, NOT a gate: the settings tools (SettingsRead/SettingsWrite)
|
||||
are always available regardless. This just focuses the agent on the exact
|
||||
fields the user clicked. Ids are AppSettings field names (e.g. 'theme',
|
||||
'default_model'), so no label map to drift out of date."""
|
||||
ids = [s for s in (selected_setting_ids or []) if s]
|
||||
if not ids:
|
||||
return None
|
||||
bullets = "\n".join(f"- {fid}" for fid in ids)
|
||||
return (
|
||||
"<selected_settings>\n"
|
||||
"The user pointed you at these specific OpenSwarm Settings fields. Focus "
|
||||
"on them: call SettingsRead to see their current values, then "
|
||||
"SettingsWrite to change what the user asked for. Leave unrelated "
|
||||
"settings alone.\n"
|
||||
f"{bullets}\n"
|
||||
"</selected_settings>"
|
||||
)
|
||||
|
||||
|
||||
def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str], get_all_tool_names: Callable[[], list[str]]) -> str | None:
|
||||
"""Compact registry of installed MCP servers, one line per server.
|
||||
|
||||
@@ -304,6 +355,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."
|
||||
@@ -402,13 +459,39 @@ def _resolve_forced_tools(forced_tools: list[str] | None) -> str:
|
||||
|
||||
|
||||
def _resolve_attached_skills(attached_skills: list | None) -> str:
|
||||
"""Build a context block injecting attached skill content into the prompt."""
|
||||
"""Build a context block injecting attached skill content into the prompt.
|
||||
|
||||
For a multi-file (folder) skill we inject the SKILL.md body as text AND point
|
||||
the agent at the folder so it can read supporting files (scripts, templates)
|
||||
on demand with the normal Read/Glob/Bash tools. That keeps skills fully
|
||||
provider-agnostic: plain prompt text plus universal file tools, identical on
|
||||
Claude, OpenAI, Gemini, or any custom model routed through 9router. The
|
||||
folder lookup is resolved backend-side from the skill id so the frontend
|
||||
send payload stays a simple {id, name, content}."""
|
||||
if not attached_skills:
|
||||
return ""
|
||||
folder_by_id: dict[str, str] = {}
|
||||
try:
|
||||
from backend.apps.skills.skills import _sync_skills
|
||||
for s in _sync_skills():
|
||||
if s.dir_path and s.has_supporting_files:
|
||||
folder_by_id[s.id] = s.dir_path
|
||||
except Exception:
|
||||
folder_by_id = {}
|
||||
|
||||
sections = []
|
||||
for skill in attached_skills:
|
||||
name = skill.get("name", "Unknown")
|
||||
content = skill.get("content", "")
|
||||
if content:
|
||||
sections.append(f"[Using skill: {name}]\n\n{content}")
|
||||
if not content:
|
||||
continue
|
||||
block = f"[Using skill: {name}]\n\n{content}"
|
||||
folder = folder_by_id.get(skill.get("id", ""))
|
||||
if folder:
|
||||
block += (
|
||||
f"\n\nThis skill bundles supporting files in {folder}. "
|
||||
"Read them with your normal file tools (Read / Glob / Bash) when "
|
||||
"the steps above call for one; don't guess their contents."
|
||||
)
|
||||
sections.append(block)
|
||||
return "\n\n".join(sections)
|
||||
|
||||
@@ -312,8 +312,10 @@ async def resolve_aux_model(
|
||||
paying for (Codex chat → Codex aux, OR chat → OR aux, etc.).
|
||||
Returns (model_id, base_url); base_url=None means default Anthropic.
|
||||
"""
|
||||
# Must track the canonical Anthropic entries in BUILTIN_MODELS (sonnet/haiku); a stale id here
|
||||
# 404s every aux call (sonnet was pinned to the long-dead 4.0 "20250514" and silently broke).
|
||||
haiku_bare = "claude-haiku-4-5-20251001"
|
||||
sonnet_bare = "claude-sonnet-4-20250514"
|
||||
sonnet_bare = "claude-sonnet-4-6"
|
||||
or_haiku = "openrouter/anthropic/claude-haiku-4.5"
|
||||
or_sonnet = "openrouter/anthropic/claude-sonnet-4.5"
|
||||
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Which credential keeps a live agent session alive, as one typed value.
|
||||
|
||||
The settings-meta tool lets an agent edit its own Settings autonomously. The
|
||||
single hard rule is "no suicide": it must never disconnect the credential that
|
||||
powers its own run. We enforce that structurally, not with a scattered if-check,
|
||||
by resolving the powering credential to a small closed value HERE, in one place,
|
||||
and having the write guard key off it.
|
||||
|
||||
Add a provider lane and you add a case here; the exhaustive enumeration in
|
||||
test_settings_meta_guard.py walks every (provider x route x connection_mode)
|
||||
combo and fails until the new lane is classified, so a wrong/forgotten state
|
||||
can't ship silently.
|
||||
|
||||
Honest scope: only API keys live in writable settings fields, so they're the
|
||||
only credential the guard can be asked to protect. Subscriptions (OpenSwarm
|
||||
Pro/free-trial, and the 9router OAuth lanes for Claude/Codex/Gemini) are either
|
||||
server-owned or live entirely outside settings.json, so the settings-meta tool
|
||||
cannot touch them at all, a stronger protection than the guard itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TYPE_CHECKING
|
||||
|
||||
from backend.apps.agents.providers.registry import (
|
||||
_CUSTOM_VALUE_PREFIX,
|
||||
_custom_provider_slug_for_lookup,
|
||||
_find_builtin_model,
|
||||
_find_custom_provider_for_value,
|
||||
get_api_type,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
# AppSettings fields holding a user-writable API key, keyed by provider api-type.
|
||||
# Blanking whichever of these powers the current run is the one suicide the guard
|
||||
# stops. Anything not here (subscription tokens, bearers) is not settings-writable.
|
||||
_API_KEY_FIELD_BY_API: dict[str, str] = {
|
||||
"anthropic": "anthropic_api_key",
|
||||
"openai": "openai_api_key",
|
||||
"codex": "openai_api_key",
|
||||
"gemini": "google_api_key",
|
||||
"openrouter": "openrouter_api_key",
|
||||
}
|
||||
|
||||
# Every settings field that can hold an API key (the full guarded set). Custom
|
||||
# providers keep their keys inside the custom_providers list, guarded separately.
|
||||
ALL_API_KEY_FIELDS: frozenset[str] = frozenset(_API_KEY_FIELD_BY_API.values())
|
||||
|
||||
CredentialKind = Literal["api_key", "subscription", "unknown"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoweringCredential:
|
||||
"""The credential keeping THIS run alive, resolved to a closed value.
|
||||
|
||||
kind=="api_key" -> protected_field (or custom slug) names exactly what
|
||||
the guard must keep alive.
|
||||
kind=="subscription" -> the live credential isn't a settings field at all
|
||||
(Pro/free-trial/9router OAuth), so no api-key field
|
||||
needs guarding; clearing OTHER keys stays allowed.
|
||||
kind=="unknown" -> we couldn't classify the run; fail safe by treating
|
||||
ALL credential fields as protected.
|
||||
"""
|
||||
|
||||
kind: CredentialKind
|
||||
provider: str
|
||||
protected_field: str | None = None
|
||||
protected_custom_slug: str | None = None
|
||||
label: str = ""
|
||||
|
||||
|
||||
def _custom_slug_for_model(model_value: str, settings: AppSettings) -> str | None:
|
||||
cp = _find_custom_provider_for_value(settings, model_value)
|
||||
if cp is not None:
|
||||
return _custom_provider_slug_for_lookup(getattr(cp, "name", ""))
|
||||
# Fall back to the slug encoded in the picker value itself.
|
||||
if isinstance(model_value, str) and model_value.startswith(_CUSTOM_VALUE_PREFIX):
|
||||
slug = model_value[len(_CUSTOM_VALUE_PREFIX):].partition("/")[0]
|
||||
return slug or None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_powering_credential(model_value: str, settings: AppSettings) -> PoweringCredential:
|
||||
"""Resolve the credential powering a run on `model_value` to a typed value.
|
||||
|
||||
`model_value` is the session's short model name (e.g. "opus-4-8", "sonnet-api",
|
||||
"custom/lmstudio/llama"), exactly what AgentSession.model holds.
|
||||
"""
|
||||
entry = _find_builtin_model(model_value)
|
||||
api = (entry or {}).get("api") or get_api_type(model_value)
|
||||
route = (entry or {}).get("route")
|
||||
mode = getattr(settings, "connection_mode", "own_key")
|
||||
|
||||
# Custom provider (LM Studio, Ollama, Together, ...). Local servers use a
|
||||
# placeholder key, so suicide is removing the provider ENTRY, not blanking
|
||||
# its key; the guard keys off the slug.
|
||||
if api == "custom":
|
||||
slug = _custom_slug_for_model(model_value, settings)
|
||||
return PoweringCredential(
|
||||
kind="api_key", provider="custom",
|
||||
protected_custom_slug=slug,
|
||||
label=f"custom provider '{slug}'" if slug else "custom provider",
|
||||
)
|
||||
|
||||
# Explicit API-key route: the matching *_api_key field is the live one.
|
||||
if route == "api":
|
||||
field = _API_KEY_FIELD_BY_API.get(api)
|
||||
if field:
|
||||
return PoweringCredential(kind="api_key", provider=api, protected_field=field,
|
||||
label=f"{field} (powers this run)")
|
||||
return PoweringCredential(kind="unknown", provider=api,
|
||||
label=f"{api} api route (unclassified)")
|
||||
|
||||
# Subscription-only routes (cx/ Codex, gc/ Gemini CLI) and pinned cc/ Claude:
|
||||
# these lanes live in 9router, never in settings.
|
||||
if route == "cc" or (entry or {}).get("subscription_only"):
|
||||
return PoweringCredential(kind="subscription", provider=api,
|
||||
label=f"{api} subscription")
|
||||
|
||||
# OpenRouter (its own `openrouter` route, plus xai/meta/deepseek/etc routed
|
||||
# through it): always an API key, never a subscription.
|
||||
if api == "openrouter":
|
||||
return PoweringCredential(kind="api_key", provider="openrouter",
|
||||
protected_field="openrouter_api_key",
|
||||
label="OpenRouter API key (powers this run)")
|
||||
|
||||
# Default Anthropic rows (route is None): connection_mode picks the lane.
|
||||
if api == "anthropic":
|
||||
if mode in ("openswarm-pro", "free-trial"):
|
||||
label = "OpenSwarm Pro" if mode == "openswarm-pro" else "OpenSwarm free trial"
|
||||
return PoweringCredential(kind="subscription", provider="anthropic", label=label)
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
return PoweringCredential(kind="api_key", provider="anthropic",
|
||||
protected_field="anthropic_api_key",
|
||||
label="Anthropic API key (powers this run)")
|
||||
# No key, no proxy mode -> the user's Claude subscription via 9router.
|
||||
return PoweringCredential(kind="subscription", provider="anthropic",
|
||||
label="Claude subscription")
|
||||
|
||||
# Default Gemini rows (api gemini-cli, route None): the AG/gc OAuth lane is a
|
||||
# subscription. A bare AI Studio key only powers the explicit -api rows above.
|
||||
if api in ("gemini", "gemini-cli"):
|
||||
return PoweringCredential(kind="subscription", provider="gemini",
|
||||
label="Gemini subscription")
|
||||
|
||||
# Anything we can't place: protect everything (fail safe), never fail open.
|
||||
return PoweringCredential(kind="unknown", provider=api or "unknown",
|
||||
label=f"{api or 'unknown'} provider (unclassified)")
|
||||
|
||||
|
||||
def _is_blank(value: Any) -> bool:
|
||||
"""A credential write that removes the credential: None, "", or whitespace."""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return value.strip() == ""
|
||||
return False
|
||||
|
||||
|
||||
def _powering_custom_slug_present(new_providers: Any, slug: str) -> bool:
|
||||
"""True if the powering custom provider's entry still exists after the write."""
|
||||
if not isinstance(new_providers, list):
|
||||
return False
|
||||
for cp in new_providers:
|
||||
name = cp.get("name") if isinstance(cp, dict) else getattr(cp, "name", None)
|
||||
if name and _custom_provider_slug_for_lookup(name) == slug:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def write_would_suicide(field: str, new_value: Any, powering: PoweringCredential) -> bool:
|
||||
"""True if writing `new_value` to `field` would disconnect the live credential.
|
||||
|
||||
Pure and total: every (field, value, powering) maps to a definite yes/no, so
|
||||
the guard can't be tricked by an unhandled path. Only blanking/removing a
|
||||
credential counts; SETTING a fresh key is a (re)connect, never suicide.
|
||||
"""
|
||||
if field == "custom_providers":
|
||||
# Removing the entry that powers a custom-provider run is suicide; a
|
||||
# local provider's placeholder key being blanked is not. When the run is
|
||||
# unknown, any custom run could be the live one, so refuse a vanish.
|
||||
if powering.kind == "api_key" and powering.provider == "custom" and powering.protected_custom_slug:
|
||||
return not _powering_custom_slug_present(new_value, powering.protected_custom_slug)
|
||||
if powering.kind == "unknown":
|
||||
return not _powering_custom_slug_present(new_value, powering.protected_custom_slug or "")
|
||||
return False
|
||||
|
||||
if field in ALL_API_KEY_FIELDS:
|
||||
if not _is_blank(new_value):
|
||||
return False
|
||||
if powering.kind == "unknown":
|
||||
return True
|
||||
return powering.kind == "api_key" and field == powering.protected_field
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stdio MCP server letting an agent read and edit its own OpenSwarm Settings.
|
||||
|
||||
Two tools, SettingsRead and SettingsWrite, backed by /api/settings-meta. Always
|
||||
on, no activation gate (Settings is the agent's own house). The backend enforces
|
||||
the only hard rule: it can change anything EXCEPT disconnect the credential
|
||||
powering its own run ("no suicide"), and reads come back with secrets redacted
|
||||
to configured/not, never the value. Both guards live server-side so this thin
|
||||
client can't weaken them."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
|
||||
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/settings-meta"
|
||||
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "SettingsRead",
|
||||
"description": (
|
||||
"Read the user's OpenSwarm Settings (model defaults, theme, prompts, "
|
||||
"connected providers, toggles). Secrets come back as configured/not, "
|
||||
"never the actual key. Call this before SettingsWrite so you change "
|
||||
"the right field to the right value."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "SettingsWrite",
|
||||
"description": (
|
||||
"Change one or more OpenSwarm Settings. Pass `changes` as a map of "
|
||||
"setting field name to new value (use the exact field names from "
|
||||
"SettingsRead, e.g. {\"theme\": \"light\", \"default_model\": \"opus-4-8\"}). "
|
||||
"You can set or clear API keys too. Two things you cannot do: clear the "
|
||||
"credential currently powering YOU (it's refused so you don't cut your "
|
||||
"own run off), and touch subscription/connection state (managed by the "
|
||||
"Subscription section; tell the user to use it). The result reports each "
|
||||
"field as applied / refused / unknown, so relay what actually changed."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"changes": {
|
||||
"type": "object",
|
||||
"description": "Field name -> new value. e.g. {\"theme\": \"light\"}.",
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
"required": ["changes"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def send_response(id_, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": id_}
|
||||
if error is not None:
|
||||
msg["error"] = error
|
||||
else:
|
||||
msg["result"] = result
|
||||
sys.stdout.write(json.dumps(msg) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def call_backend(action: str, payload: dict) -> dict:
|
||||
full = {**payload, "parent_session_id": PARENT_SESSION_ID}
|
||||
body = json.dumps(full).encode()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if BACKEND_AUTH:
|
||||
headers["Authorization"] = f"Bearer {BACKEND_AUTH}"
|
||||
req = urllib.request.Request(
|
||||
f"{BACKEND_URL}/{action}", data=body, headers=headers, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode() if e.fp else str(e)
|
||||
return {"error": f"HTTP {e.code}: {detail}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _format_read(settings: dict) -> str:
|
||||
"""Render redacted settings compactly so the model spends tokens on the
|
||||
values it can act on, not on JSON punctuation."""
|
||||
lines = ["Current OpenSwarm Settings (secrets shown as configured/not):"]
|
||||
for key in sorted(settings.keys()):
|
||||
val = settings[key]
|
||||
if isinstance(val, dict) and "configured" in val:
|
||||
state = f"configured (…{val['last4']})" if val.get("configured") else "not configured"
|
||||
lines.append(f"- {key}: {state}")
|
||||
else:
|
||||
lines.append(f"- {key}: {json.dumps(val)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_write(outcomes: dict) -> str:
|
||||
applied = [f for f, o in outcomes.items() if o.get("status") == "applied"]
|
||||
parts = []
|
||||
if applied:
|
||||
parts.append("Applied: " + ", ".join(sorted(applied)))
|
||||
for field, o in outcomes.items():
|
||||
status = o.get("status")
|
||||
if status in ("applied", None):
|
||||
continue
|
||||
# "error" is transient (retryable); "refused"/"unknown" are not.
|
||||
verb = "Failed" if status == "error" else "Refused"
|
||||
parts.append(f"{verb} {field}: {o.get('reason', status)}")
|
||||
if not parts:
|
||||
return "No changes were applied."
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def handle_tool_call(tool_name: str, arguments: dict) -> dict:
|
||||
if tool_name == "SettingsRead":
|
||||
result = call_backend("read", {})
|
||||
if "error" in result:
|
||||
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
|
||||
return {"content": [{"type": "text", "text": _format_read(result.get("settings", {}))}]}
|
||||
|
||||
if tool_name == "SettingsWrite":
|
||||
changes = arguments.get("changes")
|
||||
if not isinstance(changes, dict) or not changes:
|
||||
return {"content": [{"type": "text", "text": "Error: `changes` must be a non-empty object of field -> value."}], "isError": True}
|
||||
result = call_backend("write", {"changes": changes})
|
||||
if "error" in result:
|
||||
return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True}
|
||||
return {"content": [{"type": "text", "text": _format_write(result.get("outcomes", {}))}]}
|
||||
|
||||
return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = msg.get("method")
|
||||
id_ = msg.get("id")
|
||||
params = msg.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
send_response(id_, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "openswarm-settings-meta", "version": "1.0.0"},
|
||||
})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send_response(id_, {"tools": TOOLS})
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments", {})
|
||||
try:
|
||||
send_response(id_, handle_tool_call(tool_name, arguments))
|
||||
except Exception as e:
|
||||
send_response(id_, error={"code": -32000, "message": str(e)})
|
||||
elif method == "resources/list":
|
||||
send_response(id_, {"resources": []})
|
||||
elif method == "prompts/list":
|
||||
send_response(id_, {"prompts": []})
|
||||
elif id_ is not None:
|
||||
send_response(id_, error={"code": -32601, "message": f"Method not found: {method}"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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):
|
||||
|
||||
@@ -16,7 +16,9 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -54,8 +56,41 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
# routed via an `openai-compatible` node that honors `baseUrl`) STAYS necessary.
|
||||
NINE_ROUTER_NPM_VERSION = os.environ.get("OPENSWARM_ROUTER_VERSION", "0.3.60")
|
||||
|
||||
# 9Router (our pinned 0.3.60) appends every request to ~/.9router/request-details.json and
|
||||
# reloads the WHOLE file on each write; once it reaches tens of MB the router's node process
|
||||
# OOM-aborts and takes the app down, even while idle (verified from crash dumps). Two cheap,
|
||||
# pin-safe guards until the real fix (a 9Router bump past 0.4.66, which moved off this file):
|
||||
# 1. rotate that log before we spawn 9Router when it gets large, so growth can't run away;
|
||||
# 2. give node an explicit, generous heap ceiling for legitimate large multimodal bodies.
|
||||
# Neither touches routing, so WebSearch/WebFetch translation and the 0.3.60 pin are unaffected.
|
||||
_REQUEST_LOG_PATH = os.path.expanduser("~/.9router/request-details.json")
|
||||
_REQUEST_LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
_NODE_HEAP_MB = 4096
|
||||
|
||||
|
||||
def _rotate_request_log() -> None:
|
||||
"""Rotate ~/.9router/request-details.json to a single .0 backup when it grows past the cap,
|
||||
BEFORE 9Router is spawned (never racing a live writer). 9Router recreates a fresh file, exactly
|
||||
like a clean install. The only consumer is the 'most recent 5' reasoning-token lookup, which
|
||||
already tolerates an empty/missing file, so no feature loses data it depends on."""
|
||||
try:
|
||||
if os.path.exists(_REQUEST_LOG_PATH) and os.path.getsize(_REQUEST_LOG_PATH) > _REQUEST_LOG_MAX_BYTES:
|
||||
os.replace(_REQUEST_LOG_PATH, _REQUEST_LOG_PATH + ".0")
|
||||
logger.info(
|
||||
"9Router request log rotated (exceeded %d MB) to avoid the router OOM",
|
||||
_REQUEST_LOG_MAX_BYTES // (1024 * 1024),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("9Router request-log rotation skipped: %s", e)
|
||||
|
||||
|
||||
_process: subprocess.Popen | None = None
|
||||
|
||||
# Serializes ensure_running() so a background auto-start and a concurrent
|
||||
# dispatch-time ensure can't both spawn 9Router (double-bind on :20128). Lazily
|
||||
# created so module import doesn't require a running event loop.
|
||||
_start_lock: "asyncio.Lock | None" = None
|
||||
|
||||
# Short TTL cache for positive is_running() results. The probe is a sync
|
||||
# httpx.get that blocks the event loop, and under load (9Router busy
|
||||
# streaming inference) it can exceed its 2s timeout and return False even
|
||||
@@ -68,13 +103,29 @@ _is_running_last_ok: float = 0.0
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
"""Check if 9Router is running."""
|
||||
"""Check if 9Router is running.
|
||||
|
||||
Fast-fail when down. is_running() is called ~5x on the cold boot path (the
|
||||
settings key-sync sequence + ensure_running) BEFORE 9Router is up. The old
|
||||
body did a synchronous httpx.get to "localhost:20128"; on Windows a dead-port
|
||||
connect to "localhost" stalls multiple seconds (it tries ::1 first and the
|
||||
loopback refusal is slow), so those probes froze the asyncio event loop ~18s
|
||||
and dominated cold startup (faulthandler caught the loop stuck in
|
||||
socket.create_connection here). Fix: probe 127.0.0.1 with a 0.3s TCP timeout
|
||||
first; a down 9Router is detected in <~0.3s instead of ~7s. Only when the
|
||||
port is open do we do the HTTP confirm. 9Router binds 0.0.0.0 (the warm app
|
||||
reaches it via 127.0.0.1 today), so this changes timing, not reachability."""
|
||||
global _is_running_last_ok
|
||||
now = time.monotonic()
|
||||
if now - _is_running_last_ok < _IS_RUNNING_TTL:
|
||||
return True
|
||||
try:
|
||||
r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0)
|
||||
with socket.create_connection(("127.0.0.1", NINE_ROUTER_PORT), timeout=0.3):
|
||||
pass
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
r = httpx.get(f"http://127.0.0.1:{NINE_ROUTER_PORT}/v1/models", timeout=2.0)
|
||||
if r.status_code == 200:
|
||||
_is_running_last_ok = now
|
||||
return True
|
||||
@@ -297,7 +348,52 @@ def _ensure_router_cached() -> str | None:
|
||||
return server_js if os.path.exists(server_js) else None
|
||||
|
||||
|
||||
def _read_capture_tail(path: str, limit: int = 6000) -> str:
|
||||
"""Tail of the 9Router start-capture file, where the real spawn error lands.
|
||||
Best-effort; empty string on any hiccup so telemetry never breaks boot."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
size = f.tell()
|
||||
f.seek(max(0, size - limit))
|
||||
return f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _report_start_failure(reason: str, *, detail: str = "", **fields: Any) -> None:
|
||||
"""9Router didn't come up. Log it and ship a scrubbed diagnostic so a user's
|
||||
'every model exits 1' is finally explained from our side instead of a silent
|
||||
warning. The stderr tail can echo an own_key, so it rides the same scrub as
|
||||
every other telemetry string. Never raises."""
|
||||
logger.warning("9Router start failed (%s)", reason)
|
||||
try:
|
||||
from backend.apps.agents.core.error_classify import redact_for_telemetry
|
||||
from backend.apps.service.client import submit_diagnostic
|
||||
payload: dict[str, Any] = {
|
||||
"kind": "9router_start_failed",
|
||||
"reason": reason,
|
||||
"packaged": os.environ.get("OPENSWARM_PACKAGED") == "1",
|
||||
**fields,
|
||||
}
|
||||
if detail:
|
||||
payload["stderr_tail"] = redact_for_telemetry(detail)
|
||||
submit_diagnostic(payload)
|
||||
except Exception:
|
||||
logger.debug("9router start-failure diagnostic submit failed", exc_info=True)
|
||||
|
||||
|
||||
async def ensure_running():
|
||||
"""Start 9Router if not already running. Serialized so concurrent callers
|
||||
(the background auto-start + a dispatch-time ensure) can't double-spawn."""
|
||||
global _start_lock
|
||||
if _start_lock is None:
|
||||
_start_lock = asyncio.Lock()
|
||||
async with _start_lock:
|
||||
await _ensure_running_impl()
|
||||
|
||||
|
||||
async def _ensure_running_impl():
|
||||
"""Start 9Router if not already running."""
|
||||
global _process
|
||||
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
@@ -325,100 +421,105 @@ async def ensure_running():
|
||||
else:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
_rotate_request_log()
|
||||
_9router_dir = _find_9router_dir()
|
||||
_patch = _gpt5_patch_path()
|
||||
|
||||
if _is_packaged and _9router_dir:
|
||||
# Packaged mode; run the pre-built standalone server staged at
|
||||
# <resources>/router/server.js by scripts/fetch-router.sh at build time.
|
||||
if _is_packaged:
|
||||
# Packaged: run the pre-built standalone server staged at
|
||||
# <resources>/router/server.js by fetch-router at build time. We do NOT
|
||||
# fall back to the dev npm path here, a user machine has no npm, so that
|
||||
# only ever fails silently; every miss is reported instead.
|
||||
if not _9router_dir:
|
||||
_report_start_failure("router_not_bundled")
|
||||
return
|
||||
standalone_server = os.path.join(_9router_dir, "server.js")
|
||||
if not os.path.exists(standalone_server):
|
||||
standalone_server = os.path.join(_9router_dir, ".next", "standalone", "server.js")
|
||||
if not os.path.exists(standalone_server):
|
||||
logger.warning("9Router standalone build not found in %s", _9router_dir)
|
||||
_report_start_failure("server_missing", router_dir_found=True)
|
||||
return
|
||||
|
||||
node = _find_node()
|
||||
if not node:
|
||||
logger.warning("Node.js not found; cannot start 9Router in packaged mode.")
|
||||
_report_start_failure("node_not_found", router_dir_found=True, server_found=True)
|
||||
return
|
||||
|
||||
logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT)
|
||||
cmd = [node]
|
||||
_patch = _gpt5_patch_path()
|
||||
if _patch:
|
||||
cmd += ["--require", _patch]
|
||||
cmd.append(standalone_server)
|
||||
cmd = [node, f"--max-old-space-size={_NODE_HEAP_MB}"] + (["--require", _patch] if _patch else []) + [standalone_server]
|
||||
cwd = os.path.dirname(standalone_server)
|
||||
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"}
|
||||
if node == os.environ.get("OPENSWARM_ELECTRON_PATH"):
|
||||
env["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
|
||||
else:
|
||||
# Dev mode; install the pinned 9router npm package into a local
|
||||
# cache the first time run.sh boots, then spawn `node app/server.js`
|
||||
# directly on subsequent launches. Bypassing the package's cli.js
|
||||
# avoids its menu-bar tray icon (which users confusingly quit,
|
||||
# silently killing their subscription routing), its update-check
|
||||
# spinner, and the interactive TUI.
|
||||
# Dev: install the pinned npm package into a local cache once, then spawn
|
||||
# `node app/server.js` directly (bypasses the package cli.js tray icon
|
||||
# users confusingly quit, its update-check spinner, and the TUI).
|
||||
cached_server = _ensure_router_cached()
|
||||
if not cached_server:
|
||||
return
|
||||
|
||||
node = _find_node()
|
||||
if not node:
|
||||
logger.warning("Node.js not found; cannot start 9Router in dev mode.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Starting 9Router (dev cache, 9router@%s) on port %d...",
|
||||
NINE_ROUTER_NPM_VERSION, NINE_ROUTER_PORT,
|
||||
)
|
||||
cmd = [node]
|
||||
_patch = _gpt5_patch_path()
|
||||
if _patch:
|
||||
cmd += ["--require", _patch]
|
||||
cmd.append(cached_server)
|
||||
cmd = [node, f"--max-old-space-size={_NODE_HEAP_MB}"] + (["--require", _patch] if _patch else []) + [cached_server]
|
||||
cwd = os.path.dirname(cached_server)
|
||||
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"}
|
||||
|
||||
# By default, 9Router's stdout/stderr go to /dev/null (Next.js dev mode
|
||||
# is extremely chatty and floods the openswarm console otherwise). When
|
||||
# debugging is needed, set OPENSWARM_DEBUG_9ROUTER=1 in the environment
|
||||
# before launching the backend; output will then be appended to
|
||||
# backend/data/9router.log line-buffered, which can be `tail -f`'d.
|
||||
if os.environ.get("OPENSWARM_DEBUG_9ROUTER"):
|
||||
# Capture stdout+stderr so a failed start can tell us WHY (the old DEVNULL
|
||||
# default made every "router never came up" a silent mystery, which is the
|
||||
# whole reason #90 was un-diagnosable). Packaged prod (NODE_ENV=production
|
||||
# standalone) is quiet, so one fixed temp file, truncated each start attempt,
|
||||
# won't grow; dev keeps its chatty-Next.js DEVNULL unless debug is set.
|
||||
_cap_path = os.path.join(tempfile.gettempdir(), "openswarm-9router-start.log")
|
||||
_cap_file = None
|
||||
if _is_packaged:
|
||||
try:
|
||||
_cap_file = open(_cap_path, "wb")
|
||||
_stdout, _stderr = _cap_file, subprocess.STDOUT
|
||||
except OSError:
|
||||
_stdout, _stderr = subprocess.DEVNULL, subprocess.DEVNULL
|
||||
elif os.environ.get("OPENSWARM_DEBUG_9ROUTER"):
|
||||
_log_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"data",
|
||||
"9router.log",
|
||||
"data", "9router.log",
|
||||
)
|
||||
os.makedirs(os.path.dirname(_log_path), exist_ok=True)
|
||||
_stdout = open(_log_path, "a", buffering=1) # line-buffered
|
||||
_stderr = subprocess.STDOUT
|
||||
_stdout, _stderr = open(_log_path, "a", buffering=1), subprocess.STDOUT
|
||||
logger.info(f"9Router debug logging enabled → {_log_path}")
|
||||
else:
|
||||
_stdout = subprocess.DEVNULL
|
||||
_stderr = subprocess.DEVNULL
|
||||
_stdout, _stderr = subprocess.DEVNULL, subprocess.DEVNULL
|
||||
|
||||
try:
|
||||
_process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
stdout=_stdout,
|
||||
stderr=_stderr,
|
||||
env=env,
|
||||
)
|
||||
|
||||
_process = subprocess.Popen(cmd, cwd=cwd, stdout=_stdout, stderr=_stderr, env=env)
|
||||
if _cap_file is not None:
|
||||
_cap_file.close() # the child holds its own fd; the parent copy isn't needed
|
||||
timeout = 20 if _is_packaged else 30
|
||||
for _ in range(timeout * 2):
|
||||
await asyncio.sleep(0.5)
|
||||
if is_running():
|
||||
logger.info("9Router started successfully")
|
||||
return
|
||||
|
||||
logger.warning("9Router did not start within %ds", timeout)
|
||||
# Verify-at-boot: it never answered. Report with the captured tail + the
|
||||
# exit code (non-None = it crashed; None = wedged or just slow).
|
||||
_report_start_failure(
|
||||
"not_ready_in_time",
|
||||
detail=_read_capture_tail(_cap_path) if _is_packaged else "",
|
||||
returncode=_process.poll(),
|
||||
timeout_s=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to start 9Router: {e}")
|
||||
if _cap_file is not None and not _cap_file.closed:
|
||||
try:
|
||||
_cap_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
_report_start_failure(
|
||||
"spawn_exception",
|
||||
detail=f"{e}\n{_read_capture_tail(_cap_path) if _is_packaged else ''}",
|
||||
)
|
||||
|
||||
|
||||
def stop():
|
||||
|
||||
@@ -325,6 +325,45 @@ export const JOBS_LIST = '/api/jobs/list';
|
||||
|
||||
---
|
||||
|
||||
## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE`
|
||||
|
||||
The FastAPI backend above runs in preview but is **not hosted when an app is
|
||||
published** to the web. For features that should keep working on a published
|
||||
`{slug}.openswarm.host` link, use these two runtime calls instead of a backend.
|
||||
They run on the published site (same-origin, no credentials). In the App Builder
|
||||
**preview** they throw a clear "available once published" error, preview can't run
|
||||
them without embedding a credential into your app, so test these by publishing.
|
||||
|
||||
**AI (Claude):** call `window.OUTPUT_LLM` with an Anthropic-style messages body.
|
||||
The model is chosen for you (a cheap default), so don't pass one.
|
||||
|
||||
```ts
|
||||
const res = await window.OUTPUT_LLM({
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 512,
|
||||
});
|
||||
const data = await res.json();
|
||||
const text = data.content[0].text;
|
||||
```
|
||||
|
||||
**Data-shaping compute:** put pure Python (json/math/csv/datetime only — no
|
||||
network, no files) in a top-level `backend.py` that reads `input_data` and assigns
|
||||
`result`, then call `window.OUTPUT_COMPUTE(input)`:
|
||||
|
||||
```python
|
||||
# backend.py
|
||||
result = {"total": sum(input_data["nums"])}
|
||||
```
|
||||
```ts
|
||||
const out = await window.OUTPUT_COMPUTE({ nums: [1, 2, 3] }); // -> { total: 6 }
|
||||
```
|
||||
|
||||
Rule of thumb: if the app should be publishable, reach for `OUTPUT_LLM` /
|
||||
`OUTPUT_COMPUTE` first; only use the FastAPI backend for preview-only tools or
|
||||
things those two can't do (it won't be there once published).
|
||||
|
||||
---
|
||||
|
||||
## Debugging — use `swarm_debug`, not `print()`
|
||||
|
||||
The backend has `swarm_debug` pre-installed. It's a colored frame-aware
|
||||
|
||||
@@ -54,20 +54,36 @@ def _validate_against_schema(data: dict, schema: dict) -> str | None:
|
||||
return f"Schema validation failed at {path}: {exc.message}"
|
||||
|
||||
|
||||
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null") -> str:
|
||||
def _runtime_helpers_js() -> str:
|
||||
"""OUTPUT_COMPUTE / OUTPUT_LLM only run for real on the published edge, where they
|
||||
are same-origin and carry NO credentials. In the App Builder preview we
|
||||
deliberately do NOT wire them to the authenticated backend: doing so would embed
|
||||
this install's token into the app's own JS (the exact exposure SECURITY.md item A
|
||||
is about). Preview defines readable stubs instead, the app degrades with a clear
|
||||
message rather than crashing or leaking a credential."""
|
||||
return (
|
||||
" window.OUTPUT_COMPUTE = async function () { throw new Error('OUTPUT_COMPUTE runs once this app is published.'); };\n"
|
||||
" window.OUTPUT_LLM = async function () { throw new Error('OUTPUT_LLM runs once this app is published.'); };\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null", with_runtime: bool = False) -> str:
|
||||
"""Build a <script> tag that sets OUTPUT_INPUT / OUTPUT_BACKEND_RESULT /
|
||||
OUTPUT_BACKEND_URL and listens for postMessage updates.
|
||||
OUTPUT_BACKEND_URL, optionally wires OUTPUT_COMPUTE / OUTPUT_LLM, and listens
|
||||
for postMessage updates.
|
||||
|
||||
OUTPUT_BACKEND_URL is `null` when the app has no live `backend.py`
|
||||
process; otherwise it's `http://localhost:<port>` and app code can
|
||||
`fetch(window.OUTPUT_BACKEND_URL + '/route')` to hit the persistent
|
||||
backend's endpoints."""
|
||||
helpers = _runtime_helpers_js() if with_runtime else ""
|
||||
return (
|
||||
"<script>\n"
|
||||
"(function() {\n"
|
||||
" window.OUTPUT_INPUT = " + input_json + ";\n"
|
||||
" window.OUTPUT_BACKEND_RESULT = " + result_json + ";\n"
|
||||
" window.OUTPUT_BACKEND_URL = " + backend_url_json + ";\n"
|
||||
+ helpers +
|
||||
" window.addEventListener('message', function(e) {\n"
|
||||
" if (e.data && e.data.type === 'OUTPUT_DATA') {\n"
|
||||
" window.OUTPUT_INPUT = e.data.input || {};\n"
|
||||
@@ -81,8 +97,8 @@ def _build_data_injection(input_json: str, result_json: str, backend_url_json: s
|
||||
)
|
||||
|
||||
|
||||
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null") -> str:
|
||||
injection = _build_data_injection(input_json, result_json, backend_url_json)
|
||||
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null", with_runtime: bool = False) -> str:
|
||||
injection = _build_data_injection(input_json, result_json, backend_url_json, with_runtime)
|
||||
if "</head>" in html:
|
||||
return html.replace("</head>", f"{injection}\n</head>", 1)
|
||||
if "<body" in html:
|
||||
|
||||
@@ -25,6 +25,12 @@ class Output(BaseModel):
|
||||
workspace_id: Optional[str] = None
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
# App publishing to {slug}.openswarm.host. Server-managed: set by the publish
|
||||
# endpoint, never accepted from OutputUpdate (so a client can't spoof a live URL).
|
||||
published_slug: Optional[str] = None
|
||||
published_url: Optional[str] = None
|
||||
publish_status: Optional[Literal["publishing", "published", "error"]] = None
|
||||
publish_error: Optional[str] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@@ -55,6 +61,20 @@ class Output(BaseModel):
|
||||
return self.files.get("backend.py")
|
||||
|
||||
|
||||
class OutputVersion(BaseModel):
|
||||
"""A saved point in an app's history. The list endpoint returns these; the
|
||||
heavy bits (the serialized app metadata + the workspace byte tree) live in
|
||||
the version's manifest plus content-addressed blobs on disk, not here."""
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
label: str = ""
|
||||
# auto: saved after a builder edit run. manual: user clicked Save this version.
|
||||
# pre_restore: the automatic backup taken right before a restore (so restore undoes).
|
||||
source: Literal["auto", "manual", "pre_restore"] = "auto"
|
||||
parent_id: Optional[str] = None
|
||||
thumbnail: Optional[str] = None
|
||||
|
||||
|
||||
class OutputCreate(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
@@ -197,3 +217,35 @@ class VibeCodeRequest(BaseModel):
|
||||
current_schema: str = ""
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class PublishReview(BaseModel):
|
||||
# Same JSON shape the frontend shareTypes.ReviewSummary expects.
|
||||
verdict: Literal["clean", "warn", "block"] = "clean"
|
||||
findings: list[str] = Field(default_factory=list)
|
||||
scanned_files: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PublishPreflightRequest(BaseModel):
|
||||
output_id: str
|
||||
|
||||
|
||||
class PublishRequest(BaseModel):
|
||||
output_id: str
|
||||
slug: Optional[str] = None
|
||||
force: bool = False
|
||||
|
||||
|
||||
class PublishPreflightResponse(BaseModel):
|
||||
review: PublishReview
|
||||
|
||||
|
||||
class PublishResult(BaseModel):
|
||||
ok: bool = True
|
||||
published_slug: Optional[str] = None
|
||||
published_url: Optional[str] = None
|
||||
# When the AST safety net blocks a non-force publish, carry the findings so
|
||||
# the UI shows the review modal instead of a generic error toast.
|
||||
blocked: bool = False
|
||||
review: Optional[PublishReview] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
@@ -12,8 +12,14 @@ from backend.config.Apps import SubApp
|
||||
from backend.apps.outputs.models import (
|
||||
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
|
||||
VibeCodeRequest, WorkspaceSeedRequest,
|
||||
PublishPreflightRequest, PublishRequest, PublishPreflightResponse,
|
||||
PublishResult, PublishReview,
|
||||
)
|
||||
from backend.apps.outputs.executor import execute_backend_code, get_code_warnings
|
||||
from backend.apps.outputs.publish_common import slugify, PublishError
|
||||
from backend.apps.outputs.publish_scan import scan_for_publish, quick_ast_gate
|
||||
from backend.apps.outputs.publish_build import build_static, collect_bundle
|
||||
from backend.apps.outputs.publish_cloud import upload_to_cloud, unpublish_from_cloud
|
||||
from backend.apps.outputs.view_builder_templates import (
|
||||
VIEW_TEMPLATE_FILES,
|
||||
load_app_builder_skill,
|
||||
@@ -87,7 +93,7 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
|
||||
if filepath == "index.html":
|
||||
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
|
||||
backend_url_json = _backend_url_for_workspace(workspace_id)
|
||||
content = _inject_data_into_html(content, input_json, result_json, backend_url_json)
|
||||
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
|
||||
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
|
||||
# parent's ?token= query string, so rewrite the HTML to put the token
|
||||
# back on every relative URL; otherwise sub-resources 401.
|
||||
@@ -108,7 +114,7 @@ async def serve_output_file(output_id: str, filepath: str, _d: str = ""):
|
||||
if filepath == "index.html":
|
||||
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
|
||||
backend_url_json = _backend_url_for_workspace(output.workspace_id) if output.workspace_id else "null"
|
||||
content = _inject_data_into_html(content, input_json, result_json, backend_url_json)
|
||||
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
|
||||
content = _inject_token_into_relative_urls(content, get_auth_token())
|
||||
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
@@ -454,6 +460,33 @@ async def runtime_get_status(workspace_id: str):
|
||||
return _runtime_status_payload(workspace_id)
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/report-error")
|
||||
async def runtime_report_error(workspace_id: str, body: dict):
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
if rt is None:
|
||||
return {"ok": False, "recorded": 0}
|
||||
message = (body.get("message") or "").strip()
|
||||
component_stack = (body.get("componentStack") or "").strip()
|
||||
if not message:
|
||||
return {"ok": False, "recorded": 0}
|
||||
composed = message
|
||||
if component_stack:
|
||||
composed = f"{composed}\n{component_stack}"
|
||||
rt.set_render_error(composed)
|
||||
return {"ok": True, "recorded": 1}
|
||||
|
||||
|
||||
@outputs.router.post("/workspace/{workspace_id}/runtime/report-ready")
|
||||
async def runtime_report_ready(workspace_id: str):
|
||||
from backend.apps.outputs.runtime import manager as runtime_manager
|
||||
rt = runtime_manager.get(workspace_id)
|
||||
if rt is None:
|
||||
return {"ok": False}
|
||||
rt.set_render_ok()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@outputs.router.post("/shutdown-all")
|
||||
async def runtime_shutdown_all():
|
||||
"""Reap every workspace subprocess. Electron POSTs this during
|
||||
@@ -527,7 +560,6 @@ async def create_output(body: OutputCreate):
|
||||
updated_at=now,
|
||||
)
|
||||
_save(output)
|
||||
pass
|
||||
return {"ok": True, "output": output.model_dump()}
|
||||
|
||||
|
||||
@@ -555,6 +587,8 @@ async def delete_output(output_id: str):
|
||||
path = os.path.join(DATA_DIR, f"{output_id}.json")
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
from backend.apps.outputs import versions
|
||||
versions.delete_all(output_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -620,7 +654,6 @@ async def vibe_code(body: VibeCodeRequest):
|
||||
raw = raw[:-3]
|
||||
|
||||
result = json.loads(raw)
|
||||
pass
|
||||
return {
|
||||
"message": result.get("message", "View updated."),
|
||||
"frontend_code": result.get("frontend_code", body.current_frontend_code),
|
||||
@@ -707,3 +740,87 @@ async def execute_output(body: OutputExecute):
|
||||
).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publishing to {slug}.openswarm.host
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@outputs.router.post("/publish/preflight")
|
||||
async def publish_preflight(body: PublishPreflightRequest):
|
||||
"""Scan the app (AST + an aux-LLM pass on the user's own creds) and return a
|
||||
review. No build, no cloud call; this just drives the security modal."""
|
||||
output = _load(body.output_id)
|
||||
review = await scan_for_publish(output, load_settings())
|
||||
return PublishPreflightResponse(review=review).model_dump()
|
||||
|
||||
|
||||
@outputs.router.post("/publish")
|
||||
async def publish_output(body: PublishRequest):
|
||||
"""Build (webapp) + bundle + upload to the cloud host. `force` skips the
|
||||
cheap AST safety net (the user already saw the findings in the review modal)."""
|
||||
output = _load(body.output_id)
|
||||
settings = load_settings()
|
||||
if not body.force:
|
||||
ast = quick_ast_gate(output)
|
||||
if ast:
|
||||
return PublishResult(
|
||||
ok=False,
|
||||
blocked=True,
|
||||
review=PublishReview(verdict="warn", findings=ast),
|
||||
).model_dump()
|
||||
|
||||
output.publish_status = "publishing"
|
||||
output.publish_error = None
|
||||
_save(output)
|
||||
try:
|
||||
dist = await build_static(output)
|
||||
bundle = collect_bundle(output, dist)
|
||||
slug_hint = slugify(body.slug or output.name)
|
||||
res = await upload_to_cloud(
|
||||
settings,
|
||||
output_id=output.id,
|
||||
name=output.name,
|
||||
slug_hint=slug_hint,
|
||||
bundle=bundle,
|
||||
override=body.force,
|
||||
)
|
||||
except PublishError as e:
|
||||
output.publish_status = "error"
|
||||
output.publish_error = str(e)
|
||||
_save(output)
|
||||
return PublishResult(ok=False, error=str(e)).model_dump()
|
||||
except Exception as e:
|
||||
logger.exception("publish failed for %s", output.id)
|
||||
output.publish_status = "error"
|
||||
output.publish_error = "Something went wrong while publishing."
|
||||
_save(output)
|
||||
return PublishResult(ok=False, error=output.publish_error).model_dump()
|
||||
|
||||
output.published_slug = res.get("slug")
|
||||
output.published_url = res.get("url")
|
||||
output.publish_status = "published"
|
||||
output.publish_error = None
|
||||
_save(output)
|
||||
return PublishResult(
|
||||
ok=True,
|
||||
published_slug=output.published_slug,
|
||||
published_url=output.published_url,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@outputs.router.post("/unpublish")
|
||||
async def unpublish_output(body: PublishPreflightRequest):
|
||||
"""Take the app offline and clear its publish state."""
|
||||
output = _load(body.output_id)
|
||||
if output.published_slug:
|
||||
try:
|
||||
await unpublish_from_cloud(load_settings(), output.published_slug)
|
||||
except PublishError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
output.published_slug = None
|
||||
output.published_url = None
|
||||
output.publish_status = None
|
||||
output.publish_error = None
|
||||
_save(output)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Build + bundle the static artifact the cloud will host. Webapp-mode runs the
|
||||
bundled node on `vite build`; flat-mode is already the artifact. Secret-shaped
|
||||
files (.env, private keys) are dropped because a published bundle is world-readable."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
from typing import Optional
|
||||
|
||||
from backend.apps.outputs.models import Output
|
||||
from backend.apps.outputs.publish_common import PublishError, is_webapp, workspace_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUILD_TIMEOUT = 180 # vite build on a cold-ish node_modules can be slow
|
||||
_MAX_BUNDLE_FILE = 25 * 1024 * 1024
|
||||
_SECRET_KEY_EXTS = (".pem", ".key", ".p12", ".pfx", ".keystore")
|
||||
|
||||
|
||||
def _node_bin() -> Optional[str]:
|
||||
return os.environ.get("OPENSWARM_NODE_PATH") or shutil.which("node")
|
||||
|
||||
|
||||
def _safe_build_config(fe: str) -> tuple[list[str], Optional[str]]:
|
||||
"""vite-plugin-terminal injects a dev-only `virtual:terminal` module that
|
||||
breaks `vite build` in older workspaces (the template later gated it to dev,
|
||||
but apps seeded before that still carry the ungated plugin). Build against a
|
||||
temp config that makes that plugin a no-op (Vite drops null plugins) so ANY
|
||||
workspace builds clean. The user's own vite.config is never touched.
|
||||
|
||||
Returns (extra build args, temp-config path to delete) or ([], None)."""
|
||||
cfg_name = next(
|
||||
(n for n in ("vite.config.ts", "vite.config.js", "vite.config.mjs")
|
||||
if os.path.exists(os.path.join(fe, n))),
|
||||
None,
|
||||
)
|
||||
if not cfg_name:
|
||||
return [], None
|
||||
with open(os.path.join(fe, cfg_name), "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if "vite-plugin-terminal" not in content:
|
||||
return [], None
|
||||
patched = re.sub(
|
||||
r"import\s+terminal\s+from\s+['\"]vite-plugin-terminal['\"];?",
|
||||
"const terminal = () => null;",
|
||||
content,
|
||||
)
|
||||
ext = os.path.splitext(cfg_name)[1]
|
||||
temp_name = f"vite.config.openswarm-publish{ext}"
|
||||
with open(os.path.join(fe, temp_name), "w", encoding="utf-8") as f:
|
||||
f.write(patched)
|
||||
return ["--config", temp_name], os.path.join(fe, temp_name)
|
||||
|
||||
|
||||
async def build_static(output: Output) -> Optional[str]:
|
||||
"""Webapp apps -> build `frontend/dist`, return its path. Flat apps need no
|
||||
build (the files dict is the artifact), return None. Raises PublishError with
|
||||
a user-safe message on any failure."""
|
||||
if not is_webapp(output):
|
||||
return None
|
||||
fe = os.path.join(workspace_dir(output), "frontend")
|
||||
vite = os.path.join(fe, "node_modules", "vite", "bin", "vite.js")
|
||||
node = _node_bin()
|
||||
if not node or not os.path.exists(vite):
|
||||
raise PublishError(
|
||||
"This app isn't set up to build yet. Open it once in the editor, then try publishing again."
|
||||
)
|
||||
config_args, temp_cfg = _safe_build_config(fe)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
node, "node_modules/vite/bin/vite.js", "build", *config_args,
|
||||
cwd=fe,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={**os.environ, "NODE_ENV": "production"},
|
||||
)
|
||||
try:
|
||||
_out, err = await asyncio.wait_for(proc.communicate(), timeout=_BUILD_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise PublishError("Building your app took too long and was stopped.")
|
||||
finally:
|
||||
if temp_cfg:
|
||||
try:
|
||||
os.remove(temp_cfg)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.returncode != 0:
|
||||
logger.error("vite build failed (%s): %s", output.id, err.decode(errors="replace")[-2000:])
|
||||
raise PublishError("We couldn't build your app. Make sure it runs in the editor, then try again.")
|
||||
dist = os.path.join(fe, "dist")
|
||||
if not os.path.isdir(dist):
|
||||
raise PublishError("The build finished but produced no files.")
|
||||
return dist
|
||||
|
||||
|
||||
def _is_secret_file(rel_path: str) -> bool:
|
||||
"""This bundle is served publicly, so anything secret-shaped must never make it
|
||||
in. dotenv files and private-key material are the realistic leaks; the webapp
|
||||
path already ships only the built dist, this also covers a hand-built flat app."""
|
||||
base = rel_path.rsplit("/", 1)[-1].lower()
|
||||
return (
|
||||
base == ".env"
|
||||
or base.startswith(".env.")
|
||||
or base.endswith(_SECRET_KEY_EXTS)
|
||||
or base in (".npmrc", ".git-credentials", ".htpasswd")
|
||||
)
|
||||
|
||||
|
||||
def collect_bundle(output: Output, dist_dir: Optional[str]) -> bytes:
|
||||
"""tar.gz of what the cloud should host. Webapp -> the built dist tree.
|
||||
Flat -> the files dict, including backend.py (the edge runs it on the shared
|
||||
sandbox; the edge refuses to serve .py as a static file). Secret-shaped files
|
||||
(.env, private keys) are dropped: a published bundle is world-readable."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
if dist_dir:
|
||||
for root, _dirs, files in os.walk(dist_dir):
|
||||
for fn in files:
|
||||
full = os.path.join(root, fn)
|
||||
if os.path.islink(full):
|
||||
continue
|
||||
rel = os.path.relpath(full, dist_dir).replace(os.sep, "/")
|
||||
if _is_secret_file(rel):
|
||||
continue
|
||||
try:
|
||||
if os.path.getsize(full) > _MAX_BUNDLE_FILE:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
tar.add(full, arcname=rel)
|
||||
else:
|
||||
for name, content in (output.files or {}).items():
|
||||
rel = name.replace(os.sep, "/")
|
||||
if _is_secret_file(rel):
|
||||
continue
|
||||
data = content.encode("utf-8")
|
||||
if len(data) > _MAX_BUNDLE_FILE:
|
||||
continue
|
||||
info = tarfile.TarInfo(name=rel)
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Cloud client for the publish pipeline: ships the bundle to the host and takes
|
||||
it back down. Reads the bearer directly (publish works for any signed-in account,
|
||||
not just pro/free-trial), matching the cloud's requireAuthedUser gate."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.apps.outputs.publish_common import PublishError
|
||||
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
|
||||
|
||||
|
||||
def _cloud_auth(settings) -> tuple[Optional[str], str]:
|
||||
base = (getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
|
||||
token = getattr(settings, "openswarm_bearer_token", None)
|
||||
return token, base
|
||||
|
||||
|
||||
def _safe_detail(resp: httpx.Response, fallback: str) -> str:
|
||||
try:
|
||||
body = resp.json()
|
||||
msg = body.get("message") or body.get("error")
|
||||
if isinstance(msg, str) and msg:
|
||||
return msg
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
async def upload_to_cloud(
|
||||
settings, *, output_id: str, name: str, slug_hint: str, bundle: bytes, override: bool
|
||||
) -> dict:
|
||||
token, base = _cloud_auth(settings)
|
||||
if not token:
|
||||
raise PublishError("Sign in to your OpenSwarm account to publish apps.")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
r = await client.post(
|
||||
f"{base}/api/apps/publish",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
# output_id lets the cloud reuse this app's slug on republish instead
|
||||
# of minting a duplicate; override marks a publish past a non-clean scan.
|
||||
data={"name": name, "slug": slug_hint, "output_id": output_id, "override": "1" if override else "0"},
|
||||
files={"bundle": ("app.tar.gz", bundle, "application/gzip")},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise PublishError("Couldn't reach the publishing service. Check your connection and try again.")
|
||||
if r.status_code >= 400:
|
||||
raise PublishError(_safe_detail(r, "Publishing failed. Please try again."))
|
||||
return r.json()
|
||||
|
||||
|
||||
async def unpublish_from_cloud(settings, slug: str) -> None:
|
||||
token, base = _cloud_auth(settings)
|
||||
if not token:
|
||||
raise PublishError("Sign in to your OpenSwarm account to manage published apps.")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
r = await client.post(
|
||||
f"{base}/api/apps/{slug}/delete",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
raise PublishError("Couldn't reach the publishing service. Check your connection and try again.")
|
||||
if r.status_code >= 400 and r.status_code != 404:
|
||||
raise PublishError(_safe_detail(r, "Couldn't unpublish. Please try again."))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Shared low-level bits for the publish pipeline (scan / build / cloud). Kept
|
||||
tiny and dependency-light so scan, build, and cloud_client can all lean on it
|
||||
without reaching sideways into each other."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from backend.apps.outputs.models import Output
|
||||
from backend.config.paths import OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
|
||||
class PublishError(Exception):
|
||||
"""User-facing publish failure; message is safe to show in a toast."""
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""A url-safe slug hint from the app name; the cloud guarantees uniqueness."""
|
||||
s = re.sub(r"[^a-z0-9]+", "-", (name or "app").lower()).strip("-")
|
||||
s = s[:32].strip("-")
|
||||
return s or "app"
|
||||
|
||||
|
||||
def is_webapp(output: Output) -> bool:
|
||||
return bool(output.workspace_id)
|
||||
|
||||
|
||||
def workspace_dir(output: Output) -> str:
|
||||
return os.path.join(OUTPUTS_WORKSPACE_DIR, output.workspace_id or "")
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Pre-publish security scan: a free AST pass (reuses the executor's
|
||||
`get_code_warnings`) plus a best-effort aux-LLM semantic pass, both on the user's
|
||||
OWN creds so it costs us nothing and the code never leaves the machine until they
|
||||
ship. The JSON shape matches the frontend `ReviewSummary`.
|
||||
|
||||
The full scan (`scan_for_publish`) is memoized on a hash of the collected source:
|
||||
reopening the publish modal on unchanged code returns the cached review instead of
|
||||
billing the user's aux model again."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from typing import Literal
|
||||
|
||||
from backend.apps.outputs.executor import get_code_warnings
|
||||
from backend.apps.outputs.models import Output, PublishReview
|
||||
from backend.apps.outputs.publish_common import is_webapp, workspace_dir
|
||||
from backend.apps.outputs.workspace_io import _WALK_SKIP_DIRS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SCAN_CODE_BUDGET = 60_000 # chars of source we hand the aux model
|
||||
_SCAN_EXTS = (".py", ".html", ".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".css")
|
||||
_MEMO_MAX = 32
|
||||
|
||||
_SCAN_SYSTEM_PROMPT = (
|
||||
"You are a security reviewer for a no-code app host. The app below will be "
|
||||
"served publicly at a *.openswarm.host subdomain. Read the source and report "
|
||||
"only concrete, real risks a reviewer would act on: hardcoded secrets or API "
|
||||
"keys, phishing or credential-harvesting forms, sending user data to a "
|
||||
"third-party endpoint, obvious XSS or injection, or anything malicious. Do "
|
||||
"NOT nitpick style or speculate. Reply ONLY with JSON: "
|
||||
'{"severity": "clean|warn|block", "findings": ["short dev-readable line", ...]}. '
|
||||
"Use block only for clearly malicious or credential-harvesting code. Empty "
|
||||
"findings means clean."
|
||||
)
|
||||
|
||||
# slug-content-hash -> PublishReview, so a reopened modal doesn't re-bill the LLM.
|
||||
_memo: "OrderedDict[str, PublishReview]" = OrderedDict()
|
||||
|
||||
|
||||
def _collect_source(output: Output) -> dict[str, str]:
|
||||
"""Gather human-readable source text for the scan. Flat apps come from the
|
||||
files dict; webapp apps walk the workspace skipping node_modules/.venv/dist."""
|
||||
src: dict[str, str] = {}
|
||||
for name, content in (output.files or {}).items():
|
||||
if name.lower().endswith(_SCAN_EXTS):
|
||||
src[name] = content
|
||||
if is_webapp(output):
|
||||
root = workspace_dir(output)
|
||||
for base, _dirs, fnames in os.walk(root):
|
||||
_dirs[:] = [d for d in _dirs if d not in _WALK_SKIP_DIRS]
|
||||
for fn in fnames:
|
||||
if not fn.lower().endswith(_SCAN_EXTS):
|
||||
continue
|
||||
full = os.path.join(base, fn)
|
||||
if os.path.islink(full):
|
||||
continue
|
||||
try:
|
||||
if os.path.getsize(full) > 512 * 1024:
|
||||
continue
|
||||
with open(full, "r", encoding="utf-8", errors="replace") as f:
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
src[rel] = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
return src
|
||||
|
||||
|
||||
def _source_hash(src: dict[str, str]) -> str:
|
||||
h = hashlib.sha256()
|
||||
for path in sorted(src):
|
||||
h.update(path.encode("utf-8"))
|
||||
h.update(b"\0")
|
||||
h.update(src[path].encode("utf-8", errors="replace"))
|
||||
h.update(b"\0")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _scan_blob(src: dict[str, str]) -> str:
|
||||
parts: list[str] = []
|
||||
total = 0
|
||||
for path, code in src.items():
|
||||
chunk = f"=== {path} ===\n{code}\n"
|
||||
if total + len(chunk) > _SCAN_CODE_BUDGET:
|
||||
chunk = chunk[: max(0, _SCAN_CODE_BUDGET - total)]
|
||||
parts.append(chunk)
|
||||
total += len(chunk)
|
||||
if total >= _SCAN_CODE_BUDGET:
|
||||
break
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _ast_findings(src: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
findings: list[str] = []
|
||||
scanned: list[str] = []
|
||||
for path, code in src.items():
|
||||
if path.lower().endswith(".py"):
|
||||
scanned.append(path)
|
||||
for w in get_code_warnings(code):
|
||||
findings.append(f"{path}: {w}")
|
||||
return findings, scanned
|
||||
|
||||
|
||||
async def _llm_findings(src: dict[str, str], settings) -> tuple[list[str], str]:
|
||||
"""Aux-tier semantic pass. Best-effort: if no aux model is configured or the
|
||||
call fails, return clean so the AST pass still gates. Runs on the user's creds."""
|
||||
blob = _scan_blob(src)
|
||||
if not blob.strip():
|
||||
return [], "clean"
|
||||
from backend.apps.agents.providers.registry import resolve_aux_model
|
||||
from backend.apps.settings.credentials import get_anthropic_client_for_model
|
||||
from backend.apps.agents.core.aux_llm import _safe_resp_text
|
||||
try:
|
||||
model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
|
||||
except Exception:
|
||||
return [], "clean"
|
||||
client = get_anthropic_client_for_model(settings, model)
|
||||
try:
|
||||
resp = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=1200,
|
||||
system=_SCAN_SYSTEM_PROMPT,
|
||||
messages=[{"role": "user", "content": blob}],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("publish LLM scan call failed; AST-only result stands")
|
||||
return [], "clean"
|
||||
text = _safe_resp_text(resp).strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return [], "clean"
|
||||
findings = [str(f) for f in parsed.get("findings", []) if str(f).strip()][:20]
|
||||
severity = parsed.get("severity", "clean")
|
||||
if severity not in ("clean", "warn", "block"):
|
||||
severity = "warn" if findings else "clean"
|
||||
return findings, severity
|
||||
|
||||
|
||||
async def scan_for_publish(output: Output, settings) -> PublishReview:
|
||||
src = _collect_source(output)
|
||||
key = _source_hash(src)
|
||||
cached = _memo.get(key)
|
||||
if cached is not None:
|
||||
_memo.move_to_end(key)
|
||||
return cached
|
||||
ast_findings, scanned = _ast_findings(src)
|
||||
llm_findings, llm_sev = await _llm_findings(src, settings)
|
||||
findings = ast_findings + llm_findings
|
||||
verdict: Literal["clean", "warn", "block"] = "clean"
|
||||
if findings:
|
||||
verdict = "warn"
|
||||
if llm_sev == "block":
|
||||
verdict = "block"
|
||||
review = PublishReview(
|
||||
verdict=verdict,
|
||||
findings=findings,
|
||||
scanned_files=scanned or sorted(src.keys()),
|
||||
)
|
||||
_memo[key] = review
|
||||
_memo.move_to_end(key)
|
||||
while len(_memo) > _MEMO_MAX:
|
||||
_memo.popitem(last=False)
|
||||
return review
|
||||
|
||||
|
||||
def quick_ast_gate(output: Output) -> list[str]:
|
||||
"""Cheap, free safety net used by /publish when force is not set: flags the
|
||||
AST-visible 'runs code outside the sandbox' findings without an LLM call."""
|
||||
findings, _ = _ast_findings(_collect_source(output))
|
||||
return findings
|
||||
@@ -99,6 +99,8 @@ class AppRuntime:
|
||||
# vite/babel/uvicorn errors in its next turn and can self-fix
|
||||
# instead of leaving the user with a red iframe overlay.
|
||||
self.recent_errors: deque[str] = deque(maxlen=_RECENT_ERRORS_MAX)
|
||||
self.render_state: Optional[str] = None
|
||||
self.render_error_text: str = ""
|
||||
self._stdout_task: Optional[asyncio.Task] = None
|
||||
self._stderr_task: Optional[asyncio.Task] = None
|
||||
self._wait_task: Optional[asyncio.Task] = None
|
||||
@@ -113,6 +115,18 @@ class AppRuntime:
|
||||
self.recent_errors.clear()
|
||||
return out
|
||||
|
||||
def set_render_ok(self) -> None:
|
||||
self.render_state = "ok"
|
||||
self.render_error_text = ""
|
||||
|
||||
def set_render_error(self, text: str) -> None:
|
||||
self.render_state = "error"
|
||||
self.render_error_text = (text or "").strip()
|
||||
|
||||
def reset_render_state(self) -> None:
|
||||
self.render_state = None
|
||||
self.render_error_text = ""
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.process is not None and self.process.returncode is None
|
||||
@@ -250,12 +264,13 @@ class AppRuntime:
|
||||
env["OPENSWARM_DEBUGGER_PATH"] = _DEBUGGER_PATH
|
||||
env["OPENSWARM_TEMPLATE_BACKEND_PATH"] = _TEMPLATE_BACKEND_PATH
|
||||
|
||||
cmd, spawn_cwd, launch_desc = self._resolve_launch(env)
|
||||
try:
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
_resolve_bash(), "run.sh",
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace_path,
|
||||
cwd=spawn_cwd,
|
||||
env=env,
|
||||
**_background_priority_kwargs(),
|
||||
)
|
||||
@@ -267,7 +282,7 @@ class AppRuntime:
|
||||
self.process = None
|
||||
return False
|
||||
backend_note = f" + backend on {self.port}" if self.port else ""
|
||||
self._broadcast(LogLine("runtime", f"[runtime] bash run.sh started; frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
|
||||
self._broadcast(LogLine("runtime", f"[runtime] {launch_desc} started; frontend on {self.frontend_port}{backend_note} (pid {self.process.pid})"))
|
||||
self._stdout_task = asyncio.create_task(self._pipe_stream(self.process.stdout, "stdout"))
|
||||
self._stderr_task = asyncio.create_task(self._pipe_stream(self.process.stderr, "stderr"))
|
||||
self._wait_task = asyncio.create_task(self._await_exit())
|
||||
@@ -277,6 +292,33 @@ class AppRuntime:
|
||||
self._frontend_ready_task = asyncio.create_task(self._await_frontend_bind())
|
||||
return True
|
||||
|
||||
def _resolve_launch(self, env: dict) -> tuple[list[str], str, str]:
|
||||
"""Pick the new-mode launch command.
|
||||
|
||||
Default is `bash run.sh` at the workspace root, which handles both
|
||||
frontend-only and backend-enabled apps. On Windows we take a fast path
|
||||
for frontend-only apps (the common case): run vite directly through the
|
||||
bundled node, with no system `bash` at all. The packaged Windows build
|
||||
ships node but not bash, so a user without Git for Windows hit
|
||||
[WinError 2] on `bash run.sh` and the preview never started. We only
|
||||
take this path when vite is actually present (node_modules linked);
|
||||
otherwise fall back to bash so behavior is unchanged everywhere else.
|
||||
vite.config.ts reads FRONTEND_PORT / BACKEND_PORT from the environment."""
|
||||
if os.name == "nt" and self.port is None:
|
||||
node = env.get("OPENSWARM_NODE_PATH") or shutil.which("node")
|
||||
vite_bin = os.path.join(
|
||||
self.workspace_path, "frontend", "node_modules", "vite", "bin", "vite.js"
|
||||
)
|
||||
if node and os.path.exists(node) and os.path.exists(vite_bin):
|
||||
env["FRONTEND_PORT"] = str(self.frontend_port)
|
||||
env["BACKEND_PORT"] = "NONE"
|
||||
return (
|
||||
[node, "node_modules/vite/bin/vite.js"],
|
||||
os.path.join(self.workspace_path, "frontend"),
|
||||
"vite (bundled node, no bash)",
|
||||
)
|
||||
return [_resolve_bash(), "run.sh"], self.workspace_path, "bash run.sh"
|
||||
|
||||
async def _await_frontend_bind(self) -> None:
|
||||
"""Poll `frontend_port` every _FRONTEND_BIND_POLL_INTERVAL until
|
||||
something binds (Vite dev server) or we hit the timeout. Emits a
|
||||
@@ -460,13 +502,16 @@ class AppRuntime:
|
||||
pass
|
||||
|
||||
def _maybe_capture_error(self, text: str) -> None:
|
||||
"""If a stderr/stdout line matches a known build-error pattern,
|
||||
record it for the next agent-tool drain. Tests every line ,
|
||||
cheap (single regex search) and only the matching ones land in
|
||||
the buffer."""
|
||||
if _ERROR_PATTERNS.search(text):
|
||||
self.recent_errors.append(text.rstrip())
|
||||
|
||||
def p_maybe_capture_render_beacon(self, text: str) -> None:
|
||||
if "[openswarm:app-ready]" in text:
|
||||
self.set_render_ok()
|
||||
elif "[openswarm:app-error]" in text:
|
||||
idx = text.index("[openswarm:app-error]") + len("[openswarm:app-error]")
|
||||
self.set_render_error(text[idx:].strip())
|
||||
|
||||
async def _pipe_stream(self, stream: Optional[asyncio.StreamReader], name: str) -> None:
|
||||
if stream is None:
|
||||
return
|
||||
@@ -480,6 +525,7 @@ class AppRuntime:
|
||||
self._broadcast(LogLine(name, text))
|
||||
if name == "stderr" or name == "stdout":
|
||||
self._maybe_capture_error(text)
|
||||
self.p_maybe_capture_render_beacon(text)
|
||||
except Exception:
|
||||
logger.exception("log pipe error (%s) for %s", name, self.workspace_id)
|
||||
|
||||
@@ -638,6 +684,17 @@ class AppRuntimeManager:
|
||||
return rt.drain_errors()
|
||||
return []
|
||||
|
||||
def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]:
|
||||
rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id)
|
||||
if rt is None:
|
||||
return None, ""
|
||||
return rt.render_state, rt.render_error_text
|
||||
|
||||
def reset_render_state_for_workspace(self, workspace_id: str) -> None:
|
||||
rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id)
|
||||
if rt is not None:
|
||||
rt.reset_render_state()
|
||||
|
||||
async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]:
|
||||
rt = self.runtimes.get(workspace_id) or self._idle_lru.get(workspace_id)
|
||||
if rt is None:
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Per-app version history, stored the way git stores history: content-addressed.
|
||||
|
||||
Each unique file's bytes are written ONCE to a per-app blob store (sha256 name,
|
||||
zlib-compressed); a version is a tiny manifest mapping path -> blob digest. So a
|
||||
run that changes one file out of fifty costs one new blob, not fifty, and 500
|
||||
versions of a barely-changing app stay small. That keeps this feature from
|
||||
making OpenSwarm feel heavier than it already is. We reuse the .swarm app
|
||||
serializer (captures flat-inline AND webapp_template workspace apps; skips
|
||||
node_modules/.venv/dist/.git, excludes .env), but never .swarm's pack() (it
|
||||
refuses on secret-shaped fields, and a local snapshot must never decline to save
|
||||
the user's own app). No git binary: it isn't guaranteed on a packaged Mac/Win."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import zlib
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.apps.outputs.models import Output, OutputVersion
|
||||
from backend.apps.outputs.workspace_io import _WALK_SKIP_DIRS, _save, load_output
|
||||
from backend.apps.swarm.entities.apps import AppExportable
|
||||
from backend.config.paths import OUTPUTS_VERSIONS_DIR, OUTPUTS_WORKSPACE_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_FILE_BYTES = 25 * 1024 * 1024 # don't snapshot giant build artifacts
|
||||
|
||||
|
||||
class _NullExportCtx:
|
||||
"""serialize() wants an ExportContext but apps have no cross-refs to rewrite."""
|
||||
|
||||
def bundle_id_for(self, etype, local_id): # noqa: ARG002
|
||||
return None
|
||||
|
||||
|
||||
_NULL_CTX = _NullExportCtx()
|
||||
|
||||
|
||||
def _app_dir(output_id: str) -> str:
|
||||
return os.path.join(OUTPUTS_VERSIONS_DIR, output_id)
|
||||
|
||||
|
||||
def _blobs_dir(output_id: str) -> str:
|
||||
return os.path.join(_app_dir(output_id), "blobs")
|
||||
|
||||
|
||||
def _manifests_dir(output_id: str) -> str:
|
||||
return os.path.join(_app_dir(output_id), "manifests")
|
||||
|
||||
|
||||
def _digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
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("version file path escapes the workspace")
|
||||
return dest
|
||||
|
||||
|
||||
def _write_blob(output_id: str, data: bytes, digest: str) -> None:
|
||||
"""Store bytes once, content-addressed. A blob that already exists is the
|
||||
whole point: that's a file unchanged since an earlier version, stored zero
|
||||
extra times."""
|
||||
path = os.path.join(_blobs_dir(output_id), digest)
|
||||
if os.path.exists(path):
|
||||
return
|
||||
os.makedirs(_blobs_dir(output_id), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(zlib.compress(data, 6))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _read_blob(output_id: str, digest: str) -> bytes | None:
|
||||
try:
|
||||
with open(os.path.join(_blobs_dir(output_id), digest), "rb") as f:
|
||||
return zlib.decompress(f.read())
|
||||
except (OSError, zlib.error):
|
||||
return None
|
||||
|
||||
|
||||
def _read_manifest(output_id: str, version_id: str) -> dict | None:
|
||||
try:
|
||||
with open(os.path.join(_manifests_dir(output_id), f"{version_id}.json"), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _latest_manifest(output_id: str) -> dict | None:
|
||||
"""Newest manifest by write time; only read for the dedupe check so capture
|
||||
stays O(1) reads rather than scanning every version's content."""
|
||||
folder = _manifests_dir(output_id)
|
||||
if not os.path.isdir(folder):
|
||||
return None
|
||||
js = [os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".json")]
|
||||
if not js:
|
||||
return None
|
||||
newest = max(js, key=os.path.getmtime)
|
||||
try:
|
||||
with open(newest, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _tree_hash(app_meta: dict, file_map: dict[str, str]) -> str:
|
||||
"""Dedupe key over the snapshot's content: app metadata + path->digest map.
|
||||
Cheap because the per-file hashing already happened to name the blobs."""
|
||||
h = hashlib.sha256()
|
||||
h.update(json.dumps(app_meta, sort_keys=True).encode("utf-8"))
|
||||
h.update(b"\0")
|
||||
for path in sorted(file_map):
|
||||
h.update(path.encode("utf-8"))
|
||||
h.update(b"\0")
|
||||
h.update(file_map[path].encode("utf-8"))
|
||||
h.update(b"\0")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _snapshot(output: Output) -> tuple[dict, dict[str, bytes]]:
|
||||
app = AppExportable(output)
|
||||
return app.serialize(_NULL_CTX), app.files() # files keyed workspace/<rel>
|
||||
|
||||
|
||||
def list_versions(output_id: str) -> list[OutputVersion]:
|
||||
folder = _manifests_dir(output_id)
|
||||
if not os.path.isdir(folder):
|
||||
return []
|
||||
out: list[OutputVersion] = []
|
||||
for fname in os.listdir(folder):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
m = _read_manifest(output_id, fname[:-5])
|
||||
if m is None:
|
||||
continue
|
||||
try:
|
||||
out.append(OutputVersion(**m)) # ignores the heavy keys (app_meta, files)
|
||||
except Exception:
|
||||
logger.warning("skipping unreadable version manifest %s", fname)
|
||||
out.sort(key=lambda v: v.created_at, reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def capture(
|
||||
output_id: str,
|
||||
*,
|
||||
source: str = "auto",
|
||||
label: str = "",
|
||||
thumbnail: str | None = None,
|
||||
) -> OutputVersion | None:
|
||||
"""Snapshot current app state. Returns the existing latest version (no new
|
||||
manifest) when nothing changed, so unchanged runs don't pile up. None only if
|
||||
the app is gone."""
|
||||
output = load_output(output_id)
|
||||
if output is None:
|
||||
return None
|
||||
app_meta, files = _snapshot(output)
|
||||
|
||||
file_map: dict[str, str] = {}
|
||||
for path, data in files.items():
|
||||
if len(data) > _MAX_FILE_BYTES:
|
||||
continue
|
||||
file_map[path] = _digest(data)
|
||||
tree = _tree_hash(app_meta, file_map)
|
||||
|
||||
latest = _latest_manifest(output_id)
|
||||
parent_id = latest.get("id") if latest else None
|
||||
if latest is not None and latest.get("tree_hash") == tree:
|
||||
try:
|
||||
return OutputVersion(**latest)
|
||||
except Exception:
|
||||
pass # corrupt latest: fall through and write a fresh manifest
|
||||
|
||||
for path, data in files.items():
|
||||
d = file_map.get(path)
|
||||
if d is not None:
|
||||
_write_blob(output_id, data, d)
|
||||
|
||||
vid = uuid4().hex
|
||||
manifest = {
|
||||
"id": vid,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"label": label or "",
|
||||
"source": source,
|
||||
"parent_id": parent_id,
|
||||
"thumbnail": thumbnail if thumbnail is not None else output.thumbnail,
|
||||
"tree_hash": tree,
|
||||
"app_meta": app_meta,
|
||||
"files": file_map,
|
||||
}
|
||||
os.makedirs(_manifests_dir(output_id), exist_ok=True)
|
||||
dest = os.path.join(_manifests_dir(output_id), f"{vid}.json")
|
||||
tmp = dest + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f)
|
||||
os.replace(tmp, dest)
|
||||
return OutputVersion(**manifest)
|
||||
|
||||
|
||||
def _restore_workspace(output_id: str, workspace_id: str, file_map: dict[str, str]) -> None:
|
||||
"""Make the workspace match the snapshot. Deletes current authored files not
|
||||
in it, then writes the rest FROM BLOBS, skipping any file already byte-correct
|
||||
(so a restore writes only the diff). Keeps the live .env and build/cache dirs.
|
||||
Per-file writes: a crash mid-restore leaves a mixed tree, but the pre_restore
|
||||
backup restore() takes first is the real safety net."""
|
||||
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, workspace_id)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
targets: dict[str, str] = {
|
||||
key[len("workspace/"):]: dig for key, dig in file_map.items() if key.startswith("workspace/")
|
||||
}
|
||||
|
||||
for root, dirs, fnames in os.walk(folder):
|
||||
dirs[:] = [d for d in dirs if d not in _WALK_SKIP_DIRS]
|
||||
for fname in fnames:
|
||||
if fname == ".env":
|
||||
continue
|
||||
full = os.path.join(root, fname)
|
||||
rel = os.path.relpath(full, folder).replace(os.sep, "/")
|
||||
if rel not in targets:
|
||||
try:
|
||||
os.remove(full)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for rel, digest in targets.items():
|
||||
dest = _safe_join(folder, rel)
|
||||
if os.path.exists(dest):
|
||||
try:
|
||||
with open(dest, "rb") as f:
|
||||
if _digest(f.read()) == digest:
|
||||
continue # already correct: don't rewrite
|
||||
except OSError:
|
||||
pass
|
||||
data = _read_blob(output_id, digest)
|
||||
if data is None:
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def restore(output_id: str, version_id: str) -> Output | None:
|
||||
"""Bring the app back to an earlier version, in place. Saves the current
|
||||
state as a pre_restore version first so this is always undoable."""
|
||||
output = load_output(output_id)
|
||||
if output is None:
|
||||
return None
|
||||
manifest = _read_manifest(output_id, version_id)
|
||||
if manifest is None:
|
||||
return None
|
||||
|
||||
target_label = manifest.get("label") or "an earlier version"
|
||||
capture(output_id, source="pre_restore", label=f"Before restoring '{target_label}'")
|
||||
|
||||
app_meta = manifest.get("app_meta") or {}
|
||||
output.name = app_meta.get("name", output.name)
|
||||
output.description = app_meta.get("description", output.description)
|
||||
output.icon = app_meta.get("icon", output.icon)
|
||||
# Presence, not truthiness: a snapshot's empty {} schema must restore as empty.
|
||||
if app_meta.get("input_schema") is not None:
|
||||
output.input_schema = app_meta["input_schema"]
|
||||
output.files = app_meta.get("files") or {}
|
||||
if manifest.get("thumbnail") is not None:
|
||||
output.thumbnail = manifest["thumbnail"]
|
||||
now = datetime.now().isoformat()
|
||||
output.updated_at = now
|
||||
output.preview_updated_at = now
|
||||
|
||||
if output.workspace_id:
|
||||
_restore_workspace(output_id, output.workspace_id, manifest.get("files") or {})
|
||||
_save(output)
|
||||
return output
|
||||
|
||||
|
||||
def branch(output_id: str, version_id: str) -> str | None:
|
||||
"""Make a brand-new app from an earlier version. Reuses the .swarm importer,
|
||||
which mints a fresh output id + workspace id and localizes a fresh .env."""
|
||||
manifest = _read_manifest(output_id, version_id)
|
||||
if manifest is None:
|
||||
return None
|
||||
app_meta = dict(manifest.get("app_meta") or {})
|
||||
app_meta["name"] = f"{app_meta.get('name') or 'App'} (copy)"
|
||||
files: dict[str, bytes] = {}
|
||||
for path, digest in (manifest.get("files") or {}).items():
|
||||
data = _read_blob(output_id, digest)
|
||||
if data is not None:
|
||||
files[path] = data
|
||||
from backend.apps.swarm.exportable import RemapTable
|
||||
try:
|
||||
return AppExportable.import_(app_meta, files, RemapTable())
|
||||
except Exception:
|
||||
# A partial import leaves an orphan workspace dir (small); better than a 500.
|
||||
logger.exception("branch import failed for %s/%s", output_id, version_id)
|
||||
return None
|
||||
|
||||
|
||||
def delete_all(output_id: str) -> None:
|
||||
shutil.rmtree(_app_dir(output_id), ignore_errors=True)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""HTTP surface for app version history. Thin: each route validates then
|
||||
delegates to versions.py. Its own SubApp so the already-large outputs.py doesn't
|
||||
grow, and so the agent-running restore guard lives next to the route, not in the
|
||||
pure store. Prefix: /api/output_versions."""
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.apps.outputs import versions
|
||||
from backend.apps.outputs.workspace_io import load_output
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.config.paths import OUTPUTS_VERSIONS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def output_versions_lifespan():
|
||||
os.makedirs(OUTPUTS_VERSIONS_DIR, exist_ok=True)
|
||||
yield
|
||||
|
||||
|
||||
output_versions = SubApp("output_versions", output_versions_lifespan)
|
||||
|
||||
|
||||
class CaptureRequest(BaseModel):
|
||||
# Clients can only ask for auto/manual; pre_restore is set internally by restore.
|
||||
source: Literal["auto", "manual"] = "manual"
|
||||
label: str = ""
|
||||
thumbnail: Optional[str] = None
|
||||
|
||||
|
||||
@output_versions.router.get("/{output_id}")
|
||||
async def list_output_versions(output_id: str):
|
||||
return {"versions": [v.model_dump() for v in versions.list_versions(output_id)]}
|
||||
|
||||
|
||||
@output_versions.router.post("/{output_id}")
|
||||
async def capture_output_version(output_id: str, body: CaptureRequest):
|
||||
v = versions.capture(
|
||||
output_id, source=body.source, label=body.label, thumbnail=body.thumbnail
|
||||
)
|
||||
if v is None:
|
||||
raise HTTPException(status_code=404, detail="Output not found")
|
||||
return {"ok": True, "version": v.model_dump()}
|
||||
|
||||
|
||||
@output_versions.router.post("/{output_id}/{version_id}/restore")
|
||||
async def restore_output_version(output_id: str, version_id: str):
|
||||
output = load_output(output_id)
|
||||
if output is None:
|
||||
raise HTTPException(status_code=404, detail="Output not found")
|
||||
# Don't restore out from under a live builder run. The frontend disables the
|
||||
# button while the agent is active; this is the backend half of that guard.
|
||||
if output.session_id:
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
session = agent_manager.sessions.get(output.session_id)
|
||||
if session and getattr(session, "status", None) in ("running", "waiting_approval"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This app is still being edited. Wait for the current change to finish, then try again.",
|
||||
)
|
||||
restored = versions.restore(output_id, version_id)
|
||||
if restored is None:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
return {"ok": True, "output": restored.model_dump()}
|
||||
|
||||
|
||||
@output_versions.router.post("/{output_id}/{version_id}/branch")
|
||||
async def branch_output_version(output_id: str, version_id: str):
|
||||
if load_output(output_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Output not found")
|
||||
new_id = versions.branch(output_id, version_id)
|
||||
if new_id is None:
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
return {"ok": True, "new_output_id": new_id}
|
||||
@@ -214,6 +214,19 @@ def _bundled_archive_path_for(digest: str) -> str:
|
||||
return os.path.join(_BUNDLED_ARCHIVE_DIR, f"node_modules.{digest}.tar.gz")
|
||||
|
||||
|
||||
def _bundled_extracted_modules() -> str | None:
|
||||
"""A node_modules tree shipped ALREADY EXTRACTED in resources (digest-tagged),
|
||||
so a workspace can junction straight at it with ZERO extract. This skips the
|
||||
~14s first-app tar-extract on Windows (the extract is dominated by Defender
|
||||
scanning ~tens of thousands of small files as they're written; shipping it
|
||||
extracted moves that scan to install time, once). Returns the read-only path
|
||||
or None when no extracted tree is shipped (e.g. the Mac build, which ships
|
||||
the .tar.gz and uses the extract path instead). vite only reads node_modules
|
||||
(its optimize cache lives elsewhere), so a read-only shared tree is safe."""
|
||||
cand = os.path.join(_BUNDLED_ARCHIVE_DIR, _warm_cache_digest(), "node_modules")
|
||||
return cand if os.path.isdir(cand) else None
|
||||
|
||||
|
||||
def _try_extract_bundled_archive(cache_dir: str, digest: str) -> bool:
|
||||
"""Unpack the sha-tagged bundled archive into `cache_dir` if one
|
||||
exists for the current template digest. Returns True on success,
|
||||
@@ -273,27 +286,54 @@ def _warm_cache_dir() -> str:
|
||||
return os.path.join(base, _warm_cache_digest())
|
||||
|
||||
|
||||
def _warm_cache_is_complete(cache_modules: str) -> bool:
|
||||
"""A populated node_modules/ dir is not proof of a *finished* install.
|
||||
npm links package bins (node_modules/.bin/*) in the final phase, so an
|
||||
install killed partway (e.g. Electron quit mid-warm) leaves the package
|
||||
trees on disk but no .bin/. The old `os.path.isdir(node_modules)` check
|
||||
then trusted that half-tree forever, every app symlinked to it, and
|
||||
`npm run dev` died with `vite: command not found`. Require the one bin
|
||||
every webapp-template app actually launches with so a partial cache is
|
||||
treated as not-ready and repopulated instead of cached as good."""
|
||||
return os.path.exists(os.path.join(cache_modules, ".bin", "vite"))
|
||||
|
||||
|
||||
def _ensure_warm_cache() -> str | None:
|
||||
"""Populate the warm-cache node_modules if missing. Returns the
|
||||
absolute path to the populated `node_modules` directory, or None on
|
||||
failure. Thread-safe; concurrent callers block on a single install
|
||||
instead of racing. Idempotent and fast after the first call."""
|
||||
"""Populate the warm-cache node_modules if missing or incomplete.
|
||||
Returns the absolute path to the populated `node_modules` directory, or
|
||||
None on failure. Thread-safe; concurrent callers block on a single
|
||||
install instead of racing. Idempotent and fast after the first call."""
|
||||
cache_dir = _warm_cache_dir()
|
||||
cache_modules = os.path.join(cache_dir, "node_modules")
|
||||
|
||||
if os.path.isdir(cache_modules):
|
||||
if _warm_cache_is_complete(cache_modules):
|
||||
return cache_modules
|
||||
|
||||
# Prefer a pre-extracted bundled tree: junction the workspace straight at it,
|
||||
# no tar-extract and no npm. This is the #9 first-app speed win on Windows.
|
||||
bundled = _bundled_extracted_modules()
|
||||
if bundled:
|
||||
logger.info("webapp-template: using bundled pre-extracted node_modules (zero extract)")
|
||||
return bundled
|
||||
|
||||
with _warm_cache_lock:
|
||||
if os.path.isdir(cache_modules):
|
||||
if _warm_cache_is_complete(cache_modules):
|
||||
return cache_modules
|
||||
# A node_modules that exists but flunks the completeness check is a
|
||||
# half-finished install; wipe it so the rebuild below starts on clean
|
||||
# ground instead of layering onto a broken tree.
|
||||
if os.path.isdir(cache_modules):
|
||||
shutil.rmtree(cache_modules, ignore_errors=True)
|
||||
# Fast path: pre-built archive shipped inside the release. The
|
||||
# build script generates this so users hitting OpenSwarm for the
|
||||
# first time skip the ~22 s live `npm install`. Falls through on
|
||||
# any failure so dev installs (no archive) keep working.
|
||||
if _try_extract_bundled_archive(cache_dir, _warm_cache_digest()):
|
||||
logger.info("webapp-template: warm cache ready from bundled archive")
|
||||
return cache_modules
|
||||
if _warm_cache_is_complete(cache_modules):
|
||||
logger.info("webapp-template: warm cache ready from bundled archive")
|
||||
return cache_modules
|
||||
# Archive unpacked a tree without the launch bin; don't trust it.
|
||||
shutil.rmtree(cache_modules, ignore_errors=True)
|
||||
try:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
# Copy package.json + lockfile (if it exists) into the cache
|
||||
@@ -338,12 +378,48 @@ def _ensure_warm_cache() -> str | None:
|
||||
(result.stderr or "")[-1500:],
|
||||
)
|
||||
return None
|
||||
# Never hand back a tree the workspace can't actually launch from.
|
||||
if not _warm_cache_is_complete(cache_modules):
|
||||
logger.warning("webapp-template: warm-cache install left no .bin/vite; not caching")
|
||||
return None
|
||||
return cache_modules
|
||||
except Exception as exc:
|
||||
logger.warning("webapp-template warm-cache failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _try_link_dir(src: str, target: str) -> bool:
|
||||
"""Point `target` at `src` as cheaply as possible. Prefer a symlink (instant,
|
||||
shared, zero disk). On Windows os.symlink needs admin / Developer Mode, which
|
||||
a normal user account lacks, so fall back to a directory junction (mklink /J,
|
||||
no privilege required), then to a full copy as a last resort so even a
|
||||
locked-down Windows box ends up with a usable node_modules. Returns True if
|
||||
`target` now resolves to the dependency tree."""
|
||||
try:
|
||||
os.symlink(src, target)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
if os.name == "nt":
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", target, src],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if r.returncode == 0 and os.path.isdir(target):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Slow + uses disk, but guarantees the workspace can boot vite even when
|
||||
# neither symlink nor junction is available.
|
||||
shutil.copytree(src, target, dirs_exist_ok=True)
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("webapp-template link/copy failed (%s) for %s", exc, target)
|
||||
return False
|
||||
|
||||
|
||||
def _link_node_modules(workspace_dir: str) -> None:
|
||||
"""After copytree, point the workspace's frontend/node_modules at
|
||||
the warm-cache directory. Safe fallback; if the cache isn't ready,
|
||||
@@ -379,10 +455,11 @@ def _link_node_modules(workspace_dir: str) -> None:
|
||||
return
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
os.symlink(cache_modules, target)
|
||||
logger.info("webapp-template: linked %s -> %s", target, cache_modules)
|
||||
except OSError as exc:
|
||||
logger.warning("webapp-template symlink failed (%s) for %s", exc, workspace_dir)
|
||||
logger.warning("webapp-template mkdir failed (%s) for %s", exc, workspace_dir)
|
||||
return
|
||||
if _try_link_dir(cache_modules, target):
|
||||
logger.info("webapp-template: linked %s -> %s", target, cache_modules)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -484,7 +561,7 @@ def warm_cache_in_background() -> None:
|
||||
global _warm_cache_thread
|
||||
if _warm_cache_thread is not None and _warm_cache_thread.is_alive():
|
||||
return
|
||||
node_done = os.path.isdir(os.path.join(_warm_cache_dir(), "node_modules"))
|
||||
node_done = _warm_cache_is_complete(os.path.join(_warm_cache_dir(), "node_modules"))
|
||||
venv_done = os.path.isfile(os.path.join(_warm_venv_dir(), ".populated"))
|
||||
if node_done and venv_done:
|
||||
return
|
||||
|
||||
@@ -40,12 +40,18 @@ fi
|
||||
# Fast path: the seeder usually symlinks node_modules to a shared warm
|
||||
# cache (~/.openswarm/cache/webapp_template_node_modules/<hash>), so the
|
||||
# dependency install has already been done once and we can skip straight
|
||||
# to vite. Only run npm install when node_modules is genuinely missing
|
||||
# or empty — e.g. a workspace seeded before the warm-cache existed, or
|
||||
# the user's cache was cleared.
|
||||
if [ -d node_modules ] && [ -n "$(ls -A node_modules 2>/dev/null)" ]; then
|
||||
echo "Dependencies already present — skipping install."
|
||||
# to vite. A non-empty node_modules is NOT proof of a finished install
|
||||
# (npm links .bin/* last, so a killed install leaves trees but no bin and
|
||||
# vite dies with "command not found"); gate on the bin we actually launch.
|
||||
if [ -e node_modules/.bin/vite ]; then
|
||||
echo "Dependencies already present - skipping install."
|
||||
else
|
||||
# Incomplete tree. If node_modules is a SYMLINK to the shared warm cache,
|
||||
# never install through it: that writes into the cache every other app
|
||||
# shares (corruption) and stampedes when several apps boot at once. Drop
|
||||
# the link and install a private tree so this app heals alone while the
|
||||
# backend's background warmer rebuilds the shared cache for everyone else.
|
||||
[ -L node_modules ] && rm -f node_modules
|
||||
echo "Installing dependencies..."
|
||||
"$NPM" install --prefer-offline --no-audit --no-fund
|
||||
fi
|
||||
@@ -54,7 +60,7 @@ echo "Building with development mode..."
|
||||
# Prefer `npm run dev` (honors package.json script + flags). But the
|
||||
# packaged build ships node.exe WITHOUT npm, so on a machine with no
|
||||
# system npm we fall back to invoking vite directly through the bundled
|
||||
# node — node_modules is already populated (warm-cache symlink or seed),
|
||||
# node; node_modules is already populated (warm-cache symlink or seed),
|
||||
# so vite's bin is present and this needs no package manager at all.
|
||||
if command -v "$NPM" &>/dev/null || [[ "$NPM" != "npm" ]]; then
|
||||
"$NPM" run dev
|
||||
|
||||
@@ -35,8 +35,34 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
// Clean first mount: nothing caught, so the app is in a good render
|
||||
// state and the ready beacon is allowed.
|
||||
if (!this.state.error) {
|
||||
window.__openswarm_render_failed = false;
|
||||
window.__openswarm_last_error = '';
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState): void {
|
||||
// Fast Refresh retried the previously-broken subtree and it rendered:
|
||||
// re-allow the ready beacon so index.tsx's vite:afterUpdate can report ok.
|
||||
if (prevState.error && !this.state.error) {
|
||||
window.__openswarm_render_failed = false;
|
||||
window.__openswarm_last_error = '';
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({ errorInfo });
|
||||
// Suppress the post-mount "ready" beacon in index.tsx and re-arm the
|
||||
// window-error gate: the app is not in a rendered state right now.
|
||||
// Stash the message so the HMR handler can re-assert this error after
|
||||
// an unrelated edit (which resets the host's render-state to None).
|
||||
window.__openswarm_render_failed = true;
|
||||
window.__openswarm_rendered = false;
|
||||
window.__openswarm_last_error =
|
||||
`${error?.message ?? String(error)}\n${errorInfo?.componentStack ?? ''}`.trim();
|
||||
// Two channels so the OpenSwarm host's webview-preload bridge can
|
||||
// pick this up regardless of which one it taps:
|
||||
// 1. console.error — forwarded as a `[FRONTEND]` line into the
|
||||
|
||||
@@ -3,21 +3,67 @@ import { createRoot } from 'react-dom/client';
|
||||
import Main from './app/Main';
|
||||
import ErrorBoundary from './app/components/ErrorBoundary';
|
||||
|
||||
console.log('[App] Bootstrapping React app');
|
||||
// Render-health beacons the OpenSwarm host reads (via the forwarded preview
|
||||
// console) to decide, at the end of an agent turn, whether the app renders.
|
||||
// The ErrorBoundary covers React render crashes; the listeners here cover
|
||||
// what never reaches a boundary: module-load / pre-mount throws and vite
|
||||
// transform errors.
|
||||
function reportRender(ok: boolean, detail?: string) {
|
||||
if (ok) {
|
||||
window.__openswarm_rendered = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[openswarm:app-ready]');
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[openswarm:app-error]', detail ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// Gate on __openswarm_rendered so a throw inside a click handler after a good
|
||||
// render (a bug, but not "the app won't render") doesn't block the turn.
|
||||
window.addEventListener('error', (e) => {
|
||||
if (!window.__openswarm_rendered) reportRender(false, e.message || String(e.error ?? e));
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
if (!window.__openswarm_rendered) reportRender(false, String(e.reason ?? e));
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
const hot = import.meta.hot;
|
||||
hot.on('vite:error', (payload) => {
|
||||
const err = payload?.err;
|
||||
reportRender(false, err?.message || err?.plugin || 'vite error');
|
||||
});
|
||||
// Re-assert the real state after every HMR update: if the ErrorBoundary is
|
||||
// still showing its fallback, report the error again (an unrelated edit that
|
||||
// didn't fix it must not flip the gate to "ready"); otherwise report ready.
|
||||
hot.on('vite:afterUpdate', () => {
|
||||
if (window.__openswarm_render_failed) {
|
||||
reportRender(false, window.__openswarm_last_error || 'app still failing to render');
|
||||
} else {
|
||||
reportRender(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
console.error('[App] FATAL: #root element not found in DOM');
|
||||
console.error('[openswarm:app-error]', '#root element not found in DOM');
|
||||
} else {
|
||||
// Wrap Main in an ErrorBoundary so any runtime crash from agent
|
||||
// edits (missing imports, hook-rules violations, etc.) shows a
|
||||
// readable error card in the preview pane instead of unmounting
|
||||
// to a blank screen. The boundary also forwards the error via
|
||||
// console.error + postMessage so the agent sees it on its next
|
||||
// turn.
|
||||
// to a blank screen. The boundary forwards the error via
|
||||
// console.error + postMessage so the agent sees it on its next turn.
|
||||
createRoot(rootEl).render(
|
||||
<ErrorBoundary>
|
||||
<Main />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
console.log('[App] React root mounted');
|
||||
// Defer a frame so a synchronous render crash sets __openswarm_render_failed
|
||||
// (via the boundary) before we'd wrongly report ready.
|
||||
requestAnimationFrame(() => {
|
||||
if (window.__openswarm_render_failed) return;
|
||||
reportRender(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pages/client-react" />
|
||||
|
||||
// Render-health beacon flags the OpenSwarm App Builder host reads off the preview.
|
||||
interface Window {
|
||||
__openswarm_rendered?: boolean;
|
||||
__openswarm_render_failed?: boolean;
|
||||
__openswarm_last_error?: string;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,13 @@ export default defineConfig(({ mode }) => {
|
||||
plugins: [
|
||||
react(),
|
||||
Pages({ dirs: 'src/pages', extensions: ['tsx'] }),
|
||||
terminal({ console: 'terminal', output: ['terminal', 'console'] }),
|
||||
// vite-plugin-terminal provides a `virtual:terminal/console` module
|
||||
// that only exists in dev; loading it during `vite build` errors
|
||||
// out, so the End-of-turn build-verify gate would fail on every
|
||||
// brand-new workspace.
|
||||
...(mode === 'development'
|
||||
? [terminal({ console: 'terminal', output: ['terminal', 'console'] })]
|
||||
: []),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_pulse_task: asyncio.Task | None = None
|
||||
_drain_task: asyncio.Task | None = None
|
||||
_9r_start_task: asyncio.Task | None = None
|
||||
|
||||
_last_9r_cost: float | None = None
|
||||
_last_9r_prompt_tokens: int | None = None
|
||||
@@ -121,7 +122,7 @@ async def _drain_loop():
|
||||
|
||||
@asynccontextmanager
|
||||
async def service_lifespan():
|
||||
global _pulse_task, _drain_task
|
||||
global _pulse_task, _drain_task, _9r_start_task
|
||||
|
||||
try:
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
@@ -193,7 +194,13 @@ async def service_lifespan():
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as ensure_9router
|
||||
await ensure_9router()
|
||||
# Start 9Router in the BACKGROUND instead of awaiting it here. Awaiting
|
||||
# it was ~7s (up to ~18s cold) of the startup critical path, blocking the
|
||||
# HTTP bind and the whole UI behind it. 9Router is only needed when the
|
||||
# user sends an agent message, and the dispatch path calls ensure_running()
|
||||
# itself (now serialized, so no double-spawn), so the first message waits
|
||||
# for readiness lazily. This is the single biggest warm-startup win.
|
||||
_9r_start_task = asyncio.create_task(ensure_9router())
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router auto-start skipped: {e}")
|
||||
|
||||
@@ -218,6 +225,14 @@ async def service_lifespan():
|
||||
pass
|
||||
_drain_task = None
|
||||
|
||||
if _9r_start_task and not _9r_start_task.done():
|
||||
_9r_start_task.cancel()
|
||||
try:
|
||||
await _9r_start_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
_9r_start_task = None
|
||||
|
||||
try:
|
||||
from backend.apps.nine_router import stop as stop_9router
|
||||
stop_9router()
|
||||
|
||||
@@ -76,6 +76,9 @@ class AppSettings(BaseModel):
|
||||
free_trial_token: Optional[str] = None
|
||||
free_trial_remaining: Optional[int] = None
|
||||
free_trial_runs_limit: Optional[int] = None
|
||||
# Epoch seconds when the rolling window refills to a fresh allotment; lets the spent-trial
|
||||
# nudge say "fresh runs in ~3h" instead of a vague "for now". Server-owned.
|
||||
free_trial_resets_at: Optional[float] = None
|
||||
openswarm_subscription_plan: Optional[str] = None
|
||||
openswarm_subscription_expires: Optional[str] = None
|
||||
openswarm_usage_cached: Optional[dict] = None
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Redact secrets out of a settings view before an agent ever sees it.
|
||||
|
||||
The settings-meta read tool is always-on, so an always-on exfiltration risk: a
|
||||
prompt-injected agent that could read raw settings could mail your API keys out.
|
||||
So the read tool returns shape + state, never a secret VALUE. Keys are write-only
|
||||
from the agent's side: it can SET a new one, never SEE the old.
|
||||
|
||||
The secret set is derived from field NAMES, not a hand-kept list that silently
|
||||
drifts the day someone adds a new credential. Rule: a field whose name ends in
|
||||
`_key`, `_token`, or `_secret` is a secret, plus installation_id (a stable
|
||||
machine fingerprint that isn't a credential but still shouldn't leak). A test
|
||||
asserts every field the settings PUT path already treats as secret is caught
|
||||
here, so the two can't diverge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from backend.common.secret_scan import looks_secret
|
||||
|
||||
_SECRET_NAME_SUFFIXES = ("_key", "_token", "_secret")
|
||||
# Not a credential and doesn't match the suffix rule, but a stable hardware-ish
|
||||
# fingerprint used for cohorting/abuse; keep it out of the agent's eyes too.
|
||||
_SECRET_EXTRA_FIELDS = frozenset({"installation_id"})
|
||||
|
||||
|
||||
def is_secret_field(name: str) -> bool:
|
||||
return name.endswith(_SECRET_NAME_SUFFIXES) or name in _SECRET_EXTRA_FIELDS
|
||||
|
||||
|
||||
def _value_is_secret_shaped(value: Any) -> bool:
|
||||
"""Fail-safe behind the name rule: a field the name rule misses (a future
|
||||
secret with an off-convention name) is still caught if its VALUE looks like
|
||||
a credential (sk-..., ghp_..., Bearer ...). So a leak needs BOTH a bad name
|
||||
AND a non-credential-shaped value, not just one."""
|
||||
return isinstance(value, str) and looks_secret(value)
|
||||
|
||||
|
||||
def _redact_value(value: Any) -> dict[str, Any]:
|
||||
"""A secret rendered as state, never content: configured + last 4 only."""
|
||||
if value is None or (isinstance(value, str) and value.strip() == ""):
|
||||
return {"configured": False}
|
||||
last4 = value[-4:] if isinstance(value, str) and len(value) >= 4 else None
|
||||
return {"configured": True, "last4": last4}
|
||||
|
||||
|
||||
def redact_settings(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of a settings dict with every secret value collapsed to
|
||||
{configured, last4}. Nested custom-provider api_keys are redacted too."""
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in raw.items():
|
||||
if is_secret_field(key) or _value_is_secret_shaped(value):
|
||||
out[key] = _redact_value(value)
|
||||
elif key == "custom_providers" and isinstance(value, list):
|
||||
out[key] = [_redact_custom_provider(cp) for cp in value]
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def _redact_custom_provider(cp: Any) -> Any:
|
||||
if not isinstance(cp, dict):
|
||||
return cp
|
||||
out = dict(cp)
|
||||
if "api_key" in out:
|
||||
out["api_key"] = _redact_value(out.get("api_key"))
|
||||
return out
|
||||
@@ -137,6 +137,7 @@ SERVER_OWNED_FIELDS = (
|
||||
"free_trial_token",
|
||||
"free_trial_remaining",
|
||||
"free_trial_runs_limit",
|
||||
"free_trial_resets_at",
|
||||
"user_id",
|
||||
"signin_method",
|
||||
"installation_id",
|
||||
@@ -146,14 +147,86 @@ SERVER_OWNED_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
import weakref as _weakref
|
||||
|
||||
# One serialization point for EVERY settings write (renderer PUT/PATCH + agent
|
||||
# tool), so two writes can't interleave and clobber each other mid read-modify-
|
||||
# write. Callers hold it across read->build->save; apply_settings_update itself
|
||||
# does NOT acquire it (would deadlock the agent path that reads under it), so
|
||||
# every caller wraps apply in it. Created lazily PER event loop: prod has one
|
||||
# loop so it's effectively a singleton, but a module-level asyncio.Lock binds to
|
||||
# the first loop that uses it and then errors on reuse from another loop (every
|
||||
# async test spins a fresh one). WeakKeyDictionary auto-drops a loop's lock once
|
||||
# the loop is gone.
|
||||
_settings_write_locks: "_weakref.WeakKeyDictionary" = _weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def settings_write_lock() -> asyncio.Lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = _settings_write_locks.get(loop)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_settings_write_locks[loop] = lock
|
||||
return lock
|
||||
|
||||
|
||||
@settings.router.put("")
|
||||
async def update_settings(body: AppSettings):
|
||||
async with settings_write_lock():
|
||||
saved = await apply_settings_update(body)
|
||||
return {"ok": True, "settings": saved.model_dump()}
|
||||
|
||||
|
||||
@settings.router.patch("")
|
||||
async def patch_settings(changes: dict):
|
||||
"""Save only the fields the user changed, merged onto the CURRENT on-disk
|
||||
state. The renderer sends a diff (not a stale full object), so a save can't
|
||||
clobber a field something else, an agent, an OAuth connect, changed
|
||||
underneath it. Makes the lost-update unrepresentable: you can't overwrite a
|
||||
field you never sent."""
|
||||
async with settings_write_lock():
|
||||
saved = await apply_settings_patch(changes)
|
||||
return {"ok": True, "settings": saved.model_dump()}
|
||||
|
||||
|
||||
async def apply_settings_patch(changes: dict) -> AppSettings:
|
||||
"""Merge `changes` onto fresh on-disk settings and persist. Caller holds
|
||||
settings_write_lock so the read is current. Reuses apply_settings_update for
|
||||
every side effect: the object it hands over IS current state plus the diff,
|
||||
which is exactly what a non-clobbering save means."""
|
||||
valid = set(AppSettings.model_fields.keys())
|
||||
data = load_settings().model_dump()
|
||||
for k, v in changes.items():
|
||||
if k in valid:
|
||||
data[k] = v
|
||||
return await apply_settings_update(AppSettings(**data))
|
||||
|
||||
|
||||
async def apply_settings_update(body: AppSettings, protect_fields: set[str] | None = None) -> AppSettings:
|
||||
"""Persist a full settings object with all the safety side effects: restore
|
||||
server-owned fields, hand the wheel back from the free trial when a real
|
||||
model is connected, reconcile 9router provider connections, and sync
|
||||
analytics/identity. The PUT route and the agent settings tool both call this
|
||||
so the write semantics can't drift between them. Returns the saved body.
|
||||
|
||||
Caller must hold settings_write_lock. `protect_fields` names credential fields
|
||||
that must never be blanked by this write (the agent tool passes the field
|
||||
powering the live run): a SECOND, independent wall behind the endpoint's
|
||||
suicide-guard, so a guard bug still can't disconnect a run."""
|
||||
from backend.apps.service.client import sync as _sync
|
||||
|
||||
old = load_settings()
|
||||
for k in SERVER_OWNED_FIELDS:
|
||||
setattr(body, k, getattr(old, k, None))
|
||||
|
||||
# Second wall: if a write tries to clear a credential that's currently set and
|
||||
# flagged as powering this run, restore it (like server-owned fields). The
|
||||
# endpoint guard already strips these; this is the backstop that can't be
|
||||
# bypassed by a logic slip upstream.
|
||||
for f in (protect_fields or ()):
|
||||
if getattr(old, f, None) and not getattr(body, f, None):
|
||||
setattr(body, f, getattr(old, f, None))
|
||||
|
||||
# If the user connects their own model while the free trial is armed, hand
|
||||
# the wheel back to their provider. Without this, connection_mode (server-
|
||||
# owned, so the loop above just restored it to "free-trial") would keep them
|
||||
@@ -271,7 +344,7 @@ async def update_settings(body: AppSettings):
|
||||
any_keyed_added,
|
||||
))
|
||||
|
||||
return {"ok": True, "settings": body.model_dump()}
|
||||
return body
|
||||
|
||||
|
||||
class AppThemeOverridePayload(BaseModel):
|
||||
@@ -320,6 +393,33 @@ async def reset_system_prompt():
|
||||
return {"ok": True, "settings": current.model_dump()}
|
||||
|
||||
|
||||
# A preferences reset (the iOS "Reset All Settings" analogue): everything back to
|
||||
# defaults EXCEPT the things a "reset my preferences" click must never silently
|
||||
# sever, your connections (server-owned subscription fields AND your pasted
|
||||
# provider credentials) and your identity. Hard-erase is the separate flow.
|
||||
_RESET_PRESERVE_FIELDS = SERVER_OWNED_FIELDS + (
|
||||
"anthropic_api_key",
|
||||
"openai_api_key",
|
||||
"google_api_key",
|
||||
"openrouter_api_key",
|
||||
"custom_providers",
|
||||
"user_name",
|
||||
"user_email",
|
||||
"analytics_opt_in",
|
||||
"first_opened_at",
|
||||
)
|
||||
|
||||
|
||||
@settings.router.post("/reset-to-defaults")
|
||||
async def reset_to_defaults():
|
||||
old = load_settings()
|
||||
fresh = AppSettings()
|
||||
for k in _RESET_PRESERVE_FIELDS:
|
||||
setattr(fresh, k, getattr(old, k, None))
|
||||
await save_settings_async(fresh)
|
||||
return {"ok": True, "settings": fresh.model_dump()}
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
current: str
|
||||
parent: Optional[str]
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi import HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from backend.config.Apps import SubApp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,12 +20,62 @@ RAW_BASE = f"https://raw.githubusercontent.com/{REPO}/{BRANCH}"
|
||||
MANIFEST_URL = f"{RAW_BASE}/.claude-plugin/marketplace.json"
|
||||
REFRESH_INTERVAL_S = 3600
|
||||
CONCURRENT_FETCHES = 15
|
||||
# Retry the startup fetch on this short backoff (capped) until the FIRST success,
|
||||
# instead of waiting a full REFRESH_INTERVAL_S after a cold/slow/failed fetch.
|
||||
# That 1h gap was the "skills empty until reboot" bug on cold Windows networks.
|
||||
_RETRY_BACKOFF_START_S = 2
|
||||
_RETRY_BACKOFF_MAX_S = 60
|
||||
|
||||
# Catalog ships in the repo so a brand-new install shows skills with zero network
|
||||
# (build snapshot), and every successful live fetch is persisted to the user's
|
||||
# cache so subsequent launches are instant + offline-safe. The live fetch always
|
||||
# overwrites both once it lands, so neither can go stale at runtime.
|
||||
_BUNDLED_SNAPSHOT = os.path.join(os.path.dirname(__file__), "skills_snapshot.json")
|
||||
|
||||
_cache: dict[str, dict] = {}
|
||||
_cache_updated_at: float = 0
|
||||
_refresh_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
def _disk_cache_path() -> str:
|
||||
base = os.environ.get("OPENSWARM_SKILL_CACHE_DIR") or os.path.expanduser(
|
||||
"~/.openswarm/cache"
|
||||
)
|
||||
return os.path.join(base, "skill_registry.json")
|
||||
|
||||
|
||||
def _load_seed_cache() -> dict[str, dict]:
|
||||
"""Return a non-empty catalog from the on-disk last-good cache, falling back
|
||||
to the bundled snapshot, so the registry is never empty on a cold/offline
|
||||
start. Returns {} only if neither source is present/valid."""
|
||||
for path in (_disk_cache_path(), _BUNDLED_SNAPSHOT):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict) and data:
|
||||
logger.info(f"Skill registry: seeded {len(data)} skills from {os.path.basename(path)}")
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _save_disk_cache(skills: dict[str, dict]) -> None:
|
||||
"""Persist the last good live fetch so the next launch is instant. Atomic
|
||||
replace so a crash mid-write can't leave a truncated cache."""
|
||||
if not skills:
|
||||
return
|
||||
path = _disk_cache_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(skills, f)
|
||||
os.replace(tmp, path)
|
||||
except OSError:
|
||||
logger.debug("Skill registry: could not persist disk cache", exc_info=True)
|
||||
|
||||
|
||||
def _parse_frontmatter(raw: str) -> tuple[dict, str]:
|
||||
"""Split YAML frontmatter from markdown body."""
|
||||
if not raw.startswith("---"):
|
||||
@@ -114,18 +167,37 @@ async def _fetch_all_skills() -> dict[str, dict]:
|
||||
|
||||
async def _refresh_loop():
|
||||
global _cache, _cache_updated_at
|
||||
backoff = _RETRY_BACKOFF_START_S
|
||||
while True:
|
||||
ok = False
|
||||
try:
|
||||
_cache = await _fetch_all_skills()
|
||||
_cache_updated_at = time.time()
|
||||
fetched = await _fetch_all_skills()
|
||||
if fetched:
|
||||
_cache = fetched
|
||||
_cache_updated_at = time.time()
|
||||
_save_disk_cache(_cache)
|
||||
ok = True
|
||||
except Exception as e:
|
||||
logger.exception(f"Skill registry refresh error: {e}")
|
||||
await asyncio.sleep(REFRESH_INTERVAL_S)
|
||||
if ok:
|
||||
# Settle to the slow hourly refresh once we have a good catalog.
|
||||
backoff = _RETRY_BACKOFF_START_S
|
||||
await asyncio.sleep(REFRESH_INTERVAL_S)
|
||||
else:
|
||||
# Cold/slow/failed fetch: retry soon (capped) until the first success
|
||||
# so a transient network hiccup doesn't leave the catalog empty for
|
||||
# an hour. The seeded snapshot keeps it non-empty meanwhile.
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, _RETRY_BACKOFF_MAX_S)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def skill_registry_lifespan():
|
||||
global _refresh_task
|
||||
global _refresh_task, _cache
|
||||
# Seed instantly from disk/bundled snapshot so the very first request never
|
||||
# sees an empty catalog (the live fetch below overwrites it when it lands).
|
||||
if not _cache:
|
||||
_cache = _load_seed_cache()
|
||||
_refresh_task = asyncio.create_task(_refresh_loop())
|
||||
yield
|
||||
if _refresh_task:
|
||||
@@ -159,7 +231,16 @@ async def registry_search(
|
||||
offset: int = Query(0, ge=0),
|
||||
sort: str = Query("name", description="Sort by: name"),
|
||||
category: str = Query("", description="Filter by category"),
|
||||
source: str = Query("curated", description="curated (vetted) | community (skills.sh wild registry)"),
|
||||
):
|
||||
# The wild registry is a remote 600k-entry index, searched live, not mirrored.
|
||||
if source == "community":
|
||||
try:
|
||||
return await _community_search(q, limit)
|
||||
except Exception as e:
|
||||
logger.warning(f"community skill search failed: {e}")
|
||||
return {"skills": [], "total": 0, "offset": 0, "limit": limit, "source": "community", "error": "skills.sh unreachable"}
|
||||
|
||||
pool = list(_cache.values())
|
||||
if category:
|
||||
cat_lower = category.lower()
|
||||
@@ -197,3 +278,218 @@ async def registry_detail(skill_name: str):
|
||||
if not sk:
|
||||
return {"error": "Skill not found"}, 404
|
||||
return {"skill": sk}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Community source: the skills.sh wild registry (~600k+ telemetry-ranked,
|
||||
# zero-curation community skills, GitHub-repo backed). The curated source above
|
||||
# (anthropics/skills) stays the default; community is opt-in via ?source=community
|
||||
# and the UI flags it as unvetted. See .claude/SECURITY.md for the posture: this
|
||||
# installs INERT files only (never executes), discloses scripts before commit,
|
||||
# and any skill script later runs through the same gated Bash path as anything.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COMMUNITY_SEARCH_URL = "https://skills.sh/api/search"
|
||||
_GH_API = "https://api.github.com"
|
||||
_GH_RAW = "https://raw.githubusercontent.com"
|
||||
_MAX_SKILL_FILES = 60
|
||||
_SCRIPT_EXTS = (".sh", ".py", ".js", ".mjs", ".cjs", ".ts", ".rb", ".pl", ".ps1", ".bat", ".php")
|
||||
|
||||
|
||||
def _is_script_path(rel: str) -> bool:
|
||||
"""Whether a skill file is executable code worth disclosing before install."""
|
||||
if rel.lower().endswith(_SCRIPT_EXTS):
|
||||
return True
|
||||
head = rel.split("/", 1)[0].lower()
|
||||
return head in ("scripts", "bin", "hooks")
|
||||
|
||||
|
||||
def _github_headers() -> dict:
|
||||
"""GitHub request headers, with auth if a token is set. Unauthenticated is
|
||||
60 req/hr/IP (fine for the odd install, the wall for a power user); a token
|
||||
(OPENSWARM_GITHUB_TOKEN or GITHUB_TOKEN) raises it to 5000/hr."""
|
||||
headers = {"User-Agent": "openswarm-skill-registry", "Accept": "application/vnd.github+json"}
|
||||
token = os.environ.get("OPENSWARM_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _select_skill_paths(tree: list[dict], skill_id: str) -> tuple[str, list[str]]:
|
||||
"""From a GitHub recursive tree, pick the SKILL.md for `skill_id` and every
|
||||
file beside it. Pure, so the resolution logic is unit-tested without a network
|
||||
round-trip. When a repo has several `<x>/<skill_id>/SKILL.md` matches the pick
|
||||
is deterministic: prefer a top-level `<skill_id>/`, then `skills/<skill_id>/`,
|
||||
then the shallowest, then alphabetical, never an arbitrary tie."""
|
||||
blobs = [t["path"] for t in tree if t.get("type") == "blob" and isinstance(t.get("path"), str)]
|
||||
candidates = [p for p in blobs if p.endswith(f"/{skill_id}/SKILL.md") or p == f"{skill_id}/SKILL.md"]
|
||||
if not candidates:
|
||||
raise ValueError(f"no SKILL.md for '{skill_id}' in this repo")
|
||||
|
||||
def _rank(p: str) -> tuple:
|
||||
if p == f"{skill_id}/SKILL.md":
|
||||
return (0, 0, p)
|
||||
if p == f"skills/{skill_id}/SKILL.md":
|
||||
return (1, p.count("/"), p)
|
||||
return (2, p.count("/"), p)
|
||||
|
||||
skill_md = min(candidates, key=_rank)
|
||||
skill_dir = skill_md[: -len("/SKILL.md")] if "/" in skill_md else ""
|
||||
prefix = (skill_dir + "/") if skill_dir else ""
|
||||
members = [p for p in blobs if (p.startswith(prefix) if prefix else "/" not in p)]
|
||||
return skill_md, members[:_MAX_SKILL_FILES]
|
||||
|
||||
|
||||
class RegistryRateLimited(Exception):
|
||||
"""GitHub's unauthenticated API (60/hr) is exhausted; the caller surfaces a
|
||||
'try again shortly' rather than a generic failure."""
|
||||
|
||||
|
||||
async def _tree_at(client: httpx.AsyncClient, owner: str, repo: str, branch: str):
|
||||
"""(tree | None) for a branch. None on 404 (branch absent); raises on 403."""
|
||||
r = await client.get(f"{_GH_API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1")
|
||||
if r.status_code == 200:
|
||||
return r.json().get("tree", [])
|
||||
if r.status_code == 403:
|
||||
raise RegistryRateLimited()
|
||||
return None
|
||||
|
||||
|
||||
async def _fetch_repo_tree(client: httpx.AsyncClient, owner: str, repo: str) -> tuple[str, list[dict]]:
|
||||
"""Recursive tree of owner/repo. Tries main then master first (one call, the
|
||||
99% case, no quota wasted on a repo-meta lookup); only if BOTH are absent
|
||||
does it ask the repo for its real default branch (handles develop/trunk/etc).
|
||||
Raises RegistryRateLimited on a 403, ValueError if no branch resolves."""
|
||||
for branch in ("main", "master"):
|
||||
tree = await _tree_at(client, owner, repo, branch)
|
||||
if tree is not None:
|
||||
return branch, tree
|
||||
meta = await client.get(f"{_GH_API}/repos/{owner}/{repo}")
|
||||
if meta.status_code == 403:
|
||||
raise RegistryRateLimited()
|
||||
if meta.status_code == 200:
|
||||
default = meta.json().get("default_branch")
|
||||
if default and default not in ("main", "master"):
|
||||
tree = await _tree_at(client, owner, repo, default)
|
||||
if tree is not None:
|
||||
return default, tree
|
||||
raise ValueError(f"repo {owner}/{repo} has no resolvable default branch")
|
||||
|
||||
|
||||
async def resolve_community_skill(source: str, skill_id: str) -> dict:
|
||||
"""Resolve a skills.sh entry (source='owner/repo', skill_id=folder name) to
|
||||
its files via the GitHub trees API. Returns name/description/repo_url plus
|
||||
{relpath: content} and the list of script files. Fetches text only; never
|
||||
runs anything. Raises ValueError on a bad source or a missing skill, and
|
||||
RegistryRateLimited when GitHub's anon API is exhausted."""
|
||||
owner, _, repo = source.partition("/")
|
||||
if not owner or not repo:
|
||||
raise ValueError(f"unrecognized source '{source}' (expected owner/repo)")
|
||||
async with httpx.AsyncClient(timeout=30.0, headers=_github_headers()) as client:
|
||||
branch, tree = await _fetch_repo_tree(client, owner, repo)
|
||||
skill_md, members = _select_skill_paths(tree, skill_id)
|
||||
skill_dir = skill_md[: -len("/SKILL.md")] if "/" in skill_md else ""
|
||||
prefix = (skill_dir + "/") if skill_dir else ""
|
||||
|
||||
files: dict[str, str] = {}
|
||||
for p in members:
|
||||
rel = p[len(prefix):] if prefix else p
|
||||
raw = await client.get(f"{_GH_RAW}/{owner}/{repo}/{branch}/{p}")
|
||||
if raw.status_code == 200:
|
||||
files[rel] = raw.text
|
||||
if "SKILL.md" not in files:
|
||||
raise ValueError("SKILL.md could not be fetched")
|
||||
|
||||
meta, _body = _parse_frontmatter(files["SKILL.md"])
|
||||
# Reuse the .swarm importer's content scan: flag files holding secret-shaped
|
||||
# literals (the author's leaked key, or a sketchy skill) so the user sees it
|
||||
# before installing from an unvetted repo.
|
||||
from backend.common.secret_scan import find_secrets_in_files
|
||||
secret_findings = find_secrets_in_files({rel: data.encode("utf-8", "ignore") for rel, data in files.items()})
|
||||
return {
|
||||
"name": meta.get("name") or skill_id,
|
||||
"description": meta.get("description", ""),
|
||||
"repo_url": f"https://github.com/{owner}/{repo}/tree/{branch}/{skill_dir}".rstrip("/"),
|
||||
"skill_id": skill_id,
|
||||
"files": files,
|
||||
"scripts": sorted(rel for rel in files if _is_script_path(rel)),
|
||||
"secret_findings": secret_findings,
|
||||
}
|
||||
|
||||
|
||||
async def _community_search(q: str, limit: int) -> dict:
|
||||
"""Live-proxy a query to the skills.sh wild registry. Not cached: it's a
|
||||
600k-entry remote index, so we search it on demand rather than mirror it."""
|
||||
async with httpx.AsyncClient(timeout=15.0, headers={"User-Agent": "openswarm"}) as client:
|
||||
r = await client.get(_COMMUNITY_SEARCH_URL, params={"q": q or "skill"})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
skills = []
|
||||
for s in (data.get("skills") or [])[:limit]:
|
||||
src = s.get("source", "")
|
||||
try:
|
||||
installs = int(s.get("installs") or 0)
|
||||
except (TypeError, ValueError):
|
||||
installs = 0
|
||||
skills.append({
|
||||
"name": s.get("name", ""),
|
||||
"description": f"{installs:,} installs",
|
||||
"folder": s.get("skillId", ""),
|
||||
"category": src,
|
||||
"repositoryUrl": f"https://github.com/{src}" if src else "",
|
||||
"source": src,
|
||||
"skillId": s.get("skillId", ""),
|
||||
"installs": installs,
|
||||
"community": True,
|
||||
})
|
||||
return {"skills": skills, "total": len(skills), "offset": 0, "limit": limit, "source": "community"}
|
||||
|
||||
|
||||
class _InstallRequest(BaseModel):
|
||||
source: str
|
||||
skill_id: str
|
||||
confirm: bool = False
|
||||
|
||||
|
||||
@skill_registry.router.post("/install")
|
||||
async def registry_install(req: _InstallRequest):
|
||||
"""Install a community (skills.sh) skill, in two honest steps.
|
||||
|
||||
confirm=false (default): resolve + return a disclosure (the SKILL.md and the
|
||||
list of files, flagging scripts) WITHOUT writing anything, so the user sees
|
||||
exactly what they're about to install from an unvetted repo.
|
||||
confirm=true: write the skill folder to ~/.claude/skills/. Files only; no
|
||||
script is executed here. Curated skills install via the normal skills CRUD;
|
||||
this endpoint is the wild-registry path."""
|
||||
try:
|
||||
resolved = await resolve_community_skill(req.source, req.skill_id)
|
||||
except RegistryRateLimited:
|
||||
raise HTTPException(status_code=429, detail="GitHub rate limit hit fetching this skill; try again in a few minutes.")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"could not fetch skill: {e}")
|
||||
|
||||
disclosure = {
|
||||
"name": resolved["name"],
|
||||
"description": resolved["description"],
|
||||
"repo_url": resolved["repo_url"],
|
||||
"skill_md": resolved["files"].get("SKILL.md", ""),
|
||||
"files": sorted(resolved["files"].keys()),
|
||||
"scripts": resolved["scripts"],
|
||||
"has_scripts": bool(resolved["scripts"]),
|
||||
"secret_findings": resolved.get("secret_findings", []),
|
||||
}
|
||||
if not req.confirm:
|
||||
return {"installed": False, "disclosure": disclosure}
|
||||
|
||||
from backend.apps.skills.skills import write_folder_skill, unique_skill_slug
|
||||
# Never clobber an existing local skill that happens to share this slug; a
|
||||
# wild-registry name collision lands as a copy instead of overwriting.
|
||||
slug = unique_skill_slug(resolved["skill_id"])
|
||||
skill = write_folder_skill(
|
||||
slug,
|
||||
resolved["files"],
|
||||
{"name": resolved["name"], "description": resolved["description"]},
|
||||
)
|
||||
return {"installed": True, "skill": skill.model_dump(), "disclosure": disclosure}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"algorithmic-art": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.",
|
||||
"folder": "skills/algorithmic-art",
|
||||
"name": "algorithmic-art",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/algorithmic-art"
|
||||
},
|
||||
"brand-guidelines": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.",
|
||||
"folder": "skills/brand-guidelines",
|
||||
"name": "brand-guidelines",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/brand-guidelines"
|
||||
},
|
||||
"canvas-design": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.",
|
||||
"folder": "skills/canvas-design",
|
||||
"name": "canvas-design",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/canvas-design"
|
||||
},
|
||||
"claude-api": {
|
||||
"category": "Claude Api",
|
||||
"content": "",
|
||||
"description": "|-",
|
||||
"folder": "skills/claude-api",
|
||||
"name": "claude-api",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/claude-api"
|
||||
},
|
||||
"doc-coauthoring": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.",
|
||||
"folder": "skills/doc-coauthoring",
|
||||
"name": "doc-coauthoring",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/doc-coauthoring"
|
||||
},
|
||||
"docx": {
|
||||
"category": "Document Skills",
|
||||
"content": "",
|
||||
"description": "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of 'Word doc', 'word document', '.docx', or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a 'report', 'memo', 'letter', 'template', or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation.",
|
||||
"folder": "skills/docx",
|
||||
"name": "docx",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/docx"
|
||||
},
|
||||
"frontend-design": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.",
|
||||
"folder": "skills/frontend-design",
|
||||
"name": "frontend-design",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/frontend-design"
|
||||
},
|
||||
"internal-comms": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).",
|
||||
"folder": "skills/internal-comms",
|
||||
"name": "internal-comms",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/internal-comms"
|
||||
},
|
||||
"mcp-builder": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).",
|
||||
"folder": "skills/mcp-builder",
|
||||
"name": "mcp-builder",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/mcp-builder"
|
||||
},
|
||||
"pdf": {
|
||||
"category": "Document Skills",
|
||||
"content": "",
|
||||
"description": "Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.",
|
||||
"folder": "skills/pdf",
|
||||
"name": "pdf",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/pdf"
|
||||
},
|
||||
"pptx": {
|
||||
"category": "Document Skills",
|
||||
"content": "",
|
||||
"description": "Use this skill any time a .pptx file is involved in any way \u2014 as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \\\"deck,\\\" \\\"slides,\\\" \\\"presentation,\\\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill.",
|
||||
"folder": "skills/pptx",
|
||||
"name": "pptx",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/pptx"
|
||||
},
|
||||
"skill-creator": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.",
|
||||
"folder": "skills/skill-creator",
|
||||
"name": "skill-creator",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/skill-creator"
|
||||
},
|
||||
"slack-gif-creator": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like \"make me a GIF of X doing Y for Slack.",
|
||||
"folder": "skills/slack-gif-creator",
|
||||
"name": "slack-gif-creator",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/slack-gif-creator"
|
||||
},
|
||||
"theme-factory": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.",
|
||||
"folder": "skills/theme-factory",
|
||||
"name": "theme-factory",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/theme-factory"
|
||||
},
|
||||
"web-artifacts-builder": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.",
|
||||
"folder": "skills/web-artifacts-builder",
|
||||
"name": "web-artifacts-builder",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/web-artifacts-builder"
|
||||
},
|
||||
"webapp-testing": {
|
||||
"category": "Example Skills",
|
||||
"content": "",
|
||||
"description": "Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.",
|
||||
"folder": "skills/webapp-testing",
|
||||
"name": "webapp-testing",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/webapp-testing"
|
||||
},
|
||||
"xlsx": {
|
||||
"category": "Document Skills",
|
||||
"content": "",
|
||||
"description": "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path \u2014 even casually (like \\\"the xlsx in my downloads\\\") \u2014 and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved.",
|
||||
"folder": "skills/xlsx",
|
||||
"name": "xlsx",
|
||||
"repositoryUrl": "https://github.com/anthropics/skills/tree/main/skills/xlsx"
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,11 @@ class Skill(BaseModel):
|
||||
command: str = ""
|
||||
# Platform-shipped skills (e.g. App Builder): UI hides delete and DELETE returns 409, but content stays editable so users can tune them.
|
||||
built_in: bool = False
|
||||
# Multi-file skills live in ~/.claude/skills/<id>/ with a SKILL.md plus supporting files (scripts, templates).
|
||||
# dir_path is set for those; empty for a legacy flat <id>.md skill. has_supporting_files flags extra files
|
||||
# beyond SKILL.md so the prompt layer knows to point the agent at the folder for on-demand reading.
|
||||
dir_path: str = ""
|
||||
has_supporting_files: bool = False
|
||||
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
|
||||
+203
-57
@@ -2,6 +2,9 @@ import os
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import HTTPException
|
||||
from backend.config.Apps import SubApp
|
||||
@@ -16,15 +19,58 @@ from backend.config.paths import SKILLS_WORKSPACE_DIR
|
||||
|
||||
|
||||
def _load_index() -> dict[str, dict]:
|
||||
if os.path.exists(INDEX_PATH):
|
||||
with open(INDEX_PATH) as f:
|
||||
return json.load(f)
|
||||
"""Read the skill index, never raising on a corrupt file. A truncated/garbled
|
||||
index (e.g. a crash mid-write before atomic writes existed) is moved aside so
|
||||
it's recoverable, and we start empty rather than bricking every skill op,
|
||||
skills still list from their files with frontmatter/filename-derived names."""
|
||||
if not os.path.exists(INDEX_PATH):
|
||||
return {}
|
||||
try:
|
||||
with open(INDEX_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
logger.warning("skills index was not an object; ignoring")
|
||||
except (OSError, ValueError):
|
||||
logger.warning("skills index unreadable; preserving aside and starting empty", exc_info=True)
|
||||
try:
|
||||
os.replace(INDEX_PATH, INDEX_PATH + ".corrupt")
|
||||
except OSError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
# Guards the index write so an atomic replace is never interleaved by another
|
||||
# writer. Today every index write runs on the single backend event-loop thread
|
||||
# (no await between a load and its save, so no lost-update race), but this stays
|
||||
# correct if a save ever moves to a thread pool the way settings' did.
|
||||
_index_write_lock = threading.Lock()
|
||||
|
||||
|
||||
def _save_index(index: dict[str, dict]):
|
||||
with open(INDEX_PATH, "w") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
"""Atomic index write: tmp file + os.replace so a crash mid-write can't leave
|
||||
a truncated index. Mirrors the settings store's write discipline."""
|
||||
with _index_write_lock:
|
||||
os.makedirs(SKILLS_DIR, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".skills_index.", suffix=".tmp", dir=SKILLS_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
# Windows: Defender can briefly lock the destination; one retry covers it.
|
||||
for attempt in range(2):
|
||||
try:
|
||||
os.replace(tmp, INDEX_PATH)
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt == 1:
|
||||
raise
|
||||
time.sleep(0.05)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# Built-in skills shipped with OpenSwarm itself. Each entry describes a
|
||||
@@ -122,30 +168,79 @@ async def skills_lifespan():
|
||||
skills = SubApp("skills", skills_lifespan)
|
||||
|
||||
|
||||
def _skill_md_path(skill_id: str) -> tuple[str | None, str]:
|
||||
"""Resolve where a skill's markdown lives: (path, kind).
|
||||
|
||||
A skill is either a folder (~/.claude/skills/<id>/SKILL.md, multi-file) or a
|
||||
legacy flat file (~/.claude/skills/<id>.md). Folder wins if both exist. The
|
||||
one place that knows the layout, so get/update/delete never re-guess it."""
|
||||
folder_md = os.path.join(SKILLS_DIR, skill_id, "SKILL.md")
|
||||
if os.path.isfile(folder_md):
|
||||
return folder_md, "folder"
|
||||
flat_md = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
||||
if os.path.isfile(flat_md):
|
||||
return flat_md, "flat"
|
||||
return None, "flat"
|
||||
|
||||
|
||||
def _has_supporting_files(skill_dir: str) -> bool:
|
||||
"""True if a skill folder ships anything beyond its SKILL.md (scripts, templates)."""
|
||||
try:
|
||||
return any(e != "SKILL.md" and not e.startswith(".") for e in os.listdir(skill_dir))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _build_skill(skill_id: str, content: str, md_path: str, kind: str, index: dict) -> Skill:
|
||||
"""Assemble a Skill from disk + index, falling back to SKILL.md frontmatter
|
||||
for a folder skill the index hasn't catalogued (e.g. hand-dropped)."""
|
||||
meta = dict(index.get(skill_id, {}))
|
||||
if kind == "folder" and ("name" not in meta or "description" not in meta):
|
||||
fm = _parse_skill_frontmatter(content)
|
||||
meta.setdefault("name", fm.get("name", ""))
|
||||
meta.setdefault("description", fm.get("description", ""))
|
||||
pretty = skill_id.replace("-", " ").replace("_", " ").title()
|
||||
skill_dir = os.path.join(SKILLS_DIR, skill_id)
|
||||
return Skill(
|
||||
id=skill_id,
|
||||
name=meta.get("name") or pretty,
|
||||
description=meta.get("description", ""),
|
||||
content=content,
|
||||
file_path=md_path,
|
||||
command=meta.get("command", skill_id),
|
||||
built_in=bool(meta.get("built_in", False)),
|
||||
dir_path=skill_dir if kind == "folder" else "",
|
||||
has_supporting_files=(kind == "folder" and _has_supporting_files(skill_dir)),
|
||||
)
|
||||
|
||||
|
||||
def _sync_skills() -> list[Skill]:
|
||||
"""Sync skills from the filesystem, updating the index."""
|
||||
"""Sync skills from the filesystem, updating the index. Reads both layouts:
|
||||
legacy flat <id>.md files and multi-file <id>/SKILL.md folders."""
|
||||
index = _load_index()
|
||||
result = []
|
||||
seen: set[str] = set()
|
||||
|
||||
if os.path.exists(SKILLS_DIR):
|
||||
for fname in os.listdir(SKILLS_DIR):
|
||||
if fname.endswith(".md"):
|
||||
fpath = os.path.join(SKILLS_DIR, fname)
|
||||
with open(fpath) as f:
|
||||
content = f.read()
|
||||
if not os.path.exists(SKILLS_DIR):
|
||||
return result
|
||||
|
||||
skill_id = fname.replace(".md", "")
|
||||
meta = index.get(skill_id, {})
|
||||
skill = Skill(
|
||||
id=skill_id,
|
||||
name=meta.get("name", fname.replace(".md", "").replace("-", " ").replace("_", " ").title()),
|
||||
description=meta.get("description", ""),
|
||||
content=content,
|
||||
file_path=fpath,
|
||||
command=meta.get("command", fname.replace(".md", "")),
|
||||
built_in=bool(meta.get("built_in", False)),
|
||||
)
|
||||
result.append(skill)
|
||||
for entry in os.listdir(SKILLS_DIR):
|
||||
full = os.path.join(SKILLS_DIR, entry)
|
||||
if os.path.isdir(full):
|
||||
skill_id = entry
|
||||
elif entry.endswith(".md"):
|
||||
skill_id = entry[: -len(".md")]
|
||||
else:
|
||||
continue
|
||||
if skill_id in seen:
|
||||
continue
|
||||
md_path, kind = _skill_md_path(skill_id)
|
||||
if not md_path:
|
||||
continue
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
seen.add(skill_id)
|
||||
result.append(_build_skill(skill_id, content, md_path, kind, index))
|
||||
|
||||
return result
|
||||
|
||||
@@ -224,42 +319,95 @@ async def get_skill(skill_id: str):
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
|
||||
|
||||
@skills.router.post("/create")
|
||||
async def create_skill(body: SkillCreate):
|
||||
slug = body.name.lower().replace(" ", "-")
|
||||
fpath = os.path.join(SKILLS_DIR, f"{slug}.md")
|
||||
def _safe_slug(raw: str) -> str:
|
||||
slug = re.sub(r"[^a-zA-Z0-9_-]+", "-", (raw or "").strip().lower()).strip("-")
|
||||
return slug or "skill"
|
||||
|
||||
with open(fpath, "w") as f:
|
||||
f.write(body.content)
|
||||
|
||||
def _skill_exists(slug: str) -> bool:
|
||||
return (
|
||||
slug in _load_index()
|
||||
or os.path.isfile(os.path.join(SKILLS_DIR, f"{slug}.md"))
|
||||
or os.path.isdir(os.path.join(SKILLS_DIR, slug))
|
||||
)
|
||||
|
||||
|
||||
def unique_skill_slug(base: str) -> str:
|
||||
"""A free slug for `base`, suffixing -2, -3, ... on collision. Lets a
|
||||
registry install land beside a same-named skill instead of silently
|
||||
overwriting the user's existing one."""
|
||||
slug = _safe_slug(base)
|
||||
if not _skill_exists(slug):
|
||||
return slug
|
||||
i = 2
|
||||
while _skill_exists(f"{slug}-{i}"):
|
||||
i += 1
|
||||
return f"{slug}-{i}"
|
||||
|
||||
|
||||
def write_folder_skill(skill_id: str, files: dict[str, str], meta: dict) -> Skill:
|
||||
"""Write a multi-file skill folder (relpath -> content) under SKILLS_DIR and
|
||||
index it. `files` must include a 'SKILL.md'. Shared by registry install and
|
||||
zip/.swarm import. Relpaths that try to escape the skill folder (../, abs
|
||||
paths) are dropped, an untrusted registry archive can't write outside its
|
||||
own dir."""
|
||||
slug = _safe_slug(skill_id)
|
||||
base = os.path.join(SKILLS_DIR, slug)
|
||||
base_abs = os.path.abspath(base)
|
||||
# A folder write supersedes any legacy flat <slug>.md, so we never leave a
|
||||
# phantom flat file shadowed by the folder (folder wins in _skill_md_path).
|
||||
legacy_flat = os.path.join(SKILLS_DIR, f"{slug}.md")
|
||||
if os.path.isfile(legacy_flat):
|
||||
try:
|
||||
os.remove(legacy_flat)
|
||||
except OSError:
|
||||
pass
|
||||
os.makedirs(base, exist_ok=True)
|
||||
for rel, content in files.items():
|
||||
dest = os.path.abspath(os.path.join(base, rel))
|
||||
if os.path.commonpath([base_abs, dest]) != base_abs:
|
||||
logger.warning("skill import: dropped path-escape entry %r", rel)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
index = _load_index()
|
||||
index[slug] = {
|
||||
"name": body.name,
|
||||
"description": body.description,
|
||||
"command": body.command or slug,
|
||||
"name": meta.get("name") or slug,
|
||||
"description": meta.get("description", ""),
|
||||
"command": meta.get("command", slug),
|
||||
}
|
||||
_save_index(index)
|
||||
|
||||
skill = Skill(
|
||||
id=slug,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
content=body.content,
|
||||
file_path=fpath,
|
||||
command=body.command or slug,
|
||||
)
|
||||
pass
|
||||
md_path, kind = _skill_md_path(slug)
|
||||
if not md_path:
|
||||
raise HTTPException(status_code=400, detail="skill had no SKILL.md")
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return _build_skill(slug, content, md_path, kind, index)
|
||||
|
||||
|
||||
@skills.router.post("/create")
|
||||
async def create_skill(body: SkillCreate):
|
||||
# All user skills are folders now (<id>/SKILL.md); flat files stay readable
|
||||
# but are no longer written, so a skill's on-disk shape no longer depends on
|
||||
# how it was created vs imported.
|
||||
meta = {"name": body.name, "description": body.description}
|
||||
if body.command:
|
||||
meta["command"] = body.command
|
||||
skill = write_folder_skill(body.name, {"SKILL.md": body.content}, meta)
|
||||
return {"ok": True, "skill": skill.model_dump()}
|
||||
|
||||
|
||||
@skills.router.put("/{skill_id}")
|
||||
async def update_skill(skill_id: str, body: SkillUpdate):
|
||||
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
||||
if not os.path.exists(fpath):
|
||||
md_path, kind = _skill_md_path(skill_id)
|
||||
if not md_path:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
|
||||
if body.content is not None:
|
||||
with open(fpath, "w") as f:
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.write(body.content)
|
||||
|
||||
index = _load_index()
|
||||
@@ -273,17 +421,10 @@ async def update_skill(skill_id: str, body: SkillUpdate):
|
||||
index[skill_id] = meta
|
||||
_save_index(index)
|
||||
|
||||
with open(fpath) as f:
|
||||
with open(md_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
skill = Skill(
|
||||
id=skill_id,
|
||||
name=meta.get("name", skill_id),
|
||||
description=meta.get("description", ""),
|
||||
content=content,
|
||||
file_path=fpath,
|
||||
command=meta.get("command", skill_id),
|
||||
)
|
||||
skill = _build_skill(skill_id, content, md_path, kind, index)
|
||||
return {"ok": True, "skill": skill.model_dump()}
|
||||
|
||||
|
||||
@@ -299,9 +440,14 @@ async def delete_skill(skill_id: str):
|
||||
"the next agent turn)."
|
||||
),
|
||||
)
|
||||
fpath = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
||||
if os.path.exists(fpath):
|
||||
os.remove(fpath)
|
||||
# Remove whichever layout exists: the whole folder, or the flat file.
|
||||
import shutil
|
||||
skill_dir = os.path.join(SKILLS_DIR, skill_id)
|
||||
flat = os.path.join(SKILLS_DIR, f"{skill_id}.md")
|
||||
if os.path.isdir(skill_dir):
|
||||
shutil.rmtree(skill_dir, ignore_errors=True)
|
||||
if os.path.isfile(flat):
|
||||
os.remove(flat)
|
||||
index.pop(skill_id, None)
|
||||
_save_index(index)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -10,12 +10,14 @@ forced cheap model; this module only mirrors state into settings and 9Router.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -138,6 +140,13 @@ async def clear_free_trial(settings_obj) -> None:
|
||||
(so the UI knows it's spent) and never touches a real paid mode."""
|
||||
if getattr(settings_obj, "connection_mode", "own_key") == "free-trial":
|
||||
settings_obj.connection_mode = "own_key"
|
||||
# arm() pinned default_model to "haiku" for the free run; once the wheel is
|
||||
# handed back, don't let that forced pick linger (it'd silently default a
|
||||
# real subscription user to Haiku). "sonnet" is the fresh default; the
|
||||
# frontend's DefaultModelGuard reconciles it to a reachable model if the
|
||||
# connected provider isn't Anthropic.
|
||||
if getattr(settings_obj, "default_model", None) == "haiku":
|
||||
settings_obj.default_model = "sonnet"
|
||||
settings_obj.free_trial_token = None
|
||||
await save_settings_async(settings_obj)
|
||||
await _sync_routing(settings_obj)
|
||||
@@ -151,7 +160,35 @@ async def arm_free_trial(settings_obj) -> dict:
|
||||
mode = getattr(settings_obj, "connection_mode", "own_key")
|
||||
if mode not in ("own_key", "free-trial"):
|
||||
return {"armed": False, "reason": "other_mode"}
|
||||
if _has_own_model(settings_obj) or await _has_connected_subscription():
|
||||
own = _has_own_model(settings_obj)
|
||||
has_sub = False
|
||||
if not own:
|
||||
# A subscription lives in 9Router, not settings, and 9Router now starts in
|
||||
# the BACKGROUND (non-blocking boot), so at first-launch mint time it isn't
|
||||
# up yet. Without this wait _has_connected_subscription() reads False and
|
||||
# we'd arm the free trial OVER a real Claude/ChatGPT/Gemini sub, pinning the
|
||||
# user to Haiku until they manually reload. Bring 9Router up so the sub is
|
||||
# actually visible before we decide. Bounded + idempotent (shares the start
|
||||
# lock with the boot auto-start), and skipped when a settings-level model
|
||||
# already proves there's nothing to shadow.
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as _ensure_9r
|
||||
await _ensure_9r()
|
||||
except Exception:
|
||||
pass
|
||||
# 9Router's /api/providers can lag /v1/models (what is_running probes) by a
|
||||
# beat on a cold start, so a real sub can read as absent for a sub-second
|
||||
# window. Re-check a few times before concluding "no sub", so we never arm
|
||||
# over a sub that's merely still loading. CAPPED on purpose: a genuinely
|
||||
# sub-less user exhausts these in ~1.2s and falls through to arm, so this
|
||||
# never waits on a subscription that doesn't exist.
|
||||
for _i in range(5):
|
||||
if await _has_connected_subscription():
|
||||
has_sub = True
|
||||
break
|
||||
if _i < 4:
|
||||
await asyncio.sleep(0.3)
|
||||
if own or has_sub:
|
||||
# A real model exists now (key, custom provider, or a 9Router sub). If we
|
||||
# were on the free lane, hand the wheel back instead of re-arming.
|
||||
if mode == "free-trial":
|
||||
@@ -227,8 +264,14 @@ async def refresh_free_trial(settings_obj) -> dict:
|
||||
data = r.json()
|
||||
remaining = int(data.get("runs_remaining") or 0)
|
||||
settings_obj.free_trial_remaining = remaining
|
||||
# Stash an absolute refill time so the spent nudge can say "fresh runs in ~3h". Set before
|
||||
# clearing (clear keeps it) so it survives the hand-back to own_key. Relative -> absolute here
|
||||
# because the client reads it much later than we fetched it.
|
||||
resets_in = data.get("resets_in_seconds")
|
||||
if isinstance(resets_in, (int, float)) and resets_in > 0:
|
||||
settings_obj.free_trial_resets_at = time.time() + float(resets_in)
|
||||
if remaining <= 0:
|
||||
await clear_free_trial(settings_obj)
|
||||
return {"connected": False, "runs_remaining": 0}
|
||||
return {"connected": False, "runs_remaining": 0, "resets_at": getattr(settings_obj, "free_trial_resets_at", None)}
|
||||
await save_settings_async(settings_obj)
|
||||
return {"connected": True, "runs_remaining": remaining, "runs_limit": getattr(settings_obj, "free_trial_runs_limit", None)}
|
||||
|
||||
@@ -170,6 +170,26 @@ def swarm_filename(name: str) -> str:
|
||||
|
||||
# ---------- import: staging ----------
|
||||
|
||||
def validate_manifest(manifest: Manifest) -> None:
|
||||
"""Structural integrity of the untrusted part of a .swarm. The checksum
|
||||
covers entity payloads + files but NOT the manifest itself, so an attacker
|
||||
can rewrite root/edges/paths freely; catch the breakages that would import
|
||||
silently wrong (a root pointing nowhere, a duplicate id that drops an
|
||||
entity, an edge or path that doesn't resolve inside the bundle)."""
|
||||
seen: set[str] = set()
|
||||
for e in manifest.entities:
|
||||
if e.bundle_id in seen:
|
||||
raise BundleError("bundle manifest has duplicate entity ids")
|
||||
seen.add(e.bundle_id)
|
||||
if not e.path.startswith("entities/") or ".." in e.path.split("/"):
|
||||
raise BundleError("bundle manifest has an out-of-tree entity path")
|
||||
if manifest.root.bundle_id not in seen:
|
||||
raise BundleError("bundle manifest root is not one of its entities")
|
||||
for edge in manifest.edges:
|
||||
if edge.from_ not in seen or edge.to not in seen:
|
||||
raise BundleError("bundle manifest has an edge to an unknown entity")
|
||||
|
||||
|
||||
def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]:
|
||||
warnings: list[str] = []
|
||||
if is_zip(raw):
|
||||
@@ -179,6 +199,7 @@ def stage_upload(raw: bytes, filename: str) -> tuple[str, Manifest, list[str]]:
|
||||
raw_manifest = read_manifest(sandbox)
|
||||
verify_checksum(sandbox, raw_manifest)
|
||||
manifest = Manifest(**raw_manifest)
|
||||
validate_manifest(manifest)
|
||||
except BundleError:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
raise
|
||||
@@ -215,13 +236,27 @@ def _stage_skill_from_zip(raw: bytes, filename: str, warnings: list[str]):
|
||||
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)
|
||||
# Carry supporting files (scripts, templates) through as a folder skill,
|
||||
# keyed relative to the SKILL.md's directory so a nested layout flattens
|
||||
# onto the skill folder. Cap count + per-file size so a hostile zip can't
|
||||
# balloon the install.
|
||||
base_dir = target.rsplit("/", 1)[0] + "/" if "/" in target else ""
|
||||
extra_files: dict[str, bytes] = {}
|
||||
for n in zf.namelist():
|
||||
if n.endswith("/") or n == target:
|
||||
continue
|
||||
rel = n[len(base_dir):] if base_dir and n.startswith(base_dir) else os.path.basename(n)
|
||||
if not rel or rel.startswith("."):
|
||||
continue
|
||||
info = zf.getinfo(n)
|
||||
if info.file_size > 2_000_000 or len(extra_files) >= 50:
|
||||
warnings.append("some oversized/extra supporting files were skipped")
|
||||
continue
|
||||
extra_files[rel] = zf.read(n)
|
||||
return _synth_single_skill(content, _name_from_filename(filename), warnings, extra_files)
|
||||
|
||||
|
||||
def _synth_single_skill(content: str, name: str, warnings: list[str]):
|
||||
def _synth_single_skill(content: str, name: str, warnings: list[str], extra_files: dict[str, bytes] | None = None):
|
||||
bid = uuid4().hex
|
||||
sandbox = tempfile.mkdtemp(prefix="swarm-import-")
|
||||
edir = os.path.join(sandbox, "entities", bid)
|
||||
@@ -230,6 +265,14 @@ def _synth_single_skill(content: str, name: str, warnings: list[str]):
|
||||
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)
|
||||
# Supporting files ride the same entities/<bid>/files/<rel> channel the
|
||||
# commit reader (_read_files) feeds into import_, so a zip-of-SKILL.md
|
||||
# round-trips as a folder skill instead of getting flattened.
|
||||
for rel, data in (extra_files or {}).items():
|
||||
dest = _safe_join(edir, os.path.join("files", rel))
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
f.write(data)
|
||||
ref = EntityRef(type=EntityType.skill, bundle_id=bid, name=name, path=f"entities/{bid}")
|
||||
manifest = Manifest(
|
||||
bundle_id=uuid4().hex,
|
||||
|
||||
@@ -39,7 +39,13 @@ class DashboardExportable:
|
||||
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}
|
||||
# parent_session_id tethers the app card to the agent that built it;
|
||||
# it's a session id, so it remaps like spawned_by on browser cards.
|
||||
parent = card.get("parent_session_id")
|
||||
view_cards[bid] = {
|
||||
**card, "output_id": bid,
|
||||
"parent_session_id": ctx.bundle_id_for(EntityType.session, parent) if parent else None,
|
||||
}
|
||||
browser_cards = {}
|
||||
for bkey, card in (layout.get("browser_cards") or {}).items():
|
||||
c = dict(card)
|
||||
@@ -78,7 +84,11 @@ class DashboardExportable:
|
||||
for bid, card in (layout.get("view_cards") or {}).items():
|
||||
noid = remap.local(bid)
|
||||
if noid:
|
||||
view_cards[noid] = {**card, "output_id": noid}
|
||||
parent = card.get("parent_session_id")
|
||||
view_cards[noid] = {
|
||||
**card, "output_id": noid,
|
||||
"parent_session_id": remap.local(parent) if parent else None,
|
||||
}
|
||||
browser_cards = {}
|
||||
for _bkey, card in (layout.get("browser_cards") or {}).items():
|
||||
nbid = "browser-" + uuid4().hex[:10]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""SessionExportable: an agent card on a shared dashboard. We carry only the
|
||||
recipe (name, model, mode, system prompt, allowed tools) and deliberately DROP
|
||||
the chat transcript (privacy + size), runtime state, costs, the worktree path,
|
||||
and active_mcps (importing must never silently grant tool access, per the gate).
|
||||
Its MCP/actions, provider, and built-in mode become import requirements so the
|
||||
importer is walked through enabling them. The dashboard re-points dashboard_id
|
||||
after import."""
|
||||
"""SessionExportable: an agent card on a shared dashboard. We carry the recipe
|
||||
(name, model, mode, system prompt, allowed tools) AND the chat transcript so a
|
||||
shared agent arrives with the conversation that produced it, that's the whole
|
||||
point of sharing one. The transcript rides through the same scrub layer as every
|
||||
payload, so any secret-shaped string in it is redacted before it leaves. We still
|
||||
DROP runtime state, costs, the worktree path, and active_mcps: importing must
|
||||
never silently grant tool access, per the gate. Its MCP/actions, provider, and
|
||||
built-in mode become import requirements so the importer is walked through
|
||||
enabling them. The dashboard re-points dashboard_id after import."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
@@ -14,7 +16,14 @@ from ..exportable import DepRef, ExportContext, RemapTable
|
||||
from ..models import EntityType, Requirement, RequirementKind
|
||||
|
||||
_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"}
|
||||
_KEEP = ("name", "provider", "model", "mode", "system_prompt", "allowed_tools", "max_turns", "thinking_level")
|
||||
# Transcript fields ride along so the shared agent keeps its history; ids inside
|
||||
# (message ids, branch ids, their parent/fork refs) are self-consistent within
|
||||
# the one session file, so they carry verbatim with no remap.
|
||||
_KEEP = (
|
||||
"name", "provider", "model", "mode", "system_prompt", "allowed_tools",
|
||||
"max_turns", "thinking_level",
|
||||
"messages", "branches", "active_branch_id", "tool_group_meta",
|
||||
)
|
||||
|
||||
|
||||
class SessionExportable:
|
||||
@@ -27,8 +36,17 @@ class SessionExportable:
|
||||
|
||||
@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)
|
||||
# Memory first, disk fallback, the same order duplicate_session uses.
|
||||
# The live session holds the freshest transcript; a disk-only read would
|
||||
# ship a stale one (missing the latest turns) or drop a just-created
|
||||
# agent that hasn't flushed yet, so its card vanishes from the bundle.
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
sess = agent_manager.sessions.get(local_id)
|
||||
if sess is not None:
|
||||
d = sess.model_dump(mode="json")
|
||||
else:
|
||||
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)
|
||||
@@ -70,6 +88,14 @@ class SessionExportable:
|
||||
from backend.apps.agents.manager.session.session_store import _save_session
|
||||
sid = uuid4().hex
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
# Older bundles (made before transcripts were carried) have no messages;
|
||||
# fall back to a single empty main branch so the imported agent is valid.
|
||||
branches = payload.get("branches") or {
|
||||
"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}
|
||||
}
|
||||
active_branch_id = payload.get("active_branch_id") or "main"
|
||||
if active_branch_id not in branches:
|
||||
active_branch_id = next(iter(branches), "main")
|
||||
doc = {
|
||||
"id": sid,
|
||||
"name": payload.get("name") or "Agent",
|
||||
@@ -81,9 +107,10 @@ class SessionExportable:
|
||||
"allowed_tools": payload.get("allowed_tools") or [],
|
||||
"max_turns": payload.get("max_turns"),
|
||||
"thinking_level": payload.get("thinking_level") or "auto",
|
||||
"messages": [],
|
||||
"branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": now}},
|
||||
"active_branch_id": "main",
|
||||
"messages": payload.get("messages") or [],
|
||||
"branches": branches,
|
||||
"active_branch_id": active_branch_id,
|
||||
"tool_group_meta": payload.get("tool_group_meta") or {},
|
||||
"active_mcps": [],
|
||||
"dashboard_id": None, # the dashboard import re-points this
|
||||
"browser_id": None,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""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."""
|
||||
"""SkillExportable: skills are leaves (no deps, no requirements). A skill is
|
||||
either a single markdown file or a folder (SKILL.md + supporting files like
|
||||
scripts/templates), so this powers both the .swarm round-trip AND the generic
|
||||
"import a .md or a zip-of-SKILL.md" path. Folder skills ride the entity files()
|
||||
channel so their supporting files survive export/import. Nothing here is secret,
|
||||
but the body still rides the central scrub in case someone pasted a token in."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from backend.apps.skills import skills as store
|
||||
from ..exportable import DepRef, ExportContext, RemapTable
|
||||
@@ -14,17 +17,18 @@ from ..models import EntityType, Requirement
|
||||
class SkillExportable:
|
||||
type = EntityType.skill
|
||||
|
||||
def __init__(self, local_id: str, name: str, payload: dict):
|
||||
def __init__(self, local_id: str, name: str, payload: dict, files: dict[str, bytes] | None = None):
|
||||
self.local_id = local_id
|
||||
self.name = name
|
||||
self._payload = payload
|
||||
self._files = files or {}
|
||||
|
||||
@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):
|
||||
md_path, kind = store._skill_md_path(local_id)
|
||||
if not md_path:
|
||||
return None
|
||||
with open(fpath, encoding="utf-8") as f:
|
||||
with open(md_path, 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()
|
||||
@@ -36,13 +40,16 @@ class SkillExportable:
|
||||
"content": content,
|
||||
"builtin": bool(meta.get("built_in", False)),
|
||||
}
|
||||
return cls(local_id, name, payload)
|
||||
files: dict[str, bytes] = {}
|
||||
if kind == "folder":
|
||||
files = _read_supporting_files(os.path.join(store.SKILLS_DIR, local_id))
|
||||
return cls(local_id, name, payload, files)
|
||||
|
||||
def serialize(self, ctx: ExportContext) -> dict:
|
||||
return dict(self._payload)
|
||||
|
||||
def files(self) -> dict[str, bytes]:
|
||||
return {}
|
||||
return dict(self._files)
|
||||
|
||||
def dependencies(self) -> list[DepRef]:
|
||||
return []
|
||||
@@ -61,35 +68,56 @@ class SkillExportable:
|
||||
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] = {
|
||||
meta = {
|
||||
"name": payload.get("name", slug),
|
||||
"description": payload.get("description", ""),
|
||||
"command": payload.get("command", slug),
|
||||
}
|
||||
store._save_index(index)
|
||||
return slug
|
||||
|
||||
# Every imported skill lands as a folder (SKILL.md + any supporting files),
|
||||
# one path for one-file and multi-file skills alike. write_folder_skill is
|
||||
# path-traversal-safe, so an untrusted bundle can't escape the skill dir.
|
||||
bundle = {"SKILL.md": payload.get("content", "")}
|
||||
for rel, data in files.items():
|
||||
bundle[rel] = data.decode("utf-8", errors="replace")
|
||||
skill = store.write_folder_skill(slug, bundle, meta)
|
||||
return skill.id
|
||||
|
||||
@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)
|
||||
skill_dir = os.path.join(store.SKILLS_DIR, local_id)
|
||||
flat = os.path.join(store.SKILLS_DIR, f"{local_id}.md")
|
||||
if os.path.isdir(skill_dir):
|
||||
shutil.rmtree(skill_dir, ignore_errors=True)
|
||||
if os.path.isfile(flat):
|
||||
os.remove(flat)
|
||||
index = store._load_index()
|
||||
if local_id in index:
|
||||
index.pop(local_id, None)
|
||||
store._save_index(index)
|
||||
|
||||
|
||||
def _read_supporting_files(skill_dir: str) -> dict[str, bytes]:
|
||||
"""Every file in a skill folder except SKILL.md, as {relpath: bytes}."""
|
||||
out: dict[str, bytes] = {}
|
||||
for root, _dirs, names in os.walk(skill_dir):
|
||||
for n in names:
|
||||
full = os.path.join(root, n)
|
||||
rel = os.path.relpath(full, skill_dir)
|
||||
if rel == "SKILL.md" or n.startswith("."):
|
||||
continue
|
||||
try:
|
||||
with open(full, "rb") as f:
|
||||
out[rel] = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
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")
|
||||
return (
|
||||
slug in store._load_index()
|
||||
or os.path.isfile(os.path.join(store.SKILLS_DIR, f"{slug}.md"))
|
||||
or os.path.isdir(os.path.join(store.SKILLS_DIR, slug))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,15 +23,13 @@ _DENY_EXACT = {
|
||||
"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,}"),
|
||||
# The secret-shape scanner moved to backend.common so skills + settings reuse it
|
||||
# without reaching into swarm; re-exported here so ziputil/closure keep their API.
|
||||
from backend.common.secret_scan import ( # noqa: E402
|
||||
REDACTED,
|
||||
find_secrets_in_files,
|
||||
looks_secret as _looks_secret,
|
||||
redact_secret_shapes as scrub_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -42,12 +40,6 @@ def is_denied_key(key: str) -> bool:
|
||||
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."""
|
||||
@@ -79,3 +71,7 @@ def find_denied_keys(value: Any, _path: str = "") -> list[str]:
|
||||
for i, v in enumerate(value):
|
||||
found.extend(find_denied_keys(v, f"{_path}[{i}]"))
|
||||
return found
|
||||
|
||||
|
||||
# _looks_secret + find_secrets_in_files now come from backend.common.secret_scan
|
||||
# (imported at the top); kept re-exported so ziputil's audit import is unchanged.
|
||||
|
||||
@@ -30,5 +30,5 @@ def scan_app_files(files: dict[str, bytes]) -> ReviewSummary:
|
||||
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.")
|
||||
findings.insert(0, "This app runs code on your computer. Only import apps you trust.")
|
||||
return ReviewSummary(verdict=verdict, findings=findings, scanned_files=scanned)
|
||||
|
||||
@@ -12,7 +12,7 @@ import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from .redact import find_denied_keys
|
||||
from .redact import find_denied_keys, find_secrets_in_files
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
|
||||
@@ -46,6 +46,12 @@ def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) ->
|
||||
raise BundleError(
|
||||
f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}"
|
||||
)
|
||||
leaky_files = find_secrets_in_files(files)
|
||||
if leaky_files:
|
||||
raise BundleError(
|
||||
f"refusing to export: a secret-shaped value is in {leaky_files[0]}; "
|
||||
"remove it (use an environment variable) and try again"
|
||||
)
|
||||
entries: dict[str, bytes] = {}
|
||||
for bid, payload in payloads.items():
|
||||
entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8")
|
||||
|
||||
@@ -27,6 +27,7 @@ _TOOL_NAME_TO_PROVIDER = {
|
||||
"hubspot": "hubspot",
|
||||
"discord": "discord",
|
||||
"notion": "notion",
|
||||
"github": "github",
|
||||
# Built-in Google tool's name is "Google Workspace"; accept the bare
|
||||
# "google" alias too for forward compatibility.
|
||||
"google workspace": "google",
|
||||
@@ -61,6 +62,12 @@ def _persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None:
|
||||
elif name == "notion":
|
||||
tool.oauth_tokens = {"access_token": tokens.get("access_token", "")}
|
||||
tool.connected_account_email = tokens.get("workspace_name", "Notion workspace")
|
||||
elif name == "github":
|
||||
# GitHub OAuth-App tokens don't expire and carry no refresh_token, so
|
||||
# store the bare token; the cloud callback enriches `login` for the label.
|
||||
tool.oauth_tokens = {"access_token": tokens.get("access_token", "")}
|
||||
login = tokens.get("login")
|
||||
tool.connected_account_email = f"@{login}" if login else ""
|
||||
else:
|
||||
tool.oauth_tokens = {
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
|
||||
@@ -2,10 +2,11 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
@@ -51,11 +52,16 @@ async def tools_lib_lifespan():
|
||||
tools_lib = SubApp("tools", tools_lib_lifespan)
|
||||
|
||||
|
||||
# Bash defaults to "ask" because it can execute untrusted text from MCP tool
|
||||
# outputs (Gmail, WebFetch); every other built-in is sandboxed by domain.
|
||||
# Must match agent_manager._DEFAULTS so the Settings UI and the agent agree
|
||||
# on what "no policy set" means.
|
||||
_DEFAULT_BUILTIN_POLICIES = {"Bash": "ask"}
|
||||
# Every built-in seeds to always_allow for a frictionless run. The agent's
|
||||
# runtime guards in agent_manager (catastrophic-command match, OS-scheduling,
|
||||
# sensitive-path gate) STILL force a prompt for the dangerous shapes even on
|
||||
# always_allow, so the poisoned-MCP-output -> destructive-command case is
|
||||
# still caught. Must match agent_manager._DEFAULTS (empty -> always_allow) so
|
||||
# the Settings UI and the agent agree on what "no policy set" means.
|
||||
_DEFAULT_BUILTIN_POLICIES: dict[str, str] = {}
|
||||
|
||||
# One-time marker: older installs seeded Bash="ask"; we lift them once.
|
||||
_BASH_AUTOALLOW_MARKER = os.path.join(DATA_DIR, ".bash_autoallow_migrated")
|
||||
|
||||
|
||||
def _ensure_default_permissions() -> None:
|
||||
@@ -72,6 +78,17 @@ def _ensure_default_permissions() -> None:
|
||||
for t in BUILTIN_TOOLS
|
||||
}
|
||||
merged = {**desired, **existing}
|
||||
# One-time lift: installs seeded under the old default carry Bash="ask";
|
||||
# raise them to always_allow once so shell commands stop prompting. The
|
||||
# marker means a deliberate "ask" set afterward sticks (never re-flipped).
|
||||
if not os.path.exists(_BASH_AUTOALLOW_MARKER):
|
||||
if merged.get("Bash") == "ask":
|
||||
merged["Bash"] = "always_allow"
|
||||
try:
|
||||
with open(_BASH_AUTOALLOW_MARKER, "w") as f:
|
||||
f.write("1")
|
||||
except OSError:
|
||||
pass
|
||||
if merged != existing:
|
||||
save_builtin_permissions(merged)
|
||||
|
||||
@@ -188,6 +205,40 @@ async def list_builtin_tools():
|
||||
return {"tools": [t.model_dump() for t in BUILTIN_TOOLS]}
|
||||
|
||||
|
||||
class PolicySlot(NamedTuple):
|
||||
"""Where a tool's permission policy is stored.
|
||||
|
||||
store == "builtin": policy lives in builtin_permissions under `key`.
|
||||
store == "mcp": policy lives on the owning tool's tool_permissions[action];
|
||||
`key` is that tool's id, or None when no such tool exists.
|
||||
"""
|
||||
store: str
|
||||
key: str | None
|
||||
action: str | None
|
||||
|
||||
|
||||
def resolve_policy_slot(tool_name: str, tools: list[ToolDefinition]) -> PolicySlot:
|
||||
"""Single source of truth for WHERE a tool's permission policy is stored, so the
|
||||
dispatch gate (read) and the 'Always approve' writer (write) can never key it
|
||||
differently. That divergence was the bug behind 'Always approve' acting like a
|
||||
one-time accept: writes landed under the raw mcp__server__action name while the
|
||||
gate read the parsed inner action, so the next call never saw the policy."""
|
||||
bm = re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name)
|
||||
if bm:
|
||||
return PolicySlot("builtin", bm.group(1), None)
|
||||
im = re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name)
|
||||
if im:
|
||||
return PolicySlot("builtin", im.group(1), None)
|
||||
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name)
|
||||
if m:
|
||||
server_slug, action = m.group(1), m.group(2)
|
||||
for t in tools:
|
||||
if t.mcp_config and t.enabled and _sanitize_server_name(t.name) == server_slug:
|
||||
return PolicySlot("mcp", t.id, action)
|
||||
return PolicySlot("mcp", None, action)
|
||||
return PolicySlot("builtin", tool_name, None)
|
||||
|
||||
|
||||
def load_builtin_permissions() -> dict[str, str]:
|
||||
if not os.path.exists(BUILTIN_PERMS_PATH):
|
||||
return {}
|
||||
@@ -383,7 +434,12 @@ async def discover_tools(tool_id: str):
|
||||
|
||||
tool_names = [t["name"] for t in raw_tools]
|
||||
services, service_groups, all_read, all_write = _classify_services(tool_names, tool.name)
|
||||
permissions: dict[str, Any] = {n: tool.tool_permissions.get(n, "ask") for n in tool_names}
|
||||
# Read-only actions auto-allow by default (no prompt for safe, scoped reads);
|
||||
# writes still default to "ask". Any choice the user already made is kept.
|
||||
permissions: dict[str, Any] = {
|
||||
n: tool.tool_permissions.get(n, "always_allow" if n in all_read else "ask")
|
||||
for n in tool_names
|
||||
}
|
||||
permissions["_categories"] = {"read": all_read, "write": all_write}
|
||||
permissions["_services"] = services
|
||||
permissions["_service_groups"] = service_groups
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Shared secret-shape scanner: spot credential-shaped literals in text/files.
|
||||
|
||||
Lives in backend.common so the .swarm importer, the skills registry, and the
|
||||
settings redactor all pull it DOWN from one place instead of one feature app
|
||||
reaching sideways into another. It catches a secret by its SHAPE (sk-ant-...,
|
||||
ghp_..., AIza...), which is the fail-safe behind name-based redaction: a key
|
||||
that's misnamed (so a name rule misses it) still gets caught by its shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
REDACTED = "[redacted]"
|
||||
|
||||
# Literal-secret shapes someone might paste into a file, skill body, or setting.
|
||||
SECRET_SHAPE_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 looks_secret(text: str) -> bool:
|
||||
"""True if `text` contains a credential-shaped literal."""
|
||||
return any(p.search(text) for p in SECRET_SHAPE_PATTERNS)
|
||||
|
||||
|
||||
def redact_secret_shapes(text: str) -> str:
|
||||
"""Replace every secret-shaped literal in `text` with the redacted marker."""
|
||||
for p in SECRET_SHAPE_PATTERNS:
|
||||
text = p.sub(REDACTED, text)
|
||||
return text
|
||||
|
||||
|
||||
def find_secrets_in_files(files: dict[str, bytes]) -> list[str]:
|
||||
"""Paths of any file whose text body holds a secret-shaped literal. Binary
|
||||
files (a null byte in the first 4KB) are skipped, they aren't pasted text."""
|
||||
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
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, APIRouter
|
||||
import debug
|
||||
@@ -29,9 +30,20 @@ class MainApp:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async with AsyncExitStack() as stack:
|
||||
# [perf] per-lifespan boot timing. debug() is a no-op in the
|
||||
# packaged build, so without this the packaged backend.log has no
|
||||
# per-SubApp markers and a cold-start stall can only be guessed at.
|
||||
# One perf_counter + flushed print per app pins exactly which
|
||||
# lifespan (or the cold first-touch I/O entering it) dominates.
|
||||
_boot_t0 = time.perf_counter()
|
||||
for sub_app in sub_apps:
|
||||
debug(sub_app.name)
|
||||
_t0 = time.perf_counter()
|
||||
await stack.enter_async_context(sub_app.lifespan())
|
||||
_dt = (time.perf_counter() - _t0) * 1000
|
||||
if _dt > 50: # only flag a slow lifespan; keeps boot logs quiet
|
||||
print(f"[perf] lifespan {sub_app.name} t={_dt:.0f}ms", flush=True)
|
||||
print(f"[perf] lifespans-total t={(time.perf_counter() - _boot_t0) * 1000:.0f}ms", flush=True)
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
print(f"\nCheck out the API docs at: http://127.0.0.1:{_port}/docs\n")
|
||||
yield
|
||||
|
||||
@@ -25,6 +25,7 @@ MODES_DIR = os.path.join(DATA_ROOT, "modes")
|
||||
DASHBOARDS_DIR = os.path.join(DATA_ROOT, "dashboards")
|
||||
OUTPUTS_DIR = os.path.join(DATA_ROOT, "outputs")
|
||||
OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
|
||||
OUTPUTS_VERSIONS_DIR = os.path.join(DATA_ROOT, "outputs_versions")
|
||||
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
|
||||
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
|
||||
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
|
||||
|
||||
+113
-1
@@ -38,6 +38,7 @@ from backend.apps.settings.settings import settings
|
||||
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.outputs.versions_routes import output_versions
|
||||
from backend.apps.dashboards.dashboards import dashboards
|
||||
from backend.apps.swarm.swarm import swarm
|
||||
from backend.apps.service.service import service
|
||||
@@ -50,7 +51,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, swarm, service, subscription, auth, web, anthropic_proxy, workflows])
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, anthropic_proxy, workflows])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the
|
||||
@@ -743,6 +744,117 @@ async def mcp_meta(action: str, request: Request):
|
||||
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
|
||||
|
||||
|
||||
@app.post("/api/settings-meta/{action}")
|
||||
async def settings_meta(action: str, request: Request):
|
||||
"""Back the openswarm-settings-meta stdio MCP server (agent-editable Settings).
|
||||
|
||||
Actions:
|
||||
- read: the full settings object with every secret redacted to
|
||||
configured/not (never the value), so an always-on read is never an
|
||||
exfiltration path.
|
||||
- write: apply a field -> value map. Three things can't be written, in
|
||||
priority order: an unknown field (reported, not invented), a server-owned
|
||||
subscription/connection field (managed by its dedicated flow), and the
|
||||
credential powering THIS run (the no-suicide rule, enforced structurally
|
||||
via resolve_powering_credential). Everything else is applied through the
|
||||
same path PUT /api/settings uses, so 9router reconciliation and the
|
||||
server-owned restore behave identically.
|
||||
"""
|
||||
from backend.apps.settings.store import load_settings
|
||||
from backend.apps.settings.models import AppSettings
|
||||
from backend.apps.settings.redaction import redact_settings
|
||||
from backend.apps.settings.settings import SERVER_OWNED_FIELDS, apply_settings_update, settings_write_lock
|
||||
from backend.apps.agents.session_credential import (
|
||||
ALL_API_KEY_FIELDS, PoweringCredential, resolve_powering_credential, write_would_suicide,
|
||||
)
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from pydantic import ValidationError
|
||||
|
||||
body = await request.json()
|
||||
parent_session_id = body.get("parent_session_id", "")
|
||||
|
||||
if action == "read":
|
||||
return JSONResponse({"settings": redact_settings(load_settings().model_dump())})
|
||||
|
||||
if action == "write":
|
||||
changes = body.get("changes")
|
||||
if not isinstance(changes, dict) or not changes:
|
||||
return JSONResponse({"error": "changes must be a non-empty object of field -> value"}, status_code=400)
|
||||
|
||||
valid_fields = set(AppSettings.model_fields.keys())
|
||||
outcomes: dict[str, dict] = {}
|
||||
# Serialize the read-modify-write: SettingsWrite goes through apply_settings_update,
|
||||
# which awaits (so two autonomous agents would interleave and clobber each
|
||||
# other's fields while BOTH got an "applied" result). The lock makes agent
|
||||
# writes serial so the last load always sees the prior write. (Agent vs the
|
||||
# renderer's own PUT stays the pre-existing full-object-replace race.)
|
||||
async with settings_write_lock():
|
||||
settings = load_settings()
|
||||
session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None
|
||||
if session is not None:
|
||||
powering = resolve_powering_credential(session.model, settings)
|
||||
else:
|
||||
# No live session to anchor the guard: fail safe, protect every credential.
|
||||
powering = PoweringCredential(kind="unknown", provider="unknown", label="this run")
|
||||
|
||||
# The credential field(s) the second-wall restore in apply_settings_update
|
||||
# must never let a write blank (independent of the per-field guard below).
|
||||
if powering.kind == "unknown":
|
||||
protect_fields = set(ALL_API_KEY_FIELDS)
|
||||
elif powering.kind == "api_key" and powering.protected_field:
|
||||
protect_fields = {powering.protected_field}
|
||||
else:
|
||||
protect_fields = set()
|
||||
|
||||
staged: dict = {}
|
||||
for field, value in changes.items():
|
||||
if field not in valid_fields:
|
||||
outcomes[field] = {"status": "unknown", "reason": "not a settings field"}
|
||||
elif field in SERVER_OWNED_FIELDS:
|
||||
outcomes[field] = {"status": "refused", "reason": "managed by your subscription/connection; change it in the Subscription section"}
|
||||
elif write_would_suicide(field, value, powering):
|
||||
outcomes[field] = {"status": "refused", "reason": f"would disconnect {powering.label}, which is powering this run"}
|
||||
else:
|
||||
staged[field] = value
|
||||
|
||||
if staged:
|
||||
merged = settings.model_dump()
|
||||
merged.update(staged)
|
||||
try:
|
||||
new_body = AppSettings(**merged)
|
||||
except ValidationError as e:
|
||||
bad = {str(err["loc"][0]) for err in e.errors() if err.get("loc")}
|
||||
for f in bad & set(staged.keys()):
|
||||
outcomes[f] = {"status": "refused", "reason": "invalid value for this field"}
|
||||
staged.pop(f, None)
|
||||
new_body = None
|
||||
if staged:
|
||||
merged = settings.model_dump()
|
||||
merged.update(staged)
|
||||
new_body = AppSettings(**merged)
|
||||
if staged and new_body is not None:
|
||||
try:
|
||||
await apply_settings_update(new_body, protect_fields=protect_fields)
|
||||
for f in staged:
|
||||
outcomes[f] = {"status": "applied"}
|
||||
except Exception as e:
|
||||
# Don't hand the agent an opaque 500; tell it which writes failed.
|
||||
for f in staged:
|
||||
outcomes[f] = {"status": "error", "reason": f"write failed: {e}"}
|
||||
|
||||
if any(o.get("status") == "applied" for o in outcomes.values()):
|
||||
# An agent wrote settings (not the user via the modal), so nudge every
|
||||
# open window to refetch instead of waiting for the next window-focus.
|
||||
# Pure signal: the renderer refetches the authoritative state, so nothing
|
||||
# (least of all a secret) needs to ride the broadcast.
|
||||
from backend.apps.agents.core.ws_manager import ws_manager as _wsm
|
||||
await _wsm.broadcast_global("settings:changed", {})
|
||||
|
||||
return JSONResponse({"outcomes": outcomes})
|
||||
|
||||
return JSONResponse({"error": f"unknown action: {action}"}, status_code=400)
|
||||
|
||||
|
||||
@app.post("/api/agents/sessions/{session_id}/compact")
|
||||
async def session_compact(session_id: str):
|
||||
"""Force a compaction pass on a session (Phase 2 /compact slash cmd).
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests for the #9 item 2 pre-extracted node_modules path.
|
||||
|
||||
The packaged Windows build ships node_modules ALREADY EXTRACTED in resources so a
|
||||
workspace junctions straight at it with zero first-app tar-extract. _ensure_warm_cache
|
||||
must prefer that tree over the .tar.gz / npm paths, and must be a no-op (return None)
|
||||
when no tree is shipped so Mac and older builds fall back unchanged.
|
||||
"""
|
||||
import os
|
||||
|
||||
from backend.apps.outputs import view_builder_templates as vt
|
||||
|
||||
|
||||
def test_prefers_bundled_extracted_tree(monkeypatch, tmp_path):
|
||||
digest = vt._warm_cache_digest()
|
||||
# Force a home-cache miss so we exercise the bundled path.
|
||||
monkeypatch.setenv("OPENSWARM_WEBAPP_CACHE_DIR", str(tmp_path / "home"))
|
||||
bundle = tmp_path / "resources_cache"
|
||||
monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(bundle))
|
||||
nm = bundle / digest / "node_modules" / "vite" / "bin"
|
||||
nm.mkdir(parents=True)
|
||||
(nm / "vite.js").write_text("// fake")
|
||||
|
||||
expected = str(bundle / digest / "node_modules")
|
||||
assert vt._bundled_extracted_modules() == expected
|
||||
# Zero extract / zero npm: returns the read-only resources tree directly.
|
||||
assert vt._ensure_warm_cache() == expected
|
||||
|
||||
|
||||
def test_no_bundled_tree_returns_none(monkeypatch, tmp_path):
|
||||
# No extracted tree shipped (Mac / older builds): must not select it, so the
|
||||
# caller falls through to the .tar.gz extract or live npm.
|
||||
monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(tmp_path / "empty"))
|
||||
assert vt._bundled_extracted_modules() is None
|
||||
|
||||
|
||||
def test_warm_cache_is_complete_requires_launch_bin(tmp_path):
|
||||
# A package tree on disk is NOT a finished install; the .bin/vite launch
|
||||
# shim is what proves npm finished its bin-linking phase.
|
||||
nm = tmp_path / "node_modules"
|
||||
(nm / "vite" / "bin").mkdir(parents=True)
|
||||
(nm / "vite" / "bin" / "vite.js").write_text("// vite")
|
||||
assert vt._warm_cache_is_complete(str(nm)) is False
|
||||
bindir = nm / ".bin"
|
||||
bindir.mkdir()
|
||||
(bindir / "vite").symlink_to("../vite/bin/vite.js")
|
||||
assert vt._warm_cache_is_complete(str(nm)) is True
|
||||
|
||||
|
||||
def test_ensure_warm_cache_wipes_partial_and_never_returns_incomplete(monkeypatch, tmp_path):
|
||||
# A half-finished cache (package tree present, .bin/vite missing) must be
|
||||
# WIPED and never handed back, so no workspace symlinks to an unlaunchable
|
||||
# tree and run.sh is never pushed into installing through the shared cache.
|
||||
digest = vt._warm_cache_digest()
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setenv("OPENSWARM_WEBAPP_CACHE_DIR", str(home))
|
||||
cache_modules = home / digest / "node_modules"
|
||||
(cache_modules / "vite" / "bin").mkdir(parents=True)
|
||||
(cache_modules / "vite" / "bin" / "vite.js").write_text("// vite")
|
||||
assert vt._warm_cache_is_complete(str(cache_modules)) is False
|
||||
|
||||
# No bundled tree, no archive, no npm: the only honest answer is "not ready"
|
||||
# (None), and the broken tree must be gone, not cached for the next caller.
|
||||
monkeypatch.setattr(vt, "_BUNDLED_ARCHIVE_DIR", str(tmp_path / "noresources"))
|
||||
monkeypatch.setattr(vt, "_try_extract_bundled_archive", lambda *a, **k: False)
|
||||
monkeypatch.setattr(vt, "_resolve_npm", lambda: None)
|
||||
|
||||
assert vt._ensure_warm_cache() is None
|
||||
assert not cache_modules.exists()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""redact_for_telemetry is the wall between a model_error diagnostic and a key
|
||||
leak: in own_key mode the subprocess stderr we now attach can echo the user's
|
||||
provider key, so these tests pin that no secret shape survives while the actual
|
||||
error text (the whole point of capturing stderr) does.
|
||||
|
||||
The secret-shaped inputs are built by concatenation on purpose: no contiguous
|
||||
key-shaped literal lands in this source file (so it never trips gitleaks or
|
||||
alarms a reader), yet the runtime values are still key-shaped enough to exercise
|
||||
the scrub. None of these are real keys; they unlock nothing."""
|
||||
from backend.apps.agents.core.error_classify import redact_for_telemetry
|
||||
|
||||
|
||||
def test_redacts_provider_key_shapes_keeps_context():
|
||||
anthropic = "sk-" + "ant-" + "A" * 28
|
||||
openai = "sk-" + "B" * 24
|
||||
google = "AIza" + "C" * 30
|
||||
github = "ghp" + "_" + "D" * 24
|
||||
s = f"9router: invalid x-api-key {anthropic} {openai} {google} {github}"
|
||||
out = redact_for_telemetry(s)
|
||||
for secret in (anthropic, openai, google, github):
|
||||
assert secret not in out
|
||||
assert "[redacted]" in out
|
||||
# The diagnostic signal survives, that's the reason we capture stderr at all.
|
||||
assert "9router: invalid x-api-key" in out
|
||||
|
||||
|
||||
def test_redacts_bearer_and_key_value():
|
||||
bearer_token = "E" * 24
|
||||
kv_value = "F" * 16
|
||||
s = "Authorization: " + "Bearer " + bearer_token + "\n" + "api_key=" + kv_value
|
||||
out = redact_for_telemetry(s)
|
||||
assert bearer_token not in out
|
||||
assert kv_value not in out
|
||||
|
||||
|
||||
def test_keeps_tail_and_bounds_length():
|
||||
# The real error lands at the end of the stderr stream, so we keep the tail.
|
||||
s = "old noise\n" * 500 + "Command failed: ENOENT spawn 9router"
|
||||
out = redact_for_telemetry(s, limit=120)
|
||||
assert len(out) <= 120
|
||||
assert "Command failed: ENOENT spawn 9router" in out
|
||||
|
||||
|
||||
def test_empty_is_safe():
|
||||
assert redact_for_telemetry("") == ""
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import backend # noqa: F401 (path sanity asserted below)
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.settings.models import AppSettings
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
from backend.apps.agents.core.error_classify import (
|
||||
@@ -9,7 +11,8 @@ from backend.apps.agents.core.error_classify import (
|
||||
p_is_transient_capacity_error,
|
||||
)
|
||||
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
|
||||
from backend.apps.subscription.free_trial import _has_own_model
|
||||
from backend.apps.subscription import free_trial as ft
|
||||
from backend.apps.subscription.free_trial import _has_own_model, arm_free_trial, clear_free_trial
|
||||
|
||||
|
||||
def test_proxy_auth_for_each_mode():
|
||||
@@ -66,3 +69,112 @@ def test_has_own_model_never_shadows_a_real_provider():
|
||||
assert _has_own_model(
|
||||
AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="b")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_waits_for_9router_before_shadowing_a_background_started_sub(monkeypatch):
|
||||
"""The regression: 9Router starts in the background, so at first-boot mint time
|
||||
a real Claude sub is invisible. arm() must bring 9Router up (so the sub becomes
|
||||
visible) BEFORE deciding, instead of arming the free trial over it."""
|
||||
saved: list = []
|
||||
monkeypatch.setattr(ft, "save_settings_async", _record(saved))
|
||||
monkeypatch.setattr(ft, "_sync_routing", _noop)
|
||||
|
||||
started = {"called": False}
|
||||
|
||||
async def fake_ensure_running():
|
||||
started["called"] = True # 9Router comes up here; the sub is now visible
|
||||
|
||||
# The sub is only reachable AFTER ensure_running ran (mirrors the real race).
|
||||
async def sub_visible_after_start():
|
||||
return started["called"]
|
||||
|
||||
import backend.apps.nine_router as nr
|
||||
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
|
||||
monkeypatch.setattr(ft, "_has_connected_subscription", sub_visible_after_start)
|
||||
|
||||
s = AppSettings() # no key, own_key mode: a subscription-only user
|
||||
out = await arm_free_trial(s)
|
||||
|
||||
assert started["called"], "arm must start 9Router before trusting the sub check"
|
||||
assert out["armed"] is False and out["reason"] == "has_model"
|
||||
assert s.connection_mode == "own_key"
|
||||
assert s.default_model != "haiku"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_tolerates_provider_load_lag(monkeypatch):
|
||||
"""9Router's /api/providers can lag is_running on a cold start. arm must re-check
|
||||
a few times so a sub that loads a beat late is still caught, not shadowed."""
|
||||
monkeypatch.setattr(ft, "save_settings_async", _noop)
|
||||
monkeypatch.setattr(ft, "_sync_routing", _noop)
|
||||
|
||||
async def fake_ensure_running():
|
||||
return None
|
||||
|
||||
calls = {"n": 0}
|
||||
async def lagging_sub():
|
||||
calls["n"] += 1
|
||||
return calls["n"] >= 3 # empty for the first two probes, then the sub appears
|
||||
|
||||
import backend.apps.nine_router as nr
|
||||
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
|
||||
monkeypatch.setattr(ft, "_has_connected_subscription", lagging_sub)
|
||||
|
||||
s = AppSettings()
|
||||
res = await ft.arm_free_trial(s)
|
||||
assert res["reason"] == "has_model", res
|
||||
assert s.default_model != "haiku"
|
||||
assert calls["n"] >= 3, "should have re-checked past the lagging-empty probes"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arm_with_no_sub_is_bounded_and_falls_through_to_arm(monkeypatch):
|
||||
"""The 'don't poll for something that doesn't exist' guarantee: a genuinely
|
||||
sub-less user must exhaust the re-checks quickly and PROCEED to arm, never hang."""
|
||||
async def fake_ensure_running():
|
||||
return None
|
||||
async def never_sub():
|
||||
return False
|
||||
|
||||
import time
|
||||
import backend.apps.nine_router as nr
|
||||
monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
|
||||
monkeypatch.setattr(ft, "_has_connected_subscription", never_sub)
|
||||
# Short-circuit before the cloud mint so the test stays offline + deterministic;
|
||||
# reaching this branch proves arm did NOT falsely conclude has_model.
|
||||
monkeypatch.setattr(ft, "_fingerprint", lambda _s: None)
|
||||
|
||||
s = AppSettings()
|
||||
t = time.monotonic()
|
||||
res = await ft.arm_free_trial(s)
|
||||
elapsed = time.monotonic() - t
|
||||
assert res["reason"] == "no_fingerprint", res # got past the sub guard to the arm path
|
||||
assert elapsed < 3.0, f"re-check budget not bounded: {elapsed:.2f}s"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_reverts_forced_haiku_so_it_doesnt_outlive_the_trial(monkeypatch):
|
||||
monkeypatch.setattr(ft, "save_settings_async", _noop)
|
||||
monkeypatch.setattr(ft, "_sync_routing", _noop)
|
||||
|
||||
s = AppSettings(connection_mode="free-trial", free_trial_token="ftk", default_model="haiku")
|
||||
await clear_free_trial(s)
|
||||
assert s.connection_mode == "own_key"
|
||||
assert s.default_model == "sonnet" # forced free-run pick handed back, not left on Haiku
|
||||
assert s.free_trial_token is None
|
||||
|
||||
# A user who deliberately picked haiku OUTSIDE free-trial mode is left alone.
|
||||
s2 = AppSettings(connection_mode="own_key", default_model="haiku")
|
||||
await clear_free_trial(s2)
|
||||
assert s2.default_model == "haiku"
|
||||
|
||||
|
||||
async def _noop(*_a, **_k):
|
||||
return None
|
||||
|
||||
|
||||
def _record(bucket):
|
||||
async def _inner(obj, *_a, **_k):
|
||||
bucket.append(obj)
|
||||
return _inner
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Gate-safety invariants for the Phase 2 in-task connect offer (offer_for_gated_server).
|
||||
|
||||
The whole point of the offer is that it can ONLY ever suggest, never grant: it must surface a
|
||||
vetted, inactive, not-dismissed MCP for the user to one-click-connect, and it must never carry
|
||||
anything that could widen the MCP surface on its own. These tests make a bad offer state fail
|
||||
loudly instead of shipping a silent gate bypass.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import backend.apps.agents.core.mcp_preflight as pf
|
||||
from backend.apps.agents.core.mcp_preflight import (
|
||||
CURATED_SHORTLIST,
|
||||
offer_for_gated_server,
|
||||
run_preflight,
|
||||
)
|
||||
|
||||
VETTED = {e["id"] for e in CURATED_SHORTLIST}
|
||||
OFFER_SHAPE = {"id", "title", "description", "reason"}
|
||||
|
||||
|
||||
def _settings(dismissed=None):
|
||||
return SimpleNamespace(dismissed_mcp_suggestions=dismissed or {})
|
||||
|
||||
|
||||
def test_offer_resolves_both_display_name_and_hotpath_slug(monkeypatch):
|
||||
# The hot-path passes a sanitized slug ("google-workspace"); the curated id is a display
|
||||
# name ("Google Workspace"). Both must resolve, so the wiring isn't a load-bearing string.
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: []) # nothing enabled
|
||||
s = _settings()
|
||||
for name in ("Google Workspace", "google-workspace"):
|
||||
o = offer_for_gated_server(name, s)
|
||||
assert o is not None, f"{name!r} should resolve to the vetted entry"
|
||||
assert o["id"] == "Google Workspace"
|
||||
assert o["id"] in VETTED
|
||||
|
||||
|
||||
def test_offer_rejects_unvetted_and_empty(monkeypatch):
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
s = _settings()
|
||||
assert offer_for_gated_server("NotAVettedServer", s) is None
|
||||
assert offer_for_gated_server("", s) is None
|
||||
assert offer_for_gated_server(None, s) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_offer_suppressed_when_dismissed(monkeypatch):
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
s = _settings({"Google Workspace": "2026-01-01T00:00:00Z"})
|
||||
assert offer_for_gated_server("Google Workspace", s) is None
|
||||
|
||||
|
||||
def test_offer_suppressed_when_already_active(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
pf, "load_all_tools",
|
||||
lambda: [SimpleNamespace(name="Google Workspace", enabled=True)],
|
||||
)
|
||||
s = _settings()
|
||||
assert offer_for_gated_server("Google Workspace", s) is None
|
||||
|
||||
|
||||
def test_offer_carries_no_activate_capability(monkeypatch):
|
||||
# The security invariant: an offer is data to display, never an action that grants access.
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
s = _settings()
|
||||
for entry in CURATED_SHORTLIST:
|
||||
o = offer_for_gated_server(entry["id"], s)
|
||||
assert o is not None
|
||||
assert set(o.keys()) == OFFER_SHAPE, f"offer for {entry['id']} grew an unexpected field"
|
||||
|
||||
|
||||
# --- require_vague: the MCPSearch path keeps suggestions on a concrete prompt ----------------
|
||||
|
||||
def _stub_classifier(is_vague, ids):
|
||||
async def _fake(settings, prompt, available, task_id=None):
|
||||
return {"is_vague": is_vague, "suggestions": [{"id": i, "reason": "fits"} for i in ids]}
|
||||
return _fake
|
||||
|
||||
|
||||
def test_preflight_default_suppresses_suggestions_on_concrete_prompt(monkeypatch):
|
||||
# Launch path: a concrete (non-vague) prompt must NOT interrupt with a card.
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["Google Workspace"]))
|
||||
out = asyncio.run(run_preflight("refactor foo.ts to use the new client", timeout_s=5))
|
||||
assert out["suggestions"] == []
|
||||
|
||||
|
||||
def test_preflight_require_vague_false_keeps_suggestions(monkeypatch):
|
||||
# MCPSearch path: the agent already proved it needs an integration, so keep the suggestion
|
||||
# even though the prompt is concrete (is_vague False).
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["Google Workspace"]))
|
||||
out = asyncio.run(run_preflight("check my unread emails", timeout_s=5, require_vague=False))
|
||||
assert [s["id"] for s in out["suggestions"]] == ["Google Workspace"]
|
||||
assert set(out["suggestions"][0].keys()) == OFFER_SHAPE
|
||||
|
||||
|
||||
def test_preflight_require_vague_false_still_drops_hallucinated_ids(monkeypatch):
|
||||
# require_vague=False must NOT loosen the vetted-id revalidation: a made-up id is still dropped.
|
||||
monkeypatch.setattr(pf, "load_all_tools", lambda: [])
|
||||
monkeypatch.setattr(pf, "_call_classifier", _stub_classifier(False, ["TotallyFakeServer"]))
|
||||
out = asyncio.run(run_preflight("do the thing", timeout_s=5, require_vague=False))
|
||||
assert out["suggestions"] == []
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for app-publishing build/scan/bundle logic (the locally-testable
|
||||
core of Workstream A). The build step (vite) and the cloud upload need node /
|
||||
network and are exercised in the staging E2E, not here.
|
||||
|
||||
What this proves:
|
||||
1. slugify makes url-safe, length-capped slugs and never empties.
|
||||
2. quick_ast_gate flags backend code that reaches outside the sandbox allowlist
|
||||
and stays silent for allowlist-only code.
|
||||
3. _collect_source picks up flat files and skips binary/non-source.
|
||||
4. collect_bundle (flat) tars exactly the files dict; (webapp) tars a dist tree
|
||||
and skips symlinks; secret-shaped files never make it into a public bundle.
|
||||
5. scan_for_publish merges AST findings into the review when the LLM pass is a
|
||||
no-op, reports a clean verdict for a benign app, and memoizes by source so an
|
||||
unchanged reopen never re-bills the aux model.
|
||||
|
||||
Run with: backend/.venv/bin/python backend/tests/test_publish.py
|
||||
"""
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
|
||||
from backend.apps.outputs.models import Output
|
||||
from backend.apps.outputs import publish_common, publish_scan, publish_build
|
||||
|
||||
|
||||
def test_slugify():
|
||||
assert publish_common.slugify("My Cool App!!") == "my-cool-app"
|
||||
assert publish_common.slugify(" ") == "app"
|
||||
assert publish_common.slugify("") == "app"
|
||||
assert publish_common.slugify("a" * 100) == "a" * 32
|
||||
assert publish_common.slugify("Café ☕ Menu") == "caf-menu"
|
||||
|
||||
|
||||
def test_ast_gate_flags_unsafe_and_clean():
|
||||
unsafe = Output(name="x", files={"backend.py": "import os\nresult={'c': os.getcwd()}\n"})
|
||||
findings = publish_scan.quick_ast_gate(unsafe)
|
||||
assert findings and any("os" in f for f in findings)
|
||||
|
||||
clean = Output(name="x", files={"backend.py": "import math\nresult={'p': math.pi}\n"})
|
||||
assert publish_scan.quick_ast_gate(clean) == []
|
||||
|
||||
no_backend = Output(name="x", files={"index.html": "<html>hi</html>"})
|
||||
assert publish_scan.quick_ast_gate(no_backend) == []
|
||||
|
||||
|
||||
def test_collect_source_filters():
|
||||
o = Output(name="x", files={
|
||||
"index.html": "<html></html>",
|
||||
"backend.py": "result={}",
|
||||
"data.bin": "not source",
|
||||
"notes.txt": "ignore me",
|
||||
})
|
||||
src = publish_scan._collect_source(o)
|
||||
assert set(src.keys()) == {"index.html", "backend.py"}
|
||||
|
||||
|
||||
def test_collect_bundle_flat():
|
||||
o = Output(name="x", files={
|
||||
"index.html": "<html>hi</html>",
|
||||
"backend.py": "result={}",
|
||||
})
|
||||
blob = publish_build.collect_bundle(o, None)
|
||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as t:
|
||||
assert sorted(t.getnames()) == ["backend.py", "index.html"]
|
||||
idx = t.extractfile("index.html").read().decode()
|
||||
assert idx == "<html>hi</html>"
|
||||
|
||||
|
||||
def test_collect_bundle_drops_secret_files():
|
||||
# A public bundle must never carry secrets, in either mode.
|
||||
o = Output(name="x", files={
|
||||
"index.html": "<html>hi</html>",
|
||||
".env": "OPENAI_API_KEY=sk-secret",
|
||||
".env.local": "X=1",
|
||||
"server.pem": "-----BEGIN PRIVATE KEY-----",
|
||||
".npmrc": "//registry/:_authToken=abc",
|
||||
"app.js": "console.log(1)",
|
||||
})
|
||||
blob = publish_build.collect_bundle(o, None)
|
||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as t:
|
||||
names = set(t.getnames())
|
||||
assert names == {"index.html", "app.js"}
|
||||
assert not (names & {".env", ".env.local", "server.pem", ".npmrc"})
|
||||
|
||||
|
||||
def test_collect_bundle_webapp_dist_skips_symlink():
|
||||
o = Output(name="x", workspace_id="ws123")
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
os.makedirs(os.path.join(dist, "assets"))
|
||||
with open(os.path.join(dist, "index.html"), "w") as f:
|
||||
f.write("<html>built</html>")
|
||||
with open(os.path.join(dist, "assets", "app.js"), "w") as f:
|
||||
f.write("console.log(1)")
|
||||
with open(os.path.join(dist, ".env"), "w") as f:
|
||||
f.write("SECRET=1")
|
||||
try:
|
||||
os.symlink(os.path.join(dist, "index.html"), os.path.join(dist, "link.html"))
|
||||
except OSError:
|
||||
pass
|
||||
blob = publish_build.collect_bundle(o, dist)
|
||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as t:
|
||||
names = sorted(t.getnames())
|
||||
assert "index.html" in names
|
||||
assert "assets/app.js" in names
|
||||
assert "link.html" not in names # symlinks are skipped
|
||||
assert ".env" not in names # secrets are dropped
|
||||
|
||||
|
||||
def test_scan_for_publish_merges_ast():
|
||||
# Force the LLM pass to a deterministic no-op so the test is hermetic.
|
||||
async def _no_llm(src, settings):
|
||||
return [], "clean"
|
||||
orig = publish_scan._llm_findings
|
||||
publish_scan._llm_findings = _no_llm
|
||||
publish_scan._memo.clear()
|
||||
try:
|
||||
unsafe = Output(name="x", files={"backend.py": "import socket\nresult={}\n"})
|
||||
review = asyncio.run(publish_scan.scan_for_publish(unsafe, settings=object()))
|
||||
assert review.verdict == "warn"
|
||||
assert any("socket" in f for f in review.findings)
|
||||
|
||||
clean = Output(name="x", files={"index.html": "<html>hi</html>"})
|
||||
review2 = asyncio.run(publish_scan.scan_for_publish(clean, settings=object()))
|
||||
assert review2.verdict == "clean"
|
||||
assert review2.findings == []
|
||||
finally:
|
||||
publish_scan._llm_findings = orig
|
||||
publish_scan._memo.clear()
|
||||
|
||||
|
||||
def test_scan_memo_skips_second_llm_call():
|
||||
# Unchanged source must not re-invoke the (paid) LLM pass on a reopen.
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _counting_llm(src, settings):
|
||||
calls["n"] += 1
|
||||
return ["from the llm"], "warn"
|
||||
|
||||
orig = publish_scan._llm_findings
|
||||
publish_scan._llm_findings = _counting_llm
|
||||
publish_scan._memo.clear()
|
||||
try:
|
||||
app = Output(name="x", files={"index.html": "<html>same</html>"})
|
||||
r1 = asyncio.run(publish_scan.scan_for_publish(app, settings=object()))
|
||||
r2 = asyncio.run(publish_scan.scan_for_publish(app, settings=object()))
|
||||
assert calls["n"] == 1, "second scan of identical source should hit the memo"
|
||||
assert r1.findings == r2.findings
|
||||
|
||||
changed = Output(name="x", files={"index.html": "<html>different</html>"})
|
||||
asyncio.run(publish_scan.scan_for_publish(changed, settings=object()))
|
||||
assert calls["n"] == 2, "changed source must bust the memo"
|
||||
finally:
|
||||
publish_scan._llm_findings = orig
|
||||
publish_scan._memo.clear()
|
||||
|
||||
|
||||
def test_runtime_injection():
|
||||
from backend.apps.outputs.html_inject import _build_data_injection, _inject_data_into_html
|
||||
|
||||
base = _build_data_injection("{}", "null")
|
||||
assert "OUTPUT_COMPUTE" not in base and "OUTPUT_LLM" not in base # off by default
|
||||
|
||||
rt = _build_data_injection("{}", "null", "null", with_runtime=True)
|
||||
assert "OUTPUT_COMPUTE" in rt and "OUTPUT_LLM" in rt # preview stubs are defined
|
||||
# Preview must NEVER embed the install token into app JS (SECURITY.md item A).
|
||||
assert "Bearer" not in rt and "Authorization" not in rt
|
||||
assert "once this app is published" in rt
|
||||
|
||||
html = _inject_data_into_html("<html><head></head><body>x</body></html>", "{}", "null", "null", with_runtime=True)
|
||||
assert "OUTPUT_LLM" in html and "</head>" in html
|
||||
|
||||
|
||||
def _run_all():
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
for fn in fns:
|
||||
fn()
|
||||
print(f"ok {fn.__name__}")
|
||||
print(f"\n{len(fns)} passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run_all()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Invariant: closing or deleting a session must strand NO per-session state.
|
||||
|
||||
The orchestration core keeps several maps keyed by session id (the session
|
||||
record, its asyncio task, the live partial-stream mirror, and two module-level
|
||||
view-builder retry/dirty structures). Removal used to pop only `sessions` +
|
||||
`tasks`, leaking the rest for the life of the process, an unbounded creep over
|
||||
a long-running app. `_purge_session_memory` is the single chokepoint both the
|
||||
close and delete paths route through; this pins the invariant that after it
|
||||
runs the id is gone from EVERY structure, while a sibling session is untouched.
|
||||
|
||||
Run with: backend/.venv/bin/python -m pytest backend/tests/test_session_cleanup.py
|
||||
"""
|
||||
from backend.apps.agents import agent_manager as am
|
||||
|
||||
|
||||
def test_purge_session_memory_clears_every_structure():
|
||||
mgr = am.AgentManager()
|
||||
mgr.sessions = {"dead": object(), "alive": object()}
|
||||
mgr.tasks = {"dead": object()}
|
||||
mgr._live_partial = {"dead": {"text": "half a reply"}}
|
||||
am.p_view_builder_render_retry_counts["dead"] = 4
|
||||
am.p_view_builder_dirty_sessions.add("dead")
|
||||
|
||||
mgr._purge_session_memory("dead")
|
||||
|
||||
assert "dead" not in mgr.sessions
|
||||
assert "dead" not in mgr.tasks
|
||||
assert "dead" not in mgr._live_partial
|
||||
assert "dead" not in am.p_view_builder_render_retry_counts
|
||||
assert "dead" not in am.p_view_builder_dirty_sessions
|
||||
# Only the target id is purged; an unrelated live session survives.
|
||||
assert "alive" in mgr.sessions
|
||||
|
||||
|
||||
def test_purge_is_safe_on_an_untracked_id():
|
||||
# Purging an id that was never tracked must be a quiet no-op, not a KeyError,
|
||||
# so the delete/close paths can call it unconditionally.
|
||||
mgr = am.AgentManager()
|
||||
mgr._purge_session_memory("never-existed")
|
||||
assert mgr.sessions == {}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Concurrent SettingsWrite must not lose updates.
|
||||
|
||||
SettingsWrite is a read-modify-write that routes through update_settings (which
|
||||
awaits), so two autonomous agents writing at the same time would interleave: each
|
||||
loads the same snapshot, each writes the WHOLE object back, and the last writer
|
||||
silently reverts the other's field, while BOTH agents are told "applied". This
|
||||
drives two genuinely concurrent writes (different fields) through the ASGI app
|
||||
and asserts neither is lost. It's the regression guard for the asyncio lock that
|
||||
serializes these writes; without the lock this fails reproducibly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
def _auth_headers():
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod._TOKEN:
|
||||
import secrets
|
||||
auth_mod._TOKEN = secrets.token_urlsafe(32)
|
||||
return {"Authorization": f"Bearer {auth_mod._TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_settings():
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
original = load_settings().model_copy(deep=True)
|
||||
yield
|
||||
_save_settings(original)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_writes_to_different_fields_both_survive(reset_settings):
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
|
||||
base = load_settings()
|
||||
base.theme = "dark"
|
||||
base.default_mode = "agent"
|
||||
_save_settings(base)
|
||||
|
||||
headers = _auth_headers()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
|
||||
r1, r2 = await asyncio.gather(
|
||||
client.post("/api/settings-meta/write", json={"changes": {"theme": "light"}}),
|
||||
client.post("/api/settings-meta/write", json={"changes": {"default_mode": "chat"}}),
|
||||
)
|
||||
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
assert r1.json()["outcomes"]["theme"]["status"] == "applied"
|
||||
assert r2.json()["outcomes"]["default_mode"]["status"] == "applied"
|
||||
|
||||
final = load_settings()
|
||||
# Both concurrent edits must persist; neither agent's "applied" result is a lie.
|
||||
assert final.theme == "light", "lost update: theme was clobbered by the concurrent write"
|
||||
assert final.default_mode == "chat", "lost update: default_mode was clobbered"
|
||||
@@ -0,0 +1,145 @@
|
||||
"""End-to-end coverage of /api/settings-meta (the agent-editable Settings tool).
|
||||
|
||||
Drives the real FastAPI route with a real in-memory AgentSession so the guard
|
||||
runs against an actual run's model, exactly as it will in production. The unit
|
||||
invariant lives in test_settings_meta_guard.py; this test proves the wiring:
|
||||
redaction on read, the three write refusals, and a benign write actually landing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_wall_restores_protected_credential_even_if_body_blanks_it():
|
||||
"""Defense in depth: even if a write reaches apply_settings_update with the
|
||||
live credential blanked (a guard slip upstream), the second-wall restore puts
|
||||
it back. Proves the api-key guard isn't a single point of failure."""
|
||||
from backend.apps.settings.settings import (
|
||||
apply_settings_update, settings_write_lock, load_settings, _save_settings,
|
||||
)
|
||||
original = load_settings().model_copy(deep=True)
|
||||
try:
|
||||
s = load_settings()
|
||||
s.anthropic_api_key = "sk-live-KEEP-ME"
|
||||
_save_settings(s)
|
||||
# A body that (as if a guard bug let it through) clears the live key.
|
||||
body = load_settings()
|
||||
body.anthropic_api_key = ""
|
||||
async with settings_write_lock():
|
||||
saved = await apply_settings_update(body, protect_fields={"anthropic_api_key"})
|
||||
assert saved.anthropic_api_key == "sk-live-KEEP-ME", "second wall failed to restore"
|
||||
assert load_settings().anthropic_api_key == "sk-live-KEEP-ME"
|
||||
# And a NON-protected blank still goes through (only the protected one is restored).
|
||||
body2 = load_settings()
|
||||
body2.openai_api_key = ""
|
||||
async with settings_write_lock():
|
||||
await apply_settings_update(body2, protect_fields={"anthropic_api_key"})
|
||||
assert not load_settings().openai_api_key
|
||||
finally:
|
||||
_save_settings(original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod._TOKEN:
|
||||
import secrets
|
||||
auth_mod._TOKEN = secrets.token_urlsafe(32)
|
||||
return TestClient(app, headers={"Authorization": f"Bearer {auth_mod._TOKEN}"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_settings():
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
original = load_settings().model_copy(deep=True)
|
||||
yield
|
||||
_save_settings(original)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_on_anthropic_key():
|
||||
"""A live run on opus-4-8 in own_key mode with an Anthropic key set: the
|
||||
Anthropic key powers it. Registered in agent_manager so the guard sees it."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
|
||||
s = load_settings()
|
||||
s.connection_mode = "own_key"
|
||||
s.anthropic_api_key = "sk-ant-test-LIVE"
|
||||
s.openai_api_key = "sk-openai-test-OTHER"
|
||||
_save_settings(s)
|
||||
|
||||
sess = AgentSession(id="settings-meta-test", name="t", model="opus-4-8")
|
||||
agent_manager.sessions["settings-meta-test"] = sess
|
||||
yield "settings-meta-test"
|
||||
agent_manager.sessions.pop("settings-meta-test", None)
|
||||
|
||||
|
||||
def test_read_redacts_every_secret(client, reset_settings):
|
||||
r = client.post("/api/settings-meta/read", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
settings = r.json()["settings"]
|
||||
# Secret fields come back as state, never a raw string value.
|
||||
for field in ("anthropic_api_key", "openai_api_key", "claude_subscription_token", "openswarm_bearer_token"):
|
||||
if field in settings:
|
||||
assert isinstance(settings[field], dict), f"{field} leaked as a raw value"
|
||||
assert "configured" in settings[field]
|
||||
# A non-secret field is passed through untouched.
|
||||
assert settings["theme"] in ("dark", "light")
|
||||
|
||||
|
||||
def test_benign_write_applies(client, reset_settings):
|
||||
r = client.post("/api/settings-meta/write", json={"changes": {"theme": "light"}})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["outcomes"]["theme"]["status"] == "applied"
|
||||
from backend.apps.settings.settings import load_settings
|
||||
assert load_settings().theme == "light"
|
||||
|
||||
|
||||
def test_unknown_and_server_owned_fields_are_refused(client, reset_settings):
|
||||
r = client.post("/api/settings-meta/write", json={"changes": {
|
||||
"not_a_real_field": 1,
|
||||
"connection_mode": "openswarm-pro",
|
||||
"openswarm_bearer_token": "forged",
|
||||
}})
|
||||
assert r.status_code == 200, r.text
|
||||
out = r.json()["outcomes"]
|
||||
assert out["not_a_real_field"]["status"] == "unknown"
|
||||
assert out["connection_mode"]["status"] == "refused"
|
||||
assert out["openswarm_bearer_token"]["status"] == "refused"
|
||||
# And the server-owned field is genuinely untouched on disk.
|
||||
from backend.apps.settings.settings import load_settings
|
||||
assert load_settings().connection_mode != "openswarm-pro"
|
||||
|
||||
|
||||
def test_cannot_suicide_but_disconnects_others(client, reset_settings, session_on_anthropic_key):
|
||||
"""The spec scenario over HTTP: run on the Anthropic key, asked to clear
|
||||
every model key + flip a benign setting. It must refuse the live key,
|
||||
clear the other one, and apply the benign change, all in one call."""
|
||||
sid = session_on_anthropic_key
|
||||
r = client.post("/api/settings-meta/write", json={
|
||||
"parent_session_id": sid,
|
||||
"changes": {
|
||||
"anthropic_api_key": "",
|
||||
"openai_api_key": "",
|
||||
"theme": "light",
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
out = r.json()["outcomes"]
|
||||
assert out["anthropic_api_key"]["status"] == "refused", "blanked the live credential!"
|
||||
assert "powering this run" in out["anthropic_api_key"]["reason"]
|
||||
assert out["openai_api_key"]["status"] == "applied"
|
||||
assert out["theme"]["status"] == "applied"
|
||||
|
||||
from backend.apps.settings.settings import load_settings
|
||||
s = load_settings()
|
||||
assert s.anthropic_api_key == "sk-ant-test-LIVE", "live key was cleared despite refusal"
|
||||
assert not s.openai_api_key, "the other provider's key should have been cleared"
|
||||
assert s.theme == "light"
|
||||
@@ -0,0 +1,213 @@
|
||||
"""The no-suicide invariant for the agent-editable settings tool, proved by
|
||||
exhaustive enumeration rather than a few hand-picked cases.
|
||||
|
||||
The state space here is small and finite (every shipped model row x every
|
||||
connection mode x which keys are present), so we walk ALL of it deterministically
|
||||
instead of reaching for randomized property testing. A failure is a concrete,
|
||||
reproducible (model, mode, keys) tuple, not a flaky seed.
|
||||
|
||||
The one invariant under test: the settings-meta write guard must NEVER let an
|
||||
agent blank the credential powering its own run, while still allowing it to
|
||||
clear any OTHER provider's key. Plus two drift seals: every shipped model lane
|
||||
classifies (no "unknown"), and the redactor catches every credential field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.settings.models import AppSettings, CustomProvider
|
||||
from backend.apps.agents.providers.registry import BUILTIN_MODELS
|
||||
from backend.apps.agents.session_credential import (
|
||||
ALL_API_KEY_FIELDS,
|
||||
resolve_powering_credential,
|
||||
write_would_suicide,
|
||||
)
|
||||
from backend.apps.settings.redaction import is_secret_field, redact_settings
|
||||
|
||||
|
||||
CONNECTION_MODES = ["own_key", "openswarm-pro", "free-trial"]
|
||||
|
||||
# Every credential field the settings PUT path already treats as secret. Kept
|
||||
# here as the contract the redactor must honor; if PUT's notion of "secret"
|
||||
# grows, this list should too, and the drift-seal test fails until the redactor
|
||||
# also covers it.
|
||||
KNOWN_SECRET_FIELDS = [
|
||||
"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
|
||||
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
||||
"openswarm_bearer_token", "free_trial_token", "installation_id",
|
||||
]
|
||||
|
||||
|
||||
def _all_model_values() -> list[str]:
|
||||
vals = [m["value"] for rows in BUILTIN_MODELS.values() for m in rows]
|
||||
# Plus synthesized lanes the resolver must also place.
|
||||
vals += ["or:anthropic/claude-3.5", "custom/lmstudio/llama-3", "totally-made-up-model"]
|
||||
return vals
|
||||
|
||||
|
||||
def _settings_with(mode: str, keys: set[str], custom: bool = False) -> AppSettings:
|
||||
s = AppSettings(connection_mode=mode)
|
||||
if "anthropic" in keys:
|
||||
s.anthropic_api_key = "sk-ant-live-aaaa"
|
||||
if "openai" in keys:
|
||||
s.openai_api_key = "sk-openai-live-bbbb"
|
||||
if "google" in keys:
|
||||
s.google_api_key = "goog-live-cccc"
|
||||
if "openrouter" in keys:
|
||||
s.openrouter_api_key = "or-live-dddd"
|
||||
if mode in ("openswarm-pro", "free-trial"):
|
||||
s.openswarm_bearer_token = "bearer-live-eeee"
|
||||
if mode == "free-trial":
|
||||
s.free_trial_token = "ft-live-ffff"
|
||||
if custom:
|
||||
s.custom_providers = [CustomProvider(name="LMStudio", base_url="http://localhost:1234/v1", api_key="local")]
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The invariant: the live credential can never be blanked; others always can.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_live_api_key_can_never_be_blanked_but_others_can():
|
||||
key_subsets = [set(c) for r in range(5)
|
||||
for c in itertools.combinations(["anthropic", "openai", "google", "openrouter"], r)]
|
||||
checked_api_key_runs = 0
|
||||
for model in _all_model_values():
|
||||
for mode in CONNECTION_MODES:
|
||||
for keys in key_subsets:
|
||||
for custom in (False, True):
|
||||
s = _settings_with(mode, keys, custom=custom)
|
||||
p = resolve_powering_credential(model, s)
|
||||
|
||||
if p.kind == "api_key" and p.protected_field:
|
||||
checked_api_key_runs += 1
|
||||
# Blanking the live key, in any blank form, is refused.
|
||||
for blank in (None, "", " "):
|
||||
assert write_would_suicide(p.protected_field, blank, p), (
|
||||
f"suicide allowed: model={model} mode={mode} field={p.protected_field}={blank!r}"
|
||||
)
|
||||
# Replacing it with a real key is a reconnect, allowed.
|
||||
assert not write_would_suicide(p.protected_field, "sk-fresh-9999", p)
|
||||
# Clearing any OTHER provider's key stays allowed.
|
||||
for other in ALL_API_KEY_FIELDS - {p.protected_field}:
|
||||
assert not write_would_suicide(other, "", p), (
|
||||
f"over-blocked unrelated key {other}: model={model} mode={mode}"
|
||||
)
|
||||
|
||||
elif p.kind == "subscription":
|
||||
# The live credential isn't a settings field, so clearing
|
||||
# ANY api key is safe (it can't be the powering one).
|
||||
for field in ALL_API_KEY_FIELDS:
|
||||
assert not write_would_suicide(field, "", p), (
|
||||
f"subscription run wrongly protected {field}: model={model} mode={mode}"
|
||||
)
|
||||
|
||||
elif p.kind == "unknown":
|
||||
# Fail safe: every credential field is protected.
|
||||
for field in ALL_API_KEY_FIELDS:
|
||||
assert write_would_suicide(field, "", p)
|
||||
|
||||
assert checked_api_key_runs > 0, "enumeration never exercised an api-key run; test is vacuous"
|
||||
|
||||
|
||||
def test_custom_provider_run_protects_its_entry():
|
||||
s = _settings_with("own_key", set(), custom=True)
|
||||
p = resolve_powering_credential("custom/lmstudio/llama-3", s)
|
||||
assert p.kind == "api_key" and p.provider == "custom"
|
||||
|
||||
# Dropping the powering provider's entry is suicide.
|
||||
assert write_would_suicide("custom_providers", [], p)
|
||||
# Keeping it (even with a blanked placeholder key, local servers don't need one) is fine.
|
||||
keep = [{"name": "LMStudio", "base_url": "http://localhost:1234/v1", "api_key": ""}]
|
||||
assert not write_would_suicide("custom_providers", keep, p)
|
||||
# Swapping in a different provider but losing the live one is suicide.
|
||||
other = [{"name": "Together", "base_url": "https://api.together.xyz/v1", "api_key": "k"}]
|
||||
assert write_would_suicide("custom_providers", other, p)
|
||||
|
||||
|
||||
def test_disconnect_all_models_spec_scenario():
|
||||
"""The spec's worked example: Claude (api key) + OpenAI (api key) both
|
||||
connected, run on an Anthropic model, asked to disconnect everything. It
|
||||
must refuse to kill Claude (the live one) and allow killing OpenAI."""
|
||||
s = _settings_with("own_key", {"anthropic", "openai"})
|
||||
p = resolve_powering_credential("opus-4-8", s) # default Anthropic row, own_key -> api key
|
||||
assert p.kind == "api_key" and p.protected_field == "anthropic_api_key"
|
||||
assert write_would_suicide("anthropic_api_key", "", p) # refuse self
|
||||
assert not write_would_suicide("openai_api_key", "", p) # allow the other
|
||||
|
||||
# Same connections, but the run is on the OpenAI key instead: mirror image.
|
||||
p2 = resolve_powering_credential("gpt-5.5-api", s)
|
||||
assert p2.kind == "api_key" and p2.protected_field == "openai_api_key"
|
||||
assert write_would_suicide("openai_api_key", "", p2)
|
||||
assert not write_would_suicide("anthropic_api_key", "", p2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Drift seals.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_every_shipped_model_lane_classifies():
|
||||
"""A new model row that the resolver can't place would silently fall to the
|
||||
fail-safe 'unknown' lane (over-blocking every key). Force every shipped row
|
||||
to resolve to a real api_key/subscription so new lanes get classified."""
|
||||
s_pro = _settings_with("openswarm-pro", {"anthropic", "openai", "google", "openrouter"})
|
||||
s_key = _settings_with("own_key", {"anthropic", "openai", "google", "openrouter"})
|
||||
for rows in BUILTIN_MODELS.values():
|
||||
for m in rows:
|
||||
for s in (s_pro, s_key):
|
||||
p = resolve_powering_credential(m["value"], s)
|
||||
assert p.kind in ("api_key", "subscription"), (
|
||||
f"unclassified model lane {m['value']!r} -> {p.kind}"
|
||||
)
|
||||
|
||||
|
||||
def test_redactor_catches_every_known_secret():
|
||||
for field in KNOWN_SECRET_FIELDS:
|
||||
assert is_secret_field(field), f"redactor would leak {field}"
|
||||
# And every AppSettings field that NAMES itself a secret is caught by the rule.
|
||||
for name in AppSettings.model_fields:
|
||||
if name.endswith(("_key", "_token", "_secret")):
|
||||
assert is_secret_field(name)
|
||||
|
||||
|
||||
def test_redaction_fail_safe_catches_misnamed_secret_by_value():
|
||||
# The name rule (_key/_token/_secret) would MISS a field named off-convention.
|
||||
# The value-shape backstop must still redact it, so a leak needs BOTH a bad
|
||||
# name AND a non-credential-shaped value, not just one.
|
||||
import json
|
||||
raw = {"theme": "dark", "weird_field": "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFF"}
|
||||
red = redact_settings(raw)
|
||||
assert red["theme"] == "dark"
|
||||
assert isinstance(red["weird_field"], dict) and red["weird_field"]["configured"] is True
|
||||
assert "sk-ant-api03" not in json.dumps(red)
|
||||
|
||||
|
||||
def test_redact_settings_never_emits_a_raw_secret():
|
||||
s = _settings_with("openswarm-pro", {"anthropic", "openai", "google", "openrouter"}, custom=True)
|
||||
s.claude_subscription_token = "should-never-appear"
|
||||
raw = s.model_dump()
|
||||
red = redact_settings(raw)
|
||||
|
||||
for field in KNOWN_SECRET_FIELDS:
|
||||
if field in red:
|
||||
assert isinstance(red[field], dict), f"{field} not redacted to a state dict"
|
||||
assert "configured" in red[field]
|
||||
raw_val = raw.get(field)
|
||||
if isinstance(raw_val, str) and raw_val.strip():
|
||||
# Configured: state only, never the whole value (last4 at most).
|
||||
assert red[field]["configured"] is True
|
||||
assert red[field].get("last4") != raw_val
|
||||
assert len(red[field].get("last4") or "") <= 4
|
||||
# The nested custom-provider key is redacted too.
|
||||
assert isinstance(red["custom_providers"][0]["api_key"], dict)
|
||||
# Non-secret fields pass through untouched.
|
||||
assert red["theme"] == raw["theme"]
|
||||
assert red["connection_mode"] == raw["connection_mode"]
|
||||
|
||||
# The strongest check: the literal secret string appears nowhere in the output.
|
||||
import json
|
||||
assert "should-never-appear" not in json.dumps(red)
|
||||
assert "sk-ant-live-aaaa" not in json.dumps(red)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Definitive proof: a SettingsWrite from the REAL stdio MCP server, against a
|
||||
REAL running backend, on a REAL session whose model is powered by a specific
|
||||
API key, refuses to clear THAT key but clears a different provider's key.
|
||||
|
||||
Earlier coverage either drove the endpoint with an injected session (skipping the
|
||||
stdio subprocess) or drove the stdio subprocess with no session (hitting the
|
||||
fail-safe). This closes the gap: it boots uvicorn in-process (so the subprocess's
|
||||
HTTP call lands on a backend whose `agent_manager.sessions` we can populate),
|
||||
then runs `settings_meta_server.py` exactly as an agent would. No model required,
|
||||
the guard is provider-routing logic, not an LLM call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from backend.main import app
|
||||
|
||||
SERVER = os.path.join(os.path.dirname(os.path.dirname(__file__)), "apps", "agents", "settings_meta_server.py")
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
return port
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_backend():
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod._TOKEN:
|
||||
auth_mod._TOKEN = secrets.token_urlsafe(32)
|
||||
port = _free_port()
|
||||
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(200):
|
||||
if getattr(server, "started", False):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert getattr(server, "started", False), "uvicorn did not start"
|
||||
yield port, auth_mod._TOKEN
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_settings():
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
original = load_settings().model_copy(deep=True)
|
||||
yield
|
||||
_save_settings(original)
|
||||
|
||||
|
||||
def _run_stdio(port: int, token: str, session_id: str, changes: dict) -> str:
|
||||
env = {
|
||||
**os.environ,
|
||||
"OPENSWARM_PORT": str(port),
|
||||
"OPENSWARM_AUTH_TOKEN": token,
|
||||
"OPENSWARM_PARENT_SESSION_ID": session_id,
|
||||
}
|
||||
rpc = "\n".join([
|
||||
json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}),
|
||||
json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||
"params": {"name": "SettingsWrite", "arguments": {"changes": changes}}}),
|
||||
]) + "\n"
|
||||
proc = subprocess.run([sys.executable, SERVER], input=rpc, capture_output=True, text=True, env=env, timeout=30)
|
||||
msgs = [json.loads(l) for l in proc.stdout.splitlines() if l.strip()]
|
||||
resp = next(m for m in msgs if m.get("id") == 2)
|
||||
return resp["result"]["content"][0]["text"]
|
||||
|
||||
|
||||
def test_live_stdio_settingswrite_refuses_live_key_clears_other(live_backend, reset_settings):
|
||||
port, token = live_backend
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.models import AgentSession
|
||||
|
||||
# A real run on opus-4-8 in own_key mode: the Anthropic key powers it; an
|
||||
# OpenAI key is also connected (the "other provider").
|
||||
s = load_settings()
|
||||
s.connection_mode = "own_key"
|
||||
s.anthropic_api_key = "sk-ant-LIVE-do-not-clear"
|
||||
s.openai_api_key = "sk-oai-OTHER-ok-to-clear"
|
||||
_save_settings(s)
|
||||
agent_manager.sessions["live-stdio-test"] = AgentSession(id="live-stdio-test", name="t", model="opus-4-8")
|
||||
|
||||
try:
|
||||
text = _run_stdio(port, token, "live-stdio-test",
|
||||
{"anthropic_api_key": "", "openai_api_key": "", "theme": "light"})
|
||||
finally:
|
||||
agent_manager.sessions.pop("live-stdio-test", None)
|
||||
|
||||
# The tool's own rendered result, exactly what the agent would read.
|
||||
assert "Refused anthropic_api_key" in text, text
|
||||
assert "powering this run" in text
|
||||
assert "Applied" in text and "theme" in text
|
||||
|
||||
# And the truth on disk: live key kept, other cleared, benign applied.
|
||||
final = load_settings()
|
||||
assert final.anthropic_api_key == "sk-ant-LIVE-do-not-clear", "live key was cleared!"
|
||||
assert not final.openai_api_key, "the other provider's key should have been cleared"
|
||||
assert final.theme == "light"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""PATCH settings merges a diff onto fresh state, so a renderer save can't
|
||||
clobber a field it didn't send. This is the structural close on the last
|
||||
renderer-vs-agent race: the lost update is now unrepresentable, you can't
|
||||
overwrite a field you never put in the body.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
def _auth_headers():
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod._TOKEN:
|
||||
import secrets
|
||||
auth_mod._TOKEN = secrets.token_urlsafe(32)
|
||||
return {"Authorization": f"Bearer {auth_mod._TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app, headers=_auth_headers())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_settings():
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
original = load_settings().model_copy(deep=True)
|
||||
yield
|
||||
_save_settings(original)
|
||||
|
||||
|
||||
def test_patch_changes_only_sent_fields(client, reset_settings):
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
s = load_settings()
|
||||
s.theme = "dark"
|
||||
s.default_mode = "chat" # as if something else had set this
|
||||
_save_settings(s)
|
||||
|
||||
r = client.patch("/api/settings", json={"theme": "light"})
|
||||
assert r.status_code == 200, r.text
|
||||
final = load_settings()
|
||||
assert final.theme == "light" # the field we sent changed
|
||||
assert final.default_mode == "chat" # the field we DIDN'T send is untouched
|
||||
|
||||
|
||||
def test_patch_ignores_unknown_fields(client, reset_settings):
|
||||
r = client.patch("/api/settings", json={"theme": "light", "not_a_field": 123})
|
||||
assert r.status_code == 200
|
||||
# Unknown key is dropped, not stored; the real field still applied.
|
||||
from backend.apps.settings.settings import load_settings
|
||||
assert load_settings().theme == "light"
|
||||
assert "not_a_field" not in load_settings().model_dump()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_renderer_patch_and_agent_write_both_survive(reset_settings):
|
||||
"""The renderer PATCHes one field while an autonomous agent writes another,
|
||||
at the same time. Both must land: the renderer never sends the agent's field,
|
||||
so it can't clobber it, and both reads happen fresh under the shared lock."""
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
base = load_settings()
|
||||
base.theme = "dark"
|
||||
base.default_mode = "agent"
|
||||
_save_settings(base)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test", headers=_auth_headers()) as client:
|
||||
r1, r2 = await asyncio.gather(
|
||||
client.patch("/api/settings", json={"theme": "light"}),
|
||||
client.post("/api/settings-meta/write", json={"changes": {"default_mode": "chat"}}),
|
||||
)
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
|
||||
final = load_settings()
|
||||
assert final.theme == "light", "renderer's change lost"
|
||||
assert final.default_mode == "chat", "agent's change clobbered by the renderer PATCH"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Proof that selecting a Settings row and sending threads selected_setting_ids
|
||||
from the HTTP boundary into the run, and produces the focused context block.
|
||||
|
||||
Covers the backend half definitively (HTTP -> send_message kwarg; the context
|
||||
builder output). The browser half (click a row -> the POST body carries
|
||||
selected_setting_ids) is proven live via CDP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod._TOKEN:
|
||||
auth_mod._TOKEN = secrets.token_urlsafe(32)
|
||||
return TestClient(app, headers={"Authorization": f"Bearer {auth_mod._TOKEN}"})
|
||||
|
||||
|
||||
def test_message_endpoint_threads_selected_setting_ids(client, monkeypatch):
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_send(session_id, prompt, **kwargs):
|
||||
captured["session_id"] = session_id
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(agent_manager, "send_message", fake_send)
|
||||
r = client.post(
|
||||
"/api/agents/sessions/sess-x/message",
|
||||
json={"prompt": "flip my theme to light", "selected_setting_ids": ["theme", "default_model"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert captured["kwargs"].get("selected_setting_ids") == ["theme", "default_model"]
|
||||
|
||||
|
||||
def test_selected_settings_context_block_targets_the_fields():
|
||||
from backend.apps.agents.manager.prompt.prompt_context import _build_selected_settings_context
|
||||
assert _build_selected_settings_context(None) is None
|
||||
assert _build_selected_settings_context([]) is None
|
||||
block = _build_selected_settings_context(["theme", "default_model"])
|
||||
assert "theme" in block and "default_model" in block
|
||||
# It tells the agent to use the always-on settings tools on exactly these fields.
|
||||
assert "SettingsRead" in block and "SettingsWrite" in block
|
||||
# Targeting aid, not a gate: it focuses, never claims to unlock anything.
|
||||
assert "Leave unrelated settings alone" in block
|
||||
@@ -0,0 +1,193 @@
|
||||
"""skills.sh wild-registry resolution + safe install.
|
||||
|
||||
The network parts (GitHub trees + raw fetch) are smoked manually; here we pin
|
||||
the PURE logic that decides which files a skill is made of and the safety of
|
||||
writing them: SKILL.md selection at arbitrary repo depth, script disclosure,
|
||||
and the path-traversal guard that stops an untrusted archive escaping its dir.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.apps.skills.skills as skills_mod
|
||||
from backend.apps.skill_registry.skill_registry import _select_skill_paths, _is_script_path
|
||||
|
||||
|
||||
def test_selects_shortest_matching_skill_md_at_any_depth():
|
||||
tree = [
|
||||
{"type": "blob", "path": "README.md"},
|
||||
{"type": "blob", "path": "plugins/x/skills/pdftk/SKILL.md"},
|
||||
{"type": "blob", "path": "plugins/x/skills/pdftk/run.sh"},
|
||||
{"type": "blob", "path": "plugins/x/skills/pdftk/templates/form.txt"},
|
||||
{"type": "blob", "path": "plugins/x/skills/other/SKILL.md"},
|
||||
]
|
||||
skill_md, members = _select_skill_paths(tree, "pdftk")
|
||||
assert skill_md == "plugins/x/skills/pdftk/SKILL.md"
|
||||
assert set(members) == {
|
||||
"plugins/x/skills/pdftk/SKILL.md",
|
||||
"plugins/x/skills/pdftk/run.sh",
|
||||
"plugins/x/skills/pdftk/templates/form.txt",
|
||||
}
|
||||
# The unrelated 'other' skill's files are excluded.
|
||||
assert all("/other/" not in m for m in members)
|
||||
|
||||
|
||||
def test_top_level_skill_md():
|
||||
tree = [{"type": "blob", "path": "pdftk/SKILL.md"}, {"type": "blob", "path": "pdftk/x.py"}]
|
||||
skill_md, members = _select_skill_paths(tree, "pdftk")
|
||||
assert skill_md == "pdftk/SKILL.md"
|
||||
assert "pdftk/x.py" in members
|
||||
|
||||
|
||||
def test_missing_skill_raises():
|
||||
with pytest.raises(ValueError):
|
||||
_select_skill_paths([{"type": "blob", "path": "a/SKILL.md"}], "nonexistent")
|
||||
|
||||
|
||||
def test_ambiguous_match_picks_deterministically():
|
||||
# Several <x>/pdf/SKILL.md: a top-level pdf/ wins, else skills/pdf/, never arbitrary.
|
||||
tree = [
|
||||
{"type": "blob", "path": "plugins/z/pdf/SKILL.md"},
|
||||
{"type": "blob", "path": "skills/pdf/SKILL.md"},
|
||||
{"type": "blob", "path": "pdf/SKILL.md"},
|
||||
]
|
||||
skill_md, _ = _select_skill_paths(tree, "pdf")
|
||||
assert skill_md == "pdf/SKILL.md"
|
||||
# Without a top-level one, prefer skills/<id>/.
|
||||
tree2 = [
|
||||
{"type": "blob", "path": "plugins/z/pdf/SKILL.md"},
|
||||
{"type": "blob", "path": "skills/pdf/SKILL.md"},
|
||||
]
|
||||
skill_md2, _ = _select_skill_paths(tree2, "pdf")
|
||||
assert skill_md2 == "skills/pdf/SKILL.md"
|
||||
|
||||
|
||||
def test_github_headers_adds_token_when_set(monkeypatch):
|
||||
from backend.apps.skill_registry.skill_registry import _github_headers
|
||||
monkeypatch.delenv("OPENSWARM_GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
assert "Authorization" not in _github_headers()
|
||||
monkeypatch.setenv("OPENSWARM_GITHUB_TOKEN", "ghp_test")
|
||||
assert _github_headers()["Authorization"] == "Bearer ghp_test"
|
||||
|
||||
|
||||
def test_install_disclosure_flags_secret_shaped_files():
|
||||
# The scan we wire into the install disclosure (reused from the .swarm importer)
|
||||
# must flag a community skill shipping credentials, and leave clean files alone.
|
||||
from backend.apps.swarm.redact import find_secrets_in_files
|
||||
files = {
|
||||
"SKILL.md": b"Renders PDFs. No secrets.",
|
||||
"config.py": b'API_KEY = "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH"',
|
||||
}
|
||||
hits = find_secrets_in_files(files)
|
||||
assert "config.py" in hits
|
||||
assert "SKILL.md" not in hits
|
||||
|
||||
|
||||
def test_script_classification():
|
||||
assert _is_script_path("run.sh")
|
||||
assert _is_script_path("helper.py")
|
||||
assert _is_script_path("scripts/build.txt") # under a scripts/ dir
|
||||
assert _is_script_path("bin/tool")
|
||||
assert not _is_script_path("SKILL.md")
|
||||
assert not _is_script_path("templates/form.html")
|
||||
assert not _is_script_path("data.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safe install (write_folder_skill).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "skills"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(skills_mod, "SKILLS_DIR", str(d))
|
||||
monkeypatch.setattr(skills_mod, "INDEX_PATH", str(d / ".skills_index.json"))
|
||||
return d
|
||||
|
||||
|
||||
def test_write_folder_skill_lands_files_and_indexes(skills_dir):
|
||||
skill = skills_mod.write_folder_skill(
|
||||
"PDF Tk",
|
||||
{"SKILL.md": "---\nname: PDF Tk\n---\nbody", "scripts/run.sh": "echo hi"},
|
||||
{"name": "PDF Tk", "description": "fill forms"},
|
||||
)
|
||||
assert skill.id == "pdf-tk"
|
||||
assert skill.has_supporting_files is True
|
||||
assert os.path.isfile(skills_dir / "pdf-tk" / "SKILL.md")
|
||||
assert os.path.isfile(skills_dir / "pdf-tk" / "scripts" / "run.sh")
|
||||
# Re-syncs and shows up in the list.
|
||||
assert "pdf-tk" in {s.id for s in skills_mod._sync_skills()}
|
||||
|
||||
|
||||
def test_install_dedups_instead_of_clobbering_existing_skill(skills_dir):
|
||||
# A user already has a local skill named "pdf".
|
||||
skills_mod.write_folder_skill("pdf", {"SKILL.md": "MINE"}, {"name": "My PDF"})
|
||||
# A wild-registry install of a same-named skill must NOT overwrite it.
|
||||
slug = skills_mod.unique_skill_slug("pdf")
|
||||
assert slug == "pdf-2"
|
||||
skills_mod.write_folder_skill(slug, {"SKILL.md": "THEIRS"}, {"name": "Registry PDF"})
|
||||
with open(skills_dir / "pdf" / "SKILL.md", encoding="utf-8") as f:
|
||||
assert f.read() == "MINE", "registry install clobbered the user's existing skill"
|
||||
with open(skills_dir / "pdf-2" / "SKILL.md", encoding="utf-8") as f:
|
||||
assert f.read() == "THEIRS"
|
||||
ids = {s.id for s in skills_mod._sync_skills()}
|
||||
assert {"pdf", "pdf-2"} <= ids
|
||||
|
||||
|
||||
def test_confirm_install_writes_folder_lists_and_injects(skills_dir, monkeypatch):
|
||||
"""End-to-end install->usable: confirm=true through the real /install endpoint
|
||||
writes the folder skill, it shows up in /api/skills/list with supporting
|
||||
files, and _resolve_attached_skills injects it with the folder path so an
|
||||
agent can read its scripts. (resolve is mocked to skip the network; the live
|
||||
GitHub resolve is proven separately.)"""
|
||||
import secrets as _secrets
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
from backend.apps.agents.manager.prompt.prompt_context import _resolve_attached_skills
|
||||
import backend.auth as auth_mod
|
||||
if not auth_mod._TOKEN:
|
||||
auth_mod._TOKEN = _secrets.token_urlsafe(32)
|
||||
client = TestClient(app, headers={"Authorization": f"Bearer {auth_mod._TOKEN}"})
|
||||
|
||||
async def fake_resolve(source, skill_id):
|
||||
return {
|
||||
"name": "PDF Tools", "description": "work with pdfs", "repo_url": "https://github.com/o/r",
|
||||
"skill_id": skill_id,
|
||||
"files": {"SKILL.md": "# PDF Tools\nRun scripts/extract.py to pull text.",
|
||||
"scripts/extract.py": "print('extract')"},
|
||||
"scripts": ["scripts/extract.py"], "secret_findings": [],
|
||||
}
|
||||
monkeypatch.setattr("backend.apps.skill_registry.skill_registry.resolve_community_skill", fake_resolve)
|
||||
|
||||
r = client.post("/api/skill-registry/install", json={"source": "o/r", "skill_id": "pdf-tools", "confirm": True})
|
||||
assert r.status_code == 200 and r.json()["installed"] is True
|
||||
slug = r.json()["skill"]["id"]
|
||||
|
||||
# Listed via the real skills API, flagged as multi-file.
|
||||
listed = {s["id"]: s for s in client.get("/api/skills/list").json()["skills"]}
|
||||
assert slug in listed and listed[slug]["has_supporting_files"] is True
|
||||
# On disk as a folder with the script.
|
||||
assert (skills_dir / slug / "SKILL.md").exists()
|
||||
assert (skills_dir / slug / "scripts" / "extract.py").exists()
|
||||
# Injectable: the agent gets the body AND a pointer to the folder for on-demand reads.
|
||||
block = _resolve_attached_skills([{"id": slug, "name": "PDF Tools", "content": "# PDF Tools\nRun scripts/extract.py to pull text."}])
|
||||
assert "[Using skill: PDF Tools]" in block
|
||||
assert str(skills_dir / slug) in block
|
||||
|
||||
|
||||
def test_write_folder_skill_blocks_path_traversal(skills_dir):
|
||||
skills_mod.write_folder_skill(
|
||||
"evil",
|
||||
{"SKILL.md": "x", "../escape.txt": "pwned", "/etc/abs.txt": "pwned"},
|
||||
{"name": "evil"},
|
||||
)
|
||||
# The escape attempts never landed outside the skill folder.
|
||||
assert not (skills_dir.parent / "escape.txt").exists()
|
||||
assert not os.path.exists("/etc/abs.txt") or open("/etc/abs.txt").read() != "pwned"
|
||||
# The legitimate SKILL.md did land.
|
||||
assert os.path.isfile(skills_dir / "evil" / "SKILL.md")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Regression tests for the skill-registry never-empty seed (winv2 Bug #1).
|
||||
|
||||
The bug: the catalog was fetched from GitHub once at startup then only hourly,
|
||||
so a cold/slow/failed network left it empty for the whole session, breaking the
|
||||
Skills page and the onboarding "Install a skill" step (waitForSelector
|
||||
"skill-item-pdf" timing out). Fix: seed from a bundled snapshot + on-disk
|
||||
last-good cache so the catalog is never empty, even fully offline.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from backend.apps.skill_registry import skill_registry as sr
|
||||
|
||||
|
||||
def test_bundled_snapshot_exists_and_includes_pdf():
|
||||
# The onboarding step targets the "pdf" skill via /pdf/i; it must be present
|
||||
# in the shipped snapshot or the tour times out even with a populated list.
|
||||
assert os.path.exists(sr._BUNDLED_SNAPSHOT)
|
||||
data = json.load(open(sr._BUNDLED_SNAPSHOT, encoding="utf-8"))
|
||||
assert isinstance(data, dict) and len(data) >= 10
|
||||
assert any("pdf" in k.lower() or "pdf" in v.get("folder", "").lower()
|
||||
for k, v in data.items())
|
||||
|
||||
|
||||
def test_seed_makes_catalog_non_empty_offline(monkeypatch, tmp_path):
|
||||
# Point the disk cache at an empty tmp dir so only the bundled snapshot can
|
||||
# seed; this is the brand-new-install, no-network case.
|
||||
monkeypatch.setenv("OPENSWARM_SKILL_CACHE_DIR", str(tmp_path))
|
||||
seeded = sr._load_seed_cache()
|
||||
assert len(seeded) >= 10
|
||||
|
||||
sr._cache = seeded
|
||||
res = asyncio.run(sr.registry_search(q="", limit=100, offset=0, sort="name", category=""))
|
||||
assert res["total"] >= 10 and len(res["skills"]) >= 10
|
||||
|
||||
|
||||
def test_disk_cache_roundtrip_and_priority(monkeypatch, tmp_path):
|
||||
# A saved last-good fetch must win over the bundled snapshot on next boot.
|
||||
monkeypatch.setenv("OPENSWARM_SKILL_CACHE_DIR", str(tmp_path))
|
||||
sentinel = {"only-skill": {"name": "only-skill", "description": "", "content": "",
|
||||
"folder": "skills/only-skill", "category": "Test",
|
||||
"repositoryUrl": ""}}
|
||||
sr._save_disk_cache(sentinel)
|
||||
assert os.path.exists(sr._disk_cache_path())
|
||||
assert sr._load_seed_cache() == sentinel
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Multi-file (folder) skills, plus backward compatibility with legacy flat skills.
|
||||
|
||||
A skill is now either ~/.claude/skills/<id>/SKILL.md (with optional supporting
|
||||
files) or a legacy ~/.claude/skills/<id>.md. Both must list, read, and delete
|
||||
correctly, and a folder skill with supporting files must get its folder path
|
||||
appended to the prompt so the agent can read those files on demand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import backend.apps.skills.skills as skills_mod
|
||||
from backend.apps.agents.manager.prompt.prompt_context import _resolve_attached_skills
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "skills"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(skills_mod, "SKILLS_DIR", str(d))
|
||||
monkeypatch.setattr(skills_mod, "INDEX_PATH", str(d / ".skills_index.json"))
|
||||
return d
|
||||
|
||||
|
||||
def _write(path, text):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def test_corrupt_index_does_not_brick_skills_and_is_preserved(skills_dir):
|
||||
_write(str(skills_dir / "alpha.md"), "content")
|
||||
with open(skills_dir / ".skills_index.json", "w") as f:
|
||||
f.write("{ not valid json")
|
||||
# Load returns empty instead of raising, and moves the bad file aside.
|
||||
assert skills_mod._load_index() == {}
|
||||
assert (skills_dir / ".skills_index.json.corrupt").exists()
|
||||
# Skills still list (name falls back to the filename), so nothing is bricked.
|
||||
assert "alpha" in {s.id for s in skills_mod._sync_skills()}
|
||||
|
||||
|
||||
def test_non_object_index_is_rejected(skills_dir):
|
||||
with open(skills_dir / ".skills_index.json", "w") as f:
|
||||
f.write("[1, 2, 3]")
|
||||
assert skills_mod._load_index() == {}
|
||||
|
||||
|
||||
def test_save_index_is_atomic_no_temp_leftover(skills_dir):
|
||||
skills_mod._save_index({"x": {"name": "X"}})
|
||||
assert skills_mod._load_index() == {"x": {"name": "X"}}
|
||||
leftovers = [n for n in __import__("os").listdir(skills_dir) if n.startswith(".skills_index.") and n.endswith(".tmp")]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_flat_skill_still_syncs(skills_dir):
|
||||
_write(str(skills_dir / "my-flat.md"), "do the flat thing")
|
||||
skills = {s.id: s for s in skills_mod._sync_skills()}
|
||||
assert "my-flat" in skills
|
||||
s = skills["my-flat"]
|
||||
assert s.content == "do the flat thing"
|
||||
assert s.dir_path == ""
|
||||
assert s.has_supporting_files is False
|
||||
|
||||
|
||||
def test_folder_skill_syncs_with_supporting_files(skills_dir):
|
||||
base = skills_dir / "remotion"
|
||||
_write(str(base / "SKILL.md"), "---\nname: Remotion\ndescription: make videos\n---\nrender stuff")
|
||||
_write(str(base / "helper.py"), "print('hi')")
|
||||
skills = {s.id: s for s in skills_mod._sync_skills()}
|
||||
assert "remotion" in skills
|
||||
s = skills["remotion"]
|
||||
assert "render stuff" in s.content
|
||||
assert s.dir_path == str(base)
|
||||
assert s.has_supporting_files is True
|
||||
# Frontmatter fills name/description when the index hasn't catalogued it.
|
||||
assert s.name == "Remotion"
|
||||
assert s.description == "make videos"
|
||||
|
||||
|
||||
def test_folder_skill_without_extra_files_flags_false(skills_dir):
|
||||
base = skills_dir / "solo"
|
||||
_write(str(base / "SKILL.md"), "just one file")
|
||||
s = {x.id: x for x in skills_mod._sync_skills()}["solo"]
|
||||
assert s.dir_path == str(base)
|
||||
assert s.has_supporting_files is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_removes_folder(skills_dir):
|
||||
base = skills_dir / "doomed"
|
||||
_write(str(base / "SKILL.md"), "x")
|
||||
_write(str(base / "data.txt"), "y")
|
||||
assert base.is_dir()
|
||||
await skills_mod.delete_skill("doomed")
|
||||
assert not base.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_writes_folder_skill_md(skills_dir):
|
||||
base = skills_dir / "editable"
|
||||
_write(str(base / "SKILL.md"), "old body")
|
||||
from backend.apps.skills.models import SkillUpdate
|
||||
res = await skills_mod.update_skill("editable", SkillUpdate(content="new body", description="d"))
|
||||
assert res["ok"]
|
||||
with open(base / "SKILL.md", encoding="utf-8") as f:
|
||||
assert f.read() == "new body"
|
||||
assert res["skill"]["dir_path"] == str(base)
|
||||
|
||||
|
||||
def test_injection_points_at_folder_for_supporting_files(skills_dir):
|
||||
base = skills_dir / "withfiles"
|
||||
_write(str(base / "SKILL.md"), "use the template")
|
||||
_write(str(base / "template.html"), "<html></html>")
|
||||
|
||||
block = _resolve_attached_skills([{"id": "withfiles", "name": "WithFiles", "content": "use the template"}])
|
||||
assert "[Using skill: WithFiles]" in block
|
||||
assert str(base) in block
|
||||
assert "Read" in block # tells the agent to read supporting files
|
||||
|
||||
|
||||
def test_skill_injection_is_provider_agnostic_by_construction(skills_dir):
|
||||
"""The provider-agnostic claim, proven structurally (a live GPT/Gemini run
|
||||
needs a key): the injector takes no provider arg so it CAN'T differ by model,
|
||||
it points at supporting files via the universal Read tool, and Read is in the
|
||||
builtin set every provider gets. So a non-Claude agent receives byte-identical
|
||||
skill text and the same file-reading tools."""
|
||||
import inspect
|
||||
from backend.apps.agents.manager.prompt.prompt_context import _resolve_attached_skills
|
||||
from backend.apps.agents.manager.prompt.tool_catalog import FULL_TOOLS
|
||||
|
||||
# 1. No provider/api parameter -> the injected text cannot branch on the model.
|
||||
assert set(inspect.signature(_resolve_attached_skills).parameters) == {"attached_skills"}
|
||||
|
||||
# 2. A folder skill yields the body + a pointer to its folder via Read/Glob/Bash.
|
||||
base = skills_dir / "vid"
|
||||
_write(str(base / "SKILL.md"), "render it")
|
||||
_write(str(base / "helper.py"), "x")
|
||||
block = _resolve_attached_skills([{"id": "vid", "name": "Vid", "content": "render it"}])
|
||||
assert "[Using skill: Vid]" in block
|
||||
assert str(base) in block and "Read" in block
|
||||
|
||||
# 3. Those file tools are universal builtins, not an Anthropic-only set.
|
||||
assert {"Read", "Glob", "Bash"} <= set(FULL_TOOLS)
|
||||
|
||||
|
||||
def test_injection_no_folder_note_for_flat_skill(skills_dir):
|
||||
_write(str(skills_dir / "plain.md"), "plain content")
|
||||
block = _resolve_attached_skills([{"id": "plain", "name": "Plain", "content": "plain content"}])
|
||||
assert "[Using skill: Plain]" in block
|
||||
assert "supporting files" not in block.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .swarm round-trip for folder skills (export carries files, import rebuilds them).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_swarm_export_folder_skill_carries_supporting_files(skills_dir):
|
||||
from backend.apps.swarm.entities.skills import SkillExportable
|
||||
base = skills_dir / "vid"
|
||||
_write(str(base / "SKILL.md"), "render")
|
||||
_write(str(base / "scripts" / "go.py"), "print(1)")
|
||||
exp = SkillExportable.load("vid")
|
||||
assert exp is not None
|
||||
files = exp.files()
|
||||
assert "scripts/go.py" in files
|
||||
assert files["scripts/go.py"] == b"print(1)"
|
||||
assert exp._payload["content"] == "render"
|
||||
|
||||
|
||||
def test_swarm_import_writes_folder_when_files_present(skills_dir):
|
||||
from backend.apps.swarm.entities.skills import SkillExportable
|
||||
payload = {"slug": "vid", "name": "Vid", "description": "d", "command": "vid", "content": "render"}
|
||||
new_id = SkillExportable.import_(payload, {"scripts/go.py": b"print(1)"}, None)
|
||||
assert os.path.isfile(skills_dir / new_id / "SKILL.md")
|
||||
assert os.path.isfile(skills_dir / new_id / "scripts" / "go.py")
|
||||
synced = {s.id: s for s in skills_mod._sync_skills()}
|
||||
assert synced[new_id].has_supporting_files is True
|
||||
|
||||
|
||||
def test_swarm_import_always_writes_folder(skills_dir):
|
||||
# Unified storage: even a one-file skill imports as a folder, so a skill's
|
||||
# on-disk shape never depends on whether it had supporting files.
|
||||
from backend.apps.swarm.entities.skills import SkillExportable
|
||||
payload = {"slug": "note", "name": "Note", "content": "just text"}
|
||||
new_id = SkillExportable.import_(payload, {}, None)
|
||||
assert os.path.isfile(skills_dir / new_id / "SKILL.md")
|
||||
assert not (skills_dir / f"{new_id}.md").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_writes_folder_and_supersedes_legacy_flat(skills_dir):
|
||||
from backend.apps.skills.models import SkillCreate
|
||||
# A pre-existing legacy flat skill of the same id...
|
||||
_write(str(skills_dir / "notes.md"), "old flat")
|
||||
# ...is superseded (not shadowed) when the user (re)creates it; folder wins,
|
||||
# and the phantom flat file is removed so there's exactly one shape on disk.
|
||||
res = await skills_mod.create_skill(SkillCreate(name="Notes", content="new body", description="d"))
|
||||
sid = res["skill"]["id"]
|
||||
assert sid == "notes"
|
||||
assert os.path.isfile(skills_dir / "notes" / "SKILL.md")
|
||||
assert not (skills_dir / "notes.md").exists()
|
||||
only = [s for s in skills_mod._sync_skills() if s.id == "notes"]
|
||||
assert len(only) == 1 and only[0].content == "new body"
|
||||
|
||||
|
||||
def test_stage_zip_carries_supporting_files_into_sandbox():
|
||||
import io as _io, zipfile, os as _os, shutil
|
||||
from backend.apps.swarm.closure import _stage_skill_from_zip
|
||||
buf = _io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("my-skill/SKILL.md", "do it")
|
||||
zf.writestr("my-skill/scripts/run.sh", "echo hi")
|
||||
sandbox, manifest, warnings = _stage_skill_from_zip(buf.getvalue(), "my-skill.zip", [])
|
||||
try:
|
||||
bid = manifest.entities[0].bundle_id
|
||||
files_dir = _os.path.join(sandbox, "entities", bid, "files")
|
||||
assert _os.path.isfile(_os.path.join(files_dir, "scripts", "run.sh"))
|
||||
# SKILL.md is the payload body, not a supporting file.
|
||||
assert not _os.path.exists(_os.path.join(files_dir, "SKILL.md"))
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
@@ -49,8 +49,8 @@ def test_skill_export_import_round_trip(skill_store):
|
||||
# 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 (skill_store / "my-skill.md").exists() # original flat skill untouched
|
||||
assert (skill_store / root_id / "SKILL.md").read_text(encoding="utf-8") == "# hello\nbody text"
|
||||
assert created == {"skill": [root_id]}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_bare_markdown_import(skill_store):
|
||||
finally:
|
||||
import shutil
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
assert (skill_store / f"{root_id}.md").read_text(encoding="utf-8") == "# Just markdown"
|
||||
assert (skill_store / root_id / "SKILL.md").read_text(encoding="utf-8") == "# Just markdown"
|
||||
|
||||
|
||||
def test_content_secret_redacted_in_bundle(skill_store):
|
||||
@@ -99,6 +99,19 @@ def test_pack_refuses_denied_key():
|
||||
pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {})
|
||||
|
||||
|
||||
def test_pack_refuses_secret_in_workspace_file():
|
||||
# A key hardcoded in app source (not .env) must not ride along; pack scans
|
||||
# file bytes, not just payload keys.
|
||||
leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n"
|
||||
with pytest.raises(BundleError):
|
||||
pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak})
|
||||
|
||||
|
||||
def test_pack_allows_clean_workspace_file():
|
||||
raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/app.js": b"export default 1"})
|
||||
assert zipfile.is_zipfile(io.BytesIO(raw))
|
||||
|
||||
|
||||
def test_app_export_drops_machine_env(tmp_path, monkeypatch):
|
||||
# The live .env holds the source machine's absolute paths + pinned port; it
|
||||
# must never ride along. .env.example (portable) does.
|
||||
@@ -155,23 +168,194 @@ def test_workflow_unavailable_on_this_branch():
|
||||
WorkflowExportable.import_({"title": "x"}, {}, RemapTable())
|
||||
|
||||
|
||||
def test_session_export_strips_transcript_and_secrets():
|
||||
def test_session_export_carries_transcript_drops_runtime_and_secrets():
|
||||
from backend.apps.swarm.entities.sessions import SessionExportable
|
||||
from backend.apps.swarm.redact import scrub_payload
|
||||
data = {
|
||||
"name": "A", "provider": "anthropic", "model": "sonnet", "mode": "agent",
|
||||
"system_prompt": "hi", "allowed_tools": ["Read"],
|
||||
"messages": [{"role": "user", "content": "private chat"}],
|
||||
"messages": [
|
||||
{"id": "m1", "role": "user", "content": "private chat", "branch_id": "main"},
|
||||
{"id": "m2", "role": "assistant", "content": "token is sk-ant-abcdefghij0123456789"},
|
||||
],
|
||||
"branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}},
|
||||
"active_branch_id": "main",
|
||||
"tool_group_meta": {"g1": {"label": "x"}},
|
||||
"active_mcps": ["Gmail"], "cwd": "/Users/me/repo", "cost_usd": 9.9, "sdk_session_id": "x",
|
||||
}
|
||||
ex = SessionExportable("s1", "A", data)
|
||||
out = ex.serialize(None)
|
||||
for gone in ("messages", "cwd", "active_mcps", "cost_usd", "sdk_session_id"):
|
||||
# The transcript now rides along, that's the point of sharing an agent.
|
||||
assert out["messages"][0]["content"] == "private chat"
|
||||
assert out["active_branch_id"] == "main" and "main" in out["branches"]
|
||||
assert out["tool_group_meta"] == {"g1": {"label": "x"}}
|
||||
# Runtime, identity, and gate state still never leave.
|
||||
for gone in ("cwd", "active_mcps", "cost_usd", "sdk_session_id"):
|
||||
assert gone not in out
|
||||
assert out["model"] == "sonnet" and out["mode"] == "agent"
|
||||
# The closure runs scrub_payload on every payload, so a secret-shaped
|
||||
# string sitting in the transcript is redacted before it ships.
|
||||
assert "sk-ant-" not in json.dumps(scrub_payload(out))
|
||||
reqs = ex.requirements()
|
||||
assert any(r.kind.value == "mcp_action" and r.key == "Gmail" for r in reqs)
|
||||
|
||||
|
||||
def test_session_import_restores_transcript_without_granting_mcp(monkeypatch):
|
||||
from backend.apps.swarm.entities.sessions import SessionExportable
|
||||
from backend.apps.swarm.exportable import RemapTable
|
||||
from backend.apps.agents.manager.session import session_store
|
||||
saved: dict = {}
|
||||
monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc}))
|
||||
payload = {
|
||||
"name": "A", "model": "sonnet", "mode": "agent",
|
||||
"messages": [{"id": "m1", "role": "user", "content": "hi", "branch_id": "main"}],
|
||||
"branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None}},
|
||||
"active_branch_id": "main",
|
||||
"tool_group_meta": {"g1": {"label": "x"}},
|
||||
}
|
||||
sid = SessionExportable.import_(payload, {}, RemapTable())
|
||||
doc = saved[sid]
|
||||
assert doc["messages"][0]["content"] == "hi"
|
||||
assert doc["active_branch_id"] == "main"
|
||||
assert doc["tool_group_meta"] == {"g1": {"label": "x"}}
|
||||
# The gate stays shut: a shared agent never arrives with MCP access.
|
||||
assert doc["active_mcps"] == []
|
||||
# The dashboard import re-points this; it must never be the sharer's id.
|
||||
assert doc["dashboard_id"] is None
|
||||
|
||||
|
||||
def test_session_import_old_bundle_without_transcript(monkeypatch):
|
||||
# A bundle made before transcripts were carried has no messages; it must
|
||||
# still import as a valid empty-history agent (single main branch), not crash.
|
||||
from backend.apps.swarm.entities.sessions import SessionExportable
|
||||
from backend.apps.swarm.exportable import RemapTable
|
||||
from backend.apps.agents.manager.session import session_store
|
||||
saved: dict = {}
|
||||
monkeypatch.setattr(session_store, "_save_session", lambda sid, doc: saved.update({sid: doc}))
|
||||
sid = SessionExportable.import_({"name": "Old", "model": "sonnet"}, {}, RemapTable())
|
||||
doc = saved[sid]
|
||||
assert doc["messages"] == []
|
||||
assert doc["active_branch_id"] == "main" and "main" in doc["branches"]
|
||||
|
||||
|
||||
def test_session_load_prefers_live_memory_over_stale_disk(tmp_path, monkeypatch):
|
||||
# The freshest transcript lives in memory; a disk-only load would ship a
|
||||
# stale one. load() must read the live session first, disk only as fallback.
|
||||
from backend.apps.agents import agent_manager as am
|
||||
from backend.apps.swarm.entities.sessions import SessionExportable
|
||||
sdir = tmp_path / "sessions"
|
||||
sdir.mkdir()
|
||||
monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
|
||||
(sdir / "s1.json").write_text(json.dumps(
|
||||
{"name": "Stale", "messages": [{"id": "old", "role": "user", "content": "old"}]}))
|
||||
|
||||
class FakeSess:
|
||||
def model_dump(self, mode="json"):
|
||||
return {"name": "Live", "messages": [
|
||||
{"id": "old", "role": "user", "content": "old"},
|
||||
{"id": "new", "role": "assistant", "content": "fresh turn"},
|
||||
]}
|
||||
|
||||
monkeypatch.setattr(am.agent_manager, "sessions", {"s1": FakeSess()})
|
||||
out = SessionExportable.load("s1").serialize(None)
|
||||
assert out["name"] == "Live" # not the stale disk copy
|
||||
assert len(out["messages"]) == 2 # the unflushed turn is included
|
||||
|
||||
|
||||
def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, monkeypatch):
|
||||
# The path the single-session tests missed: a whole dashboard with agent
|
||||
# cards + a browser card. Both agents (with their transcripts) and the
|
||||
# browser must survive export -> import. An empty-history import is the bug
|
||||
# the user hit ("the chats didn't even show up, let alone the history").
|
||||
import shutil
|
||||
from backend.apps.agents import agent_manager as am
|
||||
import backend.config.paths as paths
|
||||
sdir = tmp_path / "sessions"
|
||||
ddir = tmp_path / "dashboards"
|
||||
sdir.mkdir()
|
||||
ddir.mkdir()
|
||||
monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
|
||||
monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir))
|
||||
monkeypatch.setattr(am.agent_manager, "sessions", {}) # nothing live -> disk path
|
||||
|
||||
did, sid1, sid2, bkey = "d1", "sA", "sB", "browser-1"
|
||||
|
||||
def sess(sid, name, text):
|
||||
return {
|
||||
"id": sid, "name": name, "status": "completed", "provider": "anthropic",
|
||||
"model": "sonnet", "mode": "agent", "allowed_tools": [],
|
||||
"messages": [{"id": "m1", "role": "user", "content": text, "branch_id": "main"}],
|
||||
"branches": {"main": {"id": "main", "parent_branch_id": None, "fork_point_message_id": None, "created_at": "2026-01-01"}},
|
||||
"active_branch_id": "main", "tool_group_meta": {}, "active_mcps": [], "dashboard_id": did,
|
||||
}
|
||||
|
||||
(sdir / f"{sid1}.json").write_text(json.dumps(sess(sid1, "Agent One", "from one")))
|
||||
(sdir / f"{sid2}.json").write_text(json.dumps(sess(sid2, "Agent Two", "from two")))
|
||||
(ddir / f"{did}.json").write_text(json.dumps({"id": did, "name": "Board", "layout": {
|
||||
"cards": {sid1: {"session_id": sid1}, sid2: {"session_id": sid2}},
|
||||
"view_cards": {},
|
||||
"browser_cards": {bkey: {"browser_id": bkey, "url": "u", "spawned_by": None}},
|
||||
"notes": {}, "expanded_session_ids": [sid1],
|
||||
}}))
|
||||
|
||||
raw, _ = closure.build_bundle(EntityType.dashboard, did)
|
||||
sandbox, manifest, _w = closure.stage_upload(raw, "board.swarm")
|
||||
try:
|
||||
_rt, root_id, _created, _u = closure.commit(sandbox, manifest, [])
|
||||
finally:
|
||||
shutil.rmtree(sandbox, ignore_errors=True)
|
||||
|
||||
L = json.loads((ddir / f"{root_id}.json").read_text())["layout"]
|
||||
assert len(L["cards"]) == 2, "both agent cards must survive import"
|
||||
assert len(L["browser_cards"]) == 1, "the browser card must survive too"
|
||||
total_msgs = 0
|
||||
for sid in L["cards"]:
|
||||
doc = json.loads((sdir / f"{sid}.json").read_text())
|
||||
total_msgs += len(doc.get("messages") or [])
|
||||
assert doc["active_mcps"] == [], "import must not grant MCP access"
|
||||
assert total_msgs == 2, "each agent's transcript must carry through"
|
||||
|
||||
# The bug behind "the chats didn't even show up": after import the sessions
|
||||
# are on disk but not in memory, and the dashboard-open fetch
|
||||
# (get_all_sessions) was memory-only, so the cards rendered blank. The fetch
|
||||
# must now see the freshly-imported sessions straight off disk.
|
||||
found = am.agent_manager.get_all_sessions(dashboard_id=root_id)
|
||||
assert len(found) == 2, f"dashboard-open fetch must see imported agent sessions, got {len(found)}"
|
||||
assert sum(len(s.messages) for s in found) == 2, "and with their transcripts"
|
||||
|
||||
|
||||
def test_get_all_sessions_does_not_resurrect_deleted_cards(tmp_path, monkeypatch):
|
||||
# Deleting a card removes it from the layout but the session keeps its
|
||||
# dashboard_id on disk. get_all_sessions must surface only sessions the
|
||||
# layout still has a card for, or deleted chats come back on every reopen.
|
||||
from backend.apps.agents import agent_manager as am
|
||||
import backend.config.paths as paths
|
||||
sdir = tmp_path / "sessions"
|
||||
ddir = tmp_path / "dashboards"
|
||||
sdir.mkdir()
|
||||
ddir.mkdir()
|
||||
monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir))
|
||||
monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir))
|
||||
monkeypatch.setattr(am.agent_manager, "sessions", {})
|
||||
|
||||
did = "d1"
|
||||
|
||||
def sess(sid):
|
||||
return {
|
||||
"id": sid, "name": sid, "status": "completed", "model": "sonnet",
|
||||
"mode": "agent", "messages": [], "branches": {}, "active_branch_id": "main",
|
||||
"dashboard_id": did,
|
||||
}
|
||||
|
||||
(sdir / "kept.json").write_text(json.dumps(sess("kept")))
|
||||
(sdir / "deleted.json").write_text(json.dumps(sess("deleted"))) # still tagged, card gone
|
||||
# The layout has a card only for "kept" (the user deleted "deleted"'s card).
|
||||
(ddir / f"{did}.json").write_text(json.dumps({"id": did, "layout": {"cards": {"kept": {"session_id": "kept"}}}}))
|
||||
|
||||
ids = {s.id for s in am.agent_manager.get_all_sessions(dashboard_id=did)}
|
||||
assert "kept" in ids, "a session the layout still has a card for must surface"
|
||||
assert "deleted" not in ids, "a session whose card was deleted must NOT resurrect"
|
||||
|
||||
|
||||
def test_dashboard_serialize_rewrites_refs_to_bundle_ids():
|
||||
from backend.apps.swarm.entities.dashboards import DashboardExportable
|
||||
from backend.apps.swarm.models import EntityType
|
||||
@@ -182,13 +366,15 @@ def test_dashboard_serialize_rewrites_refs_to_bundle_ids():
|
||||
|
||||
data = {"name": "D", "layout": {
|
||||
"cards": {"S": {"session_id": "S", "x": 1}},
|
||||
"view_cards": {"A": {"output_id": "A", "x": 2}},
|
||||
"view_cards": {"A": {"output_id": "A", "x": 2, "parent_session_id": "S"}},
|
||||
"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"
|
||||
# the app card's tether to its builder agent is a session id, so it remaps too
|
||||
assert L["view_cards"]["ABID"]["parent_session_id"] == "SBID"
|
||||
assert L["browser_cards"]["b1"]["spawned_by"] == "SBID"
|
||||
assert L["expanded_session_ids"] == ["SBID"]
|
||||
|
||||
@@ -205,18 +391,96 @@ def test_dashboard_import_remaps_to_fresh_local_ids(monkeypatch):
|
||||
remap.assign("ABID", "newapp")
|
||||
payload = {"name": "D", "layout": {
|
||||
"cards": {"SBID": {"session_id": "SBID"}},
|
||||
"view_cards": {"ABID": {"output_id": "ABID"}},
|
||||
"view_cards": {
|
||||
"ABID": {"output_id": "ABID", "parent_session_id": "SBID"},
|
||||
"ABID2": {"output_id": "ABID2", "parent_session_id": "GONE"},
|
||||
},
|
||||
"browser_cards": {"b1": {"browser_id": "b1", "spawned_by": "SBID"}},
|
||||
"expanded_session_ids": ["SBID", "ORPHAN"],
|
||||
}}
|
||||
remap.assign("ABID2", "newapp2")
|
||||
did = dmod.DashboardExportable.import_(payload, {}, remap)
|
||||
L = written[did]["layout"]
|
||||
assert L["cards"]["newsess"]["session_id"] == "newsess"
|
||||
assert "newapp" in L["view_cards"]
|
||||
assert L["view_cards"]["newapp"]["parent_session_id"] == "newsess"
|
||||
assert L["view_cards"]["newapp2"]["parent_session_id"] is None # parent not in bundle
|
||||
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,
|
||||
"parent_session_id": (rng.choice(sess + ["ORPHAN"]) if sess and rng.random() < 0.7 else None)}
|
||||
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
|
||||
p = card["parent_session_id"]
|
||||
assert p is None or (p in set(fresh_sess.values()) and p not in forbidden)
|
||||
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")
|
||||
@@ -239,9 +503,9 @@ 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()
|
||||
assert (skill_store / sid / "SKILL.md").exists()
|
||||
SkillExportable.rollback(sid)
|
||||
assert not (skill_store / f"{sid}.md").exists()
|
||||
assert not (skill_store / sid).exists()
|
||||
assert sid not in store._load_index()
|
||||
|
||||
|
||||
@@ -264,7 +528,41 @@ def test_commit_rolls_back_created_on_failure(skill_store, tmp_path):
|
||||
with pytest.raises(BundleError):
|
||||
closure.commit(str(sb), manifest, [])
|
||||
assert "rollme" not in store._load_index()
|
||||
assert not (skill_store / "rollme.md").exists()
|
||||
assert not (skill_store / "rollme").exists() # the imported folder was rolled back
|
||||
|
||||
|
||||
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"):
|
||||
@@ -284,6 +582,18 @@ def test_absolute_path_rejected():
|
||||
unpack(_zip_with("/etc/evil"))
|
||||
|
||||
|
||||
def test_symlink_entry_rejected():
|
||||
# A symlink entry could point outside the sandbox once followed; unpack must
|
||||
# refuse it before writing anything.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zi = zipfile.ZipInfo("link")
|
||||
zi.external_attr = 0o120777 << 16
|
||||
zf.writestr(zi, "/etc/passwd")
|
||||
with pytest.raises(BundleError):
|
||||
unpack(buf.getvalue())
|
||||
|
||||
|
||||
def test_too_many_entries_rejected():
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""'Always approve' persistence invariant.
|
||||
|
||||
The bug: the dispatch gate READ a tool's policy from one slot while the
|
||||
'Always approve' button WROTE it to another (the raw mcp__server__action name
|
||||
in builtin_permissions vs the parsed inner action on the owning tool), so the
|
||||
next call never saw the policy and the button behaved like a one-time accept.
|
||||
|
||||
The seal: both sides now resolve the slot through resolve_policy_slot(), so a
|
||||
WRITE always lands where the READ looks. These tests pin that for every
|
||||
tool-name shape, including the round-trip that the old code failed.
|
||||
"""
|
||||
|
||||
from backend.apps.tools_lib.tools_lib import resolve_policy_slot, PolicySlot
|
||||
from backend.apps.tools_lib.mcp_config import _sanitize_server_name
|
||||
from backend.apps.tools_lib.models import ToolDefinition
|
||||
|
||||
|
||||
def _mcp_tool(name: str) -> ToolDefinition:
|
||||
return ToolDefinition(name=name, mcp_config={"command": "x"}, enabled=True, tool_permissions={})
|
||||
|
||||
|
||||
def test_slot_for_builtin_tool():
|
||||
assert resolve_policy_slot("Bash", []) == PolicySlot("builtin", "Bash", None)
|
||||
assert resolve_policy_slot("Read", []) == PolicySlot("builtin", "Read", None)
|
||||
|
||||
|
||||
def test_slot_for_our_browser_and_invoke_agents_uses_inner_name():
|
||||
# These live in builtin_permissions under the INNER name, not the namespaced one.
|
||||
assert resolve_policy_slot("mcp__openswarm-browser-agent__BrowserAgent", []) == \
|
||||
PolicySlot("builtin", "BrowserAgent", None)
|
||||
assert resolve_policy_slot("mcp__openswarm-invoke-agent__InvokeAgent", []) == \
|
||||
PolicySlot("builtin", "InvokeAgent", None)
|
||||
|
||||
|
||||
def test_slot_for_community_mcp_points_at_the_owning_tool():
|
||||
tool = _mcp_tool("My Notion Server")
|
||||
slug = _sanitize_server_name(tool.name)
|
||||
assert resolve_policy_slot(f"mcp__{slug}__notion-fetch", [tool]) == \
|
||||
PolicySlot("mcp", tool.id, "notion-fetch")
|
||||
|
||||
|
||||
def test_slot_for_unknown_mcp_has_no_write_target():
|
||||
assert resolve_policy_slot("mcp__ghostserver__do-thing", []) == \
|
||||
PolicySlot("mcp", None, "do-thing")
|
||||
|
||||
|
||||
# read/write mirror the dispatch-gate branches in agent_manager
|
||||
# (_get_effective_policy / _set_tool_policy): both key through resolve_policy_slot.
|
||||
def _read(tool_name, builtin_perms, tools):
|
||||
slot = resolve_policy_slot(tool_name, tools)
|
||||
if slot.store == "builtin":
|
||||
return builtin_perms.get(slot.key, "ask")
|
||||
if slot.key is not None:
|
||||
for t in tools:
|
||||
if t.id == slot.key:
|
||||
return t.tool_permissions.get(slot.action, "ask")
|
||||
return "ask"
|
||||
|
||||
|
||||
def _write(tool_name, policy, builtin_perms, tools):
|
||||
slot = resolve_policy_slot(tool_name, tools)
|
||||
if slot.store == "builtin":
|
||||
builtin_perms[slot.key] = policy
|
||||
return
|
||||
if slot.key is not None:
|
||||
for t in tools:
|
||||
if t.id == slot.key:
|
||||
t.tool_permissions[slot.action] = policy
|
||||
return
|
||||
|
||||
|
||||
def test_always_approve_round_trips_for_every_tool_shape():
|
||||
"""The invariant the old code violated: after WRITE(always_allow), the very
|
||||
next READ returns always_allow, for builtin, our agents, and community MCP."""
|
||||
notion = _mcp_tool("Notion")
|
||||
slug = _sanitize_server_name("Notion")
|
||||
tools = [notion]
|
||||
builtin_perms: dict[str, str] = {}
|
||||
|
||||
shapes = [
|
||||
"Bash",
|
||||
"Read",
|
||||
"mcp__openswarm-browser-agent__BrowserAgent",
|
||||
"mcp__openswarm-invoke-agent__InvokeAgent",
|
||||
f"mcp__{slug}__notion-fetch",
|
||||
]
|
||||
for tool_name in shapes:
|
||||
assert _read(tool_name, builtin_perms, tools) != "always_allow"
|
||||
_write(tool_name, "always_allow", builtin_perms, tools)
|
||||
assert _read(tool_name, builtin_perms, tools) == "always_allow", \
|
||||
f"{tool_name}: write did not land in the slot the gate reads"
|
||||
|
||||
|
||||
def test_two_actions_on_the_same_mcp_server_are_independent():
|
||||
"""Approving one action must not silently approve a sibling action."""
|
||||
tool = _mcp_tool("Notion")
|
||||
slug = _sanitize_server_name("Notion")
|
||||
tools = [tool]
|
||||
bp: dict[str, str] = {}
|
||||
_write(f"mcp__{slug}__notion-fetch", "always_allow", bp, tools)
|
||||
assert _read(f"mcp__{slug}__notion-fetch", bp, tools) == "always_allow"
|
||||
assert _read(f"mcp__{slug}__notion-create-pages", bp, tools) == "ask"
|
||||
|
||||
|
||||
# ---- Integration: the same round-trip through the REAL file persistence the gate
|
||||
# uses (load_builtin_permissions / _save / _load_all), so 'write then re-read'
|
||||
# survives a save+reload, not just an in-memory dict. ----
|
||||
import backend.apps.tools_lib.tools_lib as tl
|
||||
|
||||
|
||||
def test_builtin_policy_survives_a_real_file_reload(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tl, "BUILTIN_PERMS_PATH", str(tmp_path / "builtin_permissions.json"))
|
||||
slot = tl.resolve_policy_slot("Read", [])
|
||||
perms = tl.load_builtin_permissions()
|
||||
perms[slot.key] = "always_allow"
|
||||
tl.save_builtin_permissions(perms)
|
||||
# Fresh read (what the next session / a reload does) finds it at the read key.
|
||||
reloaded = tl.load_builtin_permissions()
|
||||
assert reloaded.get(tl.resolve_policy_slot("Read", []).key) == "always_allow"
|
||||
|
||||
|
||||
def test_mcp_policy_survives_a_real_tool_file_reload(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tl, "DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(tl, "_tools_cache", None)
|
||||
monkeypatch.setattr(tl, "_tools_cache_sig", None)
|
||||
tl._save(_mcp_tool("Notion"))
|
||||
slug = _sanitize_server_name("Notion")
|
||||
name = f"mcp__{slug}__notion-fetch"
|
||||
|
||||
# WRITE via the resolver against the freshly loaded tool, then persist.
|
||||
tools = tl._load_all()
|
||||
slot = tl.resolve_policy_slot(name, tools)
|
||||
target = next(t for t in tools if t.id == slot.key)
|
||||
target.tool_permissions[slot.action] = "always_allow"
|
||||
tl._save(target)
|
||||
|
||||
# RELOAD from disk and read via the resolver: the policy is there.
|
||||
tools2 = tl._load_all()
|
||||
rslot = tl.resolve_policy_slot(name, tools2)
|
||||
got = next(t for t in tools2 if t.id == rslot.key)
|
||||
assert got.tool_permissions.get(rslot.action) == "always_allow"
|
||||
@@ -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
|
||||
# ===========================================================================
|
||||
@@ -398,6 +467,10 @@ async def test_resolve_aux_model_anthropic_pro_returns_proxy():
|
||||
settings = AppSettings()
|
||||
settings.connection_mode = "openswarm-pro"
|
||||
settings.openswarm_proxy_url = "https://api.openswarm.test"
|
||||
# A real Pro-connected user carries a bearer token; proxy_auth reads it.
|
||||
# Without it the resolver can't see Pro and falls through to the raise,
|
||||
# which is what made this test depend on live machine state.
|
||||
settings.openswarm_bearer_token = "test-pro-token"
|
||||
with patch("backend.apps.nine_router.is_running", return_value=False):
|
||||
model_id, base = await registry.resolve_aux_model(settings)
|
||||
assert "haiku" in model_id
|
||||
@@ -523,6 +596,104 @@ def test_resolve_sdk_gemini_prefers_antigravity_over_api_key():
|
||||
assert registry.resolve_model_id_for_sdk("gemini-3-flash", s2) == "gc/gemini-3-flash-preview"
|
||||
|
||||
|
||||
def test_error_classify_schema_translation_400_is_not_auth():
|
||||
"""A 9Router tool-schema translation 400 can carry provider/connection
|
||||
wording that trips the auth regex, so it used to surface a misleading
|
||||
'reconnect your subscription' card for what is really a schema bug. The
|
||||
translation guard must win: schema 400 -> not auth; a real auth failure
|
||||
with no translation signature still reads as auth."""
|
||||
from backend.apps.agents.core.error_classify import p_is_auth_error, p_is_translation_error
|
||||
both = Exception("provider not connected: 400 INVALID_ARGUMENT at "
|
||||
"tools[0].function_declarations[0].parameters")
|
||||
assert p_is_translation_error(both)
|
||||
assert not p_is_auth_error(both), "schema-400 must not be classified as auth"
|
||||
# Pure auth failures (no translation signature) still classify as auth.
|
||||
assert p_is_auth_error(Exception("provider not connected: gemini"))
|
||||
assert p_is_auth_error(Exception("401 invalid authentication credentials"))
|
||||
assert not p_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 p_is_transient_capacity_error
|
||||
assert p_is_transient_capacity_error(Exception("429 RESOURCE_EXHAUSTED: Quota exceeded"))
|
||||
assert p_is_transient_capacity_error(Exception("RESOURCE_EXHAUSTED"))
|
||||
assert not p_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
|
||||
@@ -1038,6 +1209,7 @@ async def test_aux_failover_anthropic_to_codex():
|
||||
settings = AppSettings()
|
||||
settings.connection_mode = "openswarm-pro" # provides anthropic fallback
|
||||
settings.openswarm_proxy_url = "https://api.openswarm.test"
|
||||
settings.openswarm_bearer_token = "test-pro-token" # what a real Pro user carries
|
||||
with patch("backend.apps.nine_router.is_running", return_value=True), \
|
||||
patch("backend.apps.nine_router.get_providers",
|
||||
new=AsyncMock(return_value=[])): # nothing connected
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""App version history: capture / list / restore / branch round-trips for both
|
||||
a flat (inline-files) app and a webapp_template (workspace-folder) app, plus the
|
||||
load-bearing invariants: dedupe of unchanged state, the pre_restore safety net,
|
||||
.env preserved across restore, and branch producing a fully independent app.
|
||||
|
||||
The path constants are module-level, so (like test_swarm_bundle) we monkeypatch
|
||||
them per test into a temp tree."""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.outputs import versions, workspace_io
|
||||
from backend.apps.outputs.models import Output
|
||||
from backend.apps.swarm.entities import apps as appmod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stores(tmp_path, monkeypatch):
|
||||
outputs_dir = tmp_path / "outputs"
|
||||
ws_dir = tmp_path / "ws"
|
||||
ver_dir = tmp_path / "versions"
|
||||
for d in (outputs_dir, ws_dir, ver_dir):
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(workspace_io, "DATA_DIR", str(outputs_dir))
|
||||
monkeypatch.setattr(versions, "OUTPUTS_VERSIONS_DIR", str(ver_dir))
|
||||
monkeypatch.setattr(versions, "OUTPUTS_WORKSPACE_DIR", str(ws_dir))
|
||||
monkeypatch.setattr(appmod, "OUTPUTS_WORKSPACE_DIR", str(ws_dir))
|
||||
monkeypatch.setattr(appmod, "OUTPUTS_DIR", str(outputs_dir))
|
||||
return ws_dir
|
||||
|
||||
|
||||
def _flat_app(html="<h1>v1</h1>"):
|
||||
o = Output(name="Flat", files={"index.html": html})
|
||||
workspace_io._save(o)
|
||||
return o
|
||||
|
||||
|
||||
def _webapp(ws_dir, files):
|
||||
wsid = "wsid1"
|
||||
folder = os.path.join(str(ws_dir), wsid)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
for rel, content in files.items():
|
||||
p = os.path.join(folder, rel)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "w") as f:
|
||||
f.write(content)
|
||||
o = Output(name="Web", workspace_id=wsid)
|
||||
workspace_io._save(o)
|
||||
return o, folder
|
||||
|
||||
|
||||
def test_flat_capture_list_and_restore(stores):
|
||||
o = _flat_app("<h1>v1</h1>")
|
||||
v1 = versions.capture(o.id, source="manual", label="v1")
|
||||
assert v1 is not None
|
||||
|
||||
o.files = {"index.html": "<h1>v2</h1>"}
|
||||
workspace_io._save(o)
|
||||
versions.capture(o.id, source="auto", label="made v2")
|
||||
|
||||
assert [v.label for v in versions.list_versions(o.id)] == ["made v2", "v1"]
|
||||
|
||||
# diverge the live state WITHOUT capturing, so restore must back it up.
|
||||
o.files = {"index.html": "<h1>v3 uncaptured</h1>"}
|
||||
workspace_io._save(o)
|
||||
|
||||
restored = versions.restore(o.id, v1.id)
|
||||
assert restored.files["index.html"] == "<h1>v1</h1>"
|
||||
|
||||
after = versions.list_versions(o.id)
|
||||
pre = [v for v in after if v.source == "pre_restore"]
|
||||
assert len(pre) == 1
|
||||
meta = versions._read_manifest(o.id, pre[0].id)
|
||||
assert meta["app_meta"]["files"]["index.html"] == "<h1>v3 uncaptured</h1>"
|
||||
|
||||
|
||||
def test_dedupe_unchanged_state(stores):
|
||||
o = _flat_app()
|
||||
v1 = versions.capture(o.id, label="a")
|
||||
v2 = versions.capture(o.id, label="b") # nothing changed since v1
|
||||
assert v1.id == v2.id
|
||||
assert len(versions.list_versions(o.id)) == 1
|
||||
|
||||
|
||||
def test_restore_no_redundant_backup_when_current_already_saved(stores):
|
||||
o = _flat_app("<h1>v1</h1>")
|
||||
versions.capture(o.id, label="v1")
|
||||
o.files = {"index.html": "<h1>v2</h1>"}
|
||||
workspace_io._save(o)
|
||||
v2 = versions.capture(o.id, label="v2") # live state == latest version
|
||||
versions.restore(o.id, v2.id)
|
||||
# current already equalled the latest version, so no pre_restore junk.
|
||||
assert not any(v.source == "pre_restore" for v in versions.list_versions(o.id))
|
||||
|
||||
|
||||
def test_webapp_restore_preserves_env_and_removes_added_files(stores):
|
||||
o, folder = _webapp(stores, {"index.html": "<h1>v1</h1>", "src/app.js": "console.log(1)"})
|
||||
with open(os.path.join(folder, ".env"), "w") as f:
|
||||
f.write("SECRET=keepme\nFRONTEND_PORT=51000\n")
|
||||
|
||||
v1 = versions.capture(o.id, label="v1")
|
||||
|
||||
with open(os.path.join(folder, "index.html"), "w") as f:
|
||||
f.write("<h1>v2</h1>")
|
||||
with open(os.path.join(folder, "added.txt"), "w") as f:
|
||||
f.write("added later")
|
||||
versions.capture(o.id, label="v2")
|
||||
|
||||
versions.restore(o.id, v1.id)
|
||||
|
||||
with open(os.path.join(folder, "index.html")) as f:
|
||||
assert f.read() == "<h1>v1</h1>"
|
||||
assert not os.path.exists(os.path.join(folder, "added.txt"))
|
||||
with open(os.path.join(folder, ".env")) as f:
|
||||
assert "SECRET=keepme" in f.read() # .env never snapshotted, never wiped
|
||||
|
||||
|
||||
def test_branch_makes_independent_app(stores):
|
||||
o, _ = _webapp(stores, {"index.html": "<h1>v1</h1>"})
|
||||
v1 = versions.capture(o.id, label="v1")
|
||||
|
||||
new_id = versions.branch(o.id, v1.id)
|
||||
assert new_id and new_id != o.id
|
||||
|
||||
new_o = workspace_io.load_output(new_id)
|
||||
assert new_o is not None
|
||||
assert new_o.workspace_id and new_o.workspace_id != o.workspace_id
|
||||
assert new_o.name == "Web (copy)"
|
||||
assert new_o.session_id is None
|
||||
|
||||
new_folder = os.path.join(str(stores), new_o.workspace_id)
|
||||
with open(os.path.join(new_folder, "index.html")) as f:
|
||||
assert f.read() == "<h1>v1</h1>"
|
||||
|
||||
|
||||
def test_delete_all_removes_history(stores):
|
||||
o = _flat_app()
|
||||
versions.capture(o.id, label="v1")
|
||||
assert versions.list_versions(o.id)
|
||||
versions.delete_all(o.id)
|
||||
assert versions.list_versions(o.id) == []
|
||||
|
||||
|
||||
def test_restore_is_undoable(stores):
|
||||
"""Invariant: after any restore, the state you were on is recoverable (the
|
||||
pre_restore backup), so a wrong restore is never a dead end."""
|
||||
o = _flat_app("<h1>v1</h1>")
|
||||
v1 = versions.capture(o.id, label="v1")
|
||||
o.files = {"index.html": "<h1>v2</h1>"}
|
||||
workspace_io._save(o)
|
||||
versions.capture(o.id, label="v2")
|
||||
o.files = {"index.html": "<h1>v3 live</h1>"} # uncaptured live edit
|
||||
workspace_io._save(o)
|
||||
|
||||
versions.restore(o.id, v1.id)
|
||||
assert workspace_io.load_output(o.id).files["index.html"] == "<h1>v1</h1>"
|
||||
|
||||
backup = next(v for v in versions.list_versions(o.id) if v.source == "pre_restore")
|
||||
versions.restore(o.id, backup.id)
|
||||
assert workspace_io.load_output(o.id).files["index.html"] == "<h1>v3 live</h1>"
|
||||
|
||||
|
||||
def test_branch_does_not_mutate_source(stores):
|
||||
"""Invariant: branching, then editing the copy, never touches the original."""
|
||||
o, folder = _webapp(stores, {"index.html": "<h1>source</h1>"})
|
||||
v1 = versions.capture(o.id, label="v1")
|
||||
|
||||
new_id = versions.branch(o.id, v1.id)
|
||||
new_o = workspace_io.load_output(new_id)
|
||||
new_folder = os.path.join(str(stores), new_o.workspace_id)
|
||||
with open(os.path.join(new_folder, "index.html"), "w") as f:
|
||||
f.write("<h1>changed copy</h1>")
|
||||
|
||||
with open(os.path.join(folder, "index.html")) as f:
|
||||
assert f.read() == "<h1>source</h1>"
|
||||
assert workspace_io.load_output(o.id).name == "Web"
|
||||
|
||||
|
||||
def test_restore_clears_empty_schema(stores):
|
||||
"""Regression: an empty {} input_schema must actually restore as empty, not
|
||||
fall back to the current one (the falsy-dict bug)."""
|
||||
o = Output(name="S", files={"index.html": "x"}, input_schema={})
|
||||
workspace_io._save(o)
|
||||
v1 = versions.capture(o.id, label="empty schema")
|
||||
o.input_schema = {"type": "object", "properties": {"a": {}}, "required": []}
|
||||
workspace_io._save(o)
|
||||
versions.capture(o.id, label="full schema")
|
||||
|
||||
versions.restore(o.id, v1.id)
|
||||
assert workspace_io.load_output(o.id).input_schema == {}
|
||||
|
||||
|
||||
def test_unchanged_files_are_not_duplicated(stores):
|
||||
"""The whole efficiency point: changing 1 of 10 files across two versions
|
||||
adds 1 blob, not 10. Storage is O(unique content), not O(files x versions)."""
|
||||
o, folder = _webapp(stores, {f"f{i}.txt": f"content {i}" for i in range(10)})
|
||||
versions.capture(o.id, label="v1")
|
||||
with open(os.path.join(folder, "f0.txt"), "w") as f:
|
||||
f.write("changed")
|
||||
versions.capture(o.id, label="v2")
|
||||
|
||||
blobs = os.listdir(versions._blobs_dir(o.id))
|
||||
assert len(blobs) == 11 # 10 originals shared + 1 changed; NOT 20
|
||||
|
||||
|
||||
def test_capture_missing_app_returns_none(stores):
|
||||
assert versions.capture("does-not-exist") is None
|
||||
assert versions.restore("nope", "nope") is None
|
||||
assert versions.branch("nope", "nope") is None
|
||||
assert versions.list_versions("nope") == []
|
||||
@@ -7,6 +7,15 @@ from debugger_backend.color_adjuster import rgb_to_ansi, bold_and_italicize_text
|
||||
from debugger_backend.debug_arg_parser import is_text, is_error
|
||||
|
||||
def debug(*args, mode:str='debug', override_max_chars:bool=False):
|
||||
# Packaged/prod no-op: this frame-aware debugger is a dev tool, and its first
|
||||
# call instantiates Debugleton() -> a recursive os.scandir project scan that
|
||||
# runs synchronously on the backend's startup path. On a cold launch (uncached
|
||||
# filesystem) that scan cost ~17s of the backend-http-ready time; warm it is
|
||||
# ~80ms. Skipping it in the packaged build removes the cold cost entirely. Dev
|
||||
# (OPENSWARM_PACKAGED unset) keeps the full debugger. Safe: debug() returns
|
||||
# None and every caller ignores the return value.
|
||||
if os.environ.get("OPENSWARM_PACKAGED") == "1":
|
||||
return
|
||||
frame = inspect.currentframe().f_back
|
||||
code = frame.f_code
|
||||
line_no = frame.f_lineno
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
# winv2: Windows startup + App Builder speed and bug fixes
|
||||
|
||||
Branch: `eric/winv2`. Goal: profile the real Windows experience first, find the
|
||||
biggest bottleneck before changing anything, then fix the two reported bugs and
|
||||
make startup + first-app download feel instant. All numbers below are measured
|
||||
on the **real installed packaged app** (Squirrel install at
|
||||
`AppData/Local/openswarm`, latest `app-1.2.82`), Windows 11, not dev mode.
|
||||
|
||||
Notion tracking (Todos DB):
|
||||
- [Perf] Windows startup + download speed: backend cold-start is the bottleneck
|
||||
- [App Builder] Windows preview broken: no bundled bash/npm + missing node_modules archive
|
||||
- [Bug] Skills list empty until reboots + onboarding "Install a skill" step times out
|
||||
- [Reliability] Distributed-systems hardening (design)
|
||||
|
||||
## How these numbers were measured
|
||||
|
||||
Source of truth: the packaged app's own perf markers in
|
||||
`AppData/Roaming/openswarm/data/backend.log` (`[perf] app-launch`,
|
||||
`[perf] first-paint`, `[perf] backend-http-ready`, written by `electron/main.js`).
|
||||
These are wall-clock ms from process start, i.e. exactly what the user feels.
|
||||
Raw extract: `baseline_startup.csv`. Re-run with `profile_startup.sh`.
|
||||
|
||||
Import cost measured with the bundled interpreter:
|
||||
`python-env/python.exe -X importtime -c "import backend.main"`.
|
||||
|
||||
## Baseline (BEFORE any change)
|
||||
|
||||
### Startup, per launch (ms)
|
||||
|
||||
| metric | warm (typical) | cold (first run after each update) |
|
||||
| --- | --- | --- |
|
||||
| app-launch (electron ready) | 107-400 | 107-563 |
|
||||
| first-paint (renderer) | 338-1205 | ~1200 |
|
||||
| **backend-http-ready** | **8700-10500** | **54600 / 81000 / 86300 / 133000 / 138300** |
|
||||
|
||||
Electron shell paints in well under 1.5s every time. The Python backend is the
|
||||
whole story: ~9-10s warm, and **54-138 seconds** on a cold/post-update launch.
|
||||
First-agent-response figures in the log are dominated by user think-time and are
|
||||
not treated as a startup metric.
|
||||
|
||||
### Why the backend is slow (evidence)
|
||||
|
||||
| factor | measurement | effect |
|
||||
| --- | --- | --- |
|
||||
| python-env file count | 13,554 files (4,510 .py/.pyd/.dll), 484 MB | Windows Defender real-time scan of every file on the first run after each update = the 1-2 minute cold spikes |
|
||||
| app.asar size | 639 MB | cold disk read on first launch |
|
||||
| backend.main import tree | ~2.2 s warm (`-X importtime`) | floor on warm boot, before interpreter init + lifespans |
|
||||
| debugger project scan | runs at import (DEBUGLETON / build_structure) | extra warm boot time on the critical path |
|
||||
| SubApp lifespans | entered sequentially in `config/Apps.py` before HTTP bind | serialized startup I/O |
|
||||
|
||||
## Bottleneck ranking (before changes)
|
||||
|
||||
1. **Python backend cold-start (dominant).** 9-10s warm, 54-138s cold. ~95% of
|
||||
perceived startup. Cold case driven by Defender scanning 13.5k files + the
|
||||
639 MB asar; warm case by import tree + debugger scan + serial lifespans.
|
||||
2. **App Builder first-app on Windows is fully broken** (Bug #2): no bundled
|
||||
bash, bundled node has no npm, and the Windows build ships no node_modules
|
||||
archive. Confirmed against the installed binary. Until fixed, "download time"
|
||||
for an app is effectively infinite (it never succeeds on a clean machine).
|
||||
3. **Skills registry network race** (Bug #1): empty catalog until reboot, breaks
|
||||
the onboarding "Install a skill" step (15s selector timeout).
|
||||
|
||||
## Plan (status tracked here + on Notion)
|
||||
|
||||
- [~] Bug #2 App Builder: **junction/copy link fallback DONE + tested**; archive in Windows build + direct vite spawn (no bash) TODO
|
||||
- [~] Bug #1 Skills: **bundled snapshot + disk cache + retry-until-success DONE + tested** (catalog never empty offline, onboarding pdf selector resolves); frontend loading-vs-empty retry TODO
|
||||
- [ ] Perf: trim Defender surface, lazy imports, non-blocking lifespans, move debugger scan off boot, App Builder warm pool
|
||||
- [ ] Re-measure, before/after tables + graphs
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-16 baseline measured (this doc), graphs generated, Notion todos opened.
|
||||
- 2026-06-16 Bug #1 backend: `skill_registry.py` now seeds from bundled `skills_snapshot.json` + on-disk last-good cache and retries until first success. Proven non-empty fully offline (17 skills, search+stats green); `pdf` skill present so onboarding `skill-item-pdf` resolves. Regression test `backend/tests/test_skill_registry_seed.py` (3 cases green).
|
||||
- 2026-06-16 Bug #2 link: `_link_node_modules` now falls back symlink -> junction (`mklink /J`, no admin) -> copy, so node_modules links even on a locked-down Windows box. Tested with forced symlink failure.
|
||||
|
||||
## Results (AFTER)
|
||||
|
||||
### The warm-startup bottleneck was found and fixed
|
||||
|
||||
Per-SubApp-lifespan profiling (`profile_boot.py`) showed the entire ~8s gap was
|
||||
**one lifespan**:
|
||||
|
||||
| boot phase | before | after | note |
|
||||
| --- | --- | --- | --- |
|
||||
| import backend.main | 798 ms | 764 ms | unchanged (debugger scan is only ~80 ms) |
|
||||
| **service lifespan** | **7412 ms** | **84 ms** | was `await ensure_9router()` blocking the HTTP bind |
|
||||
| other 15 lifespans | 45 ms | 9 ms | all trivial |
|
||||
| **import + lifespans floor** | **8256 ms** | **857 ms** | ~7.4 s removed (~90%) |
|
||||
|
||||
Fix: `service.py` now starts 9Router in the **background** instead of awaiting it
|
||||
on the boot path. 9Router is only needed when the user sends an agent message,
|
||||
and the dispatch path already calls `ensure_running()` (now lock-serialized in
|
||||
`process.py` so the background start and a dispatch-time ensure can't
|
||||
double-spawn). Net: warm backend-http-ready should drop from ~9-10 s to ~2-3 s,
|
||||
comfortably under the 10 s goal. See `boot_breakdown.svg`.
|
||||
|
||||
### Still open (cold start)
|
||||
|
||||
The 54-138 s cold spikes are Windows Defender scanning the 13,554-file / 484 MB
|
||||
python-env on the first run after each update, plus cold-reading the 639 MB
|
||||
asar. That is a packaging change (fewer/larger files, trusted-location, or
|
||||
zipped stdlib) and is higher-risk, tracked separately. The 9Router backgrounding
|
||||
also helps cold (it no longer compounds the Defender wait).
|
||||
|
||||
### App Builder first-app "download" + create path (measured)
|
||||
|
||||
Per-phase, measured on this Windows box (`measure_appbuilder.py` + `measure_vite.py`),
|
||||
isolated temp dirs, real warm caches. See `appbuilder_breakdown.svg`.
|
||||
|
||||
| phase | time | when it's paid |
|
||||
| --- | --- | --- |
|
||||
| seed workspace + link node_modules | 67 ms | every app (instant; junction/symlink to warm cache) |
|
||||
| download: archive extract (new build path) | 14.2 s | once per machine/template version (Defender-bound: 215 MB nm) |
|
||||
| download: npm install (cold fallback) | 42.7 s | once, only if no archive ships |
|
||||
| vite bind: cold vite cache | 6.7 s | first app ever (esbuild pre-bundle) |
|
||||
| vite bind: warm shared cache | 0.7 s | every subsequent app |
|
||||
| build-time: tar nm -> archive | 6.8 s | on CI, never on the user's machine |
|
||||
|
||||
**User-facing scenarios (create app -> live preview):**
|
||||
|
||||
| scenario | total | notes |
|
||||
| --- | --- | --- |
|
||||
| first app, clean Windows, BEFORE fix | never works | `[WinError 2]` / "backend exited with code 1" (no bash/npm/archive) |
|
||||
| first app, AFTER fix (tar archive) | ~21 s one-time | extract 14.2 + seed 0.07 + vite cold 6.7; and it actually works |
|
||||
| **first app, AFTER fix (.tar.gz + background extract at startup)** | **~7 s typical / ~21 s worst case** | extract runs in the background at startup; if done before first create -> seed 0.07 + vite cold 6.7 ~= 7s, else +14.2s |
|
||||
| first app, if we shipped npm instead | ~49 s | 42.7 + 6.7; the archive saves ~28 s and needs no npm |
|
||||
| every subsequent app | ~0.8 s | seed 0.07 + vite warm 0.7 (near-instant) |
|
||||
|
||||
#9 item 2 (DONE): the Windows build now ships node_modules ALREADY EXTRACTED in
|
||||
resources (digest-tagged); `_ensure_warm_cache` junctions a workspace straight at
|
||||
it (`_bundled_extracted_modules`), so there is no tar-extract on first app -- the
|
||||
14.2 s Defender-scanned write cost moves to install time, once. Verified by
|
||||
`backend/tests/test_bundled_extracted_modules.py` (selection + Mac fallback) and
|
||||
the build step `build-app-win.ps1` 4b now robocopies the tree into resources.
|
||||
|
||||
Takeaways: the archive (Bug #2 fix) turns a broken/∞ first-app into a working
|
||||
~21s one-time, and ~0.8s for every app after. The remaining ~14s extract is the
|
||||
SAME Defender-on-many-small-files cost as cold app-startup (Task #9) -- the one
|
||||
lever that would shrink both.
|
||||
|
||||
### Task #10 — VERIFIED on the real code-signed build (v1.3.86)
|
||||
|
||||
Downloaded the signed draft-release installer, verified signature, installed, and
|
||||
measured on this Windows 11 box. All numbers are from the packaged app, not dev.
|
||||
(Shipped as v1.3.86 on the fixed code; the earlier v1.3.87 build was identical
|
||||
bits and is abandoned. Numbers below are the v1.3.86 run; v1.3.87 matched.)
|
||||
|
||||
| metric | baseline (1.2.x) | signed v1.3.86 | result |
|
||||
| --- | --- | --- | --- |
|
||||
| installer download | n/a | 371.5 MB @ 27.1 MB/s (13.7 s) | signed: Authenticode **Valid** (CN=Eric Zeng) |
|
||||
| install time | n/a | ~6.3 s | Squirrel |
|
||||
| **cold backend-http-ready** | **54-138 s** | **22.6 s** | **~75-84% faster** |
|
||||
| **warm backend-http-ready** | **9-10 s** | **5.0 s** | **~50% faster, under the 10s goal** |
|
||||
| app.asar size | ~607 MB | **2.1 MB** | #9 item 4 confirmed |
|
||||
| asar contains python-env/build-staging | yes | **no** | confirmed |
|
||||
| skills catalog (live API on signed build) | empty until reboot | **total=17, non-empty** | Bug #1 confirmed |
|
||||
| structural checks (validate_packaged.ps1) | 4 fail | **5/5 PASS** | snapshot + node tar + unpacked python-env |
|
||||
|
||||
Cold is 22.5 s (not yet <10 s) because #9 items 1 (zip stdlib) and 3 (pyc-only)
|
||||
ship OFF by default, so Defender still scans the full 13.5k-file python-env on the
|
||||
first post-update launch. Enabling those (next, build-gated) is the remaining cold
|
||||
lever. Bug #2 (App Builder) is verified structurally (node_modules .tar.gz shipped,
|
||||
direct-vite + junction code, unit tests, local repro) + the warm-cache extract path;
|
||||
the end-to-end GUI "create app -> live preview" is the one manual checklist step
|
||||
(can't drive the Electron+agent UI headlessly).
|
||||
|
||||
## [DISPROVEN 2026-06-17] hypothesis: residual ~17s cold = swarm-debug DEBUGLETON scan
|
||||
|
||||
> UPDATE: this hypothesis was WRONG. The `debug()` -> `OPENSWARM_PACKAGED=1` no-op
|
||||
> shipped (commit 3d6fe483) and was verified live on the signed build ("Scanning
|
||||
> Project" count=0, scan confirmed gone), yet **cold backend-http-ready stayed at
|
||||
> 21.5s (no change)**. So the DEBUGLETON scan was NOT the cold driver. Kept the
|
||||
> no-op anyway (it removes a real warm cost and is harmless), but the cold 16s is
|
||||
> elsewhere. See the source-audit section below for what it actually is. The
|
||||
> original (now-disproven) reasoning is preserved below for the record.
|
||||
|
||||
### original (disproven) reasoning
|
||||
|
||||
No-coding investigation (import profile + the app's own timestamped logs) pinned it:
|
||||
- The cold launch has a ~17s SILENT, synchronous event-loop block during startup
|
||||
(no async task ran). NOT import (1.1s), NOT Defender (proven twice), NOT
|
||||
file-count, NOT network (the updater succeeded in the window), and every SubApp
|
||||
lifespan is verified trivial (mkdir / early-return / yield).
|
||||
- It is the swarm-debug DEBUGLETON: debug() -> Debugleton().find_file_info()
|
||||
(debug.py:20); the first call instantiates the singleton -> update_debug_toggles()
|
||||
-> Directory.build_structure() -> a recursive os.scandir() walk of the project
|
||||
tree (Directory.py:74). debug() is called on the startup critical path
|
||||
(config/Apps.py SubApp init + the lifespan loop), so the scan runs SYNCHRONOUSLY
|
||||
and blocks the HTTP bind. Cold (uncached fs) = ~17s; warm (cached) = ~80ms. The
|
||||
DEBUGLETON INIT log lines land exactly in the 17s gap.
|
||||
- This also explains why items 1+3 (file count) and item 5 (Defender) did nothing:
|
||||
the cost is a synchronous scandir tree-walk, not AV scanning or bytecode.
|
||||
|
||||
SAFE FIX (proposed): make debug() a no-op when OPENSWARM_PACKAGED=1 (early-return
|
||||
before Debugleton() instantiates), so the scan never runs in the packaged build.
|
||||
Dev keeps the debugger. Risk very low (debug() is non-critical logging that already
|
||||
swallows errors). Expected cold ~22s -> ~5s (under the 10s goal). Confirm with a
|
||||
cold rebuild+measure.
|
||||
|
||||
## #9 item 5 (Defender exclusion) measured: ALSO no cold benefit -> cold is NOT Defender
|
||||
|
||||
Applied the Defender exclusion (admin) for all 3 openswarm folders, rebuilt a
|
||||
fresh-content lean v1.3.86 (so Defender would see new files), installed with the
|
||||
exclusion active, measured cold:
|
||||
|
||||
| | cold backend-http-ready |
|
||||
| --- | --- |
|
||||
| no exclusion (items off) | 22.5 s |
|
||||
| no exclusion (items 1+3 on) | 22.4 s |
|
||||
| **Defender exclusion ON** | **21.4 s (no change)** |
|
||||
|
||||
Conclusion: TWO independent Defender-targeting interventions (file-count via
|
||||
items 1+3, and a full AV exclusion) both moved cold by ~0. So the residual ~22 s
|
||||
cold is NOT Defender real-time scanning. It is the first-launch-after-install cost
|
||||
-- cold disk I/O of the imported native binaries + bundled-Python interpreter init
|
||||
+ Squirrel first-run -- which neither AV-exclusion nor file-count tricks touch.
|
||||
(Caveat: my non-admin shell can't read Get-MpPreference to re-confirm the
|
||||
exclusion is live, but the result is consistent with the items-1+3 negative.)
|
||||
|
||||
ACTION: remove the exclusion -- it weakened AV for zero gain:
|
||||
`& scripts\add-defender-exclusion.ps1 -Remove` (elevated).
|
||||
|
||||
The cold win was already banked by the asar trim (54-138 s -> ~22 s). Pushing
|
||||
cold below ~22 s would need shrinking the startup-imported bytes (lazy-load heavy
|
||||
native deps like lxml/PIL, or trim the 242 MB bundled claude.exe) or faster disk
|
||||
-- bigger/riskier work with diminishing returns. Warm (5 s) is already under goal.
|
||||
|
||||
## #9 items 1+3 measured on the signed build: NO cold-start benefit (negative result)
|
||||
|
||||
Built v1.3.86 with items 1+3 ON (python-env 13,554 -> 9,285 files, ~31% fewer) and
|
||||
measured the signed install:
|
||||
|
||||
| metric | items OFF (1.3.86) | items ON (1.3.86) |
|
||||
| --- | --- | --- |
|
||||
| cold backend-http-ready | 22.5 s | **22.4 s (no change)** |
|
||||
| warm backend-http-ready | 5.0 s | 5.2 s (noise) |
|
||||
| installer | 372 MB | 365 MB (~7 MB smaller) |
|
||||
|
||||
**The hypothesis was wrong.** Cutting the file COUNT 31% did nothing for cold,
|
||||
because cold is dominated by Defender scanning the large NATIVE binaries imported
|
||||
/ present at boot, not the many small .py files. The biggest are
|
||||
`claude.exe` (242 MB!), `_rust.pyd` (9.4), `_avif...pyd` (7.5), `python313.dll`
|
||||
(5.8), `libcrypto-3-x64.dll` (5.7), `mfc140u.dll` (5.4) -- none of which items 1+3
|
||||
touch (zip/pyc only affect pure-python). So items 1+3 are a wash for cold (a tiny
|
||||
installer-size win + import-clean, but not the goal).
|
||||
|
||||
The real remaining cold levers are byte/native-bound, not file-count:
|
||||
- **#9 item 5 (Defender exclusion, opt-in)** -- the only thing that removes the
|
||||
native-binary scan entirely; would bring cold toward the ~5 s warm number.
|
||||
- Trim/lazy the heavy native deps (e.g., the 242 MB bundled claude.exe, PIL/lxml)
|
||||
-- larger, riskier code/packaging work.
|
||||
- Or accept cold 22.5 s: already 75-84% below the 54-138 s baseline, and warm 5 s
|
||||
is already under the 10 s goal.
|
||||
|
||||
Recommendation: items 1+3 don't earn their build-time/complexity for cold; keep
|
||||
them only for the marginal installer-size win, or revert step 2b to keep the build
|
||||
lean. The meaningful cold work is item 5.
|
||||
|
||||
## Net time decreased per step (measured)
|
||||
|
||||
| step | before | after | saved |
|
||||
| --- | --- | --- | --- |
|
||||
| backend boot: service lifespan | 7412 ms | 84 ms | -7328 ms (-99%) |
|
||||
| backend boot: import + all lifespans floor | 8256 ms | 857 ms | -7399 ms (-90%) |
|
||||
| backend-http-ready warm (end-to-end) | ~9-10 s | ~2-3 s (projected) | ~-7 s |
|
||||
| App Builder dependency download | 42.7 s npm | 14.2 s archive | -28.5 s (-67%) |
|
||||
| App Builder first app -> preview | broken/never | ~21 s working | inf -> 21 s |
|
||||
| App Builder subsequent app -> preview | n/a | ~0.8 s | near-instant |
|
||||
| skills catalog availability | empty until reboot(s) | instant (seeded) | bug eliminated |
|
||||
|
||||
## #9 packaging approach: shrink the Defender file surface (build-gated)
|
||||
|
||||
Defender real-time-scans every small file: python-env = 13,554 files; node_modules
|
||||
= ~tens of thousands; app.asar = 639 MB. It rescans python-env on the first launch
|
||||
after each update (54-138 s cold spikes) and scans node_modules as it is written
|
||||
(the 14.2 s extract). Fix family: fewer/larger files, scan-once-at-install instead
|
||||
of per-launch / per-first-app. Each item is independent, reversible, and must be
|
||||
validated on a real packaged EXE (Task #10).
|
||||
|
||||
1. [ENABLED + validated] Zip the Python stdlib -> python313.zip. scripts/zip-python-stdlib.ps1, now wired into build-app-win.ps1 step 2b. VALIDATED 2026-06-17: applied to a copy of the real shipped python-env and `import backend.main` (full app + deps) imported cleanly; combined with #3 the python-env drops 13,554 -> 9,287 files (~31%). Draft notes: Measured on the real env: 910 stdlib .py/.pyc files (15.1 MB) collapse into one zip. CPython auto-adds <prefix>/python313.zip to sys.path, so no python._pth is needed; site-packages + DLLs (native .pyd) stay loose; a keep-list keeps data-file stdlib dirs (lib2to3, idlelib, tkinter, ...) loose. Impact: ~7% of total python-env file count, but it collapses the stdlib import-time file-opens (the cold-launch Defender scan storm) into a single scanned file; bigger combined with #3. Validation (Task #10): -Apply on a copy, then import backend.main, importtime parity, boot the packaged backend, measure cold backend-http-ready vs baseline. Wire into build-app-win.ps1 behind an off-by-default -ZipStdlib switch only after it passes.
|
||||
2. [DONE] Ship the webapp_template node_modules archive in the Windows build (build-app-win.ps1 step 4b builds node_modules.<digest>.tar.gz, mirroring the Mac build). Runtime _try_extract_bundled_archive unpacks it into the warm cache, kicked off in the BACKGROUND by warm_cache_in_background at startup so it is off the first-app create path. CORRECTION 2026-06-17: an earlier draft shipped node_modules PRE-EXTRACTED in resources (~30k files) -- that blew the Windows build past 50 min (Squirrel LZMA on tens of thousands of tiny files) and bloated the installer, so it was reverted to the single .tar.gz. The runtime keeps _bundled_extracted_modules() as a harmless preference (returns None when no tree is shipped -> falls back to the tar). Tests: test_bundled_extracted_modules.py still valid (selection + fallback).
|
||||
3. [ENABLED + validated] Ship site-packages as sourceless .pyc only (drop .py). scripts/strip-py-to-pyc.ps1, now wired into build-app-win.ps1 step 2b. VALIDATED 2026-06-17 alongside #1 (backend.main imports clean from a transformed copy; 3,352 .py removed). Draft notes: Measured: 3,352 .py (26.9 MB) + 362 __pycache__ dirs strippable from site-packages (keep-list excludes pip/setuptools). compileall -b writes legacy module.pyc next to source; we delete the .py whose .pyc exists and drop __pycache__. Sourceless import proven with the bundled 3.13 interpreter. Scope: site-packages ONLY (NOT backend app code -- the swarm-debug debugger reads our own source for frame annotation). .pyc magic must match the shipped interpreter, so compile with the bundled python. Validate on a packaged EXE (Task #10); some packages use inspect.getsource and may need the keep-list. Combined with #1 + #2 this takes python-env from ~13,554 files toward ~9,300 (~31% fewer for Defender).
|
||||
4. [APPLIED, build-gated] Trim app.asar. Inventory (docs/perf/winv2/inspect_asar.js) found the 607 MB asar is almost entirely DUPLICATION: python-env (408 MB, incl. a 242 MB bundled claude.exe) and build-staging (197 MB: node.exe 67 MB, uv.exe 65 MB, mcp-bundles, frontend) are packed into the asar AND already shipped UNPACKED in resources/ via extraResources. The runtime reads from resources/ (confirmed: "Starting backend: ...resources\python-env\python.exe"), never from inside the asar. Source maps were a red herring (0.4 MB). Fix: added a build.files exclusion in electron/package.json ("!python-env/**", "!build-staging/**") so those trees no longer pack into the asar -> ~607 MB -> ~2 MB (just main.js/preload/node_modules). Removes the entire 639 MB cold-read on first launch. Validate on a packaged EXE (Task #10): app still boots (python/node/router resolved from resources), asar size shrunk.
|
||||
5. [DRAFTED, opt-in] Defender exclusion for OpenSwarm's dirs -- the nuclear cold-start fix (stops real-time scanning entirely, so it kills BOTH the 54-138s post-update launch and the ~14s extract). Draft: scripts/add-defender-exclusion.ps1 (dry-run by default; -Apply/-Remove need admin; -Status lists). Excludes %LOCALAPPDATA%\openswarm, %APPDATA%\openswarm, ~/.openswarm (verified the paths resolve). SECURITY: reduces AV coverage of those folders, so it must ALWAYS be an explicit user choice -- never auto-run, never a startup prompt. Proposed surface: an OFF-by-default Settings > Advanced toggle ("Faster Windows startup -- adds a Defender exclusion for OpenSwarm; one-time admin approval; reversible"), which on enable spawns an elevated `powershell Start-Process -Verb RunAs` to run the script -Apply (UAC), and -Remove on disable. This is a passive opt-in toggle, NOT a banner/tip/prompt, so it respects the no-user-action-UI rule. Not wired into the frontend yet (design only).
|
||||
|
||||
Recommended order: #2 (biggest UX win, lowest risk), then #1 (largest cold win, careful import testing), then #3/#4. Validation: re-run profile_startup.sh + a fresh-extract timing on the packaged EXE after each change, diff vs baseline_startup.csv.
|
||||
|
||||
### Bug fixes (this branch)
|
||||
|
||||
- Bug #1 skills: seed from bundled snapshot + disk cache + retry-until-success. Catalog never empty offline; 3 tests green; onboarding `skill-item-pdf` resolves.
|
||||
- Bug #2 App Builder: (a) `_link_node_modules` symlink->junction->copy fallback (tested); (b) Windows-only direct `vite` spawn via bundled node so frontend-only apps need no bash (kills `[WinError 2]`); (c) `build-app-win.ps1` now pre-builds the node_modules archive natively. Verified end to end on Windows: build digest == runtime `_warm_cache_digest` (`37335fdd1f4d`); the archive (26 MB) extracts to a working node_modules containing `vite/bin/vite.js` and the Windows-native `@esbuild/win32-x64/esbuild.exe`.
|
||||
|
||||
## Residual cold ~16s: full source audit + boot instrumentation (2026-06-17)
|
||||
|
||||
After FOUR disproven cold hypotheses (file-count via items 1+3, Defender exclusion,
|
||||
DEBUGLETON scan, and the asar trim which DID bank 138s->22s), I stopped guessing and
|
||||
read the real signed-build cold log line by line, then audited every lifespan in source.
|
||||
|
||||
What the cold log (commit 3d6fe483, scan-free build) actually shows:
|
||||
|
||||
```
|
||||
03:03:02 skill_registry: seeded 17 skills <- last backend log before the gap
|
||||
... 16 seconds, NO backend log line ...
|
||||
03:03:18 nine_router: Starting 9Router <- a backgrounded create_task finally runs
|
||||
03:03:18 Application startup complete <- uvicorn; all lifespans entered
|
||||
03:03:21 9Router started; GET /api/health 200; backend-http-ready t=21519
|
||||
```
|
||||
|
||||
SubApp lifespan order (`backend/main.py:52`):
|
||||
`health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry,
|
||||
outputs, dashboards, swarm, service, subscription, auth, web, anthropic_proxy`.
|
||||
|
||||
Source-audited EVERY one of the 16 lifespan bodies + the service client:
|
||||
- outputs: two `os.makedirs` + yield (trivial)
|
||||
- dashboards: `_migrate_if_needed()` early-returns when dashboards exist (they persist
|
||||
across reinstall in AppData, so it's a no-op on the cold post-update launch)
|
||||
- swarm: `_gc_staging()` over an empty dict + yield (trivial)
|
||||
- service: builds a provider list + `svc.sync()` x2, then `create_task(ensure_9router)`,
|
||||
`create_task(_pulse_loop)`, `create_task(_drain_loop)`, yield. `svc.sync()` is genuinely
|
||||
fire-and-forget: `client.py:sync()` -> `_schedule()` -> `loop.create_task(_post_or_spool)`;
|
||||
the actual httpx POST has a 5s timeout and runs in the task, never on the boot path.
|
||||
- skill_registry: seed from disk + `create_task(_refresh_loop)` + yield (trivial)
|
||||
- subscription / auth / anthropic_proxy: bare `yield`
|
||||
- web: `debug("START")` (no-op packaged) + yield
|
||||
|
||||
So NO lifespan body blocks. This matches `profile_boot.py` warm (import + all 16
|
||||
lifespans = 617ms, no lifespan over 95ms). The 16s is therefore NOT in our Python
|
||||
startup logic; it is cold first-run demand-paging of native bytes (interpreter .pyc
|
||||
cold reads, native .pyd / .dll first-touch, the 9Router/claude.exe binaries) that
|
||||
the OS pages in during this window. That class of cost is exactly what the asar
|
||||
trim already cut and what Defender-exclusion / file-count provably cannot move.
|
||||
|
||||
THE missing instrument: `debug(sub_app.name)` (Apps.py:33) is a no-op in the
|
||||
packaged build, so the packaged log had ZERO per-lifespan markers, which is why
|
||||
four hypotheses were guesses. Added permanent per-lifespan boot timing in
|
||||
`backend/config/Apps.py` (one `time.perf_counter()` + flushed `print` per app, plus
|
||||
a `lifespans-total`): `[perf] lifespan <name> t=<ms>ms`. Logging only, zero
|
||||
functional risk; validated warm (correctly attributes a simulated 300ms blocker to
|
||||
the one slow lifespan, others 0ms). On the NEXT cold packaged launch this pins the
|
||||
16s to a single lifespan (=> a real fix) or shows it smeared across many (=> confirms
|
||||
distributed cold I/O => accept 22s; warm 5s already meets the <10s goal).
|
||||
|
||||
Status: warm 5.0s (under goal), cold ~22s (75-84% below the 54-138s baseline), both
|
||||
bugs fixed/verified on the signed build. The cold residual is either accepted as
|
||||
first-run-only OS I/O, or pinned definitively by one more build that ships this
|
||||
instrumentation. Build-gated (user manages tags/release), so not auto-built.
|
||||
|
||||
## [SOLVED 2026-06-18] cold ~22s -> 3.86s: synchronous is_running() froze the event loop
|
||||
|
||||
The per-lifespan instrumentation (v1.3.88) overturned every prior hypothesis: all
|
||||
16 lifespans enter in ~120ms even COLD. The ~18s cold cost was entirely AFTER
|
||||
lifespan startup, in a backgrounded create_task that synchronously blocked the
|
||||
single asyncio event loop, so uvicorn could not answer the health probe.
|
||||
|
||||
Finer instrumentation (v1.3.89) split it into two stalls (~13s before any bg task,
|
||||
~5s in 9Router ensure). faulthandler (`dump_traceback_later`, v1.3.90) on the
|
||||
signed cold build caught the loop thread frozen, three times, in the SAME call:
|
||||
|
||||
```
|
||||
socket.create_connection <- stuck >7s
|
||||
httpx ... get
|
||||
backend/apps/nine_router/process.py:83 is_running() <- synchronous httpx.get
|
||||
<- sync_openswarm_pro_as_claude / sync_custom_providers (settings._boot_router_then_sync)
|
||||
<- _ensure_running_impl (ensure_running)
|
||||
```
|
||||
|
||||
ROOT CAUSE: `is_running()` did a synchronous `httpx.get("http://localhost:20128/...")`.
|
||||
It is called ~5x on the cold boot path (the settings key-sync sequence + the
|
||||
9Router ensure) BEFORE 9Router is up. On Windows a dead-port connect to
|
||||
"localhost" stalls ~7s each: getaddrinfo returns `::1` first, and the loopback
|
||||
refusal is slow (measured: a refused connect is ~2s/address, and localhost =
|
||||
`::1`+`127.0.0.1` = ~4s; cold ~7s). ~5 serial probes = the ~18s freeze.
|
||||
|
||||
This is why every earlier hypothesis missed: it is not disk, not Defender, not
|
||||
file-count, not the DEBUGLETON scan, not imports, not the lifespans. It is one
|
||||
synchronous network probe on the event loop, repeated.
|
||||
|
||||
FIX (v1.3.91, `process.py` is_running): probe `127.0.0.1` with a 0.3s TCP timeout
|
||||
first (a short timeout caps the slow Windows refusal: measured 306ms vs ~7s); only
|
||||
HTTP-confirm when the port is open. 9Router binds `0.0.0.0` (the warm app reaches
|
||||
it via `127.0.0.1`), so reachability is unchanged, only the dead-port wait dies.
|
||||
|
||||
VERIFIED on the real signed build (this Windows 11 box, fresh Squirrel install):
|
||||
|
||||
| metric | baseline | before fix (1.3.90) | after fix (1.3.91) |
|
||||
| --- | --- | --- | --- |
|
||||
| cold backend-http-ready | 54-138s | 23.5s | **3.86s** |
|
||||
| warm backend-http-ready | 9-10s | 5.0s | **3.32s** |
|
||||
|
||||
Cold is now ~97% below baseline and well under the 10s goal; warm improved too
|
||||
(the same localhost stall taxed it). 9Router still starts successfully via the new
|
||||
probe (no regression). The diagnostic `[perf] bg` logs + faulthandler were removed
|
||||
after diagnosis; the lightweight per-lifespan timer stays (prints only a lifespan
|
||||
over 50ms + the total) as a cheap regression tripwire.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Task #10 — validate the winv2 changes on the REAL signed Windows build
|
||||
|
||||
Do not call winv2 "done" until a **code-signed, downloaded, installed, launched**
|
||||
build passes this. Unit tests + dry-runs are necessary but not sufficient — the
|
||||
build-script changes (asar exclusion, node_modules pre-extract) and the cold-start
|
||||
wins only exist in a packaged EXE.
|
||||
|
||||
## 0. Produce the signed build
|
||||
- Tag the winv2 HEAD and push: `git tag v1.3.86 && git push origin v1.3.86`.
|
||||
- This runs `.github/workflows/release-windows.yml` (Azure code-signing) and creates
|
||||
a **draft** GitHub release (drafts do NOT auto-update existing users).
|
||||
- Watch it: `gh run watch` / `gh run list --workflow=release-windows.yml`.
|
||||
- If `build-app-win.ps1` errors on the new step 4b (pre-extract) or the asar
|
||||
`files` exclusion, fix and re-tag (delete the draft + tag first; never force-push
|
||||
an existing release tag).
|
||||
|
||||
## 1. Download + verify the signature (must be real signed bits)
|
||||
- `gh release download v1.3.86 --pattern "*Setup*.exe" --dir .` (or from the draft release page).
|
||||
- Verify Authenticode: `Get-AuthenticodeSignature .\OpenSwarm-Setup-x64.exe` → Status must be `Valid`, signer = the Azure Trusted Signing cert. NOT "NotSigned"/"UnknownError".
|
||||
|
||||
## 2. Install + first (COLD) launch — the headline metric
|
||||
- Install the downloaded EXE (Squirrel → `%LOCALAPPDATA%\openswarm`).
|
||||
- Launch once and let it fully load. This is the COLD launch (Defender scans fresh files).
|
||||
- Then run the automated checker: `pwsh docs/perf/winv2/validate_packaged.ps1`.
|
||||
- Acceptance (perf): cold `backend-http-ready` should be **far below the 54-138s baseline**
|
||||
(target: well under the 10s goal even cold, given the 639MB asar read is gone +
|
||||
fewer files to scan). Relaunch once for the warm number (target ~2-3s).
|
||||
|
||||
## 3. Automated structural checks (validate_packaged.ps1 must be all PASS)
|
||||
- app.asar < 50 MB (was ~607 MB) — #9 item 4.
|
||||
- app.asar does NOT contain python-env / build-staging — #9 item 4.
|
||||
- `resources/python-env/python.exe` present (still shipped unpacked).
|
||||
- `resources/node/x64/node.exe` present.
|
||||
- `resources/backend/apps/skill_registry/skills_snapshot.json` present — Bug #1.
|
||||
- webapp_template_cache has a pre-extracted `<digest>/node_modules/vite/bin/vite.js`
|
||||
(#9 item 2) — or a `.tar.gz` fallback.
|
||||
|
||||
## 4. Manual GUI checks (can't be automated)
|
||||
- **Skills (Bug #1):** open the Skills page on a fresh launch → the catalog shows
|
||||
immediately (NOT empty). Run onboarding step 6/8 "Install a skill" → it finds the
|
||||
pdf skill (no `waitForSelector "skill-item-pdf" 15000ms` timeout).
|
||||
- **App Builder (Bug #2):** create an app → the preview goes LIVE. No `[WinError 2]`,
|
||||
no "backend exited with code 1". First app should be quick (pre-extracted nm + vite).
|
||||
Bonus: test on a machine WITHOUT Git Bash to confirm the no-bash vite path.
|
||||
- Sanity: send an agent message (9Router now starts in the background → first message
|
||||
may wait a moment for it; confirm it still answers).
|
||||
|
||||
## 5. Optional cold-start levers (only after 1-4 pass)
|
||||
- #9 item 1 (`zip-python-stdlib.ps1 -Apply`) and item 3 (`strip-py-to-pyc.ps1 -Apply`)
|
||||
on a build copy, then re-run 1-4 + re-measure. Enable in the build only if green.
|
||||
- #9 item 5 (`add-defender-exclusion.ps1`) is a user opt-in, validate separately.
|
||||
|
||||
## 6. Sign-off
|
||||
- All of 1-4 green on the signed build → publish the draft release (un-draft) to ship 1.3.86.
|
||||
- Record the real cold/warm numbers in `boot_breakdown.csv` / README "Results (AFTER)".
|
||||
@@ -0,0 +1,7 @@
|
||||
phase,ms,note
|
||||
"seed workspace + link node_modules (per app)",67,"nm linked, instant"
|
||||
"download: archive extract (new build path, one-time)",14204,"215MB nm, defender-bound"
|
||||
"download: npm install (cold fallback, one-time)",42684,"ok"
|
||||
"vite bind: cold vite cache (first app)",6714,"bound"
|
||||
"vite bind: warm shared cache (subsequent)",672,"bound"
|
||||
"build-time: tar node_modules to archive (CI, not user)",6809,"26MB archive"
|
||||
|
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="350" font-family="Segoe UI, sans-serif">
|
||||
<text x="20" y="30" font-size="17" font-weight="600" fill="#1a1d27">App Builder "create app -> live preview" breakdown (ms)</text>
|
||||
<text x="310" y="76" font-size="12" fill="#1a1d27" text-anchor="end">seed workspace + link node_modules (per app)</text>
|
||||
<rect x="320" y="56" width="1.0" height="30" fill="#2e9e5b" rx="3"/>
|
||||
<text x="329" y="76" font-size="12" fill="#1a1d27">67ms</text>
|
||||
<text x="310" y="120" font-size="12" fill="#1a1d27" text-anchor="end">download: archive extract (new build path, one-time)</text>
|
||||
<rect x="320" y="100" width="189.7" height="30" fill="#2e9e5b" rx="3"/>
|
||||
<text x="518" y="120" font-size="12" fill="#1a1d27">14.20s</text>
|
||||
<text x="310" y="164" font-size="12" fill="#1a1d27" text-anchor="end">download: npm install (cold fallback, one-time)</text>
|
||||
<rect x="320" y="144" width="570.0" height="30" fill="#d64545" rx="3"/>
|
||||
<text x="898" y="164" font-size="12" fill="#1a1d27">42.68s</text>
|
||||
<text x="310" y="208" font-size="12" fill="#1a1d27" text-anchor="end">vite bind: cold vite cache (first app)</text>
|
||||
<rect x="320" y="188" width="89.7" height="30" fill="#d64545" rx="3"/>
|
||||
<text x="418" y="208" font-size="12" fill="#1a1d27">6.71s</text>
|
||||
<text x="310" y="252" font-size="12" fill="#1a1d27" text-anchor="end">vite bind: warm shared cache (subsequent)</text>
|
||||
<rect x="320" y="232" width="9.0" height="30" fill="#2e9e5b" rx="3"/>
|
||||
<text x="337" y="252" font-size="12" fill="#1a1d27">672ms</text>
|
||||
<text x="310" y="296" font-size="12" fill="#1a1d27" text-anchor="end">build-time: tar node_modules to archive (CI, not user)</text>
|
||||
<rect x="320" y="276" width="90.9" height="30" fill="#2e9e5b" rx="3"/>
|
||||
<text x="419" y="296" font-size="12" fill="#1a1d27">6.81s</text>
|
||||
<text x="20" y="340" font-size="11" fill="#8892a4">green = warm/per-app cost; red = cold one-time download (npm with no archive)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="260" font-family="Segoe UI, sans-serif">
|
||||
<text x="20" y="28" font-size="17" font-weight="600" fill="#1a1d27">where startup time goes (backend dwarfs the shell)</text>
|
||||
<text x="20" y="77" font-size="13" fill="#1a1d27">typical warm launch</text>
|
||||
<rect x="170" y="50" width="77.8" height="46" fill="#2e9e5b" rx="3"/>
|
||||
<rect x="170" y="50" width="5.5" height="46" fill="#1a1d27" rx="3"/>
|
||||
<text x="256" y="77" font-size="12" fill="#1a1d27">backend 9.6s (shell 0.68s)</text>
|
||||
<text x="20" y="149" font-size="13" fill="#1a1d27">typical cold launch</text>
|
||||
<rect x="170" y="122" width="700.0" height="46" fill="#d64545" rx="3"/>
|
||||
<rect x="170" y="122" width="12.0" height="46" fill="#1a1d27" rx="3"/>
|
||||
<text x="878" y="149" font-size="12" fill="#1a1d27">backend 86.3s (shell 1.48s)</text>
|
||||
<text x="20" y="248" font-size="11" fill="#8892a4">dark = electron shell (app-launch + first-paint); colored = python backend</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 977 B |
@@ -0,0 +1,15 @@
|
||||
launch_ts,version,app_launch_ms,first_paint_ms,backend_http_ready_ms,class
|
||||
2026-06-02T09:18:13Z,1.1.72,380,1097,54606,cold
|
||||
2026-06-08T22:16:53Z,1.2.73,143,636,10463,warm
|
||||
2026-06-08T23:03:47Z,1.2.73,317,625,10084,warm
|
||||
2026-06-08T23:58:30Z,1.2.73,198,515,81041,cold
|
||||
2026-06-09T03:13:05Z,1.2.73,147,559,10418,warm
|
||||
2026-06-09T11:04:12Z,1.2.75,129,609,10041,warm
|
||||
2026-06-09T11:53:18Z,1.2.75,108,338,8761,warm
|
||||
2026-06-10T07:44:10Z,1.2.75,388,1100,86310,cold
|
||||
2026-06-10T07:46:53Z,1.2.76,112,389,9342,warm
|
||||
2026-06-10T23:32:57Z,1.2.76,563,1205,133070,cold
|
||||
2026-06-10T23:35:13Z,1.2.77,114,391,9349,warm
|
||||
2026-06-10T23:35:41Z,1.2.77,122,404,9303,warm
|
||||
2026-06-14T00:07:35Z,1.2.77,159,1213,138335,cold
|
||||
2026-06-14T00:09:58Z,1.2.82,107,702,9590,warm
|
||||
|
@@ -0,0 +1,57 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="420" font-family="Segoe UI, sans-serif">
|
||||
<text x="60" y="28" font-size="17" font-weight="600" fill="#1a1d27">backend-http-ready per launch (ms) - lower is better</text>
|
||||
<line x1="60" y1="330" x2="880" y2="330" stroke="#e2e6ef"/>
|
||||
<text x="52" y="334" font-size="11" fill="#8892a4" text-anchor="end">0s</text>
|
||||
<line x1="60" y1="260" x2="880" y2="260" stroke="#e2e6ef"/>
|
||||
<text x="52" y="264" font-size="11" fill="#8892a4" text-anchor="end">35s</text>
|
||||
<line x1="60" y1="190" x2="880" y2="190" stroke="#e2e6ef"/>
|
||||
<text x="52" y="194" font-size="11" fill="#8892a4" text-anchor="end">69s</text>
|
||||
<line x1="60" y1="120" x2="880" y2="120" stroke="#e2e6ef"/>
|
||||
<text x="52" y="124" font-size="11" fill="#8892a4" text-anchor="end">104s</text>
|
||||
<line x1="60" y1="50" x2="880" y2="50" stroke="#e2e6ef"/>
|
||||
<text x="52" y="54" font-size="11" fill="#8892a4" text-anchor="end">138s</text>
|
||||
<rect x="68.8" y="219.5" width="41.0" height="110.5" fill="#d64545" rx="2"/>
|
||||
<text x="89.3" y="214.5" font-size="10" fill="#1a1d27" text-anchor="middle">55s</text>
|
||||
<text x="89.3" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 89.3 346)">1.1.72</text>
|
||||
<rect x="127.4" y="308.8" width="41.0" height="21.2" fill="#2e9e5b" rx="2"/>
|
||||
<text x="147.9" y="303.8" font-size="10" fill="#1a1d27" text-anchor="middle">10s</text>
|
||||
<text x="147.9" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 147.9 346)">1.2.73</text>
|
||||
<rect x="185.9" y="309.6" width="41.0" height="20.4" fill="#2e9e5b" rx="2"/>
|
||||
<text x="206.4" y="304.6" font-size="10" fill="#1a1d27" text-anchor="middle">10s</text>
|
||||
<text x="206.4" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 206.4 346)">1.2.73</text>
|
||||
<rect x="244.5" y="166.0" width="41.0" height="164.0" fill="#d64545" rx="2"/>
|
||||
<text x="265.0" y="161.0" font-size="10" fill="#1a1d27" text-anchor="middle">81s</text>
|
||||
<text x="265.0" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 265.0 346)">1.2.73</text>
|
||||
<rect x="303.1" y="308.9" width="41.0" height="21.1" fill="#2e9e5b" rx="2"/>
|
||||
<text x="323.6" y="303.9" font-size="10" fill="#1a1d27" text-anchor="middle">10s</text>
|
||||
<text x="323.6" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 323.6 346)">1.2.73</text>
|
||||
<rect x="361.6" y="309.7" width="41.0" height="20.3" fill="#2e9e5b" rx="2"/>
|
||||
<text x="382.1" y="304.7" font-size="10" fill="#1a1d27" text-anchor="middle">10s</text>
|
||||
<text x="382.1" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 382.1 346)">1.2.75</text>
|
||||
<rect x="420.2" y="312.3" width="41.0" height="17.7" fill="#2e9e5b" rx="2"/>
|
||||
<text x="440.7" y="307.3" font-size="10" fill="#1a1d27" text-anchor="middle">9s</text>
|
||||
<text x="440.7" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 440.7 346)">1.2.75</text>
|
||||
<rect x="478.8" y="155.3" width="41.0" height="174.7" fill="#d64545" rx="2"/>
|
||||
<text x="499.3" y="150.3" font-size="10" fill="#1a1d27" text-anchor="middle">86s</text>
|
||||
<text x="499.3" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 499.3 346)">1.2.75</text>
|
||||
<rect x="537.4" y="311.1" width="41.0" height="18.9" fill="#2e9e5b" rx="2"/>
|
||||
<text x="557.9" y="306.1" font-size="10" fill="#1a1d27" text-anchor="middle">9s</text>
|
||||
<text x="557.9" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 557.9 346)">1.2.76</text>
|
||||
<rect x="595.9" y="60.7" width="41.0" height="269.3" fill="#d64545" rx="2"/>
|
||||
<text x="616.4" y="55.7" font-size="10" fill="#1a1d27" text-anchor="middle">133s</text>
|
||||
<text x="616.4" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 616.4 346)">1.2.76</text>
|
||||
<rect x="654.5" y="311.1" width="41.0" height="18.9" fill="#2e9e5b" rx="2"/>
|
||||
<text x="675.0" y="306.1" font-size="10" fill="#1a1d27" text-anchor="middle">9s</text>
|
||||
<text x="675.0" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 675.0 346)">1.2.77</text>
|
||||
<rect x="713.1" y="311.2" width="41.0" height="18.8" fill="#2e9e5b" rx="2"/>
|
||||
<text x="733.6" y="306.2" font-size="10" fill="#1a1d27" text-anchor="middle">9s</text>
|
||||
<text x="733.6" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 733.6 346)">1.2.77</text>
|
||||
<rect x="771.6" y="50.0" width="41.0" height="280.0" fill="#d64545" rx="2"/>
|
||||
<text x="792.1" y="45.0" font-size="10" fill="#1a1d27" text-anchor="middle">138s</text>
|
||||
<text x="792.1" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 792.1 346)">1.2.77</text>
|
||||
<rect x="830.2" y="310.6" width="41.0" height="19.4" fill="#2e9e5b" rx="2"/>
|
||||
<text x="850.7" y="305.6" font-size="10" fill="#1a1d27" text-anchor="middle">10s</text>
|
||||
<text x="850.7" y="346" font-size="9" fill="#8892a4" text-anchor="end" transform="rotate(-40 850.7 346)">1.2.82</text>
|
||||
<rect x="700" y="50" width="12" height="12" fill="#2e9e5b"/><text x="716" y="61" font-size="12" fill="#1a1d27">warm</text>
|
||||
<rect x="770" y="50" width="12" height="12" fill="#d64545"/><text x="786" y="61" font-size="12" fill="#1a1d27">cold (post-update)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,5 @@
|
||||
phase,before_ms,after_ms
|
||||
import backend.main,798,764
|
||||
service lifespan (9router start),7412,84
|
||||
other 15 lifespans,45,9
|
||||
import + lifespans floor,8256,857
|
||||
|
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="360" font-family="Segoe UI, sans-serif">
|
||||
<text x="60" y="28" font-size="17" font-weight="600" fill="#1a1d27">warm boot breakdown: before vs after (ms) - the service lifespan was the bottleneck</text>
|
||||
<line x1="60" y1="240" x2="870" y2="240" stroke="#e2e6ef"/>
|
||||
<text x="52" y="244" font-size="11" fill="#8892a4" text-anchor="end">0.0s</text>
|
||||
<line x1="60" y1="145" x2="870" y2="145" stroke="#e2e6ef"/>
|
||||
<text x="52" y="149" font-size="11" fill="#8892a4" text-anchor="end">4.1s</text>
|
||||
<line x1="60" y1="50" x2="870" y2="50" stroke="#e2e6ef"/>
|
||||
<text x="52" y="54" font-size="11" fill="#8892a4" text-anchor="end">8.3s</text>
|
||||
<rect x="89.0" y="221.6" width="68.9" height="18.4" fill="#d64545" rx="2"/>
|
||||
<text x="123.4" y="217.6" font-size="10" fill="#1a1d27" text-anchor="middle">0.8s</text>
|
||||
<rect x="157.8" y="222.4" width="68.9" height="17.6" fill="#2e9e5b" rx="2"/>
|
||||
<text x="192.2" y="218.4" font-size="10" fill="#1a1d27" text-anchor="middle">0.8s</text>
|
||||
<text x="161.2" y="258" font-size="10" fill="#8892a4" text-anchor="end" transform="rotate(-25 161.2 258)">import backend.main</text>
|
||||
<rect x="291.5" y="69.4" width="68.9" height="170.6" fill="#d64545" rx="2"/>
|
||||
<text x="325.9" y="65.4" font-size="10" fill="#1a1d27" text-anchor="middle">7.4s</text>
|
||||
<rect x="360.3" y="238.1" width="68.9" height="1.9" fill="#2e9e5b" rx="2"/>
|
||||
<text x="394.7" y="234.1" font-size="10" fill="#1a1d27" text-anchor="middle">0.1s</text>
|
||||
<text x="363.8" y="258" font-size="10" fill="#8892a4" text-anchor="end" transform="rotate(-25 363.8 258)">service lifespan (9router start)</text>
|
||||
<rect x="494.0" y="239.0" width="68.9" height="1.0" fill="#d64545" rx="2"/>
|
||||
<text x="528.4" y="235.0" font-size="10" fill="#1a1d27" text-anchor="middle">0.0s</text>
|
||||
<rect x="562.8" y="239.8" width="68.9" height="0.2" fill="#2e9e5b" rx="2"/>
|
||||
<text x="597.2" y="235.8" font-size="10" fill="#1a1d27" text-anchor="middle">0.0s</text>
|
||||
<text x="566.2" y="258" font-size="10" fill="#8892a4" text-anchor="end" transform="rotate(-25 566.2 258)">other 15 lifespans</text>
|
||||
<rect x="696.5" y="50.0" width="68.9" height="190.0" fill="#d64545" rx="2"/>
|
||||
<text x="730.9" y="46.0" font-size="10" fill="#1a1d27" text-anchor="middle">8.3s</text>
|
||||
<rect x="765.3" y="220.3" width="68.9" height="19.7" fill="#2e9e5b" rx="2"/>
|
||||
<text x="799.7" y="216.3" font-size="10" fill="#1a1d27" text-anchor="middle">0.9s</text>
|
||||
<text x="768.8" y="258" font-size="10" fill="#8892a4" text-anchor="end" transform="rotate(-25 768.8 258)">import + lifespans floor</text>
|
||||
<rect x="700" y="50" width="12" height="12" fill="#d64545"/><text x="716" y="61" font-size="12" fill="#1a1d27">before</text>
|
||||
<rect x="780" y="50" width="12" height="12" fill="#2e9e5b"/><text x="796" y="61" font-size="12" fill="#1a1d27">after</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,57 @@
|
||||
// #9 item 4: inventory an app.asar without extracting it. Parses the asar header
|
||||
// (a Chromium Pickle: [u32 payloadSize][u32 headerSize] then [u32 payloadSize]
|
||||
// [u32 jsonLen][json...]) and reports total size, biggest top-level dirs, biggest
|
||||
// individual files, and trimmable categories (source maps, etc.).
|
||||
// Usage: node inspect_asar.js <path-to-app.asar>
|
||||
const fs = require('fs');
|
||||
|
||||
const asar = process.argv[2];
|
||||
if (!asar) { console.error('usage: node inspect_asar.js <app.asar>'); process.exit(1); }
|
||||
|
||||
const fd = fs.openSync(asar, 'r');
|
||||
const head = Buffer.alloc(8);
|
||||
fs.readSync(fd, head, 0, 8, 0);
|
||||
const headerSize = head.readUInt32LE(4); // size of the header pickle
|
||||
const hp = Buffer.alloc(headerSize);
|
||||
fs.readSync(fd, hp, 0, headerSize, 8);
|
||||
const jsonLen = hp.readUInt32LE(4); // string length inside the pickle
|
||||
const json = hp.slice(8, 8 + jsonLen).toString('utf8');
|
||||
const header = JSON.parse(json);
|
||||
fs.closeSync(fd);
|
||||
|
||||
let total = 0, fileCount = 0;
|
||||
const byExt = {};
|
||||
const files = []; // {path, size}
|
||||
const topDirs = {}; // top-level entry -> size
|
||||
|
||||
function walk(node, parts) {
|
||||
if (node.files) {
|
||||
for (const [name, child] of Object.entries(node.files)) walk(child, parts.concat(name));
|
||||
} else if (typeof node.size === 'number') {
|
||||
const p = parts.join('/');
|
||||
total += node.size; fileCount++;
|
||||
files.push({ p, size: node.size });
|
||||
const ext = (p.match(/\.[^./]+$/) || ['(none)'])[0].toLowerCase();
|
||||
byExt[ext] = (byExt[ext] || 0) + node.size;
|
||||
topDirs[parts[0]] = (topDirs[parts[0]] || 0) + node.size;
|
||||
}
|
||||
}
|
||||
walk(header, []);
|
||||
|
||||
const mb = (b) => (b / 1048576).toFixed(1) + ' MB';
|
||||
const sortObj = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
console.log(`asar total: ${mb(total)} across ${fileCount} files\n`);
|
||||
console.log('=== biggest top-level entries ===');
|
||||
for (const [d, s] of sortObj(topDirs).slice(0, 15)) console.log(` ${mb(s).padStart(10)} ${d}`);
|
||||
console.log('\n=== biggest single files ===');
|
||||
for (const f of files.sort((a, b) => b.size - a.size).slice(0, 20)) console.log(` ${mb(f.size).padStart(10)} ${f.p}`);
|
||||
console.log('\n=== by extension (top 15) ===');
|
||||
for (const [e, s] of sortObj(byExt).slice(0, 15)) console.log(` ${mb(s).padStart(10)} ${e}`);
|
||||
console.log('\n=== trimmable categories ===');
|
||||
const cat = (re) => files.filter(f => re.test(f.p)).reduce((n, f) => n + f.size, 0);
|
||||
console.log(` source maps (*.map): ${mb(cat(/\.map$/))}`);
|
||||
console.log(` .ts/.tsx sources: ${mb(cat(/\.tsx?$/))}`);
|
||||
console.log(` markdown/license/readme: ${mb(cat(/(\.md|license|readme|changelog)/i))}`);
|
||||
console.log(` test/spec/__tests__: ${mb(cat(/(\/test\/|\/tests\/|__tests__|\.spec\.|\.test\.)/i))}`);
|
||||
console.log(` node_modules inside asar: ${mb(cat(/(^|\/)node_modules\//))}`);
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Dependency-free SVG charts for the winv2 perf baseline.
|
||||
|
||||
No matplotlib/pandas (not in the bundled env). Reads baseline_startup.csv and
|
||||
writes two self-contained SVGs that render in a browser, GitHub, or Notion:
|
||||
baseline_startup.svg - backend-http-ready per launch (warm vs cold)
|
||||
baseline_phases.svg - where the time goes (app-launch / first-paint / backend)
|
||||
Run: python make_graphs.py
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CSV = os.path.join(HERE, "baseline_startup.csv")
|
||||
|
||||
WARM = "#2e9e5b"
|
||||
COLD = "#d64545"
|
||||
INK = "#1a1d27"
|
||||
MUTE = "#8892a4"
|
||||
GRID = "#e2e6ef"
|
||||
|
||||
|
||||
def rows():
|
||||
with open(CSV, newline="", encoding="utf-8") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def bars_chart(data):
|
||||
w, h = 900, 420
|
||||
pad_l, pad_b, pad_t, pad_r = 60, 90, 50, 20
|
||||
plot_w = w - pad_l - pad_r
|
||||
plot_h = h - pad_t - pad_b
|
||||
vals = [int(r["backend_http_ready_ms"]) for r in data]
|
||||
vmax = max(vals)
|
||||
n = len(data)
|
||||
bw = plot_w / n * 0.7
|
||||
gap = plot_w / n
|
||||
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" font-family="Segoe UI, sans-serif">']
|
||||
out.append(f'<text x="{pad_l}" y="28" font-size="17" font-weight="600" fill="{INK}">'
|
||||
'backend-http-ready per launch (ms) - lower is better</text>')
|
||||
# y gridlines
|
||||
for frac in (0, 0.25, 0.5, 0.75, 1.0):
|
||||
yv = vmax * frac
|
||||
y = pad_t + plot_h - plot_h * frac
|
||||
out.append(f'<line x1="{pad_l}" y1="{y:.0f}" x2="{w-pad_r}" y2="{y:.0f}" stroke="{GRID}"/>')
|
||||
out.append(f'<text x="{pad_l-8}" y="{y+4:.0f}" font-size="11" fill="{MUTE}" text-anchor="end">{yv/1000:.0f}s</text>')
|
||||
for i, r in enumerate(data):
|
||||
v = int(r["backend_http_ready_ms"])
|
||||
bh = plot_h * v / vmax
|
||||
x = pad_l + i * gap + (gap - bw) / 2
|
||||
y = pad_t + plot_h - bh
|
||||
color = COLD if r["class"] == "cold" else WARM
|
||||
out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bh:.1f}" fill="{color}" rx="2"/>')
|
||||
out.append(f'<text x="{x+bw/2:.1f}" y="{y-5:.1f}" font-size="10" fill="{INK}" text-anchor="middle">{v/1000:.0f}s</text>')
|
||||
out.append(f'<text x="{x+bw/2:.1f}" y="{h-pad_b+16:.0f}" font-size="9" fill="{MUTE}" '
|
||||
f'text-anchor="end" transform="rotate(-40 {x+bw/2:.1f} {h-pad_b+16:.0f})">{r["version"]}</text>')
|
||||
out.append(f'<rect x="{w-200}" y="{pad_t}" width="12" height="12" fill="{WARM}"/>'
|
||||
f'<text x="{w-184}" y="{pad_t+11}" font-size="12" fill="{INK}">warm</text>')
|
||||
out.append(f'<rect x="{w-130}" y="{pad_t}" width="12" height="12" fill="{COLD}"/>'
|
||||
f'<text x="{w-114}" y="{pad_t+11}" font-size="12" fill="{INK}">cold (post-update)</text>')
|
||||
out.append('</svg>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def phases_chart(data):
|
||||
warm = [r for r in data if r["class"] == "warm"]
|
||||
cold = [r for r in data if r["class"] == "cold"]
|
||||
|
||||
def med(rows_, key):
|
||||
xs = sorted(int(r[key]) for r in rows_)
|
||||
return xs[len(xs) // 2] if xs else 0
|
||||
|
||||
cases = [
|
||||
("typical warm launch", med(warm, "app_launch_ms"), med(warm, "first_paint_ms"), med(warm, "backend_http_ready_ms")),
|
||||
("typical cold launch", med(cold, "app_launch_ms"), med(cold, "first_paint_ms"), med(cold, "backend_http_ready_ms")),
|
||||
]
|
||||
w, h = 900, 260
|
||||
pad_l, pad_r, pad_t = 170, 30, 50
|
||||
plot_w = w - pad_l - pad_r
|
||||
vmax = max(c[3] for c in cases)
|
||||
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" font-family="Segoe UI, sans-serif">']
|
||||
out.append(f'<text x="20" y="28" font-size="17" font-weight="600" fill="{INK}">'
|
||||
'where startup time goes (backend dwarfs the shell)</text>')
|
||||
row_h = 46
|
||||
for i, (label, al, fp, br) in enumerate(cases):
|
||||
y = pad_t + i * (row_h + 26)
|
||||
out.append(f'<text x="20" y="{y+row_h/2+4:.0f}" font-size="13" fill="{INK}">{label}</text>')
|
||||
# backend is the full bar; app-launch+first-paint are the tiny left slice
|
||||
bw_backend = plot_w * br / vmax
|
||||
out.append(f'<rect x="{pad_l}" y="{y}" width="{bw_backend:.1f}" height="{row_h}" fill="{COLD if i==1 else WARM}" rx="3"/>')
|
||||
shell = al + fp
|
||||
bw_shell = plot_w * shell / vmax
|
||||
out.append(f'<rect x="{pad_l}" y="{y}" width="{max(bw_shell,2):.1f}" height="{row_h}" fill="{INK}" rx="3"/>')
|
||||
out.append(f'<text x="{pad_l+bw_backend+8:.0f}" y="{y+row_h/2+4:.0f}" font-size="12" fill="{INK}">'
|
||||
f'backend {br/1000:.1f}s (shell {shell/1000:.2f}s)</text>')
|
||||
out.append(f'<text x="20" y="{h-12}" font-size="11" fill="{MUTE}">'
|
||||
'dark = electron shell (app-launch + first-paint); colored = python backend</text>')
|
||||
out.append('</svg>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def boot_chart():
|
||||
"""Before/after grouped bars for the boot-phase breakdown (profile_boot.py)."""
|
||||
path = os.path.join(HERE, "boot_breakdown.csv")
|
||||
with open(path, newline="", encoding="utf-8") as f:
|
||||
data = list(csv.DictReader(f))
|
||||
w, h = 900, 360
|
||||
pad_l, pad_r, pad_t, pad_b = 60, 30, 50, 120
|
||||
plot_w = w - pad_l - pad_r
|
||||
plot_h = h - pad_t - pad_b
|
||||
vmax = max(max(int(r["before_ms"]), int(r["after_ms"])) for r in data)
|
||||
n = len(data)
|
||||
group = plot_w / n
|
||||
bw = group * 0.34
|
||||
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" font-family="Segoe UI, sans-serif">']
|
||||
out.append(f'<text x="{pad_l}" y="28" font-size="17" font-weight="600" fill="{INK}">'
|
||||
'warm boot breakdown: before vs after (ms) - the service lifespan was the bottleneck</text>')
|
||||
for frac in (0, 0.5, 1.0):
|
||||
y = pad_t + plot_h - plot_h * frac
|
||||
out.append(f'<line x1="{pad_l}" y1="{y:.0f}" x2="{w-pad_r}" y2="{y:.0f}" stroke="{GRID}"/>')
|
||||
out.append(f'<text x="{pad_l-8}" y="{y+4:.0f}" font-size="11" fill="{MUTE}" text-anchor="end">{vmax*frac/1000:.1f}s</text>')
|
||||
for i, r in enumerate(data):
|
||||
bx = pad_l + i * group + group / 2
|
||||
for j, (key, color, lab) in enumerate((("before_ms", COLD, "before"), ("after_ms", WARM, "after"))):
|
||||
v = int(r[key])
|
||||
bh = plot_h * v / vmax
|
||||
x = bx + (j - 1) * bw - bw * 0.05
|
||||
y = pad_t + plot_h - bh
|
||||
out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bh:.1f}" fill="{color}" rx="2"/>')
|
||||
out.append(f'<text x="{x+bw/2:.1f}" y="{y-4:.1f}" font-size="10" fill="{INK}" text-anchor="middle">{v/1000:.1f}s</text>')
|
||||
out.append(f'<text x="{bx:.1f}" y="{h-pad_b+18:.0f}" font-size="10" fill="{MUTE}" text-anchor="end" '
|
||||
f'transform="rotate(-25 {bx:.1f} {h-pad_b+18:.0f})">{r["phase"]}</text>')
|
||||
out.append(f'<rect x="{w-200}" y="{pad_t}" width="12" height="12" fill="{COLD}"/><text x="{w-184}" y="{pad_t+11}" font-size="12" fill="{INK}">before</text>')
|
||||
out.append(f'<rect x="{w-120}" y="{pad_t}" width="12" height="12" fill="{WARM}"/><text x="{w-104}" y="{pad_t+11}" font-size="12" fill="{INK}">after</text>')
|
||||
out.append('</svg>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def appbuilder_chart():
|
||||
"""Horizontal bars for the App Builder create-path breakdown. Returns None
|
||||
if the measurement CSV hasn't been generated yet."""
|
||||
path = os.path.join(HERE, "appbuilder_breakdown.csv")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, newline="", encoding="utf-8") as f:
|
||||
raw = list(csv.DictReader(f))
|
||||
# Keep only real timing phases (drop the boolean/skipped/-1 rows).
|
||||
data = [r for r in raw if r["ms"].lstrip("-").isdigit() and int(r["ms"]) >= 0
|
||||
and not r["phase"].strip().startswith("->")]
|
||||
if not data:
|
||||
return None
|
||||
w = 980
|
||||
row_h, gap, pad_t, pad_l, pad_r = 30, 14, 56, 320, 90
|
||||
h = pad_t + len(data) * (row_h + gap) + 30
|
||||
vmax = max(int(r["ms"]) for r in data) or 1
|
||||
plot_w = w - pad_l - pad_r
|
||||
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" font-family="Segoe UI, sans-serif">']
|
||||
out.append(f'<text x="20" y="30" font-size="17" font-weight="600" fill="{INK}">'
|
||||
'App Builder "create app -> live preview" breakdown (ms)</text>')
|
||||
for i, r in enumerate(data):
|
||||
v = int(r["ms"])
|
||||
y = pad_t + i * (row_h + gap)
|
||||
bw = max(plot_w * v / vmax, 1)
|
||||
# download/npm = cold cost (red-ish), everything else = warm/per-app (green)
|
||||
cold = ("npm" in r["phase"]) or ("cold" in r["phase"])
|
||||
color = COLD if cold else WARM
|
||||
out.append(f'<text x="{pad_l-10}" y="{y+row_h*0.68:.0f}" font-size="12" fill="{INK}" text-anchor="end">{r["phase"]}</text>')
|
||||
out.append(f'<rect x="{pad_l}" y="{y}" width="{bw:.1f}" height="{row_h}" fill="{color}" rx="3"/>')
|
||||
label = f'{v/1000:.2f}s' if v >= 1000 else f'{v}ms'
|
||||
out.append(f'<text x="{pad_l+bw+8:.0f}" y="{y+row_h*0.68:.0f}" font-size="12" fill="{INK}">{label}</text>')
|
||||
out.append(f'<text x="20" y="{h-10}" font-size="11" fill="{MUTE}">'
|
||||
'green = warm/per-app cost; red = cold one-time download (npm with no archive)</text>')
|
||||
out.append('</svg>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def startup_beforeafter_chart():
|
||||
"""Before/after grouped bars for the signed-build startup result (Task #10)."""
|
||||
path = os.path.join(HERE, "startup_beforeafter.csv")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, newline="", encoding="utf-8") as f:
|
||||
data = list(csv.DictReader(f))
|
||||
w, h = 900, 320
|
||||
pad_l, pad_r, pad_t, pad_b = 60, 30, 56, 90
|
||||
plot_w, plot_h = w - pad_l - pad_r, h - pad_t - pad_b
|
||||
vmax = max(max(int(r["before_ms"]), int(r["after_ms"])) for r in data)
|
||||
n = len(data); group = plot_w / n; bw = group * 0.30
|
||||
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" font-family="Segoe UI, sans-serif">']
|
||||
out.append(f'<text x="{pad_l}" y="30" font-size="17" font-weight="600" fill="{INK}">'
|
||||
'signed v1.3.87 startup: before vs after (seconds, lower is better)</text>')
|
||||
for frac in (0, 0.5, 1.0):
|
||||
y = pad_t + plot_h - plot_h * frac
|
||||
out.append(f'<line x1="{pad_l}" y1="{y:.0f}" x2="{w-pad_r}" y2="{y:.0f}" stroke="{GRID}"/>')
|
||||
out.append(f'<text x="{pad_l-8}" y="{y+4:.0f}" font-size="11" fill="{MUTE}" text-anchor="end">{vmax*frac/1000:.0f}s</text>')
|
||||
for i, r in enumerate(data):
|
||||
bx = pad_l + i * group + group / 2
|
||||
for j, (k, c, lab) in enumerate((("before_ms", COLD, "before"), ("after_ms", WARM, "after"))):
|
||||
v = int(r[k]); bh = plot_h * v / vmax; x = bx + (j - 1) * bw - bw * 0.05; y = pad_t + plot_h - bh
|
||||
out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bh:.1f}" fill="{c}" rx="2"/>')
|
||||
out.append(f'<text x="{x+bw/2:.1f}" y="{y-4:.1f}" font-size="11" fill="{INK}" text-anchor="middle">{v/1000:.1f}s</text>')
|
||||
out.append(f'<text x="{bx:.1f}" y="{h-pad_b+22:.0f}" font-size="11" fill="{MUTE}" text-anchor="middle">{r["metric"]}</text>')
|
||||
out.append(f'<rect x="{w-200}" y="{pad_t}" width="12" height="12" fill="{COLD}"/><text x="{w-184}" y="{pad_t+11}" font-size="12" fill="{INK}">before (1.2.x)</text>')
|
||||
out.append(f'<rect x="{w-95}" y="{pad_t}" width="12" height="12" fill="{WARM}"/><text x="{w-79}" y="{pad_t+11}" font-size="12" fill="{INK}">v1.3.87</text>')
|
||||
out.append('</svg>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
data = rows()
|
||||
open(os.path.join(HERE, "baseline_startup.svg"), "w", encoding="utf-8").write(bars_chart(data))
|
||||
open(os.path.join(HERE, "baseline_phases.svg"), "w", encoding="utf-8").write(phases_chart(data))
|
||||
open(os.path.join(HERE, "boot_breakdown.svg"), "w", encoding="utf-8").write(boot_chart())
|
||||
wrote = "baseline_startup.svg + baseline_phases.svg + boot_breakdown.svg"
|
||||
ab = appbuilder_chart()
|
||||
if ab:
|
||||
open(os.path.join(HERE, "appbuilder_breakdown.svg"), "w", encoding="utf-8").write(ab)
|
||||
wrote += " + appbuilder_breakdown.svg"
|
||||
sba = startup_beforeafter_chart()
|
||||
if sba:
|
||||
open(os.path.join(HERE, "startup_beforeafter.svg"), "w", encoding="utf-8").write(sba)
|
||||
wrote += " + startup_beforeafter.svg"
|
||||
print("wrote " + wrote)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Granular App Builder first-app create/"download" profiler (winv2 Task #3).
|
||||
|
||||
Incremental + bounded: each phase appends to appbuilder_breakdown.csv and flushes
|
||||
the instant it finishes, so a slow/hung later phase can't erase earlier numbers.
|
||||
Run UNBUFFERED (python -u) so progress is visible mid-run. Cheap phases first.
|
||||
|
||||
Phases:
|
||||
1. seed workspace + link node_modules (per-app cost, uses real warm cache)
|
||||
2. download: npm install (cold, no archive) (the "slow as bricks" download)
|
||||
3. download: archive extract (new build path) (tar the just-installed nm, time extract)
|
||||
4. vite bind: cold vite cache (first app ever)
|
||||
5. vite bind: warm shared cache (subsequent apps)
|
||||
|
||||
Isolated temp dirs; never mutates the user's real caches (read-only link to the
|
||||
warm node_modules cache; vite cache is overridden to temp for the cold case).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from backend.apps.outputs import view_builder_templates as vt
|
||||
from backend.apps.outputs.runtime_proc import _find_free_port
|
||||
from backend.apps.outputs.runtime import AppRuntime
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CSV = os.path.join(HERE, "appbuilder_breakdown.csv")
|
||||
TMP = tempfile.mkdtemp(prefix="ab-measure-")
|
||||
TMPL_FRONTEND = os.path.join(vt.WEBAPP_TEMPLATE_DIR, "frontend")
|
||||
VITE_DEADLINE = 90
|
||||
|
||||
with open(CSV, "w", encoding="utf-8") as f:
|
||||
f.write("phase,ms,note\n")
|
||||
|
||||
|
||||
def lap(t):
|
||||
return round((time.perf_counter() - t) * 1000)
|
||||
|
||||
|
||||
def record(name, ms, note=""):
|
||||
print(f"{ms:8d} ms {name}" + (f" ({note})" if note else ""), flush=True)
|
||||
with open(CSV, "a", encoding="utf-8") as f:
|
||||
f.write(f'"{name}",{ms},"{note}"\n')
|
||||
f.flush()
|
||||
|
||||
|
||||
def phase_seed():
|
||||
ws = os.path.join(TMP, "ws-seed")
|
||||
t = time.perf_counter()
|
||||
vt.seed_webapp_template_workspace(ws, _find_free_port())
|
||||
ms = lap(t)
|
||||
present = os.path.exists(os.path.join(ws, "frontend", "node_modules"))
|
||||
record("seed workspace + link node_modules (per app)", ms, "nm linked" if present else "NO nm")
|
||||
|
||||
|
||||
def phase_npm_and_extract():
|
||||
npm = vt._resolve_npm()
|
||||
if not npm:
|
||||
record("download: npm install (cold)", -1, "skipped: no npm")
|
||||
return
|
||||
work = os.path.join(TMP, "npm_cold")
|
||||
os.makedirs(work, exist_ok=True)
|
||||
shutil.copyfile(os.path.join(TMPL_FRONTEND, "package.json"), os.path.join(work, "package.json"))
|
||||
lock = os.path.join(TMPL_FRONTEND, "package-lock.json")
|
||||
cmd = [*npm, "install", "--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"]
|
||||
if os.path.exists(lock):
|
||||
shutil.copyfile(lock, os.path.join(work, "package-lock.json"))
|
||||
cmd = [*npm, "ci", "--prefer-offline", "--no-audit", "--no-fund", "--loglevel=error"]
|
||||
t = time.perf_counter()
|
||||
try:
|
||||
r = subprocess.run(cmd, cwd=work, capture_output=True, text=True, timeout=240)
|
||||
record("download: npm install (cold, no archive)", lap(t), "ok" if r.returncode == 0 else f"rc={r.returncode}")
|
||||
except subprocess.TimeoutExpired:
|
||||
record("download: npm install (cold, no archive)", -1, "TIMEOUT 240s")
|
||||
return
|
||||
|
||||
nm = os.path.join(work, "node_modules")
|
||||
if not os.path.isdir(nm):
|
||||
return
|
||||
# Reuse that node_modules to time the archive build + extract (new path).
|
||||
archive = os.path.join(TMP, "nm.tar.gz")
|
||||
t = time.perf_counter()
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
tar.add(nm, arcname="node_modules")
|
||||
record("build-time: tar node_modules -> archive", lap(t), f"{os.path.getsize(archive)//(1024*1024)}MB")
|
||||
exd = os.path.join(TMP, "extract"); os.makedirs(exd, exist_ok=True)
|
||||
t = time.perf_counter()
|
||||
with tarfile.open(archive, "r:gz") as tar:
|
||||
tar.extractall(exd)
|
||||
record("download: archive extract (new build path)", lap(t))
|
||||
|
||||
|
||||
async def _bind_once(label, vite_cache_dir):
|
||||
ws = os.path.join(TMP, f"ws-{label}")
|
||||
vt.seed_webapp_template_workspace(ws, _find_free_port())
|
||||
if vite_cache_dir:
|
||||
os.environ["OPENSWARM_VITE_CACHE_DIR"] = vite_cache_dir
|
||||
else:
|
||||
os.environ.pop("OPENSWARM_VITE_CACHE_DIR", None)
|
||||
rt = AppRuntime(f"ws-{label}", ws)
|
||||
t = time.perf_counter()
|
||||
await rt.start()
|
||||
deadline = time.perf_counter() + VITE_DEADLINE
|
||||
while rt.frontend_url is None and time.perf_counter() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
bound = rt.frontend_url is not None
|
||||
ms = lap(t) if bound else -1
|
||||
try:
|
||||
await rt.stop()
|
||||
except Exception:
|
||||
pass
|
||||
record(f"vite bind ({label})", ms, "bound" if bound else f"TIMEOUT {VITE_DEADLINE}s")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"temp: {TMP}", flush=True)
|
||||
for fn in (phase_seed, phase_npm_and_extract):
|
||||
try:
|
||||
fn()
|
||||
except Exception as e:
|
||||
record(fn.__name__, -1, f"ERR {type(e).__name__}: {e}")
|
||||
for label, cache in (("cold vite cache", os.path.join(TMP, "vite_cold")), ("warm shared cache", None)):
|
||||
try:
|
||||
await _bind_once(label, cache)
|
||||
except Exception as e:
|
||||
record(f"vite bind ({label})", -1, f"ERR {type(e).__name__}: {e}")
|
||||
print("done", flush=True)
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Task #10: track the user-facing DOWNLOAD speed + verify the signature of the
|
||||
signed build, then hand off to validate_packaged.ps1 for install/startup. Does
|
||||
NOT publish anything (downloads from the draft release / run artifacts only).
|
||||
.USAGE
|
||||
pwsh docs\perf\winv2\measure_download_install.ps1 -Tag v1.3.86
|
||||
#>
|
||||
param(
|
||||
[string]$Tag = 'v1.3.86',
|
||||
[string]$WorkDir = (Join-Path $env:TEMP "os-dl-$Tag")
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
|
||||
# 1. Download the signed installer (timed) -> download speed.
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
gh release download $Tag --pattern '*Setup*.exe' --dir $WorkDir --clobber
|
||||
$sw.Stop()
|
||||
$exe = Get-ChildItem $WorkDir -Filter '*Setup*.exe' | Select-Object -First 1
|
||||
if (-not $exe) { throw "no Setup .exe for $Tag (is the draft release built? try: gh run download <id>)" }
|
||||
$mb = [math]::Round($exe.Length / 1MB, 1)
|
||||
$secs = [math]::Round($sw.Elapsed.TotalSeconds, 1)
|
||||
$mbps = if ($secs -gt 0) { [math]::Round($mb / $secs, 1) } else { 'inf' }
|
||||
Write-Host ("DOWNLOAD: {0} MB in {1}s ({2} MB/s) -> {3}" -f $mb, $secs, $mbps, $exe.Name)
|
||||
|
||||
# 2. Verify it is really code-signed.
|
||||
$sig = Get-AuthenticodeSignature $exe.FullName
|
||||
Write-Host ("SIGNATURE: {0} signer={1}" -f $sig.Status, $sig.SignerCertificate.Subject)
|
||||
if ($sig.Status -ne 'Valid') { Write-Warning "signature is NOT Valid -- stop and investigate before installing" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Installer: $($exe.FullName)"
|
||||
Write-Host "Next (install timing): note the clock, run the installer, then time until %APPDATA%\openswarm\data\backend.log appears."
|
||||
Write-Host "Then: pwsh docs\perf\winv2\validate_packaged.ps1 (structural + cold/warm startup)"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Vite-bind-only measurement (winv2 Task #3, part 2).
|
||||
|
||||
Split out from measure_appbuilder.py because Python's tarfile gzip of a full
|
||||
node_modules is pathologically slow and was eating the time budget before the
|
||||
vite phases ran. This does ONLY the two vite binds (cold vite cache = first app
|
||||
ever; warm shared cache = subsequent apps) and APPENDS to appbuilder_breakdown.csv.
|
||||
No tar, no npm. Run unbuffered.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from backend.apps.outputs import view_builder_templates as vt
|
||||
from backend.apps.outputs.runtime_proc import _find_free_port
|
||||
from backend.apps.outputs.runtime import AppRuntime
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CSV = os.path.join(HERE, "appbuilder_breakdown.csv")
|
||||
TMP = tempfile.mkdtemp(prefix="ab-vite-")
|
||||
VITE_DEADLINE = 100
|
||||
|
||||
|
||||
def record(name, ms, note=""):
|
||||
print(f"{ms:8d} ms {name}" + (f" ({note})" if note else ""), flush=True)
|
||||
with open(CSV, "a", encoding="utf-8") as f:
|
||||
f.write(f'"{name}",{ms},"{note}"\n')
|
||||
f.flush()
|
||||
|
||||
|
||||
async def bind_once(label, vite_cache_dir):
|
||||
ws = os.path.join(TMP, f"ws-{label.replace(' ', '_')}")
|
||||
vt.seed_webapp_template_workspace(ws, _find_free_port())
|
||||
if not os.path.exists(os.path.join(ws, "frontend", "node_modules")):
|
||||
record(f"vite bind ({label})", -1, "no node_modules linked")
|
||||
return
|
||||
if vite_cache_dir:
|
||||
os.environ["OPENSWARM_VITE_CACHE_DIR"] = vite_cache_dir
|
||||
else:
|
||||
os.environ.pop("OPENSWARM_VITE_CACHE_DIR", None)
|
||||
rt = AppRuntime(f"ws-{label}", ws)
|
||||
t = time.perf_counter()
|
||||
await rt.start()
|
||||
deadline = time.perf_counter() + VITE_DEADLINE
|
||||
while rt.frontend_url is None and time.perf_counter() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
bound = rt.frontend_url is not None
|
||||
ms = round((time.perf_counter() - t) * 1000) if bound else -1
|
||||
try:
|
||||
await rt.stop()
|
||||
except Exception:
|
||||
pass
|
||||
record(f"vite bind ({label})", ms, "bound" if bound else f"TIMEOUT {VITE_DEADLINE}s")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"temp: {TMP}", flush=True)
|
||||
for label, cache in (("cold vite cache", os.path.join(TMP, "vite_cold")),
|
||||
("warm shared cache", None)):
|
||||
try:
|
||||
await bind_once(label, cache)
|
||||
except Exception as e:
|
||||
record(f"vite bind ({label})", -1, f"ERR {type(e).__name__}: {e}")
|
||||
print("done", flush=True)
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Per-phase + per-SubApp-lifespan boot profiler (winv2).
|
||||
|
||||
Warm import is ~1.3s but backend-http-ready is ~9-10s, so the gap is the
|
||||
lifespan startup (SubApp lifespans are entered sequentially in config/Apps.py
|
||||
before uvicorn serves). This times each one to find what blocks the HTTP bind.
|
||||
|
||||
Run with the bundled interpreter from the resources dir, e.g.:
|
||||
python-env/python.exe docs/perf/winv2/profile_boot.py
|
||||
It spawns the same subprocesses a real boot does (9router etc.); the
|
||||
AsyncExitStack unwinds at the end. Kill any straggler node/9router after.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
os.environ.setdefault("OPENSWARM_AUTH_TOKEN", "x")
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
import backend.main # noqa: F401 (builds main_app; full import tree)
|
||||
_import_ms = (time.perf_counter() - _t0) * 1000
|
||||
|
||||
from contextlib import AsyncExitStack # noqa: E402
|
||||
|
||||
from backend.apps.health.health import health # noqa: E402
|
||||
from backend.apps.agents.agents import agents # noqa: E402
|
||||
from backend.apps.skills.skills import skills # noqa: E402
|
||||
from backend.apps.tools_lib.tools_lib import tools_lib # noqa: E402
|
||||
from backend.apps.modes.modes import modes # noqa: E402
|
||||
from backend.apps.settings.settings import settings # noqa: E402
|
||||
from backend.apps.mcp_registry.mcp_registry import mcp_registry # noqa: E402
|
||||
from backend.apps.skill_registry.skill_registry import skill_registry # noqa: E402
|
||||
from backend.apps.outputs.outputs import outputs # noqa: E402
|
||||
from backend.apps.dashboards.dashboards import dashboards # noqa: E402
|
||||
from backend.apps.swarm.swarm import swarm # noqa: E402
|
||||
from backend.apps.service.service import service # noqa: E402
|
||||
from backend.apps.subscription.router import subscription # noqa: E402
|
||||
from backend.apps.auth.router import auth # noqa: E402
|
||||
from backend.apps.web.web import web # noqa: E402
|
||||
from backend.apps.agents.proxy.anthropic_proxy import anthropic_proxy # noqa: E402
|
||||
|
||||
SUBS = [health, agents, skills, tools_lib, modes, settings, mcp_registry,
|
||||
skill_registry, outputs, dashboards, swarm, service, subscription,
|
||||
auth, web, anthropic_proxy]
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"{_import_ms:8.0f} ms import backend.main (full tree)")
|
||||
print("-" * 48)
|
||||
total = 0.0
|
||||
async with AsyncExitStack() as stack:
|
||||
for s in SUBS:
|
||||
t = time.perf_counter()
|
||||
try:
|
||||
await asyncio.wait_for(stack.enter_async_context(s.lifespan()), timeout=60)
|
||||
except Exception as e:
|
||||
print(f" ERR lifespan {s.name}: {type(e).__name__}")
|
||||
continue
|
||||
dt = (time.perf_counter() - t) * 1000
|
||||
total += dt
|
||||
print(f"{dt:8.0f} ms lifespan {s.name}")
|
||||
print("-" * 48)
|
||||
print(f"{total:8.0f} ms all lifespans")
|
||||
print(f"{_import_ms + total:8.0f} ms import + lifespans (approx backend-ready floor)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Re-extract real packaged-app startup timings from the installed app's backend
|
||||
# log. Prints one row per launch: timestamp, version, app-launch ms,
|
||||
# first-paint ms, backend-http-ready ms. Pipe to a CSV for the metrics table.
|
||||
#
|
||||
# Usage: bash profile_startup.sh [path-to-backend.log]
|
||||
# Default log: AppData/Roaming/openswarm/data/backend.log
|
||||
|
||||
LOG="${1:-$HOME/AppData/Roaming/openswarm/data/backend.log}"
|
||||
if [[ ! -f "$LOG" ]]; then
|
||||
echo "no backend.log at $LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "launch_ts,version,app_launch_ms,first_paint_ms,backend_http_ready_ms,class"
|
||||
awk '
|
||||
/===== launch/ {
|
||||
if (ts != "") emit()
|
||||
ts=$3; ver=""
|
||||
for (i=1;i<=NF;i++) if ($i ~ /^\(app$/) { ver=$(i+1); gsub(/,/,"",ver) }
|
||||
al=""; fp=""; br=""
|
||||
}
|
||||
/\[perf\] app-launch t=/ { sub(/.*t=/,""); al=$0 }
|
||||
/\[perf\] first-paint t=/ { sub(/.*t=/,""); fp=$0 }
|
||||
/\[perf\] backend-http-ready t=/ { sub(/.*t=/,""); br=$0 }
|
||||
END { if (ts != "") emit() }
|
||||
function emit() {
|
||||
cls = (br+0 > 20000) ? "cold" : "warm"
|
||||
printf "%s,%s,%s,%s,%s,%s\n", ts, ver, al, fp, br, cls
|
||||
}
|
||||
' "$LOG"
|
||||
@@ -0,0 +1,3 @@
|
||||
metric,before_ms,after_ms
|
||||
cold backend-ready (54-138s -> 22.5s),96000,22500
|
||||
warm backend-ready (9-10s -> 5.0s),9500,5000
|
||||
|
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="320" font-family="Segoe UI, sans-serif">
|
||||
<text x="60" y="30" font-size="17" font-weight="600" fill="#1a1d27">signed v1.3.87 startup: before vs after (seconds, lower is better)</text>
|
||||
<line x1="60" y1="230" x2="870" y2="230" stroke="#e2e6ef"/>
|
||||
<text x="52" y="234" font-size="11" fill="#8892a4" text-anchor="end">0s</text>
|
||||
<line x1="60" y1="143" x2="870" y2="143" stroke="#e2e6ef"/>
|
||||
<text x="52" y="147" font-size="11" fill="#8892a4" text-anchor="end">48s</text>
|
||||
<line x1="60" y1="56" x2="870" y2="56" stroke="#e2e6ef"/>
|
||||
<text x="52" y="60" font-size="11" fill="#8892a4" text-anchor="end">96s</text>
|
||||
<rect x="134.9" y="56.0" width="121.5" height="174.0" fill="#d64545" rx="2"/>
|
||||
<text x="195.7" y="52.0" font-size="11" fill="#1a1d27" text-anchor="middle">96.0s</text>
|
||||
<rect x="256.4" y="189.2" width="121.5" height="40.8" fill="#2e9e5b" rx="2"/>
|
||||
<text x="317.2" y="185.2" font-size="11" fill="#1a1d27" text-anchor="middle">22.5s</text>
|
||||
<text x="262.5" y="252" font-size="11" fill="#8892a4" text-anchor="middle">cold backend-ready (54-138s -> 22.5s)</text>
|
||||
<rect x="539.9" y="212.8" width="121.5" height="17.2" fill="#d64545" rx="2"/>
|
||||
<text x="600.7" y="208.8" font-size="11" fill="#1a1d27" text-anchor="middle">9.5s</text>
|
||||
<rect x="661.4" y="220.9" width="121.5" height="9.1" fill="#2e9e5b" rx="2"/>
|
||||
<text x="722.2" y="216.9" font-size="11" fill="#1a1d27" text-anchor="middle">5.0s</text>
|
||||
<text x="667.5" y="252" font-size="11" fill="#8892a4" text-anchor="middle">warm backend-ready (9-10s -> 5.0s)</text>
|
||||
<rect x="700" y="56" width="12" height="12" fill="#d64545"/><text x="716" y="67" font-size="12" fill="#1a1d27">before (1.2.x)</text>
|
||||
<rect x="805" y="56" width="12" height="12" fill="#2e9e5b"/><text x="821" y="67" font-size="12" fill="#1a1d27">v1.3.87</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,62 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Task #10 automated checks: run AFTER installing the signed build. Confirms the
|
||||
winv2 structural fixes landed in the packaged app and reads the REAL cold/warm
|
||||
backend-http-ready from the app's own perf log. Manual GUI checks are in
|
||||
TASK10_CHECKLIST.md (App Builder preview, Skills list, onboarding).
|
||||
.USAGE
|
||||
pwsh docs\perf\winv2\validate_packaged.ps1
|
||||
#>
|
||||
param(
|
||||
[string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'openswarm'),
|
||||
[string]$BackendLog = (Join-Path $env:APPDATA 'openswarm\data\backend.log')
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pass = 0; $fail = 0
|
||||
function ok($m) { Write-Host " PASS $m" -ForegroundColor Green; $script:pass++ }
|
||||
function bad($m) { Write-Host " FAIL $m" -ForegroundColor Red; $script:fail++ }
|
||||
function info($m) { Write-Host " .. $m" -ForegroundColor DarkGray }
|
||||
|
||||
$app = Get-ChildItem $InstallRoot -Directory -Filter 'app-*' -EA SilentlyContinue | Sort-Object Name | Select-Object -Last 1
|
||||
if (-not $app) { throw "no app-* under $InstallRoot (install the build first)" }
|
||||
$res = Join-Path $app.FullName 'resources'
|
||||
Write-Host "Validating packaged build: $res`n"
|
||||
|
||||
# #9 item 4: asar trimmed
|
||||
$asar = Join-Path $res 'app.asar'
|
||||
if (Test-Path $asar) {
|
||||
$asarMB = [math]::Round((Get-Item $asar).Length / 1MB, 1)
|
||||
if ($asarMB -lt 50) { ok "app.asar = ${asarMB} MB (trimmed; was ~607 MB)" } else { bad "app.asar = ${asarMB} MB (expected < 50)" }
|
||||
$insp = Join-Path $PSScriptRoot 'inspect_asar.js'
|
||||
if ((Get-Command node -EA SilentlyContinue) -and (Test-Path $insp)) {
|
||||
$out = & node $insp $asar 2>&1 | Out-String
|
||||
if ($out -match 'python-env|build-staging') { bad "asar STILL contains python-env/build-staging" } else { ok "asar excludes python-env + build-staging" }
|
||||
}
|
||||
} else { bad "app.asar not found" }
|
||||
|
||||
# still shipped unpacked (runtime reads these)
|
||||
if (Test-Path (Join-Path $res 'python-env\python.exe')) { ok "python-env shipped unpacked" } else { bad "python-env\python.exe missing" }
|
||||
if (Test-Path (Join-Path $res 'node\x64\node.exe')) { ok "node bundled" } else { bad "node\x64\node.exe missing" }
|
||||
|
||||
# Bug #1: skills snapshot
|
||||
if (Test-Path (Join-Path $res 'backend\apps\skill_registry\skills_snapshot.json')) { ok "skills snapshot shipped (catalog never empty)" } else { bad "skills_snapshot.json missing" }
|
||||
|
||||
# #9 item 2 / Bug #2: webapp node_modules pre-extracted or archive
|
||||
$cache = Join-Path $res 'backend\apps\outputs\webapp_template_cache'
|
||||
if (Test-Path (Join-Path $cache '*\node_modules\vite\bin\vite.js')) { ok "webapp node_modules PRE-EXTRACTED (zero first-app extract)" }
|
||||
elseif (Test-Path (Join-Path $cache 'node_modules.*.tar.gz')) { info "webapp node_modules shipped as .tar.gz (extract path, not pre-extracted)" }
|
||||
else { bad "no webapp node_modules tree/archive in resources" }
|
||||
|
||||
# perf: real cold/warm backend-http-ready from the app's own log
|
||||
if (Test-Path $BackendLog) {
|
||||
$m = Select-String -Path $BackendLog -Pattern 'backend-http-ready t=(\d+)' -AllMatches
|
||||
$vals = @($m.Matches | ForEach-Object { [int]$_.Groups[1].Value })
|
||||
if ($vals.Count) {
|
||||
$recent = ($vals | Select-Object -Last 6 | ForEach-Object { [math]::Round($_ / 1000, 1) }) -join 's, '
|
||||
info "backend-http-ready recent: ${recent}s (baseline: warm ~9-10s, cold 54-138s)"
|
||||
info "latest: $([math]::Round($vals[-1]/1000,1))s -- first launch after install = COLD; relaunch for warm"
|
||||
} else { info "no backend-http-ready markers yet" }
|
||||
} else { info "no backend.log yet (launch the app once first)" }
|
||||
|
||||
Write-Host "`n$pass passed, $fail failed. Manual GUI checks: TASK10_CHECKLIST.md (App Builder, Skills, onboarding)."
|
||||
if ($fail) { exit 1 }
|
||||
+131
-44
@@ -1225,7 +1225,15 @@ function createWindow() {
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
if (isDev && url.startsWith('http://localhost:3000')) return;
|
||||
// Same-origin navigations are the app's own routing (reload, hash routes),
|
||||
// never an external link to pop into a browser card. The old port-specific
|
||||
// exemptions missed prod (renderer on 127.0.0.1:4173, not localhost:3000 or
|
||||
// file://), so a reload, e.g. Restart tour, got intercepted and re-opened
|
||||
// as a browser card loading the app itself (the recursive nested window).
|
||||
try {
|
||||
const current = mainWindow.webContents.getURL();
|
||||
if (current && new URL(url).origin === new URL(current).origin) return;
|
||||
} catch (_) {}
|
||||
if (url.startsWith('file://')) return;
|
||||
event.preventDefault();
|
||||
mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
|
||||
@@ -1274,40 +1282,27 @@ function createWindow() {
|
||||
// closing windows as part of its pipeline.
|
||||
mainWindow.on('close', (e) => {
|
||||
console.log(`[diag][main] mainWindow close (quitInitiated=${quitInitiated})`);
|
||||
// macOS close-to-dock: a close that is not part of a real quit (Cmd+Q,
|
||||
// dock Quit, logout, updater — all fire before-quit first, flipping
|
||||
// quitInitiated) gets prevented and the window HIDES instead. This keeps
|
||||
// the renderer, webviews, and running agents fully alive, so both the
|
||||
// user's Cmd+W/red-X and the un-attributed programmatic closer behind
|
||||
// the 1.2.77 self-quits cost nothing: the next dock click shows the
|
||||
// same window back instantly (`activate` below). Real quits must pass
|
||||
// through — preventing a close during app.quit() cancels the quit
|
||||
// (Electron semantics), which is exactly what the flag guards against.
|
||||
// isInstallingUpdate must pass through: native quitAndInstall TERMINATES the app
|
||||
// by closing the window (with quitInitiated still false), so intercepting it here
|
||||
// hides the window and strands the update uninstalled. That was THE bug behind
|
||||
// "Restart & Update does nothing" on Mac. Let that close (and real quits) through.
|
||||
// macOS: the only way to land here with quitInitiated still false is the red
|
||||
// traffic-light button. Cmd+W is swallowed in before-input-event, renderer
|
||||
// window.close() is neutered above, and crash-recovery uses destroy() (which
|
||||
// skips 'close'). So a red-button click means "quit": route it through
|
||||
// app.quit() so before-quit drains the App Builder subprocesses and will-quit
|
||||
// kills the backend, instead of leaving a headless app running. Real quits
|
||||
// (Cmd+Q, dock Quit, logout) flip quitInitiated via before-quit first and pass
|
||||
// straight through. isInstallingUpdate must also pass through: native
|
||||
// quitAndInstall closes the window with quitInitiated still false, and
|
||||
// intercepting it strands the update (THE "Restart & Update does nothing" bug).
|
||||
if (process.platform === 'darwin' && !quitInitiated && !isInstallingUpdate) {
|
||||
e.preventDefault();
|
||||
// A staged update waiting + a user close = "apply it on the way out". Kick off
|
||||
// the install (arms ShipIt + drives a real quit) instead of just hiding, so the
|
||||
// red button finally updates instead of looping.
|
||||
// A staged update waiting + a user close = "apply it on the way out": the
|
||||
// install arms ShipIt and drives its own quit, so update instead of quitting.
|
||||
if (cachedUpdateStatus && cachedUpdateStatus.status === 'downloaded') {
|
||||
console.log('[updater] close with a staged update; applying it');
|
||||
installDownloadedUpdate();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (thisWindow.isFullScreen()) {
|
||||
// Hiding a fullscreen window strands a black space; leave
|
||||
// fullscreen first, then hide once the transition lands.
|
||||
thisWindow.once('leave-full-screen', () => { try { thisWindow.hide(); } catch (_) {} });
|
||||
thisWindow.setFullScreen(false);
|
||||
} else {
|
||||
thisWindow.hide();
|
||||
}
|
||||
console.log('[diag][main] close intercepted, window hidden (app + agents stay alive)');
|
||||
} catch (_) {}
|
||||
console.log('[diag][main] red-button close, quitting app');
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
mainWindow.on('closed', () => {
|
||||
@@ -1770,7 +1765,13 @@ app.whenReady().then(async () => {
|
||||
console.log(`[drm-req] ${details.method} ${details.url}`);
|
||||
for (const [k, v] of Object.entries(details.requestHeaders || {})) {
|
||||
if (/content-type|origin|referer|auth|accept/i.test(k)) {
|
||||
console.log(`[drm-req] ${k}: ${v}`);
|
||||
// Keep the auth scheme for debugging, never the token itself.
|
||||
let safe = v;
|
||||
if (/authorization/i.test(k)) {
|
||||
const sp = String(v).indexOf(' ');
|
||||
safe = sp > 0 ? `${String(v).slice(0, sp)} <redacted>` : '<redacted>';
|
||||
}
|
||||
console.log(`[drm-req] ${k}: ${safe}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1929,7 +1930,34 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Cmd+W is the default menu's "File > Close Window". Now that the red button
|
||||
// routes a close into app.quit(), an unguarded Cmd+W would tear down the whole
|
||||
// app + every running agent on a stray tab-close reflex (the exact 1.2.77
|
||||
// self-quit class). preventDefault here also blocks the menu accelerator
|
||||
// (electron/electron#19279), and because macOS dispatches that accelerator
|
||||
// against whichever webContents is focused, we have to guard the main window AND
|
||||
// its webview guests, not just one. mac-only; on Windows Ctrl+W is input.control
|
||||
// so this no-ops there and leaves that platform's close-on-last-window intact.
|
||||
function swallowCloseWindowShortcut(event, input) {
|
||||
if (
|
||||
input.type === 'keyDown' &&
|
||||
process.platform === 'darwin' &&
|
||||
input.meta && !input.control && !input.alt &&
|
||||
(input.key || '').toLowerCase() === 'w'
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
app.on('web-contents-created', (_event, contents) => {
|
||||
// Block Cmd+W from closing the main window, whether the window chrome or one of
|
||||
// its embedded webviews has focus. OAuth popups (their own 'window' contents,
|
||||
// created while isCreatingMainWindow is false) are left alone so the user can
|
||||
// still Cmd+W them shut.
|
||||
if (isCreatingMainWindow || contents.getType() === 'webview') {
|
||||
contents.on('before-input-event', swallowCloseWindowShortcut);
|
||||
}
|
||||
|
||||
// Override the user-agent on popup BrowserWindows (i.e. anything created
|
||||
// via window.open from the renderer, which includes the OAuth popup for
|
||||
// subscription connect flows). Electron's default UA includes an
|
||||
@@ -2086,6 +2114,7 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
cdpAutoAttachWired.delete(contents.id);
|
||||
cdpRoutesByWcId.delete(contents.id);
|
||||
webviewConsoleErrors.delete(contents.id);
|
||||
cdpTearingDown.delete(contents.id);
|
||||
});
|
||||
|
||||
contents.on('render-process-gone', () => {
|
||||
@@ -2095,6 +2124,7 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
cdpAutoAttachWired.delete(contents.id);
|
||||
cdpRoutesByWcId.delete(contents.id);
|
||||
webviewConsoleErrors.delete(contents.id);
|
||||
cdpTearingDown.delete(contents.id);
|
||||
});
|
||||
|
||||
// A heavy SPA can HANG the renderer without crashing it (a render-process-gone
|
||||
@@ -2204,16 +2234,13 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
console.log(`[diag][main] window-all-closed (platform=${process.platform}${process.platform === 'darwin' ? ', staying alive' : ', quitting'})`);
|
||||
// macOS: stay alive like a standard Mac app. We never install a custom
|
||||
// application menu, so Electron's DEFAULT menu ships File > Close Window
|
||||
// (Cmd+W) — and with a single window, quitting here turned "close the
|
||||
// window" into "tear down the backend and every running agent". The 1.2.77
|
||||
// prod self-quits all carried this exact signature (window close with no
|
||||
// preceding before-quit). Keeping the process alive de-fangs the whole
|
||||
// class: the dock icon stays, `activate` below reopens against the warm
|
||||
// backend in ~1s, and the [diag][main] close-cause logging identifies the
|
||||
// closer. Explicit quits (Cmd+Q, dock Quit) are untouched — Electron's
|
||||
// quit pipeline runs will-quit -> killBackend regardless of this handler.
|
||||
// macOS: don't quit just because the window list hit zero. The red button now
|
||||
// routes through app.quit() (which drives will-quit -> killBackend itself) and
|
||||
// Cmd+W is swallowed, so the only window-vanish that ISN'T already a real quit
|
||||
// is an unforeseen teardown (a renderer-level destroy that skipped 'close'). For
|
||||
// that stray case we stay alive as a standard Mac app rather than self-quitting
|
||||
// headless, and `activate` below rebuilds the window on the next dock click. The
|
||||
// 1.2.77 self-quits lived exactly here (window close with no before-quit).
|
||||
if (process.platform === 'darwin') {
|
||||
// An update install closed the window (native quitAndInstall) and now needs the
|
||||
// process to actually die so ShipIt can swap + relaunch; finish the quit instead
|
||||
@@ -2297,9 +2324,10 @@ app.on('will-quit', () => {
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// Dock-click after close-to-dock: the common case is a HIDDEN (not
|
||||
// destroyed) window — just show it again; renderer, webviews, and agents
|
||||
// never stopped, so this is instant and lossless.
|
||||
// Live window still around (minimized, or hidden by some stray path): surface
|
||||
// it instead of building a new one. The red button quits now, so the usual
|
||||
// dock-click-after-close lands in the destroyed-window fallback below; this
|
||||
// branch is the cheap, lossless path for the cases where a window survived.
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
try {
|
||||
if (mainWindow.isMinimized()) {
|
||||
@@ -2563,6 +2591,25 @@ ipcMain.handle('get-install-state', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Factory reset ("Erase all content and settings"). Stop the backend FIRST so
|
||||
// nothing rewrites the dir mid-wipe (on Windows a live process even locks the
|
||||
// files), wipe everything under userData/data, then relaunch into a clean first
|
||||
// run. install.json lives OUTSIDE /data so the install + affiliate identity
|
||||
// survives, exactly like a real reinstall would. Best-effort throughout: a
|
||||
// failed kill or wipe still relaunches rather than wedging the user.
|
||||
ipcMain.handle('hard-reset', async () => {
|
||||
try { killBackend(); } catch (e) { console.error('[hard-reset] killBackend failed', e); }
|
||||
try {
|
||||
const dataDir = path.join(app.getPath('userData'), 'data');
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
console.log('[hard-reset] wiped data dir');
|
||||
} catch (e) {
|
||||
console.error('[hard-reset] wipe failed', e);
|
||||
}
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CDP debugger bridge for the browser sub-agent
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2583,6 +2630,10 @@ const cdpChildSessions = new Map(); // wcId -> Map<sessionId, {frameId, parent
|
||||
const cdpAutoAttachWired = new Set(); // wcIds whose 'message' listener is attached
|
||||
const cdpRoutesByWcId = new Map(); // wcId -> Map<routeKey, entry> (tier-2 shadow-API capture)
|
||||
const webviewConsoleErrors = new Map(); // wcId -> [{level,message,source,line}] capped warn+error, read via BrowserGetConsole
|
||||
// wcIds whose CDP is being cleanly detached on the way to destruction. Blocks a
|
||||
// late agent command from RE-attaching (and re-enabling Network/auto-attach) as
|
||||
// the webview tears down, which would re-arm the freed-DevToolsSession SIGSEGV.
|
||||
const cdpTearingDown = new Set();
|
||||
|
||||
function wireChildSessions(wc) {
|
||||
const wcId = wc.id;
|
||||
@@ -2612,11 +2663,14 @@ function wireChildSessions(wc) {
|
||||
parentSessionId: sessionId || null,
|
||||
url: info.url || '',
|
||||
});
|
||||
// Enable perception + network domains and propagate auto-attach into nested OOPIF.
|
||||
// Enable perception domains on the child + propagate auto-attach into nested OOPIF.
|
||||
// Deliberately NO Network.enable here: a child iframe churns constantly, and a
|
||||
// Network notification arriving after the child detaches lands on a freed session
|
||||
// and SIGSEGVs the browser process (the mid-browse crash). Root Network still
|
||||
// captures the page's own routes; we only forgo transient child-iframe routes.
|
||||
const sid = params.sessionId;
|
||||
wc.debugger.sendCommand('Accessibility.enable', {}, sid).catch(() => {});
|
||||
wc.debugger.sendCommand('DOM.enable', {}, sid).catch(() => {});
|
||||
wc.debugger.sendCommand('Network.enable', {}, sid).catch(() => {});
|
||||
wc.debugger.sendCommand('Target.setAutoAttach',
|
||||
{ autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, sid).catch(() => {});
|
||||
} else if (method === 'Target.detachedFromTarget') {
|
||||
@@ -2635,6 +2689,11 @@ async function ensureDebuggerAttached(wc) {
|
||||
if (!wc || wc.isDestroyed()) {
|
||||
throw new Error('webContents is destroyed');
|
||||
}
|
||||
// Once a clean teardown has started, never re-attach: a re-attach here would
|
||||
// re-enable Network + auto-attach right as the session is being freed.
|
||||
if (cdpTearingDown.has(wc.id)) {
|
||||
throw new Error('webContents is tearing down');
|
||||
}
|
||||
if (wc.debugger.isAttached()) return;
|
||||
try {
|
||||
wc.debugger.attach('1.3');
|
||||
@@ -2656,6 +2715,25 @@ async function ensureDebuggerAttached(wc) {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Cleanly tear the DevTools session down BEFORE the webContents is destroyed:
|
||||
// turn off the two churn-prone domains (auto-attach, Network) so no child
|
||||
// sessions or network observers are live when Chromium frees the session, then
|
||||
// detach. Without this, a notification in the mojo pipe lands on a freed
|
||||
// DevToolsSession on the browser main thread and SIGSEGVs the whole app.
|
||||
// Bounded + fail-open: a wedged pipe must never block the card from closing.
|
||||
async function detachCdpCleanly(wc) {
|
||||
if (!wc || wc.isDestroyed()) return;
|
||||
cdpTearingDown.add(wc.id);
|
||||
let attached = false;
|
||||
try { attached = wc.debugger.isAttached(); } catch (_) { return; }
|
||||
if (!attached) return;
|
||||
const drain = (method, params) =>
|
||||
raceCdp(wc.debugger.sendCommand(method, params || {}), 1200, method).catch(() => {});
|
||||
await drain('Target.setAutoAttach', { autoAttach: false, waitForDebuggerOnStart: false, flatten: true });
|
||||
await drain('Network.disable', {});
|
||||
try { wc.debugger.detach(); } catch (_) { /* already detached / gone */ }
|
||||
}
|
||||
|
||||
// debugger.sendCommand can hang FOREVER when the target's pipe breaks without
|
||||
// a detach event (renderer process swap, wedged guest). Unraced, one hung call
|
||||
// poisons the per-card queue and every later command "times out" while
|
||||
@@ -2720,6 +2798,15 @@ ipcMain.handle('send-cdp-command', async (_event, wcId, method, params, sessionI
|
||||
}
|
||||
});
|
||||
|
||||
// Called by the renderer right before a browser card unmounts, so its CDP
|
||||
// session is drained + detached while the webContents is still alive.
|
||||
ipcMain.handle('cdp-detach-clean', async (_event, wcId) => {
|
||||
try {
|
||||
await detachCdpCleanly(getWebContentsById(wcId));
|
||||
} catch (_) { /* fail-open: never block the card's teardown */ }
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Renderer-side AX index cache helpers — the renderer stores its own copy
|
||||
// keyed by (browser_id, tab_id). The main process only stores per-wcId for
|
||||
// invalidation purposes.
|
||||
|
||||
@@ -24,9 +24,20 @@ static NSEvent *ClampOffWindowRelease(NSEvent *event) {
|
||||
return event;
|
||||
}
|
||||
NSWindow *win = [event window];
|
||||
NSPoint p;
|
||||
if (win && [win contentView]) {
|
||||
// Normal case: the captured window rode along on the event.
|
||||
p = [event locationInWindow];
|
||||
} else {
|
||||
// The original gap: a release off the source window (easy with a second
|
||||
// display) can arrive with no window attached, so the old code fail-opened
|
||||
// here and the crash slipped through. Fall back to the key/main window and
|
||||
// map the screen-space location into it so we can still snap it.
|
||||
win = [NSApp keyWindow] ?: [NSApp mainWindow];
|
||||
if (!win || ![win contentView]) return event;
|
||||
p = [win convertPointFromScreen:[event locationInWindow]];
|
||||
}
|
||||
NSView *content = [win contentView];
|
||||
if (!content) return event;
|
||||
NSPoint p = [event locationInWindow];
|
||||
NSSize ws = [win frame].size;
|
||||
NSRect cb = [content frame];
|
||||
// all the misfire-prone arithmetic lives in clamp_decision() so the property
|
||||
@@ -40,7 +51,7 @@ static NSEvent *ClampOffWindowRelease(NSEvent *event) {
|
||||
location:NSMakePoint(d.x, d.y)
|
||||
modifierFlags:[event modifierFlags]
|
||||
timestamp:[event timestamp]
|
||||
windowNumber:[event windowNumber]
|
||||
windowNumber:[win windowNumber]
|
||||
context:nil
|
||||
eventNumber:[event eventNumber]
|
||||
clickCount:[event clickCount]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.2.84",
|
||||
"version": "1.4.2",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"author": "openswarm-ai",
|
||||
"main": "main.js",
|
||||
@@ -41,6 +41,13 @@
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"**/*",
|
||||
"!python-env",
|
||||
"!python-env/**",
|
||||
"!build-staging",
|
||||
"!build-staging/**"
|
||||
],
|
||||
"icon": "build/icon.png",
|
||||
"mac": {
|
||||
"icon": "build/icon.icns",
|
||||
@@ -50,6 +57,7 @@
|
||||
],
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"notarize": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"extraResources": [
|
||||
|
||||
@@ -55,8 +55,11 @@ contextBridge.exposeInMainWorld('openswarm', {
|
||||
// Renderer attaches the ref to Stripe checkout + sign-in flows so
|
||||
// the cloud can credit the affiliate. Resolves to {} if no state yet.
|
||||
getInstallState: () => ipcRenderer.invoke('get-install-state'),
|
||||
// Factory reset: wipes the data dir and relaunches. Never resolves on success (the app exits first).
|
||||
hardReset: () => ipcRenderer.invoke('hard-reset'),
|
||||
connectSlack: () => ipcRenderer.invoke('connect-slack'),
|
||||
sendCdpCommand: (wcId, method, params, sessionId) => ipcRenderer.invoke('send-cdp-command', wcId, method, params, sessionId),
|
||||
cdpDetachClean: (wcId) => ipcRenderer.invoke('cdp-detach-clean', wcId),
|
||||
cdpCacheSet: (wcId, indexMap) => ipcRenderer.invoke('cdp-cache-set', wcId, indexMap),
|
||||
cdpCacheGet: (wcId) => ipcRenderer.invoke('cdp-cache-get', wcId),
|
||||
cdpCacheClear: (wcId) => ipcRenderer.invoke('cdp-cache-clear', wcId),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user