intermesh 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
intermesh/hub.py ADDED
@@ -0,0 +1,996 @@
1
+ """
2
+ InterMesh Hub — routage central, fédération inter-organisations, télémétrie,
3
+ console d'administration, persistance.
4
+
5
+ Point d'entrée unique : ce module remplace l'ancien couple
6
+ `hub.py` / `hub_telemetry.py`, qui avait divergé (la fédération n'était
7
+ implémentée que dans le second, que personne ne démarrait).
8
+ """
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import time
13
+
14
+ import jwt
15
+ import websockets
16
+
17
+ from intermesh.admin import MUTATING, AdminContext, AdminError, authorize, caller_scope
18
+ from intermesh.admin import execute as admin_execute
19
+ from intermesh.apikeys import ApiKeyStore
20
+ from intermesh.audit import ImmutableAuditLog
21
+ from intermesh.egress import EgressBlocked, EgressPolicy, apply_egress
22
+ from intermesh.escrow import EscrowError, EscrowManager
23
+ from intermesh.guardrails import AsimovGuardrailEngine, PolicyViolationError
24
+ from intermesh.health import HealthCheckHandler
25
+ from intermesh.identity import AgentIdentity
26
+ from intermesh.message import MessageType, InterMeshMessage
27
+ from intermesh.peering import (
28
+ assert_peer_link_is_secure, build_peer_ssl_context, build_server_ssl_context,
29
+ parse_peer_spec,
30
+ )
31
+ from intermesh.ratelimit import RateLimiter
32
+ from intermesh.secret import resolve_hub_secret
33
+ from intermesh.signing import (
34
+ ALGORITHM, derive_signing_key, key_fingerprint, load_public_pem, public_pem,
35
+ )
36
+ from intermesh.store import InterMeshStore
37
+ from intermesh.task import InterMeshTask, TaskStatus
38
+
39
+ TOKEN_EXPIRY_SECONDS = 3600
40
+ MAX_FREE_AGENTS = 15
41
+
42
+ # État partagé du Hub, peuplé par main() au démarrage.
43
+ agents: dict[str, "websockets.ServerConnection"] = {} # qualified_name -> websocket
44
+ identity_registry: dict[str, AgentIdentity] = {} # qualified_name -> AgentIdentity
45
+ task_registry: dict[str, InterMeshTask] = {} # task_id -> InterMeshTask
46
+ peered_hubs: dict[str, object] = {} # org distante -> websocket du Hub pair
47
+ observers: set = set() # websockets dashboard / topologie / sécurité
48
+ agent_meta: dict[str, dict] = {} # qualified_name -> métriques de connexion
49
+
50
+ # Un agent est traité comme observateur (télémétrie seule, hors quota) s'il
51
+ # déclare le rôle "observer" ou si son nom porte un de ces préfixes.
52
+ OBSERVER_PREFIXES = ("topology_", "nexus_dashboard", "dashboard_", "agents_dir", "security_")
53
+
54
+ store: InterMeshStore | None = None
55
+ api_keys: ApiKeyStore | None = None
56
+ audit_log: ImmutableAuditLog | None = None
57
+ rate_limiter = RateLimiter(default_rate=20.0, default_burst=30.0)
58
+ asimov_engine = AsimovGuardrailEngine()
59
+ escrow_manager = EscrowManager()
60
+
61
+ HUB_SECRET: str = ""
62
+ HUB_ORG: str = "default"
63
+
64
+ # Signature asymétrique : la privée ne sort jamais d'ici, la publique est
65
+ # transmise aux pairs au moment du pairage.
66
+ HUB_PRIVATE_KEY = None # Ed25519PrivateKey
67
+ HUB_PUBLIC_PEM: str = ""
68
+ HUB_KEY_ID: str = ""
69
+ peer_public_keys: dict[str, str] = {} # org distante -> clé publique PEM
70
+
71
+ # Politique de sortie de cette organisation. Vide par défaut : rien n'est
72
+ # filtré tant que rien n'est déclaré.
73
+ egress_policy: EgressPolicy = EgressPolicy()
74
+ SNAPSHOT_DIR: str | None = None # None => ~/.intermesh/snapshots (ou $INTERMESH_HOME)
75
+
76
+
77
+ # ----------------------------------------------------------------------
78
+ # Jetons
79
+ # ----------------------------------------------------------------------
80
+
81
+ def generate_token(agent_name: str, agent_id: str, roles: list, org_id: str, auth_method: str) -> str:
82
+ payload = {
83
+ "agent_name": agent_name, "agent_id": agent_id, "roles": roles,
84
+ "org_id": org_id, "auth_method": auth_method,
85
+ "issued_at": time.time(), "expires_at": time.time() + TOKEN_EXPIRY_SECONDS,
86
+ "issuer": "intermesh-hub", "iss_org": HUB_ORG,
87
+ }
88
+ return jwt.encode(payload, HUB_PRIVATE_KEY, algorithm=ALGORITHM,
89
+ headers={"kid": HUB_KEY_ID})
90
+
91
+
92
+ def decode_token(token: str, issuing_org: str | None = None) -> dict | None:
93
+ """Décode un jeton en vérifiant sa signature.
94
+
95
+ `issuing_org` désigne l'organisation censée l'avoir émis : par défaut
96
+ ce Hub. Pour un jeton reçu d'un pair, la vérification se fait contre la
97
+ clé publique que ce pair a publiée lors du pairage — un Hub ne peut donc
98
+ pas signer au nom d'un autre.
99
+ """
100
+ if not token:
101
+ return None
102
+
103
+ if issuing_org is None or issuing_org == HUB_ORG:
104
+ key = HUB_PUBLIC_PEM
105
+ else:
106
+ key = peer_public_keys.get(issuing_org)
107
+ if key is None:
108
+ return None
109
+
110
+ try:
111
+ return jwt.decode(token, key, algorithms=[ALGORITHM])
112
+ except Exception:
113
+ return None
114
+
115
+
116
+ def verify_token(token: str, expected_agent: str, issuing_org: str | None = None) -> bool:
117
+ payload = decode_token(token, issuing_org) if token else None
118
+ if payload is None:
119
+ return False
120
+ if payload.get("agent_name") != expected_agent:
121
+ return False
122
+ if payload.get("expires_at", 0) < time.time():
123
+ return False
124
+ # Un jeton émis par une organisation ne vaut que pour elle : sans ce
125
+ # contrôle, un pair pairé pourrait rejouer un jeton en se réclamant
126
+ # d'une organisation tierce.
127
+ expected_issuer = HUB_ORG if issuing_org is None else issuing_org
128
+ return payload.get("iss_org", expected_issuer) == expected_issuer
129
+
130
+
131
+ def verify_federated_token(token: str, origin_org: str, claimed_sender: str | None) -> bool:
132
+ """Vérifie un jeton porté par un message relayé depuis le Hub `origin_org`.
133
+
134
+ Un message relayé peut être émis par le Hub lui-même (TASK_ASSIGN, par
135
+ exemple) tout en transportant le jeton de l'agent commanditaire : la
136
+ liaison nom↔jeton ne s'applique donc qu'aux messages émis par un agent.
137
+ Ce qui est vérifié dans tous les cas — et qui est le fond du sujet — est
138
+ que le jeton porte bien la signature de l'organisation d'origine.
139
+ """
140
+ payload = decode_token(token, origin_org) if token else None
141
+ if payload is None:
142
+ return False
143
+ if payload.get("expires_at", 0) < time.time():
144
+ return False
145
+ if payload.get("iss_org") != origin_org:
146
+ return False
147
+ if claimed_sender and claimed_sender != "hub":
148
+ return payload.get("agent_name") == claimed_sender
149
+ return True
150
+
151
+
152
+ # ----------------------------------------------------------------------
153
+ # Aides
154
+ # ----------------------------------------------------------------------
155
+
156
+ def _qualified_name(identity: AgentIdentity) -> str:
157
+ # Convention partagée avec intermesh.store et intermesh.admin::_org_of :
158
+ # un nom sans préfixe appartient à l'organisation "default". AgentIdentity
159
+ # préfixe pourtant systématiquement ("default/x") ; on l'enlève ici.
160
+ if identity.org_id == "default":
161
+ return identity.name
162
+ return identity.qualified_name
163
+
164
+
165
+ def _local_name(name: str | None) -> str | None:
166
+ """Nom sous lequel un agent est réellement enregistré localement, ou None.
167
+
168
+ Le SDK peut adresser un agent de l'organisation "default" aussi bien par
169
+ `x` que par `default/x` ; `_qualified_name` ne retient que la forme courte.
170
+ On accepte donc les deux à la lecture.
171
+ """
172
+ if not name:
173
+ return None
174
+ if name in agents:
175
+ return name
176
+ if name.startswith("default/"):
177
+ short = name[len("default/"):]
178
+ if short in agents:
179
+ return short
180
+ return None
181
+
182
+
183
+ def _agent_org(qualified_name: str) -> str:
184
+ ident = identity_registry.get(qualified_name)
185
+ if ident is None and qualified_name and qualified_name.startswith("default/"):
186
+ ident = identity_registry.get(qualified_name[len("default/"):])
187
+ if ident is not None:
188
+ return ident.org_id
189
+ if qualified_name and "/" in qualified_name:
190
+ return qualified_name.split("/")[0]
191
+ return HUB_ORG
192
+
193
+
194
+ def remember_task(task: InterMeshTask) -> None:
195
+ task_registry[task.task_id] = task
196
+ if store is not None:
197
+ store.save_task(task)
198
+
199
+
200
+ # ----------------------------------------------------------------------
201
+ # Télémétrie (dashboard, topologie, console sécurité)
202
+ # ----------------------------------------------------------------------
203
+
204
+ async def broadcast(event: str, data: dict) -> None:
205
+ """Diffuse un évènement de télémétrie aux observateurs connectés."""
206
+ if not observers:
207
+ return
208
+ payload = json.dumps({"type": MessageType.TELEMETRY, "content": {"event": event, **data}})
209
+ dead = []
210
+ for ws in list(observers):
211
+ try:
212
+ await ws.send(payload)
213
+ except Exception:
214
+ dead.append(ws)
215
+ for ws in dead:
216
+ observers.discard(ws)
217
+
218
+
219
+ def snapshot_agents() -> list[dict]:
220
+ now = time.time()
221
+ out = []
222
+ for name, meta in agent_meta.items():
223
+ ident = identity_registry.get(name)
224
+ out.append({
225
+ "name": name,
226
+ "agent_id": ident.agent_id if ident else None,
227
+ "roles": ident.roles if ident else [],
228
+ "capabilities": ident.capabilities if ident else [],
229
+ "status": meta.get("status", "healthy"),
230
+ "connected_at": meta.get("connected_at"),
231
+ "last_seen": meta.get("last_seen", now),
232
+ "msg_count": meta.get("msg_count", 0),
233
+ "online": name in agents,
234
+ })
235
+ return out
236
+
237
+
238
+ # ----------------------------------------------------------------------
239
+ # Fédération inter-organisations
240
+ # ----------------------------------------------------------------------
241
+
242
+ def is_peered(org: str) -> bool:
243
+ """Vrai si `org` est une organisation avec laquelle un pairage est actif.
244
+
245
+ C'est le seul assouplissement de l'isolement multi-tenant : sans pairage
246
+ explicite (--peer côté sortant, PEER_CONNECT côté entrant), toute adresse
247
+ hors organisation reste refusée.
248
+ """
249
+ return org in peered_hubs
250
+
251
+
252
+ async def deliver_or_relay(msg: InterMeshMessage) -> bool:
253
+ """Livre `msg` à l'agent local visé, sinon le relaie au Hub pair de son org.
254
+
255
+ Retourne True si le message a été livré ou relayé.
256
+ """
257
+ target = msg.to
258
+ if not target:
259
+ return False
260
+
261
+ local = _local_name(target)
262
+ if local is not None:
263
+ try:
264
+ await agents[local].send(msg.to_json())
265
+ except Exception:
266
+ return False
267
+ return True
268
+
269
+ target_org = _agent_org(target)
270
+ peer_ws = peered_hubs.get(target_org)
271
+ if peer_ws is None:
272
+ return False
273
+
274
+ # Le contenu quitte l'organisation : filtrage de sortie. Le Hub ne voit
275
+ # que ce qui n'est pas chiffré de bout en bout ; c'est un garde-fou en
276
+ # complément du filtre appliqué par l'agent, pas à sa place.
277
+ try:
278
+ msg.content, triggered = apply_egress(msg.content, target_org, egress_policy)
279
+ except EgressBlocked as blocked:
280
+ audit_log.log("EGRESS_BLOCKED", msg.sender, target_org, {
281
+ "rule": blocked.rule_name, "message_type": str(msg.type),
282
+ })
283
+ await broadcast("egress_blocked", {
284
+ "sender": msg.sender, "target_org": target_org, "rule": blocked.rule_name,
285
+ })
286
+ print(f"[egress] envoi vers '{target_org}' bloqué par la règle '{blocked.rule_name}'")
287
+ return False
288
+ if triggered:
289
+ audit_log.log("EGRESS_REDACTED", msg.sender, target_org, {
290
+ "rules": triggered, "message_type": str(msg.type),
291
+ })
292
+
293
+ relay = InterMeshMessage(
294
+ type=MessageType.FEDERATION_RELAY, sender=HUB_ORG,
295
+ to=target_org, content=msg.to_dict(),
296
+ )
297
+ try:
298
+ await peer_ws.send(relay.to_json())
299
+ except Exception:
300
+ return False
301
+ return True
302
+
303
+
304
+ async def send_task_to_agent(task: InterMeshTask) -> bool:
305
+ return await deliver_or_relay(InterMeshMessage(
306
+ type=MessageType.TASK_ASSIGN, sender="hub", to=task.assignee, content=task.to_dict(),
307
+ ))
308
+
309
+
310
+ def discover_agents(query: dict, caller_org: str) -> list[dict]:
311
+ """Recherche d'agents, strictement cloisonnée à l'organisation de l'appelant."""
312
+ limit = int(query.get("limit", 15))
313
+ online_only = query.get("online_only", True)
314
+ req_caps = set(query.get("capabilities") or [])
315
+ req_roles = set(query.get("roles") or [])
316
+ req_meta = query.get("metadata") or {}
317
+ name_contains = query.get("name_contains")
318
+
319
+ results = []
320
+ for name, ident in identity_registry.items():
321
+ if ident.org_id != caller_org:
322
+ continue
323
+ if online_only and name not in agents:
324
+ continue
325
+ if req_caps and not req_caps.issubset(set(ident.capabilities)):
326
+ continue
327
+ if req_roles and not req_roles.intersection(set(ident.roles)):
328
+ continue
329
+ if req_meta and not all(ident.metadata.get(k) == v for k, v in req_meta.items()):
330
+ continue
331
+ if name_contains and name_contains not in name:
332
+ continue
333
+ results.append({
334
+ "name": name, "qualified_name": name, "agent_id": ident.agent_id,
335
+ "capabilities": ident.capabilities, "roles": ident.roles,
336
+ "permissions": ident.permissions, "public_key": ident.public_key, "schema": ident.schema,
337
+ "online": name in agents,
338
+ })
339
+ if len(results) >= limit:
340
+ break
341
+ return results
342
+
343
+
344
+ async def process_relayed(ws, msg: InterMeshMessage, origin_org: str) -> None:
345
+ """Traite un message déballé d'un FEDERATION_RELAY émis par un Hub pair.
346
+
347
+ Le jeton porté par `msg` a été signé par le Hub d'origine avec SA clé
348
+ privée ; il est vérifié ici contre la clé publique que ce Hub a publiée
349
+ au moment du pairage. Un pair ne peut donc ni forger un jeton au nom
350
+ d'une organisation tierce, ni se réclamer d'une autre que la sienne.
351
+
352
+ Les réponses repartent sur la même connexion, ce qui les ramène
353
+ naturellement au Hub demandeur.
354
+ """
355
+ if not verify_federated_token(msg.token, origin_org, msg.sender):
356
+ audit_log.log("FEDERATION_TOKEN_REJECTED", msg.sender, HUB_ORG, {
357
+ "origin_org": origin_org, "message_type": str(msg.type),
358
+ })
359
+ await ws.send(InterMeshMessage(
360
+ type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id,
361
+ content=("FEDERATION_DENIED: jeton invalide ou non signé par "
362
+ f"l'organisation '{origin_org}'."),
363
+ ).to_json())
364
+ return
365
+
366
+ if msg.type == MessageType.WHO_IS:
367
+ ident = identity_registry.get(msg.content)
368
+ if ident is not None and ident.org_id == HUB_ORG:
369
+ await ws.send(InterMeshMessage(
370
+ type=MessageType.IDENTITY, sender="hub", to=msg.sender, reply_to=msg.id,
371
+ content=ident.to_dict(), token=msg.token,
372
+ ).to_json())
373
+
374
+ elif msg.type == MessageType.DISCOVER:
375
+ query = msg.content or {}
376
+ # Un pair ne découvre que les agents de CE Hub, jamais ceux d'un tiers.
377
+ matched = discover_agents(query, HUB_ORG)
378
+ await ws.send(InterMeshMessage(
379
+ type=MessageType.DISCOVER_RESULT, sender="hub", to=msg.sender, reply_to=msg.id,
380
+ content={"query": query, "count": len(matched), "agents": matched}, token=msg.token,
381
+ ).to_json())
382
+
383
+ elif msg.type == MessageType.TASK_SUBMIT:
384
+ raw_task = msg.content or {}
385
+ try:
386
+ asimov_engine.validate_task_submission(
387
+ agent_name=msg.sender,
388
+ task_id=raw_task.get("task_id"),
389
+ parent_task_id=raw_task.get("parent_task_id"),
390
+ estimated_cost=float(raw_task.get("estimated_cost") or 0.0),
391
+ payload_text=str(raw_task.get("input_data", raw_task.get("title", ""))),
392
+ org_id=_agent_org(msg.sender),
393
+ )
394
+ except PolicyViolationError as pve:
395
+ entry = audit_log.log("ASIMOV_GUARDRAIL_VIOLATION", msg.sender, raw_task.get("assignee"), {
396
+ "rule": pve.rule_name, "reason": str(pve), "task_id": raw_task.get("task_id"),
397
+ "federated": True,
398
+ })
399
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
400
+ await ws.send(InterMeshMessage(
401
+ type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id,
402
+ content=str(pve), token=msg.token,
403
+ ).to_json())
404
+ return
405
+
406
+ task = InterMeshTask.from_dict(raw_task)
407
+ remember_task(task)
408
+ entry = audit_log.log("TASK_SUBMITTED", msg.sender, task.assignee, {
409
+ "task_id": task.task_id, "title": task.title, "federated": True,
410
+ })
411
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
412
+ await deliver_or_relay(InterMeshMessage(
413
+ type=MessageType.TASK_ASSIGN, sender="hub", to=task.assignee, reply_to=msg.id,
414
+ content=raw_task, token=msg.token,
415
+ ))
416
+
417
+ elif msg.type == MessageType.TASK_UPDATE:
418
+ td = msg.content or {}
419
+ tid = td.get("task_id")
420
+ if tid in task_registry:
421
+ remember_task(InterMeshTask.from_dict(td))
422
+ if td.get("status") == TaskStatus.COMPLETED.value:
423
+ entry = audit_log.log("TASK_COMPLETED", msg.sender, td.get("orchestrator"), {
424
+ "task_id": tid, "federated": True,
425
+ })
426
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
427
+ await deliver_or_relay(InterMeshMessage(
428
+ type=MessageType.TASK_UPDATE, sender=msg.sender, to=td.get("orchestrator"),
429
+ content=td, token=msg.token,
430
+ ))
431
+
432
+ elif msg.type in (MessageType.MESSAGE, MessageType.REQUEST, MessageType.RESPONSE,
433
+ MessageType.TASK_ASSIGN):
434
+ await deliver_or_relay(msg)
435
+
436
+
437
+ async def connect_peer(remote_org: str, url: str, ca_file: str | None = None) -> None:
438
+ """Maintient une connexion sortante vers un Hub pair, avec reconnexion."""
439
+ ssl_context = build_peer_ssl_context(url, ca_file)
440
+ while True:
441
+ try:
442
+ async with websockets.connect(url, ssl=ssl_context) as ws:
443
+ await ws.send(InterMeshMessage(
444
+ type=MessageType.PEER_CONNECT, sender=HUB_ORG,
445
+ content={"org": HUB_ORG, "public_key": HUB_PUBLIC_PEM, "kid": HUB_KEY_ID},
446
+ ).to_json())
447
+ reply = InterMeshMessage.from_json(await ws.recv())
448
+ if reply.type != MessageType.PEER_CONNECTED:
449
+ raise ConnectionError(f"pairage refusé par '{remote_org}' : {reply.content}")
450
+
451
+ remote_key = (reply.content or {}).get("public_key")
452
+ if not remote_key:
453
+ raise ConnectionError(
454
+ f"'{remote_org}' n'a pas publié de clé publique — pairage abandonné")
455
+ load_public_pem(remote_key) # rejette une clé illisible
456
+ peer_public_keys[remote_org] = remote_key
457
+ peered_hubs[remote_org] = ws
458
+ print(f"[peering] connecté au pair '{remote_org}' ({url}) "
459
+ f"kid={key_fingerprint(remote_key)}")
460
+ try:
461
+ await handle_agent(ws, HUB_ORG)
462
+ finally:
463
+ peered_hubs.pop(remote_org, None)
464
+ peer_public_keys.pop(remote_org, None)
465
+ except Exception as exc:
466
+ print(f"[peering] lien vers '{remote_org}' ({url}) perdu : {exc} — nouvel essai")
467
+ await asyncio.sleep(3)
468
+
469
+
470
+ # ----------------------------------------------------------------------
471
+ # Probes HTTP (/healthz, /readyz, /metrics) sur le même port que le WS
472
+ # ----------------------------------------------------------------------
473
+
474
+ _health_handler = HealthCheckHandler(
475
+ readiness_evaluator=lambda: True,
476
+ state_metrics_provider=lambda: {
477
+ "intermesh_connected_agents": len(agents),
478
+ "intermesh_registered_identities": len(identity_registry),
479
+ "intermesh_peered_hubs": len(peered_hubs),
480
+ "intermesh_observers": len(observers),
481
+ },
482
+ )
483
+
484
+
485
+ async def process_request(connection, request=None):
486
+ path = getattr(request, "path", None) if request is not None else None
487
+ if path is None:
488
+ path = getattr(connection, "path", None)
489
+ if isinstance(connection, str):
490
+ path = connection
491
+
492
+ if not path:
493
+ return None
494
+
495
+ result = _health_handler.handle_request(path)
496
+ if result is None:
497
+ return None
498
+
499
+ status, headers, body = result
500
+ if hasattr(connection, "respond"):
501
+ response = connection.respond(status, body.decode("utf-8"))
502
+ for key, value in headers:
503
+ response.headers[key] = value
504
+ return response
505
+
506
+ return (status, headers, body)
507
+
508
+
509
+ # ----------------------------------------------------------------------
510
+ # Console d'administration
511
+ # ----------------------------------------------------------------------
512
+
513
+ async def _handle_admin_request(websocket, msg: InterMeshMessage) -> None:
514
+ content = msg.content if isinstance(msg.content, dict) else {}
515
+ command = content.get("command")
516
+ params = content.get("params") or {}
517
+
518
+ try:
519
+ payload = decode_token(msg.token) if msg.token else None
520
+ if payload is None:
521
+ raise AdminError("ADMIN_DENIED: token absent ou invalide.")
522
+ authorize(payload)
523
+ caller_org, scoped = caller_scope(payload)
524
+
525
+ ctx = AdminContext(
526
+ agents=agents, identity_registry=identity_registry, task_registry=task_registry,
527
+ audit_log=audit_log, api_keys=api_keys, peered_hubs=peered_hubs, store=store,
528
+ my_org=HUB_ORG, remember_task=remember_task, send_to_agent=send_task_to_agent,
529
+ caller_org=caller_org, scoped=scoped, asimov_engine=asimov_engine, escrow_manager=escrow_manager,
530
+ snapshot_dir=SNAPSHOT_DIR,
531
+ )
532
+ result = await admin_execute(command, params, ctx)
533
+
534
+ if command in MUTATING:
535
+ audit_log.log("ADMIN_ACTION", msg.sender, None, {"command": command})
536
+
537
+ await websocket.send(InterMeshMessage(
538
+ type=MessageType.ADMIN_RESULT, sender="hub", to=msg.sender, reply_to=msg.id,
539
+ content=result, token=msg.token,
540
+ ).to_json())
541
+
542
+ except AdminError as exc:
543
+ audit_log.log("ADMIN_DENIED", msg.sender, None, {"command": command, "reason": str(exc)})
544
+ await websocket.send(InterMeshMessage(
545
+ type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id,
546
+ content=str(exc), token=msg.token,
547
+ ).to_json())
548
+
549
+
550
+ # ----------------------------------------------------------------------
551
+ # Boucle principale par connexion
552
+ # ----------------------------------------------------------------------
553
+
554
+ AUTHENTICATED_TYPES = (
555
+ MessageType.MESSAGE, MessageType.REQUEST, MessageType.RESPONSE,
556
+ MessageType.TASK_SUBMIT, MessageType.TASK_UPDATE,
557
+ MessageType.WHO_IS, MessageType.DISCOVER, MessageType.ADMIN_REQUEST,
558
+ )
559
+
560
+
561
+ async def handle_agent(websocket, my_org: str):
562
+ agent_name = None
563
+ is_observer = False
564
+ try:
565
+ async for raw_data in websocket:
566
+ try:
567
+ msg = InterMeshMessage.from_json(raw_data)
568
+ except Exception as e:
569
+ await websocket.send(InterMeshMessage(type=MessageType.ERROR, sender="hub", content=str(e)).to_json())
570
+ continue
571
+
572
+ # -- Kill-switch distant (plan de contrôle) --------------------
573
+ if msg.type == "disconnect_agent" or (
574
+ isinstance(msg.content, dict) and msg.content.get("action") == "kill_agent"
575
+ ):
576
+ target_name = msg.to or (
577
+ msg.content.get("target_agent") if isinstance(msg.content, dict) else None
578
+ )
579
+ local = _local_name(target_name)
580
+ if local is not None:
581
+ target_ws = agents[local]
582
+ entry = audit_log.log("REMOTE_KILL_SWITCH", msg.sender, local,
583
+ {"reason": "Control Plane Revocation"})
584
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
585
+ try:
586
+ await target_ws.send(InterMeshMessage(
587
+ type=MessageType.ERROR, sender="hub", to=local,
588
+ content="DISCONNECTED_BY_ADMIN: connexion close depuis le plan de contrôle.",
589
+ ).to_json())
590
+ await target_ws.close(1000, "Disconnected by Admin")
591
+ except Exception:
592
+ pass
593
+ continue
594
+
595
+ # -- Pairage Hub à Hub (connexion entrante) --------------------
596
+ if msg.type == MessageType.PEER_CONNECT:
597
+ content = msg.content or {}
598
+ remote_org = content.get("org") or msg.sender
599
+ remote_key = content.get("public_key")
600
+ if not remote_key:
601
+ # Sans clé publique, les jetons du pair seraient
602
+ # invérifiables : le lien serait de la confiance aveugle.
603
+ await websocket.send(InterMeshMessage(
604
+ type=MessageType.ERROR, sender=my_org, to=remote_org,
605
+ content="PEER_REJECTED: clé publique absente de la demande de pairage.",
606
+ ).to_json())
607
+ audit_log.log("PEER_REJECTED", remote_org, my_org, {"reason": "no_public_key"})
608
+ continue
609
+ try:
610
+ load_public_pem(remote_key)
611
+ except Exception as exc:
612
+ await websocket.send(InterMeshMessage(
613
+ type=MessageType.ERROR, sender=my_org, to=remote_org,
614
+ content=f"PEER_REJECTED: clé publique illisible ({exc}).",
615
+ ).to_json())
616
+ audit_log.log("PEER_REJECTED", remote_org, my_org, {"reason": "bad_public_key"})
617
+ continue
618
+
619
+ peer_public_keys[remote_org] = remote_key
620
+ peered_hubs[remote_org] = websocket
621
+ await websocket.send(InterMeshMessage(
622
+ type=MessageType.PEER_CONNECTED, sender=my_org,
623
+ content={"org": my_org, "public_key": HUB_PUBLIC_PEM, "kid": HUB_KEY_ID},
624
+ ).to_json())
625
+ audit_log.log("PEER_CONNECTED", remote_org, my_org, {
626
+ "direction": "inbound", "peer_kid": key_fingerprint(remote_key),
627
+ })
628
+ print(f"[peering] pair accepté : {remote_org} (kid={key_fingerprint(remote_key)})")
629
+ continue
630
+
631
+ if msg.type == MessageType.FEDERATION_RELAY:
632
+ try:
633
+ inner = InterMeshMessage.from_dict(msg.content)
634
+ except Exception:
635
+ continue
636
+ await process_relayed(websocket, inner, msg.sender)
637
+ continue
638
+
639
+ # Réponse d'un pair à une requête relayée : à rendre telle quelle
640
+ # à l'agent local qui l'avait demandée (identifié par `to`).
641
+ if msg.type in (MessageType.IDENTITY, MessageType.DISCOVER_RESULT):
642
+ local = _local_name(msg.to)
643
+ if local is not None:
644
+ try:
645
+ await agents[local].send(msg.to_json())
646
+ except Exception:
647
+ pass
648
+ continue
649
+
650
+ if msg.type == MessageType.REGISTER:
651
+ d = msg.content or {}
652
+ raw_name = msg.sender
653
+
654
+ # -- Observateurs : télémétrie seule, hors quota ------------
655
+ roles_declared = d.get("roles", ["standard"])
656
+ if "observer" in roles_declared or str(raw_name).startswith(OBSERVER_PREFIXES):
657
+ is_observer = True
658
+ observers.add(websocket)
659
+ await websocket.send(json.dumps({
660
+ "type": MessageType.TELEMETRY,
661
+ "content": {
662
+ "event": "snapshot",
663
+ "agents": snapshot_agents(),
664
+ "audit_chain": [e.to_dict() for e in audit_log.chain],
665
+ "server_time": time.time(),
666
+ },
667
+ }))
668
+ await websocket.send(InterMeshMessage(
669
+ type=MessageType.REGISTERED, sender="hub", to=raw_name,
670
+ content={"status": "ready", "token": "observer"},
671
+ ).to_json())
672
+ print(f"[observer] {raw_name} connecté")
673
+ continue
674
+
675
+ current_active = len(agents)
676
+ if current_active >= MAX_FREE_AGENTS:
677
+ err_msg = f"AGENT_QUOTA_EXCEEDED: Free tier limit reached ({current_active}/{MAX_FREE_AGENTS} agents)."
678
+ await websocket.send(InterMeshMessage(type=MessageType.ERROR, sender="hub", to=raw_name, content=err_msg).to_json())
679
+ continue
680
+
681
+ api_key = d.get("api_key")
682
+ if api_key:
683
+ info = api_keys.lookup(api_key)
684
+ if info is None:
685
+ await websocket.send(InterMeshMessage(type=MessageType.ERROR, sender="hub", to=raw_name, content="API Key Entreprise invalide.").to_json())
686
+ continue
687
+ org_id = info["org_id"]
688
+ roles = info["roles"]
689
+ perms = info["permissions"]
690
+ auth_method = "api_key"
691
+ else:
692
+ org_id = d.get("org_id", my_org)
693
+ roles = d.get("roles", ["standard"])
694
+ perms = d.get("permissions", [])
695
+ auth_method = "self_declared"
696
+
697
+ identity = AgentIdentity.from_dict({
698
+ "name": raw_name, "org_id": org_id, "agent_id": d.get("agent_id"),
699
+ "capabilities": d.get("capabilities", []), "roles": roles,
700
+ "permissions": perms, "created_at": d.get("created_at"),
701
+ "metadata": d.get("metadata", {}), "public_key": d.get("public_key"),
702
+ "schema": d.get("schema"),
703
+ })
704
+
705
+ agent_name = _qualified_name(identity)
706
+
707
+ if asimov_engine.circuit_breaker.is_tripped(agent_name):
708
+ await websocket.send(InterMeshMessage(
709
+ type=MessageType.ERROR, sender="hub", to=agent_name,
710
+ content="ASIMOV_GUARDRAIL: agent isolé par le disjoncteur de sécurité.",
711
+ ).to_json())
712
+ await websocket.close(1008, "Asimov Isolated")
713
+ agent_name = None
714
+ continue
715
+
716
+ agents[agent_name] = websocket
717
+ identity_registry[agent_name] = identity
718
+ agent_meta[agent_name] = {
719
+ "connected_at": time.time(), "last_seen": time.time(),
720
+ "msg_count": 0, "status": "healthy",
721
+ }
722
+ if store is not None:
723
+ store.save_identity(identity)
724
+ token = generate_token(agent_name, identity.agent_id, identity.roles, identity.org_id, auth_method)
725
+
726
+ entry = audit_log.log("AGENT_REGISTERED", agent_name, "hub", {"roles": identity.roles, "org_id": identity.org_id})
727
+ await broadcast("agent_connected", {
728
+ "agent": {**identity.to_dict(), "status": "healthy", "online": True},
729
+ })
730
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
731
+
732
+ await websocket.send(InterMeshMessage(type=MessageType.REGISTERED, sender="hub", to=agent_name, content={
733
+ "status": "ready", "agent_id": identity.agent_id, "qualified_name": agent_name,
734
+ "roles": identity.roles, "permissions": identity.permissions, "org_id": identity.org_id,
735
+ "token": token, "online_agents": list(agents.keys()),
736
+ }).to_json())
737
+
738
+ # Reprise des tâches restées en attente pour cet exécutant.
739
+ pending = [t for t in task_registry.values()
740
+ if t.assignee == agent_name and t.status == TaskStatus.PENDING]
741
+ for task in pending:
742
+ await websocket.send(InterMeshMessage(
743
+ type=MessageType.TASK_ASSIGN, sender="hub", to=agent_name, content=task.to_dict(),
744
+ ).to_json())
745
+ audit_log.log("TASK_RESUMED", "hub", agent_name, {"task_id": task.task_id})
746
+
747
+ elif msg.type in AUTHENTICATED_TYPES:
748
+ if not msg.token or not verify_token(msg.token, msg.sender):
749
+ await websocket.send(InterMeshMessage(type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id, content="Token invalide.").to_json())
750
+ continue
751
+
752
+ if agent_name and agent_name in agent_meta:
753
+ agent_meta[agent_name]["last_seen"] = time.time()
754
+ agent_meta[agent_name]["msg_count"] = agent_meta[agent_name].get("msg_count", 0) + 1
755
+
756
+ if msg.type == MessageType.WHO_IS:
757
+ target_name = msg.content
758
+ ident = identity_registry.get(target_name)
759
+ sender_org = _agent_org(msg.sender)
760
+ if ident is not None and ident.org_id == sender_org:
761
+ await websocket.send(InterMeshMessage(type=MessageType.IDENTITY, sender="hub", to=msg.sender, reply_to=msg.id, content=ident.to_dict(), token=msg.token).to_json())
762
+ elif ident is None:
763
+ # Inconnu localement : interroger le pair si l'org visée
764
+ # fait l'objet d'un pairage explicite.
765
+ target_org = _agent_org(target_name)
766
+ peer_ws = peered_hubs.get(target_org) if target_org != sender_org else None
767
+ if peer_ws is not None:
768
+ await peer_ws.send(InterMeshMessage(
769
+ type=MessageType.FEDERATION_RELAY, sender=HUB_ORG, to=target_org,
770
+ content=msg.to_dict(),
771
+ ).to_json())
772
+
773
+ elif msg.type == MessageType.DISCOVER:
774
+ query = msg.content or {}
775
+ caller_org = _agent_org(msg.sender)
776
+ matched = discover_agents(query, caller_org)
777
+ await websocket.send(InterMeshMessage(
778
+ type=MessageType.DISCOVER_RESULT, sender="hub", to=msg.sender, reply_to=msg.id,
779
+ content={"query": query, "count": len(matched), "agents": matched}, token=msg.token,
780
+ ).to_json())
781
+
782
+ elif msg.type == MessageType.TASK_SUBMIT:
783
+ raw_task = msg.content or {}
784
+ task_org = _agent_org(msg.sender)
785
+
786
+ try:
787
+ asimov_engine.validate_task_submission(
788
+ agent_name=msg.sender,
789
+ task_id=raw_task.get("task_id"),
790
+ parent_task_id=raw_task.get("parent_task_id"),
791
+ estimated_cost=float(raw_task.get("estimated_cost") or 0.0),
792
+ payload_text=str(raw_task.get("input_data", raw_task.get("title", ""))),
793
+ org_id=task_org,
794
+ )
795
+ except PolicyViolationError as pve:
796
+ audit_log.log("ASIMOV_GUARDRAIL_VIOLATION", msg.sender, raw_task.get("assignee"), {
797
+ "rule": pve.rule_name, "reason": str(pve), "task_id": raw_task.get("task_id"),
798
+ })
799
+ await websocket.send(InterMeshMessage(
800
+ type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id,
801
+ content=str(pve), token=msg.token,
802
+ ).to_json())
803
+ continue
804
+
805
+ escrow_req = raw_task.get("escrow")
806
+ if escrow_req:
807
+ payee_org = _agent_org(raw_task.get("assignee"))
808
+ try:
809
+ hold = escrow_manager.create_hold(
810
+ task_id=raw_task.get("task_id"), payer_org=task_org, payee_org=payee_org,
811
+ amount=float(escrow_req.get("amount", 0) or 0),
812
+ currency=escrow_req.get("currency", "USD"),
813
+ auto_release=bool(escrow_req.get("auto_release", True)),
814
+ )
815
+ except EscrowError as ee:
816
+ audit_log.log("ESCROW_REJECTED", msg.sender, raw_task.get("assignee"), {
817
+ "reason": str(ee), "task_id": raw_task.get("task_id"),
818
+ })
819
+ await websocket.send(InterMeshMessage(
820
+ type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id,
821
+ content=str(ee), token=msg.token,
822
+ ).to_json())
823
+ continue
824
+ audit_log.log("ESCROW_HELD", msg.sender, raw_task.get("assignee"), {
825
+ "task_id": hold.task_id, "amount": hold.amount, "currency": hold.currency,
826
+ })
827
+
828
+ task = InterMeshTask.from_dict(msg.content)
829
+ remember_task(task)
830
+ entry = audit_log.log("TASK_SUBMITTED", msg.sender, task.assignee, {"task_id": task.task_id, "title": task.title})
831
+ await broadcast("task_submitted", {
832
+ "task_id": task.task_id, "title": task.title,
833
+ "orchestrator": msg.sender, "assignee": task.assignee,
834
+ })
835
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
836
+ # Non livrée (exécutant hors ligne) : la tâche reste PENDING
837
+ # et sera réassignée à sa reconnexion.
838
+ await deliver_or_relay(InterMeshMessage(
839
+ type=MessageType.TASK_ASSIGN, sender="hub", to=task.assignee,
840
+ reply_to=msg.id, content=task.to_dict(), token=msg.token,
841
+ ))
842
+
843
+ elif msg.type == MessageType.TASK_UPDATE:
844
+ td = msg.content or {}
845
+ tid = td.get("task_id")
846
+ orch = td.get("orchestrator")
847
+ if tid in task_registry:
848
+ task = InterMeshTask.from_dict(td)
849
+ remember_task(task)
850
+ if task.status == TaskStatus.COMPLETED:
851
+ entry = audit_log.log("TASK_COMPLETED", msg.sender, orch, {"task_id": tid})
852
+ await broadcast("task_completed", {"task_id": tid})
853
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
854
+ hold = escrow_manager.get(tid)
855
+ if hold and hold.status.value == "held" and hold.auto_release:
856
+ escrow_manager.release(tid)
857
+ audit_log.log("ESCROW_RELEASED", "hub", hold.payee_org, {
858
+ "task_id": tid, "amount": hold.amount, "currency": hold.currency,
859
+ })
860
+ elif task.status == TaskStatus.FAILED:
861
+ audit_log.log("TASK_FAILED", msg.sender, orch, {"task_id": tid, "error": task.error_message})
862
+ hold = escrow_manager.get(tid)
863
+ if hold and hold.status.value == "held":
864
+ escrow_manager.refund(tid)
865
+ audit_log.log("ESCROW_REFUNDED", "hub", hold.payer_org, {
866
+ "task_id": tid, "amount": hold.amount, "currency": hold.currency,
867
+ })
868
+ await deliver_or_relay(InterMeshMessage(
869
+ type=MessageType.TASK_UPDATE, sender="hub", to=orch,
870
+ content=td, token=msg.token,
871
+ ))
872
+
873
+ elif msg.type in (MessageType.MESSAGE, MessageType.REQUEST, MessageType.RESPONSE):
874
+ target = msg.to
875
+ sender_org = _agent_org(msg.sender)
876
+ target_org = _agent_org(target) if target else sender_org
877
+ if target and target_org != sender_org and not is_peered(target_org):
878
+ await websocket.send(InterMeshMessage(
879
+ type=MessageType.ERROR, sender="hub", to=msg.sender, reply_to=msg.id,
880
+ content=f"Isolement Multi-Tenant : '{target}' appartient à une autre organisation.",
881
+ ).to_json())
882
+ continue
883
+ await deliver_or_relay(msg)
884
+
885
+ elif msg.type == MessageType.ADMIN_REQUEST:
886
+ await _handle_admin_request(websocket, msg)
887
+
888
+ except websockets.ConnectionClosed:
889
+ pass
890
+ finally:
891
+ if is_observer:
892
+ observers.discard(websocket)
893
+ for org, peer_ws in list(peered_hubs.items()):
894
+ if peer_ws is websocket:
895
+ peered_hubs.pop(org, None)
896
+ peer_public_keys.pop(org, None)
897
+ print(f"[peering] pair '{org}' déconnecté")
898
+ if agent_name and agent_name in agents:
899
+ del agents[agent_name]
900
+ if agent_name in agent_meta:
901
+ agent_meta[agent_name]["status"] = "unhealthy"
902
+ entry = audit_log.log("AGENT_DISCONNECTED", agent_name, "hub", {"status": "unhealthy"})
903
+ await broadcast("agent_disconnected", {"agent_name": agent_name, "status": "unhealthy"})
904
+ await broadcast("audit_entry", {"entry": entry.to_dict()})
905
+
906
+
907
+ async def main(argv: list[str] | None = None):
908
+ """Démarre le Hub. `argv` permet de l'appeler depuis le CLI sans
909
+ dépendre de sys.argv, qui porte déjà le nom de la sous-commande."""
910
+ global store, api_keys, audit_log, HUB_SECRET, HUB_ORG, SNAPSHOT_DIR
911
+ global HUB_PRIVATE_KEY, HUB_PUBLIC_PEM, HUB_KEY_ID, egress_policy
912
+
913
+ parser = argparse.ArgumentParser(description="InterMesh Hub")
914
+ parser.add_argument("--port", type=int, default=8765, help="Port")
915
+ parser.add_argument("--org", type=str, default="default", help="Org")
916
+ parser.add_argument("--ephemeral-state", action="store_true", help="État en mémoire, rien sur disque")
917
+ parser.add_argument("--state-file", type=str, default=None, help="Chemin de la base d'état")
918
+ parser.add_argument("--ephemeral-secret", action="store_true", help="Clé JWT jetable")
919
+ parser.add_argument("--secret-file", type=str, default=None, help="Chemin du fichier de clé JWT")
920
+ parser.add_argument("--snapshot-dir", type=str, default=None, help="Dossier des instantanés d'état")
921
+ parser.add_argument("--dev-api-keys", action="store_true", help="Active les clés de démonstration")
922
+ parser.add_argument("--peer", action="append", default=[], help="Pair fédéré ORG=wss://host:port")
923
+ parser.add_argument("--tls-cert", type=str, default=None, help="Certificat TLS (sert en wss://)")
924
+ parser.add_argument("--tls-key", type=str, default=None, help="Clé privée du certificat TLS")
925
+ parser.add_argument("--peer-ca", type=str, default=None,
926
+ help="Autorité de certification des pairs (PKI privée)")
927
+ parser.add_argument("--allow-insecure-peering", action="store_true",
928
+ help="Autorise un pairage en clair vers un hôte distant (déconseillé)")
929
+ parser.add_argument("--egress-policy", type=str, default=None,
930
+ help="Politique de filtrage des contenus sortants (fichier JSON)")
931
+
932
+ # Tolérance : tout argument inconnu (utilisé par un autre point d'entrée) est ignoré.
933
+ args, _ = parser.parse_known_args(argv)
934
+
935
+ HUB_ORG = args.org
936
+ SNAPSHOT_DIR = args.snapshot_dir
937
+
938
+ if args.ephemeral_state:
939
+ store = InterMeshStore(ephemeral=True)
940
+ elif args.state_file:
941
+ store = InterMeshStore(path=args.state_file)
942
+ else:
943
+ store = InterMeshStore()
944
+
945
+ identity_registry.update(store.load_identities())
946
+ task_registry.update(store.load_tasks())
947
+
948
+ secret, secret_desc = resolve_hub_secret(secret_file=args.secret_file, ephemeral=args.ephemeral_secret)
949
+ HUB_SECRET = secret
950
+ HUB_PRIVATE_KEY = derive_signing_key(secret)
951
+ HUB_PUBLIC_PEM = public_pem(HUB_PRIVATE_KEY)
952
+ HUB_KEY_ID = key_fingerprint(HUB_PUBLIC_PEM)
953
+
954
+ api_keys = ApiKeyStore.load(dev_keys=args.dev_api_keys)
955
+
956
+ audit_log = ImmutableAuditLog(entries=store.load_audit(), on_append=store.append_audit)
957
+
958
+ print(f"InterMesh Hub — org={HUB_ORG} port={args.port}")
959
+ print(f" secret : {secret_desc}")
960
+ print(f" clé : Ed25519 kid={HUB_KEY_ID} (privée non exportée)")
961
+ print(f" état : {store.description}")
962
+ print(f" clés : {api_keys.source}")
963
+
964
+ if args.egress_policy:
965
+ egress_policy = EgressPolicy.load(args.egress_policy)
966
+ print(f" egress : {egress_policy.name} ({len(egress_policy.rules)} règle(s))")
967
+ else:
968
+ print(" egress : aucun filtre déclaré")
969
+
970
+ if bool(args.tls_cert) != bool(args.tls_key):
971
+ parser.error("--tls-cert et --tls-key vont de pair")
972
+ server_ssl = build_server_ssl_context(args.tls_cert, args.tls_key) if args.tls_cert else None
973
+ print(f" écoute : {'wss:// (TLS)' if server_ssl else 'ws:// (en clair)'}")
974
+
975
+ for spec in args.peer:
976
+ try:
977
+ remote_org, url = parse_peer_spec(spec)
978
+ assert_peer_link_is_secure(remote_org, url, args.allow_insecure_peering)
979
+ except ValueError as exc:
980
+ print(f"[peering] --peer ignoré : {exc}")
981
+ continue
982
+ asyncio.create_task(connect_peer(remote_org, url, args.peer_ca))
983
+ print(f" pair : {remote_org} -> {url}")
984
+
985
+ async with websockets.serve(
986
+ lambda ws: handle_agent(ws, args.org),
987
+ "0.0.0.0",
988
+ args.port,
989
+ process_request=process_request,
990
+ ssl=server_ssl,
991
+ ):
992
+ await asyncio.Future()
993
+
994
+
995
+ if __name__ == "__main__":
996
+ asyncio.run(main())