diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index 885efebe..f5295d54 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -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 = + '' + + 'You can close this tab, and any other Claude login tab still open.'; 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 (_) {} diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 5c97c8d4..a8338b0a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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 + 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//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) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 1933f463..44802b18 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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} diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 14ab253d..779ea6e0 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -941,6 +941,7 @@ async def run_browser_agent( done_called = False done_message = "" done_success = True + done_keep_open = False # Completion detection: once an irreversible SEND has confirmed, the goal is # met. The model otherwise stalls re-verifying what the confirm already proved # (measured: send done at turn ~11, then ~12 wasted perception turns). We drive @@ -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, diff --git a/backend/apps/agents/browser/browser_schema.py b/backend/apps/agents/browser/browser_schema.py index afc14fac..b4e59f2d 100644 --- a/backend/apps/agents/browser/browser_schema.py +++ b/backend/apps/agents/browser/browser_schema.py @@ -117,6 +117,17 @@ BROWSER_TOOLS_SCHEMA = [ "(login wall, missing info, something blocked you). Default true." ), }, + "keep_open": { + "type": "boolean", + "description": ( + "Set true ONLY when the result IS the open page and the user will keep " + "using it right now: a video or audio playing, a page you opened for them " + "to read or watch, a download you started, or a place left ready for them " + "to take over. The browser then stays put instead of closing. Leave false " + "(default) for info tasks where you just look something up and report the " + "answer back, since there's nothing left to keep on screen." + ), + }, }, "required": ["message"], }, @@ -885,9 +896,11 @@ SYSTEM_PROMPT = ( "tool, never by typing a sentence. Put your reply to the user in Done's `message`, " "written like a normal chat reply: what got done plus the human proof (the name, the " "time, what's now on screen), in one or two plain sentences with zero interface words. " - "Set `success` false if you couldn't finish. For irreversible actions, only report " - "success with real proof you actually observed (the name and where/when you saw it), " - "just phrased for a person, not for a machine." + "Set `success` false if you couldn't finish. Set `keep_open` true when the result is the " + "open page itself and the user keeps using it now (a video playing, a page opened to " + "read, a download started), so the browser stays instead of closing. For irreversible " + "actions, only report success with real proof you actually observed (the name and " + "where/when you saw it), just phrased for a person, not for a machine." ) MAX_TURNS = 40 diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index 95a8ad21..aef0b4dc 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -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"; diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index 0471734b..50a57779 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -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) diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 00313ee0..7151c147 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -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) diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index b6935496..484cca6d 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -98,6 +98,35 @@ def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names: ) +# A run of this many ToolSearch calls with no other tool between them is the +# "looping on ToolSearch" wedge: the model hunts for a gated MCP server's tools, +# which ToolSearch can never see, gets empty results, and retries. Two free +# calls (a power user with many activated MCPs may legitimately ToolSearch to +# load a deferred tool); redirect on the third. +TOOLSEARCH_LOOP_THRESHOLD = 3 + + +def toolsearch_loop_redirect(consecutive_toolsearch: int, gated_servers: list[str]) -> str | None: + """The feedback to hand a model that's stuck calling ToolSearch in a row. + None until it crosses the threshold; then a steer toward MCPActivate (the + only path to a gated server) plus a reminder its other tools are already + loaded. Pure so the loop-break boundary is unit-testable.""" + if consecutive_toolsearch < TOOLSEARCH_LOOP_THRESHOLD: + return None + reason = ( + "ToolSearch can't load anything here, every tool you can use is already " + "active and callable by name, so there's nothing to search for. " + ) + if gated_servers: + reason += ( + "If you need an app you don't see yet (email, calendar, drive, etc.), " + "it's gated: call MCPActivate(server_name) with one of these and its " + f"tools become callable next turn: {', '.join(gated_servers)}. " + ) + reason += "Stop calling ToolSearch." + return reason + + def _build_browser_context(dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None: """Build a context block listing browser cards and delegation instructions. @@ -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 ( + "\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" + "" + ) + + 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) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 3b1f0d17..3ef184a8 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -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 diff --git a/backend/apps/agents/session_credential.py b/backend/apps/agents/session_credential.py new file mode 100644 index 00000000..1facfad3 --- /dev/null +++ b/backend/apps/agents/session_credential.py @@ -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 diff --git a/backend/apps/agents/settings_meta_server.py b/backend/apps/agents/settings_meta_server.py new file mode 100644 index 00000000..63ee1816 --- /dev/null +++ b/backend/apps/agents/settings_meta_server.py @@ -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() diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 81dbf0c1..f5fc346d 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -363,10 +363,45 @@ async def generate_name(dashboard_id: str): return {"name": dashboard.name, "auto_named": True} +def _strip_orphan_session_cards(data: dict) -> None: + """Drop layout cards (and expanded ids) whose agent session no longer exists + anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and + GETs its session; a card pointing at a vanished session (e.g. an empty + never-saved session) 404s on every load and flashes a dead "connect a model" + card before the client reconciles it away. The `gone()` test is the exact + condition that makes GET /sessions/{id} 404, so it removes precisely those + cards and nothing else. Filtering the RESPONSE (never the stored file) is + non-destructive: a wrong check can only hide a card for one response, not + delete it. Drafts have no backend session yet, so they're always kept.""" + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.manager.session.session_store import _load_session_data + layout = data.get("layout") + if not isinstance(layout, dict): + return + cards = layout.get("cards") + if not isinstance(cards, dict): + return + + def gone(sid: str) -> bool: + if sid.startswith("draft-") or sid in agent_manager.sessions: + return False + return _load_session_data(sid) is None + + orphans = [sid for sid in cards if gone(sid)] + for sid in orphans: + cards.pop(sid, None) + if orphans: + exp = layout.get("expanded_session_ids") + if isinstance(exp, list): + layout["expanded_session_ids"] = [s for s in exp if s not in orphans] + + @dashboards.router.get("/{dashboard_id}") async def get_dashboard(dashboard_id: str): dashboard = _load(dashboard_id) - return dashboard.model_dump(mode="json") + data = dashboard.model_dump(mode="json") + _strip_orphan_session_cards(data) + return data @dashboards.router.put("/{dashboard_id}") diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 2b203965..f3729af5 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -40,6 +40,10 @@ class BrowserCardPosition(BaseModel): # Used by the frontend to auto-remove the browser when its owner agent # reaches a terminal completed/error state. spawned_by: Optional[str] = None + # When the agent leaves the deliverable on the page (a video playing, a page + # to read), it sets this so the frontend's auto-close on parent finish skips + # the card and the browser stays put. + keep_open: bool = False class NotePosition(BaseModel): diff --git a/backend/apps/nine_router/process.py b/backend/apps/nine_router/process.py index eee3e4a1..6e63119a 100644 --- a/backend/apps/nine_router/process.py +++ b/backend/apps/nine_router/process.py @@ -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 - # /router/server.js by scripts/fetch-router.sh at build time. + if _is_packaged: + # Packaged: run the pre-built standalone server staged at + # /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(): diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index ed740f0b..4fbc82e1 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -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 diff --git a/backend/apps/outputs/html_inject.py b/backend/apps/outputs/html_inject.py index c9fd8d1b..ba56f156 100644 --- a/backend/apps/outputs/html_inject.py +++ b/backend/apps/outputs/html_inject.py @@ -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 """ + + +def inject_runtime(html: bytes) -> bytes: + """Insert the runtime shim into an HTML document (before , else before + , else prepend). Returns bytes so callers can serve it directly.""" + try: + text = html.decode("utf-8") + except UnicodeDecodeError: + return html # not text we can safely rewrite; serve as-is + if "" in text: + out = text.replace("", _RUNTIME_SHIM + "\n", 1) + elif " str | None: + """Extract the app slug from a {slug}.openswarm.host Host header. Rejects the + apex, www, multi-label subdomains, and anything not slug-shaped.""" + host = (host or "").split(":")[0].lower() + suffix = "." + APPS_BASE_DOMAIN + if not host.endswith(suffix): + return None + sub = host[: -len(suffix)] + if not sub or "." in sub or sub == "www": + return None + return sub if _SLUG_RE.match(sub) else None + + +def client_ip(request: Request) -> str: + return request.headers.get("fly-client-ip") or (request.client.host if request.client else "unknown") + + +def _security_headers() -> dict[str, str]: + # The hard isolation is the separate apex (no openswarm.com cookies are reachable + # here). CSP is defense-in-depth: block embedding + sniffing. We deliberately do + # NOT lock script/connect-src, arbitrary apps need to run their own JS and call + # their own APIs; the pre-publish scan is what screens for abusive content. + return { + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": "frame-ancestors 'none'", + "Referrer-Policy": "no-referrer-when-downgrade", + "Cache-Control": "public, max-age=60", + } + + +@app.get("/__edge/health") +async def health() -> JSONResponse: + return JSONResponse({"ok": True}) + + +@app.post("/__compute") +async def edge_compute(request: Request) -> Response: + slug = slug_from_host(request.headers.get("host", "")) + if not slug: + return JSONResponse({"error": "unknown app"}, status_code=404) + if not _compute_limiter.allow(client_ip(request)): + return JSONResponse({"error": "Too many requests, slow down."}, status_code=429) + bundle = await get_bundle(slug) + if bundle is None: + return JSONResponse({"error": "app not found"}, status_code=404) + if not bundle.backend_code: + return JSONResponse({"error": "this app has no compute backend"}, status_code=404) + try: + payload = await request.json() + except Exception: + payload = {} + raw_input = payload.get("input_data", payload) if isinstance(payload, dict) else {} + input_data = raw_input if isinstance(raw_input, dict) else {} + try: + res = await run_backend(bundle.backend_code, input_data) + except UnsafeCodeError: + return JSONResponse({"error": "this app's backend can't run here"}, status_code=400) + except Exception: + return JSONResponse({"error": "compute failed"}, status_code=500) + return JSONResponse({"result": res.result, "stdout": res.stdout}) + + +@app.post("/__llm") +async def edge_llm(request: Request) -> Response: + slug = slug_from_host(request.headers.get("host", "")) + if not slug: + return JSONResponse({"error": "unknown app"}, status_code=404) + if not _llm_limiter.allow(client_ip(request)): + return JSONResponse( + {"type": "error", "error": {"type": "rate_limited", "message": "Too many requests."}}, + status_code=429, + ) + body = await request.body() + headers = { + "x-edge-auth": EDGE_AUTH_TOKEN, + "x-app-slug": slug, + "content-type": "application/json", + } + for k in ("anthropic-version", "anthropic-beta"): + v = request.headers.get(k) + if v: + headers[k] = v + + client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=None)) + upstream_req = client.build_request("POST", f"{CLOUD_INTERNAL_URL}/api/apps/internal/llm", headers=headers, content=body) + try: + upstream = await client.send(upstream_req, stream=True) + except httpx.HTTPError: + await client.aclose() + return JSONResponse( + {"type": "error", "error": {"type": "upstream_unreachable", "message": "This app's AI is unavailable."}}, + status_code=502, + ) + + async def relay(): + try: + async for chunk in upstream.aiter_raw(): + yield chunk + finally: + await upstream.aclose() + await client.aclose() + + return StreamingResponse( + relay(), + status_code=upstream.status_code, + media_type=upstream.headers.get("content-type", "application/json"), + ) + + +@app.get("/{path:path}") +async def serve_static(path: str, request: Request) -> Response: + slug = slug_from_host(request.headers.get("host", "")) + if not slug: + return HTMLResponse(apex_page(), status_code=404) + bundle = await get_bundle(slug) + if bundle is None: + return HTMLResponse(not_found_page(), status_code=404) + resolved = resolve_file(bundle, path) + if resolved is None: + return HTMLResponse(not_found_page(), status_code=404) + data, mime = resolved + if mime == "text/html": + # Give the page the published-app runtime (OUTPUT_COMPUTE / OUTPUT_LLM). + data = inject_runtime(data) + return Response(content=data, media_type=mime, headers=_security_headers()) diff --git a/openswarm-edge/app/ratelimit.py b/openswarm-edge/app/ratelimit.py new file mode 100644 index 00000000..e990478f --- /dev/null +++ b/openswarm-edge/app/ratelimit.py @@ -0,0 +1,44 @@ +"""Tiny in-memory per-key fixed-window rate limiter. Guards /__compute and /__llm +so one visitor or scraper can't burn a creator's budget or our CPU. Best-effort +and single-process: the cloud's budget ledger is the hard backstop, this just +keeps the obvious abuse out cheaply.""" +from __future__ import annotations + +import time + +# Hard ceiling on tracked keys so a flood of unique IPs can't grow the map without +# bound. Past it we evict the oldest-inserted keys in a batch, never the whole map: +# a dropped key just gets a fresh allowance, so the worst case is being briefly +# lenient to a few stale IPs, not wiping every live visitor's count at once. +_MAX_KEYS = 50_000 +_EVICT_BATCH = _MAX_KEYS // 10 + + +class RateLimiter: + def __init__(self, limit: int, window_seconds: float): + self.limit = limit + self.window = window_seconds + self._hits: dict[str, list[float]] = {} + + def allow(self, key: str) -> bool: + now = time.time() + if len(self._hits) > _MAX_KEYS: + for stale in list(self._hits)[:_EVICT_BATCH]: + self._hits.pop(stale, None) + bucket = self._hits.get(key) + if bucket is None: + bucket = [] + self._hits[key] = bucket + cutoff = now - self.window + # Drop timestamps that fell out of the window. + keep = 0 + for t in bucket: + if t >= cutoff: + break + keep += 1 + if keep: + del bucket[:keep] + if len(bucket) >= self.limit: + return False + bucket.append(now) + return True diff --git a/openswarm-edge/app/sandbox.py b/openswarm-edge/app/sandbox.py new file mode 100644 index 00000000..c7a0947e --- /dev/null +++ b/openswarm-edge/app/sandbox.py @@ -0,0 +1,123 @@ +"""Sandboxed Python runner for published apps' backend.py compute. + +VENDORED from backend/apps/outputs/executor.py (the desktop App Builder runtime). +Keep the allow/deny lists + the subprocess hardening in sync with that file; this +is the same data-shaping sandbox, just running in the edge instead of on the +desktop. Pure compute only: no network, no disk, no subprocess, no secrets. Safe +to run multi-tenant on one machine because nothing here can reach shared state.""" +from __future__ import annotations + +import ast +import asyncio +import json +import os +import sys +import tempfile +from dataclasses import dataclass + +TIMEOUT_SECONDS = 30 + +_ALLOWED_MODULES = frozenset({ + "json", "math", "re", "datetime", "collections", "itertools", + "functools", "statistics", "decimal", "fractions", "random", + "string", "textwrap", "unicodedata", "csv", "copy", "enum", + "dataclasses", "typing", "abc", "numbers", "uuid", "hashlib", + "base64", "binascii", "operator", "heapq", "bisect", "array", +}) + +_BLOCKED_BUILTINS = frozenset({ + "exec", "eval", "compile", "__import__", "open", "input", + "breakpoint", "exit", "quit", +}) + + +class UnsafeCodeError(Exception): + """AST validation rejected the backend code.""" + + +def validate_code_safety(code: str) -> None: + """Raise UnsafeCodeError on the first AST-visible risk. Published apps are + vetted at publish time, but we re-check here: the edge never trusts that the + bundle in storage matches what was scanned.""" + try: + tree = ast.parse(code) + except SyntaxError as e: + raise UnsafeCodeError(f"Syntax error: {e}") + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] not in _ALLOWED_MODULES: + raise UnsafeCodeError(f"import '{alias.name}' is not allowed") + elif isinstance(node, ast.ImportFrom): + if node.module and node.module.split(".")[0] not in _ALLOWED_MODULES: + raise UnsafeCodeError(f"import from '{node.module}' is not allowed") + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in _BLOCKED_BUILTINS: + raise UnsafeCodeError(f"builtin '{node.func.id}()' is not allowed") + + +def _minimal_env() -> dict: + return { + "PYTHONDONTWRITEBYTECODE": "1", + "LANG": os.environ.get("LANG", "C.UTF-8"), + "LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"), + "PYTHONUTF8": "1", + "PYTHONIOENCODING": "utf-8", + } + + +@dataclass +class ComputeResult: + result: dict + stdout: str + + +async def run_backend(code: str, input_data: dict) -> ComputeResult: + """Validate + execute user backend code in a hardened subprocess. The code + reads `input_data` (a global dict) and assigns a global `result` dict.""" + validate_code_safety(code) + + preamble = ( + "import json, sys, io, builtins\n" + "for _b in ('exec','eval','compile','open','input',\n" + " 'breakpoint','exit','quit'):\n" + " try: delattr(builtins, _b)\n" + " except AttributeError: pass\n" + "_orig_stdout = sys.stdout\n" + "_capture = io.StringIO()\n" + "sys.stdout = _capture\n" + "input_data = json.loads(sys.stdin.read())\n" + "result = {}\n" + ) + postamble = ( + "\nsys.stdout = _orig_stdout\n" + 'json.dump({"__stdout__": _capture.getvalue(), "__result__": result}, sys.stdout)\n' + ) + wrapper = preamble + code + postamble + + with tempfile.TemporaryDirectory(prefix="osw-edge-exec-") as workdir: + proc = await asyncio.create_subprocess_exec( + sys.executable, "-c", wrapper, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workdir, + env=_minimal_env(), + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=json.dumps(input_data).encode()), + timeout=TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise RuntimeError(f"compute timed out after {TIMEOUT_SECONDS}s") + + if proc.returncode != 0: + raise RuntimeError(f"compute failed: {stderr.decode(errors='replace').strip()[:500]}") + try: + parsed = json.loads(stdout.decode()) + except json.JSONDecodeError: + raise RuntimeError("compute did not return valid JSON") + return ComputeResult(result=parsed.get("__result__", {}), stdout=parsed.get("__stdout__", "")) diff --git a/openswarm-edge/fly.toml b/openswarm-edge/fly.toml new file mode 100644 index 00000000..c90a3101 --- /dev/null +++ b/openswarm-edge/fly.toml @@ -0,0 +1,40 @@ +# openswarm-edge: serves *.openswarm.host. Public-facing, least-privileged. The +# only secrets it gets (via `fly secrets`, never here) are a READ-ONLY Tigris key +# and EDGE_AUTH_TOKEN. Its call to the cloud rides the private 6PN mesh, not the +# public internet. Wildcard cert: `fly certs create "*.openswarm.host" -a openswarm-edge`. +app = 'openswarm-edge' +primary_region = 'iad' +kill_signal = 'SIGINT' +kill_timeout = '30s' + +[build] + dockerfile = 'Dockerfile' + +[env] + PORT = '8080' + APPS_BASE_DOMAIN = 'openswarm.host' + # Private 6PN address of the cloud app (same org). Not public. + OPENSWARM_CLOUD_INTERNAL_URL = 'http://openswarm-cloud.internal:8080' + TIGRIS_ENDPOINT = 'https://fly.storage.tigris.dev' + TIGRIS_BUCKET = 'openswarm-app-bundles' + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'off' + min_machines_running = 1 + [http_service.concurrency] + type = 'requests' + hard_limit = 250 + soft_limit = 200 + [[http_service.checks]] + interval = '30s' + timeout = '5s' + grace_period = '10s' + method = 'get' + path = '/__edge/health' + +[[vm]] + cpu_kind = 'shared' + cpus = 1 + memory_mb = 512 diff --git a/openswarm-edge/requirements.txt b/openswarm-edge/requirements.txt new file mode 100644 index 00000000..e550411a --- /dev/null +++ b/openswarm-edge/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +boto3==1.35.90 +httpx==0.28.1 diff --git a/openswarm-edge/tests/test_edge.py b/openswarm-edge/tests/test_edge.py new file mode 100644 index 00000000..9d4870c0 --- /dev/null +++ b/openswarm-edge/tests/test_edge.py @@ -0,0 +1,102 @@ +"""Unit tests for the edge's pure logic: Host->slug parsing, path-safe file +resolution, the rate limiter, and the vendored sandbox. The Tigris fetch + cloud +proxy need live services and are exercised in the staging E2E, not here. + +Run with: .venv/bin/python -m pytest tests/test_edge.py +""" +import asyncio +import io +import os +import sys +import tarfile + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.main import slug_from_host +from app.bundles import unpack, resolve_file +from app.inject import inject_runtime +from app.ratelimit import RateLimiter +from app.sandbox import validate_code_safety, run_backend, UnsafeCodeError + + +def test_slug_from_host(): + assert slug_from_host("notes.openswarm.host") == "notes" + assert slug_from_host("notes.openswarm.host:443") == "notes" + assert slug_from_host("UPPER.openswarm.host") == "upper" + assert slug_from_host("openswarm.host") is None # apex + assert slug_from_host("www.openswarm.host") is None # www + assert slug_from_host("a.b.openswarm.host") is None # multi-label + assert slug_from_host("notes.evil.com") is None # wrong domain + assert slug_from_host("bad_slug.openswarm.host") is None # underscore + + +def _mk_tar(files: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as t: + for name, data in files.items(): + info = tarfile.TarInfo(name=name) + info.size = len(data) + t.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def test_resolve_file_paths(): + b = unpack(_mk_tar({ + "index.html": b"home", + "assets/app.js": b"console.log(1)", + "backend.py": b"result={}", + })) + assert resolve_file(b, "/")[0] == b"home" + assert resolve_file(b, "assets/app.js")[1] == "text/javascript" + assert resolve_file(b, "deep/spa/route")[0] == b"home" # SPA fallback + assert resolve_file(b, "backend.py")[0] == b"home" # never serve source + assert resolve_file(b, "../../etc/passwd")[0] == b"home" # traversal blocked + + +def test_backend_code_available_for_compute_not_static(): + b = unpack(_mk_tar({"index.html": b"x", "backend.py": b"import math\nresult={}"})) + assert b.backend_code == "import math\nresult={}" + data, _ = resolve_file(b, "backend.py") + assert data == b"x" + + +def test_rate_limiter(): + rl = RateLimiter(limit=3, window_seconds=100) + assert all(rl.allow("ip1") for _ in range(3)) + assert rl.allow("ip1") is False # 4th over the limit + assert rl.allow("ip2") is True # a different key is independent + + +def test_sandbox_rejects_unsafe_and_allows_safe(): + try: + validate_code_safety("import os\nresult={}") + assert False, "expected UnsafeCodeError" + except UnsafeCodeError: + pass + validate_code_safety("import math\nresult={'x': math.pi}") # no raise + + +def test_sandbox_runs_safe_code(): + res = asyncio.run(run_backend("result = {'sum': sum(input_data['nums'])}", {"nums": [1, 2, 3]})) + assert res.result == {"sum": 6} + + +def test_inject_runtime(): + out = inject_runtime(b"xhi").decode() + assert "OUTPUT_COMPUTE" in out and "OUTPUT_LLM" in out + assert out.index("OUTPUT_COMPUTE") < out.index("") # injected inside + # no head/body: shim is prepended, original content preserved + bare = inject_runtime(b"
bare
").decode() + assert "OUTPUT_COMPUTE" in bare and bare.endswith("
bare
") + + +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() diff --git a/scripts/add-defender-exclusion.ps1 b/scripts/add-defender-exclusion.ps1 new file mode 100644 index 00000000..4537c739 --- /dev/null +++ b/scripts/add-defender-exclusion.ps1 @@ -0,0 +1,73 @@ +<# +.SYNOPSIS + #9 item 5 (DRAFT, opt-in, NEVER silent): add a Windows Defender exclusion for + OpenSwarm's install + data dirs. This is the nuclear cold-start fix -- it stops + Defender real-time-scanning those folders entirely, which is the root of the + 54-138s post-update cold launch AND the ~14s first-app extract. + +.SECURITY + Excluding a folder from Defender reduces AV coverage of it. This must ALWAYS be + an explicit, informed user choice -- never auto-run, never a startup prompt. The + install is Azure code-signed, so the risk is bounded, but the user owns the + call. Fully reversible with -Remove. Requires admin (Add/Remove-MpPreference do). + +.USAGE + pwsh scripts\add-defender-exclusion.ps1 # show plan + paths, change nothing + pwsh scripts\add-defender-exclusion.ps1 -Status # list current openswarm exclusions + pwsh scripts\add-defender-exclusion.ps1 -Apply # add (run elevated) + pwsh scripts\add-defender-exclusion.ps1 -Remove # undo (run elevated) +#> +param( + [switch]$Apply, + [switch]$Remove, + [switch]$Status +) + +$ErrorActionPreference = 'Stop' + +# The three trees Defender rescans on launch / first-app: the Squirrel install +# (executables + python-env + node_modules), the Electron user data, and the +# warm caches. +$paths = @( + (Join-Path $env:LOCALAPPDATA 'openswarm'), + (Join-Path $env:APPDATA 'openswarm'), + (Join-Path $env:USERPROFILE '.openswarm') +) | Where-Object { $_ } + +function Test-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal $id).IsInRole( + [Security.Principal.WindowsBuiltinRole]::Administrator) +} + +if ($Status) { + try { + $ex = (Get-MpPreference).ExclusionPath | Where-Object { $_ -match 'openswarm' } + if ($ex) { $ex | ForEach-Object { Write-Host " excluded: $_" } } else { Write-Host " (no openswarm Defender exclusions set)" } + } catch { + Write-Warning "Defender not queryable here (non-Defender AV, or needs elevation): $_" + } + return +} + +Write-Host "OpenSwarm Defender exclusion (OPT-IN). Would apply to:" +$paths | ForEach-Object { Write-Host " $_" } +Write-Host "" +Write-Host "SECURITY: this stops Windows Defender from real-time-scanning those folders." +Write-Host "Only do this if you trust this install (it is code-signed). Reversible with -Remove." + +if (-not ($Apply -or $Remove)) { + Write-Host "" + Write-Host "DRY RUN -- nothing changed. Re-run ELEVATED with -Apply (add), -Remove (undo), or -Status (list)." + return +} + +if (-not (Test-Admin)) { + throw "Needs admin. Re-run from an elevated PowerShell (Add/Remove-MpPreference require elevation)." +} + +foreach ($p in $paths) { + if ($Apply) { Add-MpPreference -ExclusionPath $p; Write-Host "added exclusion: $p" } + else { Remove-MpPreference -ExclusionPath $p; Write-Host "removed exclusion: $p" } +} +Write-Host "Done. Verify with -Status." diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 9413c2f6..2755e678 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -281,6 +281,10 @@ if (-not (Test-Path (Join-Path $ProjectRoot 'electron\python-env'))) { } Write-Host "Python environment ready." Write-Host "" +# NOTE: #9 items 1+3 (zip stdlib + pyc-only site-packages) were measured to give +# NO cold-start benefit (cold is native-binary-scan-bound, not file-count-bound), +# so they are NOT wired in. scripts/zip-python-stdlib.ps1 + strip-py-to-pyc.ps1 +# remain as drafts. The cold lever is the opt-in Defender exclusion (item 5). # --- Step 3: Fetch Router from npm --- # The 9router Next.js server is published as an npm package with a pre-built @@ -363,6 +367,56 @@ if (Test-Path $EnvExampleSrc) { Copy-Item -Force $EnvExampleSrc $EnvExampleDst Write-Host "Restored webapp_template/.env.example (stripped by the .env.* exclude)" } + +# --- Step 4b: Pre-build the webapp-template node_modules archive (.tar.gz). +# The Windows build never shipped any node_modules, and the bundled node has no +# npm, so the App Builder frontend had no way to get its deps; the preview died +# with the misleading "backend exited with code 1". We ship a single compressed +# archive (mirrors the Mac build's step 3c); the runtime's _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). +# NOTE: we deliberately do NOT ship node_modules pre-extracted into resources -- +# that adds ~30k tiny files which made electron-builder/Squirrel LZMA compression +# blow the build past 50 min and bloats the installer. One .tar.gz (~26 MB) keeps +# the build fast and the installer small. Built natively so the esbuild/rollup +# win32 binaries are correct. Non-fatal: a failure warns but does not break the +# build. Digest == _warm_cache_digest() (sha256 of frontend/package.json, 12 hex). +Write-Host "[4b] Pre-building webapp-template node_modules archive (.tar.gz)..." +try { + $TmplFrontend = Join-Path $Staging 'backend\apps\outputs\webapp_template\frontend' + $PkgJson = Join-Path $TmplFrontend 'package.json' + if (-not (Test-Path $PkgJson)) { throw "template package.json not found at $PkgJson" } + $Digest = (Get-FileHash -Algorithm SHA256 $PkgJson).Hash.ToLower().Substring(0, 12) + $CacheDir = Join-Path $Staging 'backend\apps\outputs\webapp_template_cache' + New-Item -ItemType Directory -Force -Path $CacheDir | Out-Null + $OutArchive = Join-Path $CacheDir "node_modules.$Digest.tar.gz" + $WorkDir = Join-Path $env:TEMP "os-tmpl-nm-$([guid]::NewGuid())" + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + try { + Copy-Item -Force $PkgJson (Join-Path $WorkDir 'package.json') + $Lock = Join-Path $TmplFrontend 'package-lock.json' + Push-Location $WorkDir + if (Test-Path $Lock) { + Copy-Item -Force $Lock (Join-Path $WorkDir 'package-lock.json') + & npm ci --prefer-offline --no-audit --no-fund --loglevel=error + } else { + & npm install --prefer-offline --no-audit --no-fund --loglevel=error + } + if ($LASTEXITCODE -ne 0) { throw "npm install/ci failed ($LASTEXITCODE)" } + if (-not (Test-Path (Join-Path $WorkDir 'node_modules'))) { throw "no node_modules produced" } + # tar.exe (bsdtar) ships with Windows 10+; archive root is node_modules/. + & tar -czf $OutArchive -C $WorkDir node_modules + if ($LASTEXITCODE -ne 0) { throw "tar failed ($LASTEXITCODE)" } + Pop-Location + $ArchMB = (Get-Item $OutArchive).Length / 1MB + Write-Host ("[4b] webapp-template archive staged: node_modules.$Digest.tar.gz ({0:N1} MB)" -f $ArchMB) + } finally { + if ((Get-Location).Path -eq $WorkDir) { Pop-Location } + if (Test-Path $WorkDir) { Remove-Item -Recurse -Force $WorkDir } + } +} catch { + Write-Warning "[4b] webapp-template archive build FAILED: $_ (App Builder first-app falls back to live npm; non-fatal)" +} # data: backend/config/paths.py points DATA_ROOT at %APPDATA%/OpenSwarm/data in # packaged mode and no code seeds from the bundle, so the entire shipped # backend/data/ tree was dead weight (and was leaking the dev machine's diff --git a/scripts/strip-py-to-pyc.ps1 b/scripts/strip-py-to-pyc.ps1 new file mode 100644 index 00000000..083eef86 --- /dev/null +++ b/scripts/strip-py-to-pyc.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + #9 item 3 (DRAFT, build-gated): ship site-packages as sourceless .pyc only, so + Windows Defender has ~half as many loose files to scan on a cold launch after + an update. Compiles each module.py -> legacy module.pyc (next to the source, + NOT in __pycache__), then deletes the .py whose .pyc exists and removes the + redundant __pycache__ dirs. Python imports the sourceless .pyc directly. + +.SCOPE + TARGET SITE-PACKAGES ONLY by default. Do NOT strip the backend app code: the + swarm-debug debugger reads our own .py source for frame annotation, and we want + readable tracebacks for first-party code. Stdlib is handled by #9 item 1 + (zip-python-stdlib.ps1); this is the dependency tree. + +.STATUS + UNVALIDATED. Default is -DryRun (reports only). The .pyc magic must match the + SHIPPED interpreter, so compile with the bundled python (-PythonExe). Some + packages read their own source (inspect.getsource) and break sourceless; keep + a keep-list and validate on a packaged EXE (Task #10) BEFORE wiring into a + release. Intentionally NOT called by build-app-win.ps1 yet. + +.USAGE + pwsh scripts\strip-py-to-pyc.ps1 -TargetDir electron\python-env\Lib\site-packages # dry run + pwsh scripts\strip-py-to-pyc.ps1 -TargetDir \site-packages -PythonExe \python.exe -Apply +#> +param( + [Parameter(Mandatory = $true)][string]$TargetDir, + [string]$PythonExe, + [switch]$Apply +) + +$ErrorActionPreference = 'Stop' +if (-not (Test-Path $TargetDir)) { throw "no target dir: $TargetDir" } + +# Packages that read their own .py at runtime (inspect.getsource / exec of source +# / .py-relative data) -> keep their source. Conservative starting set; expand +# whatever validation flags. Matched against the top-level package dir name. +$KeepSource = @('pip', 'setuptools', 'pkg_resources', '_distutils_hack') + +$allPy = Get-ChildItem -Recurse -File $TargetDir -Filter *.py -ErrorAction SilentlyContinue +$py = $allPy | Where-Object { + $rel = $_.FullName.Substring($TargetDir.Length).TrimStart('\', '/') + $top = ($rel -split '[\\/]')[0] + $KeepSource -notcontains $top +} +$pyCount = ($py | Measure-Object).Count +$pyMB = [math]::Round((($py | Measure-Object -Property Length -Sum).Sum) / 1MB, 1) +$pycacheDirs = (Get-ChildItem -Recurse -Directory $TargetDir -Filter __pycache__ -ErrorAction SilentlyContinue | Measure-Object).Count +Write-Host ("#9 item 3: {0} .py files ({1} MB) eligible under {2}" -f $pyCount, $pyMB, $TargetDir) +Write-Host ("keep-source packages: {0} | __pycache__ dirs present: {1}" -f ($KeepSource -join ', '), $pycacheDirs) + +if (-not $Apply) { + Write-Host "DRY RUN. -Apply compiles to legacy .pyc (compileall -b) next to each source," + Write-Host "deletes each .py whose .pyc now exists, and removes __pycache__. Validate (Task #10):" + Write-Host " 1. python.exe -c 'import backend.main' resolves (deps import sourceless)" + Write-Host " 2. boot the packaged backend; exercise agents/app-builder/skills/MCP" + Write-Host " 3. measure cold backend-http-ready vs baseline_startup.csv" + return +} + +if (-not $PythonExe) { throw "-PythonExe is required for -Apply (must be the SHIPPED interpreter; .pyc magic must match)" } +if (-not (Test-Path $PythonExe)) { throw "no python at $PythonExe" } + +# 1. Compile to legacy sourceless .pyc next to each source (-b). -q quiet; it +# continues past files that fail to compile (py2-only, optional) -> those keep +# their .py since no sibling .pyc is produced. +& $PythonExe -m compileall -b -q $TargetDir +# compileall returns nonzero if ANY file failed; that is expected for odd files, +# so we don't treat it as fatal -- we only delete .py that actually got a .pyc. +$global:LASTEXITCODE = 0 + +# 2. Delete each eligible .py that now has a sibling .pyc. +$deleted = 0 +foreach ($f in $py) { + $pyc = [System.IO.Path]::ChangeExtension($f.FullName, '.pyc') + if (Test-Path $pyc) { Remove-Item -Force $f.FullName; $deleted++ } +} +# 3. Remove redundant __pycache__ (we use the legacy .pyc next to source). +Get-ChildItem -Recurse -Directory $TargetDir -Filter __pycache__ -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force +Write-Host ("Removed {0} .py (kept {1} that did not compile). UNVALIDATED -- verify on the packaged EXE before shipping." -f $deleted, ($pyCount - $deleted)) diff --git a/scripts/zip-python-stdlib.ps1 b/scripts/zip-python-stdlib.ps1 new file mode 100644 index 00000000..000b6668 --- /dev/null +++ b/scripts/zip-python-stdlib.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + #9 item 1 (DRAFT, build-gated): collapse the bundled Python stdlib into a single + python313.zip so Windows Defender scans one file instead of hundreds of loose + .py/.pyc on every cold launch after an update (the 54-138s cold-start spikes). + +.WHY IT WORKS + CPython always puts "\python313.zip" on sys.path automatically (the zip + import path), so placing the stdlib there needs NO python._pth. We keep + Lib\site-packages and DLLs\ loose (native .pyd can't be imported from a zip), + and keep a small keep-list of stdlib dirs that read data files via __file__. + +.STATUS + UNVALIDATED. Default is -DryRun (reports only, changes nothing). Run -Apply on a + throwaway python-env copy, then boot the packaged backend and confirm every + import works + measure cold start (Task #10) BEFORE wiring this into a release. + It is intentionally NOT called by build-app-win.ps1 yet. + +.USAGE + pwsh scripts\zip-python-stdlib.ps1 -PythonEnv electron\python-env # dry run + pwsh scripts\zip-python-stdlib.ps1 -PythonEnv \python-env -Apply # perform +#> +param( + [Parameter(Mandatory = $true)][string]$PythonEnv, + [switch]$Apply +) + +$ErrorActionPreference = 'Stop' +$Lib = Join-Path $PythonEnv 'Lib' +$SitePkgs = Join-Path $Lib 'site-packages' +$ZipPath = Join-Path $PythonEnv 'python313.zip' + +if (-not (Test-Path $Lib)) { throw "no Lib\ under $PythonEnv" } + +# Stdlib dirs known to read data/grammar files relative to __file__ -> keep loose +# (zipimport gives them no real path). Conservative; expand if validation flags more. +$KeepLoose = @('site-packages', 'lib2to3', 'idlelib', 'tkinter', 'turtledemo', 'ensurepip', 'venv', 'test', '__pycache__') + +# Pure-stdlib set = everything directly under Lib\ EXCEPT the keep-list. Native +# stdlib extensions live in DLLs\ (not Lib\) on Windows, so Lib-minus-keeplist is +# pure python and safe to zip. +$entries = Get-ChildItem -Force $Lib | Where-Object { $KeepLoose -notcontains $_.Name } +$pyFiles = $entries | ForEach-Object { + if ($_.PSIsContainer) { Get-ChildItem -Recurse -File $_.FullName -Include *.py, *.pyc -ErrorAction SilentlyContinue } + elseif ($_.Extension -in '.py', '.pyc') { $_ } +} +$count = ($pyFiles | Measure-Object).Count +$bytes = ($pyFiles | Measure-Object -Property Length -Sum).Sum +Write-Host ("#9 item 1: {0} stdlib .py/.pyc files ({1:N1} MB) would be zipped into python313.zip" -f $count, ($bytes / 1MB)) +Write-Host ("keep-loose dirs: {0}" -f ($KeepLoose -join ', ')) + +if (-not $Apply) { + Write-Host "DRY RUN. Re-run with -Apply on a COPY of python-env, then validate (Task #10):" + Write-Host " 1. python.exe -c 'import backend.main' (full import tree resolves)" + Write-Host " 2. python.exe -X importtime -c 'import backend.main' parity vs loose" + Write-Host " 3. boot the packaged backend, exercise agents/app-builder/skills" + Write-Host " 4. measure cold backend-http-ready vs baseline_startup.csv" + return +} + +# --- Apply: build the zip, then remove the now-redundant loose copies. --- +if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath } +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::Open($ZipPath, 'Create') +try { + foreach ($f in $pyFiles) { + # Archive entry path must be relative to Lib\ so it resolves as a top-level + # module (e.g. Lib\json\__init__.py -> json/__init__.py in the zip root). + $rel = $f.FullName.Substring($Lib.Length + 1).Replace('\', '/') + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $f.FullName, $rel) | Out-Null + } +} finally { + $zip.Dispose() +} +Write-Host ("Wrote {0} ({1:N1} MB)" -f $ZipPath, ((Get-Item $ZipPath).Length / 1MB)) + +# Remove the loose stdlib we just zipped (keep the keep-list dirs untouched). +foreach ($e in $entries) { + if ($e.PSIsContainer) { Remove-Item -Recurse -Force $e.FullName } + elseif ($e.Extension -in '.py', '.pyc') { Remove-Item -Force $e.FullName } +} +Write-Host "Removed loose stdlib copies. VALIDATE on the packaged EXE before shipping (this is unvalidated)."