mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""The apps SDK host surface (ENG-202): provider-agnostic LLM completions and positioned agent
|
|
spawns for OpenSwarm-built apps, plus the template helpers that ride them."""
|
|
|
|
import os
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from backend.main import app
|
|
|
|
client = TestClient(app)
|
|
P_TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "apps", "outputs", "webapp_template")
|
|
|
|
|
|
def p_auth() -> dict:
|
|
from backend.auth import init_auth_token
|
|
return {"Authorization": f"Bearer {init_auth_token()}"}
|
|
|
|
|
|
class Blk:
|
|
def __init__(self, type: str, text: str = "") -> None:
|
|
self.type = type
|
|
self.text = text
|
|
|
|
|
|
class FakeStream:
|
|
def __init__(self, resp) -> None:
|
|
self.resp = resp
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def get_final_message(self):
|
|
return self.resp
|
|
|
|
|
|
class FakeLLMClient:
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
self.messages = self
|
|
|
|
def stream(self, **kw):
|
|
self.calls.append(kw)
|
|
resp = type("R", (), {"content": [Blk("text", "hello from fake")]})()
|
|
return FakeStream(resp)
|
|
|
|
|
|
def test_llm_routes_through_the_users_provider(monkeypatch):
|
|
import backend.apps.agents.providers.registry as reg
|
|
import backend.apps.settings.credentials as cred
|
|
import backend.apps.settings.settings as settings_mod
|
|
|
|
fake = FakeLLMClient()
|
|
monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True)
|
|
|
|
async def p_aux(settings, preferred_tier="haiku", primary_api=None):
|
|
return ("aux-cheap", None)
|
|
monkeypatch.setattr(reg, "resolve_aux_model", p_aux, raising=True)
|
|
monkeypatch.setattr(cred, "get_anthropic_client_for_model", lambda s, m: fake, raising=True)
|
|
|
|
r = client.post("/api/apps-sdk/llm", json={"prompt": "say hello"}, headers=p_auth())
|
|
assert r.status_code == 200, r.text
|
|
assert r.json() == {"text": "hello from fake", "model": "aux-cheap"}
|
|
assert fake.calls[0]["model"] == "aux-cheap"
|
|
|
|
|
|
def test_llm_honors_an_explicit_model(monkeypatch):
|
|
import backend.apps.agents.providers.registry as reg
|
|
import backend.apps.settings.credentials as cred
|
|
import backend.apps.settings.settings as settings_mod
|
|
|
|
fake = FakeLLMClient()
|
|
monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True)
|
|
monkeypatch.setattr(reg, "resolve_model_id_for_sdk", lambda short, s: f"resolved-{short}", raising=True)
|
|
monkeypatch.setattr(cred, "get_anthropic_client_for_model", lambda s, m: fake, raising=True)
|
|
|
|
r = client.post("/api/apps-sdk/llm", json={"prompt": "hi", "model": "haiku", "system": "terse"},
|
|
headers=p_auth())
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["model"] == "resolved-haiku"
|
|
assert fake.calls[0]["system"] == "terse"
|
|
|
|
|
|
def test_llm_rejects_an_empty_prompt():
|
|
r = client.post("/api/apps-sdk/llm", json={"prompt": " "}, headers=p_auth())
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_spawn_agent_launches_and_broadcasts_position(monkeypatch):
|
|
import backend.apps.dashboards.dashboards as dash_mod
|
|
from backend.apps.agents import agent_manager as am_mod
|
|
from backend.apps.agents.core import ws_manager as ws_mod
|
|
from backend.apps.agents.core.models import AgentSession
|
|
|
|
monkeypatch.setattr(dash_mod, "load_all", lambda: [], raising=True)
|
|
launched = []
|
|
messaged = []
|
|
broadcasts = []
|
|
|
|
async def p_launch(config):
|
|
launched.append(config)
|
|
return AgentSession(id="sess-1", name=config.name, model=config.model, mode=config.mode)
|
|
|
|
async def p_send(session_id, prompt):
|
|
messaged.append((session_id, prompt))
|
|
|
|
async def p_broadcast(event, payload):
|
|
broadcasts.append((event, payload))
|
|
|
|
monkeypatch.setattr(am_mod.agent_manager, "launch_agent", p_launch, raising=True)
|
|
monkeypatch.setattr(am_mod.agent_manager, "send_message", p_send, raising=True)
|
|
monkeypatch.setattr(ws_mod.ws_manager, "broadcast_global", p_broadcast, raising=True)
|
|
|
|
r = client.post("/api/apps-sdk/agents/spawn", json={
|
|
"prompt": "research crm tools", "name": "CRM scout", "x": 400, "y": 300,
|
|
}, headers=p_auth())
|
|
assert r.status_code == 200, r.text
|
|
assert r.json() == {"session_id": "sess-1"}
|
|
assert launched[0].name == "CRM scout" and launched[0].prompt == "research crm tools"
|
|
assert broadcasts == [("apps_sdk:place_agent_card",
|
|
{"session_id": "sess-1", "dashboard_id": None, "x": 400.0, "y": 300.0})]
|
|
|
|
|
|
def test_spawn_agent_without_position_skips_the_broadcast(monkeypatch):
|
|
import backend.apps.dashboards.dashboards as dash_mod
|
|
from backend.apps.agents import agent_manager as am_mod
|
|
from backend.apps.agents.core import ws_manager as ws_mod
|
|
from backend.apps.agents.core.models import AgentSession
|
|
|
|
monkeypatch.setattr(dash_mod, "load_all", lambda: [], raising=True)
|
|
broadcasts = []
|
|
|
|
async def p_launch(config):
|
|
return AgentSession(id="sess-2", name=config.name, model=config.model, mode=config.mode)
|
|
|
|
async def p_send(session_id, prompt):
|
|
return None
|
|
|
|
async def p_broadcast(event, payload):
|
|
broadcasts.append(event)
|
|
|
|
monkeypatch.setattr(am_mod.agent_manager, "launch_agent", p_launch, raising=True)
|
|
monkeypatch.setattr(am_mod.agent_manager, "send_message", p_send, raising=True)
|
|
monkeypatch.setattr(ws_mod.ws_manager, "broadcast_global", p_broadcast, raising=True)
|
|
|
|
r = client.post("/api/apps-sdk/agents/spawn", json={"prompt": "hi"}, headers=p_auth())
|
|
assert r.status_code == 200
|
|
assert broadcasts == []
|
|
|
|
|
|
def test_template_ships_both_sdk_helpers_and_the_skill_references_them():
|
|
front = os.path.join(P_TEMPLATE, "frontend", "src", "openswarmHost.ts")
|
|
back = os.path.join(P_TEMPLATE, "backend", "apps", "openswarm_host", "openswarm_host.py")
|
|
guide = os.path.join(P_TEMPLATE, "SDK.md")
|
|
assert os.path.exists(front) and os.path.exists(back) and os.path.exists(guide)
|
|
skill_path = os.path.join(P_TEMPLATE, "..", "app_builder_skill.md")
|
|
with open(skill_path, "r", encoding="utf-8") as f:
|
|
skill = f.read()
|
|
assert "SDK.md" in skill and "openswarmHost" in skill and "openswarm_host" in skill
|
|
# The tools surface is wired behind per-app grants; the guide must teach the deny contract, not hide the gate.
|
|
with open(guide, "r", encoding="utf-8") as f:
|
|
text = f.read()
|
|
assert "per-app" in text and "Allow once" in text and "never retry" in text
|
|
|
|
|
|
def p_isolated_grants(tmp_path, monkeypatch):
|
|
from backend.apps.apps_sdk import app_identity, tool_grants
|
|
monkeypatch.setattr(tool_grants, "GRANTS_FILE", str(tmp_path / "grants.json"))
|
|
monkeypatch.setattr(app_identity, "APP_TOKENS_FILE", str(tmp_path / "app_tokens.json"))
|
|
return tool_grants
|
|
|
|
|
|
def p_token_for(app: str) -> str:
|
|
from backend.apps.apps_sdk.app_identity import mint_app_token
|
|
return mint_app_token(app)
|
|
|
|
|
|
def test_tool_call_denied_grant_is_refused_flat(tmp_path, monkeypatch):
|
|
grants = p_isolated_grants(tmp_path, monkeypatch)
|
|
grants.set_grant("app1", "srv1:SendEmail", "denied")
|
|
r = client.post("/api/apps-sdk/tools/call", headers=p_auth(),
|
|
json={"app_token": p_token_for("app1"), "tool": "srv1:SendEmail", "args": {}})
|
|
assert r.status_code == 403
|
|
assert "denied" in r.json()["detail"]
|
|
|
|
|
|
def test_tool_call_ungranted_times_out_to_deny(tmp_path, monkeypatch):
|
|
from backend.apps.apps_sdk import tool_grants
|
|
p_isolated_grants(tmp_path, monkeypatch)
|
|
monkeypatch.setattr(tool_grants, "GRANT_WAIT_SECONDS", 0.05)
|
|
called = {"n": 0}
|
|
|
|
async def p_never(*a, **k):
|
|
called["n"] += 1
|
|
return "should not run"
|
|
import backend.apps.tools_lib.mcp_call as mcp_call
|
|
monkeypatch.setattr(mcp_call, "call_mcp_tool", p_never)
|
|
r = client.post("/api/apps-sdk/tools/call", headers=p_auth(),
|
|
json={"app_token": p_token_for("app1"), "tool": "srv1:SendEmail", "args": {}})
|
|
assert r.status_code == 403
|
|
assert called["n"] == 0
|
|
|
|
|
|
def test_tool_call_granted_dispatches(tmp_path, monkeypatch):
|
|
grants = p_isolated_grants(tmp_path, monkeypatch)
|
|
grants.set_grant("app1", "srv1:SendEmail", "granted")
|
|
|
|
async def p_fake_call(tool_id, tool_name, arguments):
|
|
assert (tool_id, tool_name) == ("srv1", "SendEmail")
|
|
return "sent: " + arguments["to"]
|
|
import backend.apps.tools_lib.mcp_call as mcp_call
|
|
monkeypatch.setattr(mcp_call, "call_mcp_tool", p_fake_call)
|
|
r = client.post("/api/apps-sdk/tools/call", headers=p_auth(),
|
|
json={"app_token": p_token_for("app1"), "tool": "srv1:SendEmail", "args": {"to": "a@b.c"}})
|
|
assert r.status_code == 200
|
|
assert r.json()["result"] == "sent: a@b.c"
|
|
|
|
|
|
def test_grant_prompt_approval_flow_allows_and_remembers(tmp_path, monkeypatch):
|
|
import asyncio
|
|
grants = p_isolated_grants(tmp_path, monkeypatch)
|
|
|
|
async def p_fake_call(tool_id, tool_name, arguments):
|
|
return "ok"
|
|
import backend.apps.tools_lib.mcp_call as mcp_call
|
|
monkeypatch.setattr(mcp_call, "call_mcp_tool", p_fake_call)
|
|
|
|
captured = {}
|
|
|
|
async def p_capture_broadcast(event, payload):
|
|
captured.update(payload)
|
|
asyncio.get_running_loop().call_soon(
|
|
lambda: grants.resolve_grant(payload["request_id"], True, True))
|
|
from backend.apps.agents.core import ws_manager as wsm
|
|
monkeypatch.setattr(wsm.ws_manager, "broadcast_global", p_capture_broadcast)
|
|
r = client.post("/api/apps-sdk/tools/call", headers=p_auth(),
|
|
json={"app_token": p_token_for("app2"), "tool": "srv9:ReadSheet", "args": {}})
|
|
assert r.status_code == 200
|
|
assert captured["tool_label"] == "ReadSheet"
|
|
assert grants.grant_status("app2", "srv9:ReadSheet") == "granted"
|
|
|
|
|
|
def test_tools_grant_route_unknown_request_is_no_op():
|
|
r = client.post("/api/apps-sdk/tools/grant", headers=p_auth(),
|
|
json={"request_id": "nope", "allow": True})
|
|
assert r.status_code == 200 and r.json()["ok"] is False
|
|
|
|
|
|
def test_bare_output_id_is_never_identity(tmp_path, monkeypatch):
|
|
grants = p_isolated_grants(tmp_path, monkeypatch)
|
|
grants.set_grant("app1", "srv1:SendEmail", "granted")
|
|
# A caller who only KNOWS app1's id (no minted token, no Origin) must not inherit its grants.
|
|
r = client.post("/api/apps-sdk/tools/call", headers=p_auth(),
|
|
json={"output_id": "app1", "tool": "srv1:SendEmail", "args": {}})
|
|
assert r.status_code == 403
|
|
assert "identify" in r.json()["detail"]
|
|
|
|
|
|
def test_token_for_app_a_cannot_use_app_bs_grants(tmp_path, monkeypatch):
|
|
grants = p_isolated_grants(tmp_path, monkeypatch)
|
|
grants.set_grant("app-b", "srv1:SendEmail", "granted")
|
|
from backend.apps.apps_sdk import tool_grants
|
|
monkeypatch.setattr(tool_grants, "GRANT_WAIT_SECONDS", 0.05)
|
|
called = {"n": 0}
|
|
|
|
async def p_never(*a, **k):
|
|
called["n"] += 1
|
|
import backend.apps.tools_lib.mcp_call as mcp_call
|
|
monkeypatch.setattr(mcp_call, "call_mcp_tool", p_never)
|
|
# app-a's token resolves to app-a, whose grant is absent, so the ask times out to deny.
|
|
r = client.post("/api/apps-sdk/tools/call", headers=p_auth(),
|
|
json={"app_token": p_token_for("app-a"), "output_id": "app-b", "tool": "srv1:SendEmail", "args": {}})
|
|
assert r.status_code == 403
|
|
assert called["n"] == 0
|
|
|
|
|
|
def test_mint_is_stable_and_resolves(tmp_path, monkeypatch):
|
|
p_isolated_grants(tmp_path, monkeypatch)
|
|
from backend.apps.apps_sdk.app_identity import mint_app_token, resolve_app_token, revoke_app_token
|
|
t1 = mint_app_token("app-x")
|
|
t2 = mint_app_token("app-x")
|
|
assert t1 == t2 and resolve_app_token(t1) == "app-x"
|
|
assert resolve_app_token("forged-token") is None
|
|
revoke_app_token("app-x")
|
|
assert resolve_app_token(t1) is None
|