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/__init__.py +56 -0
- intermesh/adapters.py +90 -0
- intermesh/admin.py +733 -0
- intermesh/agent.py +520 -0
- intermesh/apikeys.py +273 -0
- intermesh/audit.py +153 -0
- intermesh/bridge.py +181 -0
- intermesh/cli.py +453 -0
- intermesh/config.py +145 -0
- intermesh/crypto.py +150 -0
- intermesh/egress.py +180 -0
- intermesh/escrow.py +208 -0
- intermesh/guardrails.py +235 -0
- intermesh/hardware.py +32 -0
- intermesh/health.py +56 -0
- intermesh/hub.py +996 -0
- intermesh/identity.py +94 -0
- intermesh/logger.py +90 -0
- intermesh/message.py +152 -0
- intermesh/metrics.py +49 -0
- intermesh/peering.py +103 -0
- intermesh/pipeline.py +218 -0
- intermesh/policy.py +35 -0
- intermesh/ratelimit.py +42 -0
- intermesh/schema.py +125 -0
- intermesh/secret.py +115 -0
- intermesh/signing.py +64 -0
- intermesh/snapshot.py +342 -0
- intermesh/store.py +177 -0
- intermesh/task.py +122 -0
- intermesh-0.3.0.dist-info/METADATA +198 -0
- intermesh-0.3.0.dist-info/RECORD +35 -0
- intermesh-0.3.0.dist-info/WHEEL +5 -0
- intermesh-0.3.0.dist-info/entry_points.txt +2 -0
- intermesh-0.3.0.dist-info/top_level.txt +1 -0
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())
|