feat: add ERP agent pet bridge and startup guide

This commit is contained in:
郎速科技
2026-08-14 14:28:28 +08:00
parent a803070819
commit 4c08f4c948
252 changed files with 134752 additions and 72 deletions
@@ -0,0 +1,53 @@
using System.Buffers.Binary;
namespace Lskj.AgentPet.Host.Core.ErpBridge;
public static class BridgeFrameCodec
{
public const int MaxMessageBytes = 1024 * 1024;
public static async Task WriteAsync(
Stream stream,
ReadOnlyMemory<byte> body,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
if (body.Length is <= 0 or > MaxMessageBytes)
throw new HostError("bridge_protocol_error", "ERP 桥请求长度无效或超过 1 MB。");
byte[] header = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(header, body.Length);
await stream.WriteAsync(header, cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
public static async Task<byte[]> ReadAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
byte[] header = new byte[4];
await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
int length = BinaryPrimitives.ReadInt32LittleEndian(header);
if (length is <= 0 or > MaxMessageBytes)
throw new HostError("bridge_protocol_error", "ERP 桥响应长度无效或超过 1 MB。");
byte[] body = new byte[length];
await ReadExactlyAsync(stream, body, cancellationToken).ConfigureAwait(false);
return body;
}
private static async Task ReadExactlyAsync(
Stream stream,
Memory<byte> target,
CancellationToken cancellationToken)
{
int offset = 0;
while (offset < target.Length)
{
int count = await stream.ReadAsync(target[offset..], cancellationToken).ConfigureAwait(false);
if (count == 0)
throw new HostError("bridge_disconnected", "ERP 桥在完整消息到达前关闭。");
offset += count;
}
}
}
@@ -0,0 +1,492 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
using Lskj.AgentPet.Host.Core.Configuration;
using Microsoft.Win32.SafeHandles;
namespace Lskj.AgentPet.Host.Core.ErpBridge;
public interface IErpBridgeClient
{
Task<JsonDocument> SendAsync(JsonElement request, CancellationToken cancellationToken = default);
}
public sealed class ErpBridgeClient : IErpBridgeClient
{
internal static readonly TimeSpan MaximumProcessStartDrift = TimeSpan.FromSeconds(1);
private static readonly Regex SafePipe = new(
"^lserp\\.agent\\.(?<pid>[0-9]{1,10})"
+ "\\.(?<instance>[a-f0-9]{32})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeCode = new(
"^[A-Za-z0-9_.:-]{1,128}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SafeBridgeInstanceId = new(
"^[a-f0-9]{32}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex TimeZoneSuffix = new(
"(?:Z|[+-][0-9]{2}:[0-9]{2})$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly ISet<string> AllowedResponseProperties = new HashSet<string>(
new[]
{
"protocolVersion",
"requestId",
"correlationId",
"success",
"code",
"message",
"data"
},
StringComparer.Ordinal);
private static readonly ISet<string> AllowedErrorDataProperties = new HashSet<string>(
new[] { "recovery" },
StringComparer.Ordinal);
private static readonly ISet<string> AllowedRecoveryProperties = new HashSet<string>(
new[] { "action", "retryable", "planInvalidated", "message" },
StringComparer.Ordinal);
private static readonly ISet<string> AllowedRecoveryActions = new HashSet<string>(
new[]
{
"review_and_retry",
"reconcile_execution",
"restart_erp_pet",
"inspect_existing_record",
"replan",
"correct_request",
"contact_administrator",
"wait_and_retry",
"contact_support"
},
StringComparer.Ordinal);
private static readonly ISet<string> AllowedDiscoveryProperties = new HashSet<string>(
new[]
{
"protocolVersion",
"pipeName",
"processId",
"startedAtUtc",
"bridgeInstanceId"
},
StringComparer.Ordinal);
private readonly HostConfiguration _configuration;
private readonly object _bridgeInstanceSync = new();
private string? _boundBridgeInstanceId;
public ErpBridgeClient(HostConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
public async Task<JsonDocument> SendAsync(
JsonElement request,
CancellationToken cancellationToken = default)
{
if (!OperatingSystem.IsWindows())
throw new HostError("windows_required", "ERP 命名管道只支持 Windows。");
if (request.ValueKind != JsonValueKind.Object)
throw new HostError("bridge_request_invalid", "ERP 桥请求必须是 JSON 对象。");
string requestId = RequiredString(request, "requestId", 128);
string correlationId = RequiredString(request, "correlationId", 128);
byte[] body = JsonSerializer.SerializeToUtf8Bytes(request);
if (body.Length > BridgeFrameCodec.MaxMessageBytes)
throw new HostError("bridge_protocol_error", "ERP 桥请求超过 1 MB。");
ErpBridgeDiscovery discovery = FindPipe();
lock (_bridgeInstanceSync)
{
_boundBridgeInstanceId = RequireStableBridgeInstance(
_boundBridgeInstanceId,
discovery);
}
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_configuration.BridgeTimeout);
await using NamedPipeClientStream pipe = new(
".",
discovery.PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
try
{
await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false);
VerifyConnectedServer(pipe, discovery);
await BridgeFrameCodec.WriteAsync(pipe, body, timeout.Token).ConfigureAwait(false);
byte[] responseBytes = await BridgeFrameCodec.ReadAsync(pipe, timeout.Token).ConfigureAwait(false);
JsonDocument response = JsonDocument.Parse(responseBytes, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 64
});
try
{
ValidateResponse(response.RootElement, requestId, correlationId);
}
catch
{
response.Dispose();
throw;
}
return response;
}
catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested)
{
throw new HostError("bridge_timeout", "连接或调用 ERP 命令桥超时。", error);
}
catch (JsonException error)
{
throw new HostError("bridge_protocol_error", "ERP 桥返回无效 JSON。", error);
}
catch (IOException error)
{
throw new HostError("bridge_unavailable", "ERP 命令桥连接失败。", error);
}
}
private ErpBridgeDiscovery FindPipe()
{
DirectoryInfo directory = new(_configuration.BridgeDiscoveryDirectory);
if (!directory.Exists)
throw new HostError("erp_bridge_not_running", "未发现已登录 ERP 的命令桥。");
List<ErpBridgeDiscovery> live = new();
foreach (FileInfo file in directory.EnumerateFiles("agentbridge-*.json")
.Where(item => (item.Attributes & FileAttributes.ReparsePoint) == 0)
.OrderByDescending(item => item.LastWriteTimeUtc))
{
try
{
if (file.Length is <= 0 or > 64 * 1024) continue;
using FileStream stream = new(
file.FullName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
4096,
FileOptions.SequentialScan);
using JsonDocument document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 16
});
JsonElement root = document.RootElement;
ErpBridgeDiscovery discovery = ValidateDiscovery(root);
if (!string.Equals(
file.Name,
"agentbridge-" + discovery.ProcessId + ".json",
StringComparison.OrdinalIgnoreCase)
|| !ProcessMatches(discovery)) continue;
live.Add(discovery);
}
catch (Exception error) when (error is IOException
or UnauthorizedAccessException
or JsonException
or InvalidOperationException
or KeyNotFoundException
or System.ComponentModel.Win32Exception
or ArgumentException)
{
continue;
}
}
return SelectDiscovery(
live,
_configuration.BridgeProcessId,
_configuration.BridgeProcessStartedAtUnixSeconds);
}
private static void VerifyConnectedServer(
NamedPipeClientStream pipe,
ErpBridgeDiscovery discovery)
{
uint serverProcessId = 0;
bool querySucceeded;
try
{
querySucceeded = GetNamedPipeServerProcessId(
pipe.SafePipeHandle,
out serverProcessId);
}
catch (Exception error) when (error is DllNotFoundException
or EntryPointNotFoundException
or ObjectDisposedException
or InvalidOperationException)
{
querySucceeded = false;
}
bool processStillMatches;
try
{
processStillMatches = ProcessMatches(discovery);
}
catch (Exception error) when (error is ArgumentException
or InvalidOperationException
or System.ComponentModel.Win32Exception)
{
processStillMatches = false;
}
ValidateConnectedServerIdentity(
discovery,
querySucceeded,
serverProcessId,
processStillMatches);
}
internal static void ValidateConnectedServerIdentity(
ErpBridgeDiscovery discovery,
bool querySucceeded,
uint serverProcessId,
bool processStillMatches)
{
if (discovery is null
|| discovery.ProcessId <= 0
|| !querySucceeded
|| serverProcessId != (uint)discovery.ProcessId
|| !processStillMatches)
{
throw new HostError(
"bridge_server_identity_mismatch",
"ERP 命令桥服务进程身份不匹配,连接已关闭。");
}
}
internal static string RequireStableBridgeInstance(
string? expectedBridgeInstanceId,
ErpBridgeDiscovery discovery)
{
if (discovery is null
|| !SafeBridgeInstanceId.IsMatch(discovery.BridgeInstanceId))
throw ProtocolError();
if (string.IsNullOrEmpty(expectedBridgeInstanceId))
return discovery.BridgeInstanceId;
if (!string.Equals(
expectedBridgeInstanceId,
discovery.BridgeInstanceId,
StringComparison.Ordinal))
{
throw new HostError(
"erp_bridge_instance_changed",
"ERP 已重新登录或命令桥已经重建,请重新启动桌宠以建立新会话。");
}
return expectedBridgeInstanceId;
}
internal static ErpBridgeDiscovery SelectDiscovery(
IReadOnlyList<ErpBridgeDiscovery> live,
int? targetProcessId,
long? targetStartedAtUnixSeconds)
{
if (targetStartedAtUnixSeconds.HasValue && !targetProcessId.HasValue)
throw new HostError("bridge_process_binding_invalid", "ERP 进程启动时间缺少对应进程 ID。");
if (targetProcessId.HasValue)
{
List<ErpBridgeDiscovery> selected = live.Where(
item => item.ProcessId == targetProcessId.Value
&& (!targetStartedAtUnixSeconds.HasValue
|| item.StartedAtUtc.ToUnixTimeSeconds()
== targetStartedAtUnixSeconds.Value)).ToList();
if (selected.Count != 1)
{
throw new HostError(
"erp_bridge_target_not_running",
"指定的 ERP 进程未运行命令桥,请重新从目标 ERP 启动桌宠。");
}
return selected[0];
}
if (live.Count == 0)
throw new HostError("erp_bridge_not_running", "ERP 命令桥发现文件均已失效。");
if (live.Count > 1)
{
throw new HostError(
"erp_bridge_ambiguous",
"检测到多个已登录 ERP,必须从目标 ERP 启动桌宠或显式指定进程。");
}
return live[0];
}
private static bool ProcessMatches(ErpBridgeDiscovery discovery)
{
using Process process = Process.GetProcessById(discovery.ProcessId);
if (process.HasExited) return false;
DateTimeOffset actualStart = process.StartTime.ToUniversalTime();
return ProcessStartMatches(discovery, actualStart);
}
internal static bool ProcessStartMatches(
ErpBridgeDiscovery discovery,
DateTimeOffset actualStart)
{
return discovery is not null
&& Math.Abs((actualStart - discovery.StartedAtUtc).TotalSeconds)
<= MaximumProcessStartDrift.TotalSeconds;
}
internal static void ValidateResponse(
JsonElement response,
string expectedRequestId,
string expectedCorrelationId)
{
ValidateProperties(response, AllowedResponseProperties, "ERP 桥响应");
string protocol = RequiredString(response, "protocolVersion", 16);
string requestId = RequiredString(response, "requestId", 128);
string correlationId = RequiredString(response, "correlationId", 128);
string code = RequiredString(response, "code", 128);
if (!string.Equals(protocol, "1.0", StringComparison.Ordinal)
|| !string.Equals(requestId, expectedRequestId, StringComparison.Ordinal)
|| !string.Equals(correlationId, expectedCorrelationId, StringComparison.Ordinal)
|| !SafeCode.IsMatch(code)
|| !response.TryGetProperty("success", out JsonElement success)
|| (success.ValueKind != JsonValueKind.True
&& success.ValueKind != JsonValueKind.False)
|| !response.TryGetProperty("data", out JsonElement data)
|| data.ValueKind != JsonValueKind.Object)
throw ProtocolError();
if (response.TryGetProperty("message", out JsonElement message)
&& message.ValueKind != JsonValueKind.Null)
{
if (message.ValueKind != JsonValueKind.String
|| (message.GetString()?.Length ?? 0) > 2000)
throw ProtocolError();
}
if (success.ValueKind == JsonValueKind.False)
ValidateErrorRecovery(data);
}
private static void ValidateErrorRecovery(JsonElement data)
{
ValidateProperties(data, AllowedErrorDataProperties, "ERP 桥错误 data");
if (!data.TryGetProperty("recovery", out JsonElement recovery)
|| recovery.ValueKind != JsonValueKind.Object)
throw ProtocolError();
ValidateProperties(recovery, AllowedRecoveryProperties, "ERP 桥错误 recovery");
string action = RequiredString(recovery, "action", 64);
string message = RequiredString(recovery, "message", 300);
if (!AllowedRecoveryActions.Contains(action)
|| message.Any(char.IsControl)
|| !recovery.TryGetProperty("retryable", out JsonElement retryable)
|| (retryable.ValueKind != JsonValueKind.True
&& retryable.ValueKind != JsonValueKind.False)
|| !recovery.TryGetProperty("planInvalidated", out JsonElement invalidated)
|| (invalidated.ValueKind != JsonValueKind.True
&& invalidated.ValueKind != JsonValueKind.False))
throw ProtocolError();
}
internal static ErpBridgeDiscovery ValidateDiscovery(JsonElement discovery)
{
ValidateProperties(discovery, AllowedDiscoveryProperties, "ERP 桥发现文件");
string protocol = RequiredString(discovery, "protocolVersion", 16);
string pipeName = RequiredString(discovery, "pipeName", 64);
string bridgeInstanceId = RequiredString(
discovery,
"bridgeInstanceId",
32);
if (!string.Equals(protocol, "1.0", StringComparison.Ordinal)
|| !discovery.TryGetProperty("processId", out JsonElement process)
|| process.ValueKind != JsonValueKind.Number
|| !process.TryGetInt32(out int processId)
|| processId <= 0
|| !discovery.TryGetProperty("startedAtUtc", out JsonElement started)
|| started.ValueKind != JsonValueKind.String
|| !TimeZoneSuffix.IsMatch(started.GetString() ?? string.Empty)
|| !started.TryGetDateTimeOffset(out DateTimeOffset startedAt))
throw ProtocolError();
if (!SafeBridgeInstanceId.IsMatch(bridgeInstanceId))
throw ProtocolError();
Match match = SafePipe.Match(pipeName);
if (!match.Success
|| !int.TryParse(match.Groups["pid"].Value, out int pipeProcessId)
|| pipeProcessId != processId
|| !string.Equals(
match.Groups["instance"].Value,
bridgeInstanceId,
StringComparison.Ordinal))
throw ProtocolError();
return new ErpBridgeDiscovery(
pipeName,
processId,
startedAt.ToUniversalTime(),
bridgeInstanceId);
}
private static void ValidateProperties(
JsonElement source,
ISet<string> allowed,
string label)
{
if (source.ValueKind != JsonValueKind.Object) throw ProtocolError();
ValidateUniqueProperties(source, label);
foreach (JsonProperty property in source.EnumerateObject())
{
if (!allowed.Contains(property.Name))
throw new HostError(
"bridge_protocol_error",
label + "包含未知字段。");
}
}
private static void ValidateUniqueProperties(JsonElement source, string label)
{
if (source.ValueKind == JsonValueKind.Object)
{
HashSet<string> seen = new(StringComparer.Ordinal);
foreach (JsonProperty property in source.EnumerateObject())
{
if (!seen.Add(property.Name))
throw new HostError(
"bridge_protocol_error",
label + "包含重复 JSON 字段。");
ValidateUniqueProperties(property.Value, label);
}
}
else if (source.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in source.EnumerateArray())
ValidateUniqueProperties(item, label);
}
}
private static string RequiredString(JsonElement source, string name, int maximumLength)
{
if (!source.TryGetProperty(name, out JsonElement value)
|| value.ValueKind != JsonValueKind.String)
throw new HostError("bridge_protocol_error", "ERP 桥消息缺少字段:" + name);
string result = value.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(result) || result.Length > maximumLength)
throw new HostError("bridge_protocol_error", "ERP 桥消息字段格式无效:" + name);
return result;
}
private static HostError ProtocolError() => new(
"bridge_protocol_error",
"ERP 桥返回了无效或不匹配的协议消息。");
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeServerProcessId(
SafePipeHandle pipe,
out uint serverProcessId);
}
internal sealed class ErpBridgeDiscovery
{
public ErpBridgeDiscovery(
string pipeName,
int processId,
DateTimeOffset startedAtUtc,
string bridgeInstanceId)
{
PipeName = pipeName;
ProcessId = processId;
StartedAtUtc = startedAtUtc;
BridgeInstanceId = bridgeInstanceId;
}
public string PipeName { get; }
public int ProcessId { get; }
public DateTimeOffset StartedAtUtc { get; }
public string BridgeInstanceId { get; }
}
@@ -0,0 +1,156 @@
using System.Text.Json;
using System.Collections.Generic;
using System.Linq;
using Lskj.AgentPet.Host.Core.Configuration;
namespace Lskj.AgentPet.Host.Core.ErpBridge;
public sealed class SessionBoundErpBridgeClient : IErpBridgeClient
{
private readonly IErpBridgeClient _inner;
private readonly ErpSessionScopeBinding _scope;
public SessionBoundErpBridgeClient(
IErpBridgeClient inner,
ErpSessionScopeBinding scope)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
}
public async Task<JsonDocument> SendAsync(
JsonElement request,
CancellationToken cancellationToken = default)
{
string method = RequiredString(request, "method", 128);
if (string.Equals(method, "context.get", StringComparison.Ordinal))
{
using JsonDocument boundRequest = BindRequest(request);
JsonDocument context = await _inner.SendAsync(
boundRequest.RootElement,
cancellationToken)
.ConfigureAwait(false);
try
{
_scope.VerifyContextResponse(context.RootElement);
return context;
}
catch
{
context.Dispose();
throw;
}
}
string protocol = RequiredString(request, "protocolVersion", 16);
string clientSessionId = RequiredString(request, "clientSessionId", 128);
ErpSessionScopeSnapshot before = await ReadContextAsync(
protocol,
clientSessionId,
cancellationToken).ConfigureAwait(false);
JsonDocument? result = null;
try
{
using JsonDocument boundRequest = BindRequest(request);
result = await _inner.SendAsync(
boundRequest.RootElement,
cancellationToken)
.ConfigureAwait(false);
ErpSessionScopeSnapshot after;
try
{
after = await ReadContextAsync(
protocol,
clientSessionId,
cancellationToken).ConfigureAwait(false);
}
catch (HostError error) when (error.Code == "erp_session_scope_mismatch")
{
throw new HostError(
"erp_session_scope_changed",
"ERP 会话在桌宠操作期间发生切换,操作结果不会交给页面继续使用。",
error);
}
bool targetMayChangeUi = string.Equals(
method,
"command.execute",
StringComparison.Ordinal);
if (!before.SameSessionIdentity(after)
|| (!targetMayChangeUi && !before.SameUiState(after)))
{
throw new HostError(
"erp_session_scope_changed",
"ERP 会话在桌宠操作期间发生切换,操作结果不会交给页面继续使用。");
}
JsonDocument accepted = result;
result = null;
return accepted;
}
finally
{
result?.Dispose();
}
}
private async Task<ErpSessionScopeSnapshot> ReadContextAsync(
string protocol,
string clientSessionId,
CancellationToken cancellationToken)
{
string requestId = Guid.NewGuid().ToString("N");
string correlationId = "scope-" + Guid.NewGuid().ToString("N");
using JsonDocument request = JsonSerializer.SerializeToDocument(new
{
protocolVersion = protocol,
requestId,
correlationId,
clientSessionId,
sessionScopeToken = _scope.Token,
method = "context.get",
payload = new { }
});
using JsonDocument response = await _inner.SendAsync(
request.RootElement,
cancellationToken).ConfigureAwait(false);
return _scope.VerifyContextResponse(response.RootElement);
}
private JsonDocument BindRequest(JsonElement request)
{
if (request.ValueKind != JsonValueKind.Object)
throw new HostError(
"bridge_request_invalid",
"ERP 桥请求必须是 JSON 对象。");
Dictionary<string, JsonElement> fields = new(StringComparer.Ordinal);
foreach (JsonProperty property in request.EnumerateObject())
{
if (!fields.TryAdd(property.Name, property.Value.Clone()))
throw new HostError(
"bridge_request_invalid",
"ERP 桥请求包含重复字段。");
}
// 浏览器页面不能声明或覆盖会话令牌;Host 只使用启动时绑定的
// ERP 登录范围重新注入它,然后才把请求交给命名管道客户端。
fields["sessionScopeToken"] = JsonSerializer.SerializeToElement(
_scope.Token);
return JsonSerializer.SerializeToDocument(fields);
}
private static string RequiredString(JsonElement source, string name, int maximumLength)
{
if (source.ValueKind != JsonValueKind.Object
|| !source.TryGetProperty(name, out JsonElement value)
|| value.ValueKind != JsonValueKind.String)
{
throw new HostError("bridge_request_invalid", "ERP 桥请求缺少会话绑定字段。");
}
string result = value.GetString() ?? string.Empty;
if (string.IsNullOrWhiteSpace(result)
|| result.Length > maximumLength
|| result.Any(char.IsControl))
throw new HostError("bridge_request_invalid", "ERP 桥请求会话绑定字段无效。");
return result;
}
}