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 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\\.(?[0-9]{1,10})" + "\\.(?[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 AllowedResponseProperties = new HashSet( new[] { "protocolVersion", "requestId", "correlationId", "success", "code", "message", "data" }, StringComparer.Ordinal); private static readonly ISet AllowedErrorDataProperties = new HashSet( new[] { "recovery" }, StringComparer.Ordinal); private static readonly ISet AllowedRecoveryProperties = new HashSet( new[] { "action", "retryable", "planInvalidated", "message" }, StringComparer.Ordinal); private static readonly ISet AllowedRecoveryActions = new HashSet( 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 AllowedDiscoveryProperties = new HashSet( 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 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 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 live, int? targetProcessId, long? targetStartedAtUnixSeconds) { if (targetStartedAtUnixSeconds.HasValue && !targetProcessId.HasValue) throw new HostError("bridge_process_binding_invalid", "ERP 进程启动时间缺少对应进程 ID。"); if (targetProcessId.HasValue) { List 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 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 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; } }