inter-agent-core 0.2.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.
- inter_agent/__init__.py +0 -0
- inter_agent/core/__init__.py +1 -0
- inter_agent/core/adapter_control.py +350 -0
- inter_agent/core/auth.py +279 -0
- inter_agent/core/channels.py +131 -0
- inter_agent/core/client.py +345 -0
- inter_agent/core/config.py +325 -0
- inter_agent/core/errors.py +31 -0
- inter_agent/core/kick.py +111 -0
- inter_agent/core/list.py +127 -0
- inter_agent/core/publish.py +146 -0
- inter_agent/core/router.py +8 -0
- inter_agent/core/send.py +265 -0
- inter_agent/core/server.py +765 -0
- inter_agent/core/shared.py +182 -0
- inter_agent/core/shutdown.py +92 -0
- inter_agent/core/status.py +291 -0
- inter_agent/core/tls.py +141 -0
- inter_agent/core/transport.py +37 -0
- inter_agent/py.typed +0 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/asyncapi.yaml +225 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/error-codes.md +28 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_challenge.json +6 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_response.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/broadcast.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/bye.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels_ok.json +9 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/custom.unknown-pass-through.json +9 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/error.auth-failed.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent-with-label.json +14 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick_ok.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list_ok.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.custom.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.text.json +9 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/ping.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/pong.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/publish.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/send.direct.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown_ok.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe_ok.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe_ok.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/welcome.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_challenge.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_response.json +11 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/broadcast.json +18 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/bye.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels_ok.json +26 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/custom.json +22 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/error.json +37 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/hello.json +59 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick_ok.json +12 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list_ok.json +24 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/msg.json +29 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/ping.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/pong.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/publish.json +23 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/send.json +22 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown_ok.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe.json +15 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe_ok.json +11 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe.json +15 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe_ok.json +11 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/welcome.json +36 -0
- inter_agent_core-0.2.0.dist-info/METADATA +85 -0
- inter_agent_core-0.2.0.dist-info/RECORD +80 -0
- inter_agent_core-0.2.0.dist-info/WHEEL +5 -0
- inter_agent_core-0.2.0.dist-info/entry_points.txt +10 -0
- inter_agent_core-0.2.0.dist-info/licenses/LICENSE.md +21 -0
- inter_agent_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import http
|
|
6
|
+
import json
|
|
7
|
+
import signal
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import websockets
|
|
13
|
+
from websockets.asyncio.server import ServerConnection
|
|
14
|
+
from websockets.http11 import Request, Response
|
|
15
|
+
|
|
16
|
+
from inter_agent.core.auth import build_auth_challenge, client_nonce_from_hello, verify_client_proof
|
|
17
|
+
from inter_agent.core.errors import ErrorCode
|
|
18
|
+
from inter_agent.core.router import RouterMiddleware
|
|
19
|
+
from inter_agent.core.shared import (
|
|
20
|
+
Limits,
|
|
21
|
+
next_msg_id,
|
|
22
|
+
resolve_endpoint,
|
|
23
|
+
resolve_shared_secret,
|
|
24
|
+
utc_now,
|
|
25
|
+
validate_channel_name,
|
|
26
|
+
validate_name,
|
|
27
|
+
)
|
|
28
|
+
from inter_agent.core.tls import TlsConfigError, build_server_ssl_context
|
|
29
|
+
|
|
30
|
+
DEFAULT_IDLE_TIMEOUT_S: float | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Conn:
|
|
35
|
+
ws: ServerConnection
|
|
36
|
+
session_id: str
|
|
37
|
+
name: str
|
|
38
|
+
role: str
|
|
39
|
+
label: str | None
|
|
40
|
+
capabilities: dict[str, object]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class TargetResolution:
|
|
45
|
+
target: Conn | None
|
|
46
|
+
error_code: ErrorCode | None = None
|
|
47
|
+
message: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BusServer:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
host: str,
|
|
54
|
+
port: int,
|
|
55
|
+
limits: Limits | None = None,
|
|
56
|
+
idle_timeout_s: float | None = DEFAULT_IDLE_TIMEOUT_S,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.host = host
|
|
59
|
+
self.port = port
|
|
60
|
+
self.secret = resolve_shared_secret().secret
|
|
61
|
+
self.limits = limits or Limits()
|
|
62
|
+
self.registry: dict[str, Conn] = {}
|
|
63
|
+
self.channels: dict[str, set[str]] = {}
|
|
64
|
+
self.subscriptions: dict[str, set[str]] = {}
|
|
65
|
+
self.middlewares: list[RouterMiddleware] = []
|
|
66
|
+
self.shutdown_event = asyncio.Event()
|
|
67
|
+
self._lock = asyncio.Lock()
|
|
68
|
+
self._idle_timeout = idle_timeout_s
|
|
69
|
+
self._idle_timer: asyncio.Task[None] | None = None
|
|
70
|
+
|
|
71
|
+
def _cancel_idle_timer(self) -> None:
|
|
72
|
+
if self._idle_timer is not None:
|
|
73
|
+
self._idle_timer.cancel()
|
|
74
|
+
self._idle_timer = None
|
|
75
|
+
|
|
76
|
+
def _schedule_idle_timer(self) -> None:
|
|
77
|
+
if self._idle_timeout is None or self._idle_timeout <= 0:
|
|
78
|
+
return
|
|
79
|
+
self._cancel_idle_timer()
|
|
80
|
+
self._idle_timer = asyncio.create_task(self._start_idle_timer())
|
|
81
|
+
|
|
82
|
+
async def _start_idle_timer(self) -> None:
|
|
83
|
+
"""Shut down after idle_timeout seconds with no connections when configured."""
|
|
84
|
+
timeout = self._idle_timeout
|
|
85
|
+
if timeout is None or timeout <= 0:
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
# Use a CancelledError check below rather than watching shutdown_event,
|
|
89
|
+
# so we don't race with an external shutdown_set + new connection.
|
|
90
|
+
await asyncio.sleep(timeout)
|
|
91
|
+
except asyncio.CancelledError:
|
|
92
|
+
return
|
|
93
|
+
# If we weren't cancelled, nobody reconnected — time to stop.
|
|
94
|
+
self.shutdown_event.set()
|
|
95
|
+
|
|
96
|
+
async def send_error(self, ws: ServerConnection, code: ErrorCode, message: str) -> None:
|
|
97
|
+
await ws.send(json.dumps({"op": "error", "code": code.value, "message": message}))
|
|
98
|
+
|
|
99
|
+
async def read_object(
|
|
100
|
+
self, ws: ServerConnection, raw: str | bytes, frame_name: str
|
|
101
|
+
) -> dict[str, object] | None:
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(raw)
|
|
104
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
105
|
+
await self.send_error(ws, ErrorCode.PROTOCOL_ERROR, f"{frame_name} must be valid JSON")
|
|
106
|
+
return None
|
|
107
|
+
if not isinstance(payload, dict):
|
|
108
|
+
await self.send_error(ws, ErrorCode.PROTOCOL_ERROR, f"{frame_name} must be an object")
|
|
109
|
+
return None
|
|
110
|
+
return {str(key): value for key, value in payload.items()}
|
|
111
|
+
|
|
112
|
+
async def handle(self, ws: ServerConnection) -> None:
|
|
113
|
+
session_id = None
|
|
114
|
+
self._cancel_idle_timer()
|
|
115
|
+
try:
|
|
116
|
+
raw = await ws.recv()
|
|
117
|
+
hello = await self.read_object(ws, raw, "first frame")
|
|
118
|
+
if hello is None:
|
|
119
|
+
return
|
|
120
|
+
if hello.get("op") != "hello":
|
|
121
|
+
await self.send_error(ws, ErrorCode.PROTOCOL_ERROR, "first op must be hello")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
client_nonce = client_nonce_from_hello(hello)
|
|
125
|
+
if client_nonce is None:
|
|
126
|
+
await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth")
|
|
127
|
+
return
|
|
128
|
+
challenge = build_auth_challenge(
|
|
129
|
+
self.secret,
|
|
130
|
+
client_nonce=client_nonce,
|
|
131
|
+
hello=hello,
|
|
132
|
+
)
|
|
133
|
+
await ws.send(json.dumps(challenge))
|
|
134
|
+
response_raw = await ws.recv()
|
|
135
|
+
response = await self.read_object(ws, response_raw, "auth_response")
|
|
136
|
+
if response is None:
|
|
137
|
+
return
|
|
138
|
+
if response.get("op") != "auth_response":
|
|
139
|
+
await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth response")
|
|
140
|
+
return
|
|
141
|
+
client_proof_value = response.get("client_proof")
|
|
142
|
+
server_nonce_value = challenge.get("server_nonce")
|
|
143
|
+
if not isinstance(client_proof_value, str) or not isinstance(server_nonce_value, str):
|
|
144
|
+
await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth response")
|
|
145
|
+
return
|
|
146
|
+
if not verify_client_proof(
|
|
147
|
+
client_proof_value,
|
|
148
|
+
self.secret,
|
|
149
|
+
client_nonce=client_nonce,
|
|
150
|
+
server_nonce=server_nonce_value,
|
|
151
|
+
hello=hello,
|
|
152
|
+
):
|
|
153
|
+
await self.send_error(ws, ErrorCode.AUTH_FAILED, "invalid auth")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
role_value = hello.get("role")
|
|
157
|
+
session_id_value = hello.get("session_id")
|
|
158
|
+
name_value = hello.get("name")
|
|
159
|
+
label_value = hello.get("label")
|
|
160
|
+
if label_value is not None and not isinstance(label_value, str):
|
|
161
|
+
await self.send_error(ws, ErrorCode.BAD_LABEL, "label must be a string or null")
|
|
162
|
+
return
|
|
163
|
+
if not isinstance(role_value, str) or role_value not in {"agent", "control"}:
|
|
164
|
+
await self.send_error(ws, ErrorCode.BAD_ROLE, "role must be agent or control")
|
|
165
|
+
return
|
|
166
|
+
if not session_id_value or not isinstance(session_id_value, str):
|
|
167
|
+
await self.send_error(ws, ErrorCode.BAD_SESSION, "missing session_id")
|
|
168
|
+
return
|
|
169
|
+
raw_capabilities = hello.get("capabilities")
|
|
170
|
+
if not isinstance(raw_capabilities, dict):
|
|
171
|
+
await self.send_error(
|
|
172
|
+
ws, ErrorCode.PROTOCOL_ERROR, "capabilities must be an object"
|
|
173
|
+
)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
role = role_value
|
|
177
|
+
async with self._lock:
|
|
178
|
+
if session_id_value in self.registry:
|
|
179
|
+
await self.send_error(ws, ErrorCode.SESSION_TAKEN, "session_id already active")
|
|
180
|
+
return
|
|
181
|
+
if len(self.registry) >= self.limits.connection_max:
|
|
182
|
+
await self.send_error(
|
|
183
|
+
ws, ErrorCode.TOO_MANY_CONNECTIONS, "connection limit reached"
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
session_id = session_id_value
|
|
187
|
+
label = label_value
|
|
188
|
+
if role == "agent":
|
|
189
|
+
if not validate_name(name_value):
|
|
190
|
+
await self.send_error(ws, ErrorCode.BAD_NAME, "invalid name")
|
|
191
|
+
return
|
|
192
|
+
assert isinstance(name_value, str)
|
|
193
|
+
assigned_name = name_value
|
|
194
|
+
if any(c.name == assigned_name for c in self.registry.values()):
|
|
195
|
+
await self.send_error(ws, ErrorCode.NAME_TAKEN, "name already in use")
|
|
196
|
+
return
|
|
197
|
+
elif isinstance(name_value, str) and name_value:
|
|
198
|
+
assigned_name = name_value
|
|
199
|
+
else:
|
|
200
|
+
assigned_name = f"control-{session_id[:6]}"
|
|
201
|
+
|
|
202
|
+
capabilities = {str(key): value for key, value in raw_capabilities.items()}
|
|
203
|
+
conn = Conn(
|
|
204
|
+
ws=ws,
|
|
205
|
+
session_id=session_id,
|
|
206
|
+
name=assigned_name,
|
|
207
|
+
role=role,
|
|
208
|
+
label=label,
|
|
209
|
+
capabilities=capabilities,
|
|
210
|
+
)
|
|
211
|
+
self.registry[session_id] = conn
|
|
212
|
+
await ws.send(
|
|
213
|
+
json.dumps(
|
|
214
|
+
{
|
|
215
|
+
"op": "welcome",
|
|
216
|
+
"session_id": session_id,
|
|
217
|
+
"assigned_name": conn.name,
|
|
218
|
+
"capabilities": {
|
|
219
|
+
"core": {"version": "0.1"},
|
|
220
|
+
"channels": True,
|
|
221
|
+
"rate_limit": False,
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async for frame in ws:
|
|
228
|
+
msg = await self.read_object(ws, frame, "frame")
|
|
229
|
+
if msg is None:
|
|
230
|
+
return
|
|
231
|
+
op = msg.get("op")
|
|
232
|
+
if op == "ping":
|
|
233
|
+
await ws.send(json.dumps({"op": "pong"}))
|
|
234
|
+
elif op == "bye":
|
|
235
|
+
return
|
|
236
|
+
elif op == "shutdown":
|
|
237
|
+
await self._handle_shutdown(conn)
|
|
238
|
+
elif op == "kick":
|
|
239
|
+
await self._handle_kick(conn, msg)
|
|
240
|
+
elif op == "list":
|
|
241
|
+
sessions = [
|
|
242
|
+
{
|
|
243
|
+
"session_id": c.session_id,
|
|
244
|
+
"name": c.name,
|
|
245
|
+
"label": c.label,
|
|
246
|
+
}
|
|
247
|
+
for c in sorted(self.registry.values(), key=lambda conn: conn.name)
|
|
248
|
+
if c.role == "agent"
|
|
249
|
+
]
|
|
250
|
+
await ws.send(json.dumps({"op": "list_ok", "sessions": sessions}))
|
|
251
|
+
elif op == "send":
|
|
252
|
+
await self._route_send(conn, msg)
|
|
253
|
+
elif op == "broadcast":
|
|
254
|
+
await self._route_broadcast(conn, msg)
|
|
255
|
+
elif op == "custom":
|
|
256
|
+
await self._route_custom(conn, msg)
|
|
257
|
+
elif op == "subscribe":
|
|
258
|
+
await self._route_subscribe(conn, msg)
|
|
259
|
+
elif op == "unsubscribe":
|
|
260
|
+
await self._route_unsubscribe(conn, msg)
|
|
261
|
+
elif op == "publish":
|
|
262
|
+
await self._route_publish(conn, msg)
|
|
263
|
+
elif op == "channels":
|
|
264
|
+
await self._route_channels(conn, msg)
|
|
265
|
+
else:
|
|
266
|
+
await self.send_error(ws, ErrorCode.UNKNOWN_OP, f"unsupported op: {op}")
|
|
267
|
+
except websockets.ConnectionClosed:
|
|
268
|
+
pass
|
|
269
|
+
finally:
|
|
270
|
+
if session_id:
|
|
271
|
+
await self._cleanup_session_subscriptions(session_id)
|
|
272
|
+
if session_id in self.registry:
|
|
273
|
+
self.registry.pop(session_id, None)
|
|
274
|
+
if not self.registry:
|
|
275
|
+
self._schedule_idle_timer()
|
|
276
|
+
|
|
277
|
+
async def _apply_middlewares(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
278
|
+
for middleware in self.middlewares:
|
|
279
|
+
await middleware.before_route(sender.session_id, msg)
|
|
280
|
+
|
|
281
|
+
async def _handle_shutdown(self, sender: Conn) -> None:
|
|
282
|
+
if sender.role != "control":
|
|
283
|
+
await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "shutdown requires control role")
|
|
284
|
+
return
|
|
285
|
+
await sender.ws.send(json.dumps({"op": "shutdown_ok"}))
|
|
286
|
+
self.shutdown_event.set()
|
|
287
|
+
|
|
288
|
+
async def _handle_kick(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
289
|
+
"""Force-disconnect a registered session by name or session_id.
|
|
290
|
+
|
|
291
|
+
Control-only. Used to clear ghost or unwanted sessions without
|
|
292
|
+
restarting the whole server. Not exposed through host extension tools.
|
|
293
|
+
"""
|
|
294
|
+
if sender.role != "control":
|
|
295
|
+
await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "kick requires control role")
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
target_name = msg.get("name")
|
|
299
|
+
target_session_id = msg.get("session_id")
|
|
300
|
+
target: Conn | None = None
|
|
301
|
+
async with self._lock:
|
|
302
|
+
if isinstance(target_session_id, str) and target_session_id:
|
|
303
|
+
target = self.registry.get(target_session_id)
|
|
304
|
+
elif isinstance(target_name, str) and target_name:
|
|
305
|
+
target = next(
|
|
306
|
+
(c for c in self.registry.values() if c.name == target_name),
|
|
307
|
+
None,
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
await self.send_error(
|
|
311
|
+
sender.ws, ErrorCode.PROTOCOL_ERROR, "kick requires name or session_id"
|
|
312
|
+
)
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
if target is None:
|
|
316
|
+
await self.send_error(sender.ws, ErrorCode.UNKNOWN_TARGET, "unknown target")
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
# Kick targets only registered agent-role sessions. A control-role
|
|
320
|
+
# connection is rejected without being closed.
|
|
321
|
+
if target.role != "agent":
|
|
322
|
+
await self.send_error(
|
|
323
|
+
sender.ws, ErrorCode.BAD_ROLE, "kick targets agent sessions only"
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Remove before closing so the target's own handler finally block
|
|
328
|
+
# sees the session already gone and does not double-remove. Late
|
|
329
|
+
# cleanup by session_id cannot clobber a newer same-name connection
|
|
330
|
+
# claimed under a different session_id.
|
|
331
|
+
self.registry.pop(target.session_id, None)
|
|
332
|
+
for channel in list(self.subscriptions.pop(target.session_id, [])):
|
|
333
|
+
self.channels[channel].discard(target.session_id)
|
|
334
|
+
if not self.channels[channel]:
|
|
335
|
+
self.channels.pop(channel, None)
|
|
336
|
+
await sender.ws.send(
|
|
337
|
+
json.dumps(
|
|
338
|
+
{
|
|
339
|
+
"op": "kick_ok",
|
|
340
|
+
"name": target.name,
|
|
341
|
+
"session_id": target.session_id,
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Signal the target that it was removed, then close. Both are best
|
|
347
|
+
# effort: a target that raced closed first must not unwind the control
|
|
348
|
+
# request. The KICKED message carries no controller identity, secret, or
|
|
349
|
+
# private session metadata.
|
|
350
|
+
try:
|
|
351
|
+
await self.send_error(target.ws, ErrorCode.KICKED, "removed by kick")
|
|
352
|
+
except websockets.ConnectionClosed:
|
|
353
|
+
pass
|
|
354
|
+
try:
|
|
355
|
+
await target.ws.close(code=1000, reason="kicked")
|
|
356
|
+
except websockets.ConnectionClosed:
|
|
357
|
+
pass
|
|
358
|
+
if not self.registry:
|
|
359
|
+
self._schedule_idle_timer()
|
|
360
|
+
|
|
361
|
+
async def close_connections(self) -> None:
|
|
362
|
+
for conn in list(self.registry.values()):
|
|
363
|
+
await conn.ws.close(code=1001, reason="server shutdown")
|
|
364
|
+
|
|
365
|
+
def _resolve_target(self, target_name: object) -> TargetResolution:
|
|
366
|
+
if not isinstance(target_name, str) or not target_name:
|
|
367
|
+
return TargetResolution(
|
|
368
|
+
target=None,
|
|
369
|
+
error_code=ErrorCode.UNKNOWN_TARGET,
|
|
370
|
+
message=f"unknown target: {target_name}",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
exact = next((c for c in self.registry.values() if c.name == target_name), None)
|
|
374
|
+
if exact is not None:
|
|
375
|
+
return TargetResolution(target=exact)
|
|
376
|
+
|
|
377
|
+
prefix_matches = [c for c in self.registry.values() if c.name.startswith(target_name)]
|
|
378
|
+
if len(prefix_matches) == 1:
|
|
379
|
+
return TargetResolution(target=prefix_matches[0])
|
|
380
|
+
if len(prefix_matches) > 1:
|
|
381
|
+
return TargetResolution(
|
|
382
|
+
target=None,
|
|
383
|
+
error_code=ErrorCode.AMBIGUOUS_TARGET,
|
|
384
|
+
message=f"ambiguous target: {target_name}",
|
|
385
|
+
)
|
|
386
|
+
return TargetResolution(
|
|
387
|
+
target=None,
|
|
388
|
+
error_code=ErrorCode.UNKNOWN_TARGET,
|
|
389
|
+
message=f"unknown target: {target_name}",
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
async def _send_resolution_error(self, sender: Conn, resolution: TargetResolution) -> None:
|
|
393
|
+
if resolution.error_code is None or resolution.message is None:
|
|
394
|
+
raise RuntimeError("target resolution did not include an error")
|
|
395
|
+
await self.send_error(sender.ws, resolution.error_code, resolution.message)
|
|
396
|
+
|
|
397
|
+
async def _route_send(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
398
|
+
await self._apply_middlewares(sender, msg)
|
|
399
|
+
to = msg.get("to")
|
|
400
|
+
text = msg.get("text")
|
|
401
|
+
if not isinstance(text, str):
|
|
402
|
+
await self.send_error(sender.ws, ErrorCode.BAD_TEXT, "text must be a string")
|
|
403
|
+
return
|
|
404
|
+
if len(text.encode()) > self.limits.direct_text_max:
|
|
405
|
+
await self.send_error(sender.ws, ErrorCode.TEXT_TOO_LARGE, "direct message too large")
|
|
406
|
+
return
|
|
407
|
+
resolution = self._resolve_target(to)
|
|
408
|
+
if resolution.target is None:
|
|
409
|
+
await self._send_resolution_error(sender, resolution)
|
|
410
|
+
return
|
|
411
|
+
target = resolution.target
|
|
412
|
+
from_name = msg.get("from_name", sender.name)
|
|
413
|
+
if not isinstance(from_name, str):
|
|
414
|
+
await self.send_error(sender.ws, ErrorCode.BAD_FROM_NAME, "from_name must be a string")
|
|
415
|
+
return
|
|
416
|
+
await target.ws.send(
|
|
417
|
+
json.dumps(
|
|
418
|
+
{
|
|
419
|
+
"op": "msg",
|
|
420
|
+
"msg_id": next_msg_id(),
|
|
421
|
+
"from": sender.session_id,
|
|
422
|
+
"from_name": from_name,
|
|
423
|
+
"to": target.name,
|
|
424
|
+
"text": text,
|
|
425
|
+
"ts": utc_now(),
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
async def _route_broadcast(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
431
|
+
await self._apply_middlewares(sender, msg)
|
|
432
|
+
text = msg.get("text")
|
|
433
|
+
if not isinstance(text, str):
|
|
434
|
+
await self.send_error(sender.ws, ErrorCode.BAD_TEXT, "text must be a string")
|
|
435
|
+
return
|
|
436
|
+
if len(text.encode()) > self.limits.broadcast_text_max:
|
|
437
|
+
await self.send_error(
|
|
438
|
+
sender.ws, ErrorCode.TEXT_TOO_LARGE, "broadcast message too large"
|
|
439
|
+
)
|
|
440
|
+
return
|
|
441
|
+
from_name = msg.get("from_name", sender.name)
|
|
442
|
+
if not isinstance(from_name, str):
|
|
443
|
+
await self.send_error(sender.ws, ErrorCode.BAD_FROM_NAME, "from_name must be a string")
|
|
444
|
+
return
|
|
445
|
+
payload = json.dumps(
|
|
446
|
+
{
|
|
447
|
+
"op": "msg",
|
|
448
|
+
"msg_id": next_msg_id(),
|
|
449
|
+
"from": sender.session_id,
|
|
450
|
+
"from_name": from_name,
|
|
451
|
+
"text": text,
|
|
452
|
+
"ts": utc_now(),
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
for conn in self.registry.values():
|
|
456
|
+
if conn.session_id == sender.session_id or conn.role != "agent":
|
|
457
|
+
continue
|
|
458
|
+
await conn.ws.send(payload)
|
|
459
|
+
|
|
460
|
+
async def _route_custom(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
461
|
+
await self._apply_middlewares(sender, msg)
|
|
462
|
+
custom_type = msg.get("custom_type")
|
|
463
|
+
if not isinstance(custom_type, str) or not custom_type:
|
|
464
|
+
await self.send_error(sender.ws, ErrorCode.BAD_CUSTOM_TYPE, "custom_type required")
|
|
465
|
+
return
|
|
466
|
+
if len(custom_type.encode()) > self.limits.custom_type_max:
|
|
467
|
+
await self.send_error(sender.ws, ErrorCode.BAD_CUSTOM_TYPE, "custom_type too large")
|
|
468
|
+
return
|
|
469
|
+
custom_payload = msg.get("payload")
|
|
470
|
+
custom_payload_size = len(json.dumps(custom_payload, ensure_ascii=False).encode())
|
|
471
|
+
if custom_payload_size > self.limits.custom_payload_max:
|
|
472
|
+
await self.send_error(
|
|
473
|
+
sender.ws, ErrorCode.CUSTOM_PAYLOAD_TOO_LARGE, "custom payload too large"
|
|
474
|
+
)
|
|
475
|
+
return
|
|
476
|
+
payload = {
|
|
477
|
+
"op": "msg",
|
|
478
|
+
"msg_id": next_msg_id(),
|
|
479
|
+
"from": sender.session_id,
|
|
480
|
+
"from_name": sender.name,
|
|
481
|
+
"custom_type": custom_type,
|
|
482
|
+
"payload": custom_payload,
|
|
483
|
+
"ts": utc_now(),
|
|
484
|
+
}
|
|
485
|
+
if msg.get("to"):
|
|
486
|
+
resolution = self._resolve_target(msg.get("to"))
|
|
487
|
+
if resolution.target is None:
|
|
488
|
+
await self._send_resolution_error(sender, resolution)
|
|
489
|
+
return
|
|
490
|
+
target = resolution.target
|
|
491
|
+
payload["to"] = target.name
|
|
492
|
+
await target.ws.send(json.dumps(payload))
|
|
493
|
+
return
|
|
494
|
+
for conn in self.registry.values():
|
|
495
|
+
if conn.session_id == sender.session_id or conn.role != "agent":
|
|
496
|
+
continue
|
|
497
|
+
await conn.ws.send(json.dumps(payload))
|
|
498
|
+
|
|
499
|
+
async def _cleanup_session_subscriptions(self, session_id: str) -> None:
|
|
500
|
+
async with self._lock:
|
|
501
|
+
for channel in list(self.subscriptions.get(session_id, [])):
|
|
502
|
+
self.channels[channel].discard(session_id)
|
|
503
|
+
if not self.channels[channel]:
|
|
504
|
+
self.channels.pop(channel, None)
|
|
505
|
+
self.subscriptions.pop(session_id, None)
|
|
506
|
+
|
|
507
|
+
def _validate_channel_name(self, channel: object) -> str | None:
|
|
508
|
+
if not validate_channel_name(channel, self.limits.channel_name_max):
|
|
509
|
+
return None
|
|
510
|
+
return str(channel)
|
|
511
|
+
|
|
512
|
+
async def _route_subscribe(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
513
|
+
if sender.role != "agent":
|
|
514
|
+
await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "subscribe requires agent role")
|
|
515
|
+
return
|
|
516
|
+
channel = self._validate_channel_name(msg.get("channel"))
|
|
517
|
+
if channel is None:
|
|
518
|
+
await self.send_error(sender.ws, ErrorCode.BAD_CHANNEL, "invalid channel name")
|
|
519
|
+
return
|
|
520
|
+
async with self._lock:
|
|
521
|
+
session_subs = self.subscriptions.setdefault(sender.session_id, set())
|
|
522
|
+
if channel in session_subs:
|
|
523
|
+
await sender.ws.send(json.dumps({"op": "subscribe_ok", "channel": channel}))
|
|
524
|
+
return
|
|
525
|
+
if len(session_subs) >= self.limits.subscriptions_max:
|
|
526
|
+
await self.send_error(
|
|
527
|
+
sender.ws, ErrorCode.CHANNEL_LIMIT_REACHED, "subscription limit reached"
|
|
528
|
+
)
|
|
529
|
+
return
|
|
530
|
+
is_new_channel = channel not in self.channels
|
|
531
|
+
if is_new_channel and len(self.channels) >= self.limits.channels_max:
|
|
532
|
+
await self.send_error(
|
|
533
|
+
sender.ws, ErrorCode.CHANNEL_LIMIT_REACHED, "server channel limit reached"
|
|
534
|
+
)
|
|
535
|
+
return
|
|
536
|
+
session_subs.add(channel)
|
|
537
|
+
self.channels.setdefault(channel, set()).add(sender.session_id)
|
|
538
|
+
await sender.ws.send(json.dumps({"op": "subscribe_ok", "channel": channel}))
|
|
539
|
+
|
|
540
|
+
async def _route_unsubscribe(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
541
|
+
if sender.role != "agent":
|
|
542
|
+
await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "unsubscribe requires agent role")
|
|
543
|
+
return
|
|
544
|
+
channel = self._validate_channel_name(msg.get("channel"))
|
|
545
|
+
if channel is None:
|
|
546
|
+
await self.send_error(sender.ws, ErrorCode.BAD_CHANNEL, "invalid channel name")
|
|
547
|
+
return
|
|
548
|
+
async with self._lock:
|
|
549
|
+
session_subs = self.subscriptions.get(sender.session_id, set())
|
|
550
|
+
if channel not in session_subs:
|
|
551
|
+
await self.send_error(sender.ws, ErrorCode.NOT_SUBSCRIBED, "not subscribed")
|
|
552
|
+
return
|
|
553
|
+
session_subs.discard(channel)
|
|
554
|
+
if not session_subs:
|
|
555
|
+
self.subscriptions.pop(sender.session_id, None)
|
|
556
|
+
self.channels[channel].discard(sender.session_id)
|
|
557
|
+
if not self.channels[channel]:
|
|
558
|
+
self.channels.pop(channel, None)
|
|
559
|
+
await sender.ws.send(json.dumps({"op": "unsubscribe_ok", "channel": channel}))
|
|
560
|
+
|
|
561
|
+
async def _route_publish(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
562
|
+
if sender.role not in {"agent", "control"}:
|
|
563
|
+
await self.send_error(
|
|
564
|
+
sender.ws, ErrorCode.BAD_ROLE, "publish requires agent or control role"
|
|
565
|
+
)
|
|
566
|
+
return
|
|
567
|
+
channel = self._validate_channel_name(msg.get("channel"))
|
|
568
|
+
if channel is None:
|
|
569
|
+
await self.send_error(sender.ws, ErrorCode.BAD_CHANNEL, "invalid channel name")
|
|
570
|
+
return
|
|
571
|
+
text = msg.get("text")
|
|
572
|
+
if not isinstance(text, str):
|
|
573
|
+
await self.send_error(sender.ws, ErrorCode.BAD_TEXT, "text must be a string")
|
|
574
|
+
return
|
|
575
|
+
if len(text.encode()) > self.limits.broadcast_text_max:
|
|
576
|
+
await self.send_error(sender.ws, ErrorCode.TEXT_TOO_LARGE, "publish text too large")
|
|
577
|
+
return
|
|
578
|
+
async with self._lock:
|
|
579
|
+
subscribers = self.channels.get(channel)
|
|
580
|
+
if not subscribers:
|
|
581
|
+
await self.send_error(sender.ws, ErrorCode.UNKNOWN_CHANNEL, "unknown channel")
|
|
582
|
+
return
|
|
583
|
+
from_name = msg.get("from_name", sender.name)
|
|
584
|
+
if not isinstance(from_name, str):
|
|
585
|
+
await self.send_error(
|
|
586
|
+
sender.ws, ErrorCode.BAD_FROM_NAME, "from_name must be a string"
|
|
587
|
+
)
|
|
588
|
+
return
|
|
589
|
+
payload = json.dumps(
|
|
590
|
+
{
|
|
591
|
+
"op": "msg",
|
|
592
|
+
"msg_id": next_msg_id(),
|
|
593
|
+
"from": sender.session_id,
|
|
594
|
+
"from_name": from_name,
|
|
595
|
+
"channel": channel,
|
|
596
|
+
"text": text,
|
|
597
|
+
"ts": utc_now(),
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
for session_id in subscribers:
|
|
601
|
+
if session_id == sender.session_id:
|
|
602
|
+
continue
|
|
603
|
+
conn = self.registry.get(session_id)
|
|
604
|
+
if conn is not None:
|
|
605
|
+
await conn.ws.send(payload)
|
|
606
|
+
|
|
607
|
+
async def _route_channels(self, sender: Conn, msg: dict[str, object]) -> None:
|
|
608
|
+
del msg
|
|
609
|
+
if sender.role != "control":
|
|
610
|
+
await self.send_error(sender.ws, ErrorCode.BAD_ROLE, "channels requires control role")
|
|
611
|
+
return
|
|
612
|
+
async with self._lock:
|
|
613
|
+
result: list[dict[str, object]] = []
|
|
614
|
+
for channel in sorted(self.channels):
|
|
615
|
+
subscriber_ids = sorted(self.channels[channel])
|
|
616
|
+
names = sorted(
|
|
617
|
+
self.registry[sid].name for sid in subscriber_ids if sid in self.registry
|
|
618
|
+
)
|
|
619
|
+
result.append({"name": channel, "subscribers": names})
|
|
620
|
+
await sender.ws.send(json.dumps({"op": "channels_ok", "channels": result}))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _is_websocket_upgrade(request: Request) -> bool:
|
|
624
|
+
"""Return True when ``request`` carries a valid WebSocket upgrade."""
|
|
625
|
+
connection = [
|
|
626
|
+
token.strip().lower()
|
|
627
|
+
for value in request.headers.get_all("Connection")
|
|
628
|
+
for token in value.split(",")
|
|
629
|
+
]
|
|
630
|
+
if "upgrade" not in connection:
|
|
631
|
+
return False
|
|
632
|
+
upgrade = [value.strip().lower() for value in request.headers.get_all("Upgrade")]
|
|
633
|
+
return upgrade == ["websocket"]
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
async def _process_request(connection: ServerConnection, request: Request) -> Response | None:
|
|
637
|
+
"""Serve a friendly HTTP response for non-WebSocket requests.
|
|
638
|
+
|
|
639
|
+
Plain HTTP probes (e.g. ``curl http://host:port``) would otherwise make
|
|
640
|
+
the underlying library raise ``InvalidUpgrade`` and log a noisy traceback.
|
|
641
|
+
Returning a ``Response`` short-circuits the handshake before that happens
|
|
642
|
+
and keeps the server log quiet. WebSocket upgrades fall through to the
|
|
643
|
+
normal handshake by returning ``None``.
|
|
644
|
+
"""
|
|
645
|
+
if _is_websocket_upgrade(request):
|
|
646
|
+
return None
|
|
647
|
+
headers = websockets.datastructures.Headers()
|
|
648
|
+
headers["Upgrade"] = "websocket"
|
|
649
|
+
headers["Content-Type"] = "text/plain; charset=utf-8"
|
|
650
|
+
body = b"This is an inter-agent WebSocket server.\n" b"Use a WebSocket client to connect.\n"
|
|
651
|
+
return Response(
|
|
652
|
+
http.HTTPStatus.UPGRADE_REQUIRED,
|
|
653
|
+
http.HTTPStatus.UPGRADE_REQUIRED.phrase,
|
|
654
|
+
headers,
|
|
655
|
+
body,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
async def run_server(
|
|
660
|
+
host: str,
|
|
661
|
+
port: int,
|
|
662
|
+
limits: Limits | None = None,
|
|
663
|
+
idle_timeout_s: float | None = DEFAULT_IDLE_TIMEOUT_S,
|
|
664
|
+
*,
|
|
665
|
+
tls: bool = False,
|
|
666
|
+
data_dir: Path | None = None,
|
|
667
|
+
tls_cert_path: Path | None = None,
|
|
668
|
+
tls_key_path: Path | None = None,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Start the core WebSocket bus until the task is cancelled."""
|
|
671
|
+
server = BusServer(host=host, port=port, limits=limits, idle_timeout_s=idle_timeout_s)
|
|
672
|
+
ssl_context = None
|
|
673
|
+
if tls:
|
|
674
|
+
if data_dir is None:
|
|
675
|
+
raise TlsConfigError("TLS server startup requires a data directory")
|
|
676
|
+
ssl_context = build_server_ssl_context(
|
|
677
|
+
data_dir,
|
|
678
|
+
host,
|
|
679
|
+
tls_cert_path,
|
|
680
|
+
tls_key_path,
|
|
681
|
+
)
|
|
682
|
+
print(f"Starting inter-agent-server on {'wss' if tls else 'ws'}://{host}:{port}...")
|
|
683
|
+
|
|
684
|
+
def _request_shutdown() -> None:
|
|
685
|
+
print("\nShutting down inter-agent-server...")
|
|
686
|
+
server.shutdown_event.set()
|
|
687
|
+
|
|
688
|
+
loop = asyncio.get_running_loop()
|
|
689
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
690
|
+
try:
|
|
691
|
+
loop.add_signal_handler(sig, _request_shutdown)
|
|
692
|
+
except (NotImplementedError, ValueError):
|
|
693
|
+
pass # Signal not supported on this platform
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
async with websockets.serve(
|
|
697
|
+
server.handle,
|
|
698
|
+
host=host,
|
|
699
|
+
port=port,
|
|
700
|
+
max_size=server.limits.frame_max,
|
|
701
|
+
process_request=_process_request,
|
|
702
|
+
ssl=ssl_context,
|
|
703
|
+
):
|
|
704
|
+
if not server.registry:
|
|
705
|
+
server._schedule_idle_timer()
|
|
706
|
+
await server.shutdown_event.wait()
|
|
707
|
+
server._cancel_idle_timer()
|
|
708
|
+
await server.close_connections()
|
|
709
|
+
finally:
|
|
710
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
711
|
+
try:
|
|
712
|
+
loop.remove_signal_handler(sig)
|
|
713
|
+
except (NotImplementedError, ValueError):
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
718
|
+
parser = argparse.ArgumentParser(prog="inter-agent-server")
|
|
719
|
+
parser.add_argument("--host")
|
|
720
|
+
parser.add_argument("--port", type=int)
|
|
721
|
+
parser.add_argument("--tls", dest="tls", action="store_true", default=None)
|
|
722
|
+
parser.add_argument("--no-tls", dest="tls", action="store_false")
|
|
723
|
+
parser.add_argument("--tls-cert")
|
|
724
|
+
parser.add_argument("--tls-key")
|
|
725
|
+
parser.add_argument(
|
|
726
|
+
"--idle-timeout",
|
|
727
|
+
type=int,
|
|
728
|
+
default=None,
|
|
729
|
+
help="shut down after N seconds with no connections (default: disabled; 0 disables)",
|
|
730
|
+
)
|
|
731
|
+
return parser
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
735
|
+
parser = build_parser()
|
|
736
|
+
args = parser.parse_args(argv)
|
|
737
|
+
endpoint = resolve_endpoint(
|
|
738
|
+
args.host,
|
|
739
|
+
args.port,
|
|
740
|
+
tls=args.tls,
|
|
741
|
+
tls_cert_path=args.tls_cert,
|
|
742
|
+
tls_key_path=args.tls_key,
|
|
743
|
+
)
|
|
744
|
+
try:
|
|
745
|
+
asyncio.run(
|
|
746
|
+
run_server(
|
|
747
|
+
endpoint.host,
|
|
748
|
+
endpoint.port,
|
|
749
|
+
idle_timeout_s=args.idle_timeout,
|
|
750
|
+
tls=endpoint.tls,
|
|
751
|
+
data_dir=endpoint.data_dir,
|
|
752
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
753
|
+
tls_key_path=endpoint.tls_key_path,
|
|
754
|
+
)
|
|
755
|
+
)
|
|
756
|
+
except TlsConfigError as exc:
|
|
757
|
+
raise SystemExit(f"could not start inter-agent-server TLS: {exc}") from exc
|
|
758
|
+
except OSError as exc:
|
|
759
|
+
message = f"could not start inter-agent-server on {endpoint.host}:{endpoint.port}: {exc}"
|
|
760
|
+
raise SystemExit(message) from exc
|
|
761
|
+
return 0
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
if __name__ == "__main__":
|
|
765
|
+
raise SystemExit(main())
|