diff --git a/.env.example b/.env.example index 9e450327..06b1ab3c 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,12 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory A2AT_CRED_KEY=your-32-byte-hex-key-here # Keep true for production HTTPS. Set false only for local/self-signed integration testing. +# For LoadPsop HTTPS, false skips both server trust and hostname checks; no local CA file is needed. +# The server still needs a TLS certificate. This does not bypass mTLS or change forwarding-platform TLS. A2A_SSL_VERIFY=false # Comma-separated classpath:, file: URI, or absolute file locations. Leave empty for bundled cards. # For a live OMC, point the city AgentCards at files exported by the registry center. A2A_AGENT_CARD_LOCATIONS= +# Embedded startup validates both effective AgentCards against local network interfaces. +# Set false for an external OMC, including one running on the same host. Empty defaults to true. +A2A_EMBEDDED_OMC_ENABLED= diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index 4584beab..aca2b997 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -283,7 +283,7 @@ Override to receive real-time execution events. Event types are defined in `Even | `AUTHORIZATION_RESOLVED` | Authorization decision was made | | `NOTIFICATION` | Notification received from agent | | `ROUTE_DECISION` | Route decision was made | -| `WORKFLOW_COMPLETE` | The workflow completed (all steps finished) | +| `WORKFLOW_COMPLETE` | DAG scheduling ended; check success, not all nodes necessarily ran | | `START` | Workflow execution started | | `COMPLETE` | Workflow execution completed successfully | | `ERROR` | Workflow execution failed | @@ -321,6 +321,12 @@ static List search( POST `/api/v1/orchestrate/search`. Returns ranked workflow summaries matched by natural-language intent. +LoadPsop convenience overloads default to `sslVerify=true`, using JVM trust and hostname checks. +Explicit `false` skips certificate-chain and hostname verification on that orchestration HTTPS connection only, +allowing development without a local CA file. The server still needs a TLS certificate; this does not bypass +mTLS, modify JVM-wide defaults, or change other clients' TLS policies. Production must verify trust and matching SANs. +This is distinct from the engine's southbound HTTP/JSON-RPC verification policy. + ### RegistryClient The two-argument constructor uses a 30-second complete-response deadline. Override it with @@ -555,6 +561,12 @@ Canonical negotiation metadata is `templateUri` plus ## Error Handling +- Top-level remote problem responses retain their fields in `RemoteProblemException`; + `findIn(Throwable)` finds it through wrappers. Workflow failures use `remote.problem.`, + with details in history / TASK_RESPONSE rather than business outputs. + Executor node events include executionId; task response events and history include the logical taskId. + See [integration guidance](INTEGRATION_GUIDE.md#remote-problem-responses) for propagation and logging. + - Missing/null/exceptional/timed-out callbacks fail explicitly. Host SDK errors may be mapped to BusinessFailure with safe code/details. - Exhausted maxNegotiationExchanges or local Stop does not generate Abort. Business Send (Abort) is never diagnosis diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index 1dd8bcc4..2e4c1f46 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -528,6 +528,79 @@ Construct final MessageContent (parts, metadata, extensions), with host-owned co handler or SDK instance registration. A2atMessages.from copies A2A-T metadata; other extensions can directly supply metadata and activation URIs. AgentCard declarations never cause implicit generation. +## External OMC startup checks + +### Orchestration-center HTTPS development + +The demo passes `A2A_SSL_VERIFY` (Spring property `a2a.ssl-verify`) to LoadPsop.search/load. +For controlled development, set `A2A_SSL_VERIFY=false` or `--a2a.ssl-verify=false`. +No local orchestration CA file is required: both calls skip chain and hostname checks, including +self-signed certificates, missing SANs and mismatched SANs, and emit a `[Registry] INSECURE_TLS` warning. + +The HTTPS server must still present a certificate, and client-certificate requirements (mTLS) still apply. +This mode cannot authenticate the server and is vulnerable to interception; do not use it in production. +Keep verification enabled in production, configure matching server SANs and trust its CA in the running JVM. +The southbound `caCertsPath` setting is not forwarded to LoadPsop. +LoadPsop never changes JVM-wide SSLContext, SocketFactory or HostnameVerifier defaults. +Southbound HTTP/JSON-RPC retains hostname checks when chain verification is disabled; vendor forwarding TLS +remains managed by the vendor SDK/platform. + +### Downstream OMC startup + +Set `A2A_AGENT_CARD_LOCATIONS` in the environment or local .env to comma-separated external +AgentCard file locations. For SpringSpnDemo, set `A2A_EMBEDDED_OMC_ENABLED=false` or pass +`--a2a.embedded-omc-enabled=false` when using externally managed OMCs. +Use SpringWorkbenchApplication for a persistent host; the demo exits after its task. + +Embedded startup validates both effective city cards before starting either OMC, then uses those +cards to bind. Missing cards, invalid URLs and non-local addresses fail early. Loopback and local +network-interface addresses are accepted; non-loopback does not automatically mean remote. +This does not probe OMCs or detect existing services. Disable embedded startup even when an externally +managed OMC runs on this same machine. Keep real cards and credentials outside tracked sample resources. + +## Remote problem responses + +A successful HTTP envelope does not guarantee a successful task: SSE data can contain a top-level +problem object such as `{"status":429,"detail":"Active task limit reached","type":""}`. +Top-level numeric status 400–599 with title/detail becomes `RemoteProblemException`, preserving +status, title, detail, type and timestamp. Both synchronous and streaming responses are checked. +Nested business data is not classified as a protocol error. +Detection is limited to bodies actually delivered by the SDK. A non-2xx HTTP response may be rejected +first as an HTTP/authentication error by the SDK or adapter, without exposing its problem body; +such failures retain the generic failure path rather than fabricating errorDetails. + +The error fails the call without fabricating a successful task, invoking onNegotiation, or automatically +resubmitting it. For 400, inspect business inputs; for 429, inspect OMC capacity and existing tasks or +subscriptions before deciding whether to retry. Negotiation still requires a valid Negotiation-T Propose. +Use `RemoteProblemException.findIn(error)` to inspect the cause chain (null means no problem found); +workflow execution follows its existing failure path. +Task results and execution history / TASK_RESPONSE events expose `remote.problem.400`, +`remote.problem.429`, etc. as errorCode, the reason as error, the five fields above as errorDetails, +and empty outputs. Known credential fields use the protocol logger's redaction rules; +unrecognized SDK exceptions still expose only the exception type. +Independent authorization/subscription failures do not determine the workflow result. +Pretty logging does not modify the received problem payload. + +In the two-city sample, one failed diagnosis does not interrupt the other already dispatched call: +its result or timeout is collected before the workflow returns `success=false`. +The merge_analysis onSelfTask/LLM is not invoked; successful peer results remain in history, +not in a fabricated overall diagnosis. There is no automatic retry, queuing or partial-success merge. +Generic AnySuccess nodes retain their existing first-success semantics. + +Observe failures through TASK_RESPONSE or inspect result.history in `onFinish(result, events)`; +onTask prepares outbound content and is not an error-retry callback. +Executor node events include executionId; TASK_RESPONSE and history also include the logical taskId. +The sample's northbound A2A task returns FAILED with per-agent errorCode, error and errorDetails +in its status message, without a success-summary artifact. Demo shutdown closes subscriptions; +a persistent host owns them independently of workflow results. + +Logging responsibilities: PROTOCOL captures observed traffic, correlated by requestId rather than +adjacent log entries; REMOTE_PROBLEM reports transport rejection; TASK_FAILED identifies the execution, +step, logical task, agent, code and reason; WORKFLOW_STOPPED lists unexecuted steps. +Sample TASK_RESPONSE logs bridge contextId and executionId. Recognized remote problems use WARN summaries, +while unexpected exceptions retain ERROR stack traces. Known credentials and Bearer/Basic values are redacted. +Logging configuration, pretty display and observer callbacks do not determine task outcomes. + ## 13. Interface Reference | Interface/Class | Purpose | diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index e8caf69d..b50ff8d3 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -272,7 +272,7 @@ public class EventCallback { | `AUTHORIZATION_RESOLVED` | 授权决策已做出 | | `NOTIFICATION` | 收到智能体通知 | | `ROUTE_DECISION` | 路由决策已做出 | -| `WORKFLOW_COMPLETE` | 工作流完成(所有步骤结束) | +| `WORKFLOW_COMPLETE` | DAG 调度结束,需检查 success;不代表全部节点成功执行 | | `START` | 工作流执行开始 | | `COMPLETE` | 工作流执行成功完成 | | `ERROR` | 工作流执行失败 | @@ -310,6 +310,11 @@ static List search( POST `/api/v1/orchestrate/search`。返回按自然语言意图匹配的工作流摘要列表。 +LoadPsop 的简便重载默认 `sslVerify=true`,使用 JVM 信任库并校验主机名。 +显式传 `false` 时,仅该编排中心 HTTPS 连接跳过证书链和主机名校验,可免配本地 CA 文件联调。 +服务端仍须提供 HTTPS 证书;此开关不绕过 mTLS,也不修改其他客户端或 JVM 的全局 TLS 策略。 +生产环境必须保持验证并配置正确的服务端 SAN 与信任库;本设置不是引擎南向 HTTP/JSON-RPC 的 TLS 策略变更。 + ### RegistryClient 双参数构造默认使用 30 秒完整响应截止时间。可通过 @@ -536,6 +541,11 @@ getReceivedMessages () 是保留层级的响应来源,getOutputs () 为便利 ## 错误处理 +- 顶层远端 problem 错误通过 `RemoteProblemException` 保留;可用 `findIn(Throwable)` 查找包装异常中的原因。 + 工作流任务失败信息为 `remote.problem.`,详情进入 history / TASK_RESPONSE,不作为业务 outputs。 + 引擎节点事件携带 executionId,任务结果事件和 history 携带逻辑 taskId。 + 失败传播、双地市汇总规则和日志职责见 [集成指南](INTEGRATION_GUIDE.md#远端错误响应)。 + - 回调异常/空值/超时明确失败;SDK 内容错误由宿主转换为 BusinessFailure,必要时保留安全的 code/details。 - maxNegotiationExchanges 耗尽或本地 Stop 只结束本地交互,不自动发送 Abort。业务 Send (Abort) 不算诊断成功。 - 远端状态和错误与业务输出分开,失败状态消息不成为 outputs,已有部分 artifact 保留。 diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 620fc21c..9dd72fa2 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -509,6 +509,79 @@ Workflow workflow = LoadPsop.load( 实例。A2atMessages.from 是 A2A-T metadata 复制辅助;非 A2A-T 扩展也可直接提供 metadata 和激活 URI。引擎不会仅因 AgentCard 声明而自动生成内容。 +## 远端 OMC 联调与启动校验 + +### 编排中心 HTTPS 联调 + +Demo 将 `A2A_SSL_VERIFY`(Spring 属性 `a2a.ssl-verify`)传给 LoadPsop.search/load。 +受控开发环境可设置 `A2A_SSL_VERIFY=false` 或启动参数 `--a2a.ssl-verify=false`: +执行引擎端不用下载或配置编排中心证书,搜索与加载连接均跳过证书链及主机名校验, +包括自签名、缺少 SAN 或 SAN 不匹配的开发证书,并打印 `[Registry] INSECURE_TLS` 警告。 + +“免配本地证书”不表示 HTTPS 服务端不需要证书,也不能绕过要求客户端证书的 mTLS。 +该非验证模式不能确认服务端身份,存在中间人风险,仅用于受控联调。 +生产保持 `true`,给服务端配置匹配实际 URL 的 SAN,并让运行 JVM 信任其 CA; +引擎南向 `caCertsPath` 不会自动传给 LoadPsop。 +LoadPsop 不修改 JVM 全局 SSLContext、默认 SocketFactory 或 HostnameVerifier; +引擎南向 HTTP/JSON-RPC 的 `false` 仍仅跳过证书链校验,自定义转发适配器的 TLS 由其 SDK/平台管理。 + +### 下游 OMC 启动 + +在 IDEA 环境变量或本地 .env 中设置 `A2A_AGENT_CARD_LOCATIONS`,以逗号分隔外部 AgentCard 文件路径。 +运行 SpringSpnDemo 对接外部 OMC 时,显式设置 `A2A_EMBEDDED_OMC_ENABLED=false`, +或使用 Program arguments `--a2a.embedded-omc-enabled=false`;不会为这些下游节点创建本地 JDK Server。 +持续接收任务和保持订阅应运行 SpringWorkbenchApplication,而不是执行一次后退出的 Demo。 + +启用内嵌 OMC 时,Demo 在启动任意 OMC 前校验两张实际加载的地市卡片,并使用其地址启动。 +非本机地址、缺失卡片或非法 URL 会提前失败;回环地址和本机网卡地址都可绑定, +不以“是否 127.0.0.1”判断远端。校验不探测 OMC,也不能判定本机某端口是否已有真实服务: +即使外部 OMC 恰好部署在本机,也必须显式关闭内嵌服务。 +真实地址与凭据应放在本地外部文件,不覆盖仓库示例,不提交。 + +## 远端错误响应 + +响应头成功不代表任务成功。HTTP 200 的 SSE data 也可能返回顶层错误对象,例如: + +```json +{"status":429,"detail":"Current active tasks have reached the maximum limit 10.","type":""} +``` + +通信层识别顶层数字 status 为 400–599 且携带 title/detail 的错误对象, +以 `RemoteProblemException` 保留 status、title、detail、type、timestamp。 +同步响应和流式响应均检查;普通任务内容或 artifact 内嵌的同名字段不被当成协议错误。 +该识别以 SDK 实际交付的正文为限:真实 HTTP 非 2xx 可能先被 SDK/适配器作为 HTTP 或认证错误拒绝, +不保证可取得 problem 正文;没有该正文时仍走原有通用失败路径,不伪造 errorDetails。 +错误不生成成功结果、不触发 onNegotiation,也不自动重新提交请求。400 应核对业务参数; +429 应检查 OMC 活跃任务配额及已有任务/订阅,再由业务决定是否重试。 +只有合法 Negotiation-T Propose 才进入协商回调。 + +直接调用客户端时可用 `RemoteProblemException.findIn(error)` 检查 cause 链(没有则返回 null); +工作流执行时沿现有失败路径报告错误: +任务结果及执行 history / TASK_RESPONSE 事件中的 errorCode 为 `remote.problem.400`、 +`remote.problem.429` 等,error 包含原因,errorDetails 包含上述五个字段,outputs 为空。 +错误内容中的已知凭据字段按协议日志的规则脱敏;未识别的 SDK 异常仍只暴露异常类型。 +预置授权、通知订阅的错误仍由其独立生命周期处理,不决定工作流执行结果。 +协议 pretty 日志仅用于展示,不改写原始收到的错误对象。 + +**当前双地市示例的失败流程**:两个诊断并行下发,一方失败后记录该方错误; +已经下发的另一方继续等待结果或超时。当前批次结束后返回 `success=false`, +不进入 `merge_analysis`,也不调用该节点的 onSelfTask/LLM。成功地市的结果留在 history 中, +不冒充整体诊断结论。此处没有自动重试、排队或“部分成功后汇总”策略; +通用引擎的 AnySuccess 节点仍按原有任一成功规则执行。 + +集成方通过 TASK_RESPONSE 事件及时观察任务失败,或在 `onFinish(result, events)` +检查 `result.history`;onTask 是发送前准备消息的回调,不是失败重试回调。 +引擎节点事件带 executionId;TASK_RESPONSE 和 history 还带逻辑 taskId。 +Demo 的北向 A2A 任务返回 FAILED,状态消息包含失败地市的 errorCode、error 和 errorDetails, +不产生成功汇总 artifact。整个演示退出时会关闭订阅;常驻宿主中的订阅由独立生命周期管理。 + +**日志职责**:PROTOCOL 记录已观察到的请求/响应,按 requestId 关联并发报文; +REMOTE_PROBLEM 记录通信层识别的远端拒绝,TASK_FAILED 记录执行 ID、节点、逻辑任务 ID、 +智能体、错误码和原因,WORKFLOW_STOPPED 列出未执行节点。示例 TASK_RESPONSE 日志同时包含 +contextId 与 executionId,供跨层定位。已识别的远端问题使用 WARN 摘要, +未知异常保留 ERROR 堆栈;错误详情中的已知凭据及 Bearer/Basic 值脱敏。 +日志开关、pretty 展示和观察回调不会改变任务结果。 + ## 13. 你需要使用的接口一览 | 接口/类 | 用途 | diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/EmbeddedOmcSupport.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/EmbeddedOmcSupport.java new file mode 100644 index 00000000..16a34207 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/EmbeddedOmcSupport.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import dev.openan.workflow.engine.examples.server.LocalServerAddress; +import dev.openan.workflow.engine.examples.workbench.WorkbenchAgentCatalog; +import java.util.List; +import org.a2aproject.sdk.spec.AgentCard; + +/** Preflights both demo OMCs before either local server or business executor starts. */ +final class EmbeddedOmcSupport { + private static final List AGENTS = + List.of("SPN Domain Agent City1", "SPN Domain Agent City2"); + + private EmbeddedOmcSupport() {} + + static boolean enabled(String[] args, boolean defaultValue) { + String configured = null; + for (String arg : args) { + if (arg.startsWith("--a2a.embedded-omc-enabled=")) { + configured = arg.substring("--a2a.embedded-omc-enabled=".length()); + if (configured.isBlank()) { + throw new IllegalArgumentException("--a2a.embedded-omc-enabled must be true or false"); + } + } + } + if (configured == null) configured = System.getProperty("A2A_EMBEDDED_OMC_ENABLED"); + if (configured == null || configured.isBlank()) configured = System.getenv("A2A_EMBEDDED_OMC_ENABLED"); + if (configured == null || configured.isBlank()) return defaultValue; + if ("true".equalsIgnoreCase(configured.trim())) return true; + if ("false".equalsIgnoreCase(configured.trim())) return false; + throw new IllegalArgumentException("A2A_EMBEDDED_OMC_ENABLED must be true or false"); + } + + static List prepare(boolean enabled) { + return enabled ? validateTargets(new WorkbenchAgentCatalog().load()) : List.of(); + } + + static List validateTargets(List cards) { + return AGENTS.stream() + .map(name -> { + AgentCard card = cards.stream().filter(candidate -> name.equals(candidate.name())) + .findFirst().orElseThrow(() -> new IllegalArgumentException( + "Embedded OMC AgentCard missing: " + name + + "; provide both demo cards or set A2A_EMBEDDED_OMC_ENABLED=false")); + if (card.supportedInterfaces() == null || card.supportedInterfaces().isEmpty()) { + throw new IllegalArgumentException("Embedded OMC AgentCard has no interface: " + name); + } + LocalServerAddress.requireLocalEndpoint( + card.supportedInterfaces().get(0).url(), "Embedded OMC " + name); + return card; + }) + .toList(); + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java index 4b564284..658daac0 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemo.java @@ -90,29 +90,33 @@ public void run(String... applicationArgs) throws Exception { long demoStarted = System.nanoTime(); ConfigurableApplicationContext ctx = null; boolean success = false; - boolean demoNegotiation = SpnCasePrompts.demoNegotiationEnabled(true); + boolean embeddedOmc = EmbeddedOmcSupport.enabled(applicationArgs, true); + List embeddedCards = EmbeddedOmcSupport.prepare(embeddedOmc); + boolean demoNegotiation = SpnCasePrompts.demoNegotiationEnabled(embeddedOmc); log.info( "[Demo] NEGOTIATION_DEMO enabled={}, city={} (local default: City1 negotiates, City2 diagnoses directly; " + "disable with -Da2at.samples.negotiation=false)", demoNegotiation, System.getProperty("a2at.samples.negotiation.city", "city1")); log.info( - "[Demo] START mode=direct, " - + "workbench=https://127.0.0.1:26337/a2a/json, omcPorts=[26335,26336]"); + "[Demo] START mode=direct, embeddedOmc={}, workbench=https://127.0.0.1:26337/a2a/json", + embeddedOmc); try { long stageStarted; - stageStarted = System.nanoTime(); - log.info("[Demo] STAGE_START stage=start-omc-agents"); - omc.startFromResource( - "agentcard/spn_domain_agent_city1.json", new SpnDomainAgentCity1Executor()); - omc.startFromResource( - "agentcard/spn_domain_agent_city2.json", new SpnDomainAgentCity2Executor()); - log.info("[Demo] Waiting {}s for agent ports to bind", STARTUP_WAIT); - TimeUnit.SECONDS.sleep(STARTUP_WAIT); - log.info( - "[Demo] STAGE_DONE stage=start-omc-agents, count={}, elapsedMs={}", - omc.servers().size(), - elapsedMillis(stageStarted)); + if (embeddedOmc) { + stageStarted = System.nanoTime(); + log.info("[Demo] STAGE_START stage=start-omc-agents"); + omc.startFromCard(embeddedCards.get(0), new SpnDomainAgentCity1Executor()); + omc.startFromCard(embeddedCards.get(1), new SpnDomainAgentCity2Executor()); + log.info("[Demo] Waiting {}s for agent ports to bind", STARTUP_WAIT); + TimeUnit.SECONDS.sleep(STARTUP_WAIT); + log.info( + "[Demo] STAGE_DONE stage=start-omc-agents, count={}, elapsedMs={}", + omc.servers().size(), + elapsedMillis(stageStarted)); + } else { + log.info("[Demo] STAGE_SKIP stage=start-omc-agents, reason=external-omc, mode=direct"); + } stageStarted = System.nanoTime(); log.info("[Demo] STAGE_START stage=start-spring-workbench"); @@ -152,7 +156,7 @@ public void run(String... applicationArgs) throws Exception { success = true; // Task result is already final. This bounded observation only keeps the local // demonstration alive long enough to show its independent recovery notification. - { + if (embeddedOmc) { boolean observed = ctx.getBean(SpringWorkbenchExtensionLifecycle.class) .awaitFirstRecovery(java.time.Duration.ofSeconds(10)); diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/server/LocalServerAddress.java b/samples/src/main/java/dev/openan/workflow/engine/examples/server/LocalServerAddress.java new file mode 100644 index 00000000..0d7d5513 --- /dev/null +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/LocalServerAddress.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.server; + +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.URI; + +/** Validates local listener addresses without opening a socket or contacting an OMC. */ +public final class LocalServerAddress { + private LocalServerAddress() {} + + /** Requires every resolved address to belong to this host, including loopback and wildcard. */ + public static void requireLocalHost(String host, String setting) { + if (host == null || host.isBlank()) { + throw new IllegalArgumentException(setting + " must specify a local bind host"); + } + try { + for (InetAddress address : InetAddress.getAllByName(host)) { + if (!address.isLoopbackAddress() + && !address.isAnyLocalAddress() + && NetworkInterface.getByInetAddress(address) == null) { + throw new IllegalArgumentException( + setting + " cannot bind a non-local address; for an external OMC set " + + "A2A_EMBEDDED_OMC_ENABLED=false; for an external platform disable its simulator"); + } + } + } catch (java.io.IOException e) { + throw new IllegalArgumentException(setting + " cannot resolve a local bind address", e); + } + } + + /** Validates an advertised HTTP(S) endpoint before using it as a local server address. */ + public static URI requireLocalEndpoint(String url, String setting) { + URI uri; + try { + uri = URI.create(url); + } catch (RuntimeException e) { + throw new IllegalArgumentException(setting + " must be an absolute HTTP(S) URL"); + } + if (uri.getHost() == null || uri.getUserInfo() != null + || !("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) + || uri.getPort() > 65535) { + throw new IllegalArgumentException(setting + " must be an absolute HTTP(S) URL without user info"); + } + requireLocalHost(uri.getHost(), setting); + return uri; + } +} diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java b/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java index cedca55f..cca7a63d 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/server/OmcAgentLauncher.java @@ -67,9 +67,10 @@ public JdkHttpA2AServer startFromCard(Map card, AgentExecutor ex (List>) card.getOrDefault("supportedInterfaces", List.of()); String url = ifaces.isEmpty() ? "https://127.0.0.1:0" : String.valueOf(ifaces.get(0).get("url")); - java.net.URI uri = java.net.URI.create(url); + java.net.URI uri = LocalServerAddress.requireLocalEndpoint(url, "Embedded OMC AgentCard"); String host = uri.getHost() != null ? uri.getHost() : "127.0.0.1"; - int port = Math.max(uri.getPort(), 0); + int port = + uri.getPort() >= 0 ? uri.getPort() : ("https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80); JdkHttpA2AServer server = new JdkHttpA2AServer(host, port, card, executor); server.start(); servers.add(server); @@ -80,6 +81,16 @@ public List servers() { return servers; } + /** Starts an already loaded and validated card, including externally configured local cards. */ + public JdkHttpA2AServer startFromCard(AgentCard card, AgentExecutor executor) throws Exception { + return startFromCard( + MAPPER.readValue( + com.google.protobuf.util.JsonFormat.printer() + .print(org.a2aproject.sdk.grpc.utils.ProtoUtils.ToProto.agentCard(card)), + new com.fasterxml.jackson.core.type.TypeReference>() {}), + executor); + } + @Override public void close() { for (JdkHttpA2AServer server : servers) { diff --git a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java index 5592b7c8..e518fd2a 100644 --- a/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java +++ b/samples/src/main/java/dev/openan/workflow/engine/examples/workbench/WorkbenchOrchestrator.java @@ -119,6 +119,7 @@ static String buildResultText(ExecutionResult result) { .append(", Status: ") .append(h.get("status")) .append("\n"); + appendFailureDetails(sb, h); } } if (result.getError() != null) { @@ -127,6 +128,22 @@ static String buildResultText(ExecutionResult result) { return sb.toString(); } + // The host renders safe engine failure facts, never treats them as diagnosis output or LLM input. + private static void appendFailureDetails(StringBuilder text, Map history) { + for (String field : List.of("errorCode", "error", "errorDetails")) { + Object value = history.get(field); + if (value == null || value instanceof Map map && map.isEmpty()) continue; + String rendered = renderOutput(value); + if (!rendered.isBlank()) { + text.append(" ") + .append(field) + .append(": ") + .append(dev.openan.workflow.engine.client.WireLog.redact(rendered)) + .append("\n"); + } + } + } + private static String outputText(Map output) { if (output == null || output.isEmpty()) { return ""; @@ -295,7 +312,7 @@ String run(String messageText, boolean demoNegotiationEnabled, ExecutionCancella .lang("zh") .sslVerify(sslVerify) .credentialsConfigPath(credentialsPath) - .eventCallback(createLogCallback()) + .eventCallback(createLogCallback(transport.getContextId())) .onFinish( (r, events) -> { log.info( @@ -363,7 +380,7 @@ private ResolvedWorkflow resolveWorkflow(String messageText) { return new ResolvedWorkflow(FALLBACK_PSOP_ID, fallbackWorkflow()); } - private EventCallback createLogCallback() { + private EventCallback createLogCallback(String contextId) { return new EventCallback() { @Override public void onEvent(String type, Map data) { @@ -371,7 +388,16 @@ public void onEvent(String type, Map data) { case EventType.START -> log.info(" [START] {}", data.get("workflow")); case EventType.STEP_START -> log.info(" [STEP_START] {}", data.get("step")); case EventType.TASK_REQUEST -> log.info(" [TASK_REQUEST] agent={}", data.get("agent")); - case EventType.TASK_RESPONSE -> log.info(" [TASK_RESPONSE] agent={}", data.get("agent")); + case EventType.TASK_RESPONSE -> + log.info( + " [TASK_RESPONSE] contextId={}, executionId={}, step={}, taskId={}, agent={}, success={}, errorCode={}", + contextId, + data.get("executionId"), + data.get("step"), + data.get("taskId"), + data.get("agent"), + data.get("success"), + data.get("errorCode")); case EventType.AGENT_STATUS_UPDATE -> log.info( " [STATUS_UPDATE] agent={}, state={}, final={}", @@ -392,7 +418,13 @@ public void onEvent(String type, Map data) { case EventType.ROUTE_DECISION -> log.info(" [ROUTE] {} -> {}", data.get("step"), data.get("next")); case EventType.COMPLETE -> log.info(" [COMPLETE]"); - case EventType.ERROR -> log.error(" [ERROR] {}", data.get("error")); + case EventType.ERROR -> + log.error( + " [ERROR] contextId={}, step={}, reason={}", + contextId, + data.getOrDefault("step", "(workflow)"), + dev.openan.workflow.engine.client.WireLog.redact( + String.valueOf(data.getOrDefault("error", "Execution failed")))); case EventType.CLOSE -> log.info(" [CLOSE]"); default -> {} } diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/EmbeddedOmcSupportTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/EmbeddedOmcSupportTest.java new file mode 100644 index 00000000..abd3a638 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/EmbeddedOmcSupportTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.openan.workflow.engine.client.AgentCardJacksonModule; +import java.util.List; +import org.a2aproject.sdk.spec.AgentCard; +import org.junit.jupiter.api.Test; + +class EmbeddedOmcSupportTest { + @Test + void explicitFlagControlsStartupAndRejectsTypos() { + assertFalse(EmbeddedOmcSupport.enabled(new String[] {"--a2a.embedded-omc-enabled=false"}, true)); + assertTrue(EmbeddedOmcSupport.enabled(new String[] {"--a2a.embedded-omc-enabled=true"}, false)); + assertThrows(IllegalArgumentException.class, + () -> EmbeddedOmcSupport.enabled(new String[] {"--a2a.embedded-omc-enabled=treu"}, true)); + } + + @Test + void externalModeDoesNotLoadBundledOrExternalCardsForBinding() { + String previous = System.getProperty("A2A_AGENT_CARD_LOCATIONS"); + System.setProperty("A2A_AGENT_CARD_LOCATIONS", "file:/missing-card-must-not-be-read.json"); + try { + assertEquals(List.of(), EmbeddedOmcSupport.prepare(false)); + } finally { + if (previous == null) System.clearProperty("A2A_AGENT_CARD_LOCATIONS"); + else System.setProperty("A2A_AGENT_CARD_LOCATIONS", previous); + } + } + + @Test + void validatesBothCardsBeforeStartingEitherServer() throws Exception { + var city1 = card("city1", "127.0.0.1:26335"); + var city2 = card("city2", "192.0.2.17:26336"); + assertThrows(IllegalArgumentException.class, + () -> EmbeddedOmcSupport.validateTargets(List.of(city1, city2))); + assertThrows(IllegalArgumentException.class, + () -> EmbeddedOmcSupport.validateTargets(List.of(city1))); + } + + @Test + void usesConfiguredLocalCardsInsteadOfBundledDefaultPorts() throws Exception { + var city1 = card("city1", "localhost:27335"); + var city2 = card("city2", "127.0.0.1:27336"); + assertEquals(List.of(city1, city2), EmbeddedOmcSupport.validateTargets(List.of(city2, city1))); + } + + @Test + void launchesTypedCardWithoutLosingSecurityScheme() throws Exception { + var card = card("city1", "127.0.0.1:0"); + try (var launcher = new dev.openan.workflow.engine.examples.server.OmcAgentLauncher()) { + var server = launcher.startFromCard(card, + new org.a2aproject.sdk.server.agentexecution.AgentExecutor() { + @Override public void execute( + org.a2aproject.sdk.server.agentexecution.RequestContext context, + org.a2aproject.sdk.server.tasks.AgentEmitter emitter) { + throw new AssertionError("Binding must not dispatch a task"); + } + @Override public void cancel( + org.a2aproject.sdk.server.agentexecution.RequestContext context, + org.a2aproject.sdk.server.tasks.AgentEmitter emitter) {} + }); + assertEquals(card.name(), server.getAgentName()); + var schemes = (java.util.Map) server.getAgentCard().get("securitySchemes"); + var bearer = (java.util.Map) schemes.values().iterator().next(); + assertTrue(bearer.containsKey("httpAuthSecurityScheme")); + } + } + + private AgentCard card(String city, String address) throws Exception { + try (var input = getClass().getResourceAsStream("/agentcard/spn_domain_agent_" + city + ".json")) { + String json = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + json = json.replace(city.equals("city1") ? "127.0.0.1:26335" : "127.0.0.1:26336", address); + return new com.fasterxml.jackson.databind.ObjectMapper() + .registerModule(new AgentCardJacksonModule()).readValue(json, AgentCard.class); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java index daad54d5..4822529b 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoE2ETest.java @@ -25,21 +25,30 @@ /** Runs the actual northbound Spring demo with local OMCs and the offline SDK provider. */ class SpringSpnDemoE2ETest { + private dev.openan.workflow.engine.examples.testsupport.CapturedLogs captured; + + @org.junit.jupiter.api.BeforeEach + void captureLogs() { + captured = new dev.openan.workflow.engine.examples.testsupport.CapturedLogs(); + } + + @org.junit.jupiter.api.AfterEach + void closeCapture() { + captured.close(); + } @Test @Timeout(120) void defaultLocalDemoNegotiatesOnlyCity1WithoutChangingGlobalSwitch() throws Exception { String enabled = System.getProperty("a2at.samples.negotiation"); String city = System.getProperty("a2at.samples.negotiation.city"); - java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); - long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + int offset = captured.length(); System.clearProperty("a2at.samples.negotiation"); System.clearProperty("a2at.samples.negotiation.city"); try { runDemo(); - try (var input = java.nio.file.Files.newInputStream(log)) { - input.skipNBytes(offset); - String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + { + String evidence = captured.since(offset); org.junit.jupiter.api.Assertions.assertTrue( evidence.contains("NEGOTIATION_DEMO enabled=true, city=city1")); org.junit.jupiter.api.Assertions.assertTrue( @@ -76,14 +85,12 @@ void defaultLocalDemoNegotiatesOnlyCity1WithoutChangingGlobalSwitch() throws Exc @Timeout(120) void bothCitiesNegotiateIndependentlyAndComplete() throws Exception { String previous = System.getProperty("a2at.samples.negotiation.city"); - java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); - long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + int offset = captured.length(); System.setProperty("a2at.samples.negotiation.city", "both"); try { missingPortNegotiatesAndCompletesThroughTheSameSpringWorkflow(); - try (var input = java.nio.file.Files.newInputStream(log)) { - input.skipNBytes(offset); - String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + { + String evidence = captured.since(offset); for (String city : java.util.List.of("City1", "City2")) { org.junit.jupiter.api.Assertions.assertTrue( evidence.contains("[SpnDomainAgent" + city + "Executor] NEGOTIATION_APPLIED"), city); @@ -102,15 +109,13 @@ void bothCitiesNegotiateIndependentlyAndComplete() throws Exception { @Test @Timeout(120) void missingPortNegotiatesAndCompletesThroughTheSameSpringWorkflow() throws Exception { - java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); - long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + int offset = captured.length(); String previous = System.getProperty("a2at.samples.negotiation"); System.setProperty("a2at.samples.negotiation", "true"); try { runDemo(); - try (var input = java.nio.file.Files.newInputStream(log)) { - input.skipNBytes(offset); - String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + { + String evidence = captured.since(offset); org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("DEMO_NEGOTIATION")); org.junit.jupiter.api.Assertions.assertTrue(evidence.contains("TASK_STATE_INPUT_REQUIRED")); org.junit.jupiter.api.Assertions.assertTrue( @@ -175,14 +180,12 @@ protected String[] arguments() { @Timeout(120) void northboundComplaintCompletesAndResourcesClose() throws Exception { String previous = System.getProperty("a2at.samples.negotiation"); - java.nio.file.Path log = java.nio.file.Path.of("logs/spn-demo.log"); - long offset = java.nio.file.Files.exists(log) ? java.nio.file.Files.size(log) : 0; + int offset = captured.length(); System.setProperty("a2at.samples.negotiation", "false"); try { runDemo(); - try (var input = java.nio.file.Files.newInputStream(log)) { - input.skipNBytes(offset); - String evidence = new String(input.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + { + String evidence = captured.since(offset); org.junit.jupiter.api.Assertions.assertTrue( evidence.contains("NEGOTIATION_DEMO enabled=false")); org.junit.jupiter.api.Assertions.assertFalse(evidence.contains("DEMO_NEGOTIATION agent=")); diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java new file mode 100644 index 00000000..acde806a --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/demo/SpringSpnDemoFailureE2ETest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.demo; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import dev.openan.workflow.engine.client.A2ATExtension; +import dev.openan.workflow.engine.examples.testsupport.CapturedLogs; +import dev.openan.workflow.engine.examples.testsupport.OfflineA2ATLlmClient; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Actual northbound Spring request through the engine to two HTTP OMC fixtures. No live LLM or OMC. */ +class SpringSpnDemoFailureE2ETest { + private static final ObjectMapper JSON = new ObjectMapper(); + + @TempDir Path temp; + + protected String[] arguments(String city1, String city2) throws Exception { + return new String[] { + "--a2a.transport-mode=direct", + "--a2a.embedded-omc-enabled=false", + "--a2a.orch-url=http://127.0.0.1:1", + "--a2a.a2at-env-path=" + OfflineA2ATLlmClient.envPath() + }; + } + + @ParameterizedTest + @ValueSource(ints = {400, 429}) + @org.junit.jupiter.api.Timeout(60) + void remoteProblemReachesNorthboundFailureWithoutMergeOrResubmission(int status) throws Exception { + var firstCalls = new AtomicInteger(); + var secondCalls = new AtomicInteger(); + HttpServer city1 = omc(status, firstCalls); + HttpServer city2 = omc(0, secondCalls); + String first = "http://127.0.0.1:" + city1.getAddress().getPort(); + String second = "http://127.0.0.1:" + city2.getAddress().getPort(); + Map previous = new java.util.HashMap<>(); + try (var logs = new CapturedLogs()) { + set(previous, "A2A_AGENT_CARD_LOCATIONS", + writeCard("city1", first).toUri() + "," + writeCard("city2", second).toUri()); + set(previous, "a2at.env.path", OfflineA2ATLlmClient.envPath()); + set(previous, "a2at.samples.negotiation", "false"); + OfflineA2ATLlmClient.install(); + var error = assertThrows(IllegalStateException.class, + () -> new SpringSpnDemo().run(arguments(first, second))); + assertTrue(error.getMessage().contains("TASK_STATE_FAILED"), error.getMessage()); + assertTrue(error.getMessage().contains("remote.problem." + status), error.getMessage()); + assertTrue(error.getMessage().contains(reason(status)), error.getMessage()); + assertTrue(error.getMessage().contains("diagnosis_city2")); + assertEquals(1, firstCalls.get()); + assertEquals(1, secondCalls.get()); + String evidence = logs.since(0); + assertTrue(evidence.contains("OPERATION_FAILED extension=Authorization-T")); + assertTrue(evidence.contains("OPERATION_FAILED extension=Notification-T")); + assertTrue(evidence.contains("[Executor] TASK_FAILED executionId=")); + assertTrue(evidence.contains("errorCode=remote.problem." + status)); + assertTrue(evidence.contains("skippedSteps=[merge_analysis]")); + assertTrue(evidence.contains("[Orchestrator] WORKFLOW_FINISH success=false, history=2")); + assertTrue(evidence.contains("=== SSE data (JSON display; not wire text) ===")); + assertTrue(evidence.contains("\"status\": " + status)); + assertTrue(evidence.contains("[SpringWorkbench] TASK_FAILED")); + assertTrue(evidence.contains("[Demo] SHUTDOWN_DONE success=false")); + assertFalse(evidence.contains("[ERROR] null")); + assertFalse(evidence.contains("executionId=null")); + assertFalse(evidence.contains("Dispatching task: step=merge_analysis")); + assertFalse(evidence.contains("\"name\": \"cross-city-diagnosis-summary\"")); + assertFalse(evidence.contains("NEGOTIATION_APPLIED")); + var failures = java.util.regex.Pattern.compile("\\[DIRECT_HTTP\\] FAILURE requestId=([a-f0-9-]+)") + .matcher(evidence).results().map(match -> match.group(1)).toList(); + assertEquals(failures.size(), new java.util.HashSet<>(failures).size(), + "body and future errors must not duplicate one HTTP failure log"); + } finally { + previous.forEach((key, value) -> { + if (value == null) System.clearProperty(key); + else System.setProperty(key, value); + }); + city1.stop(0); + city2.stop(0); + } + } + + private static void set(Map previous, String key, String value) { + previous.put(key, System.getProperty(key)); + System.setProperty(key, value); + } + + private Path writeCard(String city, String target) throws Exception { + try (var input = getClass().getResourceAsStream("/agentcard/spn_domain_agent_" + city + ".json")) { + var card = (com.fasterxml.jackson.databind.node.ObjectNode) JSON.readTree(input); + ((com.fasterxml.jackson.databind.node.ObjectNode) card.path("supportedInterfaces").get(0)) + .put("url", target + "/a2a/json"); + // Auth is covered by dedicated direct/Order tests; these fixtures exercise task failure propagation. + card.remove(List.of("securitySchemes", "securityRequirements")); + Path file = temp.resolve(city + ".json"); + JSON.writeValue(file.toFile(), card); + return file; + } + } + + private static String reason(int status) { + return status == 400 ? "该端口下未发现业务信息,请校验业务的接入网元、端口信息是否正确" + : "Current active tasks have reached the maximum limit 10. Please try again later."; + } + + private static HttpServer omc(int status, AtomicInteger calls) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + var request = JSON.readTree(exchange.getRequestBody()); + boolean task = request.path("message").path("metadata").has(A2ATExtension.TASK_T.uri()); + String response; + if (!task) { + // Deliberate pre-position failures must not prevent the subsequent workflow tasks. + response = JSON.writeValueAsString(Map.of("status", 503, "detail", "Independent operation rejected")); + } else if (status != 0) { + calls.incrementAndGet(); + response = JSON.writeValueAsString(Map.of( + "status", status, "detail", reason(status), "type", "", "timestamp", "2026-08-31T09:07:35Z")); + } else { + calls.incrementAndGet(); + response = JSON.writeValueAsString(Map.of("task", Map.of( + "id", "city2-diagnosis", "contextId", request.path("message").path("contextId").asText(), + "status", Map.of("state", "TASK_STATE_COMPLETED"), + "artifacts", List.of(Map.of("artifactId", "diagnosis", "parts", List.of(Map.of("text", "City2 diagnosis"))))))); + } + byte[] bytes = ("data: " + response + "\n\n").getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/event-stream;charset=UTF-8"); + exchange.sendResponseHeaders(200, 0); + try (var output = exchange.getResponseBody()) { output.write(bytes); } + finally { exchange.close(); } + }); + server.start(); + return server; + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/server/LocalServerAddressTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/server/LocalServerAddressTest.java new file mode 100644 index 00000000..cd6731c1 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/server/LocalServerAddressTest.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.server; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.NetworkInterface; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class LocalServerAddressTest { + @Test + void acceptsLoopbackWildcardAndActualInterfaceAddresses() throws Exception { + for (String host : new String[] {"127.0.0.1", "localhost", "::1", "0.0.0.0"}) { + assertDoesNotThrow(() -> LocalServerAddress.requireLocalHost(host, "test")); + } + for (var network : Collections.list(NetworkInterface.getNetworkInterfaces())) { + for (var address : Collections.list(network.getInetAddresses())) { + assertDoesNotThrow(() -> LocalServerAddress.requireLocalHost(address.getHostAddress(), "test")); + } + } + } + + @Test + void rejectsRemoteBindWithoutOpeningASocket() { + var error = assertThrows(IllegalArgumentException.class, + () -> LocalServerAddress.requireLocalHost("192.0.2.17", "Embedded OMC")); + assertTrue(error.getMessage().contains("A2A_EMBEDDED_OMC_ENABLED=false")); + } + + @Test + void rejectsMissingHostInvalidPortAndUserInfo() { + for (String url : new String[] {"not-a-url", "https://", "https://127.0.0.1:65536", + "https://user:secret@127.0.0.1:26335"}) { + var error = assertThrows(IllegalArgumentException.class, + () -> LocalServerAddress.requireLocalEndpoint(url, "test")); + assertFalse(error.getMessage().contains("secret")); + } + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/CapturedLogs.java b/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/CapturedLogs.java new file mode 100644 index 00000000..5dc82898 --- /dev/null +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/testsupport/CapturedLogs.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.examples.testsupport; + +import java.beans.PropertyChangeListener; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.layout.PatternLayout; + +/** Request-local test evidence, unaffected by production log rotation or Spring logging reconfiguration. */ +public final class CapturedLogs implements AutoCloseable { + private final LoggerContext context = (LoggerContext) LogManager.getContext(false); + private final StringBuffer content = new StringBuffer(); + private final AbstractAppender appender = new AbstractAppender( + "test-capture-" + java.util.UUID.randomUUID(), null, + PatternLayout.newBuilder().withPattern("%d %-5level [%t] %logger{1} - %msg%n").build(), + true, Property.EMPTY_ARRAY) { + @Override public void append(LogEvent event) { + content.append(getLayout().toSerializable(event)); + } + }; + private final PropertyChangeListener reconfigured = event -> { + if ("config".equals(event.getPropertyName())) attach(); + }; + + /** Begins capturing without replacing any existing console or protocol appender. */ + public CapturedLogs() { + context.addPropertyChangeListener(reconfigured); + attach(); + } + + private void attach() { + if (context.getConfiguration().getRootLogger().getAppenders().get(appender.getName()) == appender) return; + appender.start(); + context.getConfiguration().getRootLogger().addAppender(appender, null, null); + context.updateLoggers(); + } + + /** Position for reading only the events produced by the next operation. */ + public int length() { return content.length(); } + + /** Returns formatted log events after the given position. */ + public String since(int offset) { return content.substring(offset); } + + /** Detaches test observation; production logging remains enabled. */ + @Override public void close() { + context.removePropertyChangeListener(reconfigured); + context.getConfiguration().getRootLogger().removeAppender(appender.getName()); + context.updateLoggers(); + appender.stop(); + } +} diff --git a/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java index 1dc293cd..4f7e0860 100644 --- a/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java +++ b/samples/src/test/java/dev/openan/workflow/engine/examples/workbench/WorkbenchTaskFlowTest.java @@ -21,6 +21,53 @@ import org.junit.jupiter.api.Test; class WorkbenchTaskFlowTest { + @Test + void failureSummaryPreservesEachCityReasonWithoutPassingPartialOutputsAsDiagnosis() { + var result = + ExecutionResult.builder() + .success(false) + .error("Step execution failed") + .history( + List.of( + Map.of( + "step", + "diagnosis_city1", + "agent", + "city1", + "status", + "failed", + "errorCode", + "remote.problem.429", + "error", + "OMC capacity reached", + "errorDetails", + Map.of( + "status", + 429, + "detail", + "capacity reached", + "type", + "", + "accessSession", + "private-token")), + Map.of( + "step", + "diagnosis_city2", + "agent", + "city2", + "status", + "success", + "outputs", + List.of("partial diagnosis must not masquerade as summary")))) + .build(); + String summary = WorkbenchOrchestrator.buildResultText(result); + assertTrue(summary.contains("city1")); + assertTrue(summary.contains("remote.problem.429")); + assertTrue(summary.contains("capacity reached")); + assertTrue(summary.contains("city2")); + org.junit.jupiter.api.Assertions.assertFalse(summary.contains("private-token")); + org.junit.jupiter.api.Assertions.assertFalse(summary.contains("partial diagnosis")); + } @Test void localFallbackKeepsBothCityDiagnosesParallelBeforeSelfLoopMerge() { diff --git a/samples/src/test/resources/spn_agent_credentials.json b/samples/src/test/resources/spn_agent_credentials.json index 1e035169..ccf69d37 100644 --- a/samples/src/test/resources/spn_agent_credentials.json +++ b/samples/src/test/resources/spn_agent_credentials.json @@ -12,7 +12,7 @@ "ipaddr": "*" }, "token_field": "accessSession", - "order_token_header": "bearToken", + "order_token_header": "accessSession", "token_ttl": 3600 } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java index aa858134..8cbb426e 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/A2ATransport.java @@ -502,11 +502,21 @@ CompletableFuture send( .receivedMessages(ProtocolResponses.assemble(events)) .build(); } catch (Exception e) { - log.error( - "[Transport] Failed to send message to {}: {}", - agentName, - e.getMessage(), - e); + RemoteProblemException problem = RemoteProblemException.findIn(e); + if (problem != null) { + log.warn( + "[Transport] REMOTE_PROBLEM agent={}, contextId={}, status={}, reason={}", + agentName, + contextId, + problem.getStatus(), + problem.getMessage().replace("\r", "\\r").replace("\n", "\\n")); + } else { + log.error( + "[Transport] Failed to send message to {}: {}", + agentName, + e.getMessage(), + e); + } if (e instanceof SecurityException securityException) { throw securityException; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java index f8216fe7..caeb796b 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultA2AJavaClientRuntime.java @@ -214,14 +214,25 @@ private static void onError( error.getMessage()); } else { String msg = error.getMessage() != null ? error.getMessage() : ""; + RemoteProblemException problem = RemoteProblemException.findIn(error); boolean connectionClosed = - msg.contains("connection closed locally") - || msg.contains("chunked transfer encoding, state: READING_LENGTH"); + problem == null + && (msg.contains("connection closed locally") + || msg.contains("chunked transfer encoding, state: READING_LENGTH")); if (connectionClosed) { log.debug("[A2ARuntime] Connection closed for '{}': {}", agentName, msg); } else { errorRef.set(error); - log.error("[A2ARuntime] Error callback for '{}': {}", agentName, error.getMessage(), error); + if (problem != null) { + log.warn( + "[A2ARuntime] REMOTE_PROBLEM agent={}, status={}, reason={}", + agentName, + problem.getStatus(), + problem.getMessage().replace("\r", "\\r").replace("\n", "\\n")); + } else { + log.error( + "[A2ARuntime] Error callback for '{}': {}", agentName, error.getMessage(), error); + } } done.countDown(); } @@ -672,7 +683,7 @@ private A2AHttpClient createHttpClient() { crlPath, Duration.ofSeconds(60), httpClientExecutor); - return new JdkA2AHttpClient(new ObservedHttpClient(httpClient)); + return new ProblemDetectingHttpClient(new JdkA2AHttpClient(new ObservedHttpClient(httpClient))); } private record StreamClientKey(String agentName, String contextId) {} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java index f8aaf3ad..3a42077a 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ObservedHttpClient.java @@ -143,6 +143,8 @@ private final class Exchange { final String id = UUID.randomUUID().toString(); final HttpRequest request; final Map context = WireLog.context(); + final java.util.concurrent.atomic.AtomicBoolean failureLogged = + new java.util.concurrent.atomic.AtomicBoolean(); Exchange(HttpRequest request) { this.request = request; @@ -172,6 +174,8 @@ void emit( } void failure(Throwable error) { + // Body subscriber and sendAsync completion can report the same exchange failure. + if (!failureLogged.compareAndSet(false, true)) return; emit( "FAILURE", null, diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProblemDetectingHttpClient.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProblemDetectingHttpClient.java new file mode 100644 index 00000000..8b00c65e --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProblemDetectingHttpClient.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +import org.a2aproject.sdk.client.http.ServerSentEvent; + +/** Checks problem responses before the SDK's A2A parser; wire logging remains observational. */ +final class ProblemDetectingHttpClient implements A2AHttpClient { + private final A2AHttpClient delegate; + + ProblemDetectingHttpClient(A2AHttpClient delegate) { + this.delegate = delegate; + } + + @Override public GetBuilder createGet() { return new Get(delegate.createGet()); } + @Override public PostBuilder createPost() { return new Post(delegate.createPost()); } + @Override public DeleteBuilder createDelete() { return new Delete(delegate.createDelete()); } + + private static A2AHttpResponse check(A2AHttpResponse response) { + var error = RemoteProblemException.fromPayload(response.body()); + if (error != null) throw error; + return response; + } + + private abstract static class Wrapped> implements Builder { + final T target; + Wrapped(T target) { this.target = target; } + abstract T self(); + @Override public T url(String url) { target.url(url); return self(); } + @Override public T addHeader(String name, String value) { target.addHeader(name, value); return self(); } + @Override public T addHeaders(Map headers) { target.addHeaders(headers); return self(); } + } + + private static final class Get extends Wrapped implements GetBuilder { + Get(GetBuilder target) { super(target); } + @Override GetBuilder self() { return this; } + @Override public A2AHttpResponse get() throws IOException, InterruptedException { return check(target.get()); } + @Override public CompletableFuture getAsyncSSE( + Consumer messages, Consumer errors, Runnable complete) + throws IOException, InterruptedException { + return stream(target::getAsyncSSE, messages, errors, complete); + } + } + + private static final class Post extends Wrapped implements PostBuilder { + Post(PostBuilder target) { super(target); } + @Override PostBuilder self() { return this; } + @Override public PostBuilder body(String body) { target.body(body); return this; } + @Override public A2AHttpResponse post() throws IOException, InterruptedException { return check(target.post()); } + @Override public CompletableFuture postAsyncSSE( + Consumer messages, Consumer errors, Runnable complete) + throws IOException, InterruptedException { + return stream(target::postAsyncSSE, messages, errors, complete); + } + } + + private static final class Delete extends Wrapped implements DeleteBuilder { + Delete(DeleteBuilder target) { super(target); } + @Override DeleteBuilder self() { return this; } + @Override public A2AHttpResponse delete() throws IOException, InterruptedException { return check(target.delete()); } + } + + private static CompletableFuture stream( + StreamStarter starter, Consumer messages, + Consumer errors, Runnable complete) throws IOException, InterruptedException { + var result = new CompletableFuture(); + var upstream = new AtomicReference>(); + var ended = new AtomicBoolean(); + Consumer fail = error -> { + if (!ended.compareAndSet(false, true)) return; + try { errors.accept(error); } + catch (RuntimeException observerError) { + if (observerError != error) error.addSuppressed(observerError); + } + finally { result.completeExceptionally(error); } + }; + result.whenComplete((ignored, error) -> { + if (error != null) { + ended.set(true); + var active = upstream.get(); + if (active != null) active.cancel(true); + } + }); + var active = starter.start(event -> { + if (ended.get()) return; + var problem = RemoteProblemException.fromPayload(event.data()); + if (problem != null) fail.accept(problem); + else { + try { messages.accept(event); } + catch (RuntimeException callbackError) { fail.accept(callbackError); } + } + }, fail, () -> { + if (ended.compareAndSet(false, true)) { + try { complete.run(); result.complete(null); } + catch (RuntimeException error) { result.completeExceptionally(error); } + } + }); + upstream.set(active); + if (result.isCompletedExceptionally()) active.cancel(true); + active.whenComplete((ignored, error) -> { + if (error != null) fail.accept(error); + }); + return result; + } + + @FunctionalInterface + private interface StreamStarter { + CompletableFuture start(Consumer messages, Consumer errors, + Runnable complete) throws IOException, InterruptedException; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/RemoteProblemException.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/RemoteProblemException.java new file mode 100644 index 00000000..245660cc --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/RemoteProblemException.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * A remote problem response, including errors sent as SSE data after HTTP 200 headers. + * Known credential fields are redacted using the same rules as protocol logging. + */ +public final class RemoteProblemException extends RuntimeException { + private static final ObjectMapper JSON = + new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + private final int status; + private final String title; + private final String detail; + private final String type; + private final String timestamp; + + private RemoteProblemException(JsonNode problem) { + super("Remote agent error (status=" + problem.path("status").asInt() + "): " + + text(problem, "title") + + (text(problem, "title").isEmpty() ? "" : " — ") + text(problem, "detail")); + status = problem.path("status").asInt(); + title = text(problem, "title"); + detail = text(problem, "detail"); + type = text(problem, "type"); + timestamp = text(problem, "timestamp"); + } + + /** Returns a top-level problem error, or null for ordinary A2A events and incomplete JSON. */ + public static RemoteProblemException fromPayload(String payload) { + if (payload == null || payload.isBlank()) return null; + JsonNode node; + try { + node = JSON.readTree(payload); + } catch (java.io.IOException ignored) { + return null; + } + if (node == null || !node.isObject() || !node.path("status").isIntegralNumber() + || !node.path("status").canConvertToInt()) return null; + int status = node.path("status").asInt(); + if (status < 400 || status > 599 + || (text(node, "title").isBlank() && text(node, "detail").isBlank())) return null; + for (String envelope : new String[] {"task", "message", "statusUpdate", "artifactUpdate", "result", "error"}) { + if (node.has(envelope)) return null; + } + return new RemoteProblemException(node); + } + + private static String text(JsonNode node, String key) { + return node.path(key).isTextual() ? WireLog.redact(node.path(key).textValue()) : ""; + } + + /** Finds a problem preserved in a transport exception's cause chain, without looping on cycles. */ + public static RemoteProblemException findIn(Throwable error) { + var seen = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap()); + for (Throwable current = error; current != null && seen.add(current); current = current.getCause()) { + if (current instanceof RemoteProblemException problem) return problem; + } + return null; + } + + /** Problem status, which can differ from the HTTP envelope status. */ + public int getStatus() { return status; } + /** Remote error title, or an empty string. */ + public String getTitle() { return title; } + /** Remote business failure detail. */ + public String getDetail() { return detail; } + /** Remote problem type, preserved even when empty. */ + public String getType() { return type; } + /** Remote timestamp when supplied. */ + public String getTimestamp() { return timestamp; } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java index ed0c786d..48a8e5e8 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLog.java @@ -36,6 +36,8 @@ public final class WireLog { private static final Pattern SECRET_FORM = Pattern.compile( "(?i)((?:password|passwd|pwd|[\\w-]*token|[\\w-]*secret|accessSession|api[-_]?key)=)[^&\\s]*"); + private static final Pattern AUTH_VALUE = + Pattern.compile("(?i)(\\b(?:Bearer|Basic)[ \\t]+)[^\\s\"\\\\,;]+"); private WireLog() {} @@ -113,7 +115,7 @@ public static String redact(String text) { cursor = end - 1; } result.append(text, copied, text.length()); - return SECRET_FORM.matcher(result).replaceAll("$1***"); + return AUTH_VALUE.matcher(SECRET_FORM.matcher(result).replaceAll("$1***")).replaceAll("$1***"); } /** Skip a complete JSON value without reserializing the surrounding wire text. */ diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java index b47afdef..14477221 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WireLogFormatter.java @@ -78,7 +78,7 @@ private static String formatFrame(WireLog.Entry entry, boolean pretty) { .append(" requestId=") .append(entry.requestId()) .append( - "\n(envelope logged in the preceding entry; target/status/headers/correlation not repeated)") + "\n(envelope logged separately; correlate target/status/headers using the same requestId)") .append("\nVisibility: ") .append(entry.visibility()) .append("\n=== Body ===\n") diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java index 4d44fd15..a6465690 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/FailureMapping.java @@ -19,16 +19,20 @@ package dev.openan.workflow.engine.core; +import dev.openan.workflow.engine.client.RemoteProblemException; import dev.openan.workflow.engine.model.BusinessFailure; import dev.openan.workflow.engine.model.TaskResult; import java.util.*; -/** Maps only generic failures or explicitly safe host-selected business facts. */ +/** Maps generic failures, remote problem facts, or explicitly safe host-selected business facts. */ final class FailureMapping { static TaskResult from(Throwable error) { Throwable root = error; Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); - while (root.getCause() != null && seen.add(root) && !(root instanceof BusinessFailure)) { + while (root.getCause() != null + && seen.add(root) + && !(root instanceof BusinessFailure) + && !(root instanceof RemoteProblemException)) { root = root.getCause(); } if (root instanceof BusinessFailure business) { @@ -39,6 +43,20 @@ static TaskResult from(Throwable error) { .errorDetails(business.details()) .build(); } + if (root instanceof RemoteProblemException problem) { + return TaskResult.builder() + .success(false) + .errorCode("remote.problem." + problem.getStatus()) + .error(problem.getMessage()) + .errorDetails( + Map.of( + "status", problem.getStatus(), + "title", problem.getTitle(), + "detail", problem.getDetail(), + "type", problem.getType(), + "timestamp", problem.getTimestamp())) + .build(); + } String code = root instanceof java.util.concurrent.TimeoutException ? "workflow.timeout" diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java index 052d6a73..9ea2cff5 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/core/WorkflowExecutor.java @@ -164,7 +164,9 @@ public List> getHistory() { private void emit(String type, Map data) { try { - eventCallback.onEvent(type, data); + Map correlated = new LinkedHashMap<>(data); + correlated.put("executionId", executionId); + eventCallback.onEvent(type, Collections.unmodifiableMap(correlated)); } catch (Exception e) { log.warn("Event callback error: {}", e.getMessage()); } @@ -211,11 +213,22 @@ public CompletableFuture run() { executeSteps(pending, scheduled, executed, activated, failed) .thenApply( v -> { - emit(EventType.WORKFLOW_COMPLETE, Map.of()); + emit(EventType.WORKFLOW_COMPLETE, Map.of("success", !failed.get())); log.info( - "[Executor] Workflow completed: {}, {} task(s) executed", + "[Executor] WORKFLOW_FINISHED executionId={}, workflow={}, success={}, tasks={}", + executionId, workflow.getName(), + !failed.get(), executionHistory.size()); + if (failed.get()) { + log.warn( + "[Executor] WORKFLOW_STOPPED executionId={}, reason=step_failed, skippedSteps={}", + executionId, + java.util.stream.IntStream.range(0, workflow.getSteps().size()) + .filter(index -> !executed.contains(index)) + .mapToObj(index -> workflow.getSteps().get(index).getName()) + .toList()); + } return ExecutionResult.builder() .success(!failed.get()) .history(new ArrayList<>(executionHistory)) @@ -332,8 +345,21 @@ private CompletableFuture executeStep( stepOutputs.put(step.getName(), result.results()); stepExecutionResults.put(step.getName(), result.taskResults()); if (!result.success()) { - log.error("Step {} failed, stopping.", step.getName()); - emit(EventType.ERROR, Map.of("step", step.getName(), "results", result.results())); + log.warn( + "[Executor] STEP_FAILED executionId={}, step={}, action=stop-downstream", + executionId, + step.getName()); + emit( + EventType.ERROR, + Map.of( + "step", + step.getName(), + "results", + result.results(), + "error", + "Step execution failed", + "errorCode", + "workflow.step_failed")); failed.set(true); return CompletableFuture.completedFuture(null); } @@ -571,6 +597,18 @@ private StepResult processTaskResult( task.getStatus().getValue())); String status = response.isSuccess() ? "success" : "failed"; log.info("[Executor] Task {} -> {}: {}", task.getDescription(), task.getAgent(), status); + if (!response.isSuccess()) { + log.warn( + "[Executor] TASK_FAILED executionId={}, step={}, taskId={}, agent={}, errorCode={}, reason={}", + executionId, + step.getName(), + taskId(step.getName(), subtaskIndex), + task.getAgent(), + response.getErrorCode(), + dev.openan.workflow.engine.client.WireLog.redact(response.getError()) + .replace("\r", "\\r") + .replace("\n", "\\n")); + } if (response.isSuccess() && !response.getOutputs().isEmpty()) { log.debug("[Executor] Task outputs from {}: [{}]", task.getAgent(), response.getOutputs()); } @@ -587,6 +625,8 @@ private StepResult processTaskResult( task.getAgent(), "status", status, + "taskId", + taskId(step.getName(), subtaskIndex), "outputs", outputs, "error", @@ -610,6 +650,8 @@ private StepResult processTaskResult( outputs, "success", response.isSuccess(), + "taskId", + taskId(step.getName(), subtaskIndex), "error", response.getError() == null ? "" : response.getError(), "errorCode", diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java index c701426b..d798d0a3 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/registry/LoadPsop.java @@ -42,6 +42,10 @@ *
  • {@code load} -- GET /api/v1/orchestrate/psop/{psop_id} (full workflow) *
  • {@code search} -- POST /api/v1/orchestrate/search (summary list by intent) * + * + *

    Defaults verify the server using JVM trust and hostname checks. Explicit {@code sslVerify=false} + * skips both checks for this connection only, for controlled development without local trust files. + * It does not remove the server's HTTPS certificate or satisfy a server requirement for mTLS. */ public class LoadPsop { private static final Logger log = LoggerFactory.getLogger(LoadPsop.class); @@ -126,9 +130,14 @@ private static HttpResult execute(String method, String url, String jsonBody, bo throws Exception { HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); if (!sslVerify && connection instanceof HttpsURLConnection https) { - // Trust policy and hostname policy remain independent: this accepts an untrusted - // development CA, but still rejects a certificate issued for another host. - https.setSSLSocketFactory(SslContextFactory.createTrustAll().getSocketFactory()); + // Development opt-out is connection-local: never replace JVM-wide TLS defaults. + https.setSSLSocketFactory( + SslContextFactory.create(false, null).orElseThrow().getSocketFactory()); + https.setHostnameVerifier((hostname, session) -> true); + log.warn( + "[Registry] INSECURE_TLS host={}, port={}: certificate and hostname verification disabled; development only", + https.getURL().getHost(), + https.getURL().getPort()); } try { connection.setInstanceFollowRedirects(true); diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java index 34311f20..b8e94cca 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ObservedHttpClientTest.java @@ -56,6 +56,7 @@ void logsActualRequestAndResponseWithoutReencodingOrExposingCredentials() throws exchange.getResponseHeaders().add("X-Multi", "one"); exchange.getResponseHeaders().add("X-Multi", "two"); exchange.getResponseHeaders().add("bearToken", "header-secret"); + exchange.getResponseHeaders().add("accessSession", "session-header-secret"); byte[] bytes = response.getBytes(StandardCharsets.UTF_8); exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); @@ -97,6 +98,7 @@ void logsActualRequestAndResponseWithoutReencodingOrExposingCredentials() throws .orElseThrow(); assertEquals(List.of("one", "two"), received.headers().get("x-multi")); assertEquals(List.of("***"), received.headers().get("beartoken")); + assertEquals(List.of("***"), received.headers().get("accesssession")); assertEquals(1, records.stream().map(WireLog.Entry::requestId).distinct().count()); String logs = records.toString(); assertFalse(logs.contains("login-secret")); diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProblemDetectingHttpClientTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProblemDetectingHttpClientTest.java new file mode 100644 index 00000000..3d8b7779 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProblemDetectingHttpClientTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpResponse; +import org.a2aproject.sdk.client.http.ServerSentEvent; +import org.junit.jupiter.api.Test; + +class ProblemDetectingHttpClientTest { + @Test + void synchronousProblemCancelsUpstreamAndSurvivesAThrowingErrorObserver() throws Exception { + var upstream = new CompletableFuture(); + var failures = new AtomicInteger(); + var events = new AtomicInteger(); + var completions = new AtomicInteger(); + var client = client((messages, errors, complete) -> { + messages.accept(new ServerSentEvent("{\"status\":429,\"detail\":\"capacity reached\"}")); + messages.accept(new ServerSentEvent("{\"message\":{}}")); + complete.run(); + errors.accept(new IllegalStateException("late close")); + return upstream; + }); + var result = client.createPost().postAsyncSSE(event -> events.incrementAndGet(), + error -> { + failures.incrementAndGet(); + throw new IllegalStateException("observer failed"); + }, completions::incrementAndGet); + var problem = RemoteProblemException.findIn(assertThrows(CompletionException.class, result::join)); + assertNotNull(problem); + assertEquals(429, problem.getStatus()); + assertEquals(1, problem.getSuppressed().length); + assertTrue(upstream.isCancelled()); + assertEquals(1, failures.get()); + assertEquals(0, events.get()); + assertEquals(0, completions.get()); + } + + @Test + void cancellationPropagatesAndLateEventsCannotCompleteTheCall() throws Exception { + var upstream = new CompletableFuture(); + var messages = new AtomicReference>(); + var complete = new AtomicReference(); + var callbacks = new AtomicInteger(); + var result = client((m, e, c) -> { + messages.set(m); + complete.set(c); + return upstream; + }).createPost().postAsyncSSE(event -> callbacks.incrementAndGet(), + error -> callbacks.incrementAndGet(), callbacks::incrementAndGet); + assertTrue(result.cancel(true)); + assertTrue(upstream.isCancelled()); + messages.get().accept(new ServerSentEvent("{}")); + complete.get().run(); + assertEquals(0, callbacks.get()); + } + + @Test + void successfulDataIsNotRewrittenOrMistakenForANestedProblem() throws Exception { + String data = "{\"task\":{\"status\":{\"state\":\"TASK_STATE_COMPLETED\"}},\"metadata\":{\"status\":400}}"; + var seen = new AtomicReference(); + var completions = new AtomicInteger(); + var result = client((messages, errors, complete) -> { + messages.accept(new ServerSentEvent(data)); + complete.run(); + return CompletableFuture.completedFuture(null); + }).createPost().postAsyncSSE(event -> seen.set(event.data()), + error -> fail("unexpected error", error), completions::incrementAndGet); + result.join(); + assertEquals(data, seen.get()); + assertEquals(1, completions.get()); + } + + private static A2AHttpClient client(Start start) { + return new ProblemDetectingHttpClient(new A2AHttpClient() { + @Override public GetBuilder createGet() { throw new UnsupportedOperationException(); } + @Override public DeleteBuilder createDelete() { throw new UnsupportedOperationException(); } + @Override public PostBuilder createPost() { + return new PostBuilder() { + @Override public PostBuilder url(String url) { return this; } + @Override public PostBuilder body(String body) { return this; } + @Override public PostBuilder addHeader(String key, String value) { return this; } + @Override public PostBuilder addHeaders(Map values) { return this; } + @Override public A2AHttpResponse post() { throw new UnsupportedOperationException(); } + @Override public CompletableFuture postAsyncSSE( + Consumer messages, Consumer errors, Runnable complete) { + return start.run(messages, errors, complete); + } + }; + } + }); + } + + @FunctionalInterface + private interface Start { + CompletableFuture run(Consumer messages, Consumer errors, Runnable complete); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/RemoteProblemResponseTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/RemoteProblemResponseTest.java new file mode 100644 index 00000000..40046407 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/RemoteProblemResponseTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.a2aproject.sdk.client.http.JdkA2AHttpClient; +import org.a2aproject.sdk.spec.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RemoteProblemResponseTest { + static String problem(int status) { + return "{\"status\":" + status + ",\"detail\":\"该端口下未发现业务信息\"," + + "\"title\":\"Invalid Params\",\"type\":\"\",\"timestamp\":\"2026-08-31T09:07:36Z\"}"; + } + + @Test + void preservesProblemFieldsWithoutInspectingNestedBusinessData() { + var error = RemoteProblemException.fromPayload(problem(400)); + assertNotNull(error); + assertEquals(400, error.getStatus()); + assertEquals("Invalid Params", error.getTitle()); + assertEquals("该端口下未发现业务信息", error.getDetail()); + assertEquals("", error.getType()); + assertEquals("2026-08-31T09:07:36Z", error.getTimestamp()); + assertNull(RemoteProblemException.fromPayload("{\"artifactUpdate\":{\"data\":" + problem(400) + "}}")); + assertNull(RemoteProblemException.fromPayload("{\"status\":{\"state\":\"TASK_STATE_WORKING\"}}")); + assertNull(RemoteProblemException.fromPayload("{\"status\":400}")); + assertNull(RemoteProblemException.fromPayload(problem(200))); + assertNull(RemoteProblemException.fromPayload("{\"status\":429,")); + } + + @ParameterizedTest + @ValueSource(ints = {400, 429, 503}) + void directRuntimeFailsPromptlyWithoutWaitingForSseClosureOrRetrying(int status) throws Exception { + var release = new CountDownLatch(1); + var disconnected = new CountDownLatch(1); + var requests = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requests.incrementAndGet(); + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, 0); + try { + String payload = status == 503 + ? problem(status).replace("该端口下未发现业务信息", "connection closed locally; request rejected") + : problem(status); + byte[] body = ("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8); + exchange.getResponseBody().write(body, 0, 12); + exchange.getResponseBody().flush(); + exchange.getResponseBody().write(body, 12, body.length - 12); + exchange.getResponseBody().flush(); + for (int i = 0; i < 80 && !release.await(100, TimeUnit.MILLISECONDS); i++) { + exchange.getResponseBody().write(": keep-alive\n\n".getBytes(StandardCharsets.UTF_8)); + exchange.getResponseBody().flush(); + } + } catch (java.io.IOException clientClosed) { + disconnected.countDown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + var runtime = new DefaultA2AJavaClientRuntime(true, null, 20, "HTTP+JSON"); + try { + AgentCard card = AgentCard.builder().name("remote-test").description("test").version("1") + .capabilities(AgentCapabilities.builder().streaming(true).build()) + .defaultInputModes(List.of("text/plain")).defaultOutputModes(List.of("text/plain")) + .skills(List.of()).supportedInterfaces(List.of(new AgentInterface("HTTP+JSON", + "http://127.0.0.1:" + server.getAddress().getPort() + "/a2a/json"))).build(); + MessageSendParams params = MessageSendParams.builder().message(Message.builder() + .role(Message.Role.ROLE_USER).messageId("test").parts(new TextPart("diagnose")).build()).build(); + var events = new AtomicInteger(); + var response = CompletableFuture.supplyAsync( + () -> runtime.sendMessage(card, params, null, event -> events.incrementAndGet(), null)); + var thrown = assertThrows(java.util.concurrent.ExecutionException.class, + () -> response.get(3, TimeUnit.SECONDS)); + Throwable cause = thrown; + while (cause.getCause() != null && !(cause instanceof RemoteProblemException)) cause = cause.getCause(); + var problem = assertInstanceOf(RemoteProblemException.class, cause); + assertEquals(status, problem.getStatus()); + assertEquals(0, events.get()); + assertEquals(1, requests.get()); + assertTrue(disconnected.await(3, TimeUnit.SECONDS), "failed SSE must close its HTTP connection"); + } finally { + release.countDown(); + runtime.close(); + server.stop(0); + } + } + + @Test + void causeLookupHandlesNullCyclesAndWrappedProblems() { + var problem = RemoteProblemException.fromPayload(problem(429)); + assertSame(problem, RemoteProblemException.findIn(new IllegalStateException("wrapper", problem))); + assertNull(RemoteProblemException.findIn(null)); + var one = new IllegalStateException("one"); + var two = new IllegalStateException("two", one); + one.initCause(two); + assertNull(RemoteProblemException.findIn(one)); + } + + @Test + void nonStreamingResponsesPreserveTheRemoteBusinessFailure() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + byte[] body = problem(400).getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(400, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + try { + var client = new ProblemDetectingHttpClient(new JdkA2AHttpClient(HttpClient.newHttpClient())); + var error = assertThrows(RemoteProblemException.class, () -> client.createPost() + .url("http://127.0.0.1:" + server.getAddress().getPort()).body("{}").post()); + assertEquals(400, error.getStatus()); + } finally { + server.stop(0); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java index 7ff6940e..8aba0d30 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogFormatterTest.java @@ -115,7 +115,7 @@ void logLayoutIsPrettyButObserversKeepExactRedactedWireText() { // body frames are continuations: the envelope (headers/correlation) was logged by the preceding // entry assertTrue(pretty.startsWith("[DIRECT_HTTP] REQUEST_BODY requestId=req-1\n")); - assertTrue(pretty.contains("(envelope logged in the preceding entry")); + assertTrue(pretty.contains("(envelope logged separately; correlate")); assertFalse(pretty.contains("=== Headers ===")); assertFalse(pretty.contains("hidden")); assertTrue(pretty.contains("\n \"performative\": \"ACCEPT\"")); diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java index c28586eb..8813c6b8 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/WireLogRedactionTest.java @@ -12,6 +12,17 @@ import org.junit.jupiter.api.Test; class WireLogRedactionTest { + @Test + void masksAuthorizationValuesInRemoteErrorText() { + assertEquals( + "Rejected Bearer ***; Basic ***", + WireLog.redact("Rejected Bearer private-token; Basic c2VjcmV0")); + assertTrue( + WireLog.redact("{\"detail\":\"Rejected Bearer private-token\"}").contains("Bearer ***")); + assertFalse( + WireLog.redact("{\"detail\":\"Rejected Bearer private-token\"}").contains("private-token")); + } + @Test void largeEscapedContentDoesNotUseRecursiveRegexBacktracking() { String payload = diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/RemoteProblemFailureTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/RemoteProblemFailureTest.java new file mode 100644 index 00000000..ef6cde1b --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/RemoteProblemFailureTest.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.core; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.openan.workflow.engine.StubWorkflowEngineClient; +import dev.openan.workflow.engine.client.RemoteProblemException; +import dev.openan.workflow.engine.control.ControlPoint; +import dev.openan.workflow.engine.model.*; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class RemoteProblemFailureTest { + @ParameterizedTest + @ValueSource(ints = {400, 429}) + void waitsForTheOtherCityAndSkipsMergeWhileReportingCorrelatedFailure(int status) throws Exception { + var started = new java.util.concurrent.CountDownLatch(2); + var reported = new java.util.concurrent.CountDownLatch(1); + var city1 = new CompletableFuture(); + var city2 = new CompletableFuture(); + var observed = new java.util.concurrent.CopyOnWriteArrayList>(); + var client = new StubWorkflowEngineClient() { + @Override public CompletableFuture dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks) { + started.countDown(); + return request.getAgentName().equals("city1") ? city1 : city2; + } + }; + var merges = new AtomicInteger(); + var callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture(MessageContent.text("diagnose"))) + .onSelfTask(request -> { + merges.incrementAndGet(); + return CompletableFuture.completedFuture(TaskResult.success(List.of("must not merge"))); + }).build(); + var workflow = Workflow.builder().name("parallel-failure").steps(List.of( + city("city1"), city("city2"), + WorkflowStep.builder().name("merge").stepType(StepType.SELF_LOOP) + .subtasks(List.of(Task.builder().agent("host").description("aggregate").build())) + .next(List.of()).build())).build(); + var events = new dev.openan.workflow.engine.control.EventCallback() { + @Override public void onEvent(String type, Map data) { + if (type.equals(dev.openan.workflow.engine.control.EventType.TASK_RESPONSE)) { + observed.add(data); + if (Boolean.FALSE.equals(data.get("success"))) reported.countDown(); + } + if (type.equals(dev.openan.workflow.engine.control.EventType.ERROR)) { + assertNotNull(data.get("error")); + } + } + }; + var execution = new WorkflowExecutor(workflow, callbacks, client, events, "complaint", "zh").run(); + try { + assertTrue(started.await(5, java.util.concurrent.TimeUnit.SECONDS)); + city1.completeExceptionally(RemoteProblemException.fromPayload( + "{\"status\":" + status + ",\"detail\":\"OMC拒绝请求\"}")); + assertTrue(reported.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertFalse(execution.isDone(), "The other in-flight diagnosis must be collected"); + assertFalse(city2.isCancelled()); + city2.complete(SendMessageResult.builder().taskState("TASK_STATE_COMPLETED") + .receivedMessages(List.of(new ReceivedMessage( + MessageContent.text("city2 diagnosis"), Map.of(), List.of()))).build()); + var result = execution.get(5, java.util.concurrent.TimeUnit.SECONDS); + assertFalse(result.isSuccess()); + assertEquals(2, result.getHistory().size()); + assertEquals(0, merges.get()); + assertFalse(result.getStepOutputs().containsKey("merge")); + assertEquals(2, observed.size()); + for (var event : observed) { + assertNotNull(event.get("executionId")); + assertNotNull(event.get("taskId")); + } + assertEquals(observed.get(0).get("executionId"), observed.get(1).get("executionId")); + assertNotEquals(observed.get(0).get("taskId"), observed.get(1).get("taskId")); + var success = result.getHistory().stream().filter(h -> h.get("agent").equals("city2")).findFirst().orElseThrow(); + assertEquals("success", success.get("status")); + assertEquals(List.of("city2 diagnosis"), success.get("outputs")); + } finally { + city1.cancel(true); + city2.cancel(true); + execution.cancel(true); + } + } + + private static WorkflowStep city(String name) { + return WorkflowStep.builder().name(name).stepType(StepType.ALL_SUCCESS) + .subtasks(List.of(Task.builder().agent(name).description("diagnose").build())) + .next(List.of(new JumpCondition("merge", ""))).build(); + } + @ParameterizedTest + @ValueSource(ints = {400, 429}) + void remoteFailureReachesWorkflowHistoryWithoutNegotiationOrSuccessfulOutputs(int status) { + var problem = RemoteProblemException.fromPayload( + "{\"status\":" + status + ",\"detail\":\"OMC拒绝请求\",\"type\":\"\"}"); + var calls = new AtomicInteger(); + var negotiations = new AtomicInteger(); + var client = new StubWorkflowEngineClient("omc") { + @Override + public CompletableFuture dispatch( + TaskRequest request, MessageContent content, ControlPoint callbacks) { + calls.incrementAndGet(); + return CompletableFuture.failedFuture(new CompletionException(problem)); + } + }; + var step = WorkflowStep.builder().name("diagnose").stepType(StepType.ALL_SUCCESS) + .subtasks(List.of(Task.builder().agent("omc").description("diagnose").build())) + .next(List.of()).build(); + var workflow = Workflow.builder().name("remote-failure").steps(List.of(step)).build(); + var callbacks = ControlPoint.builder() + .onTask(request -> CompletableFuture.completedFuture(MessageContent.text("diagnose"))) + .onNegotiation(request -> { + negotiations.incrementAndGet(); + return CompletableFuture.failedFuture(new AssertionError("must not negotiate")); + }).build(); + var result = new WorkflowExecutor(workflow, callbacks, client, null, "complaint", "zh").run().join(); + assertFalse(result.isSuccess()); + assertEquals(1, calls.get()); + assertEquals(0, negotiations.get()); + var history = result.getHistory().get(0); + assertEquals("failed", history.get("status")); + assertEquals("remote.problem." + status, history.get("errorCode")); + assertEquals(List.of(), history.get("outputs")); + assertTrue(history.get("error").toString().contains("OMC拒绝请求")); + assertEquals(status, ((Map) history.get("errorDetails")).get("status")); + assertEquals("", ((Map) history.get("errorDetails")).get("type")); + } + + @Test + void knownSecretsAreRedactedAndUnrecognizedProviderErrorsRemainPrivate() { + var problem = RemoteProblemException.fromPayload( + "{\"status\":400,\"detail\":\"bad request password=private-value accessSession=private-session\"}"); + var result = FailureMapping.from(problem); + assertFalse(result.toString().contains("private-value")); + assertFalse(result.toString().contains("private-session")); + assertFalse(problem.getMessage().contains("private-value")); + assertEquals("IllegalStateException", + FailureMapping.from(new IllegalStateException("raw private provider body")).getError()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java new file mode 100644 index 00000000..efbc02ca --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/registry/LoadPsopHttpsTest.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.registry; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import dev.openan.workflow.engine.client.SslContextFactory; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLHandshakeException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Real loopback TLS handshakes; certificates are ephemeral and never added to JVM trust. */ +class LoadPsopHttpsTest { + @TempDir Path directory; + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void explicitOptOutLoadsAndSearchesWithoutTrustFilesAndWithoutGlobalSideEffects(boolean wrongSan) + throws Exception { + var defaults = HttpsURLConnection.getDefaultSSLSocketFactory(); + var verifier = HttpsURLConnection.getDefaultHostnameVerifier(); + var defaultContext = SSLContext.getDefault(); + String hostnameProperty = System.getProperty("jdk.internal.httpclient.disableHostnameVerification"); + try (var endpoint = endpoint(wrongSan, false)) { + String baseUrl = endpoint.baseUrl(); + assertEquals("tls-psop", LoadPsop.load(baseUrl, "tls-psop", null, false).getName()); + assertEquals("tls-psop", LoadPsop.search(baseUrl, "complaint", 3, null, false).get(0).getWorkflowId()); + assertThrows(SSLHandshakeException.class, () -> LoadPsop.load(baseUrl, "tls-psop")); + assertThrows(SSLHandshakeException.class, () -> LoadPsop.search(baseUrl, "complaint")); + + // Even another trust-all connection must still enforce its own hostname policy. + var independent = (HttpsURLConnection) URI.create(baseUrl + "/").toURL().openConnection(); + independent.setSSLSocketFactory(SslContextFactory.createTrustAll().getSocketFactory()); + independent.setConnectTimeout(3000); + independent.setReadTimeout(3000); + try { + assertThrows(IOException.class, independent::getResponseCode); + } finally { + independent.disconnect(); + } + } + assertSame(defaults, HttpsURLConnection.getDefaultSSLSocketFactory()); + assertSame(verifier, HttpsURLConnection.getDefaultHostnameVerifier()); + assertSame(defaultContext, SSLContext.getDefault()); + assertEquals(hostnameProperty, System.getProperty("jdk.internal.httpclient.disableHostnameVerification")); + } + + @Test + void skippingServerVerificationDoesNotBypassMutualTls() throws Exception { + try (var endpoint = endpoint(false, true)) { + assertThrows(IOException.class, () -> LoadPsop.load(endpoint.baseUrl(), "tls-psop", null, false)); + } + } + + private Endpoint endpoint(boolean wrongSan, boolean clientAuth) throws Exception { + Path keyStoreFile = directory.resolve("server.p12"); + Path keytool = Path.of(System.getProperty("java.home"), "bin", + System.getProperty("os.name").startsWith("Windows") ? "keytool.exe" : "keytool"); + var command = new ArrayList<>(List.of(keytool.toString(), "-genkeypair", "-alias", "test", + "-keyalg", "RSA", "-keysize", "2048", "-dname", "CN=unrelated.example.test", + "-validity", "2", "-storetype", "PKCS12", "-keystore", keyStoreFile.toString(), + "-storepass", "test-only-password", "-keypass", "test-only-password", "-noprompt")); + if (wrongSan) command.addAll(List.of("-ext", "SAN=dns:unrelated.example.test")); + var process = new ProcessBuilder(command).redirectErrorStream(true) + .redirectOutput(directory.resolve("keytool.log").toFile()).start(); + try { + assertTrue(process.waitFor(20, TimeUnit.SECONDS), "keytool timed out"); + assertEquals(0, process.exitValue(), () -> "keytool failed; see " + directory.resolve("keytool.log")); + } finally { + if (process.isAlive()) process.destroyForcibly(); + } + var store = KeyStore.getInstance("PKCS12"); + try (var input = Files.newInputStream(keyStoreFile)) { + store.load(input, "test-only-password".toCharArray()); + } + var certificate = (X509Certificate) store.getCertificate("test"); + if (wrongSan) assertNotNull(certificate.getSubjectAlternativeNames()); + else assertNull(certificate.getSubjectAlternativeNames()); + var keys = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keys.init(store, "test-only-password".toCharArray()); + var context = SSLContext.getInstance("TLS"); + context.init(keys.getKeyManagers(), null, null); + var server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(context) { + @Override public void configure(HttpsParameters parameters) { + var ssl = context.getDefaultSSLParameters(); + ssl.setNeedClientAuth(clientAuth); + parameters.setSSLParameters(ssl); + } + }); + server.createContext("/", exchange -> { + exchange.getRequestBody().readAllBytes(); + String body = exchange.getRequestURI().getPath().endsWith("/search") + ? "{\"data\":[{\"workflow_id\":\"tls-psop\",\"name\":\"tls-psop\"}]}" + : "{\"data\":{\"name\":\"tls-psop\",\"steps\":[]}}"; + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (var output = exchange.getResponseBody()) { output.write(bytes); } + finally { exchange.close(); } + }); + server.start(); + return new Endpoint(server); + } + + private record Endpoint(HttpsServer server) implements AutoCloseable { + String baseUrl() { return "https://127.0.0.1:" + server.getAddress().getPort(); } + @Override public void close() { server.stop(0); } + } +}