Terminal / api /blackboard.py
Baida—-
deploy: full backend sync for Node A [Kernel Restore]
8b87770 verified
Raw
History Blame Contribute Delete
5.3 kB
"""
backend/api/blackboard.py — S-BB: Blackboard condiviso cross-sessione via Upstash Redis REST.
Riusa UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN già configurati
in backend/api/llm_cache.py — zero setup aggiuntivo.
TTL: 600s (10 minuti) — session-scoped.
Endpoint:
POST /api/blackboard/{session_id}/write — scrive una entry (MACHINE auth)
GET /api/blackboard/{session_id}/read — legge tutte le entry (pubblico)
DEL /api/blackboard/{session_id} — pulisce il blackboard (MACHINE auth)
GAP-BLACKBOARD-NOAUTH fix: write e delete protetti con require_role(MACHINE).
La lettura rimane pubblica (solo findings, nessun segreto) ma con struttura
che non espone dati sensibili — i valori nel blackboard sono findings agente.
Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts);
il backend persiste su Upstash per cross-tab/cross-reload continuity.
"""
import os
import json
import httpx
import asyncio
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from .auth_guard import require_role, AuthRole
router = APIRouter(prefix="/api/blackboard", tags=["blackboard"])
_URL = os.getenv("UPSTASH_REDIS_REST_URL", "")
_TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "")
_TTL = 600 # 10 minuti
class BBEntry(BaseModel):
agentId: str
key: str
value: str
severity: str = "info"
ts: float = 0.0
async def _redis_post(command: list) -> dict | None:
"""Esegue un comando Redis via Upstash REST API."""
if not _URL or not _TOKEN:
return None
try:
async with httpx.AsyncClient(timeout=2.0) as c:
r = await c.post(
_URL,
json=command,
headers={
"Authorization": f"Bearer {_TOKEN}",
"Content-Type": "application/json",
},
)
return r.json() if r.is_success else None
except Exception:
return None
async def _redis_scan(pattern: str) -> list[str]:
"""Scansiona le chiavi Redis con SCAN paginato via POST (Upstash REST API).
P40-H: la versione precedente usava cursor fisso "0" → solo il primo batch
di 100 chiavi veniva letto. Con >100 entry Redis per sessione le chiavi
extra venivano omesse silenziosamente. Fix: loop cursor finché cursor == "0".
FIX G3: la versione GET usava URL-encoding del pattern (: → %3A, * → %2A).
Upstash router path-decodifica %3A → ':' ma il pattern risultante veniva
spezzato alla prima '/' → SCAN trovava 0 chiavi su pattern con ':'.
La versione POST invia il pattern come stringa JSON → nessun encoding → corretto.
"""
all_keys: list[str] = []
cursor = "0"
while True:
result = await _redis_post(["SCAN", cursor, "MATCH", pattern, "COUNT", "100"])
if not result or "result" not in result:
break
scan_result = result["result"]
# Upstash SCAN ritorna [next_cursor, [keys]] o [[next_cursor, [keys]]] (pipeline)
if not isinstance(scan_result, list) or len(scan_result) < 2:
break
cursor = str(scan_result[0])
keys = scan_result[1]
if isinstance(keys, list):
all_keys.extend(keys)
# cursor "0" segnala fine iterazione
if cursor == "0":
break
return all_keys
@router.post("/{session_id}/write")
async def bb_write(
session_id: str,
entry: BBEntry,
_auth: AuthRole = Depends(require_role(AuthRole.MACHINE)),
):
"""GAP-BLACKBOARD-NOAUTH fix: scrittura protetta con require_role(MACHINE).
Richiede X-Internal-Token header (aggiunto dal CF Worker su route non-public).
Senza auth, un attaccante poteva avvelenare il contesto dei sub-agenti.
"""
rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}"
payload = json.dumps({
"agentId": entry.agentId,
"key": entry.key,
"value": entry.value,
"severity": entry.severity,
"ts": entry.ts,
})
# SET key value EX ttl
await _redis_post(["SET", rkey, payload, "EX", _TTL])
return {"ok": True}
@router.get("/{session_id}/read")
async def bb_read(session_id: str):
"""Legge tutte le entries del blackboard per la sessione. Pubblico (solo findings)."""
keys = await _redis_scan(f"bb:{session_id}:*")
if not keys:
return {"entries": [], "session_id": session_id}
mget = await _redis_post(["MGET"] + keys)
entries = []
if mget and "result" in mget:
for v in mget["result"]:
if v:
try:
entries.append(json.loads(v))
except Exception:
pass
return {"entries": entries, "session_id": session_id}
@router.delete("/{session_id}")
async def bb_clear(
session_id: str,
_auth: AuthRole = Depends(require_role(AuthRole.MACHINE)),
):
"""GAP-BLACKBOARD-NOAUTH fix: delete protetto con require_role(MACHINE).
Senza auth, un attaccante poteva cancellare il blackboard di sessioni attive
→ VALIDATOR cieco, self-healing disabilitato silenziosamente.
"""
keys = await _redis_scan(f"bb:{session_id}:*")
if keys:
await _redis_post(["DEL"] + keys)
return {"ok": True, "deleted": len(keys)}