using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using Lskj.CommandKernel; using Newtonsoft.Json.Linq; namespace Lskj.AgentBridge { public sealed class AgentBridgeRuntime : IAgentBridgeRuntime { public const string ProtocolVersion = "1.0"; private static readonly Regex SafeEnvelopeIdentifier = new Regex( "^[A-Za-z0-9_.:-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeMethod = new Regex( "^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeCommandName = new Regex( "^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafePlanId = new Regex( "^[A-Fa-f0-9]{32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeIdempotencyKey = new Regex( "^[A-Za-z0-9_.:-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex SafeOutcomeCode = new Regex( "^[a-z0-9_.-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private readonly CommandRegistry _registry; private readonly CommandDispatcher _dispatcher; private readonly IServerPlanStore _plans; private readonly IBridgeExecutionContextFactory _contexts; private readonly IBridgeContextProvider _contextProvider; private readonly ICommandConfirmationPrompt _confirmationPrompt; private readonly IConfirmationTokenIssuer _confirmationIssuer; private readonly ISystemClock _clock; private readonly BridgeOperationalPolicy _operations; private readonly IBridgeOperationalAuditSink _operationalAudit; private readonly CommandRolloutPolicy _rolloutPolicy; private readonly WorkflowUatAuthorizationPolicy _uatPolicy; private readonly object _executionSyncRoot = new object(); public AgentBridgeRuntime( CommandRegistry registry, CommandDispatcher dispatcher, IServerPlanStore plans, IBridgeExecutionContextFactory contexts, IBridgeContextProvider contextProvider, ICommandConfirmationPrompt confirmationPrompt, IConfirmationTokenIssuer confirmationIssuer, ISystemClock clock) : this( registry, dispatcher, plans, contexts, contextProvider, confirmationPrompt, confirmationIssuer, clock, BridgeOperationalPolicy.CreateDefault(clock), new NullBridgeOperationalAuditSink(), CommandRolloutPolicy.AllowAll(), WorkflowUatAuthorizationPolicy.Disabled(clock)) { } public AgentBridgeRuntime( CommandRegistry registry, CommandDispatcher dispatcher, IServerPlanStore plans, IBridgeExecutionContextFactory contexts, IBridgeContextProvider contextProvider, ICommandConfirmationPrompt confirmationPrompt, IConfirmationTokenIssuer confirmationIssuer, ISystemClock clock, BridgeOperationalPolicy operations) : this( registry, dispatcher, plans, contexts, contextProvider, confirmationPrompt, confirmationIssuer, clock, operations, new NullBridgeOperationalAuditSink(), CommandRolloutPolicy.AllowAll(), WorkflowUatAuthorizationPolicy.Disabled(clock)) { } public AgentBridgeRuntime( CommandRegistry registry, CommandDispatcher dispatcher, IServerPlanStore plans, IBridgeExecutionContextFactory contexts, IBridgeContextProvider contextProvider, ICommandConfirmationPrompt confirmationPrompt, IConfirmationTokenIssuer confirmationIssuer, ISystemClock clock, BridgeOperationalPolicy operations, IBridgeOperationalAuditSink operationalAudit) : this( registry, dispatcher, plans, contexts, contextProvider, confirmationPrompt, confirmationIssuer, clock, operations, operationalAudit, CommandRolloutPolicy.AllowAll(), WorkflowUatAuthorizationPolicy.Disabled(clock)) { } public AgentBridgeRuntime( CommandRegistry registry, CommandDispatcher dispatcher, IServerPlanStore plans, IBridgeExecutionContextFactory contexts, IBridgeContextProvider contextProvider, ICommandConfirmationPrompt confirmationPrompt, IConfirmationTokenIssuer confirmationIssuer, ISystemClock clock, BridgeOperationalPolicy operations, IBridgeOperationalAuditSink operationalAudit, CommandRolloutPolicy rolloutPolicy) : this( registry, dispatcher, plans, contexts, contextProvider, confirmationPrompt, confirmationIssuer, clock, operations, operationalAudit, rolloutPolicy, WorkflowUatAuthorizationPolicy.Disabled(clock)) { } public AgentBridgeRuntime( CommandRegistry registry, CommandDispatcher dispatcher, IServerPlanStore plans, IBridgeExecutionContextFactory contexts, IBridgeContextProvider contextProvider, ICommandConfirmationPrompt confirmationPrompt, IConfirmationTokenIssuer confirmationIssuer, ISystemClock clock, BridgeOperationalPolicy operations, IBridgeOperationalAuditSink operationalAudit, CommandRolloutPolicy rolloutPolicy, WorkflowUatAuthorizationPolicy uatPolicy) { if (registry == null) throw new ArgumentNullException("registry"); if (dispatcher == null) throw new ArgumentNullException("dispatcher"); if (plans == null) throw new ArgumentNullException("plans"); if (contexts == null) throw new ArgumentNullException("contexts"); if (contextProvider == null) throw new ArgumentNullException("contextProvider"); if (confirmationPrompt == null) throw new ArgumentNullException("confirmationPrompt"); if (confirmationIssuer == null) throw new ArgumentNullException("confirmationIssuer"); if (clock == null) throw new ArgumentNullException("clock"); if (operations == null) throw new ArgumentNullException("operations"); if (operationalAudit == null) throw new ArgumentNullException("operationalAudit"); if (rolloutPolicy == null) throw new ArgumentNullException("rolloutPolicy"); if (uatPolicy == null) throw new ArgumentNullException("uatPolicy"); _registry = registry; _dispatcher = dispatcher; _plans = plans; _contexts = contexts; _contextProvider = contextProvider; _confirmationPrompt = confirmationPrompt; _confirmationIssuer = confirmationIssuer; _clock = clock; _operations = operations; _operationalAudit = operationalAudit; _rolloutPolicy = rolloutPolicy; _uatPolicy = uatPolicy; } public BridgeResponse Handle(BridgeRequest request) { if (request == null) return BridgeResponse.Error(null, "invalid_request", "请求不能为空。"); if (!string.Equals(request.ProtocolVersion, ProtocolVersion, StringComparison.Ordinal)) return BridgeResponse.Error(request, "protocol_version_unsupported", "不支持的桥协议版本。"); if (!SafeEnvelopeIdentifier.IsMatch(request.RequestId ?? string.Empty) || !SafeEnvelopeIdentifier.IsMatch(request.CorrelationId ?? string.Empty) || !SafeEnvelopeIdentifier.IsMatch(request.ClientSessionId ?? string.Empty) || !SafeMethod.IsMatch(request.Method ?? string.Empty)) { return BridgeResponse.Error( request, "invalid_request", "requestId、correlationId、clientSessionId 或 method 格式无效。"); } if (request.Payload == null) request.Payload = new JObject(); if (request.UatGrant != null && request.Method != "command.plan" && request.Method != "command.execute") return BridgeResponse.Error( request, "workflow_uat_grant_not_applicable", "UAT 用例令牌只能用于受控计划或执行请求。"); BridgeResponse sessionScopeFailure = ValidateSessionScope(request); if (sessionScopeFailure != null) return sessionScopeFailure; BridgePolicyDecision admission = _operations.Admit( request.ClientSessionId); if (!admission.Allowed) { SafeOperationalAudit( request, "operational_rejected", null, "request", admission.Code, admission.AuditRecommended); return BridgeResponse.Error( request, admission.Code, admission.Message); } try { _plans.PurgeExpired(_clock.UtcNow); switch (request.Method.Trim().ToLowerInvariant()) { case "health": return Health(request); case "capabilities.list": return Capabilities(request); case "context.get": return Context(request); case "command.plan": return Plan(request); case "command.execute": // ERP UI 本身是单线程;串行化还能确保同一 plan 的并发重试 // 不会重复弹出确认窗口。第一个请求完成后计划即被移除。 lock (_executionSyncRoot) return Execute(request); default: return BridgeResponse.Error(request, "method_not_found", "未注册桥方法:" + request.Method); } } catch (CommandKernelException ex) { return BridgeResponse.Error(request, ex.Code, ex.Message); } catch (Exception) { return BridgeResponse.Error(request, "internal_error", "ERP 命令桥发生内部错误,请使用关联 ID 查询审计日志。"); } } private BridgeResponse ValidateSessionScope(BridgeRequest request) { string method = (request.Method ?? string.Empty).Trim() .ToLowerInvariant(); bool bootstrapAllowed = method == "health" || method == "context.get"; if (string.IsNullOrWhiteSpace(request.SessionScopeToken)) { return bootstrapAllowed ? null : BridgeResponse.Error( request, "bridge_session_scope_token_required", "能力、计划和执行请求必须绑定当前 ERP 会话作用域。"); } if (!BridgeSessionScopeToken.IsValid(request.SessionScopeToken)) { return BridgeResponse.Error( request, "bridge_session_scope_token_invalid", "ERP 会话作用域令牌格式无效。"); } try { CommandExecutionContext context = _contexts.Create(request); if (!BridgeSessionScopeToken.Matches( request.SessionScopeToken, context)) { return BridgeResponse.Error( request, "erp_session_scope_mismatch", "请求绑定的数据库、用户、账套、子系统或权限范围与当前 ERP 会话不一致。"); } return null; } catch (CommandKernelException error) { return BridgeResponse.Error(request, error.Code, error.Message); } catch { return BridgeResponse.Error( request, "erp_session_scope_unavailable", "当前 ERP 会话作用域无法安全确认。"); } } private BridgeResponse Health(BridgeRequest request) { EnsurePayloadShape(request.Payload); BridgeOperationalSnapshot policy = _operations.Snapshot(); int enabledCommands = _registry.List().Count(item => _operations.IsCommandVisible( item.Name, IsExecutableRisk(item.Risk)) && _uatPolicy.IsVisibleInGeneralCapabilities(item.Name)); return BridgeResponse.Ok(request, new { status = "ready", protocolVersion = ProtocolVersion, serverTimeUtc = _clock.UtcNow, commandCount = _registry.List().Count, enabledCommandCount = enabledCommands, operationalPolicy = new { requestsPerMinute = policy.RequestsPerMinute, maximumTrackedSessions = policy.MaximumTrackedSessions, trackedSessionCount = policy.TrackedSessionCount, disabledCommandCount = policy.DisabledCommandCount, openCircuitCount = policy.OpenCircuitCount, circuitFailureThreshold = policy.CircuitFailureThreshold, circuitOpenSeconds = policy.CircuitOpenSeconds }, rolloutPolicy = new { configured = _rolloutPolicy.Configured, failClosed = _rolloutPolicy.Configured && string.Equals( _rolloutPolicy.DefaultAction, "deny", StringComparison.Ordinal), customerId = _rolloutPolicy.CustomerId, databaseScopeFingerprint = _rolloutPolicy.DatabaseScopeFingerprint, sourceSha256 = _rolloutPolicy.SourceSha256, defaultAction = _rolloutPolicy.DefaultAction, ruleCount = _rolloutPolicy.RuleCount }, workflowUat = _uatPolicy.SafeSnapshot() }); } private BridgeResponse Capabilities(BridgeRequest request) { EnsurePayloadShape(request.Payload); CommandExecutionContext context = _contexts.Create(request); IList commands = _registry.List() .Where(item => _dispatcher.CheckAuthorization(item, context, false).Allowed) .Where(item => _operations.IsCommandVisible( item.Name, IsExecutableRisk(item.Risk))) .Where(item => _uatPolicy.IsVisibleInGeneralCapabilities( item.Name)) .ToList(); return BridgeResponse.Ok(request, new { commands = commands.Select(item => new { name = item.Name, version = item.Version, description = item.Description, schemaVersion = item.SchemaVersion, inputSchema = item.InputSchema, risk = item.Risk.ToString().ToLowerInvariant(), requiresConfirmation = item.RequiresConfirmation, requiresIdempotencyKey = item.RequiresIdempotencyKey }).ToList() }); } private BridgeResponse Context(BridgeRequest request) { EnsurePayloadShape(request.Payload); CommandExecutionContext context = _contexts.Create(request); return BridgeResponse.Ok(request, _contextProvider.Snapshot(context)); } private BridgeResponse Plan(BridgeRequest request) { EnsurePayloadShape(request.Payload, "command", "input"); string commandName = RequiredString( request.Payload, "command", 128, SafeCommandName); JToken inputToken = request.Payload["input"]; if (inputToken != null && inputToken.Type != JTokenType.Object) throw new CommandKernelException( "invalid_request", "command.plan 的 input 必须是 JSON 对象。", 2); JObject inputObject = inputToken as JObject ?? new JObject(); IDictionary input = inputObject.ToObject>(); EnsurePlanOperationalCommand(commandName, request); try { CommandExecutionContext context = _contexts.Create(request); WorkflowUatPlanBinding uatBinding = _uatPolicy.AuthorizePlan( request, context, commandName); CommandPlan plan = _dispatcher.Plan(commandName, input, context); _uatPolicy.BindPlan(plan, uatBinding); if (IsExecutionAllowed(plan)) _plans.Save(plan); RecordCommandSuccess( request, commandName, BridgeCommandStage.Plan); return BridgeResponse.Ok(request, new { plan = ProjectPlan(plan) }); } catch (CommandKernelException error) { RecordCommandFailure( request, commandName, BridgeCommandStage.Plan, error.Code); throw; } catch { RecordCommandFailure( request, commandName, BridgeCommandStage.Plan, "internal_error"); throw; } } private BridgeResponse Execute(BridgeRequest request) { EnsurePayloadShape(request.Payload, "planId", "idempotencyKey"); string planId = RequiredString( request.Payload, "planId", 32, SafePlanId); string idempotencyKey = RequiredString( request.Payload, "idempotencyKey", 128, SafeIdempotencyKey); CommandPlan plan; if (!_plans.TryGet(planId, out plan)) return BridgeResponse.Error(request, "plan_not_found", "计划不存在或已过期,请重新生成预览。"); if (!IsExecutionAllowed(plan)) { _plans.Remove(plan.PlanId); return BridgeResponse.Error(request, "plan_not_executable", "该计划是解析或只读预览,不能执行。"); } ICommandHandler handler = _registry.Resolve(plan.CommandName); if (handler == null) { _plans.Remove(plan.PlanId); return BridgeResponse.Error(request, "command_not_found", "计划对应的命令已不可用。"); } BridgePolicyDecision availability = _operations.TryEnterCommand( plan.CommandName, BridgeCommandStage.Execute); if (!availability.Allowed) { if (BridgeErrorRecoveryContract.InvalidatesPlan( availability.Code)) _plans.Remove(plan.PlanId); AuditPolicyRejection( request, plan.CommandName, BridgeCommandStage.Execute, availability); return BridgeResponse.Error( request, availability.Code, availability.Message); } try { CommandExecutionContext context = _contexts.Create(request); _uatPolicy.AuthorizeExecute(request, context, plan); context.IdempotencyKey = idempotencyKey; _dispatcher.ValidateExecutionPreconditions(plan, context); if (handler.Descriptor.RequiresConfirmation) { bool confirmed = _confirmationPrompt.Confirm( handler.Descriptor, plan, context); if (!confirmed) { RecordCommandFailure( request, plan.CommandName, BridgeCommandStage.Execute, "user_cancelled"); return BridgeResponse.Error( request, "user_cancelled", "用户已取消操作。"); } context.ConfirmationToken = _confirmationIssuer.Issue( plan, context, TimeSpan.FromMinutes(2)); } CommandResult result = _dispatcher.Execute(plan, context); if (!result.Success) throw new CommandKernelException( "command_result_invalid", "ERP 命令处理器没有返回可确认的成功结果,请使用关联 ID 查询审计。", 6); if (result.Success) _plans.Remove(plan.PlanId); RecordCommandSuccess( request, plan.CommandName, BridgeCommandStage.Execute); string followupCode; CommandPlan followup = TryPlanFollowup( handler, plan, result, request, out followupCode); return BridgeResponse.Ok(request, new { result = ProjectResult(result), followupPlan = followup == null ? null : ProjectPlan(followup), followupCode = followupCode }); } catch (CommandKernelException error) { if (BridgeErrorRecoveryContract.InvalidatesPlan(error.Code)) _plans.Remove(plan.PlanId); RecordCommandFailure( request, plan.CommandName, BridgeCommandStage.Execute, error.Code); throw; } catch { _plans.Remove(plan.PlanId); RecordCommandFailure( request, plan.CommandName, BridgeCommandStage.Execute, "internal_error"); throw; } } private CommandPlan TryPlanFollowup( ICommandHandler completedHandler, CommandPlan completedPlan, CommandResult completedResult, BridgeRequest request, out string failureCode) { failureCode = null; if (completedResult == null || !completedResult.Success) return null; ICommandExecutionFollowupProvider provider = completedHandler as ICommandExecutionFollowupProvider; if (provider == null) return null; string followupCommandName = null; try { CommandExecutionContext followupContext = _contexts.Create(request); CommandFollowupRequest followup; if (!provider.TryCreateFollowup( completedPlan, completedResult, followupContext, out followup) || followup == null || string.IsNullOrWhiteSpace(followup.CommandName)) return null; followupCommandName = followup.CommandName; EnsurePlanOperationalCommand(followupCommandName, request); WorkflowUatPlanBinding uatBinding = _uatPolicy.AuthorizePlan( request, followupContext, followupCommandName); CommandPlan plan = _dispatcher.Plan( followupCommandName, followup.Input ?? new Dictionary(), followupContext); _uatPolicy.BindPlan(plan, uatBinding); if (IsExecutionAllowed(plan)) _plans.Save(plan); RecordCommandSuccess( request, followupCommandName, BridgeCommandStage.Plan); return plan; } catch (CommandKernelException error) { // 主写入已成功,后续计划失败不能把整个请求伪装成写入失败。 if (!string.IsNullOrWhiteSpace(followupCommandName)) RecordCommandFailure( request, followupCommandName, BridgeCommandStage.Plan, error.Code); failureCode = error.Code; return null; } catch { if (!string.IsNullOrWhiteSpace(followupCommandName)) RecordCommandFailure( request, followupCommandName, BridgeCommandStage.Plan, "internal_error"); failureCode = "followup_plan_failed"; return null; } } private static object ProjectPlan(CommandPlan plan) { object title; object preview; plan.Data.TryGetValue("title", out title); plan.Data.TryGetValue("preview", out preview); return new { planId = plan.PlanId, commandName = plan.CommandName, commandVersion = plan.CommandVersion, moduleCode = plan.ModuleCode, risk = plan.Risk.ToString().ToLowerInvariant(), createdAtUtc = plan.CreatedAtUtc, expiresAtUtc = plan.ExpiresAtUtc, valid = plan.Valid, executionAllowed = IsExecutionAllowed(plan), inputFingerprint = plan.InputFingerprint, outcomeCode = ProjectOutcomeCode(plan), title = title, preview = preview, data = plan.Data, warnings = plan.Warnings }; } private static string ProjectOutcomeCode(CommandPlan plan) { object raw; string value = plan != null && plan.Data.TryGetValue("outcomeCode", out raw) ? raw as string : null; if (!string.IsNullOrWhiteSpace(value) && SafeOutcomeCode.IsMatch(value)) return value; return plan != null && plan.Valid ? "plan_ready" : "plan_invalid"; } private static bool IsExecutionAllowed(CommandPlan plan) { if (plan == null || !plan.Valid) return false; return plan.Risk == CommandRisk.Navigate || plan.Risk == CommandRisk.Write || plan.Risk == CommandRisk.Critical; } private static bool IsExecutableRisk(CommandRisk risk) { return risk == CommandRisk.Navigate || risk == CommandRisk.Write || risk == CommandRisk.Critical; } private void EnsureOperationalCommand( string commandName, BridgeCommandStage stage, BridgeRequest request) { BridgePolicyDecision decision = _operations.TryEnterCommand( commandName, stage); if (decision.Allowed) return; AuditPolicyRejection(request, commandName, stage, decision); throw new CommandKernelException( decision.Code, decision.Message, 6); } private void EnsurePlanOperationalCommand( string commandName, BridgeRequest request) { BridgePolicyDecision planning = _operations.CheckCommandAvailability( commandName, BridgeCommandStage.Plan); if (!planning.Allowed) { AuditPolicyRejection( request, commandName, BridgeCommandStage.Plan, planning); throw new CommandKernelException( planning.Code, planning.Message, 6); } ICommandHandler handler = _registry.Resolve(commandName); if (handler != null && handler.Descriptor != null && IsExecutableRisk(handler.Descriptor.Risk)) { BridgePolicyDecision execution = _operations.CheckCommandAvailability( commandName, BridgeCommandStage.Execute); if (!execution.Allowed) { AuditPolicyRejection( request, commandName, BridgeCommandStage.Execute, execution); throw new CommandKernelException( execution.Code, execution.Message, 6); } } EnsureOperationalCommand( commandName, BridgeCommandStage.Plan, request); } private void AuditPolicyRejection( BridgeRequest request, string commandName, BridgeCommandStage stage, BridgePolicyDecision decision) { if (decision == null || decision.Allowed) return; bool recommended = _operations.ShouldAuditCommandRejection( commandName, stage, decision.Code); SafeOperationalAudit( request, "operational_rejected", commandName, StageName(stage), decision.Code, recommended); } private void RecordCommandSuccess( BridgeRequest request, string commandName, BridgeCommandStage stage) { if (!_operations.RecordCommandSuccess(commandName, stage)) return; SafeOperationalAudit( request, "command_circuit_closed", commandName, StageName(stage), "command_circuit_closed", true); } private void RecordCommandFailure( BridgeRequest request, string commandName, BridgeCommandStage stage, string errorCode) { if (!_operations.RecordCommandFailure( commandName, stage, errorCode)) return; SafeOperationalAudit( request, "command_circuit_opened", commandName, StageName(stage), "command_circuit_open", true); } private void SafeOperationalAudit( BridgeRequest request, string eventName, string commandName, string stage, string outcomeCode, bool recommended) { if (!recommended || request == null) return; CommandExecutionContext context = null; try { context = _contexts.Create(request); } catch { } if (context == null) { context = new CommandExecutionContext { CorrelationId = request.CorrelationId, ClientSessionId = request.ClientSessionId }; } try { _operationalAudit.RecordOperationalEvent( eventName, request.Method, commandName, stage, outcomeCode, context); } catch { // A rejection must remain fail-closed even if its best-effort // operational audit cannot be appended. Duplicate rejection // suppression prevents the rejection path becoming a log DoS. } } private static string StageName(BridgeCommandStage stage) { return stage == BridgeCommandStage.Execute ? "execute" : "plan"; } private static object ProjectResult(CommandResult result) { return new { success = result.Success, code = result.Code, message = result.Message, recordId = result.RecordId, replayed = result.Replayed, transactionEvidenceId = result.TransactionEvidenceId, businessAuditId = result.BusinessAuditId, data = result.Data }; } private static void EnsurePayloadShape(JObject payload, params string[] allowedNames) { if (payload == null) throw new CommandKernelException( "invalid_request", "桥请求 payload 必须是 JSON 对象。", 2); ISet allowed = new HashSet( allowedNames ?? new string[0], StringComparer.Ordinal); foreach (JProperty property in payload.Properties()) { if (!allowed.Contains(property.Name)) throw new CommandKernelException( "invalid_request", "桥请求 payload 包含未知字段:" + property.Name, 2); } } private static string RequiredString( JObject source, string name, int maximumLength, Regex pattern) { JToken token = source == null ? null : source[name]; if (token == null || token.Type != JTokenType.String) throw new CommandKernelException("invalid_request", "缺少字段:" + name, 2); string value = token.Value(); if (string.IsNullOrWhiteSpace(value)) throw new CommandKernelException("invalid_request", "缺少字段:" + name, 2); value = value.Trim(); if (value.Length > maximumLength || pattern == null || !pattern.IsMatch(value)) throw new CommandKernelException( "invalid_request", "桥请求字段格式无效:" + name, 2); return value; } } }