mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-29 04:54:49 +02:00
[eric] settings-agent: always-on SettingsRead/SettingsWrite MCP server + guarded /api/settings-meta endpoint, wired into every run
This commit is contained in:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user