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/apikeys.py ADDED
@@ -0,0 +1,273 @@
1
+ """
2
+ Comptes de service : magasin de clés d'API du Hub.
3
+
4
+ Les clés étaient auparavant écrites en clair dans le code source du Hub,
5
+ donc publiées avec le dépôt. Une clé versionnée dans Git est une clé
6
+ compromise : elle reste lisible dans l'historique même après suppression.
7
+
8
+ Ce module les charge depuis une source externe et ne conserve jamais que
9
+ leur empreinte SHA-256. Le Hub ne peut donc pas révéler une clé, même si
10
+ sa configuration fuit — il peut seulement vérifier qu'une clé présentée
11
+ correspond à une empreinte connue.
12
+
13
+ Sources, par ordre de priorité :
14
+
15
+ 1. INTERMESH_API_KEYS — JSON inline (Kubernetes, CI)
16
+ 2. INTERMESH_API_KEYS_FILE — chemin d'un fichier JSON
17
+ 3. ~/.intermesh/api_keys.json
18
+ 4. aucune — les comptes de service sont simplement désactivés
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import secrets
27
+ import stat
28
+ import time
29
+ from pathlib import Path
30
+
31
+ ENV_INLINE = "INTERMESH_API_KEYS"
32
+ ENV_FILE = "INTERMESH_API_KEYS_FILE"
33
+
34
+ # Clés de démonstration, activées uniquement sur demande explicite
35
+ # (--dev-api-keys). Elles sont publiques par construction : ne jamais
36
+ # s'en servir ailleurs que pour une démo ou un test.
37
+ DEV_API_KEYS = {
38
+ "nx_dev_acme_demo_key": {
39
+ "org_id": "acme",
40
+ "roles": ["admin", "service_account"],
41
+ "permissions": ["admin:*"],
42
+ },
43
+ "nx_dev_globex_demo_key": {
44
+ "org_id": "globex",
45
+ "roles": ["worker", "service_account"],
46
+ "permissions": ["compute:execute"],
47
+ },
48
+ }
49
+
50
+
51
+ def default_keys_path() -> Path:
52
+ base = os.environ.get("INTERMESH_HOME")
53
+ return (Path(base) if base else Path.home() / ".intermesh") / "api_keys.json"
54
+
55
+
56
+ def hash_key(raw_key: str) -> str:
57
+ """Empreinte SHA-256 d'une clé d'API."""
58
+ return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
59
+
60
+
61
+ def generate_key(prefix: str = "nx_live") -> str:
62
+ """Génère une clé d'API cryptographiquement aléatoire."""
63
+ return f"{prefix}_{secrets.token_urlsafe(32)}"
64
+
65
+
66
+ class ApiKeyStore:
67
+ """Vérifie les clés d'API sans jamais conserver leur valeur en clair."""
68
+
69
+ def __init__(
70
+ self,
71
+ keys: dict[str, dict] | None = None,
72
+ source: str = "aucune",
73
+ path: Path | None = None,
74
+ ):
75
+ """
76
+ Args:
77
+ keys: Mapping clé_en_clair -> {org_id, roles, permissions}.
78
+ Seules les empreintes sont conservées.
79
+ source: Description de la provenance, pour la bannière de démarrage.
80
+ path: Fichier de sauvegarde, si les mutations sont autorisées.
81
+ """
82
+ self._by_hash: dict[str, dict] = {}
83
+ self.source = source
84
+ self.path = path
85
+
86
+ for raw_key, info in (keys or {}).items():
87
+ self._by_hash[hash_key(raw_key)] = self._normalize(info)
88
+
89
+ @staticmethod
90
+ def _normalize(info: dict, label: str | None = None) -> dict:
91
+ entry = {
92
+ "org_id": info["org_id"],
93
+ "roles": list(info.get("roles", [])),
94
+ "permissions": list(info.get("permissions", [])),
95
+ }
96
+ if label or info.get("label"):
97
+ entry["label"] = label or info["label"]
98
+ if info.get("created_at"):
99
+ entry["created_at"] = info["created_at"]
100
+ return entry
101
+
102
+ # ------------------------------------------------------------------
103
+ # Mutations (console d'administration)
104
+ # ------------------------------------------------------------------
105
+
106
+ @property
107
+ def mutable(self) -> bool:
108
+ """Les mutations exigent un fichier : une config injectée par
109
+ l'environnement appartient à l'orchestrateur, pas au Hub."""
110
+ return self.path is not None
111
+
112
+ def describe(self) -> list[dict]:
113
+ """
114
+ Inventaire des clés, sans jamais exposer leur valeur.
115
+
116
+ L'empreinte est tronquée à 12 caractères : assez pour identifier
117
+ une clé dans une interface, trop peu pour aider une attaque hors
118
+ ligne si la page est capturée.
119
+ """
120
+ return [
121
+ {
122
+ "fingerprint": h[:12],
123
+ "org_id": info["org_id"],
124
+ "roles": info["roles"],
125
+ "permissions": info["permissions"],
126
+ "label": info.get("label", ""),
127
+ "created_at": info.get("created_at"),
128
+ }
129
+ for h, info in sorted(self._by_hash.items())
130
+ ]
131
+
132
+ def _persist(self) -> None:
133
+ if not self.path:
134
+ raise PermissionError(
135
+ "Les clés proviennent de l'environnement et sont en lecture seule. "
136
+ f"Utilisez un fichier ({ENV_FILE} ou ~/.intermesh/api_keys.json) "
137
+ "pour autoriser les mutations."
138
+ )
139
+ # Le fichier stocke déjà des empreintes : la valeur en clair n'a
140
+ # jamais existé côté Hub après création.
141
+ payload = {"__hashed__": True, "keys": self._by_hash}
142
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
143
+ tmp = self.path.with_suffix(".tmp")
144
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
145
+ try:
146
+ os.write(fd, json.dumps(payload, indent=2).encode("utf-8"))
147
+ finally:
148
+ os.close(fd)
149
+ os.replace(tmp, self.path) # remplacement atomique
150
+
151
+ def create(self, org_id: str, roles: list[str], permissions: list[str],
152
+ label: str = "") -> str:
153
+ """
154
+ Crée une clé et retourne sa valeur en clair — la seule et unique
155
+ fois où elle existe. Seule l'empreinte est conservée ensuite.
156
+ """
157
+ raw = generate_key()
158
+ self._by_hash[hash_key(raw)] = self._normalize(
159
+ {
160
+ "org_id": org_id,
161
+ "roles": roles,
162
+ "permissions": permissions,
163
+ "created_at": time.time(),
164
+ },
165
+ label=label,
166
+ )
167
+ self._persist()
168
+ return raw
169
+
170
+ def revoke(self, fingerprint_prefix: str) -> bool:
171
+ """Révoque une clé désignée par le préfixe de son empreinte."""
172
+ matches = [h for h in self._by_hash if h.startswith(fingerprint_prefix)]
173
+ if len(matches) != 1:
174
+ return False
175
+ del self._by_hash[matches[0]]
176
+ self._persist()
177
+ return True
178
+
179
+ def __len__(self) -> int:
180
+ return len(self._by_hash)
181
+
182
+ # ------------------------------------------------------------------
183
+ # Instantanés
184
+ # ------------------------------------------------------------------
185
+
186
+ def export_hashed(self) -> dict[str, dict]:
187
+ """
188
+ Empreintes et privilèges, tels quels — la forme attendue par
189
+ `import_hashed`. Aucune clé en clair n'en sort, il n'y en a pas.
190
+ """
191
+ return {h: dict(info) for h, info in self._by_hash.items()}
192
+
193
+ def import_hashed(self, keys: dict[str, dict]) -> None:
194
+ """
195
+ Remplace intégralement le jeu de clés.
196
+
197
+ Raises:
198
+ PermissionError: si les clés viennent de l'environnement. Elles
199
+ appartiennent alors à l'orchestrateur (Kubernetes, CI), pas
200
+ au Hub : les écraser depuis un instantané ferait diverger la
201
+ mémoire du Hub de sa source de vérité.
202
+ """
203
+ if not self.mutable:
204
+ raise PermissionError(
205
+ "Les clés proviennent de l'environnement et sont en lecture seule : "
206
+ "elles ne peuvent pas être restaurées depuis un instantané."
207
+ )
208
+ self._by_hash = {h: self._normalize(info) for h, info in keys.items()}
209
+ self._persist()
210
+
211
+ def lookup(self, raw_key: str) -> dict | None:
212
+ """
213
+ Retourne les privilèges associés à une clé, ou None si inconnue.
214
+
215
+ La comparaison est à temps constant : comparer des empreintes avec
216
+ `==` laisse fuir, par le temps de réponse, le nombre d'octets
217
+ corrects en tête — de quoi reconstruire une empreinte valide
218
+ octet par octet.
219
+ """
220
+ if not raw_key:
221
+ return None
222
+
223
+ candidate = hash_key(raw_key)
224
+ matched = None
225
+ for known_hash, info in self._by_hash.items():
226
+ if secrets.compare_digest(candidate, known_hash):
227
+ matched = info
228
+ return matched
229
+
230
+ # ------------------------------------------------------------------
231
+
232
+ @classmethod
233
+ def load(cls, dev_keys: bool = False) -> "ApiKeyStore":
234
+ """Charge les clés depuis la première source disponible."""
235
+ inline = os.environ.get(ENV_INLINE)
236
+ if inline:
237
+ try:
238
+ return cls(json.loads(inline), source=f"variable {ENV_INLINE}")
239
+ except json.JSONDecodeError as exc:
240
+ raise ValueError(f"{ENV_INLINE} n'est pas du JSON valide : {exc}") from exc
241
+
242
+ explicit = os.environ.get(ENV_FILE)
243
+ path = Path(explicit) if explicit else default_keys_path()
244
+
245
+ if path.is_file():
246
+ mode = path.stat().st_mode
247
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
248
+ print(
249
+ f"\033[33m⚠️ [SÉCURITÉ] {path} est lisible par d'autres utilisateurs.\033[0m\n"
250
+ f" Corrigez avec : chmod 600 {path}"
251
+ )
252
+ try:
253
+ data = json.loads(path.read_text(encoding="utf-8"))
254
+ except json.JSONDecodeError as exc:
255
+ raise ValueError(f"{path} n'est pas du JSON valide : {exc}") from exc
256
+
257
+ if data.get("__hashed__"):
258
+ # Format produit par la console : déjà haché, rien à recalculer.
259
+ store = cls({}, source=f"fichier {path}", path=path)
260
+ store._by_hash = {
261
+ h: cls._normalize(info) for h, info in data.get("keys", {}).items()
262
+ }
263
+ return store
264
+
265
+ # Format historique : clés en clair, hachées au chargement.
266
+ return cls(data, source=f"fichier {path}", path=path)
267
+
268
+ if dev_keys:
269
+ return cls(DEV_API_KEYS, source="clés de DÉMONSTRATION (publiques)")
270
+
271
+ # Aucun fichier : le chemin par défaut devient la cible d'écriture,
272
+ # pour qu'une première clé créée depuis la console soit persistée.
273
+ return cls({}, source="aucune — comptes de service désactivés", path=path)
intermesh/audit.py ADDED
@@ -0,0 +1,153 @@
1
+ import hashlib
2
+ import json
3
+ import time
4
+ from typing import Optional, List, Dict, Any
5
+
6
+
7
+ class AuditEntry:
8
+ """
9
+ Une entrée immuable dans le journal d'audit InterMesh.
10
+ Chaque entrée est scellée par son empreinte SHA-256 incluant le hash de l'entrée précédente.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ index: int,
16
+ event_type: str,
17
+ sender: str,
18
+ target: Optional[str],
19
+ metadata: Dict[str, Any],
20
+ prev_hash: str,
21
+ timestamp: Optional[float] = None,
22
+ entry_hash: Optional[str] = None
23
+ ):
24
+ self.index = index
25
+ self.timestamp = timestamp or time.time()
26
+ self.event_type = event_type
27
+ self.sender = sender
28
+ self.target = target
29
+ self.metadata = metadata
30
+ self.prev_hash = prev_hash
31
+ self.hash = entry_hash or self.compute_hash()
32
+
33
+ def compute_hash(self) -> str:
34
+ """Calcule le hash SHA-256 de cette entrée lié à l'entrée précédente."""
35
+ payload = {
36
+ "index": self.index,
37
+ "timestamp": self.timestamp,
38
+ "event_type": self.event_type,
39
+ "sender": self.sender,
40
+ "target": self.target,
41
+ "metadata": self.metadata,
42
+ "prev_hash": self.prev_hash
43
+ }
44
+ raw = json.dumps(payload, sort_keys=True).encode("utf-8")
45
+ return hashlib.sha256(raw).hexdigest()
46
+
47
+ def to_dict(self) -> dict:
48
+ return {
49
+ "index": self.index,
50
+ "timestamp": self.timestamp,
51
+ "event_type": self.event_type,
52
+ "sender": self.sender,
53
+ "target": self.target,
54
+ "metadata": self.metadata,
55
+ "prev_hash": self.prev_hash,
56
+ "hash": self.hash
57
+ }
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: dict) -> "AuditEntry":
61
+ """
62
+ Reconstruit une entrée depuis sa forme sérialisée.
63
+
64
+ Le hash stocké est conservé tel quel, sans être recalculé : c'est
65
+ précisément l'écart entre le hash stocké et le hash recalculé qui
66
+ révèle une altération lors de verify_integrity().
67
+ """
68
+ return cls(
69
+ index=data["index"],
70
+ event_type=data["event_type"],
71
+ sender=data["sender"],
72
+ target=data.get("target"),
73
+ metadata=data.get("metadata", {}),
74
+ prev_hash=data["prev_hash"],
75
+ timestamp=data.get("timestamp"),
76
+ entry_hash=data.get("hash"),
77
+ )
78
+
79
+
80
+ class ImmutableAuditLog:
81
+ """
82
+ Registre d'audit chaîné (Merkle-Chain).
83
+ Permet d'ajouter des événements et de vérifier l'intégrité globale du journal.
84
+ """
85
+
86
+ def __init__(self, entries: Optional[List[dict]] = None, on_append=None):
87
+ """
88
+ Args:
89
+ entries: Chaîne existante à reprendre (forme sérialisée). Si elle
90
+ est vide ou absente, un bloc genesis est créé.
91
+ on_append: Callback appelé avec chaque nouvelle AuditEntry, pour
92
+ l'écrire dans un stockage durable.
93
+ """
94
+ self.chain: List[AuditEntry] = []
95
+ self._on_append = on_append
96
+
97
+ if entries:
98
+ self.chain = [AuditEntry.from_dict(e) for e in entries]
99
+ else:
100
+ self._create_genesis_block()
101
+
102
+ def _create_genesis_block(self):
103
+ """Crée le bloc racine (Genesis) de la chaîne d'audit."""
104
+ genesis = AuditEntry(
105
+ index=0,
106
+ event_type="GENESIS",
107
+ sender="intermesh_system",
108
+ target=None,
109
+ metadata={"system": "InterMesh Protocol Immutable Audit Log Initialized"},
110
+ prev_hash="0" * 64,
111
+ timestamp=time.time()
112
+ )
113
+ self.chain.append(genesis)
114
+ if self._on_append:
115
+ self._on_append(genesis)
116
+
117
+ def log(self, event_type: str, sender: str, target: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> AuditEntry:
118
+ """Ajoute un nouvel événement au journal et le scelle cryptographiquement."""
119
+ prev_entry = self.chain[-1]
120
+ new_entry = AuditEntry(
121
+ index=len(self.chain),
122
+ event_type=event_type,
123
+ sender=sender,
124
+ target=target,
125
+ metadata=metadata or {},
126
+ prev_hash=prev_entry.hash
127
+ )
128
+ self.chain.append(new_entry)
129
+ if self._on_append:
130
+ self._on_append(new_entry)
131
+ return new_entry
132
+
133
+ def verify_integrity(self) -> bool:
134
+ """
135
+ Vérifie l'intégrité mathématique complète de toute la chaîne d'audit.
136
+ Retourne False si une quelconque entrée a été modifiée ou supprimée.
137
+ """
138
+ for i in range(1, len(self.chain)):
139
+ curr = self.chain[i]
140
+ prev = self.chain[i - 1]
141
+
142
+ # 1. Vérifier la liaison avec le bloc précédent
143
+ if curr.prev_hash != prev.hash:
144
+ return False
145
+
146
+ # 2. Vérifier que le hash correspond au contenu actuel
147
+ if curr.hash != curr.compute_hash():
148
+ return False
149
+
150
+ return True
151
+
152
+ def export_json(self) -> str:
153
+ return json.dumps([e.to_dict() for e in self.chain], indent=2)
intermesh/bridge.py ADDED
@@ -0,0 +1,181 @@
1
+ """
2
+ Pont universel : brancher un agent écrit dans n'importe quel langage.
3
+
4
+ Écrire un SDK natif par langage ne passe pas à l'échelle — il y en aurait
5
+ toujours un qui manque. Ce module prend le problème par l'autre bout : le
6
+ protocole reste du JSON, et l'agent étranger n'a qu'à savoir lire du JSON
7
+ sur son entrée standard, ou répondre à une requête HTTP. Go, Rust, Java,
8
+ PHP, C, un script shell : tout ce qui sait faire ça devient un agent
9
+ InterMesh sans une ligne de code d'intégration.
10
+
11
+ Deux modes, selon ce que le programme existant sait déjà faire :
12
+
13
+ * `exec` — un processus est lancé par tâche. Le JSON d'entrée arrive sur
14
+ stdin, la réponse est lue sur stdout. C'est le mode des
15
+ binaires, des scripts et des CLI.
16
+ * `http` — la tâche est POSTée en JSON sur une URL. C'est le mode des
17
+ services déjà en ligne, qu'on ne veut pas relancer.
18
+
19
+ Tolérance assumée en mode `exec` : si stdout n'est pas du JSON valide, le
20
+ texte brut est renvoyé sous `{"output": ...}`. Un `echo` en shell doit
21
+ suffire à faire un agent ; exiger du JSON parfait tuerait le cas d'usage.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import json
29
+ import os
30
+ import shlex
31
+ import signal
32
+ import urllib.error
33
+ import urllib.request
34
+ from typing import Any, Optional, Sequence
35
+
36
+ DEFAULT_TIMEOUT = 30.0
37
+
38
+
39
+ class BridgeError(RuntimeError):
40
+ """L'agent externe a échoué : code de sortie non nul, timeout, ou HTTP en erreur."""
41
+
42
+
43
+ def _decode_output(raw: str) -> Any:
44
+ text = raw.strip()
45
+ if not text:
46
+ return {}
47
+ try:
48
+ return json.loads(text)
49
+ except json.JSONDecodeError:
50
+ return {"output": text}
51
+
52
+
53
+ def _kill_process_group(process) -> None:
54
+ """Tue le programme et toute sa descendance.
55
+
56
+ Un agent qui dépasse son délai ne doit pas laisser d'orphelins derrière
57
+ lui : ils continueraient de consommer des ressources et de tenir les
58
+ tuyaux d'un appel qu'on a déjà abandonné.
59
+ """
60
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
61
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
62
+ return
63
+ with contextlib.suppress(ProcessLookupError):
64
+ process.kill()
65
+
66
+
67
+ async def run_command(command: str | Sequence[str], payload: Any,
68
+ timeout: float = DEFAULT_TIMEOUT) -> Any:
69
+ """Exécute `command`, lui passe `payload` en JSON sur stdin, lit sa réponse.
70
+
71
+ `command` accepte une chaîne (interprétée par le shell, pratique pour
72
+ les pipelines) ou une liste d'arguments (sans shell, à préférer quand
73
+ la commande vient d'une source non maîtrisée).
74
+ """
75
+ stdin_data = json.dumps(payload).encode("utf-8")
76
+
77
+ # start_new_session place le programme dans son propre groupe de processus.
78
+ # Sans ça, un timeout ne tue que le shell : ses enfants survivent, gardent
79
+ # les tuyaux ouverts, et l'appel reste bloqué jusqu'à leur propre fin — le
80
+ # délai maximal ne protégeait alors de rien.
81
+ spawn = dict(
82
+ stdin=asyncio.subprocess.PIPE,
83
+ stdout=asyncio.subprocess.PIPE,
84
+ stderr=asyncio.subprocess.PIPE,
85
+ start_new_session=True,
86
+ )
87
+ if isinstance(command, str):
88
+ process = await asyncio.create_subprocess_shell(command, **spawn)
89
+ shown = command
90
+ else:
91
+ process = await asyncio.create_subprocess_exec(*command, **spawn)
92
+ shown = shlex.join(command)
93
+
94
+ try:
95
+ stdout, stderr = await asyncio.wait_for(
96
+ process.communicate(input=stdin_data), timeout=timeout)
97
+ except asyncio.TimeoutError:
98
+ _kill_process_group(process)
99
+ with contextlib.suppress(Exception):
100
+ await asyncio.wait_for(process.wait(), timeout=5.0)
101
+ raise BridgeError(f"'{shown}' n'a pas répondu en {timeout}s.")
102
+
103
+ if process.returncode != 0:
104
+ detail = stderr.decode("utf-8", "replace").strip() or "(stderr vide)"
105
+ raise BridgeError(f"'{shown}' a terminé avec le code {process.returncode} : {detail}")
106
+
107
+ return _decode_output(stdout.decode("utf-8", "replace"))
108
+
109
+
110
+ def _post_json(url: str, payload: Any, timeout: float) -> Any:
111
+ request = urllib.request.Request(
112
+ url, data=json.dumps(payload).encode("utf-8"),
113
+ headers={"Content-Type": "application/json"}, method="POST",
114
+ )
115
+ with urllib.request.urlopen(request, timeout=timeout) as response:
116
+ return _decode_output(response.read().decode("utf-8", "replace"))
117
+
118
+
119
+ async def post_task(url: str, payload: Any, timeout: float = DEFAULT_TIMEOUT) -> Any:
120
+ """POSTe `payload` en JSON sur `url` et retourne la réponse décodée.
121
+
122
+ urllib plutôt qu'un client HTTP tiers : le pont ne doit pas imposer une
123
+ dépendance de plus à qui veut juste brancher un service existant.
124
+ """
125
+ loop = asyncio.get_running_loop()
126
+ try:
127
+ return await loop.run_in_executor(None, _post_json, url, payload, timeout)
128
+ except urllib.error.HTTPError as exc:
129
+ raise BridgeError(f"{url} a répondu {exc.code} : {exc.reason}") from exc
130
+ except urllib.error.URLError as exc:
131
+ raise BridgeError(f"{url} injoignable : {exc.reason}") from exc
132
+
133
+
134
+ def exec_handler(command: str | Sequence[str], timeout: float = DEFAULT_TIMEOUT):
135
+ """Handler de tâche déléguant à un programme externe."""
136
+ async def handler(input_data: Any, task: Any):
137
+ return await run_command(command, input_data, timeout)
138
+ return handler
139
+
140
+
141
+ def http_handler(url: str, timeout: float = DEFAULT_TIMEOUT):
142
+ """Handler de tâche déléguant à un service HTTP."""
143
+ async def handler(input_data: Any, task: Any):
144
+ return await post_task(url, input_data, timeout)
145
+ return handler
146
+
147
+
148
+ def _build_agent(name: str, handler, capabilities, roles, org_id, hub_url,
149
+ encrypt, **kwargs):
150
+ from intermesh.agent import InterMeshAgent
151
+
152
+ agent = InterMeshAgent(
153
+ name=name, org_id=org_id, capabilities=capabilities or ["compute"],
154
+ roles=roles or ["worker"], hub_url=hub_url, encrypt=encrypt, **kwargs,
155
+ )
156
+ agent.on_task(handler)
157
+ return agent
158
+
159
+
160
+ def from_command(command: str | Sequence[str], name: str,
161
+ capabilities: Optional[list] = None, roles: Optional[list] = None,
162
+ org_id: str = "default", hub_url: str = "ws://localhost:8765",
163
+ encrypt: bool = True, timeout: float = DEFAULT_TIMEOUT, **kwargs):
164
+ """Transforme n'importe quel exécutable en agent InterMesh.
165
+
166
+ agent = from_command("./mon-agent-en-go", name="pricing")
167
+ """
168
+ return _build_agent(name, exec_handler(command, timeout), capabilities, roles,
169
+ org_id, hub_url, encrypt, **kwargs)
170
+
171
+
172
+ def from_http(url: str, name: str,
173
+ capabilities: Optional[list] = None, roles: Optional[list] = None,
174
+ org_id: str = "default", hub_url: str = "ws://localhost:8765",
175
+ encrypt: bool = True, timeout: float = DEFAULT_TIMEOUT, **kwargs):
176
+ """Transforme n'importe quel service HTTP en agent InterMesh.
177
+
178
+ agent = from_http("http://localhost:9000/task", name="scoring")
179
+ """
180
+ return _build_agent(name, http_handler(url, timeout), capabilities, roles,
181
+ org_id, hub_url, encrypt, **kwargs)