[eric] settings-agent: always-on SettingsRead/SettingsWrite MCP server + guarded /api/settings-meta endpoint, wired into every run

This commit is contained in:
ciregenz
2026-06-19 02:47:38 -07:00
parent f094de5fbd
commit 1a4cccf3cd
4 changed files with 400 additions and 0 deletions
+83
View File
@@ -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).