From 1a4cccf3cd78eec77d2e2b9ba4e73d630170e414 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 19 Jun 2026 02:47:38 -0700 Subject: [PATCH] [eric] settings-agent: always-on SettingsRead/SettingsWrite MCP server + guarded /api/settings-meta endpoint, wired into every run --- backend/apps/agents/agent_manager.py | 21 +++ backend/apps/agents/settings_meta_server.py | 181 +++++++++++++++++++ backend/main.py | 83 +++++++++ backend/tests/test_settings_meta_endpoint.py | 115 ++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 backend/apps/agents/settings_meta_server.py create mode 100644 backend/tests/test_settings_meta_endpoint.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 38461130..31a44ffc 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1378,6 +1378,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 diff --git a/backend/apps/agents/settings_meta_server.py b/backend/apps/agents/settings_meta_server.py new file mode 100644 index 00000000..d630217e --- /dev/null +++ b/backend/apps/agents/settings_meta_server.py @@ -0,0 +1,181 @@ +#!/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"] + refused = {f: o for f, o in outcomes.items() if o.get("status") not in ("applied", None)} + parts = [] + if applied: + parts.append("Applied: " + ", ".join(sorted(applied))) + for field, o in refused.items(): + parts.append(f"Refused {field}: {o.get('reason', o.get('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/main.py b/backend/main.py index 9aa7d69f..17201672 100644 --- a/backend/main.py +++ b/backend/main.py @@ -742,6 +742,89 @@ async def mcp_meta(action: str, request: Request): return JSONResponse({"error": f"unknown action: {action}"}, status_code=400) +@app.post("/api/settings-meta/{action}") +async def settings_meta(action: str, request: Request): + """Back the openswarm-settings-meta stdio MCP server (agent-editable Settings). + + Actions: + - read: the full settings object with every secret redacted to + configured/not (never the value), so an always-on read is never an + exfiltration path. + - write: apply a field -> value map. Three things can't be written, in + priority order: an unknown field (reported, not invented), a server-owned + subscription/connection field (managed by its dedicated flow), and the + credential powering THIS run (the no-suicide rule, enforced structurally + via resolve_powering_credential). Everything else is applied through the + same path PUT /api/settings uses, so 9router reconciliation and the + server-owned restore behave identically. + """ + from backend.apps.settings.store import load_settings + from backend.apps.settings.models import AppSettings + from backend.apps.settings.redaction import redact_settings + from backend.apps.settings.settings import SERVER_OWNED_FIELDS, update_settings + from backend.apps.agents.session_credential import ( + PoweringCredential, resolve_powering_credential, write_would_suicide, + ) + from backend.apps.agents.agent_manager import agent_manager + from pydantic import ValidationError + + body = await request.json() + parent_session_id = body.get("parent_session_id", "") + + if action == "read": + return JSONResponse({"settings": redact_settings(load_settings().model_dump())}) + + if action == "write": + changes = body.get("changes") + if not isinstance(changes, dict) or not changes: + return JSONResponse({"error": "changes must be a non-empty object of field -> value"}, status_code=400) + + settings = load_settings() + session = agent_manager.sessions.get(parent_session_id) if parent_session_id else None + if session is not None: + powering = resolve_powering_credential(session.model, settings) + else: + # No live session to anchor the guard: fail safe, protect every credential. + powering = PoweringCredential(kind="unknown", provider="unknown", label="this run") + + valid_fields = set(AppSettings.model_fields.keys()) + outcomes: dict[str, dict] = {} + staged: dict = {} + for field, value in changes.items(): + if field not in valid_fields: + outcomes[field] = {"status": "unknown", "reason": "not a settings field"} + elif field in SERVER_OWNED_FIELDS: + outcomes[field] = {"status": "refused", "reason": "managed by your subscription/connection; change it in the Subscription section"} + elif write_would_suicide(field, value, powering): + outcomes[field] = {"status": "refused", "reason": f"would disconnect {powering.label}, which is powering this run"} + else: + staged[field] = value + + if staged: + merged = settings.model_dump() + merged.update(staged) + try: + new_body = AppSettings(**merged) + except ValidationError as e: + bad = {str(err["loc"][0]) for err in e.errors() if err.get("loc")} + for f in bad & set(staged.keys()): + outcomes[f] = {"status": "refused", "reason": "invalid value for this field"} + staged.pop(f, None) + new_body = None + if staged: + merged = settings.model_dump() + merged.update(staged) + new_body = AppSettings(**merged) + if staged and new_body is not None: + await update_settings(new_body) + for f in staged: + outcomes[f] = {"status": "applied"} + + return JSONResponse({"outcomes": outcomes}) + + return JSONResponse({"error": f"unknown action: {action}"}, status_code=400) + + @app.post("/api/agents/sessions/{session_id}/compact") async def session_compact(session_id: str): """Force a compaction pass on a session (Phase 2 /compact slash cmd). diff --git a/backend/tests/test_settings_meta_endpoint.py b/backend/tests/test_settings_meta_endpoint.py new file mode 100644 index 00000000..7054b1cb --- /dev/null +++ b/backend/tests/test_settings_meta_endpoint.py @@ -0,0 +1,115 @@ +"""End-to-end coverage of /api/settings-meta (the agent-editable Settings tool). + +Drives the real FastAPI route with a real in-memory AgentSession so the guard +runs against an actual run's model, exactly as it will in production. The unit +invariant lives in test_settings_meta_guard.py; this test proves the wiring: +redaction on read, the three write refusals, and a benign write actually landing. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + import backend.auth as auth_mod + if not auth_mod._TOKEN: + import secrets + auth_mod._TOKEN = secrets.token_urlsafe(32) + return TestClient(app, headers={"Authorization": f"Bearer {auth_mod._TOKEN}"}) + + +@pytest.fixture +def reset_settings(): + from backend.apps.settings.settings import load_settings, _save_settings + original = load_settings().model_copy(deep=True) + yield + _save_settings(original) + + +@pytest.fixture +def session_on_anthropic_key(): + """A live run on opus-4-8 in own_key mode with an Anthropic key set: the + Anthropic key powers it. Registered in agent_manager so the guard sees it.""" + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.core.models import AgentSession + from backend.apps.settings.settings import load_settings, _save_settings + + s = load_settings() + s.connection_mode = "own_key" + s.anthropic_api_key = "sk-ant-test-LIVE" + s.openai_api_key = "sk-openai-test-OTHER" + _save_settings(s) + + sess = AgentSession(id="settings-meta-test", name="t", model="opus-4-8") + agent_manager.sessions["settings-meta-test"] = sess + yield "settings-meta-test" + agent_manager.sessions.pop("settings-meta-test", None) + + +def test_read_redacts_every_secret(client, reset_settings): + r = client.post("/api/settings-meta/read", json={}) + assert r.status_code == 200, r.text + settings = r.json()["settings"] + # Secret fields come back as state, never a raw string value. + for field in ("anthropic_api_key", "openai_api_key", "claude_subscription_token", "openswarm_bearer_token"): + if field in settings: + assert isinstance(settings[field], dict), f"{field} leaked as a raw value" + assert "configured" in settings[field] + # A non-secret field is passed through untouched. + assert settings["theme"] in ("dark", "light") + + +def test_benign_write_applies(client, reset_settings): + r = client.post("/api/settings-meta/write", json={"changes": {"theme": "light"}}) + assert r.status_code == 200, r.text + assert r.json()["outcomes"]["theme"]["status"] == "applied" + from backend.apps.settings.settings import load_settings + assert load_settings().theme == "light" + + +def test_unknown_and_server_owned_fields_are_refused(client, reset_settings): + r = client.post("/api/settings-meta/write", json={"changes": { + "not_a_real_field": 1, + "connection_mode": "openswarm-pro", + "openswarm_bearer_token": "forged", + }}) + assert r.status_code == 200, r.text + out = r.json()["outcomes"] + assert out["not_a_real_field"]["status"] == "unknown" + assert out["connection_mode"]["status"] == "refused" + assert out["openswarm_bearer_token"]["status"] == "refused" + # And the server-owned field is genuinely untouched on disk. + from backend.apps.settings.settings import load_settings + assert load_settings().connection_mode != "openswarm-pro" + + +def test_cannot_suicide_but_disconnects_others(client, reset_settings, session_on_anthropic_key): + """The spec scenario over HTTP: run on the Anthropic key, asked to clear + every model key + flip a benign setting. It must refuse the live key, + clear the other one, and apply the benign change, all in one call.""" + sid = session_on_anthropic_key + r = client.post("/api/settings-meta/write", json={ + "parent_session_id": sid, + "changes": { + "anthropic_api_key": "", + "openai_api_key": "", + "theme": "light", + }, + }) + assert r.status_code == 200, r.text + out = r.json()["outcomes"] + assert out["anthropic_api_key"]["status"] == "refused", "blanked the live credential!" + assert "powering this run" in out["anthropic_api_key"]["reason"] + assert out["openai_api_key"]["status"] == "applied" + assert out["theme"]["status"] == "applied" + + from backend.apps.settings.settings import load_settings + s = load_settings() + assert s.anthropic_api_key == "sk-ant-test-LIVE", "live key was cleared despite refusal" + assert not s.openai_api_key, "the other provider's key should have been cleared" + assert s.theme == "light"