agentremote-common 1.0.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.
@@ -0,0 +1,21 @@
1
+ """Shared AgentRemote demo protocol, MQTT client and security helpers."""
2
+
3
+ from .mqtt_client import AgentRemoteMqttClient
4
+ from .protocol.message import MqttConfig, WSMessage, WSMessageType
5
+ from .protocol.topics import (
6
+ PLUGIN_OUTBOUND_TOPICS,
7
+ gateway_topic,
8
+ parse_plugin_topic,
9
+ plugin_topic,
10
+ )
11
+
12
+ __all__ = [
13
+ "MqttConfig",
14
+ "WSMessage",
15
+ "WSMessageType",
16
+ "AgentRemoteMqttClient",
17
+ "PLUGIN_OUTBOUND_TOPICS",
18
+ "parse_plugin_topic",
19
+ "plugin_topic",
20
+ "gateway_topic",
21
+ ]
@@ -0,0 +1,341 @@
1
+ """Role-based AgentRemote configuration helpers.
2
+
3
+ Resolution order is always: explicit CLI args, environment variables,
4
+ role-specific TOML config, local development defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ import os
11
+ from pathlib import Path
12
+ import tomllib
13
+ from typing import Any, Mapping
14
+
15
+
16
+ TRUE_VALUES = {"1", "true", "yes", "on"}
17
+ FALSE_VALUES = {"0", "false", "no", "off"}
18
+
19
+ ROLE_ENV_VARS = {
20
+ "cli": "AGENTREMOTE_CLI_CONFIG",
21
+ "server": "AGENTREMOTE_MIDDLEWARE_CONFIG",
22
+ "client": "AGENTREMOTE_RUNTIME_CONFIG",
23
+ }
24
+
25
+ ROLE_CONFIG_FILES = {
26
+ "cli": "agentremote.cli.toml",
27
+ "server": "agentremote.server.toml",
28
+ "client": "agentremote.client.toml",
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class CliDefaults:
34
+ middleware_url: str = "http://127.0.0.1:9100"
35
+ tenant: str = "acme_shanghai"
36
+ node: str = "sh_win_01"
37
+ agent: str = "agent_ai_01"
38
+ handle: str = ""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class MiddlewareDefaults:
43
+ http_host: str = "127.0.0.1"
44
+ http_port: int = 9100
45
+ mqtt_broker: str = "127.0.0.1"
46
+ mqtt_port: int = 1883
47
+ auth_broker_url: str = ""
48
+ require_auth: bool = False
49
+ dev_skip_license: bool = False
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RuntimeDefaults:
54
+ node: str = "sh_win_01"
55
+ tenant: str = "acme_shanghai"
56
+ mqtt_broker: str = "127.0.0.1"
57
+ mqtt_port: int = 1883
58
+ plugins_dir: str = "client/plugins"
59
+ watch_plugins: bool = False
60
+ plugin_watch_interval: float = 2.0
61
+
62
+
63
+ def load_cli_defaults(config_path: str = "") -> CliDefaults:
64
+ config = load_role_config("cli", config_path)
65
+ return CliDefaults(
66
+ middleware_url=_pick_str(
67
+ config,
68
+ "cli",
69
+ "middleware_url",
70
+ "http://127.0.0.1:9100",
71
+ "AGENTREMOTE_MIDDLEWARE_URL",
72
+ "MIDDLEWARE_HTTP",
73
+ ),
74
+ tenant=_pick_str(config, "cli", "tenant", "acme_shanghai", "AGENTREMOTE_TENANT", "TENANT_ID"),
75
+ node=_pick_str(config, "cli", "node", "sh_win_01", "AGENTREMOTE_NODE", "NODE_ID"),
76
+ agent=_pick_str(config, "cli", "agent", "agent_ai_01", "AGENTREMOTE_AGENT", "AGENT_ID"),
77
+ handle=_pick_str(config, "cli", "handle", "", "AGENTREMOTE_HANDLE"),
78
+ )
79
+
80
+
81
+ def load_middleware_defaults(config_path: str = "") -> MiddlewareDefaults:
82
+ config = load_role_config("server", config_path)
83
+ return MiddlewareDefaults(
84
+ http_host=_pick_str(
85
+ config,
86
+ "middleware",
87
+ "http_host",
88
+ "127.0.0.1",
89
+ "AGENTREMOTE_HTTP_HOST",
90
+ "AGENTREMOTE_MIDDLEWARE_HTTP_HOST",
91
+ ),
92
+ http_port=_pick_int(
93
+ config,
94
+ "middleware",
95
+ "http_port",
96
+ 9100,
97
+ "AGENTREMOTE_HTTP_PORT",
98
+ "AGENTREMOTE_MIDDLEWARE_HTTP_PORT",
99
+ ),
100
+ mqtt_broker=_pick_str(
101
+ config,
102
+ "mqtt",
103
+ "broker_host",
104
+ "127.0.0.1",
105
+ "AGENTREMOTE_MQTT_BROKER",
106
+ "AGENTREMOTE_MQTT_HOST",
107
+ "AGENTREMOTE_MIDDLEWARE_MQTT_BROKER",
108
+ ),
109
+ mqtt_port=_pick_int(
110
+ config,
111
+ "mqtt",
112
+ "broker_port",
113
+ 1883,
114
+ "AGENTREMOTE_MQTT_PORT",
115
+ "AGENTREMOTE_MIDDLEWARE_MQTT_PORT",
116
+ ),
117
+ auth_broker_url=_pick_str(
118
+ config,
119
+ "middleware",
120
+ "auth_broker_url",
121
+ "",
122
+ "AGENTREMOTE_AUTH_BROKER_URL",
123
+ "AUTH_BROKER_URL",
124
+ ),
125
+ require_auth=_pick_bool(config, "middleware", "require_auth", False, "AGENTREMOTE_REQUIRE_AUTH"),
126
+ dev_skip_license=_pick_bool(
127
+ config,
128
+ "middleware",
129
+ "dev_skip_license",
130
+ False,
131
+ "AGENTREMOTE_DEV_SKIP_LICENSE",
132
+ ),
133
+ )
134
+
135
+
136
+ def load_runtime_defaults(config_path: str = "") -> RuntimeDefaults:
137
+ config = load_role_config("client", config_path)
138
+ return RuntimeDefaults(
139
+ node=_pick_str(config, "runtime", "node", "sh_win_01", "AGENTREMOTE_NODE", "NODE_ID"),
140
+ tenant=_pick_str(config, "runtime", "tenant", "acme_shanghai", "AGENTREMOTE_TENANT", "TENANT_ID"),
141
+ mqtt_broker=_pick_str(
142
+ config,
143
+ "mqtt",
144
+ "broker_host",
145
+ "127.0.0.1",
146
+ "AGENTREMOTE_MQTT_BROKER",
147
+ "AGENTREMOTE_MQTT_HOST",
148
+ "AGENTREMOTE_RUNTIME_MQTT_BROKER",
149
+ ),
150
+ mqtt_port=_pick_int(
151
+ config,
152
+ "mqtt",
153
+ "broker_port",
154
+ 1883,
155
+ "AGENTREMOTE_MQTT_PORT",
156
+ "AGENTREMOTE_RUNTIME_MQTT_PORT",
157
+ ),
158
+ plugins_dir=_pick_str(
159
+ config,
160
+ "runtime",
161
+ "plugins_dir",
162
+ "client/plugins",
163
+ "AGENTREMOTE_PLUGINS_DIR",
164
+ "AGENTREMOTE_RUNTIME_PLUGINS_DIR",
165
+ ),
166
+ watch_plugins=_pick_bool(
167
+ config,
168
+ "runtime",
169
+ "watch_plugins",
170
+ False,
171
+ "AGENTREMOTE_WATCH_PLUGINS",
172
+ "AGENTREMOTE_RUNTIME_WATCH_PLUGINS",
173
+ ),
174
+ plugin_watch_interval=_pick_float(
175
+ config,
176
+ "runtime",
177
+ "plugin_watch_interval",
178
+ 2.0,
179
+ "AGENTREMOTE_PLUGIN_WATCH_INTERVAL",
180
+ "AGENTREMOTE_RUNTIME_PLUGIN_WATCH_INTERVAL",
181
+ ),
182
+ )
183
+
184
+
185
+ def load_role_config(role: str, explicit_path: str = "") -> dict[str, Any]:
186
+ path = resolve_config_path(role, explicit_path)
187
+ if path is None:
188
+ return {}
189
+ with path.open("rb") as f:
190
+ data = tomllib.load(f)
191
+ if not isinstance(data, dict):
192
+ return {}
193
+ return data
194
+
195
+
196
+ def resolve_config_path(role: str, explicit_path: str = "") -> Path | None:
197
+ if role not in ROLE_CONFIG_FILES:
198
+ raise ValueError(f"Unknown AgentRemote config role: {role}")
199
+
200
+ explicit = explicit_path.strip()
201
+ if explicit:
202
+ return _existing_path(explicit, "explicit")
203
+
204
+ role_env = os.environ.get(ROLE_ENV_VARS[role], "").strip()
205
+ if role_env:
206
+ return _existing_path(role_env, ROLE_ENV_VARS[role])
207
+
208
+ shared_env = os.environ.get("AGENTREMOTE_CONFIG", "").strip()
209
+ if shared_env:
210
+ return _existing_path(shared_env, "AGENTREMOTE_CONFIG")
211
+
212
+ for candidate in _default_config_candidates(role):
213
+ if candidate.exists():
214
+ return candidate
215
+ return None
216
+
217
+
218
+ def _default_config_candidates(role: str) -> list[Path]:
219
+ filename = ROLE_CONFIG_FILES[role]
220
+ root = Path(__file__).resolve().parents[2]
221
+ cwd = Path.cwd()
222
+
223
+ role_dirs = {
224
+ "cli": ("remotecli", "config"),
225
+ "server": ("platform", "middleware", "config"),
226
+ "client": ("client", "config"),
227
+ }
228
+ role_dir = role_dirs[role]
229
+ candidates = [
230
+ cwd.joinpath(*role_dir, filename),
231
+ root.joinpath(*role_dir, filename),
232
+ ]
233
+
234
+ result: list[Path] = []
235
+ for candidate in candidates:
236
+ if candidate not in result:
237
+ result.append(candidate)
238
+ return result
239
+
240
+
241
+ def _existing_path(value: str, source: str) -> Path:
242
+ path = Path(value).expanduser()
243
+ if not path.exists():
244
+ raise FileNotFoundError(f"{source} config path not found: {path}")
245
+ return path
246
+
247
+
248
+ def _env_value(*names: str) -> str | None:
249
+ for name in names:
250
+ value = os.environ.get(name)
251
+ if value is not None and value.strip() != "":
252
+ return value.strip()
253
+ return None
254
+
255
+
256
+ def _section(config: Mapping[str, Any], name: str) -> Mapping[str, Any]:
257
+ value = config.get(name, {})
258
+ if isinstance(value, Mapping):
259
+ return value
260
+ return {}
261
+
262
+
263
+ def _config_value(config: Mapping[str, Any], section: str, key: str) -> Any:
264
+ return _section(config, section).get(key)
265
+
266
+
267
+ def _pick_str(
268
+ config: Mapping[str, Any],
269
+ section: str,
270
+ key: str,
271
+ default: str,
272
+ *env_names: str,
273
+ ) -> str:
274
+ env = _env_value(*env_names)
275
+ if env is not None:
276
+ return env
277
+ value = _config_value(config, section, key)
278
+ if value is None:
279
+ return default
280
+ return str(value)
281
+
282
+
283
+ def _pick_int(
284
+ config: Mapping[str, Any],
285
+ section: str,
286
+ key: str,
287
+ default: int,
288
+ *env_names: str,
289
+ ) -> int:
290
+ env = _env_value(*env_names)
291
+ if env is not None:
292
+ return int(env)
293
+ value = _config_value(config, section, key)
294
+ if value is None:
295
+ return default
296
+ return int(value)
297
+
298
+
299
+ def _pick_float(
300
+ config: Mapping[str, Any],
301
+ section: str,
302
+ key: str,
303
+ default: float,
304
+ *env_names: str,
305
+ ) -> float:
306
+ env = _env_value(*env_names)
307
+ if env is not None:
308
+ return float(env)
309
+ value = _config_value(config, section, key)
310
+ if value is None:
311
+ return default
312
+ return float(value)
313
+
314
+
315
+ def _pick_bool(
316
+ config: Mapping[str, Any],
317
+ section: str,
318
+ key: str,
319
+ default: bool,
320
+ *env_names: str,
321
+ ) -> bool:
322
+ env = _env_value(*env_names)
323
+ if env is not None:
324
+ return _to_bool(env)
325
+ value = _config_value(config, section, key)
326
+ if value is None:
327
+ return default
328
+ return _to_bool(value)
329
+
330
+
331
+ def _to_bool(value: Any) -> bool:
332
+ if isinstance(value, bool):
333
+ return value
334
+ if isinstance(value, int):
335
+ return bool(value)
336
+ lowered = str(value).strip().lower()
337
+ if lowered in TRUE_VALUES:
338
+ return True
339
+ if lowered in FALSE_VALUES:
340
+ return False
341
+ raise ValueError(f"Invalid boolean config value: {value!r}")
@@ -0,0 +1,69 @@
1
+ """
2
+ AgentRemote Crypto Layer — ECDSA + JWT + AES + SHA256
3
+
4
+ Provides all cryptographic primitives for the demo security chain:
5
+ - keys: ECDSA P-256 key generation, signing, verification
6
+ - certs: X.509 self-signed device certificates
7
+ - jwt: JWT (ES256) authentication tokens
8
+ - license: AES-256-GCM license encryption/decryption
9
+ - integrity: SHA256 file/content hashing
10
+ """
11
+
12
+ from .keys import (
13
+ generate_keypair,
14
+ sign_data,
15
+ verify_signature,
16
+ load_private_key,
17
+ load_public_key,
18
+ private_to_pem,
19
+ public_to_pem,
20
+ )
21
+ from .certificates import (
22
+ generate_device_cert,
23
+ load_device_cert,
24
+ verify_device_cert,
25
+ )
26
+ from .jwt_auth import (
27
+ create_auth_token,
28
+ verify_auth_token,
29
+ )
30
+ from .license import (
31
+ encrypt_license,
32
+ decrypt_license,
33
+ generate_license_key,
34
+ load_license_key,
35
+ )
36
+ from .integrity import (
37
+ hash_file,
38
+ hash_content,
39
+ hash_skill_directory,
40
+ verify_skill_integrity,
41
+ )
42
+
43
+ __all__ = [
44
+ # keys
45
+ "generate_keypair",
46
+ "sign_data",
47
+ "verify_signature",
48
+ "load_private_key",
49
+ "load_public_key",
50
+ "private_to_pem",
51
+ "public_to_pem",
52
+ # certs
53
+ "generate_device_cert",
54
+ "load_device_cert",
55
+ "verify_device_cert",
56
+ # jwt
57
+ "create_auth_token",
58
+ "verify_auth_token",
59
+ # license
60
+ "encrypt_license",
61
+ "decrypt_license",
62
+ "generate_license_key",
63
+ "load_license_key",
64
+ # integrity
65
+ "hash_file",
66
+ "hash_content",
67
+ "hash_skill_directory",
68
+ "verify_skill_integrity",
69
+ ]
@@ -0,0 +1,171 @@
1
+ """
2
+ X.509 设备证书管理
3
+
4
+ Demo 用自签名 CA 签发的设备证书。
5
+ - CA 根证书 (ca_public.pem) → Middleware 内置,验证设备证书
6
+ - 设备证书 (device_cert.pem + device_private.pem) → Plugin 持有
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ from pathlib import Path
13
+ from typing import Tuple
14
+
15
+ from cryptography import x509
16
+ from cryptography.x509.oid import NameOID
17
+ from cryptography.hazmat.primitives import hashes, serialization
18
+ from cryptography.hazmat.primitives.asymmetric import ec
19
+
20
+ from .keys import generate_keypair, private_to_pem, public_to_pem
21
+
22
+
23
+ def generate_ca() -> Tuple[ec.EllipticCurvePrivateKey, x509.Certificate]:
24
+ """
25
+ 生成自签名 CA 根证书。
26
+
27
+ Returns:
28
+ (ca_private_key, ca_certificate)
29
+ """
30
+ ca_private, ca_public = generate_keypair()
31
+
32
+ subject = issuer = x509.Name([
33
+ x509.NameAttribute(NameOID.COMMON_NAME, "AgentRemote Demo CA"),
34
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "AgentRemote Demo"),
35
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "CN"),
36
+ ])
37
+
38
+ ca_cert = (
39
+ x509.CertificateBuilder()
40
+ .subject_name(subject)
41
+ .issuer_name(issuer)
42
+ .public_key(ca_public)
43
+ .serial_number(x509.random_serial_number())
44
+ .not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=1))
45
+ .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) # 10 年 Demo CA
46
+ .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
47
+ .add_extension(x509.KeyUsage(
48
+ key_cert_sign=True,
49
+ crl_sign=True,
50
+ digital_signature=False,
51
+ content_commitment=False,
52
+ key_encipherment=False,
53
+ data_encipherment=False,
54
+ key_agreement=False,
55
+ encipher_only=False,
56
+ decipher_only=False,
57
+ ), critical=True)
58
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_public), critical=False)
59
+ .sign(ca_private, hashes.SHA256())
60
+ )
61
+
62
+ return ca_private, ca_cert
63
+
64
+
65
+ def generate_device_cert(
66
+ ca_private: ec.EllipticCurvePrivateKey,
67
+ ca_cert: x509.Certificate,
68
+ device_id: str = "sh_win_01",
69
+ tenant_id: str = "acme_shanghai",
70
+ ) -> Tuple[ec.EllipticCurvePrivateKey, x509.Certificate]:
71
+ """
72
+ 由 CA 签发设备证书。
73
+
74
+ Args:
75
+ ca_private: CA 私钥
76
+ ca_cert: CA 证书
77
+ device_id: 设备标识
78
+ tenant_id: 租户标识
79
+
80
+ Returns:
81
+ (device_private_key, device_certificate)
82
+ """
83
+ device_private, device_public = generate_keypair()
84
+ ca_public = ca_cert.public_key()
85
+
86
+ subject = x509.Name([
87
+ x509.NameAttribute(NameOID.COMMON_NAME, f"plugin-{device_id}"),
88
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, tenant_id),
89
+ x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"node:{device_id}"),
90
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "CN"),
91
+ ])
92
+
93
+ device_cert = (
94
+ x509.CertificateBuilder()
95
+ .subject_name(subject)
96
+ .issuer_name(ca_cert.subject)
97
+ .public_key(device_public)
98
+ .serial_number(x509.random_serial_number())
99
+ .not_valid_before(datetime.datetime.utcnow() - datetime.timedelta(days=1))
100
+ .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=365)) # 1 年设备证书
101
+ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
102
+ .add_extension(x509.KeyUsage(
103
+ digital_signature=True,
104
+ content_commitment=False,
105
+ key_encipherment=False,
106
+ data_encipherment=False,
107
+ key_agreement=False,
108
+ key_cert_sign=False,
109
+ crl_sign=False,
110
+ encipher_only=False,
111
+ decipher_only=False,
112
+ ), critical=True)
113
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(device_public), critical=False)
114
+ .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_public), critical=False)
115
+ .sign(ca_private, hashes.SHA256())
116
+ )
117
+
118
+ return device_private, device_cert
119
+
120
+
121
+ def load_device_cert(cert_path: str | Path) -> x509.Certificate:
122
+ """从 PEM 文件加载设备证书"""
123
+ data = Path(cert_path).read_bytes()
124
+ return x509.load_pem_x509_certificate(data)
125
+
126
+
127
+ def load_ca_public_key(ca_cert_path: str | Path) -> ec.EllipticCurvePublicKey:
128
+ """从 CA 证书中提取公钥(用于验证设备证书签名)"""
129
+ ca_cert = load_device_cert(ca_cert_path)
130
+ pk = ca_cert.public_key()
131
+ if not isinstance(pk, ec.EllipticCurvePublicKey):
132
+ raise TypeError("CA public key is not EC")
133
+ return pk
134
+
135
+
136
+ def extract_device_public_key(device_cert: x509.Certificate) -> ec.EllipticCurvePublicKey:
137
+ """从设备证书中提取公钥"""
138
+ pk = device_cert.public_key()
139
+ if not isinstance(pk, ec.EllipticCurvePublicKey):
140
+ raise TypeError("Device public key is not EC")
141
+ return pk
142
+
143
+
144
+ def verify_device_cert(
145
+ device_cert: x509.Certificate,
146
+ ca_public_key: ec.EllipticCurvePublicKey,
147
+ ) -> bool:
148
+ """
149
+ 验证设备证书是否由受信 CA 签发。
150
+
151
+ 同时检查:
152
+ - 证书签名(CA 公钥验证)
153
+ - 有效期
154
+ """
155
+ try:
156
+ # 验证签名 — CA 用 SHA256 签名设备证书
157
+ ca_public_key.verify(
158
+ device_cert.signature,
159
+ device_cert.tbs_certificate_bytes,
160
+ ec.ECDSA(hashes.SHA256()),
161
+ )
162
+ return True
163
+ except Exception as e:
164
+ import sys
165
+ print(f" [DEBUG] Cert verification error: {e}", file=sys.stderr)
166
+ return False
167
+
168
+
169
+ def cert_to_pem(cert: x509.Certificate) -> bytes:
170
+ """证书 → PEM 字节"""
171
+ return cert.public_bytes(serialization.Encoding.PEM)