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.
Files changed (80) hide show
  1. inter_agent/__init__.py +0 -0
  2. inter_agent/core/__init__.py +1 -0
  3. inter_agent/core/adapter_control.py +350 -0
  4. inter_agent/core/auth.py +279 -0
  5. inter_agent/core/channels.py +131 -0
  6. inter_agent/core/client.py +345 -0
  7. inter_agent/core/config.py +325 -0
  8. inter_agent/core/errors.py +31 -0
  9. inter_agent/core/kick.py +111 -0
  10. inter_agent/core/list.py +127 -0
  11. inter_agent/core/publish.py +146 -0
  12. inter_agent/core/router.py +8 -0
  13. inter_agent/core/send.py +265 -0
  14. inter_agent/core/server.py +765 -0
  15. inter_agent/core/shared.py +182 -0
  16. inter_agent/core/shutdown.py +92 -0
  17. inter_agent/core/status.py +291 -0
  18. inter_agent/core/tls.py +141 -0
  19. inter_agent/core/transport.py +37 -0
  20. inter_agent/py.typed +0 -0
  21. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/asyncapi.yaml +225 -0
  22. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/error-codes.md +28 -0
  23. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_challenge.json +6 -0
  24. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_response.json +4 -0
  25. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/broadcast.json +4 -0
  26. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/bye.json +3 -0
  27. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels.json +3 -0
  28. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels_ok.json +9 -0
  29. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/custom.unknown-pass-through.json +9 -0
  30. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/error.auth-failed.json +5 -0
  31. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent-with-label.json +14 -0
  32. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent.json +13 -0
  33. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick.json +4 -0
  34. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick_ok.json +5 -0
  35. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list.json +3 -0
  36. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list_ok.json +10 -0
  37. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.custom.json +13 -0
  38. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.text.json +9 -0
  39. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/ping.json +3 -0
  40. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/pong.json +3 -0
  41. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/publish.json +5 -0
  42. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/send.direct.json +5 -0
  43. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown.json +3 -0
  44. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown_ok.json +3 -0
  45. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe.json +4 -0
  46. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe_ok.json +4 -0
  47. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe.json +4 -0
  48. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe_ok.json +4 -0
  49. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/welcome.json +10 -0
  50. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_challenge.json +13 -0
  51. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_response.json +11 -0
  52. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/broadcast.json +18 -0
  53. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/bye.json +10 -0
  54. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels.json +10 -0
  55. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels_ok.json +26 -0
  56. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/custom.json +22 -0
  57. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/error.json +37 -0
  58. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/hello.json +59 -0
  59. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick.json +13 -0
  60. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick_ok.json +12 -0
  61. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list.json +10 -0
  62. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list_ok.json +24 -0
  63. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/msg.json +29 -0
  64. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/ping.json +10 -0
  65. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/pong.json +10 -0
  66. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/publish.json +23 -0
  67. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/send.json +22 -0
  68. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown.json +10 -0
  69. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown_ok.json +10 -0
  70. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe.json +15 -0
  71. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe_ok.json +11 -0
  72. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe.json +15 -0
  73. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe_ok.json +11 -0
  74. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/welcome.json +36 -0
  75. inter_agent_core-0.2.0.dist-info/METADATA +85 -0
  76. inter_agent_core-0.2.0.dist-info/RECORD +80 -0
  77. inter_agent_core-0.2.0.dist-info/WHEEL +5 -0
  78. inter_agent_core-0.2.0.dist-info/entry_points.txt +10 -0
  79. inter_agent_core-0.2.0.dist-info/licenses/LICENSE.md +21 -0
  80. inter_agent_core-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ import socket
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from inter_agent.core.auth import build_hello
10
+ from inter_agent.core.config import (
11
+ BUILTIN_DEFAULT_HOST,
12
+ BUILTIN_DEFAULT_PORT,
13
+ ConfigError,
14
+ EndpointResolution,
15
+ resolve_data_dir_path,
16
+ resolve_endpoint_config,
17
+ resolve_explicit_secret_config,
18
+ )
19
+
20
+ DEFAULT_HOST = BUILTIN_DEFAULT_HOST
21
+ DEFAULT_PORT = BUILTIN_DEFAULT_PORT
22
+ DEFAULT_DIRECT_CAP = 2 * 1024 * 1024
23
+ DEFAULT_BROADCAST_CAP = 512 * 1024
24
+ DEFAULT_FRAME_CAP = 16 * 1024 * 1024
25
+ DEFAULT_CONNECTION_CAP = 64
26
+ DEFAULT_CUSTOM_TYPE_CAP = 128
27
+ DEFAULT_CUSTOM_PAYLOAD_CAP = 1024 * 1024
28
+ DEFAULT_CHANNEL_NAME_CAP = 40
29
+ DEFAULT_SUBSCRIPTIONS_CAP = 32
30
+ DEFAULT_CHANNELS_CAP = 256
31
+
32
+
33
+ def env_int(name: str, default: int) -> int:
34
+ raw = os.getenv(name)
35
+ if raw is None:
36
+ return default
37
+ try:
38
+ return int(raw)
39
+ except ValueError:
40
+ return default
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Limits:
45
+ frame_max: int = env_int("INTER_AGENT_FRAME_MAX", DEFAULT_FRAME_CAP)
46
+ direct_text_max: int = env_int("INTER_AGENT_DIRECT_MAX", DEFAULT_DIRECT_CAP)
47
+ broadcast_text_max: int = env_int("INTER_AGENT_BROADCAST_MAX", DEFAULT_BROADCAST_CAP)
48
+ connection_max: int = env_int("INTER_AGENT_CONNECTION_MAX", DEFAULT_CONNECTION_CAP)
49
+ custom_type_max: int = env_int("INTER_AGENT_CUSTOM_TYPE_MAX", DEFAULT_CUSTOM_TYPE_CAP)
50
+ custom_payload_max: int = env_int("INTER_AGENT_CUSTOM_PAYLOAD_MAX", DEFAULT_CUSTOM_PAYLOAD_CAP)
51
+ channel_name_max: int = env_int("INTER_AGENT_CHANNEL_NAME_MAX", DEFAULT_CHANNEL_NAME_CAP)
52
+ subscriptions_max: int = env_int("INTER_AGENT_SUBSCRIPTIONS_MAX", DEFAULT_SUBSCRIPTIONS_CAP)
53
+ channels_max: int = env_int("INTER_AGENT_CHANNELS_MAX", DEFAULT_CHANNELS_CAP)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class SecretResolution:
58
+ """Resolved shared server secret without exposing it in status output."""
59
+
60
+ secret: str
61
+ source: str
62
+ config_path: Path | None = None
63
+ path: Path | None = None
64
+
65
+
66
+ class ServerAlreadyRunningError(RuntimeError):
67
+ """Compatibility error for older callers; bind failure now detects duplicates."""
68
+
69
+
70
+ def data_dir() -> Path:
71
+ path = resolve_data_dir_path()
72
+ path.mkdir(parents=True, exist_ok=True)
73
+ os.chmod(path, 0o700)
74
+ return path
75
+
76
+
77
+ def token_path() -> Path:
78
+ return data_dir() / "token"
79
+
80
+
81
+ def _atomic_write_text(path: Path, content: str, mode: int = 0o600) -> None:
82
+ temp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp")
83
+ temp_path.write_text(content, encoding="utf-8")
84
+ os.chmod(temp_path, mode)
85
+ os.replace(temp_path, path)
86
+ os.chmod(path, mode)
87
+
88
+
89
+ def load_or_create_token() -> str:
90
+ """Load or create the fallback local generated secret."""
91
+ path = token_path()
92
+ if path.exists():
93
+ os.chmod(path, 0o600)
94
+ token = path.read_text(encoding="utf-8").strip()
95
+ if token:
96
+ return token
97
+ token = secrets.token_urlsafe(32)
98
+ _atomic_write_text(path, token + "\n")
99
+ return token
100
+
101
+
102
+ def resolve_shared_secret() -> SecretResolution:
103
+ """Resolve the shared server secret using env/config/token-file precedence."""
104
+ try:
105
+ explicit = resolve_explicit_secret_config()
106
+ except ConfigError as exc:
107
+ raise SystemExit(str(exc)) from exc
108
+ if explicit is not None:
109
+ return SecretResolution(
110
+ secret=explicit.secret,
111
+ source=explicit.source,
112
+ config_path=explicit.config_path,
113
+ )
114
+ return SecretResolution(secret=load_or_create_token(), source="token_file", path=token_path())
115
+
116
+
117
+ def resolve_endpoint(
118
+ host: str | None = None,
119
+ port: int | None = None,
120
+ *,
121
+ allow_discovery: bool = False,
122
+ tls: bool | None = None,
123
+ tls_cert_path: str | None = None,
124
+ tls_key_path: str | None = None,
125
+ ) -> EndpointResolution:
126
+ """Resolve the configured endpoint.
127
+
128
+ ``allow_discovery`` is accepted for compatibility but no longer redirects
129
+ away from the configured endpoint.
130
+ """
131
+ del allow_discovery
132
+ try:
133
+ return resolve_endpoint_config(host, port, tls, tls_cert_path, tls_key_path)
134
+ except ConfigError as exc:
135
+ raise SystemExit(str(exc)) from exc
136
+
137
+
138
+ def utc_now() -> str:
139
+ from datetime import UTC, datetime
140
+
141
+ return datetime.now(UTC).isoformat()
142
+
143
+
144
+ def next_msg_id() -> str:
145
+ return secrets.token_hex(8)
146
+
147
+
148
+ def control_hello(session_id: str, client_nonce: str | None = None) -> dict[str, object]:
149
+ return build_hello(
150
+ role="control",
151
+ session_id=session_id,
152
+ name="control",
153
+ capabilities={},
154
+ client_nonce=client_nonce,
155
+ )
156
+
157
+
158
+ def validate_name(name: object) -> bool:
159
+ if not isinstance(name, str):
160
+ return False
161
+ import re
162
+
163
+ return bool(re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", name))
164
+
165
+
166
+ def validate_channel_name(channel: object, max_bytes: int) -> bool:
167
+ if not isinstance(channel, str):
168
+ return False
169
+ import re
170
+
171
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", channel):
172
+ return False
173
+ return len(channel.encode("utf-8")) <= max_bytes
174
+
175
+
176
+ def is_localhost(host: str) -> bool:
177
+ if host == "127.0.0.1":
178
+ return True
179
+ try:
180
+ return socket.gethostbyname(host) == "127.0.0.1"
181
+ except Exception:
182
+ return False
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import uuid
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import websockets
12
+
13
+ from inter_agent.core.auth import AuthError, AuthProtocolError, client_handshake
14
+ from inter_agent.core.shared import control_hello, resolve_endpoint, resolve_shared_secret
15
+ from inter_agent.core.transport import client_ssl_context, websocket_uri
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ShutdownResult:
20
+ """Result returned after requesting server shutdown."""
21
+
22
+ response: str
23
+ response_payload: dict[str, object]
24
+
25
+
26
+ def _text_frame(frame: str | bytes) -> str:
27
+ if isinstance(frame, bytes):
28
+ return frame.decode("utf-8")
29
+ return frame
30
+
31
+
32
+ def _json_object(raw: str) -> dict[str, object]:
33
+ payload: object = json.loads(raw)
34
+ if not isinstance(payload, dict):
35
+ raise ValueError("server response must be a JSON object")
36
+ return {str(key): value for key, value in payload.items()}
37
+
38
+
39
+ async def shutdown_server(
40
+ host: str,
41
+ port: int,
42
+ *,
43
+ tls: bool = False,
44
+ data_dir: Path | None = None,
45
+ tls_cert_path: Path | None = None,
46
+ ) -> ShutdownResult:
47
+ """Request authenticated shutdown through a control connection."""
48
+ secret = resolve_shared_secret().secret
49
+ ssl_context = client_ssl_context(tls, data_dir, tls_cert_path)
50
+ async with websockets.connect(websocket_uri(host, port, tls), ssl=ssl_context) as ws:
51
+ try:
52
+ _ = await client_handshake(ws, secret, control_hello(f"shutdown-{uuid.uuid4()}"))
53
+ except AuthError as exc:
54
+ raise SystemExit(str(exc)) from exc
55
+ except (AuthProtocolError, json.JSONDecodeError, UnicodeDecodeError) as exc:
56
+ raise SystemExit(f"server protocol mismatch: {exc}") from exc
57
+ await ws.send(json.dumps({"op": "shutdown"}))
58
+ response = _text_frame(await ws.recv())
59
+ return ShutdownResult(response=response, response_payload=_json_object(response))
60
+
61
+
62
+ def build_parser() -> argparse.ArgumentParser:
63
+ parser = argparse.ArgumentParser(prog="inter-agent-shutdown")
64
+ parser.add_argument("--host")
65
+ parser.add_argument("--port", type=int)
66
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
67
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
68
+ parser.add_argument("--tls-cert")
69
+ return parser
70
+
71
+
72
+ def main(argv: Sequence[str] | None = None) -> int:
73
+ parser = build_parser()
74
+ args = parser.parse_args(argv)
75
+ endpoint = resolve_endpoint(
76
+ args.host, args.port, allow_discovery=True, tls=args.tls, tls_cert_path=args.tls_cert
77
+ )
78
+ result = asyncio.run(
79
+ shutdown_server(
80
+ endpoint.host,
81
+ endpoint.port,
82
+ tls=endpoint.tls,
83
+ data_dir=endpoint.data_dir,
84
+ tls_cert_path=endpoint.tls_cert_path,
85
+ )
86
+ )
87
+ print(result.response)
88
+ return 0 if result.response_payload.get("op") == "shutdown_ok" else 1
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -0,0 +1,291 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import uuid
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass, replace
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ import websockets
13
+ from websockets.exceptions import WebSocketException
14
+
15
+ from inter_agent.core.auth import AuthError, AuthProtocolError, client_handshake
16
+ from inter_agent.core.config import EndpointResolution
17
+ from inter_agent.core.shared import control_hello, resolve_endpoint, resolve_shared_secret
18
+ from inter_agent.core.tls import TlsConfigError
19
+ from inter_agent.core.transport import client_ssl_context, websocket_uri
20
+
21
+ StatusState = Literal[
22
+ "available",
23
+ "unavailable",
24
+ "auth_failed",
25
+ "protocol_mismatch",
26
+ ]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CoreCommandStatus:
31
+ """Static command capabilities exposed to host adapters."""
32
+
33
+ list_supported: bool
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ServerStatus:
38
+ """Live server status for command adapters."""
39
+
40
+ state: StatusState
41
+ host: str
42
+ port: int
43
+ reachable: bool
44
+ message: str
45
+ configured_host: str | None = None
46
+ configured_port: int | None = None
47
+ scheme: str | None = None
48
+ tls: bool | None = None
49
+ tls_source: str | None = None
50
+ tls_cert_path: str | None = None
51
+ tls_cert_source: str | None = None
52
+ host_source: str | None = None
53
+ port_source: str | None = None
54
+ data_dir: str | None = None
55
+ data_dir_source: str | None = None
56
+ config_path: str | None = None
57
+ hints: tuple[str, ...] = ()
58
+
59
+
60
+ def command_status() -> CoreCommandStatus:
61
+ """Return core command capabilities that do not require a live server."""
62
+ return CoreCommandStatus(list_supported=True)
63
+
64
+
65
+ def _status(
66
+ state: StatusState,
67
+ host: str,
68
+ port: int,
69
+ *,
70
+ reachable: bool,
71
+ message: str,
72
+ ) -> ServerStatus:
73
+ return ServerStatus(
74
+ state=state,
75
+ host=host,
76
+ port=port,
77
+ reachable=reachable,
78
+ message=message,
79
+ )
80
+
81
+
82
+ def _json_object(raw: str) -> dict[str, object]:
83
+ payload: object = json.loads(raw)
84
+ if not isinstance(payload, dict):
85
+ raise ValueError("server response must be a JSON object")
86
+ return {str(key): value for key, value in payload.items()}
87
+
88
+
89
+ async def _probe_server(
90
+ host: str,
91
+ port: int,
92
+ secret: str,
93
+ *,
94
+ tls: bool = False,
95
+ data_dir: Path | None = None,
96
+ tls_cert_path: Path | None = None,
97
+ ) -> ServerStatus:
98
+ ssl_context = client_ssl_context(tls, data_dir, tls_cert_path)
99
+ async with websockets.connect(websocket_uri(host, port, tls), ssl=ssl_context) as ws:
100
+ response_raw = await client_handshake(ws, secret, control_hello(f"status-{uuid.uuid4()}"))
101
+
102
+ response = _json_object(response_raw)
103
+ op = response.get("op")
104
+ if op == "welcome":
105
+ return _status(
106
+ "available",
107
+ host,
108
+ port,
109
+ reachable=True,
110
+ message="server available",
111
+ )
112
+ if op == "error" and response.get("code") == "AUTH_FAILED":
113
+ return _status(
114
+ "auth_failed",
115
+ host,
116
+ port,
117
+ reachable=True,
118
+ message="server authentication failed",
119
+ )
120
+ return _status(
121
+ "protocol_mismatch",
122
+ host,
123
+ port,
124
+ reachable=True,
125
+ message="server returned an unexpected status response",
126
+ )
127
+
128
+
129
+ async def check_server_status(
130
+ host: str,
131
+ port: int,
132
+ timeout: float = 0.5,
133
+ *,
134
+ tls: bool = False,
135
+ data_dir: Path | None = None,
136
+ tls_cert_path: Path | None = None,
137
+ ) -> ServerStatus:
138
+ """Probe the live WebSocket endpoint with the resolved shared secret."""
139
+ try:
140
+ secret = resolve_shared_secret().secret
141
+ return await asyncio.wait_for(
142
+ _probe_server(
143
+ host,
144
+ port,
145
+ secret,
146
+ tls=tls,
147
+ data_dir=data_dir,
148
+ tls_cert_path=tls_cert_path,
149
+ ),
150
+ timeout=timeout,
151
+ )
152
+ except AuthError:
153
+ return _status(
154
+ "auth_failed",
155
+ host,
156
+ port,
157
+ reachable=True,
158
+ message="server authentication failed",
159
+ )
160
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError, AuthProtocolError):
161
+ return _status(
162
+ "protocol_mismatch",
163
+ host,
164
+ port,
165
+ reachable=True,
166
+ message="server returned an invalid status response",
167
+ )
168
+ except TlsConfigError as exc:
169
+ return _status(
170
+ "unavailable",
171
+ host,
172
+ port,
173
+ reachable=False,
174
+ message=f"TLS configuration failed: {exc}",
175
+ )
176
+ except (OSError, TimeoutError, WebSocketException):
177
+ return _status(
178
+ "unavailable",
179
+ host,
180
+ port,
181
+ reachable=False,
182
+ message="server connection failed",
183
+ )
184
+
185
+
186
+ def _hints(status: ServerStatus) -> tuple[str, ...]:
187
+ if status.state == "unavailable":
188
+ return ("start inter-agent-server or check INTER_AGENT_HOST and INTER_AGENT_PORT",)
189
+ return ()
190
+
191
+
192
+ async def check_resolved_server_status(
193
+ resolution: EndpointResolution, timeout: float = 0.5
194
+ ) -> ServerStatus:
195
+ """Check status and attach endpoint/config diagnostics."""
196
+ status = await check_server_status(
197
+ resolution.host,
198
+ resolution.port,
199
+ timeout=timeout,
200
+ tls=resolution.tls,
201
+ data_dir=resolution.data_dir,
202
+ tls_cert_path=resolution.tls_cert_path,
203
+ )
204
+ status = replace(
205
+ status,
206
+ configured_host=resolution.configured_host,
207
+ configured_port=resolution.configured_port,
208
+ scheme=resolution.scheme,
209
+ tls=resolution.tls,
210
+ tls_source=resolution.tls_source,
211
+ tls_cert_path=(
212
+ str(resolution.tls_cert_path) if resolution.tls_cert_path is not None else None
213
+ ),
214
+ tls_cert_source=resolution.tls_cert_source,
215
+ host_source=resolution.host_source,
216
+ port_source=resolution.port_source,
217
+ data_dir=str(resolution.data_dir),
218
+ data_dir_source=resolution.data_dir_source,
219
+ config_path=str(resolution.config_path) if resolution.config_path is not None else None,
220
+ )
221
+ return replace(status, hints=_hints(status))
222
+
223
+
224
+ def build_parser() -> argparse.ArgumentParser:
225
+ parser = argparse.ArgumentParser(prog="inter-agent-status")
226
+ parser.add_argument("--host")
227
+ parser.add_argument("--port", type=int)
228
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
229
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
230
+ parser.add_argument("--tls-cert")
231
+ parser.add_argument("--json", action="store_true", help="emit JSON status output")
232
+ return parser
233
+
234
+
235
+ def _status_payload(status: ServerStatus) -> dict[str, object]:
236
+ return {
237
+ "state": status.state,
238
+ "host": status.host,
239
+ "port": status.port,
240
+ "configured_host": status.configured_host,
241
+ "configured_port": status.configured_port,
242
+ "scheme": status.scheme,
243
+ "tls": status.tls,
244
+ "tls_source": status.tls_source,
245
+ "tls_cert_path": status.tls_cert_path,
246
+ "tls_cert_source": status.tls_cert_source,
247
+ "host_source": status.host_source,
248
+ "port_source": status.port_source,
249
+ "data_dir": status.data_dir,
250
+ "data_dir_source": status.data_dir_source,
251
+ "config_path": status.config_path,
252
+ "hints": list(status.hints),
253
+ "reachable": status.reachable,
254
+ "message": status.message,
255
+ }
256
+
257
+
258
+ def main(argv: Sequence[str] | None = None) -> int:
259
+ parser = build_parser()
260
+ args = parser.parse_args(argv)
261
+ endpoint = resolve_endpoint(
262
+ args.host, args.port, allow_discovery=True, tls=args.tls, tls_cert_path=args.tls_cert
263
+ )
264
+ status = asyncio.run(check_resolved_server_status(endpoint))
265
+ if args.json:
266
+ print(json.dumps(_status_payload(status)))
267
+ else:
268
+ print(f"state={status.state}")
269
+ print(f"host={status.host}")
270
+ print(f"port={status.port}")
271
+ print(f"scheme={status.scheme}")
272
+ print(f"tls={status.tls}")
273
+ print(f"tls_source={status.tls_source}")
274
+ print(f"tls_cert_path={status.tls_cert_path or ''}")
275
+ print(f"tls_cert_source={status.tls_cert_source or ''}")
276
+ print(f"reachable={status.reachable}")
277
+ print(f"configured_host={status.configured_host}")
278
+ print(f"configured_port={status.configured_port}")
279
+ print(f"host_source={status.host_source}")
280
+ print(f"port_source={status.port_source}")
281
+ print(f"data_dir={status.data_dir}")
282
+ print(f"data_dir_source={status.data_dir_source}")
283
+ print(f"config_path={status.config_path or ''}")
284
+ print(f"message={status.message}")
285
+ for hint in status.hints:
286
+ print(f"hint={hint}")
287
+ return 0
288
+
289
+
290
+ if __name__ == "__main__":
291
+ raise SystemExit(main())
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import os
5
+ import ssl
6
+ from datetime import UTC, datetime, timedelta
7
+ from pathlib import Path
8
+
9
+ from cryptography import x509
10
+ from cryptography.hazmat.primitives import hashes, serialization
11
+ from cryptography.hazmat.primitives.asymmetric import rsa
12
+ from cryptography.x509.oid import NameOID
13
+
14
+
15
+ class TlsConfigError(RuntimeError):
16
+ """Raised when TLS configuration cannot be used."""
17
+
18
+
19
+ DEFAULT_CERT_NAME = "tls-cert.pem"
20
+ DEFAULT_KEY_NAME = "tls-key.pem"
21
+
22
+
23
+ def default_cert_path(data_dir: Path) -> Path:
24
+ return data_dir / DEFAULT_CERT_NAME
25
+
26
+
27
+ def default_key_path(data_dir: Path) -> Path:
28
+ return data_dir / DEFAULT_KEY_NAME
29
+
30
+
31
+ def resolved_tls_paths(
32
+ data_dir: Path,
33
+ cert_path: Path | None = None,
34
+ key_path: Path | None = None,
35
+ ) -> tuple[Path, Path]:
36
+ if (cert_path is None) != (key_path is None):
37
+ raise TlsConfigError("TLS requires both certificate and key paths, or neither")
38
+ if cert_path is not None and key_path is not None:
39
+ return cert_path, key_path
40
+ return default_cert_path(data_dir), default_key_path(data_dir)
41
+
42
+
43
+ def ensure_tls_material(
44
+ data_dir: Path,
45
+ host: str,
46
+ cert_path: Path | None = None,
47
+ key_path: Path | None = None,
48
+ ) -> tuple[Path, Path]:
49
+ cert, key = resolved_tls_paths(data_dir, cert_path, key_path)
50
+ if cert.exists() and key.exists():
51
+ os.chmod(cert, 0o600)
52
+ os.chmod(key, 0o600)
53
+ return cert, key
54
+ if cert_path is not None or key_path is not None:
55
+ raise TlsConfigError(f"configured TLS certificate or key does not exist: {cert}, {key}")
56
+ data_dir.mkdir(parents=True, exist_ok=True)
57
+ os.chmod(data_dir, 0o700)
58
+ _generate_self_signed_cert(cert, key, host)
59
+ return cert, key
60
+
61
+
62
+ def build_server_ssl_context(
63
+ data_dir: Path,
64
+ host: str,
65
+ cert_path: Path | None = None,
66
+ key_path: Path | None = None,
67
+ ) -> ssl.SSLContext:
68
+ cert, key = ensure_tls_material(data_dir, host, cert_path, key_path)
69
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
70
+ try:
71
+ context.load_cert_chain(certfile=cert, keyfile=key)
72
+ except OSError as exc:
73
+ raise TlsConfigError(f"could not load TLS certificate or key: {exc}") from exc
74
+ return context
75
+
76
+
77
+ def build_client_ssl_context(
78
+ data_dir: Path,
79
+ cert_path: Path | None = None,
80
+ ) -> ssl.SSLContext:
81
+ cert = cert_path or default_cert_path(data_dir)
82
+ if not cert.exists():
83
+ raise TlsConfigError(
84
+ f"TLS certificate not found at {cert}; "
85
+ "start the server or configure INTER_AGENT_TLS_CERT"
86
+ )
87
+ context = ssl.create_default_context(cafile=str(cert))
88
+ context.check_hostname = False
89
+ return context
90
+
91
+
92
+ def _subject_alt_names(host: str) -> list[x509.GeneralName]:
93
+ names: list[x509.GeneralName] = [
94
+ x509.DNSName("localhost"),
95
+ x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
96
+ x509.IPAddress(ipaddress.ip_address("::1")),
97
+ ]
98
+ try:
99
+ names.append(x509.IPAddress(ipaddress.ip_address(host)))
100
+ except ValueError:
101
+ if host:
102
+ names.append(x509.DNSName(host))
103
+ return names
104
+
105
+
106
+ def _generate_self_signed_cert(cert_path: Path, key_path: Path, host: str) -> None:
107
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
108
+ subject = issuer = x509.Name(
109
+ [
110
+ x509.NameAttribute(NameOID.COMMON_NAME, "inter-agent local TLS"),
111
+ ]
112
+ )
113
+ now = datetime.now(UTC)
114
+ certificate = (
115
+ x509.CertificateBuilder()
116
+ .subject_name(subject)
117
+ .issuer_name(issuer)
118
+ .public_key(private_key.public_key())
119
+ .serial_number(x509.random_serial_number())
120
+ .not_valid_before(now - timedelta(minutes=1))
121
+ .not_valid_after(now + timedelta(days=3650))
122
+ .add_extension(x509.SubjectAlternativeName(_subject_alt_names(host)), critical=False)
123
+ .sign(private_key, hashes.SHA256())
124
+ )
125
+
126
+ cert_temp = cert_path.with_name(f".{cert_path.name}.{os.getpid()}.tmp")
127
+ key_temp = key_path.with_name(f".{key_path.name}.{os.getpid()}.tmp")
128
+ cert_temp.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
129
+ key_temp.write_bytes(
130
+ private_key.private_bytes(
131
+ encoding=serialization.Encoding.PEM,
132
+ format=serialization.PrivateFormat.PKCS8,
133
+ encryption_algorithm=serialization.NoEncryption(),
134
+ )
135
+ )
136
+ os.chmod(cert_temp, 0o600)
137
+ os.chmod(key_temp, 0o600)
138
+ os.replace(cert_temp, cert_path)
139
+ os.replace(key_temp, key_path)
140
+ os.chmod(cert_path, 0o600)
141
+ os.chmod(key_path, 0o600)