1274 lines
46 KiB
Python
1274 lines
46 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ctypes
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import struct
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, BinaryIO, Callable
|
|
|
|
PROTOCOL_VERSION = "1.0"
|
|
MAX_MESSAGE_BYTES = 1024 * 1024
|
|
SAFE_METHODS = frozenset({"health", "capabilities.list", "context.get", "command.plan"})
|
|
SAFE_COMMAND = re.compile(r"^[a-zA-Z0-9_.:-]{1,128}$")
|
|
SAFE_PIPE = re.compile(r"^lserp\.agent\.([0-9]{1,10})\.([a-f0-9]{32})$")
|
|
SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z0-9_.:-]{8,128}$")
|
|
SAFE_BRIDGE_INSTANCE_ID = re.compile(r"^[a-f0-9]{32}$")
|
|
SAFE_SESSION_SCOPE_TOKEN = re.compile(r"^[a-f0-9]{32}$")
|
|
SAFE_CODE = re.compile(r"^[a-zA-Z0-9_.:-]{1,128}$")
|
|
SAFE_HASH = re.compile(r"^[a-f0-9]{64}$")
|
|
SAFE_VERSION = re.compile(r"^[0-9]+(?:\.[0-9]+){1,3}$")
|
|
PLAN_PROJECTION_FIELDS = frozenset(
|
|
{
|
|
"planId",
|
|
"commandName",
|
|
"commandVersion",
|
|
"moduleCode",
|
|
"risk",
|
|
"createdAtUtc",
|
|
"expiresAtUtc",
|
|
"valid",
|
|
"executionAllowed",
|
|
"inputFingerprint",
|
|
"outcomeCode",
|
|
"title",
|
|
"preview",
|
|
"data",
|
|
"warnings",
|
|
}
|
|
)
|
|
PLAN_RISKS = frozenset({"read", "navigate", "draft", "write", "critical"})
|
|
EXECUTABLE_PLAN_RISKS = frozenset({"navigate", "write", "critical"})
|
|
CONTEXT_RESPONSE_FIELDS = frozenset(
|
|
{
|
|
"userId",
|
|
"userName",
|
|
"accountBook",
|
|
"subSystemId",
|
|
"databaseScopeFingerprint",
|
|
"subSystemName",
|
|
"isAdministrator",
|
|
"activeModule",
|
|
"openModuleCount",
|
|
"openModulesTruncated",
|
|
"openModules",
|
|
}
|
|
)
|
|
CONTEXT_MODULE_FIELDS = frozenset(
|
|
{"moduleCode", "navigationCode", "moduleName"}
|
|
)
|
|
CONTEXT_MAX_OPEN_MODULES = 50
|
|
CAPABILITIES_RESPONSE_FIELDS = frozenset({"commands"})
|
|
CAPABILITY_DESCRIPTOR_FIELDS = frozenset(
|
|
{
|
|
"name",
|
|
"version",
|
|
"description",
|
|
"schemaVersion",
|
|
"inputSchema",
|
|
"risk",
|
|
"requiresConfirmation",
|
|
"requiresIdempotencyKey",
|
|
}
|
|
)
|
|
CAPABILITIES_MAX_COMMANDS = 128
|
|
SCHEMA_MAX_DEPTH = 8
|
|
SCHEMA_MAX_NODES = 512
|
|
SCHEMA_MAX_PROPERTIES = 128
|
|
SAFE_SCHEMA_PROPERTY = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,63}$")
|
|
ALLOWED_RESPONSE_FIELDS = frozenset(
|
|
{
|
|
"protocolVersion",
|
|
"requestId",
|
|
"correlationId",
|
|
"success",
|
|
"code",
|
|
"message",
|
|
"data",
|
|
}
|
|
)
|
|
ALLOWED_DISCOVERY_FIELDS = frozenset(
|
|
{
|
|
"protocolVersion",
|
|
"pipeName",
|
|
"processId",
|
|
"startedAtUtc",
|
|
"bridgeInstanceId",
|
|
}
|
|
)
|
|
DISCOVERY_MAX_BYTES = 64 * 1024
|
|
MAX_BOUND_BRIDGE_INSTANCES = 128
|
|
MAX_PROCESS_START_DRIFT_SECONDS = 1.0
|
|
_WINDOWS_EPOCH_FILETIME = 116_444_736_000_000_000
|
|
_PIPE_CLOSED_ERRORS = frozenset({6, 109, 232, 233})
|
|
|
|
|
|
class ErpBridgeError(RuntimeError):
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ErpBridgeDiscovery:
|
|
pipe_name: str
|
|
process_id: int
|
|
started_at_utc: datetime
|
|
bridge_instance_id: str
|
|
|
|
|
|
def _strict_json_loads(text: str, label: str) -> Any:
|
|
if not isinstance(text, str) or not text.strip():
|
|
raise ErpBridgeError("bridge_protocol_error", f"{label}不能为空。")
|
|
|
|
def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key, value in pairs:
|
|
if key in result:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"{label}包含重复 JSON 字段。"
|
|
)
|
|
result[key] = value
|
|
return result
|
|
|
|
def reject_non_finite_number(_value: str) -> None:
|
|
raise ValueError("non-finite JSON number")
|
|
|
|
try:
|
|
return json.loads(
|
|
text,
|
|
object_pairs_hook=reject_duplicate_keys,
|
|
parse_constant=reject_non_finite_number,
|
|
)
|
|
except ErpBridgeError:
|
|
raise
|
|
except (json.JSONDecodeError, RecursionError, ValueError) as error:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"{label}不是严格 JSON。"
|
|
) from error
|
|
|
|
|
|
def _required_string(
|
|
source: dict[str, Any], name: str, maximum_length: int
|
|
) -> str:
|
|
value = source.get(name)
|
|
if (
|
|
not isinstance(value, str)
|
|
or not value.strip()
|
|
or len(value) > maximum_length
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 桥消息字段格式无效:{name}。"
|
|
)
|
|
return value
|
|
|
|
|
|
def parse_response(
|
|
text: str, expected_request_id: str, expected_correlation_id: str
|
|
) -> dict[str, Any]:
|
|
response = _strict_json_loads(text, "ERP 桥响应")
|
|
if not isinstance(response, dict):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应必须是 JSON 对象。")
|
|
if not set(response).issubset(ALLOWED_RESPONSE_FIELDS):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应包含未知字段。")
|
|
|
|
protocol = _required_string(response, "protocolVersion", 16)
|
|
request_id = _required_string(response, "requestId", 128)
|
|
correlation_id = _required_string(response, "correlationId", 128)
|
|
code = _required_string(response, "code", 128)
|
|
success = response.get("success")
|
|
data = response.get("data")
|
|
message = response.get("message")
|
|
if (
|
|
protocol != PROTOCOL_VERSION
|
|
or SAFE_IDENTIFIER.fullmatch(request_id) is None
|
|
or SAFE_IDENTIFIER.fullmatch(correlation_id) is None
|
|
or SAFE_CODE.fullmatch(code) is None
|
|
or request_id != expected_request_id
|
|
or correlation_id != expected_correlation_id
|
|
or not isinstance(success, bool)
|
|
or not isinstance(data, dict)
|
|
or (message is not None and not isinstance(message, str))
|
|
or (isinstance(message, str) and len(message) > 2000)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 桥返回了无效或不匹配的协议消息。"
|
|
)
|
|
if not success:
|
|
raise ErpBridgeError(code, message or "ERP 桥调用失败。")
|
|
return data
|
|
|
|
|
|
def _parse_plan_utc(value: Any, name: str) -> datetime:
|
|
if not isinstance(value, str) or not value or len(value) > 64:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 计划时间字段格式无效:{name}。"
|
|
)
|
|
try:
|
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
parsed = datetime.fromisoformat(normalized)
|
|
except ValueError as error:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 计划时间字段格式无效:{name}。"
|
|
) from error
|
|
if parsed.tzinfo is None:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 计划时间字段必须包含时区:{name}。"
|
|
)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _validate_plan_projection(plan: Any) -> dict[str, Any]:
|
|
"""Validate the exact ERP-to-AstrBot plan projection for protocol 1.0."""
|
|
if not isinstance(plan, dict) or set(plan) != PLAN_PROJECTION_FIELDS:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 计划字段不完整或包含未知字段。"
|
|
)
|
|
|
|
plan_id = plan.get("planId")
|
|
command = plan.get("commandName")
|
|
version = plan.get("commandVersion")
|
|
module_code = plan.get("moduleCode")
|
|
risk = plan.get("risk")
|
|
fingerprint = plan.get("inputFingerprint")
|
|
outcome_code = plan.get("outcomeCode")
|
|
valid = plan.get("valid")
|
|
execution_allowed = plan.get("executionAllowed")
|
|
title = plan.get("title")
|
|
preview = plan.get("preview")
|
|
data = plan.get("data")
|
|
warnings = plan.get("warnings")
|
|
|
|
if (
|
|
not isinstance(plan_id, str)
|
|
or re.fullmatch(r"[A-Fa-f0-9]{32}", plan_id) is None
|
|
or not isinstance(command, str)
|
|
or SAFE_COMMAND.fullmatch(command) is None
|
|
or not isinstance(version, str)
|
|
or SAFE_VERSION.fullmatch(version) is None
|
|
or not isinstance(module_code, str)
|
|
or SAFE_CODE.fullmatch(module_code) is None
|
|
or not isinstance(risk, str)
|
|
or risk not in PLAN_RISKS
|
|
or not isinstance(fingerprint, str)
|
|
or SAFE_HASH.fullmatch(fingerprint) is None
|
|
or not isinstance(outcome_code, str)
|
|
or SAFE_CODE.fullmatch(outcome_code) is None
|
|
or type(valid) is not bool
|
|
or type(execution_allowed) is not bool
|
|
or execution_allowed != (valid and risk in EXECUTABLE_PLAN_RISKS)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 计划标识、版本、风险或执行语义无效。"
|
|
)
|
|
|
|
created_at = _parse_plan_utc(plan.get("createdAtUtc"), "createdAtUtc")
|
|
expires_at = _parse_plan_utc(plan.get("expiresAtUtc"), "expiresAtUtc")
|
|
lifetime_seconds = (expires_at - created_at).total_seconds()
|
|
if lifetime_seconds <= 0 or lifetime_seconds > 15 * 60:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 计划有效期必须大于 0 且不超过 15 分钟。"
|
|
)
|
|
|
|
if title is not None and (
|
|
not isinstance(title, str)
|
|
or not title.strip()
|
|
or len(title) > 500
|
|
or any(ord(character) < 32 for character in title)
|
|
):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 计划标题格式无效。")
|
|
if preview is not None and not isinstance(preview, dict):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 计划预览必须是对象或 null。")
|
|
if not isinstance(data, dict):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 计划 data 必须是对象。")
|
|
if (
|
|
not isinstance(warnings, list)
|
|
or len(warnings) > 64
|
|
or any(
|
|
not isinstance(item, str)
|
|
or not item.strip()
|
|
or len(item) > 1000
|
|
or any(ord(character) < 32 for character in item)
|
|
for item in warnings
|
|
)
|
|
):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 计划警告列表格式无效。")
|
|
|
|
if title != data.get("title") or preview != data.get("preview"):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 计划展示字段与服务端 data 投影不一致。"
|
|
)
|
|
data_outcome = data.get("outcomeCode")
|
|
if data_outcome is not None and (
|
|
not isinstance(data_outcome, str)
|
|
or SAFE_CODE.fullmatch(data_outcome) is None
|
|
):
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 计划结果码格式无效。")
|
|
expected_outcome = (
|
|
data_outcome if data_outcome is not None else "plan_ready" if valid else "plan_invalid"
|
|
)
|
|
if outcome_code != expected_outcome:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 计划结果码与服务端 data 投影不一致。"
|
|
)
|
|
return plan
|
|
|
|
|
|
def _validate_context_text(value: Any, name: str, maximum_length: int) -> str:
|
|
if (
|
|
not isinstance(value, str)
|
|
or not value.strip()
|
|
or len(value) > maximum_length
|
|
or any(ord(character) < 32 for character in value)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 上下文字段格式无效:{name}。"
|
|
)
|
|
return value
|
|
|
|
|
|
def _validate_context_code(value: Any, name: str) -> str:
|
|
text = _validate_context_text(value, name, 128)
|
|
if SAFE_CODE.fullmatch(text) is None:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 上下文标识格式无效:{name}。"
|
|
)
|
|
return text
|
|
|
|
|
|
def _validate_context_module(value: Any, name: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict) or set(value) != CONTEXT_MODULE_FIELDS:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 上下文模块字段无效:{name}。"
|
|
)
|
|
_validate_context_code(value.get("moduleCode"), f"{name}.moduleCode")
|
|
_validate_context_code(
|
|
value.get("navigationCode"), f"{name}.navigationCode"
|
|
)
|
|
_validate_context_text(value.get("moduleName"), f"{name}.moduleName", 500)
|
|
return value
|
|
|
|
|
|
def _validate_context_projection(data: Any) -> dict[str, Any]:
|
|
"""Validate the exact ERP context before exposing it to the model."""
|
|
if not isinstance(data, dict) or set(data) != CONTEXT_RESPONSE_FIELDS:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 上下文字段不完整或包含未知字段。"
|
|
)
|
|
|
|
_validate_context_code(data.get("userId"), "userId")
|
|
_validate_context_text(data.get("userName"), "userName", 500)
|
|
_validate_context_text(data.get("accountBook"), "accountBook", 500)
|
|
_validate_context_code(data.get("subSystemId"), "subSystemId")
|
|
database_scope = _validate_context_text(
|
|
data.get("databaseScopeFingerprint"),
|
|
"databaseScopeFingerprint",
|
|
64,
|
|
)
|
|
if SAFE_HASH.fullmatch(database_scope) is None:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 数据库作用域指纹格式无效。"
|
|
)
|
|
_validate_context_text(data.get("subSystemName"), "subSystemName", 500)
|
|
if type(data.get("isAdministrator")) is not bool:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 上下文管理员标识格式无效。"
|
|
)
|
|
|
|
open_count = data.get("openModuleCount")
|
|
truncated = data.get("openModulesTruncated")
|
|
open_modules = data.get("openModules")
|
|
if (
|
|
not isinstance(open_count, int)
|
|
or isinstance(open_count, bool)
|
|
or open_count < 0
|
|
or open_count > 100_000
|
|
or type(truncated) is not bool
|
|
or not isinstance(open_modules, list)
|
|
or len(open_modules) > CONTEXT_MAX_OPEN_MODULES
|
|
or open_count < len(open_modules)
|
|
or truncated != (open_count > len(open_modules))
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 上下文打开模块统计不一致。"
|
|
)
|
|
|
|
validated_open_modules = [
|
|
_validate_context_module(item, f"openModules[{index}]")
|
|
for index, item in enumerate(open_modules)
|
|
]
|
|
active = data.get("activeModule")
|
|
if active is not None:
|
|
validated_active = _validate_context_module(active, "activeModule")
|
|
if open_count == 0 or (
|
|
not truncated and validated_active not in validated_open_modules
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 活动模块与已打开模块不一致。"
|
|
)
|
|
return data
|
|
|
|
|
|
def _validate_capability_text(value: Any, name: str, maximum_length: int) -> str:
|
|
if (
|
|
not isinstance(value, str)
|
|
or not value.strip()
|
|
or len(value) > maximum_length
|
|
or any(ord(character) < 32 for character in value)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 能力字段格式无效:{name}。"
|
|
)
|
|
return value
|
|
|
|
|
|
def _validate_schema_integer(
|
|
value: Any,
|
|
name: str,
|
|
minimum: int,
|
|
maximum: int,
|
|
) -> int:
|
|
if (
|
|
not isinstance(value, int)
|
|
or isinstance(value, bool)
|
|
or value < minimum
|
|
or value > maximum
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 输入 Schema 约束无效:{name}。"
|
|
)
|
|
return value
|
|
|
|
|
|
def _validate_schema_number(value: Any, name: str) -> float | int:
|
|
try:
|
|
numeric = float(value)
|
|
except (OverflowError, TypeError, ValueError):
|
|
numeric = math.nan
|
|
if (
|
|
not isinstance(value, (int, float))
|
|
or isinstance(value, bool)
|
|
or not math.isfinite(numeric)
|
|
or not (-1e18 <= numeric <= 1e18)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 输入 Schema 数值约束无效:{name}。"
|
|
)
|
|
return value
|
|
|
|
|
|
def _validate_input_schema(
|
|
schema: Any,
|
|
name: str,
|
|
depth: int = 0,
|
|
node_budget: list[int] | None = None,
|
|
) -> dict[str, Any]:
|
|
if node_budget is None:
|
|
node_budget = [0]
|
|
node_budget[0] += 1
|
|
if (
|
|
depth > SCHEMA_MAX_DEPTH
|
|
or node_budget[0] > SCHEMA_MAX_NODES
|
|
or not isinstance(schema, dict)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 输入 Schema 结构超出安全边界。"
|
|
)
|
|
|
|
schema_type = schema.get("type")
|
|
if schema_type == "object":
|
|
if set(schema) != {
|
|
"type",
|
|
"properties",
|
|
"required",
|
|
"additionalProperties",
|
|
}:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 对象 Schema 字段无效:{name}。"
|
|
)
|
|
properties = schema.get("properties")
|
|
required = schema.get("required")
|
|
if (
|
|
not isinstance(properties, dict)
|
|
or len(properties) > SCHEMA_MAX_PROPERTIES
|
|
or not isinstance(required, list)
|
|
or len(required) > len(properties)
|
|
or schema.get("additionalProperties") is not False
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 对象 Schema 约束无效:{name}。"
|
|
)
|
|
property_names = list(properties)
|
|
if any(
|
|
not isinstance(item, str)
|
|
or SAFE_SCHEMA_PROPERTY.fullmatch(item) is None
|
|
for item in property_names
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 对象 Schema 属性名无效:{name}。"
|
|
)
|
|
if (
|
|
any(not isinstance(item, str) for item in required)
|
|
or len(set(required)) != len(required)
|
|
or any(item not in properties for item in required)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 对象 Schema 必填字段无效:{name}。"
|
|
)
|
|
for property_name, child in properties.items():
|
|
_validate_input_schema(
|
|
child,
|
|
f"{name}.properties.{property_name}",
|
|
depth + 1,
|
|
node_budget,
|
|
)
|
|
return schema
|
|
|
|
if schema_type == "string":
|
|
_validate_capability_text(
|
|
schema.get("description"), f"{name}.description", 1000
|
|
)
|
|
allowed = {"type", "description", "minLength", "maxLength", "format"}
|
|
if not set(schema).issubset(allowed):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 字符串 Schema 字段无效:{name}。"
|
|
)
|
|
minimum = (
|
|
_validate_schema_integer(schema["minLength"], f"{name}.minLength", 0, 1_000_000)
|
|
if "minLength" in schema
|
|
else 0
|
|
)
|
|
maximum = (
|
|
_validate_schema_integer(schema["maxLength"], f"{name}.maxLength", 0, 1_000_000)
|
|
if "maxLength" in schema
|
|
else None
|
|
)
|
|
if maximum is not None and maximum < minimum:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 字符串 Schema 长度无效:{name}。"
|
|
)
|
|
if "format" in schema and schema.get("format") not in {
|
|
"date",
|
|
"date-time",
|
|
"local-date-time",
|
|
}:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 字符串 Schema 格式无效:{name}。"
|
|
)
|
|
return schema
|
|
|
|
if schema_type == "number":
|
|
_validate_capability_text(
|
|
schema.get("description"), f"{name}.description", 1000
|
|
)
|
|
if not set(schema).issubset(
|
|
{"type", "description", "minimum", "maximum"}
|
|
) or "minimum" not in schema:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 数值 Schema 字段无效:{name}。"
|
|
)
|
|
minimum_number = _validate_schema_number(
|
|
schema.get("minimum"), f"{name}.minimum"
|
|
)
|
|
maximum_number = (
|
|
_validate_schema_number(schema.get("maximum"), f"{name}.maximum")
|
|
if "maximum" in schema
|
|
else None
|
|
)
|
|
if maximum_number is not None and maximum_number < minimum_number:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 数值 Schema 范围无效:{name}。"
|
|
)
|
|
return schema
|
|
|
|
if schema_type == "boolean":
|
|
_validate_capability_text(
|
|
schema.get("description"), f"{name}.description", 1000
|
|
)
|
|
if set(schema) != {"type", "description"}:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 布尔 Schema 字段无效:{name}。"
|
|
)
|
|
return schema
|
|
|
|
if schema_type == "array":
|
|
if not set(schema).issubset(
|
|
{"type", "items", "minItems", "maxItems"}
|
|
) or not {"type", "items", "minItems"}.issubset(schema):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 数组 Schema 字段无效:{name}。"
|
|
)
|
|
minimum_items = _validate_schema_integer(
|
|
schema.get("minItems"), f"{name}.minItems", 0, 10_000
|
|
)
|
|
maximum_items = (
|
|
_validate_schema_integer(
|
|
schema.get("maxItems"), f"{name}.maxItems", 0, 10_000
|
|
)
|
|
if "maxItems" in schema
|
|
else None
|
|
)
|
|
if maximum_items is not None and maximum_items < minimum_items:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 数组 Schema 数量无效:{name}。"
|
|
)
|
|
_validate_input_schema(
|
|
schema.get("items"), f"{name}.items", depth + 1, node_budget
|
|
)
|
|
return schema
|
|
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 输入 Schema 类型不受支持:{name}。"
|
|
)
|
|
|
|
|
|
def _validate_capabilities_projection(data: Any) -> dict[str, Any]:
|
|
"""Validate permission-filtered command descriptors before model exposure."""
|
|
if not isinstance(data, dict) or set(data) != CAPABILITIES_RESPONSE_FIELDS:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 能力响应字段不完整或包含未知字段。"
|
|
)
|
|
commands = data.get("commands")
|
|
if not isinstance(commands, list) or len(commands) > CAPABILITIES_MAX_COMMANDS:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 能力命令数量超出安全边界。"
|
|
)
|
|
|
|
seen: set[str] = set()
|
|
for index, descriptor in enumerate(commands):
|
|
label = f"commands[{index}]"
|
|
if (
|
|
not isinstance(descriptor, dict)
|
|
or set(descriptor) != CAPABILITY_DESCRIPTOR_FIELDS
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 命令描述字段无效:{label}。"
|
|
)
|
|
command_name = _validate_capability_text(
|
|
descriptor.get("name"), f"{label}.name", 128
|
|
)
|
|
if SAFE_COMMAND.fullmatch(command_name) is None:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 命令名称格式无效:{label}。"
|
|
)
|
|
normalized_name = command_name.lower()
|
|
if normalized_name in seen:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 能力响应包含重复命令。"
|
|
)
|
|
seen.add(normalized_name)
|
|
|
|
version = _validate_capability_text(
|
|
descriptor.get("version"), f"{label}.version", 32
|
|
)
|
|
schema_version = _validate_capability_text(
|
|
descriptor.get("schemaVersion"), f"{label}.schemaVersion", 32
|
|
)
|
|
if (
|
|
SAFE_VERSION.fullmatch(version) is None
|
|
or SAFE_VERSION.fullmatch(schema_version) is None
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 命令版本格式无效:{label}。"
|
|
)
|
|
_validate_capability_text(
|
|
descriptor.get("description"), f"{label}.description", 1000
|
|
)
|
|
risk = descriptor.get("risk")
|
|
confirmation = descriptor.get("requiresConfirmation")
|
|
idempotency = descriptor.get("requiresIdempotencyKey")
|
|
if (
|
|
risk not in PLAN_RISKS
|
|
or type(confirmation) is not bool
|
|
or type(idempotency) is not bool
|
|
or (risk in {"write", "critical"} and not (confirmation and idempotency))
|
|
or (risk not in {"write", "critical"} and (confirmation or idempotency))
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 命令风险语义无效:{label}。"
|
|
)
|
|
input_schema = _validate_input_schema(
|
|
descriptor.get("inputSchema"), f"{label}.inputSchema"
|
|
)
|
|
if input_schema.get("type") != "object":
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", f"ERP 命令输入根 Schema 无效:{label}。"
|
|
)
|
|
return data
|
|
|
|
|
|
def _bind_plan_correlation(
|
|
method: str,
|
|
data: dict[str, Any],
|
|
correlation_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Project the validated ERP correlation into plan tool data.
|
|
|
|
The value comes from the trusted request envelope, never from model input or
|
|
response data. Non-plan calls keep their existing contract unchanged.
|
|
"""
|
|
if method != "command.plan":
|
|
return data
|
|
if set(data) != {"plan"}:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 计划响应字段不完整或包含未知字段。"
|
|
)
|
|
_validate_plan_projection(data.get("plan"))
|
|
projected = dict(data)
|
|
projected["bridgeCorrelationId"] = correlation_id
|
|
return projected
|
|
|
|
|
|
def _project_response_data(
|
|
method: str,
|
|
data: dict[str, Any],
|
|
correlation_id: str,
|
|
) -> dict[str, Any]:
|
|
if method == "context.get":
|
|
return _validate_context_projection(data)
|
|
if method == "capabilities.list":
|
|
return _validate_capabilities_projection(data)
|
|
return _bind_plan_correlation(method, data, correlation_id)
|
|
|
|
|
|
def _validate_connected_server(
|
|
discovery: ErpBridgeDiscovery,
|
|
server_process_id: int | None,
|
|
process_started_at_utc: datetime | None,
|
|
) -> None:
|
|
if (
|
|
not isinstance(server_process_id, int)
|
|
or isinstance(server_process_id, bool)
|
|
or server_process_id != discovery.process_id
|
|
or process_started_at_utc is None
|
|
or not _process_start_matches(discovery, process_started_at_utc)
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_server_identity_mismatch",
|
|
"ERP 命令桥服务进程身份不匹配,连接已关闭。",
|
|
)
|
|
|
|
|
|
def _process_start_matches(
|
|
discovery: ErpBridgeDiscovery,
|
|
actual_started_at_utc: datetime,
|
|
) -> bool:
|
|
return (
|
|
abs(
|
|
(actual_started_at_utc - discovery.started_at_utc).total_seconds()
|
|
)
|
|
<= MAX_PROCESS_START_DRIFT_SECONDS
|
|
)
|
|
|
|
|
|
def parse_discovery(text: str) -> ErpBridgeDiscovery:
|
|
value = _strict_json_loads(text, "ERP 桥发现文件")
|
|
if not isinstance(value, dict):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 桥发现文件必须是 JSON 对象。"
|
|
)
|
|
if set(value) != ALLOWED_DISCOVERY_FIELDS:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 桥发现文件字段不完整或包含未知字段。"
|
|
)
|
|
protocol = _required_string(value, "protocolVersion", 16)
|
|
pipe_name = _required_string(value, "pipeName", 64)
|
|
process_id = value.get("processId")
|
|
started_text = _required_string(value, "startedAtUtc", 64)
|
|
bridge_instance_id = _required_string(value, "bridgeInstanceId", 32)
|
|
pipe_match = SAFE_PIPE.fullmatch(pipe_name)
|
|
if (
|
|
protocol != PROTOCOL_VERSION
|
|
or not isinstance(process_id, int)
|
|
or isinstance(process_id, bool)
|
|
or process_id <= 0
|
|
or process_id > 2_147_483_647
|
|
or pipe_match is None
|
|
or int(pipe_match.group(1)) != process_id
|
|
or SAFE_BRIDGE_INSTANCE_ID.fullmatch(bridge_instance_id) is None
|
|
or pipe_match.group(2) != bridge_instance_id
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 桥发现文件字段格式无效。"
|
|
)
|
|
try:
|
|
normalized = started_text[:-1] + "+00:00" if started_text.endswith("Z") else started_text
|
|
started_at = datetime.fromisoformat(normalized)
|
|
except ValueError as error:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 桥发现文件启动时间无效。"
|
|
) from error
|
|
if started_at.tzinfo is None:
|
|
raise ErpBridgeError(
|
|
"bridge_protocol_error", "ERP 桥发现文件启动时间必须包含时区。"
|
|
)
|
|
return ErpBridgeDiscovery(
|
|
pipe_name=pipe_name,
|
|
process_id=process_id,
|
|
started_at_utc=started_at.astimezone(timezone.utc),
|
|
bridge_instance_id=bridge_instance_id,
|
|
)
|
|
|
|
|
|
def read_exactly(stream: BinaryIO, length: int) -> bytes:
|
|
if length < 0:
|
|
raise ErpBridgeError("bridge_protocol_error", "消息长度无效。")
|
|
chunks: list[bytes] = []
|
|
remaining = length
|
|
while remaining:
|
|
chunk = stream.read(remaining)
|
|
if not chunk:
|
|
raise ErpBridgeError("bridge_disconnected", "ERP 桥在完整响应到达前关闭。")
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
return b"".join(chunks)
|
|
|
|
|
|
def write_frame(stream: BinaryIO, body: bytes) -> None:
|
|
if not body or len(body) > MAX_MESSAGE_BYTES:
|
|
raise ErpBridgeError("bridge_protocol_error", "桥请求长度无效或超过 1 MB。")
|
|
_write_all(stream, struct.pack("<I", len(body)))
|
|
_write_all(stream, body)
|
|
stream.flush()
|
|
|
|
|
|
def _write_all(stream: BinaryIO, body: bytes) -> None:
|
|
remaining = memoryview(body)
|
|
while remaining:
|
|
written = stream.write(remaining)
|
|
if not isinstance(written, int) or written <= 0:
|
|
raise ErpBridgeError("bridge_disconnected", "ERP 桥在请求写完前关闭。")
|
|
remaining = remaining[written:]
|
|
|
|
|
|
def read_frame(stream: BinaryIO) -> bytes:
|
|
(length,) = struct.unpack("<I", read_exactly(stream, 4))
|
|
if length <= 0 or length > MAX_MESSAGE_BYTES:
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应长度无效或超过 1 MB。")
|
|
return read_exactly(stream, length)
|
|
|
|
|
|
def _available_pipe_bytes(stream: BinaryIO) -> int:
|
|
try:
|
|
import msvcrt
|
|
|
|
handle = msvcrt.get_osfhandle(stream.fileno())
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
peek_named_pipe = kernel32.PeekNamedPipe
|
|
peek_named_pipe.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.c_void_p,
|
|
ctypes.c_uint32,
|
|
ctypes.POINTER(ctypes.c_uint32),
|
|
ctypes.POINTER(ctypes.c_uint32),
|
|
ctypes.POINTER(ctypes.c_uint32),
|
|
]
|
|
peek_named_pipe.restype = ctypes.c_int
|
|
available = ctypes.c_uint32(0)
|
|
succeeded = peek_named_pipe(
|
|
ctypes.c_void_p(handle),
|
|
None,
|
|
0,
|
|
None,
|
|
ctypes.byref(available),
|
|
None,
|
|
)
|
|
if not succeeded:
|
|
error_code = ctypes.get_last_error()
|
|
if error_code in _PIPE_CLOSED_ERRORS:
|
|
raise ErpBridgeError(
|
|
"bridge_disconnected", "ERP 桥在完整响应到达前关闭。"
|
|
)
|
|
raise ErpBridgeError("bridge_unavailable", "无法读取 ERP 命名管道。")
|
|
return int(available.value)
|
|
except ErpBridgeError:
|
|
raise
|
|
except (AttributeError, OSError, ValueError) as error:
|
|
raise ErpBridgeError("bridge_unavailable", "无法检查 ERP 命名管道状态。") from error
|
|
|
|
|
|
def read_exactly_until(
|
|
stream: BinaryIO,
|
|
length: int,
|
|
deadline: float,
|
|
available_bytes: Callable[[BinaryIO], int] = _available_pipe_bytes,
|
|
) -> bytes:
|
|
if length < 0:
|
|
raise ErpBridgeError("bridge_protocol_error", "消息长度无效。")
|
|
chunks: list[bytes] = []
|
|
remaining = length
|
|
while remaining:
|
|
if time.monotonic() >= deadline:
|
|
raise ErpBridgeError("bridge_timeout", "调用 ERP 命令桥超时。")
|
|
available = available_bytes(stream)
|
|
if available <= 0:
|
|
time.sleep(0.01)
|
|
continue
|
|
chunk = stream.read(min(remaining, available))
|
|
if not chunk:
|
|
raise ErpBridgeError("bridge_disconnected", "ERP 桥在完整响应到达前关闭。")
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
return b"".join(chunks)
|
|
|
|
|
|
def read_frame_until(
|
|
stream: BinaryIO,
|
|
deadline: float,
|
|
available_bytes: Callable[[BinaryIO], int] = _available_pipe_bytes,
|
|
) -> bytes:
|
|
(length,) = struct.unpack(
|
|
"<I", read_exactly_until(stream, 4, deadline, available_bytes)
|
|
)
|
|
if length <= 0 or length > MAX_MESSAGE_BYTES:
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 桥响应长度无效或超过 1 MB。")
|
|
return read_exactly_until(stream, length, deadline, available_bytes)
|
|
|
|
|
|
class LocalErpBridgeClient:
|
|
"""AstrBot 的本机只读/计划客户端;代码层面不提供 execute 方法。"""
|
|
|
|
def __init__(
|
|
self,
|
|
discovery_directory: str = "",
|
|
connect_timeout_ms: int = 5000,
|
|
call_timeout_ms: int = 30_000,
|
|
) -> None:
|
|
self.discovery_directory = discovery_directory.strip()
|
|
self.connect_timeout_ms = max(250, min(int(connect_timeout_ms), 30_000))
|
|
self.call_timeout_ms = max(1000, min(int(call_timeout_ms), 300_000))
|
|
self._bridge_instance_lock = threading.Lock()
|
|
self._bound_bridge_instances: OrderedDict[
|
|
str, tuple[int, datetime, str]
|
|
] = OrderedDict()
|
|
|
|
async def call(
|
|
self,
|
|
method: str,
|
|
payload: dict[str, Any] | None = None,
|
|
*,
|
|
client_session_id: str,
|
|
target_process_id: int | None = None,
|
|
target_started_at_unix_seconds: int | None = None,
|
|
correlation_id: str | None = None,
|
|
session_scope_token: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if method not in SAFE_METHODS:
|
|
raise ErpBridgeError(
|
|
"method_not_allowed",
|
|
"AstrBot 插件只允许读取 ERP 状态和生成计划,不能执行计划。",
|
|
)
|
|
if not isinstance(client_session_id, str) or SAFE_IDENTIFIER.fullmatch(
|
|
client_session_id
|
|
) is None:
|
|
raise ErpBridgeError(
|
|
"client_session_invalid", "AstrBot ERP 桥客户端会话格式无效。"
|
|
)
|
|
if target_process_id is not None and (
|
|
not isinstance(target_process_id, int)
|
|
or isinstance(target_process_id, bool)
|
|
or target_process_id <= 0
|
|
or target_process_id > 2_147_483_647
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_process_id_invalid", "ERP 目标进程 ID 格式无效。"
|
|
)
|
|
if target_started_at_unix_seconds is not None and (
|
|
target_process_id is None
|
|
or not isinstance(target_started_at_unix_seconds, int)
|
|
or isinstance(target_started_at_unix_seconds, bool)
|
|
or target_started_at_unix_seconds <= 0
|
|
or target_started_at_unix_seconds > 253_402_300_799
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_process_binding_invalid",
|
|
"ERP 目标进程启动时间指纹无效。",
|
|
)
|
|
if correlation_id is not None and (
|
|
not isinstance(correlation_id, str)
|
|
or SAFE_IDENTIFIER.fullmatch(correlation_id) is None
|
|
):
|
|
raise ErpBridgeError(
|
|
"correlation_id_invalid", "ERP 桥关联 ID 格式无效。"
|
|
)
|
|
if session_scope_token is not None and (
|
|
not isinstance(session_scope_token, str)
|
|
or SAFE_SESSION_SCOPE_TOKEN.fullmatch(session_scope_token) is None
|
|
):
|
|
raise ErpBridgeError(
|
|
"bridge_session_scope_token_invalid",
|
|
"ERP 会话作用域令牌格式无效。",
|
|
)
|
|
effective_payload = {} if payload is None else payload
|
|
if not isinstance(effective_payload, dict):
|
|
raise ErpBridgeError("invalid_input", "ERP 桥 payload 必须是 JSON 对象。")
|
|
if method == "command.plan":
|
|
if set(effective_payload) != {"command", "input"}:
|
|
raise ErpBridgeError("invalid_input", "计划 payload 字段不完整或包含未知字段。")
|
|
command = effective_payload.get("command")
|
|
if not isinstance(command, str) or not SAFE_COMMAND.fullmatch(command):
|
|
raise ErpBridgeError("invalid_command", "命令名格式无效。")
|
|
command_input = effective_payload.get("input")
|
|
if not isinstance(command_input, dict):
|
|
raise ErpBridgeError("invalid_input", "命令 input 必须是 JSON 对象。")
|
|
elif effective_payload:
|
|
raise ErpBridgeError("invalid_input", "该 ERP 桥方法不接受 payload 字段。")
|
|
return await asyncio.to_thread(
|
|
self._call_sync,
|
|
method,
|
|
effective_payload,
|
|
client_session_id,
|
|
target_process_id,
|
|
target_started_at_unix_seconds,
|
|
correlation_id,
|
|
session_scope_token,
|
|
)
|
|
|
|
def _call_sync(
|
|
self,
|
|
method: str,
|
|
payload: dict[str, Any],
|
|
client_session_id: str,
|
|
target_process_id: int | None,
|
|
target_started_at_unix_seconds: int | None,
|
|
requested_correlation_id: str | None,
|
|
session_scope_token: str | None,
|
|
) -> dict[str, Any]:
|
|
if os.name != "nt":
|
|
raise ErpBridgeError("windows_required", "本机 ERP 命名管道只支持 Windows。")
|
|
call_deadline = time.monotonic() + self.call_timeout_ms / 1000
|
|
discovery = self._find_discovery(
|
|
target_process_id, target_started_at_unix_seconds
|
|
)
|
|
self._bind_bridge_instance(client_session_id, discovery)
|
|
request_id = uuid.uuid4().hex
|
|
correlation_id = requested_correlation_id or uuid.uuid4().hex
|
|
request = {
|
|
"protocolVersion": PROTOCOL_VERSION,
|
|
"requestId": request_id,
|
|
"correlationId": correlation_id,
|
|
"clientSessionId": client_session_id,
|
|
"method": method,
|
|
"payload": payload,
|
|
}
|
|
if session_scope_token is not None:
|
|
request["sessionScopeToken"] = session_scope_token
|
|
try:
|
|
encoded = json.dumps(
|
|
request,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
except (TypeError, ValueError, RecursionError) as error:
|
|
raise ErpBridgeError("invalid_input", "ERP 桥请求不是有效 JSON。") from error
|
|
connect_deadline = min(
|
|
call_deadline, time.monotonic() + self.connect_timeout_ms / 1000
|
|
)
|
|
stream: BinaryIO | None = None
|
|
pipe_path = rf"\\.\pipe\{discovery.pipe_name}"
|
|
while stream is None:
|
|
try:
|
|
stream = open(pipe_path, "r+b", buffering=0)
|
|
except OSError as error:
|
|
if time.monotonic() >= connect_deadline:
|
|
raise ErpBridgeError("bridge_timeout", "连接 ERP 命令桥超时。") from error
|
|
time.sleep(0.05)
|
|
|
|
with stream:
|
|
_validate_connected_server(
|
|
discovery,
|
|
self._named_pipe_server_process_id(stream),
|
|
self._process_started_at_utc(discovery.process_id),
|
|
)
|
|
write_frame(stream, encoded)
|
|
try:
|
|
response_text = read_frame_until(stream, call_deadline).decode("utf-8")
|
|
except UnicodeDecodeError as error:
|
|
raise ErpBridgeError("bridge_protocol_error", "ERP 桥返回了无效 JSON。") from error
|
|
return _project_response_data(
|
|
method,
|
|
parse_response(response_text, request_id, correlation_id),
|
|
correlation_id,
|
|
)
|
|
|
|
def _bind_bridge_instance(
|
|
self,
|
|
client_session_id: str,
|
|
discovery: ErpBridgeDiscovery,
|
|
) -> None:
|
|
binding = (
|
|
discovery.process_id,
|
|
discovery.started_at_utc,
|
|
discovery.bridge_instance_id,
|
|
)
|
|
with self._bridge_instance_lock:
|
|
previous = self._bound_bridge_instances.get(client_session_id)
|
|
if previous is not None and previous != binding:
|
|
raise ErpBridgeError(
|
|
"erp_bridge_instance_changed",
|
|
"ERP 已重新登录或命令桥已经重建,请重新启动桌宠以建立新会话。",
|
|
)
|
|
self._bound_bridge_instances[client_session_id] = binding
|
|
self._bound_bridge_instances.move_to_end(client_session_id)
|
|
while len(self._bound_bridge_instances) > MAX_BOUND_BRIDGE_INSTANCES:
|
|
self._bound_bridge_instances.popitem(last=False)
|
|
|
|
def _find_pipe(
|
|
self,
|
|
target_process_id: int | None = None,
|
|
target_started_at_unix_seconds: int | None = None,
|
|
) -> str:
|
|
return self._find_discovery(
|
|
target_process_id, target_started_at_unix_seconds
|
|
).pipe_name
|
|
|
|
def _find_discovery(
|
|
self,
|
|
target_process_id: int | None = None,
|
|
target_started_at_unix_seconds: int | None = None,
|
|
) -> ErpBridgeDiscovery:
|
|
directory = self._discovery_path()
|
|
if not directory.is_dir():
|
|
raise ErpBridgeError("erp_bridge_not_running", "未发现已登录 ERP 的命令桥。")
|
|
candidates: list[tuple[float, Path]] = []
|
|
for path in directory.glob("agentbridge-*.json"):
|
|
try:
|
|
if path.is_symlink():
|
|
continue
|
|
stat = path.stat()
|
|
if stat.st_size <= 0 or stat.st_size > DISCOVERY_MAX_BYTES:
|
|
continue
|
|
candidates.append((stat.st_mtime, path))
|
|
except OSError:
|
|
continue
|
|
candidates.sort(key=lambda item: item[0], reverse=True)
|
|
live: list[ErpBridgeDiscovery] = []
|
|
for _modified, path in candidates:
|
|
try:
|
|
if path.is_symlink():
|
|
continue
|
|
value = parse_discovery(path.read_text(encoding="utf-8"))
|
|
if path.name.lower() != f"agentbridge-{value.process_id}.json":
|
|
continue
|
|
actual_start = self._process_started_at_utc(value.process_id)
|
|
if actual_start is None:
|
|
continue
|
|
if _process_start_matches(value, actual_start):
|
|
live.append(value)
|
|
except (OSError, UnicodeDecodeError, ErpBridgeError):
|
|
continue
|
|
if target_process_id is not None:
|
|
for value in live:
|
|
if value.process_id == target_process_id and (
|
|
target_started_at_unix_seconds is None
|
|
or int(value.started_at_utc.timestamp())
|
|
== target_started_at_unix_seconds
|
|
):
|
|
return value
|
|
raise ErpBridgeError(
|
|
"erp_bridge_target_not_running",
|
|
"指定的 ERP 进程未运行命令桥,请重新从目标 ERP 启动桌宠。",
|
|
)
|
|
if not live:
|
|
raise ErpBridgeError("erp_bridge_not_running", "ERP 命令桥发现文件均已失效。")
|
|
if len(live) > 1:
|
|
raise ErpBridgeError(
|
|
"erp_bridge_ambiguous",
|
|
"检测到多个已登录 ERP,必须从目标 ERP 启动桌宠或显式指定进程。",
|
|
)
|
|
return live[0]
|
|
|
|
def _discovery_path(self) -> Path:
|
|
if self.discovery_directory:
|
|
return Path(self.discovery_directory).expanduser().resolve()
|
|
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
|
if not local_app_data:
|
|
raise ErpBridgeError("local_app_data_missing", "Windows LOCALAPPDATA 未配置。")
|
|
return Path(local_app_data) / "Langsu" / "Lserp" / "AgentBridge"
|
|
|
|
@staticmethod
|
|
def _process_started_at_utc(process_id: int) -> datetime | None:
|
|
try:
|
|
class FileTime(ctypes.Structure):
|
|
_fields_ = [
|
|
("low", ctypes.c_uint32),
|
|
("high", ctypes.c_uint32),
|
|
]
|
|
|
|
process_query_limited_information = 0x1000
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
open_process = kernel32.OpenProcess
|
|
open_process.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
|
|
open_process.restype = ctypes.c_void_p
|
|
get_process_times = kernel32.GetProcessTimes
|
|
get_process_times.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(FileTime),
|
|
ctypes.POINTER(FileTime),
|
|
ctypes.POINTER(FileTime),
|
|
ctypes.POINTER(FileTime),
|
|
]
|
|
get_process_times.restype = ctypes.c_int
|
|
close_handle = kernel32.CloseHandle
|
|
close_handle.argtypes = [ctypes.c_void_p]
|
|
close_handle.restype = ctypes.c_int
|
|
handle = open_process(
|
|
process_query_limited_information, False, process_id
|
|
)
|
|
if not handle:
|
|
return None
|
|
creation = FileTime()
|
|
exit_time = FileTime()
|
|
kernel_time = FileTime()
|
|
user_time = FileTime()
|
|
try:
|
|
if not get_process_times(
|
|
handle,
|
|
ctypes.byref(creation),
|
|
ctypes.byref(exit_time),
|
|
ctypes.byref(kernel_time),
|
|
ctypes.byref(user_time),
|
|
):
|
|
return None
|
|
creation_filetime = (creation.high << 32) | creation.low
|
|
unix_seconds = (
|
|
creation_filetime - _WINDOWS_EPOCH_FILETIME
|
|
) / 10_000_000
|
|
return datetime.fromtimestamp(unix_seconds, timezone.utc)
|
|
finally:
|
|
close_handle(handle)
|
|
except (AttributeError, OSError, OverflowError, ValueError):
|
|
return None
|
|
|
|
@staticmethod
|
|
def _named_pipe_server_process_id(stream: BinaryIO) -> int | None:
|
|
try:
|
|
import msvcrt
|
|
|
|
handle = msvcrt.get_osfhandle(stream.fileno())
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
get_server_process_id = kernel32.GetNamedPipeServerProcessId
|
|
get_server_process_id.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_uint32),
|
|
]
|
|
get_server_process_id.restype = ctypes.c_int
|
|
server_process_id = ctypes.c_uint32(0)
|
|
if not get_server_process_id(
|
|
ctypes.c_void_p(handle), ctypes.byref(server_process_id)
|
|
):
|
|
return None
|
|
return int(server_process_id.value)
|
|
except (AttributeError, OSError, OverflowError, ValueError):
|
|
return None
|