gateway — il punto di ingresso
Il gateway è il processo di myclaw. Non una libreria, non un
insieme di script: un singolo processo FastAPI che parte a boot (via
systemd --user), ascolta su 127.0.0.1:42618, e resta
vivo finché il PC è acceso. Tutto ciò che succede in myclaw passa da qui.
AgentRuntime.handle() e cura il ciclo di vita della ExecutionTrace.channel.html).policy.html).AgentRuntime. Il runtime, a sua volta, attinge dall'interfaccia LLM e dal workspace. Nessuna comunicazione diretta canali ↔ runtime: il gateway è il solo punto di snodo.
Tutte le route sono su 127.0.0.1:42618. Nessuna è esposta su
0.0.0.0 di default. L'accesso da altro host della LAN (o remoto
via tunnel) richiede reverse-proxy esplicito, fuori scope di questo doc.
| Metodo | Path | Cosa fa |
|---|---|---|
| POST | /ingest/cli |
Canale CLI locale invia un messaggio. Body: {session_id, text}. Ritorna ExecutionTrace id (per sse). |
| POST | /ingest/telegram |
Webhook-style, ma in realtà chiamato dal worker di long-poll interno al gateway. Body: Telegram Update JSON. |
| SSE | /ingest/<channel>/stream/<trace_id> |
Server-Sent Events: stream del final_response token-by-token, utile per canali che supportano streaming progressivo (CLI). |
| Metodo | Path | Cosa fa |
|---|---|---|
| GET | /approvals/pending?sender=<id> |
Lista ApprovalRequest pendenti per un sender. |
| POST | /approvals/<request_id>/grant |
Concede. Body: {sender, via: "explicit"|"batch", batch_window_min?: int}. |
| POST | /approvals/<request_id>/deny |
Rifiuta. Body: {sender, permanent?: bool}. Se permanent=true → never_list. |
| POST | /undo?sender=<id> |
Revoca ultima azione del sender. Vedi approval_ux §6. |
| Metodo | Path | Cosa fa |
|---|---|---|
| GET | /health | Stato: uptime, version, memoria, ultimo errore. |
| GET | /metrics | Metriche Prometheus-like: richieste, costo giornaliero, trace count, sessioni attive. |
| GET | /admin/sessions | Lista sessioni attive (richiede token). |
| GET | /admin/pairings | Lista sender pairati per canale. |
| POST | /admin/pairings/approve | Approva un pairing code. Body: {channel, code, as_role}. |
| GET | /admin/cron | Lista cron job schedulati. |
| POST | /admin/cron | Aggiunge cron job. Body: {when, intent, channel}. |
| GET | /admin/audit?since=&limit= | Legge l'audit log JSONL filtrato. |
| GET | /admin/budget | Mostra consumo giornaliero vs soft/hard cap. |
| Metodo | Path | Cosa fa |
|---|---|---|
| WS | /ws/internal | Solo per il worker CLI: WebSocket bidirezionale per streaming interattivo del loop ReAct (thought + tool_call intermedi). |
Una sessione è una conversazione logica con un sender su un canale. Ha uno stato (memoria di chat, autonomy corrente, batch attivi) e un ciclo di vita. Il gateway le gestisce in memoria con write-through su SQLite.
session_id := hash("{channel}:{sender_id}:{autonomy_level}")
# esempio: hash("telegram:@roberto:supervised") = "s_4a7b..."
La stessa persona su due canali = due sessioni distinte. Lo stesso sender che cambia autonomy = nuova sessione (la vecchia diventa archived). Questo mantiene chiaro il confine di autonomia.
Per evitare confusione cross-doc, fissiamo qui la chiave di recupero usata da memory:
| Livello di memoria | Chiave di indicizzazione | Ciclo di vita |
|---|---|---|
| working | trace_id | vive e muore con la ExecutionTrace del turno |
| episodic | sender (canale-agnostico: telegram:@rob e cli:roberto condividono episodic se mappati alla stessa identità utente) | persiste oltre la sessione, decade Ebbinghaus (vedi synapse) |
| semantic e core | globale, nessuna chiave | vive per sempre salvo reflection + approvazione |
Il Gateway espone Session.sender come unica chiave che la Memory deve
leggere; non espone session_id alla Memory (la session è un oggetto
di conversazione, non di identità).
In memoria per il ciclo di vita, con write-through su SQLite per sopravvivere ai restart del gateway:
workspace/state/sessions.db # SQLite
└─ sessions(session_id, channel, sender, autonomy, created, last_activity, closed_at)
└─ messages(session_id, step_idx, role, content, trace_id, timestamp)
└─ batches(batch_id, session_id, effect_class, expires_at)
ApprovalRequest
La revisione incrociata dei doc ha trovato un buco di responsabilità: la
ApprovalRequest (approval_ux §9) è il dato
chiave del flusso, ma né la Policy né il broker la costruiscono. È il
Gateway, e qui è il motivo: solo il Gateway conosce
insieme il session_id, il trace_id in costruzione, il canale e il
sender attivo. La Policy non ha queste informazioni contestuali; il broker è
un consumatore puro.
ApprovalRequest. Solo il Gateway ha il contesto completo (trace, canale, sender, session, timeout).# Pseudocodice del Gateway (vive accanto a ingest()):
def _on_policy_approve_required(
self,
draft: ApprovalRequestDraft, # da Policy
trace_id: UUID,
session: Session,
) -> ApprovalRequest:
return ApprovalRequest(
request_id=uuid4(),
trace_id=trace_id,
sender=session.sender,
channel=session.channel,
tool_name=draft.tool_name,
args=draft.args_preview, # redacted
effect_class=draft.effect_class,
summary=draft.summary, # preferibilmente da tool.dry_run()
reversibility=draft.reversibility,
risk=draft.risk,
requested_at=now_utc(),
timeout_at=now_utc() + channel.default_approval_timeout(),
# fonte: il Channel possiede il default per-canale (vedi approval_ux §8).
)
async def _await_approval(self, req: ApprovalRequest) -> Approval:
# Il broker si occupa di batching, tutor mode, pausa lettura, revoca:
return await self.broker.request(req)
ApprovalRequest e a chiamare
ApprovalBroker.request(). La Policy emette un draft; il broker
consuma la request finale. Questo chiude il buco B4 della revisione
incrociata di fase 1.
Tre strategie, una per tipo di canale. Il gateway ne orchestra fino a N in parallelo, ciascuna in un task asyncio dedicato.
| Canale | Strategia | Dettaglio |
|---|---|---|
| CLI | unix socket + HTTP |
Il binario myclaw chat apre una connessione a
/tmp/myclaw.sock. Parla HTTP sul socket, con upgrade a
WebSocket per lo streaming del loop (thought + tool_call mostrati in tempo
reale). Latenza <5ms.
|
| Telegram | long-poll outbound |
Un task asyncio in loop chiama getUpdates di Telegram Bot API.
Gli update vengono tradotti in POST interni a /ingest/telegram.
Nessun webhook pubblico: non serve esporre myclaw a
internet, evita problemi di reachability e TLS.
|
| Voce (futuro) | push via sibling |
Un assistente domotico
(sibling agent che gestisce voce e domotica) invia trascrizioni STT a
POST /ingest/voice. Il gateway risponde con testo che il
sibling sintetizzerà via TTS. Il full duplex della conversazione vocale
resta gestito dal sibling; myclaw si limita al ragionamento sul testo.
|
| MQTT (futuro) | subscriber |
Un task subscribe a topic myclaw/inbox/#. Risponde su
myclaw/outbox/<origin>. Integrazione con Home Assistant.
|
Il gateway non mantiene una connessione bidirezionale coi canali
esterni (Telegram, MQTT). Il canale ha un proprio task che, oltre a
ricevere, sa anche rispondere via API sua (es. sendMessage
di Telegram). Il runtime, a fine loop, passa il final_response al channel adapter
via coda asyncio. Il channel adapter fa la chiamata outbound verso il mondo.
Il gateway ha uno scheduler interno basato su APScheduler (o
equivalente asyncio-native). Gli utenti aggiungono job via comando
(myclaw cron add "ogni sera 22:00" "riassumi log di oggi").
Un cron job non è un comando shell. È un'intenzione che, a tempo stabilito, viene iniettata come messaggio utente in una sessione dedicata:
CronJob(
id="cj_daily_22",
when="0 22 * * *",
channel="telegram:@roberto",
autonomy="supervised", # stessa policy di un messaggio utente
intent="riassumi gli errori in /var/log/syslog di oggi, se ce ne sono notificami"
)
Alle 22:00, il gateway crea una sessione cron-owned, inietta l'intent come messaggio utente, lascia che l'agent_runtime risponda. Se la risposta contiene "notifica", il canale dedicato la recapita. Se una tool-call richiede approvazione e Roberto dorme, la richiesta va in Pending con timeout di 2h.
workspace/state/cron.yaml # file YAML editabile anche a mano
jobs:
- id: cj_daily_22
when: "0 22 * * *"
channel: "telegram:@roberto"
intent: "..."
created_at: "2026-04-21T18:00:00Z"
last_run: "2026-04-20T22:00:00Z"
last_outcome: "success"
Un singolo bearer token in config/secrets.env:
MYCLAW_ADMIN_TOKEN=<32 random bytes in hex>
Richiesto in header Authorization: Bearer <token> su ogni path
/admin/*. Il CLI lo legge dal file (chmod 600) alla partenza. Niente
OAuth, JWT, o roba complessa: è un singolo token per un singolo amministratore (Roberto)
su un singolo host. Semplicità batte sofisticazione.
| Canale | Meccanismo |
|---|---|
| CLI | Accesso al socket richiede uid matching con roberto. Chmod 600 sul socket. Nessun token. |
| Telegram |
Il gateway identifica il sender dal campo from.id dell'Update. Lo confronta con la pairing list (pairing.html). Sender non pairato → pairing flow.
Bot token Telegram in secrets.env.
|
| Voce (futuro) | L'assistente domotico sibling autentica l'utente via speaker ID (servizio fornito dall'interfaccia LLM); passa il sender_id al gateway con HMAC sul payload. |
/admin, ma /ingest/cli assume
socket Unix con controllo di uid. Bind su 0.0.0.0 equivale a dare /ingest/cli
a chiunque sulla LAN.
Il gateway gira come systemd user service per roberto. L'unità è
pesantemente indurita: myclaw deve fare cose potenti (vedi nota operativa di
Roberto: "sandbox abbastanza potente da permettere azioni concrete"), ma il
gateway stesso non deve avere più privilegi di quelli necessari ad
ascoltare HTTP e invocare la sandbox.
# ~/.config/systemd/user/myclaw-gateway.service
[Unit]
Description=myclaw gateway
After=network.target
[Service]
Type=simple
ExecStart=/opt/myclaw/.venv/bin/python -m myclaw.gateway
Restart=on-failure
RestartSec=5s
# ---- Hardening ----
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/opt/myclaw/workspace /opt/myclaw/.runtime
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# ---- Limits ----
LimitNOFILE=4096
MemoryHigh=512M
MemoryMax=1G
# ---- Env ----
EnvironmentFile=/opt/myclaw/config/secrets.env
[Install]
WantedBy=default.target
| Direttiva | Perché |
|---|---|
NoNewPrivileges=yes | Nessun processo figlio può guadagnare privilegi (setuid, file capabilities). Fondamentale dato che il gateway spawna sandbox. |
ProtectSystem=strict | Tutto il filesystem è read-only tranne ReadWritePaths. Workspace scrivibile, tutto il resto no. |
ProtectHome=read-only | La home di Roberto è leggibile ma non modificabile dal gateway stesso. La sandbox invocata può avere permessi diversi (gestiti in sandbox.html). |
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | Niente raw socket, niente AF_NETLINK. Solo TCP/IP (per chiamate outbound a LLM/Telegram) e Unix socket (CLI). |
MemoryMax=1G | Hard cap. Se il gateway sale oltre, OOM-kill e restart. Previene leak di contesto. |
SystemCallFilter=@system-service | Blocca syscall esotiche (ptrace, io_uring, keyctl...) non necessarie. |
sandbox.html) avrà i suoi profili bwrap dimensionati per lasciare
a myclaw la potenza effettiva di agire nel workspace e in aree
operative definite. Separazione netta: gateway stretto, sandbox ampia entro
limiti esplicitati.
from typing import Protocol
from fastapi import FastAPI
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass
class Session:
session_id: str
channel: str
sender: str
autonomy: str # "readonly" | "supervised" | "full"
created_at: datetime
last_activity: datetime
state: str # "new" | "active" | "idle" | "closed"
pending_approvals: list[UUID]
class Gateway(Protocol):
app: FastAPI
runtime: AgentRuntime # iniettato
sessions: SessionStore
broker: ApprovalBroker
scheduler: CronScheduler
async def start(self) -> None: ...
async def shutdown(self) -> None: ...
async def ingest(
self,
channel: str,
sender: str,
message: str,
) -> ExecutionTrace:
"""Punto d'ingresso unificato usato da tutti gli adapter canale."""
...
class SessionStore(Protocol):
async def get_or_create(
self, channel: str, sender: str, autonomy: str,
) -> Session: ...
async def close(self, session_id: str) -> None: ...
async def archive_on_autonomy_change(
self, session_id: str, new_autonomy: str,
) -> Session: ...
class CronScheduler(Protocol):
async def add(self, job: CronJob) -> str: ... # ritorna job_id
async def remove(self, job_id: str) -> None: ...
async def list_jobs(self) -> list[CronJob]: ...
async def run_now(self, job_id: str) -> ExecutionTrace: ...
| Alternativa | Perché scartata |
|---|---|
| Gateway + runtime come microservizi separati | Latenza IPC, doppio audit, doppio log, due unità systemd, zero beneficio a scala casa. Process singolo è la scelta giusta. |
| gRPC invece di HTTP/REST | Nessun consumer ne ha bisogno. HTTP è ispezionabile con curl, loggabile con tcpdump, sufficiente per l'uso. |
| Webhook pubblico Telegram (reverse proxy + Let's Encrypt) | Richiede TLS su router, DDNS, rinnovi cert. Long-poll è 20 righe di codice e zero infra pubblica. |
| OAuth/OIDC per admin API | Popolazione di 1 utente amministratore. Bearer token statico batte OAuth per semplicità. |
Cron di sistema (crontab) |
Perde la semantica "inject intent as user message in session": cron di sistema esegue shell, non dialoga con l'agente. Il nostro APScheduler è la glue corretta. |
| WebSocket per tutti i canali | Overkill per canali stateless (Telegram). Usiamo WS solo dove il bidirezionale ha senso (CLI streaming del loop). |
| Invariante | Test |
|---|---|
| Bind solo su 127.0.0.1 | Avvio con config default → ss -tlnp mostra socket solo loopback. Bind su 0.0.0.0 richiede override esplicito + warning. |
| Admin endpoint richiede token | GET /admin/sessions senza header → 401. Con token corretto → 200. |
| Sessione nasce alla prima ingest | POST /ingest/cli con sender nuovo → session_id creato, state=new → passa a active durante il turno. |
| Cambio autonomy archivia e ricrea | Sessione S in autonomy supervised. Cambio → S diventa closed (archived), nasce S2 con stesso sender/channel ma autonomy nuova. |
| Idle timeout dopo 30 min | Sessione senza attività per 30:01 min → stato passa a closed. |
| Crash recovery session | Kill -9 del gateway durante turno attivo. Restart → sessione è idle (non attiva), ultimo messaggio completato visibile. |
| Cron inject come user message | Job con intent "test cron" scatta → nell'audit log compare un turno con channel="cron:<job_id>" e messaggio utente "test cron". |
| Approval endpoint non accetta sender spoofing | POST /approvals/<id>/grant con sender diverso dal sender che ha originato la richiesta → 403. |
| SSE stream chiude a completamento | Client connesso a /ingest/.../stream/<trace_id> riceve eventi token, poi evento event: complete, poi close. |
| Systemd restart su crash | Crash del processo → systemd riavvia entro 5s. /health risponde di nuovo. |
| Budget hit blocca nuovi turni | Config con hard_cap a 0.01€ raggiunto → POST /ingest/* ritorna 429 con messaggio esplicativo. |
| Riferimento | Cosa abbiamo preso |
|---|---|
supra-gateway (pattern di sibling project /opt/suprastructure/gateway/) | Convenzioni di porta, systemd unit, health endpoint. Stile coerente in casa. |
| FastAPI docs (SSE, WebSocket, background tasks) | Patterns per streaming e long-running tasks. |
Telegram Bot API (getUpdates long-polling) | Strategia di ingestione senza webhook pubblico (§5). |
| systemd.exec(5) — sandbox directives | Tutte le direttive hardening del §8. |
| OpenHands gateway design | Pattern di separazione tra ingest, runtime, persistence. |
| Giudizio di fase 0 — critica #4 status visibility | SSE e WS per mostrare il thinking in real time ai canali (§3). |
Channel e le due prime implementazioni (CLI e Telegram). Lato adapter dei canali che il gateway orchestra.
myclaw — gateway microprogettazione v1.0 — 2026-04-21
Primo dei 4 classici. Prossimo: channel.html.