New: app/control.py
- ControlConfig: operator_allowlist + control_rooms (frozensets)
- parse_control_config(): validates @user:server + !room:server formats, fail-fast
- parse_command(): parses !verb subcommand [args] [key=value] up to 512 chars
- check_authorization(): AND(is_control_room, is_operator) → (bool, reason)
- Reply helpers: not_implemented, unknown_command, unauthorized, help
- KNOWN_VERBS: runbook, status, help (M3.1+ stubs)
- MAX_CMD_LEN=512, MAX_CMD_TOKENS=20
ingress.py:
- _try_control(): dispatch for control rooms (authorized → audit + reply, unauthorized → audit + optional ⛔)
- join control rooms on startup
- _enqueue_from_sync: control rooms processed first, never forwarded to agents
- on_control_command(sender, verb, subcommand) metric callback
- CONTROL_UNAUTHORIZED_BEHAVIOR: "ignore" | "reply_error"
Audit events:
matrix.control.command — authorised command (verb, subcommand, args, kwargs)
matrix.control.unauthorized — rejected by allowlist (reason: not_operator | not_control_room)
matrix.control.unknown_cmd — authorised but unrecognised verb
Config + main:
- bridge_operator_allowlist, bridge_control_rooms, control_unauthorized_behavior
- matrix_bridge_control_commands_total{sender,verb,subcommand} counter
- /health: control_channel section (enabled, rooms_count, operators_count, behavior)
- /bridge/mappings: control_rooms + control_operators_count
- docker-compose: BRIDGE_OPERATOR_ALLOWLIST, BRIDGE_CONTROL_ROOMS, CONTROL_UNAUTHORIZED_BEHAVIOR
Tests: 40 new → 148 total pass
Made-with: Cursor
106 lines
4.5 KiB
Python
106 lines
4.5 KiB
Python
"""
|
|
matrix-bridge-dagi — configuration and validation (M2.1 + M2.2 + M3.0)
|
|
"""
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import FrozenSet
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BridgeConfig:
|
|
# Matrix homeserver
|
|
matrix_homeserver_url: str
|
|
matrix_access_token: str
|
|
matrix_user_id: str # e.g. @dagi_bridge:daarion.space
|
|
|
|
# Room → agent mapping (M1: single room)
|
|
sofiia_room_id: str # e.g. !abcdef:daarion.space
|
|
|
|
# DAGI backend
|
|
dagi_gateway_url: str # e.g. http://dagi-gateway-node1:9300
|
|
default_node_id: str # e.g. NODA1
|
|
|
|
# Sofiia Console (audit write)
|
|
sofiia_console_url: str # e.g. http://dagi-sofiia-console-node1:8002
|
|
sofiia_internal_token: str # X-Internal-Service-Token for audit ingest
|
|
|
|
# Policy
|
|
bridge_allowed_agents: FrozenSet[str]
|
|
rate_limit_room_rpm: int # max messages per room per minute
|
|
rate_limit_sender_rpm: int # max messages per sender per minute
|
|
|
|
# H2: Backpressure queue
|
|
queue_max_events: int # max pending items (drops oldest on full)
|
|
worker_concurrency: int # parallel invoke workers
|
|
queue_drain_timeout_s: float # graceful shutdown drain timeout
|
|
|
|
# M2.1: Mixed rooms
|
|
# "!roomX:server=sofiia,helion;!roomY:server=druid"
|
|
bridge_mixed_room_map: str
|
|
# "!roomX:server=helion" — explicit default per mixed room (optional)
|
|
bridge_mixed_defaults: str
|
|
|
|
# M2.2: Mixed room guard rails
|
|
max_agents_per_mixed_room: int # fail-fast if room defines more agents than this
|
|
max_slash_len: int # reject slash token longer than this (anti-garbage)
|
|
unknown_agent_behavior: str # "ignore" | "reply_error"
|
|
mixed_concurrency_cap: int # max parallel invokes per (room, agent); 0 = unlimited
|
|
|
|
# M3.0: Operator control channel
|
|
# "@ivan:daarion.space,@sergiy:daarion.space"
|
|
bridge_operator_allowlist: str
|
|
# "!opsroom:server,!opsroom2:server2"
|
|
bridge_control_rooms: str
|
|
# "ignore" | "reply_error" (send ⛔ to room on unauthorized attempt)
|
|
control_unauthorized_behavior: str
|
|
|
|
# Service identity
|
|
node_id: str
|
|
build_sha: str
|
|
build_time: str
|
|
|
|
|
|
def load_config() -> BridgeConfig:
|
|
"""Load and validate config from environment variables."""
|
|
|
|
def _require(key: str) -> str:
|
|
v = os.getenv(key, "").strip()
|
|
if not v:
|
|
raise RuntimeError(f"Required env var {key!r} is not set")
|
|
return v
|
|
|
|
def _optional(key: str, default: str = "") -> str:
|
|
return os.getenv(key, default).strip()
|
|
|
|
allowed_raw = _optional("BRIDGE_ALLOWED_AGENTS", "sofiia")
|
|
allowed = frozenset(a.strip() for a in allowed_raw.split(",") if a.strip())
|
|
|
|
return BridgeConfig(
|
|
matrix_homeserver_url=_require("MATRIX_HOMESERVER_URL").rstrip("/"),
|
|
matrix_access_token=_require("MATRIX_ACCESS_TOKEN"),
|
|
matrix_user_id=_require("MATRIX_USER_ID"),
|
|
sofiia_room_id=_require("SOFIIA_ROOM_ID"),
|
|
dagi_gateway_url=_require("DAGI_GATEWAY_URL").rstrip("/"),
|
|
default_node_id=_optional("DEFAULT_NODE_ID", "NODA1"),
|
|
sofiia_console_url=_optional("SOFIIA_CONSOLE_URL", "").rstrip("/"),
|
|
sofiia_internal_token=_optional("SOFIIA_INTERNAL_TOKEN", ""),
|
|
bridge_allowed_agents=allowed,
|
|
rate_limit_room_rpm=int(_optional("RATE_LIMIT_ROOM_RPM", "20")),
|
|
rate_limit_sender_rpm=int(_optional("RATE_LIMIT_SENDER_RPM", "10")),
|
|
queue_max_events=max(1, int(_optional("QUEUE_MAX_EVENTS", "100"))),
|
|
worker_concurrency=max(1, int(_optional("WORKER_CONCURRENCY", "2"))),
|
|
queue_drain_timeout_s=max(1.0, float(_optional("QUEUE_DRAIN_TIMEOUT_S", "5"))),
|
|
bridge_mixed_room_map=_optional("BRIDGE_MIXED_ROOM_MAP", ""),
|
|
bridge_mixed_defaults=_optional("BRIDGE_MIXED_DEFAULTS", ""),
|
|
max_agents_per_mixed_room=max(1, int(_optional("MAX_AGENTS_PER_MIXED_ROOM", "5"))),
|
|
max_slash_len=max(4, int(_optional("MAX_SLASH_LEN", "32"))),
|
|
unknown_agent_behavior=_optional("UNKNOWN_AGENT_BEHAVIOR", "ignore"),
|
|
mixed_concurrency_cap=max(0, int(_optional("MIXED_CONCURRENCY_CAP", "1"))),
|
|
bridge_operator_allowlist=_optional("BRIDGE_OPERATOR_ALLOWLIST", ""),
|
|
bridge_control_rooms=_optional("BRIDGE_CONTROL_ROOMS", ""),
|
|
control_unauthorized_behavior=_optional("CONTROL_UNAUTHORIZED_BEHAVIOR", "ignore"),
|
|
node_id=_optional("NODE_ID", "NODA1"),
|
|
build_sha=_optional("BUILD_SHA", "dev"),
|
|
build_time=_optional("BUILD_TIME", "local"),
|
|
)
|