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,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())
|
inter_agent/core/tls.py
ADDED
|
@@ -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)
|