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.
- agentremote_common/__init__.py +21 -0
- agentremote_common/config.py +341 -0
- agentremote_common/crypto/__init__.py +69 -0
- agentremote_common/crypto/certificates.py +171 -0
- agentremote_common/crypto/integrity.py +129 -0
- agentremote_common/crypto/jwt_auth.py +108 -0
- agentremote_common/crypto/keys.py +128 -0
- agentremote_common/crypto/license.py +157 -0
- agentremote_common/models/__init__.py +34 -0
- agentremote_common/models/capability.py +32 -0
- agentremote_common/models/license.py +50 -0
- agentremote_common/models/node.py +47 -0
- agentremote_common/models/task.py +53 -0
- agentremote_common/mqtt_client.py +435 -0
- agentremote_common/protocol/__init__.py +21 -0
- agentremote_common/protocol/message.py +53 -0
- agentremote_common/protocol/topics.py +73 -0
- agentremote_common-1.0.0.dist-info/METADATA +9 -0
- agentremote_common-1.0.0.dist-info/RECORD +21 -0
- agentremote_common-1.0.0.dist-info/WHEEL +5 -0
- agentremote_common-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SHA256 文件完整性校验
|
|
3
|
+
|
|
4
|
+
用于:
|
|
5
|
+
- Skill 包文件完整性验证(安装/加载前校验)
|
|
6
|
+
- Robot 文件防篡改检测(运行时校验)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def hash_content(content: bytes | str) -> str:
|
|
18
|
+
"""计算内容的 SHA256 hex digest"""
|
|
19
|
+
if isinstance(content, str):
|
|
20
|
+
content = content.encode("utf-8")
|
|
21
|
+
return hashlib.sha256(content).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_file(file_path: str | Path) -> str:
|
|
25
|
+
"""计算文件的 SHA256 hex digest"""
|
|
26
|
+
return hash_content(Path(file_path).read_bytes())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def hash_skill_directory(skill_dir: str | Path, exclude_self_yaml: bool = True) -> Dict[str, str]:
|
|
30
|
+
"""
|
|
31
|
+
计算 Skill 目录下所有文件的 SHA256。
|
|
32
|
+
|
|
33
|
+
递归扫描,排除 __pycache__、.git、*.pyc。
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
skill_dir: Skill 目录路径
|
|
37
|
+
exclude_self_yaml: 是否排除 skill.yaml(签名场景需要排除自身)
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
{relative_path: sha256_hex}
|
|
41
|
+
"""
|
|
42
|
+
skill_dir = Path(skill_dir).resolve()
|
|
43
|
+
hashes: Dict[str, str] = {}
|
|
44
|
+
|
|
45
|
+
for fpath in sorted(skill_dir.rglob("*")):
|
|
46
|
+
if not fpath.is_file():
|
|
47
|
+
continue
|
|
48
|
+
# 跳过无关文件
|
|
49
|
+
parts = set(fpath.parts)
|
|
50
|
+
if "__pycache__" in parts or ".git" in parts:
|
|
51
|
+
continue
|
|
52
|
+
if fpath.suffix == ".pyc":
|
|
53
|
+
continue
|
|
54
|
+
# 跳过签名文件自身
|
|
55
|
+
if fpath.name.startswith("."):
|
|
56
|
+
continue
|
|
57
|
+
# 跳过 skill.yaml 自身(避免鸡生蛋问题:签名写入会改变哈希)
|
|
58
|
+
if exclude_self_yaml and fpath.name == "skill.yaml":
|
|
59
|
+
continue
|
|
60
|
+
# 跳过 Robot Framework 运行产物(每次执行都会变化)
|
|
61
|
+
if fpath.name in ("log.html", "output.xml", "report.html", "@AutomationLog.txt"):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
rel = str(fpath.relative_to(skill_dir)).replace("\\", "/")
|
|
65
|
+
hashes[rel] = hash_file(fpath)
|
|
66
|
+
|
|
67
|
+
return hashes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def verify_skill_integrity(
|
|
71
|
+
skill_dir: str | Path,
|
|
72
|
+
expected_hashes: Dict[str, str],
|
|
73
|
+
strict: bool = True,
|
|
74
|
+
) -> Tuple[bool, List[str]]:
|
|
75
|
+
"""
|
|
76
|
+
验证 Skill 目录完整性。
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
skill_dir: Skill 目录路径
|
|
80
|
+
expected_hashes: {文件路径: sha256} 期望的哈希表
|
|
81
|
+
strict: True=拒绝未知文件(启动时校验), False=只检查已知文件(执行时校验)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
(passed, [错误信息列表])
|
|
85
|
+
"""
|
|
86
|
+
errors: List[str] = []
|
|
87
|
+
actual = hash_skill_directory(skill_dir)
|
|
88
|
+
|
|
89
|
+
# 检查期望文件是否存在且哈希一致
|
|
90
|
+
for rel_path, expected_hash in expected_hashes.items():
|
|
91
|
+
fpath = Path(skill_dir) / rel_path
|
|
92
|
+
if not fpath.exists():
|
|
93
|
+
errors.append(f"[MISSING] {rel_path}: 文件不存在或被删除")
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
actual_hash = actual.get(rel_path, "")
|
|
97
|
+
if actual_hash != expected_hash:
|
|
98
|
+
errors.append(
|
|
99
|
+
f"[TAMPERED] {rel_path}: 哈希不匹配 "
|
|
100
|
+
f"期望 {expected_hash[:16]}... 实际 {actual_hash[:16]}..."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# 检查是否有多余文件(仅 strict 模式)
|
|
104
|
+
if strict:
|
|
105
|
+
for rel_path in actual:
|
|
106
|
+
if rel_path not in expected_hashes:
|
|
107
|
+
errors.append(f"[EXTRA] {rel_path}: 未在签名清单中的文件")
|
|
108
|
+
|
|
109
|
+
return len(errors) == 0, errors
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def compute_manifest_hash(skill_dir: str | Path, exclude_signature: bool = True) -> str:
|
|
113
|
+
"""
|
|
114
|
+
计算 Skill 目录的内容哈希(用于签名)。
|
|
115
|
+
|
|
116
|
+
将所有文件按路径排序,拼接 SHA256 后再做一次 SHA256,
|
|
117
|
+
得到该目录的"内容指纹"。
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
skill_dir: Skill 目录
|
|
121
|
+
exclude_signature: 是否排除 _security 相关字段
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
内容指纹的 hex digest
|
|
125
|
+
"""
|
|
126
|
+
hashes = hash_skill_directory(skill_dir)
|
|
127
|
+
# 按路径排序拼接
|
|
128
|
+
combined = "".join(f"{k}:{v}" for k, v in sorted(hashes.items()))
|
|
129
|
+
return hash_content(combined)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWT 认证 — Plugin → Middleware 身份认证
|
|
3
|
+
|
|
4
|
+
Plugin 用设备私钥签 JWT(ES256),Middleware 用设备公钥验证。
|
|
5
|
+
|
|
6
|
+
JWT Payload:
|
|
7
|
+
{
|
|
8
|
+
"sub": "sh_win_01", # 设备 ID
|
|
9
|
+
"tenant": "acme_shanghai", # 租户
|
|
10
|
+
"iat": 1719323500, # 签发时间
|
|
11
|
+
"exp": 1719327100, # 过期时间(1h)
|
|
12
|
+
"jti": "random_nonce" # 防重放
|
|
13
|
+
}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
import jwt # PyJWT
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Token 有效期(Demo: 1 小时)
|
|
27
|
+
TOKEN_TTL_SEC = 3600
|
|
28
|
+
|
|
29
|
+
# JWT 算法
|
|
30
|
+
ALGORITHM = "ES256"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_auth_token(
|
|
34
|
+
device_private_key: ec.EllipticCurvePrivateKey,
|
|
35
|
+
device_id: str,
|
|
36
|
+
tenant_id: str,
|
|
37
|
+
ttl_sec: int = TOKEN_TTL_SEC,
|
|
38
|
+
extra_claims: Optional[Dict[str, Any]] = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""
|
|
41
|
+
创建设备认证 JWT。
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
device_private_key: 设备 ECDSA 私钥(P-256)
|
|
45
|
+
device_id: 设备标识 (对应 node_id)
|
|
46
|
+
tenant_id: 租户标识
|
|
47
|
+
ttl_sec: 有效期(秒)
|
|
48
|
+
extra_claims: 额外的 JWT claims
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
JWT 字符串
|
|
52
|
+
"""
|
|
53
|
+
now = int(time.time())
|
|
54
|
+
payload = {
|
|
55
|
+
"sub": device_id,
|
|
56
|
+
"tenant": tenant_id,
|
|
57
|
+
"iat": now,
|
|
58
|
+
"exp": now + ttl_sec,
|
|
59
|
+
"jti": uuid.uuid4().hex[:16],
|
|
60
|
+
}
|
|
61
|
+
if extra_claims:
|
|
62
|
+
payload.update(extra_claims)
|
|
63
|
+
|
|
64
|
+
return jwt.encode(payload, device_private_key, algorithm=ALGORITHM)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def verify_auth_token(
|
|
68
|
+
token: str,
|
|
69
|
+
device_public_key: ec.EllipticCurvePublicKey,
|
|
70
|
+
expected_device_id: Optional[str] = None,
|
|
71
|
+
expected_tenant: Optional[str] = None,
|
|
72
|
+
) -> Dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
验证设备认证 JWT。
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
token: JWT 字符串
|
|
78
|
+
device_public_key: 设备 ECDSA 公钥(P-256)
|
|
79
|
+
expected_device_id: 期望的设备 ID(可选,不传则跳过)
|
|
80
|
+
expected_tenant: 期望的租户(可选,不传则跳过)
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
解码后的 JWT payload
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
jwt.ExpiredSignatureError: Token 已过期
|
|
87
|
+
jwt.InvalidSignatureError: 签名无效
|
|
88
|
+
jwt.InvalidTokenError: Token 无效
|
|
89
|
+
ValueError: sub/tenant 不匹配
|
|
90
|
+
"""
|
|
91
|
+
payload = jwt.decode(
|
|
92
|
+
token,
|
|
93
|
+
device_public_key,
|
|
94
|
+
algorithms=[ALGORITHM],
|
|
95
|
+
options={"require": ["sub", "tenant", "iat", "exp", "jti"]},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if expected_device_id and payload.get("sub") != expected_device_id:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"JWT sub mismatch: expected '{expected_device_id}', got '{payload.get('sub')}'"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if expected_tenant and payload.get("tenant") != expected_tenant:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"JWT tenant mismatch: expected '{expected_tenant}', got '{payload.get('tenant')}'"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return payload
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ECDSA P-256 密钥管理 — 生成密钥对、签名、验签
|
|
3
|
+
|
|
4
|
+
用于:
|
|
5
|
+
- 开发者签名 Skill 包(私钥签名,Plugin 用公钥验证)
|
|
6
|
+
- 设备密钥(Plugin 用私钥签 JWT,Middleware 用公钥验证)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Tuple
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.utils import (
|
|
18
|
+
decode_dss_signature,
|
|
19
|
+
encode_dss_signature,
|
|
20
|
+
)
|
|
21
|
+
from cryptography.exceptions import InvalidSignature
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ═══════════════════════════════════════════
|
|
25
|
+
# 密钥生成
|
|
26
|
+
# ═══════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
def generate_keypair() -> Tuple[ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey]:
|
|
29
|
+
"""生成 ECDSA P-256 密钥对"""
|
|
30
|
+
private = ec.generate_private_key(ec.SECP256R1())
|
|
31
|
+
return private, private.public_key()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════
|
|
35
|
+
# PEM 序列化
|
|
36
|
+
# ═══════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
def private_to_pem(private_key: ec.EllipticCurvePrivateKey, password: bytes | None = None) -> bytes:
|
|
39
|
+
"""私钥 → PEM 字节"""
|
|
40
|
+
enc = serialization.BestAvailableEncryption(password) if password else serialization.NoEncryption()
|
|
41
|
+
return private_key.private_bytes(
|
|
42
|
+
encoding=serialization.Encoding.PEM,
|
|
43
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
44
|
+
encryption_algorithm=enc,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def public_to_pem(public_key: ec.EllipticCurvePublicKey) -> bytes:
|
|
49
|
+
"""公钥 → PEM 字节"""
|
|
50
|
+
return public_key.public_bytes(
|
|
51
|
+
encoding=serialization.Encoding.PEM,
|
|
52
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_private_key(pem_data: bytes, password: bytes | None = None) -> ec.EllipticCurvePrivateKey:
|
|
57
|
+
"""从 PEM 字节加载私钥"""
|
|
58
|
+
key = serialization.load_pem_private_key(pem_data, password=password)
|
|
59
|
+
if not isinstance(key, ec.EllipticCurvePrivateKey):
|
|
60
|
+
raise TypeError(f"Expected EC private key, got {type(key)}")
|
|
61
|
+
return key
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_public_key(pem_data: bytes) -> ec.EllipticCurvePublicKey:
|
|
65
|
+
"""从 PEM 字节加载公钥"""
|
|
66
|
+
key = serialization.load_pem_public_key(pem_data)
|
|
67
|
+
if not isinstance(key, ec.EllipticCurvePublicKey):
|
|
68
|
+
raise TypeError(f"Expected EC public key, got {type(key)}")
|
|
69
|
+
return key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_private_key_file(path: str | Path) -> ec.EllipticCurvePrivateKey:
|
|
73
|
+
"""从文件加载私钥"""
|
|
74
|
+
return load_private_key(Path(path).read_bytes())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_public_key_file(path: str | Path) -> ec.EllipticCurvePublicKey:
|
|
78
|
+
"""从文件加载公钥"""
|
|
79
|
+
return load_public_key(Path(path).read_bytes())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ═══════════════════════════════════════════
|
|
83
|
+
# 签名 / 验签
|
|
84
|
+
# ═══════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
def sign_data(private_key: ec.EllipticCurvePrivateKey, data: bytes) -> str:
|
|
87
|
+
"""
|
|
88
|
+
用 ECDSA P-256 + SHA256 签名数据。
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Base64 编码的签名(DER 格式)
|
|
92
|
+
"""
|
|
93
|
+
der_sig = private_key.sign(data, ec.ECDSA(hashes.SHA256()))
|
|
94
|
+
return base64.b64encode(der_sig).decode("ascii")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def verify_signature(public_key: ec.EllipticCurvePublicKey, data: bytes, signature_b64: str) -> bool:
|
|
98
|
+
"""
|
|
99
|
+
验证 ECDSA P-256 + SHA256 签名。
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
public_key: 签名者的公钥
|
|
103
|
+
data: 原始数据
|
|
104
|
+
signature_b64: Base64 编码的签名(DER 格式)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True 如果签名有效
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
der_sig = base64.b64decode(signature_b64)
|
|
111
|
+
public_key.verify(der_sig, data, ec.ECDSA(hashes.SHA256()))
|
|
112
|
+
return True
|
|
113
|
+
except (InvalidSignature, Exception):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sign_json(private_key: ec.EllipticCurvePrivateKey, data: dict) -> str:
|
|
118
|
+
"""对 JSON 可序列化的 dict 签名(先排序 key 再序列化)"""
|
|
119
|
+
import json
|
|
120
|
+
canonical = json.dumps(data, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
121
|
+
return sign_data(private_key, canonical)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def verify_json(public_key: ec.EllipticCurvePublicKey, data: dict, signature_b64: str) -> bool:
|
|
125
|
+
"""验证对 JSON dict 的签名"""
|
|
126
|
+
import json
|
|
127
|
+
canonical = json.dumps(data, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
128
|
+
return verify_signature(public_key, canonical, signature_b64)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AES-256-GCM 许可证加密/解密 — capability-first.
|
|
3
|
+
|
|
4
|
+
许可证文件包含:
|
|
5
|
+
{
|
|
6
|
+
"tenant_id": "acme_shanghai",
|
|
7
|
+
"capability_id": "desktop.cua",
|
|
8
|
+
"status": "active",
|
|
9
|
+
"max_tasks": 0,
|
|
10
|
+
"task_count": 0,
|
|
11
|
+
"issued_at": "2026-06-25T00:00:00",
|
|
12
|
+
"expires_at": "2026-12-25T00:00:00",
|
|
13
|
+
"node_ids": ["sh_win_01"]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
加密: AES-256-GCM, 随机 IV (12 bytes), 认证标签 (16 bytes)
|
|
17
|
+
格式: base64(iv + ciphertext + tag)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, List
|
|
29
|
+
|
|
30
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ═══════════════════════════════════════════
|
|
34
|
+
# 数据模型
|
|
35
|
+
# ═══════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class LicenseData:
|
|
39
|
+
"""许可证数据 — capability-first."""
|
|
40
|
+
tenant_id: str
|
|
41
|
+
capability_id: str # ★ 替代 skill_name
|
|
42
|
+
status: str = "active"
|
|
43
|
+
max_tasks: int = 0
|
|
44
|
+
task_count: int = 0
|
|
45
|
+
issued_at: str = ""
|
|
46
|
+
expires_at: str = ""
|
|
47
|
+
node_ids: List[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
50
|
+
return {
|
|
51
|
+
"tenant_id": self.tenant_id,
|
|
52
|
+
"capability_id": self.capability_id,
|
|
53
|
+
"status": self.status,
|
|
54
|
+
"max_tasks": self.max_tasks,
|
|
55
|
+
"task_count": self.task_count,
|
|
56
|
+
"issued_at": self.issued_at,
|
|
57
|
+
"expires_at": self.expires_at,
|
|
58
|
+
"node_ids": self.node_ids,
|
|
59
|
+
"version": 2,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, d: Dict[str, Any]) -> "LicenseData":
|
|
64
|
+
# Support both old (skill_name) and new (capability_id) format
|
|
65
|
+
cap_id = d.get("capability_id", d.get("skill_name", ""))
|
|
66
|
+
return cls(
|
|
67
|
+
tenant_id=d["tenant_id"],
|
|
68
|
+
capability_id=cap_id,
|
|
69
|
+
status=d.get("status", "active"),
|
|
70
|
+
max_tasks=d.get("max_tasks", 0),
|
|
71
|
+
task_count=d.get("task_count", 0),
|
|
72
|
+
issued_at=d.get("issued_at", ""),
|
|
73
|
+
expires_at=d.get("expires_at", ""),
|
|
74
|
+
node_ids=d.get("node_ids", []),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def is_expired(self) -> bool:
|
|
78
|
+
if not self.expires_at:
|
|
79
|
+
return False
|
|
80
|
+
try:
|
|
81
|
+
exp = datetime.fromisoformat(self.expires_at)
|
|
82
|
+
return datetime.utcnow() > exp
|
|
83
|
+
except ValueError:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def is_valid_for_node(self, node_id: str) -> bool:
|
|
87
|
+
if not self.node_ids:
|
|
88
|
+
return True
|
|
89
|
+
return node_id in self.node_ids
|
|
90
|
+
|
|
91
|
+
def can_execute(self) -> bool:
|
|
92
|
+
if self.status == "expired":
|
|
93
|
+
return False
|
|
94
|
+
if self.is_expired():
|
|
95
|
+
return False
|
|
96
|
+
if self.status == "trial" and self.max_tasks > 0 and self.task_count >= self.max_tasks:
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ═══════════════════════════════════════════
|
|
102
|
+
# 密钥管理
|
|
103
|
+
# ═══════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
def generate_license_key() -> bytes:
|
|
106
|
+
return AESGCM.generate_key(bit_length=256)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_license_key(path: str | Path) -> bytes:
|
|
110
|
+
return Path(path).read_bytes()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def save_license_key(path: str | Path, key: bytes) -> None:
|
|
114
|
+
Path(path).write_bytes(key)
|
|
115
|
+
try:
|
|
116
|
+
os.chmod(path, 0o600)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ═══════════════════════════════════════════
|
|
122
|
+
# 加密 / 解密
|
|
123
|
+
# ═══════════════════════════════════════════
|
|
124
|
+
|
|
125
|
+
def encrypt_license(license_data: LicenseData, key: bytes) -> str:
|
|
126
|
+
aesgcm = AESGCM(key)
|
|
127
|
+
plaintext = json.dumps(license_data.to_dict(), ensure_ascii=False).encode("utf-8")
|
|
128
|
+
nonce = os.urandom(12)
|
|
129
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
|
130
|
+
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def decrypt_license(encrypted: str, key: bytes) -> LicenseData:
|
|
134
|
+
aesgcm = AESGCM(key)
|
|
135
|
+
raw = base64.b64decode(encrypted)
|
|
136
|
+
nonce = raw[:12]
|
|
137
|
+
ciphertext = raw[12:]
|
|
138
|
+
try:
|
|
139
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
raise ValueError(f"License decryption failed: {e}") from e
|
|
142
|
+
data = json.loads(plaintext.decode("utf-8"))
|
|
143
|
+
return LicenseData.from_dict(data)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ═══════════════════════════════════════════
|
|
147
|
+
# 文件操作
|
|
148
|
+
# ═══════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
def encrypt_license_file(license_data: LicenseData, key: bytes, output_path: str | Path) -> None:
|
|
151
|
+
encrypted = encrypt_license(license_data, key)
|
|
152
|
+
Path(output_path).write_text(encrypted, encoding="ascii")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decrypt_license_file(file_path: str | Path, key: bytes) -> LicenseData:
|
|
156
|
+
encrypted = Path(file_path).read_text(encoding="ascii").strip()
|
|
157
|
+
return decrypt_license(encrypted, key)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""AgentRemote data models — re-exported by domain."""
|
|
2
|
+
|
|
3
|
+
from .capability import CapabilityManifest, FlowManifest
|
|
4
|
+
from .license import GatewayCheckResult, License, LicenseInfo, LicenseStatus
|
|
5
|
+
from .node import BackendStatus, Node, NodeStatus, RuntimeBackend
|
|
6
|
+
from .task import RunnerType, Task, TaskResult, TaskStatus
|
|
7
|
+
|
|
8
|
+
# 向后兼容 — 协议模型原在 models.py,现从 protocol 子包重导出
|
|
9
|
+
from ..protocol.message import MqttConfig, WSMessage, WSMessageType
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
# task
|
|
13
|
+
"RunnerType",
|
|
14
|
+
"TaskStatus",
|
|
15
|
+
"Task",
|
|
16
|
+
"TaskResult",
|
|
17
|
+
# capability
|
|
18
|
+
"CapabilityManifest",
|
|
19
|
+
"FlowManifest",
|
|
20
|
+
# node
|
|
21
|
+
"BackendStatus",
|
|
22
|
+
"NodeStatus",
|
|
23
|
+
"RuntimeBackend",
|
|
24
|
+
"Node",
|
|
25
|
+
# license
|
|
26
|
+
"LicenseStatus",
|
|
27
|
+
"LicenseInfo",
|
|
28
|
+
"License",
|
|
29
|
+
"GatewayCheckResult",
|
|
30
|
+
# protocol (backward compat — 原在 models.py)
|
|
31
|
+
"MqttConfig",
|
|
32
|
+
"WSMessage",
|
|
33
|
+
"WSMessageType",
|
|
34
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentRemote — 能力注册数据模型.
|
|
3
|
+
|
|
4
|
+
CapabilityManifest — 替代旧的 SkillManifest + Capability.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CapabilityManifest(BaseModel):
|
|
15
|
+
"""节点上报的能力清单(替代 SkillManifest)"""
|
|
16
|
+
id: str # "desktop.cua" | "crm-transaction-filter"
|
|
17
|
+
runner: str # "cua" | "rpa" — Middleware 用此字段路由
|
|
18
|
+
tools: List[str] = Field(default_factory=list) # ["click","type_text","list_apps",...]
|
|
19
|
+
description: str = ""
|
|
20
|
+
version: str = "1.0.0"
|
|
21
|
+
metadata: Dict[str, Any] = Field(default_factory=dict) # 预留扩展
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FlowManifest(BaseModel):
|
|
25
|
+
"""RPA 流程清单(替代旧 skill.yaml 中 runner=rpa 的部分)"""
|
|
26
|
+
id: str # "crm-transaction-filter"
|
|
27
|
+
name: str = ""
|
|
28
|
+
runner: str = "rpa"
|
|
29
|
+
robot_file: str = "" # 相对路径, e.g. "crm_transaction_date_filter.robot"
|
|
30
|
+
tasks: List[Dict[str, str]] = Field(default_factory=list) # [{id:"01", name:"..."}]
|
|
31
|
+
description: str = ""
|
|
32
|
+
version: str = "1.0.0"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentRemote — 许可证和三道校验数据模型.
|
|
3
|
+
|
|
4
|
+
LicenseStatus, LicenseInfo, License, GatewayCheckResult.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LicenseStatus(str, Enum):
|
|
17
|
+
TRIAL = "trial"
|
|
18
|
+
ACTIVE = "active"
|
|
19
|
+
EXPIRED = "expired"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LicenseInfo(BaseModel):
|
|
23
|
+
"""解密后的许可证信息"""
|
|
24
|
+
tenant_id: str
|
|
25
|
+
capability_id: str # 授权的能力 ID
|
|
26
|
+
status: str = "active" # trial | active | expired
|
|
27
|
+
max_tasks: int = 0 # 0 = unlimited
|
|
28
|
+
task_count: int = 0
|
|
29
|
+
issued_at: str = ""
|
|
30
|
+
expires_at: str = ""
|
|
31
|
+
node_ids: List[str] = Field(default_factory=list)
|
|
32
|
+
valid: bool = True # 许可证是否有效
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class License(BaseModel):
|
|
36
|
+
"""许可证"""
|
|
37
|
+
tenant_id: str
|
|
38
|
+
capability_id: str # 授权的能力 ID
|
|
39
|
+
status: LicenseStatus = LicenseStatus.TRIAL
|
|
40
|
+
max_tasks: int = 10
|
|
41
|
+
task_count: int = 0
|
|
42
|
+
expires_at: Optional[datetime] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GatewayCheckResult(BaseModel):
|
|
46
|
+
"""三道校验结果"""
|
|
47
|
+
passed: bool
|
|
48
|
+
check_name: str # "身份" | "能力" | "许可证"
|
|
49
|
+
detail: str = ""
|
|
50
|
+
rejected_reason: str = ""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentRemote — 节点和运行时后端数据模型.
|
|
3
|
+
|
|
4
|
+
Node, RuntimeBackend, BackendStatus, NodeStatus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BackendStatus(str, Enum):
|
|
16
|
+
READY = "ready"
|
|
17
|
+
DEGRADED = "degraded"
|
|
18
|
+
UNAVAILABLE = "unavailable"
|
|
19
|
+
LIMITED = "limited"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NodeStatus(str, Enum):
|
|
23
|
+
IDLE = "IDLE"
|
|
24
|
+
BUSY = "BUSY"
|
|
25
|
+
SESSION = "SESSION"
|
|
26
|
+
FDE_DEBUG = "FDE_DEBUG"
|
|
27
|
+
OFFLINE = "OFFLINE"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RuntimeBackend(BaseModel):
|
|
31
|
+
"""Plugin 本地的运行时后端(Playwright / Shell / CUA / Native)"""
|
|
32
|
+
backend_name: str # playwright | shell | cua | native
|
|
33
|
+
status: BackendStatus = BackendStatus.READY
|
|
34
|
+
version: Optional[str] = None
|
|
35
|
+
platform: Optional[str] = None
|
|
36
|
+
capabilities: List[str] = Field(default_factory=list)
|
|
37
|
+
limitations: List[str] = Field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Node(BaseModel):
|
|
41
|
+
"""节点(一台客户电脑)"""
|
|
42
|
+
node_id: str # "sh_win_01"
|
|
43
|
+
tenant_id: str # "acme_shanghai"
|
|
44
|
+
hostname: str = ""
|
|
45
|
+
os: str = ""
|
|
46
|
+
status: NodeStatus = NodeStatus.IDLE
|
|
47
|
+
plugin_version: str = "v0.3.0"
|