diff --git a/backend/apps/agents/9router_gpt5_patch.js b/backend/apps/agents/9router_gpt5_patch.js index c236c2c7..9d32cdf7 100644 --- a/backend/apps/agents/9router_gpt5_patch.js +++ b/backend/apps/agents/9router_gpt5_patch.js @@ -64,20 +64,31 @@ const _http = require('http'); const backendPort = process.env.OPENSWARM_PORT || '8324'; const path = '/api/subscriptions/callback' + url.slice('/callback'.length); let done = false; - const finish = () => { + // Relay the backend's real outcome page: the old static close-page rendered success even when the exchange failed, so a broken claude connect looked like it worked and left nothing to debug from user reports. + const finish = (body) => { if (done) return; done = true; - try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(closePage); } catch (_) {} + try { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(body || 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); } + (proxyRes) => { + const chunks = []; + proxyRes.on('data', (c) => { if (chunks.length < 64) chunks.push(c); }); + proxyRes.on('end', () => finish(Buffer.concat(chunks).toString('utf8') || null)); + proxyRes.on('error', () => finish(null)); + } ); - proxyReq.on('error', finish); - proxyReq.setTimeout(5000, () => { try { proxyReq.destroy(); } catch (_) {} finish(); }); + proxyReq.on('error', () => finish( + '
' + + 'Connection failed: OpenSwarm is not reachable on this machine (port ' + backendPort + '). ' + + 'Open the OpenSwarm app and try connecting again.' + )); + proxyReq.setTimeout(15000, () => { try { proxyReq.destroy(); } catch (_) {} finish(null); }); proxyReq.end(); - } catch (_) { finish(); } + } catch (_) { finish(null); } return true; } } catch (_) {} diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 37875ba6..0195e04a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -20,6 +20,7 @@ from backend.apps.agents.manager.session.session_store import ( from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.PartialReply import PartialReply from backend.apps.agents.manager.session.SessionLifecycle import SessionLifecycle +from backend.apps.agents.manager.SpawnAgentRun import SpawnAgentRun from backend.apps.agents.manager.session.SessionPersistence import SessionPersistence from backend.apps.agents.manager.Messaging import Messaging from backend.apps.agents.manager.SessionControl import SessionControl @@ -38,7 +39,7 @@ os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") MAX_CONCURRENT_TURNS = int(os.environ.get("OSW_MAX_CONCURRENT_TURNS", "8") or "0") -class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, MockAgent, TurnRunner, RunOptions, RunSupport): +class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, SpawnAgentRun, MockAgent, TurnRunner, RunOptions, RunSupport): @typechecked def __init__(self): self.sessions: Dict[str, AgentSession] = {} diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 34291509..07c059d0 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -1,15 +1,16 @@ -from backend.config.Apps import SubApp -from backend.apps.agents.agent_manager import agent_manager -from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.core.models import AgentConfig, ApprovalResponse -from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input -from contextlib import asynccontextmanager -from fastapi import WebSocket, WebSocketDisconnect, HTTPException -from fastapi.responses import JSONResponse import asyncio -import json import logging import time +from contextlib import asynccontextmanager +from typing import Any, Dict + +from fastapi import HTTPException +from typeguard import typechecked + +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentConfig, AgentSession, ApprovalResponse +from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input +from backend.config.Apps import SubApp logger = logging.getLogger(__name__) @@ -39,10 +40,31 @@ async def agents_lifespan(): agents = SubApp("agents", agents_lifespan) +@typechecked +def p_session_list_item(session: AgentSession) -> Dict[str, Any]: + """Serialize dashboard metadata without retaining the full chat history.""" + data = session.model_dump(mode="json", exclude={"messages"}) + messages = session.messages + last_content = messages[-1].content if messages else "" + first_user_content = next( + (message.content for message in messages if message.role == "user"), + "", + ) + data.update( + messages=[], + last_message_preview=last_content[:120] if isinstance(last_content, str) else "", + first_user_message=( + first_user_content[:200] if isinstance(first_user_content, str) else "" + ), + message_count=len(messages), + ) + return data + + @agents.router.get("/sessions") async def list_sessions(dashboard_id: str = ""): sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None) - return {"sessions": [s.model_dump(mode="json") for s in sessions]} + return {"sessions": [p_session_list_item(s) for s in sessions]} @agents.router.get("/activity") async def agent_activity(): diff --git a/backend/apps/agents/manager/SpawnAgentRun.py b/backend/apps/agents/manager/SpawnAgentRun.py new file mode 100644 index 00000000..1c3e601a --- /dev/null +++ b/backend/apps/agents/manager/SpawnAgentRun.py @@ -0,0 +1,99 @@ +"""spawn_agent: back the SpawnAgent MCP tool with a FRESH sub-agent session (no history +copy; the prompt must be self-contained). Replaces the CLI's built-in Agent tool, which is +blocked in RunOptions: its subagent types resolve to models router setups can't serve, and +its schema drags description/subagent_type/model/isolation along. Mixin, same MRO pattern +as AgentLaunch.""" + +import asyncio +import logging +from datetime import datetime +from typing import Dict, Optional +from uuid import uuid4 + +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.core.ws_manager import ws_manager +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol +from backend.apps.agents.manager.session.apply_context_window import apply_context_window +from backend.apps.agents.manager.session.session_store import load_session_data + +logger = logging.getLogger(__name__) + + +def last_assistant_text(session: AgentSession) -> Optional[str]: + for msg in reversed(session.messages): + if msg.role == "assistant": + content = msg.content + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + return "\n".join(texts) + return str(content) + return None + + +class SpawnAgentRun(AgentManagerProtocol): + @typechecked + async def spawn_agent( + self, + prompt: str, + parent_session_id: str, + dashboard_id: Optional[str] = None, + run_in_background: bool = False, + ) -> Dict: + parent = self.sessions.get(parent_session_id) + if not parent: + data = load_session_data(parent_session_id) + if data is None: + raise ValueError(f"Parent session {parent_session_id} not found") + parent = AgentSession(**data) + + title = (prompt.strip().splitlines() or [""])[0][:60] or "Sub-agent" + child = AgentSession( + id=uuid4().hex, + name=title, + status="running", + model=parent.model, + mode="sub-agent", + system_prompt=parent.system_prompt, + allowed_tools=list(parent.allowed_tools), + max_turns=parent.max_turns or 25, + cwd=parent.cwd, + created_at=datetime.now(), + dashboard_id=dashboard_id or parent.dashboard_id, + parent_session_id=parent_session_id, + ) + apply_context_window(child) + self.sessions[child.id] = child + + await ws_manager.broadcast_global("agent:status", { + "session_id": child.id, + "status": child.status, + "session": child.model_dump(mode="json"), + }) + + user_msg = Message( + role="user", + content=prompt, + branch_id=child.active_branch_id, + ) + child.messages.append(user_msg) + await ws_manager.send_to_session(child.id, "agent:message", { + "session_id": child.id, + "message": user_msg.model_dump(mode="json"), + }) + + if run_in_background: + # Fire-and-forget; the child's card carries its progress and result. Keep a handle in self.tasks so stop/shutdown machinery sees it. + task = asyncio.create_task(self.run_agent_loop(child.id, prompt)) + self.tasks[child.id] = task + return {"session_id": child.id, "background": True} + + await self.run_agent_loop(child.id, prompt) + return { + "session_id": child.id, + "response": last_assistant_text(child) or "No response from sub-agent.", + "cost_usd": child.cost_usd, + } diff --git a/backend/apps/agents/manager/configure_provider_env.py b/backend/apps/agents/manager/configure_provider_env.py index 327a2033..a7fa44f5 100644 --- a/backend/apps/agents/manager/configure_provider_env.py +++ b/backend/apps/agents/manager/configure_provider_env.py @@ -1,10 +1,9 @@ """Configure the SDK environment for the run's provider route: set ANTHROPIC/OPENAI/GOOGLE auth env vars (direct key, OpenSwarm Pro proxy, OpenRouter, or 9Router) and pin subagent models, -ensuring 9Router is up where the route needs it. sub_conns is the active-connection list for -subagent-model fallback (empty today).""" +ensuring 9Router is up where the route needs it.""" import os -from typing import Dict, List, Optional +from typing import Dict, Optional from typeguard import typechecked @@ -50,7 +49,6 @@ async def configure_provider_env( resolved_model: object, api_type: Optional[str], global_settings: AppSettings, - sub_conns: List, ) -> None: from backend.apps.nine_router import is_running as nine_router_running from backend.apps.agents.providers.registry import NINEROUTER_MODEL_PREFIXES as NINEROUTER_MODEL_PREFIXES @@ -198,7 +196,9 @@ async def configure_provider_env( "ANTHROPIC_API_KEY": "9router", "ANTHROPIC_BASE_URL": "http://localhost:20128", } - # Pin subagents to whichever lane the user has, else CLI's default Haiku 4.5 hits 9Router with no Claude route and 401s. NOTE: callers pass sub_conns=[] today so this is inert (latent regression from the run/ split; pyright caught the dangling _conns ref). + # Pin subagents to whichever lane the user has, else the CLI's default Haiku 4.5 hits 9Router with no Claude route and every sub-agent 401s while the parent turn works. Fetched live here (fail-open []) so no caller can starve the pin with a stale list again, the run/ split did exactly that and silently killed sub-agents on router routes. + from backend.apps.nine_router import get_providers as p_get_providers + sub_conns = await p_get_providers() active = {c.get("provider") for c in sub_conns if isinstance(c, dict) and c.get("isActive")} sub_model = None diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index eb916bcd..033aaa0c 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -60,6 +60,14 @@ def build_effective_tool_lists( effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}") continue + if name == "openswarm-spawn-agent": + policy = builtin_perms.get("Agent", "always_allow") + if policy == "always_allow": + effective_allowed.append("mcp__openswarm-spawn-agent__SpawnAgent") + elif policy == "deny": + effective_disallowed.append("mcp__openswarm-spawn-agent__SpawnAgent") + continue + if name == "openswarm-skill": policy = builtin_perms.get("Skill", "always_allow") if policy == "always_allow": diff --git a/backend/apps/agents/manager/prompt/tool_catalog.py b/backend/apps/agents/manager/prompt/tool_catalog.py index 1fe141ae..dbedf807 100644 --- a/backend/apps/agents/manager/prompt/tool_catalog.py +++ b/backend/apps/agents/manager/prompt/tool_catalog.py @@ -19,7 +19,6 @@ FULL_TOOLS = [ "TaskOutput", "TaskStop", "CronCreate", "CronList", "CronDelete", "InvokeAgent", - "Agent", # ToolSearch is the loader the CLI uses to expose deferred tool schemas on demand. Must be in the allowedTools whitelist or the model can't call it, which means none of the deferred extended tools become reachable even when the CLI advertises them in the system prompt. "ToolSearch", ] diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index a954ca13..4cfec36e 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -78,6 +78,23 @@ def register_builtin_mcp_servers( "type": "stdio", } + # SpawnAgent replaces the CLI's built-in Agent tool (blocked in RunOptions); gated by the same "Agent" permission so the Tools-page toggle keeps working. + if builtin_perms.get("Agent", "always_allow") != "deny": + spawn_agent_server_path = os.path.join( + agents_dir, "spawn_agent_mcp_server.py" + ) + mcp_servers["openswarm-spawn-agent"] = { + "command": sys.executable, + "args": [spawn_agent_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + }, + "type": "stdio", + } + # Always-on meta-MCP server. Exposes MCPList / MCPSearch / MCPActivate so the model can discover and activate user MCPs at runtime. The activation gate (active_mcps filter in build_mcp_servers above) ensures the model cannot reach any other MCP server's tools without going through this layer first. mcp_meta_server_path = os.path.join( agents_dir, "mcp_meta_server.py" diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index 37b7f3da..c9266625 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -188,7 +188,7 @@ class RunOptions(AgentManagerProtocol): } # cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key. await configure_provider_env( - options_kwargs, session, resolved_model, api_type, global_settings, [] + options_kwargs, session, resolved_model, api_type, global_settings ) if mcp_servers: options_kwargs["mcp_servers"] = mcp_servers @@ -229,6 +229,9 @@ class RunOptions(AgentManagerProtocol): # The claude_code preset auto-attaches the user's claude.ai- connected partner MCPs (`mcp__claude_ai_*`). Those bypass our MCPActivate gate, don't share OAuth state with the OpenSwarm Gmail/Calendar/Drive connectors the user actually configured here, and confuse the model into picking the partner shim instead of our vetted server. Hard-block them at the SDK layer so the model can't even attempt the call. options_kwargs["disallowed_tools"] = [ "mcp__claude_ai_*", + # The CLI's built-in sub-agent tool is replaced by our SpawnAgent MCP (prompt + run_in_background only); it is named Task on CLI 2.1.122 (Agent on older builds, kept for safety), and its subagent types resolve to models router setups can't serve. + "Agent", + "Task", ] if session.cwd: diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 1b38c21a..a2d0e1ff 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -93,6 +93,13 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { "context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini", "api": "codex", "subscription_only": True, "reasoning": True}, # gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes. + # GPT-5.6 (Sol / Terra / Luna, 2026-07) is HELD, not offered: it is Responses-API-only + # (api model ids gpt-5.6-sol [alias gpt-5.6], gpt-5.6-terra, gpt-5.6-luna; per-1M in/out + # $5/$30, $2.50/$15, $1/$6). Our lane goes user -> 9Router 0.3.60 -> cp-openai passthrough, + # and 0.3.60 only speaks /chat/completions, so a gpt-5.6 request hits the wrong endpoint and + # OpenAI rejects it (plus it is a trusted-partner limited preview, so most keys 404 anyway). + # No working lane = not offered (same rule as gpt-5.5's cx entry). Enable all three tiers + # once 9Router can translate to /v1/responses AND the model is generally available. {"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)", "context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5", "api": "openai", "reasoning": True, "route": "api"}, diff --git a/backend/apps/agents/spawn_agent_mcp_server.py b/backend/apps/agents/spawn_agent_mcp_server.py new file mode 100644 index 00000000..6c5a9255 --- /dev/null +++ b/backend/apps/agents/spawn_agent_mcp_server.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Stdio MCP server exposing the SpawnAgent tool; proxies to /api/spawn-agent/run. + +Replaces the CLI's built-in Agent tool (blocked in RunOptions): that schema drags +description/subagent_type/model/isolation along, and its subagent types resolve to +models our router setups can't serve. This one takes prompt + run_in_background, +nothing else; the child runs as a real OpenSwarm session card on the dashboard.""" + +import json +import sys +import os +import urllib.request +import urllib.error + +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/spawn-agent/run" +PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "") +DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "") + +TOOLS = [ + { + "name": "SpawnAgent", + "description": ( + "Spawn a sub-agent to handle a task. The sub-agent runs as its own " + "agent session (visible on the dashboard) with the same working " + "directory and model as you. By default this blocks until the " + "sub-agent finishes and returns its final answer; set " + "run_in_background=true to return immediately and let it work on " + "its own, its progress and result appear on its dashboard card." + ), + "inputSchema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": ( + "The task for the sub-agent. Include all context it " + "needs; it does not see your conversation." + ), + }, + "run_in_background": { + "type": "boolean", + "description": ( + "true = return immediately with the sub-agent's session " + "id instead of waiting for its result." + ), + }, + }, + "required": ["prompt"], + }, + }, +] + + +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(prompt: str, run_in_background: bool) -> dict: + payload = json.dumps({ + "prompt": prompt, + "run_in_background": run_in_background, + "parent_session_id": PARENT_SESSION_ID, + "dashboard_id": DASHBOARD_ID, + }).encode() + headers = {"Content-Type": "application/json"} + if BACKEND_AUTH: + headers["Authorization"] = f"Bearer {BACKEND_AUTH}" + req = urllib.request.Request( + BACKEND_URL, + data=payload, + headers=headers, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=1800) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body = e.read().decode() if e.fp else str(e) + return {"error": f"HTTP {e.code}: {body}"} + except Exception as e: + return {"error": str(e)} + + +def handle_tool_call(tool_name: str, arguments: dict) -> dict: + if tool_name != "SpawnAgent": + return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} + + prompt = arguments.get("prompt", "") + run_in_background = bool(arguments.get("run_in_background", False)) + + if not prompt: + return {"content": [{"type": "text", "text": "Error: prompt is required"}], "isError": True} + + result = call_backend(prompt, run_in_background) + + if "error" in result: + return {"content": [{"type": "text", "text": f"Error: {result['error']}"}], "isError": True} + + sid = result.get("session_id", "") + if run_in_background: + return {"content": [{"type": "text", "text": ( + f"Spawned background sub-agent (session: {sid}). It is working on its own " + "dashboard card; its result will appear there. Do not wait for it." + )}]} + + response = result.get("response", "No response from sub-agent.") + cost = result.get("cost_usd", 0) + lines = [f"**Sub-Agent Result** (session: {sid})"] + if cost > 0: + lines.append(f"*Cost: ${cost:.4f}*") + lines.append("") + lines.append(response) + return {"content": [{"type": "text", "text": "\n".join(lines)}]} + + +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-spawn-agent", + "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", {}) + result = handle_tool_call(tool_name, arguments) + send_response(id_, result) + elif method == "ping": + send_response(id_, {}) + 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/swarm/closure.py b/backend/apps/swarm/closure.py index 9d13c8ce..b09ec962 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -126,9 +126,9 @@ def build_manifest(root_type: EntityType, root_id: str) -> Manifest: return p_assemble(root_type, root_id)[0] -def build_bundle(root_type: EntityType, root_id: str) -> tuple[bytes, str]: +def build_bundle(root_type: EntityType, root_id: str, allow_file_secrets: bool = False) -> tuple[bytes, str]: manifest, payloads, files = p_assemble(root_type, root_id) - raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files) + raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files, allow_file_secrets=allow_file_secrets) return raw, manifest.root.name diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index 42a8e92e..0b2ddef3 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -1,8 +1,5 @@ """WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule -shape, actions, model). The workflow store lives on the eric/workflow branch and -is NOT on eric/dev yet, so every store touch is lazy: on a build without it, -export finds nothing and import fails with a clear message, and the module still -imports cleanly. It lights up the moment the workflow forward-port lands. +shape, actions, model). Safety: an imported workflow must never silently start running on someone else's machine, so the schedule is forced off on import (the importer re-arms it). The @@ -12,6 +9,8 @@ from __future__ import annotations from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable from backend.apps.swarm.models import EntityType, Requirement, RequirementKind +from backend.apps.workflows import storage +from backend.apps.workflows.models import Workflow P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} @@ -52,10 +51,7 @@ class WorkflowExportable: @classmethod def load(cls, local_id: str) -> "WorkflowExportable | None": - store = p_store() - if store is None: - return None - wf = store.get_workflow(local_id) + wf = storage.get_workflow(local_id) if wf is None: return None data = wf.model_dump(mode="json") @@ -92,38 +88,15 @@ class WorkflowExportable: @classmethod def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: - store = p_store() - model = p_model() - if store is None or model is None: - from backend.apps.swarm.ziputil import BundleError - raise BundleError("this build doesn't support workflows yet; please update OpenSwarm") clean = sanitize_workflow(payload) clean.pop("id", None) # fresh id via the model's default_factory - wf = model(**clean) - store.save_workflow(wf) + wf = Workflow(**clean) + storage.save_workflow(wf) return wf.id @classmethod def rollback(cls, local_id: str) -> None: - store = p_store() - if store is not None: - try: - store.delete_workflow(local_id) - except Exception: - pass - - -def p_store(): - try: - from backend.apps.workflows import storage - return storage - except Exception: - return None - - -def p_model(): - try: - from backend.apps.workflows.models import Workflow - return Workflow - except Exception: - return None + try: + storage.delete_workflow(local_id) + except Exception: + pass diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py index c5c02460..c47f7652 100644 --- a/backend/apps/swarm/models.py +++ b/backend/apps/swarm/models.py @@ -104,6 +104,8 @@ class ReviewSummary(BaseModel): class ExportRequest(BaseModel): type: EntityType id: str + # User-confirmed "export anyway": skips the file-content secret heuristic on direct download only; denied payload fields stay blocked. + allow_secrets: bool = False class ExportPreflightResponse(BaseModel): diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py index 57c3a66f..ceca79ca 100644 --- a/backend/apps/swarm/swarm.py +++ b/backend/apps/swarm/swarm.py @@ -71,7 +71,7 @@ async def export_preflight(body: ExportRequest) -> ExportPreflightResponse: @swarm.router.post("/export") async def export_bundle(body: ExportRequest) -> Response: try: - raw, name = closure.build_bundle(body.type, body.id) + raw, name = closure.build_bundle(body.type, body.id, allow_file_secrets=body.allow_secrets) except BundleError as e: raise HTTPException(status_code=400, detail=str(e)) fname = closure.swarm_filename(name) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index 4e1e3ceb..f47f240c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -37,21 +37,25 @@ def p_content_digest(entries: dict[str, bytes]) -> str: return h.hexdigest() -def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes: +def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes], allow_file_secrets: bool = False) -> bytes: """payloads: bundle_id -> JSON payload (-> entities/