feat: OAuth 2.0 support, server-side safety governor, multi-user auth mode

Publishes server work that shipped in the Android edition but never made
it to this repo:

- Full OAuth 2.0 flow (discovery metadata, dynamic client registration,
  authorize + token endpoints) so claude.ai remote connectors and the
  Android app can authenticate per-user instead of relying on the
  sole-phone fallback.
- Safety governor: server-side heat model (intensity x time) with
  automatic cooldown, per-user overrides via GET/POST /safety/config,
  and governor state piggybacked on heartbeat pings so relay clients
  can display it.
- SB_REQUIRE_MCP_AUTH env flag for multi-user deployments (disables the
  unauthenticated sole-phone fallback).
- requirements-phone.txt and .env.example documenting the new knobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aletheia
2026-07-07 20:05:45 +02:00
parent f8a9245f90
commit 6a8bc353c5
10 changed files with 1398 additions and 16 deletions

View File

@@ -14,6 +14,7 @@ import logging
import time
from . import config
from .governor import governor
from .session_registry import registry
log = logging.getLogger("signal_bridge.safety")
@@ -70,8 +71,15 @@ class DeadManSwitch:
sessions = await registry.get_all_sessions()
for user_id, session in sessions.items():
# Send ping
ping = {"type": "heartbeat_ping", "timestamp": now}
# Tick the governor (advances heat model)
governor.tick(user_id)
# Send ping with governor state piggybacked
ping = {
"type": "heartbeat_ping",
"timestamp": now,
**governor.get_state(user_id),
}
try:
await session.websocket.send(json.dumps(ping))
except Exception:
@@ -92,6 +100,8 @@ class DeadManSwitch:
async def _emergency_stop(self, user_id: str, session):
"""Send stop-all and disconnect the session."""
log.critical(f"EMERGENCY STOP for user {user_id} — all devices halted")
governor.record_stop(user_id)
try:
stop_cmd = {"type": "stop", "device": "all", "emergency": True}
await session.websocket.send(json.dumps(stop_cmd))
@@ -104,6 +114,7 @@ class DeadManSwitch:
pass
await registry.unregister(user_id)
governor.remove_user(user_id)
# Singleton