From 861934f57902473a1ca62255f373e78dcb65ba1f Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 19 Aug 2026 11:04:18 +0800 Subject: [PATCH 1/3] feat(engine): harden transport security and workflow validation Signed-off-by: Zhoujie628 --- .../workflow/engine/client/A2ATransport.java | 199 +++++++------- .../engine/client/AgentAuthManager.java | 109 +++----- .../engine/client/AgentCardNormalizer.java | 16 -- .../engine/client/AgentCredentialService.java | 25 +- .../workflow/engine/client/AuthProvider.java | 6 +- .../client/AuthProviderHeaderContributor.java | 34 +++ .../client/ClientCallContextFactory.java | 58 ++++ .../engine/client/CredentialCrypto.java | 31 ++- .../client/CredentialHeaderContributor.java | 114 ++++++++ .../engine/client/CustomAuthInterceptor.java | 99 ------- .../client/DefaultA2AJavaClientRuntime.java | 250 +++++++++++++----- .../engine/client/DefaultExtensionSender.java | 6 +- .../client/ExtensionHeaderContributor.java | 41 +++ .../engine/client/ExtensionRegistry.java | 24 +- .../engine/client/ExtensionSender.java | 24 +- .../engine/client/HeaderContributor.java | 24 ++ .../engine/client/JdkHttpClientFactory.java | 44 +++ .../engine/client/ProtocolLogger.java | 74 +++++- .../engine/client/SslContextFactory.java | 243 ++++++++++++----- .../client/WorkflowEngineClientConfig.java | 72 +++++ .../engine/control/DefaultControlPoint.java | 16 +- .../engine/core/WorkflowExecutor.java | 176 +++++++++--- .../engine/client/A2ATransportHeaderTest.java | 146 ++++++++++ .../engine/client/AgentAuthManagerTest.java | 20 +- .../client/AgentCardNormalizerTest.java | 27 +- .../client/AgentCredentialServiceTest.java | 60 +++++ .../engine/client/CredentialCryptoTest.java | 41 +++ .../engine/client/ExtensionRegistryTest.java | 15 ++ .../engine/client/ExtensionSenderTest.java | 30 +++ .../engine/client/ProtocolLoggerTest.java | 44 +++ .../engine/client/SslContextFactoryTest.java | 65 +++++ .../control/DefaultControlPointTest.java | 46 ++++ .../engine/core/WorkflowExecutorTest.java | 136 +++++++++- 33 files changed, 1771 insertions(+), 544 deletions(-) create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java delete mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/CustomAuthInterceptor.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java create mode 100644 workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java create mode 100644 workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java 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 e0041169..2ee2f283 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 @@ -28,15 +28,11 @@ import org.a2aproject.sdk.client.TaskEvent; import org.a2aproject.sdk.client.TaskUpdateEvent; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; -import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.Artifact; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.MessageSendParams; import org.a2aproject.sdk.spec.Part; -import org.a2aproject.sdk.spec.SecurityRequirement; -import org.a2aproject.sdk.spec.SecurityScheme; import org.a2aproject.sdk.spec.Task; import org.a2aproject.sdk.spec.TaskArtifactUpdateEvent; import org.a2aproject.sdk.spec.TaskStatusUpdateEvent; @@ -49,11 +45,16 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.time.Duration; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; /** @@ -63,8 +64,8 @@ * * * Neither facade duplicates transport code; both delegate here. @@ -79,13 +80,10 @@ public class A2ATransport implements AutoCloseable { private final AuthProvider authProvider; private final A2ATClient a2atClient; private final String contextId; - private final ExecutorService asyncExecutor = - Executors.newCachedThreadPool( - r -> { - Thread t = new Thread(r, "engine-send"); - t.setDaemon(true); - return t; - }); + private final ClientCallContextFactory clientCallContextFactory; + private final ExecutorService asyncExecutor; + private final long notificationAckTimeoutSeconds; + private final AtomicBoolean closed = new AtomicBoolean(); public A2ATransport( List agentCards, @@ -97,21 +95,64 @@ public A2ATransport( : new DefaultA2AJavaClientRuntime( config.isSslVerify(), config.getCaCertsPath(), + config.getClientCertPath(), + config.getClientKeyPath(), + config.getClientKeyPassword(), + config.getCrlPath(), config.getSendTimeoutSeconds(), - config.getPreferredProtocol()); + config.getPreferredProtocol(), + config.getSendExecutorCoreSize(), + config.getSendExecutorMaxSize(), + config.getSendExecutorQueueCapacity()); this.contextId = UUID.randomUUID().toString(); + this.notificationAckTimeoutSeconds = config.getNotificationAckTimeoutSeconds(); + this.asyncExecutor = + new ThreadPoolExecutor( + config.getSendExecutorCoreSize(), + config.getSendExecutorMaxSize(), + 60L, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(config.getSendExecutorQueueCapacity()), + r -> { + Thread t = new Thread(r, "engine-send"); + t.setDaemon(true); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy()); + if (config.getA2atEnvPath() != null) { + EnvFileLoader.loadToSystemProperties(java.nio.file.Path.of(config.getA2atEnvPath())); + } + java.net.http.HttpClient credentialHttpClient = null; + if (config.getCredentialsConfigPath() != null + || config.getCredentialsConfig() != null) { + credentialHttpClient = + JdkHttpClientFactory.create( + config.isSslVerify(), + config.getCaCertsPath(), + config.getClientCertPath(), + config.getClientKeyPath(), + config.getClientKeyPassword(), + config.getCrlPath(), + Duration.ofSeconds(30), + null); + } if (config.getCredentialsConfigPath() != null) { - this.authManager = new AgentAuthManager(config.getCredentialsConfigPath()); + this.authManager = + new AgentAuthManager( + config.getCredentialsConfigPath(), credentialHttpClient); } else if (config.getCredentialsConfig() != null) { - this.authManager = new AgentAuthManager(config.getCredentialsConfig()); + this.authManager = + new AgentAuthManager(config.getCredentialsConfig(), credentialHttpClient); } else { this.authManager = new AgentAuthManager(); } - if (config.getA2atEnvPath() != null) { - EnvFileLoader.loadToSystemProperties(java.nio.file.Path.of(config.getA2atEnvPath())); - } this.a2atClient = initA2atClient(config.getA2atEnvPath()); this.authProvider = config.getAuthProvider(); + this.clientCallContextFactory = + new ClientCallContextFactory( + new AuthProviderHeaderContributor(authProvider), + new CredentialHeaderContributor(authManager, authProvider), + new ExtensionHeaderContributor(authManager)); for (AgentCard card : agentCards) { if (!card.name().isEmpty()) { cardMap.put(card.name(), card); @@ -279,6 +320,10 @@ public CompletableFuture send( String contextId, Map metadata, Consumer eventSink) { + if (closed.get()) { + return CompletableFuture.failedFuture( + new IllegalStateException("A2A transport is closed")); + } return send(agentCard, agentName, message, contextId, null, metadata, eventSink); } @@ -291,6 +336,10 @@ public CompletableFuture send( String taskId, Map metadata, Consumer eventSink) { + if (closed.get()) { + return CompletableFuture.failedFuture( + new IllegalStateException("A2A transport is closed")); + } return CompletableFuture.supplyAsync( () -> { try { @@ -333,6 +382,9 @@ public CompletableFuture send( agentName, e.getMessage(), e); + if (e instanceof SecurityException securityException) { + throw securityException; + } throw new RuntimeException("Agent call failed: " + e.getMessage(), e); } }, @@ -368,6 +420,10 @@ public CompletableFuture sendNotificationStream( String contextId, Map metadata, Consumer eventSink) { + if (closed.get()) { + return CompletableFuture.failedFuture( + new IllegalStateException("A2A transport is closed")); + } CompletableFuture future = new CompletableFuture<>(); Thread streamThread = new Thread( @@ -442,11 +498,17 @@ public CompletableFuture sendNotificationStream( "notif-t-" + agentName); streamThread.setDaemon(true); streamThread.start(); - return future.orTimeout(5, TimeUnit.SECONDS) - .exceptionally( - e -> { + return future.orTimeout(notificationAckTimeoutSeconds, TimeUnit.SECONDS) + .handle( + (result, error) -> { + if (error == null) return result; + Throwable cause = unwrapCompletionError(error); + if (!(cause instanceof TimeoutException)) { + throw new CompletionException(cause); + } log.warn( - "[Transport] Notification-T subscription: no event in 5s, assuming active (stream stays open)"); + "[Transport] Notification-T subscription: no event in {}s, assuming active (stream stays open)", + notificationAckTimeoutSeconds); return SendMessageResult.builder() .text("Subscribed (no-ack)") .taskState("TASK_STATE_WORKING") @@ -454,6 +516,14 @@ public CompletableFuture sendNotificationStream( }); } + private static Throwable unwrapCompletionError(Throwable error) { + Throwable current = error; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + private MessageSendParams buildMessageSendParams( String message, String contextId, Map metadata) { return buildMessageSendParams(message, contextId, null, metadata); @@ -475,69 +545,7 @@ private MessageSendParams buildMessageSendParams( private ClientCallContext buildClientCallContext( AgentCard agentCard, String agentName, Map messageMetadata) { - Map headers = new HashMap<>(); - if (authProvider != null) { - authProvider.applyAuth(agentName, agentCard, headers); - } - applyAuthHeaders(agentCard, agentName, headers); - List interceptors = - authManager.buildInterceptors(agentCard, agentName); - for (ClientCallInterceptor interceptor : interceptors) { - if (interceptor instanceof ExtensionInterceptor extInterceptor) { - try { - ClientCallContext interceptCtx = - new ClientCallContext(new HashMap<>(), headers); - PayloadAndHeaders ph = - extInterceptor.intercept( - "message/send", messageMetadata, headers, null, interceptCtx); - headers.putAll(ph.getHeaders()); - } catch (Exception e) { - log.warn("[Transport] Extension interceptor failed: {}", e.getMessage()); - } - } - } - return new ClientCallContext(new HashMap<>(), headers); - } - - // ------------------------------------------------------------------ - // Auth headers - // ------------------------------------------------------------------ - - private void applyAuthHeaders( - AgentCard agentCard, String agentName, Map headerMap) { - AgentCredentialService credSvc = authManager.getService(agentName); - if (credSvc == null) return; - Map> schemeConfigs = authManager.getConfig(agentName); - if (schemeConfigs == null) schemeConfigs = Map.of(); - Map secSchemes = agentCard.securitySchemes(); - List secReqs = agentCard.securityRequirements(); - if (secSchemes == null || secSchemes.isEmpty() || secReqs == null || secReqs.isEmpty()) - return; - for (SecurityRequirement req : secReqs) { - Map> schemes = req.schemes(); - for (String schemeName : schemes.keySet()) { - Map schemeCfg = schemeConfigs.getOrDefault(schemeName, Map.of()); - String credential = credSvc.getCredential(schemeName, null); - if (credential == null) { - throw new SecurityException( - "Authentication failed for agent " + agentName - + " (scheme=" + schemeName + "), request blocked"); - } - String authHeader = (String) schemeCfg.get("auth_header"); - if (authHeader != null && !authHeader.isEmpty()) { - String prefix = (String) schemeCfg.getOrDefault("auth_header_prefix", ""); - headerMap.put(authHeader, prefix + credential); - log.info("[Auth] Set header {} for agent {}", authHeader, agentName); - } else { - headerMap.put("Authorization", "Bearer " + credential); - log.info("[Auth] Set Bearer header for agent {}", agentName); - } - String acceptHeader = (String) schemeCfg.get("accept_header"); - if (acceptHeader != null && !acceptHeader.isEmpty()) - headerMap.put("Accept", acceptHeader); - break; - } - } + return clientCallContextFactory.create(agentCard, agentName, messageMetadata); } // ------------------------------------------------------------------ @@ -546,16 +554,21 @@ private void applyAuthHeaders( @Override public void close() { + if (!closed.compareAndSet(false, true)) return; log.info("[Transport] Closing"); + asyncExecutor.shutdown(); try { - a2aClientRuntime.close(); - } catch (Exception ignored) { - } - asyncExecutor.shutdownNow(); - try { - asyncExecutor.awaitTermination(2, TimeUnit.SECONDS); + if (!asyncExecutor.awaitTermination(2, TimeUnit.SECONDS)) { + asyncExecutor.shutdownNow(); + } } catch (InterruptedException e) { + asyncExecutor.shutdownNow(); Thread.currentThread().interrupt(); } + try { + a2aClientRuntime.close(); + } catch (Exception e) { + log.warn("[Transport] Runtime close failed: {}", e.getMessage(), e); + } } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java index ee66267c..30c9f6dd 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentAuthManager.java @@ -22,14 +22,12 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; -import org.a2aproject.sdk.client.transport.spi.interceptors.auth.AuthInterceptor; import org.a2aproject.sdk.spec.AgentCard; -import org.a2aproject.sdk.spec.SecurityScheme; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.net.http.HttpClient; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -48,11 +46,21 @@ class AgentAuthManager { private static final ObjectMapper mapper = new ObjectMapper(); private final Map>> config; + private final HttpClient credentialHttpClient; private final Map services = new ConcurrentHashMap<>(); /** Create with a config map (agent name -> scheme name -> scheme config). */ public AgentAuthManager(Map>> config) { + this(config, null); + } + + /** Create with a config map and an explicitly configured login HTTP client. */ + public AgentAuthManager( + Map>> config, + HttpClient credentialHttpClient) { this.config = config != null ? config : new HashMap<>(); + this.credentialHttpClient = credentialHttpClient; + validateEncryptedCredentials(this.config); if (!this.config.isEmpty()) { log.info( "[Auth] Loaded credentials for {} agent(s): {}", @@ -63,7 +71,12 @@ public AgentAuthManager(Map>> config) { /** Create by loading credentials from a JSON file. */ public AgentAuthManager(String configPath) { - this(loadFromFile(configPath)); + this(loadFromFile(configPath), null); + } + + /** Create from a JSON file and use the supplied client for login requests. */ + public AgentAuthManager(String configPath, HttpClient credentialHttpClient) { + this(loadFromFile(configPath), credentialHttpClient); } /** Create with no credentials (auth disabled). */ @@ -82,8 +95,8 @@ private static Map>> loadFromFile(String java.io.InputStream is = AgentAuthManager.class.getClassLoader().getResourceAsStream(resource); if (is == null) { - log.warn("[Auth] Classpath resource not found: {}", resource); - return new HashMap<>(); + throw new IllegalStateException( + "Credentials classpath resource not found: " + resource); } loaded = mapper.readValue( @@ -93,8 +106,7 @@ private static Map>> loadFromFile(String } else { File file = new File(path); if (!file.exists()) { - log.warn("[Auth] Credentials file not found: {}", path); - return new HashMap<>(); + throw new IllegalStateException("Credentials file not found: " + path); } loaded = mapper.readValue( @@ -105,8 +117,8 @@ private static Map>> loadFromFile(String log.info("[Auth] Loaded credentials for {} agent(s) from {}", loaded.size(), path); return loaded; } catch (Exception e) { - log.warn("[Auth] Failed to load credentials from {}: {}", path, e.getMessage()); - return new HashMap<>(); + throw new IllegalStateException( + "Failed to load credentials from " + path + ": " + e.getMessage(), e); } } @@ -135,7 +147,7 @@ public AgentCredentialService getService(String agentName) { return null; } log.info("[Auth] Created credential service for agent: {}", name); - return new AgentCredentialService(name, agentCreds); + return new AgentCredentialService(name, agentCreds, credentialHttpClient); }); } @@ -144,76 +156,19 @@ public Map> getConfig(String agentName) { return config.get(agentName); } - /** - * Propagate an HTTP client to all credential services. Mirrors Python SDK's {@code - * AgentAuthManager.set_httpx_client()}. - */ - public void setHttpClient(java.net.http.HttpClient httpClient) { - for (AgentCredentialService svc : services.values()) { - if (svc != null) { - // AgentCredentialService stores httpClient internally - // New services created after this call will use default client + private static void validateEncryptedCredentials(Map values) { + for (Object value : values.values()) { + if (value instanceof Map nested) { + validateEncryptedCredentials(nested); + } else if (value instanceof String text && text.startsWith("enc:")) { + CredentialCrypto.decryptIfNeeded(text); } } - log.info("[Auth] HTTP client propagated to {} service(s)", services.size()); } - /** - * Build interceptors for an agent based on its AgentCard and credentials. - * - * @param agentCard the agent's card (as a map for flexibility) - * @param agentName the agent name - * @return list of interceptors (auth + extension), or empty if none needed - */ - public List buildInterceptors(AgentCard agentCard, String agentName) { - List interceptors = new ArrayList<>(); - Map secSchemes = agentCard.securitySchemes(); - var secReqs = agentCard.securityRequirements(); - boolean hasSecurity = - secSchemes != null - && !secSchemes.isEmpty() - && secReqs != null - && !secReqs.isEmpty(); - AgentCredentialService credSvc = null; - if (hasSecurity) { - credSvc = getService(agentName); - } else { - log.info("[AuthManager] Agent {}: no security schemes, skipping auth", agentName); - if (agentCard.capabilities().extensions() == null - || agentCard.capabilities().extensions().isEmpty()) { - return interceptors; - } - } - - if (credSvc != null) { - Map> agentCfg = getConfig(agentName); - if (agentCfg == null) { - agentCfg = new HashMap<>(); - } - // Check for custom header configuration - boolean useCustomHeaders = - agentCfg.values().stream() - .anyMatch( - v -> - v != null - && (v.containsKey("auth_header") - || v.containsKey("accept_header"))); - if (useCustomHeaders) { - interceptors.add(new CustomAuthInterceptor(credSvc, agentCfg)); - log.info( - "[AuthManager] Agent {}: configured with CustomAuthInterceptor", agentName); - } else { - interceptors.add(new AuthInterceptor(credSvc)); - log.info("[AuthManager] Agent {}: configured with AuthInterceptor", agentName); - } - } - - // Extension interceptor + /** Build the extension-header contributor for an AgentCard, if it declares extensions. */ + public ExtensionInterceptor buildExtensionInterceptor(AgentCard agentCard) { List extUris = extractExtensionUris(agentCard); - if (!extUris.isEmpty()) { - interceptors.add(new ExtensionInterceptor(extUris)); - } - - return interceptors; + return extUris.isEmpty() ? null : new ExtensionInterceptor(extUris); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java index efe63ac5..b63acc37 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCardNormalizer.java @@ -78,22 +78,6 @@ public static Map normalize(Map agentDict) { "securityRequirements", normalizeSecurityRequirements(result.get("securityRequirements"))); } - // Auto-populate securityRequirements from securitySchemes if missing - Object secSchemes = result.get("securitySchemes"); - if (secSchemes instanceof Map - && !((Map) secSchemes).isEmpty() - && !(result.get("securityRequirements") instanceof List - && !((List) result.get("securityRequirements")).isEmpty())) { - List names = new ArrayList<>(((Map) secSchemes).keySet()); - List> reqs = new ArrayList<>(); - Map schemesMap = new LinkedHashMap<>(); - for (String s : names) { - schemesMap.put(s, Map.of()); - } - reqs.add(Map.of("schemes", schemesMap)); - result.put("securityRequirements", reqs); - log.info("Auto-populated securityRequirements from securitySchemes: {}", names); - } return result; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java index 289e411d..9aef5477 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AgentCredentialService.java @@ -19,6 +19,7 @@ package dev.openan.workflow.engine.client; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; @@ -28,9 +29,11 @@ import org.slf4j.LoggerFactory; import java.net.URI; +import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -70,14 +73,9 @@ public AgentCredentialService( if (httpClient != null) { this.httpClient = httpClient; } else { - HttpClient.Builder b = - HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(30)) - .followRedirects(HttpClient.Redirect.ALWAYS); - - // Disable TLS verification (mirrors Python's verify=False for login endpoints) - b.sslContext(SslContextFactory.createTrustAll()); - this.httpClient = b.build(); + this.httpClient = + JdkHttpClientFactory.create( + true, null, null, null, null, null, Duration.ofSeconds(30), null); } } @@ -200,7 +198,9 @@ private String login(Map schemeCfg) { if (!form.isEmpty()) { form.append("&"); } - form.append(e.getKey()).append("=").append(e.getValue()); + form.append(encodeForm(e.getKey())) + .append("=") + .append(encodeForm(String.valueOf(e.getValue()))); } bodyPublisher = HttpRequest.BodyPublishers.ofString(form.toString()); } else { @@ -220,7 +220,8 @@ private String login(Map schemeCfg) { log.error("[Auth] Login failed: agent={}, status={}", agentName, resp.statusCode()); return null; } - Map data = mapper.readValue(resp.body(), Map.class); + Map data = + mapper.readValue(resp.body(), new TypeReference>() {}); String token = extractNestedValue(data, tokenField); if (token == null) { token = @@ -240,6 +241,10 @@ private String login(Map schemeCfg) { } } + private static String encodeForm(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + private record TokenEntry(String token, long expiresAt) { boolean isExpired() { return System.currentTimeMillis() / 1000 >= expiresAt - 60; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java index a3091fbd..9d622612 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProvider.java @@ -47,8 +47,10 @@ * } * *

The provider is called for every message send, regardless of whether the AgentCard declares - * security schemes. If both a credentials file and a custom AuthProvider are configured, both run - * and may add headers to the same map (custom provider runs first, credentials-based auth second). + * security schemes. If both a credentials file and a custom AuthProvider are configured, their + * headers are calculated independently and then merged. Different values for the same header name + * are rejected with a {@link SecurityException}; no authentication source silently overwrites the + * other. */ public interface AuthProvider { diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java new file mode 100644 index 00000000..68527258 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/AuthProviderHeaderContributor.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.spec.AgentCard; + +import java.util.HashMap; +import java.util.Map; + +/** Supplies headers from the host-provided {@link AuthProvider}. */ +final class AuthProviderHeaderContributor implements HeaderContributor { + private final AuthProvider authProvider; + + AuthProviderHeaderContributor(AuthProvider authProvider) { + this.authProvider = authProvider; + } + + @Override + public Map contribute( + AgentCard agentCard, + String agentName, + Map messageMetadata, + Map currentHeaders) { + Map headers = new HashMap<>(); + if (authProvider != null) { + authProvider.applyAuth(agentName, agentCard, headers); + } + return headers; + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java new file mode 100644 index 00000000..3682df13 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ClientCallContextFactory.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.spec.AgentCard; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Builds the final outbound call context from independent header contributors. */ +final class ClientCallContextFactory { + private final List contributors; + + ClientCallContextFactory(HeaderContributor... contributors) { + this.contributors = List.of(contributors); + } + + ClientCallContext create( + AgentCard agentCard, String agentName, Map messageMetadata) { + Map headers = new HashMap<>(); + for (HeaderContributor contributor : contributors) { + Map contributed = + contributor.contribute(agentCard, agentName, messageMetadata, headers); + mergeHeaders(agentName, headers, contributed); + } + return new ClientCallContext(new HashMap<>(), headers); + } + + static void mergeHeaders( + String agentName, + Map destination, + Map additionalHeaders) { + for (Map.Entry entry : additionalHeaders.entrySet()) { + String existingKey = + destination.keySet().stream() + .filter(key -> key.equalsIgnoreCase(entry.getKey())) + .findFirst() + .orElse(null); + String existing = existingKey != null ? destination.get(existingKey) : null; + if (existing != null && !existing.equals(entry.getValue())) { + throw new SecurityException( + "Authentication header conflict for agent " + + agentName + + ": " + + entry.getKey()); + } + if (existingKey == null) { + destination.put(entry.getKey(), entry.getValue()); + } + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java index f22afa99..3f1e02a3 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialCrypto.java @@ -99,6 +99,7 @@ public static void main(String[] args) { * * @param value the raw value from config (may be {@code enc:...} or plaintext) * @return decrypted plaintext, or the original value if not encrypted + * @throws IllegalStateException when an encrypted value cannot be decrypted */ public static String decryptIfNeeded(String value) { if (value == null || !value.startsWith(PREFIX)) { @@ -106,18 +107,17 @@ public static String decryptIfNeeded(String value) { } String keyHex = resolveKey(); if (keyHex == null || keyHex.isBlank()) { - log.warn( - "[CredentialCrypto] Encrypted value found but {} not set (env var or system property), using as-is", - ENV_KEY); - return value; + throw new IllegalStateException( + "Encrypted credential found but " + + ENV_KEY + + " is not set (environment variable or system property)"); } try { String encoded = value.substring(PREFIX.length()); String[] parts = encoded.split(":", 2); if (parts.length != 2) { - log.error( - "[CredentialCrypto] Invalid encrypted format, expected enc::"); - return value; + throw new IllegalArgumentException( + "Invalid encrypted credential format; expected enc::"); } byte[] iv = Base64.getDecoder().decode(parts[0]); byte[] cipherText = Base64.getDecoder().decode(parts[1]); @@ -130,8 +130,8 @@ public static String decryptIfNeeded(String value) { byte[] plainBytes = cipher.doFinal(cipherText); return new String(plainBytes, StandardCharsets.UTF_8); } catch (Exception e) { - log.error("[CredentialCrypto] Decryption failed: {}", e.getMessage()); - return value; + throw new IllegalStateException( + "Credential decryption failed; verify " + ENV_KEY + " and encrypted value", e); } } @@ -183,12 +183,19 @@ private static String resolveKey() { private static byte[] hexToBytes(String hex) { int len = hex.length(); + if (len != 64) { + throw new IllegalArgumentException("A2AT_CRED_KEY must contain 64 hexadecimal characters"); + } byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { + int high = Character.digit(hex.charAt(i), 16); + int low = Character.digit(hex.charAt(i + 1), 16); + if (high < 0 || low < 0) { + throw new IllegalArgumentException( + "A2AT_CRED_KEY must contain hexadecimal characters only"); + } data[i / 2] = - (byte) - ((Character.digit(hex.charAt(i), 16) << 4) - + Character.digit(hex.charAt(i + 1), 16)); + (byte) ((high << 4) + low); } return data; } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java new file mode 100644 index 00000000..b066b69d --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CredentialHeaderContributor.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.spec.AgentCard; +import org.a2aproject.sdk.spec.SecurityRequirement; +import org.a2aproject.sdk.spec.SecurityScheme; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Supplies credentials declared by an AgentCard and loaded from engine credential configuration. */ +final class CredentialHeaderContributor implements HeaderContributor { + private static final Logger log = LoggerFactory.getLogger(CredentialHeaderContributor.class); + + private final AgentAuthManager authManager; + private final AuthProvider authProvider; + + CredentialHeaderContributor(AgentAuthManager authManager, AuthProvider authProvider) { + this.authManager = authManager; + this.authProvider = authProvider; + } + + @Override + public Map contribute( + AgentCard agentCard, + String agentName, + Map messageMetadata, + Map currentHeaders) { + Map headers = new HashMap<>(); + Map securitySchemes = agentCard.securitySchemes(); + List securityRequirements = agentCard.securityRequirements(); + if (securitySchemes == null + || securitySchemes.isEmpty() + || securityRequirements == null + || securityRequirements.isEmpty()) { + return headers; + } + + AgentCredentialService credentialService = authManager.getService(agentName); + if (credentialService == null) { + if (authProvider != null) { + return headers; + } + throw new SecurityException( + "Authentication failed for agent " + + agentName + + ": AgentCard requires credentials but none are configured"); + } + Map> schemeConfigs = authManager.getConfig(agentName); + if (schemeConfigs == null || schemeConfigs.isEmpty()) { + if (authProvider != null) { + return headers; + } + throw new SecurityException( + "Authentication failed for agent " + + agentName + + ": credential configuration is empty"); + } + + for (SecurityRequirement requirement : securityRequirements) { + Map> schemes = requirement.schemes(); + for (String schemeName : schemes.keySet()) { + Map schemeConfig = schemeConfigs.get(schemeName); + if (schemeConfig == null) { + throw new SecurityException( + "Authentication failed for agent " + + agentName + + ": no configuration for scheme " + + schemeName); + } + String credential = credentialService.getCredential(schemeName, null); + if (credential == null) { + throw new SecurityException( + "Authentication failed for agent " + + agentName + + " (scheme=" + + schemeName + + "), request blocked"); + } + addCredentialHeader(agentName, headers, schemeConfig, credential); + return headers; + } + } + return headers; + } + + private static void addCredentialHeader( + String agentName, + Map headers, + Map schemeConfig, + String credential) { + String authHeader = (String) schemeConfig.get("auth_header"); + if (authHeader != null && !authHeader.isEmpty()) { + String prefix = (String) schemeConfig.getOrDefault("auth_header_prefix", ""); + headers.put(authHeader, prefix + credential); + log.info("[Auth] Set header {} for agent {}", authHeader, agentName); + } else { + headers.put("Authorization", "Bearer " + credential); + log.info("[Auth] Set Bearer header for agent {}", agentName); + } + String acceptHeader = (String) schemeConfig.get("accept_header"); + if (acceptHeader != null && !acceptHeader.isEmpty()) { + headers.put("Accept", acceptHeader); + } + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CustomAuthInterceptor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CustomAuthInterceptor.java deleted file mode 100644 index 304b3f3a..00000000 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/CustomAuthInterceptor.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2026 Huawei Technologies Co., Ltd. - * All Rights Reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the License); you may - * not use this file except in compliance with the License. You may obtain - * a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an AS IS BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package dev.openan.workflow.engine.client; - -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; -import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; -import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; -import org.a2aproject.sdk.spec.AgentCard; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; - -/** - * Auth interceptor supporting custom header names (non-Bearer Authorization). - * - *

Mirrors the Python SDK's {@code CustomAuthInterceptor}. Some agents require tokens in custom - * headers (e.g. {@code X-API-Key}) or with custom prefixes (e.g. {@code Token} instead of {@code - * Bearer}). - */ -class CustomAuthInterceptor extends ClientCallInterceptor { - - private static final Logger log = LoggerFactory.getLogger(CustomAuthInterceptor.class); - - private final AgentCredentialService credentialService; - private final Map> schemeConfigs; - - public CustomAuthInterceptor( - AgentCredentialService credentialService, - Map> schemeConfigs) { - this.credentialService = credentialService; - this.schemeConfigs = schemeConfigs != null ? schemeConfigs : Map.of(); - } - - @NotNull - @Override - public PayloadAndHeaders intercept( - @NotNull String method, - Object payload, - @NotNull Map headers, - AgentCard agentCard, - ClientCallContext context) { - if (agentCard == null - || agentCard.securityRequirements() == null - || agentCard.securityRequirements().isEmpty() - || agentCard.securitySchemes() == null - || agentCard.securitySchemes().isEmpty()) { - return new PayloadAndHeaders(payload, headers); - } - - Map newHeaders = new HashMap<>(headers); - - for (org.a2aproject.sdk.spec.SecurityRequirement req : agentCard.securityRequirements()) { - for (Map.Entry> entry : req.schemes().entrySet()) { - String schemeName = entry.getKey(); - Map schemeCfg = schemeConfigs.getOrDefault(schemeName, Map.of()); - String credential = credentialService.getCredential(schemeName, context); - if (credential == null) { - continue; - } - String authHeader = (String) schemeCfg.get("auth_header"); - if (authHeader != null && !authHeader.isEmpty()) { - String prefix = (String) schemeCfg.getOrDefault("auth_header_prefix", ""); - newHeaders.put(authHeader, prefix + credential); - log.info("[CustomAuth] Set header {} for scheme {}", authHeader, schemeName); - } else { - newHeaders.put("Authorization", "Bearer " + credential); - log.info("[CustomAuth] Set Bearer header for scheme {}", schemeName); - } - String acceptHeader = (String) schemeCfg.get("accept_header"); - if (acceptHeader != null && !acceptHeader.isEmpty()) { - newHeaders.put("Accept", acceptHeader); - log.info("[CustomAuth] Override Accept header to {}", acceptHeader); - } - break; - } - } - return new PayloadAndHeaders(payload, newHeaders); - } -} 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 c1795d93..513a83bb 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 @@ -45,30 +45,33 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.net.http.HttpClient; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import javax.net.ssl.SSLContext; - /** * Default implementation of {@link A2AJavaClientRuntime} that uses the a2a-java SDK's {@link * Client} with {@link RestTransport}. * *

This replaces the engine's previous hand-written HTTP fallback ({@code sendViaRawHttp}) with - * the SDK's built-in transport, SSE parsing, and error handling. The runtime creates a new {@link - * Client} per message send (matching the pattern in the a2a-t-sdk-java sample). + * the SDK's built-in transport, SSE parsing, and error handling. Clients are cached by endpoint, + * protocol, AgentCard version, and TLS policy so connections and TLS sessions can be reused. * - *

SSL handling: when {@code sslVerify=false}, a trust-all SSL context is created and hostname - * verification is disabled globally via the {@code - * jdk.internal.httpclient.disableHostnameVerification} system property (must be set before any - * {@code HttpClient} is created). + *

SSL handling: when {@code sslVerify=false}, certificate-chain verification is disabled only + * for clients created by this runtime. Hostname verification remains enabled and no JVM-wide TLS + * property is changed. */ public class DefaultA2AJavaClientRuntime implements A2AJavaClientRuntime { @@ -76,15 +79,15 @@ public class DefaultA2AJavaClientRuntime implements A2AJavaClientRuntime { private final boolean sslVerify; private final String caCertsPath; + private final String clientCertPath; + private final String clientKeyPath; + private final String clientKeyPassword; + private final String crlPath; private final long sendTimeoutSeconds; private final String preferredProtocol; - private final java.util.concurrent.ExecutorService httpClientExecutor = - java.util.concurrent.Executors.newCachedThreadPool( - r -> { - Thread t = new Thread(r, "a2a-client"); - t.setDaemon(true); - return t; - }); + private final java.util.concurrent.ExecutorService httpClientExecutor; + private final Map clientCache = new ConcurrentHashMap<>(); + private final AtomicBoolean closed = new AtomicBoolean(); /** * Create a runtime with the given SSL configuration. @@ -98,14 +101,56 @@ public DefaultA2AJavaClientRuntime( String caCertsPath, long sendTimeoutSeconds, String preferredProtocol) { + this( + sslVerify, + caCertsPath, + null, + null, + null, + null, + sendTimeoutSeconds, + preferredProtocol, + 4, + 16, + 256); + } + + public DefaultA2AJavaClientRuntime( + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String clientKeyPassword, + String crlPath, + long sendTimeoutSeconds, + String preferredProtocol, + int executorCoreSize, + int executorMaxSize, + int executorQueueCapacity) { this.sslVerify = sslVerify; this.caCertsPath = caCertsPath; + this.clientCertPath = clientCertPath; + this.clientKeyPath = clientKeyPath; + this.clientKeyPassword = clientKeyPassword; + this.crlPath = crlPath; this.sendTimeoutSeconds = sendTimeoutSeconds; this.preferredProtocol = preferredProtocol; + this.httpClientExecutor = + new ThreadPoolExecutor( + executorCoreSize, + executorMaxSize, + 60L, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(executorQueueCapacity), + r -> { + Thread t = new Thread(r, "a2a-client"); + t.setDaemon(true); + return t; + }, + new ThreadPoolExecutor.CallerRunsPolicy()); if (!sslVerify) { - // Must be set before any HttpClient is created: the JDK caches - // this property at class-load time. - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); + log.warn( + "[A2ARuntime] TLS certificate-chain verification is disabled; hostname verification remains enabled"); } log.info( "[A2ARuntime] Initialized: sslVerify={}, caCerts={}, timeout={}s", @@ -114,9 +159,9 @@ public DefaultA2AJavaClientRuntime( sendTimeoutSeconds); } - /** Simplified constructor without CA trust store (SSL verify defaults to false). */ + /** Simplified constructor using strict TLS verification and the JVM default trust store. */ public DefaultA2AJavaClientRuntime() { - this(false, null, 600L, null); + this(true, null, 600L, null); } private static String extractAgentUrl(AgentCard agentCard) { @@ -268,8 +313,9 @@ public Iterable sendMessage( ClientCallContext callContext, Consumer eventSink, Consumer logSink) { + if (closed.get()) throw new IllegalStateException("A2A client runtime is closed"); String agentUrl = extractAgentUrl(agentCard); - Client client = createClient(agentCard, agentUrl); + Client client = getOrCreateClient(agentCard, agentUrl); List events = Collections.synchronizedList(new ArrayList<>()); CountDownLatch done = new CountDownLatch(1); AtomicReference errorRef = new AtomicReference<>(); @@ -292,13 +338,11 @@ public Iterable sendMessage( error -> onError(agentCard.name(), error, done, errorRef), callContext); } catch (A2AClientException e) { - client.close(); throw new RuntimeException( "A2A message:send failed for " + agentCard.name() + ": " + e.getMessage(), e); } - awaitCompletion(agentCard.name(), done, events, lastEventRef, client); - client.close(); + awaitCompletion(agentCard.name(), done, events, lastEventRef); if (errorRef.get() != null) { throw new RuntimeException( @@ -312,32 +356,42 @@ public Iterable sendMessage( return events; } - private Client createClient(AgentCard agentCard, String agentUrl) { + private Client getOrCreateClient(AgentCard agentCard, String agentUrl) { AgentInterface selected = selectInterface(agentCard); String protocolBinding = selected.protocolBinding(); - A2AHttpClient httpClient = createHttpClient(); - try { - Client client = buildClientWithTransport(agentCard, protocolBinding, httpClient); - log.info( - "[A2ARuntime] Transport: {} for '{}' ({})", - protocolBinding, - agentCard.name(), - selected.url()); - return client; - } catch (A2AClientException e) { - log.error( - "[A2ARuntime] Failed to create client for '{}' ({}): {}", - agentCard.name(), - agentUrl, - e.getMessage(), - e); - throw new RuntimeException( - "Failed to create a2a-java client for " - + agentCard.name() - + ": " - + e.getMessage(), - e); - } + ClientCacheKey key = + new ClientCacheKey( + agentCard.name(), + agentCard.version(), + selected.url(), + protocolBinding, + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + crlPath); + return clientCache.computeIfAbsent( + key, + ignored -> { + A2AHttpClient httpClient = createHttpClient(); + try { + Client client = + buildClientWithTransport(agentCard, protocolBinding, httpClient); + log.info( + "[A2ARuntime] Created cached transport: {} for '{}' ({})", + protocolBinding, + agentCard.name(), + selected.url()); + return client; + } catch (A2AClientException e) { + throw new IllegalStateException( + "Failed to create a2a-java client for " + + agentCard.name() + + " at " + + agentUrl, + e); + } + }); } /** Select the best AgentInterface based on preferredProtocol or first available. */ @@ -369,8 +423,8 @@ private AgentInterface selectInterface(AgentCard agentCard) { * Build the client with the transport matching the protocol binding. * *

HTTP+JSON and JSONRPC use A2AHttpClient for SSL configuration. GRPC uses - * GrpcTransportConfig with a custom Channel factory. When sslVerify=false, gRPC uses plaintext - * (HTTP/2 without TLS). For custom gRPC CA certs, provide a custom A2AJavaClientRuntime. + * GrpcTransportConfig with a custom Channel factory. Insecure gRPC is plaintext and cannot + * carry mTLS or CRL options; such combinations fail fast instead of being ignored. */ private Client buildClientWithTransport( AgentCard agentCard, String protocolBinding, A2AHttpClient httpClient) @@ -393,8 +447,7 @@ private void awaitCompletion( String agentName, CountDownLatch done, List events, - AtomicReference lastEventRef, - Client client) { + AtomicReference lastEventRef) { try { if (!done.await(sendTimeoutSeconds, TimeUnit.SECONDS)) { ClientEvent last = lastEventRef.get(); @@ -404,48 +457,103 @@ private void awaitCompletion( sendTimeoutSeconds, events.size(), last != null ? last.getClass().getSimpleName() : "none"); - client.close(); throw new RuntimeException("A2A message:send timed out for " + agentName); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - client.close(); throw new RuntimeException("A2A message:send interrupted for " + agentName, e); } } @Override public void close() { - log.info("[A2ARuntime] Closed"); + if (!closed.compareAndSet(false, true)) return; + clientCache.values().forEach( + client -> { + try { + client.close(); + } catch (RuntimeException e) { + log.warn("[A2ARuntime] Failed to close cached client: {}", e.getMessage()); + } + }); + clientCache.clear(); + httpClientExecutor.shutdownNow(); + log.info("[A2ARuntime] Closed cached clients and HTTP executor"); } /** * Create a gRPC channel with SSL settings matching the engine config. * - *

When sslVerify=false, uses plaintext (HTTP/2 without TLS). When sslVerify=true, uses the - * default TLS trust store. For custom CA certs with gRPC, add grpc-netty-shaded to classpath - * and override this method via a custom A2AJavaClientRuntime. + *

When sslVerify=false, uses plaintext (HTTP/2 without TLS). mTLS and CRL configuration + * require TLS and therefore cannot be combined with that mode in the default gRPC runtime. */ private io.grpc.Channel createGrpcChannel(String url) { - ManagedChannelBuilder builder = ManagedChannelBuilder.forTarget(url); if (!sslVerify) { - builder.usePlaintext(); + if ((clientCertPath != null && !clientCertPath.isBlank()) + || (clientKeyPath != null && !clientKeyPath.isBlank())) { + throw new IllegalArgumentException( + "sslVerify=false uses plaintext gRPC and cannot carry mTLS configuration"); + } + if (crlPath != null && !crlPath.isBlank()) { + throw new IllegalArgumentException( + "sslVerify=false uses plaintext gRPC and cannot check a CRL"); + } + return ManagedChannelBuilder.forTarget(url).usePlaintext().build(); + } + if (crlPath != null && !crlPath.isBlank()) { + throw new IllegalArgumentException( + "CRL configuration is not supported by the default gRPC transport"); + } + try { + io.grpc.TlsChannelCredentials.Builder credentials = + io.grpc.TlsChannelCredentials.newBuilder(); + if (caCertsPath != null && !caCertsPath.isBlank()) { + credentials.trustManager(new File(caCertsPath)); + } + boolean hasCert = clientCertPath != null && !clientCertPath.isBlank(); + boolean hasKey = clientKeyPath != null && !clientKeyPath.isBlank(); + if (hasCert != hasKey) { + throw new IllegalArgumentException( + "Both client certificate and private key are required for gRPC mTLS"); + } + if (hasCert) { + if (clientKeyPassword == null || clientKeyPassword.isEmpty()) { + credentials.keyManager(new File(clientCertPath), new File(clientKeyPath)); + } else { + credentials.keyManager( + new File(clientCertPath), + new File(clientKeyPath), + clientKeyPassword); + } + } + return io.grpc.Grpc.newChannelBuilder(url, credentials.build()).build(); + } catch (java.io.IOException e) { + throw new IllegalStateException("Failed to configure gRPC TLS", e); } - return builder.build(); } private A2AHttpClient createHttpClient() { - if (this.sslVerify) { - return new JdkA2AHttpClient(); - } - SSLContext trustAllCtx = SslContextFactory.createTrustAll(); HttpClient httpClient = - HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .connectTimeout(Duration.ofSeconds(60)) - .sslContext(trustAllCtx) - .executor(httpClientExecutor) - .build(); + JdkHttpClientFactory.create( + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + clientKeyPassword, + crlPath, + Duration.ofSeconds(60), + httpClientExecutor); return new JdkA2AHttpClient(httpClient); } + + private record ClientCacheKey( + String agentName, + String agentVersion, + String endpoint, + String protocolBinding, + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String crlPath) {} } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java index ae6ac962..1212f5ae 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/DefaultExtensionSender.java @@ -243,21 +243,21 @@ private String generateTaskPromptText(String naturalLanguageInput) { } return null; } - + // TODO private String generateNegotiationPrompt(String naturalLanguageInput) { if (transport.getA2atClient() == null) { return null; } return null; } - + // TODO private String generateAuthorizationPrompt(String naturalLanguageInput) { if (transport.getA2atClient() == null) { return null; } return null; } - + // TODO private String generateNotificationPrompt(String naturalLanguageInput) { if (transport.getA2atClient() == null) { return null; diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java new file mode 100644 index 00000000..dd7e15ad --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionHeaderContributor.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.a2aproject.sdk.client.transport.spi.interceptors.PayloadAndHeaders; +import org.a2aproject.sdk.spec.AgentCard; + +import java.util.HashMap; +import java.util.Map; + +/** Adds headers required by extensions declared by the target AgentCard. */ +final class ExtensionHeaderContributor implements HeaderContributor { + private final AgentAuthManager authManager; + + ExtensionHeaderContributor(AgentAuthManager authManager) { + this.authManager = authManager; + } + + @Override + public Map contribute( + AgentCard agentCard, + String agentName, + Map messageMetadata, + Map currentHeaders) { + ExtensionInterceptor interceptor = authManager.buildExtensionInterceptor(agentCard); + if (interceptor == null) { + return Map.of(); + } + Map headers = new HashMap<>(currentHeaders); + ClientCallContext interceptContext = new ClientCallContext(new HashMap<>(), headers); + PayloadAndHeaders payloadAndHeaders = + interceptor.intercept( + "message/send", messageMetadata, headers, agentCard, interceptContext); + return payloadAndHeaders.getHeaders(); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java index fb888d56..ace13511 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionRegistry.java @@ -22,9 +22,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; /** @@ -66,10 +69,10 @@ public List getHandlersForExtensions(List extensionUri if (uri == null) { continue; } + java.util.Set pathSegments = extensionPathSegments(uri); for (Map.Entry entry : handlers.entrySet()) { - // Case-insensitive: URIs commonly use uppercase (NEGOTIATION-T) - // while the handler keyword uses mixed case (Negotiation-T). - if (uri.toLowerCase().contains(entry.getKey().toLowerCase()) + String keyword = entry.getKey().toLowerCase(Locale.ROOT); + if (pathSegments.contains(keyword) && !seen.contains(entry.getKey())) { matched.add(entry.getValue()); seen.add(entry.getKey()); @@ -79,4 +82,19 @@ public List getHandlersForExtensions(List extensionUri } return matched; } + + private static java.util.Set extensionPathSegments(String value) { + try { + String path = new URI(value).getPath(); + if (path == null || path.isBlank()) return java.util.Set.of(); + java.util.Set segments = new java.util.HashSet<>(); + for (String segment : path.split("/")) { + if (!segment.isBlank()) segments.add(segment.toLowerCase(Locale.ROOT)); + } + return segments; + } catch (URISyntaxException e) { + log.warn("Ignoring malformed extension URI: {}", value); + return java.util.Set.of(); + } + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java index 51da7e09..c2c75653 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ExtensionSender.java @@ -28,10 +28,10 @@ /** * One-shot pre-positioning facade over a shared {@link A2ATransport}. * - *

Single responsibility: send Authorization-T / Notification-T (and any other one-shot - * extension) messages to agents before the workflow starts. Bypasses Task-T prompt - * generation and the Negotiation-T auto-loop, and does not emit events through the global - * EventCallback (the returned {@link CompletableFuture} is the callback). + *

Single responsibility: send Authorization-T, establish Notification-T subscriptions, or send + * another pre-positioning extension before a workflow starts. It bypasses Task-T prompt + * generation and the Negotiation-T auto-loop. The returned future carries the first response; + * later Notification-T events flow through the subscription callback. * *

Kept separate from {@link WorkflowEngineClient} so a caller that only wants to pre-position is * not forced to hold a workflow-machinery facade. @@ -60,21 +60,17 @@ default CompletableFuture sendAuthorization( } /** - * Convenience for Notification-T pre-positioning. + * Establish a Notification-T subscription. * - *

The returned future completes on the first event (subscription confirmed). - * Subsequent events pushed by the agent through the long-lived SSE stream are - * forwarded to {@code eventCallback} as a map containing extracted text, metadata, - * state, and agent name. Pass {@code null} if you do not need later events. + *

The returned future completes on the first acknowledgement or event. Subsequent events + * pushed by the agent must be forwarded to {@code eventCallback}; implementations cannot + * silently discard it. */ - default CompletableFuture sendNotification( + CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, - Consumer> eventCallback) { - return sendExtensionMessage( - agentName, instruction, naturalLanguageInput, A2ATExtension.NOTIFICATION_T); - } + Consumer> eventCallback); /** Convenience for Notification-T pre-positioning (no event callback). */ default CompletableFuture sendNotification( diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java new file mode 100644 index 00000000..30263b26 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/HeaderContributor.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import org.a2aproject.sdk.spec.AgentCard; + +import java.util.Map; + +/** + * Supplies one isolated group of outbound A2A headers. Contributors return their own map so the + * factory can reject conflicting values instead of relying on mutation order. + */ +interface HeaderContributor { + + Map contribute( + AgentCard agentCard, + String agentName, + Map messageMetadata, + Map currentHeaders); +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java new file mode 100644 index 00000000..7348cb74 --- /dev/null +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/JdkHttpClientFactory.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.concurrent.Executor; + +/** Builds JDK HTTP clients from the engine's outbound TLS policy. */ +final class JdkHttpClientFactory { + + private JdkHttpClientFactory() {} + + static HttpClient create( + boolean sslVerify, + String caCertsPath, + String clientCertPath, + String clientKeyPath, + String clientKeyPassword, + String crlPath, + Duration connectTimeout, + Executor executor) { + HttpClient.Builder builder = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(connectTimeout) + .followRedirects(HttpClient.Redirect.ALWAYS); + if (executor != null) builder.executor(executor); + SslContextFactory.create( + sslVerify, + caCertsPath, + clientCertPath, + clientKeyPath, + clientKeyPassword, + crlPath) + .ifPresent(builder::sslContext); + return builder.build(); + } +} diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java index 05865c06..fe7dd229 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/ProtocolLogger.java @@ -36,15 +36,19 @@ import org.slf4j.LoggerFactory; import java.time.OffsetDateTime; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; /** * Logs complete A2A protocol messages (headers + body) for protocol-level verification against real * network captures. Uses a dedicated "PROTOCOL" logger so output can be independently enabled or * suppressed via logging configuration. * - *

Request side: serializes {@link MessageSendParams} to pretty-printed JSON and logs all headers - * from {@code ClientCallContext}. + *

Request side: serializes {@link MessageSendParams} to pretty-printed JSON. Sensitive headers + * are redacted unless {@code WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=true} is explicitly + * configured. * *

Response side: serializes each {@link ClientEvent} payload (Task, TaskStatusUpdateEvent, * TaskArtifactUpdateEvent, Message) to JSON. @@ -52,6 +56,20 @@ final class ProtocolLogger { private static final Logger log = LoggerFactory.getLogger("PROTOCOL"); + private static final String INCLUDE_SENSITIVE_HEADERS = + "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS"; + private static final String INCLUDE_BODY = "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY"; + private static final String MAX_BODY_CHARS = "WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS"; + private static final int DEFAULT_MAX_BODY_CHARS = 100_000; + private static final Set SENSITIVE_HEADERS = + Set.of( + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key"); + private static final AtomicBoolean sensitiveWarningLogged = new AtomicBoolean(); private static final ObjectMapper mapper = new ObjectMapper() @@ -82,7 +100,7 @@ static void logRequest( return; } try { - String bodyJson = mapper.writeValueAsString(params); + String bodyJson = formatBody(mapper.writeValueAsString(params)); log.info( ">>> [{}] REQUEST to {}\n=== Headers ===\n{}\n=== Body ===\n{}", agentName, @@ -111,7 +129,7 @@ static void logResponseEvent(String agentName, ClientEvent event) { log.info("<<< [{}] RESPONSE [{}]: (no serializable payload)", agentName, eventType); return; } - String json = mapper.writeValueAsString(payload); + String json = formatBody(mapper.writeValueAsString(payload)); log.info("<<< [{}] RESPONSE [{}]\n{}", agentName, eventType, json); } catch (Exception e) { log.warn("<<< [{}] Failed to serialize response event: {}", agentName, e.getMessage()); @@ -143,12 +161,56 @@ private static Object extractPayload(ClientEvent event) { } /** Format headers map as "Key: Value" lines for readable logging. */ - private static String formatHeaders(Map headers) { + static String formatHeaders(Map headers) { if (headers == null || headers.isEmpty()) { return "(none)"; } + boolean includeSensitive = booleanSetting(INCLUDE_SENSITIVE_HEADERS, false); + if (includeSensitive && sensitiveWarningLogged.compareAndSet(false, true)) { + log.warn( + "PROTOCOL sensitive-header logging is enabled; protect logs and disable it outside controlled troubleshooting"); + } StringBuilder sb = new StringBuilder(); - headers.forEach((k, v) -> sb.append(k).append(": ").append(v).append("\n")); + headers.forEach( + (k, v) -> { + String normalized = k.toLowerCase(Locale.ROOT); + boolean sensitive = + SENSITIVE_HEADERS.contains(normalized) + || normalized.contains("token") + || normalized.contains("secret"); + sb.append(k) + .append(": ") + .append(sensitive && !includeSensitive ? "***" : v) + .append("\n"); + }); return sb.toString().trim(); } + + private static String formatBody(String body) { + if (!booleanSetting(INCLUDE_BODY, true)) return "(body logging disabled)"; + int maxChars = intSetting(MAX_BODY_CHARS, DEFAULT_MAX_BODY_CHARS); + if (body.length() <= maxChars) return body; + return body.substring(0, maxChars) + + "\n... (truncated, originalChars=" + + body.length() + + ")"; + } + + private static boolean booleanSetting(String name, boolean defaultValue) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : Boolean.parseBoolean(value); + } + + private static int intSetting(String name, int defaultValue) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) value = System.getenv(name); + if (value == null || value.isBlank()) return defaultValue; + try { + int parsed = Integer.parseInt(value); + return parsed > 0 ? parsed : defaultValue; + } catch (NumberFormatException ignored) { + return defaultValue; + } + } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java index c5ec11bb..33ccc8ac 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/SslContextFactory.java @@ -23,12 +23,28 @@ import org.slf4j.LoggerFactory; import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; import java.security.KeyStore; +import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; +import java.security.cert.X509CRL; import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.List; import java.util.Optional; +import javax.crypto.Cipher; +import javax.crypto.EncryptedPrivateKeyInfo; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; @@ -36,9 +52,9 @@ /** * SSL context factory for outbound HTTPS calls. * - *

Mirrors the Python SDK's {@code ssl_context.create_ssl_context()}. Returns an {@link - * SSLContext} configured with the given CA trust store, or {@code Optional#empty()} when server - * verification is disabled. + *

All configuration errors fail closed. Disabling certificate-chain verification creates a + * per-client trust-all context, but deliberately does not change the JVM-wide JDK HTTP client + * hostname-verification property. */ public final class SslContextFactory { @@ -51,7 +67,7 @@ private SslContextFactory() {} * * @param verifyServer whether to verify remote server certificates * @param caCertsPath optional path to a PEM CA trust store file - * @return SSLContext, or empty when verification is disabled + * @return the configured SSLContext */ public static Optional create(boolean verifyServer, String caCertsPath) { return create(verifyServer, caCertsPath, null, null, null, null); @@ -66,7 +82,7 @@ public static Optional create(boolean verifyServer, String caCertsPa * @param keyPath optional path to client private key (for mTLS) * @param keyPassword optional password for the private key * @param crlPath optional path to a CRL file - * @return SSLContext, or empty when verification is disabled + * @return the configured SSLContext */ public static Optional create( boolean verifyServer, @@ -75,24 +91,23 @@ public static Optional create( String keyPath, String keyPassword, String crlPath) { - if (!verifyServer) { - log.warn("Outbound TLS verification disabled. Insecure for production."); - return Optional.empty(); - } try { - X509TrustManager trustManager = createTrustManager(caCertsPath); - // Load client identity cert for mTLS if provided javax.net.ssl.KeyManager[] keyManagers = loadKeyManagers(certPath, keyPath, keyPassword); + X509TrustManager trustManager = + verifyServer + ? createTrustManager(caCertsPath) + : trustAllManager(); + trustManager = applyCrl(trustManager, crlPath); SSLContext ctx = SSLContext.getInstance("TLS"); ctx.init(keyManagers, new TrustManager[] {trustManager}, null); - log.info("Client SSL: context initialized (ca_certs={})", caCertsPath); + log.info( + "Client SSL: context initialized (verifyServer={}, ca_certs={})", + verifyServer, + caCertsPath); return Optional.of(ctx); } catch (Exception e) { - log.error( - "Failed to build SSL context: {}. Falling back to no verification.", - e.getMessage()); - return Optional.empty(); + throw new IllegalStateException("Failed to build outbound TLS context", e); } } @@ -107,6 +122,7 @@ private static X509TrustManager createTrustManager(String caCertsPath) throws Ex ks.setCertificateEntry("ca-" + i, cert); i++; } + if (i == 0) throw new IllegalArgumentException("CA file contains no certificates"); log.info("Client SSL: loaded CA trust store from {}", caCertsPath); javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance( @@ -123,71 +139,156 @@ private static X509TrustManager createTrustManager(String caCertsPath) throws Ex } private static javax.net.ssl.KeyManager[] loadKeyManagers( - String certPath, String keyPath, String keyPassword) { - if (certPath == null - || certPath.isEmpty() - || keyPath == null - || keyPath.isEmpty() - || !new java.io.File(certPath).exists() - || !new java.io.File(keyPath).exists()) { - return null; + String certPath, String keyPath, String keyPassword) throws Exception { + boolean hasCert = certPath != null && !certPath.isBlank(); + boolean hasKey = keyPath != null && !keyPath.isBlank(); + if (!hasCert && !hasKey) return null; + if (!hasCert || !hasKey) { + throw new IllegalArgumentException( + "Both client certificate and private key are required for mTLS"); } - try { - KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - keyStore.load(null, null); - java.security.cert.CertificateFactory cf = - java.security.cert.CertificateFactory.getInstance("X.509"); - try (FileInputStream certFis = new FileInputStream(certPath)) { - X509Certificate cert = (X509Certificate) cf.generateCertificate(certFis); - keyStore.setCertificateEntry("client-cert", cert); + Path certificateFile = Path.of(certPath); + Path privateKeyFile = Path.of(keyPath); + if (!Files.isRegularFile(certificateFile) || !Files.isRegularFile(privateKeyFile)) { + throw new IllegalArgumentException("mTLS certificate or private-key file not found"); + } + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + List chain; + try (FileInputStream certFis = new FileInputStream(certificateFile.toFile())) { + chain = new ArrayList<>(cf.generateCertificates(certFis)); + } + if (chain.isEmpty()) throw new IllegalArgumentException("mTLS certificate chain is empty"); + + PrivateKey privateKey = parsePkcs8PrivateKey(privateKeyFile, keyPassword); + char[] password = keyPassword != null ? keyPassword.toCharArray() : new char[0]; + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setKeyEntry( + "client-key", privateKey, password, chain.toArray(new Certificate[0])); + javax.net.ssl.KeyManagerFactory kmf = + javax.net.ssl.KeyManagerFactory.getInstance( + javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, password); + log.info("Client SSL: loaded client identity certificate chain for mTLS"); + return kmf.getKeyManagers(); + } + + private static PrivateKey parsePkcs8PrivateKey(Path keyPath, String keyPassword) + throws Exception { + byte[] raw = Files.readAllBytes(keyPath); + String text = new String(raw, StandardCharsets.US_ASCII); + byte[] der = raw; + if (text.contains("-----BEGIN")) { + boolean encrypted = text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----"); + if (!encrypted && !text.contains("-----BEGIN PRIVATE KEY-----")) { + throw new IllegalArgumentException( + "Only PKCS#8 PEM private keys are supported"); } - byte[] keyBytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of(keyPath)); - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.spec.PKCS8EncodedKeySpec keySpec = - new java.security.spec.PKCS8EncodedKeySpec(keyBytes); - java.security.PrivateKey privateKey = kf.generatePrivate(keySpec); - char[] password = keyPassword != null ? keyPassword.toCharArray() : new char[0]; - keyStore.setKeyEntry( - "client-key", - privateKey, - password, - new java.security.cert.Certificate[] {keyStore.getCertificate("client-cert")}); - javax.net.ssl.KeyManagerFactory kmf = - javax.net.ssl.KeyManagerFactory.getInstance( - javax.net.ssl.KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keyStore, password); - log.info("Client SSL: loaded client identity cert for mTLS"); - return kmf.getKeyManagers(); - } catch (Exception e) { - log.warn("Client SSL: could not load client cert chain: {}", e.getMessage()); - return null; + String base64 = + text.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN ENCRYPTED PRIVATE KEY-----", "") + .replace("-----END ENCRYPTED PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + der = Base64.getDecoder().decode(base64); + if (encrypted) der = decryptPkcs8PrivateKey(der, keyPassword); } + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + Exception last = null; + for (String algorithm : List.of("RSA", "EC", "Ed25519")) { + try { + return KeyFactory.getInstance(algorithm).generatePrivate(spec); + } catch (Exception e) { + last = e; + } + } + throw new IllegalArgumentException("Unsupported PKCS#8 private-key algorithm", last); + } + + private static byte[] decryptPkcs8PrivateKey(byte[] encrypted, String keyPassword) + throws Exception { + if (keyPassword == null || keyPassword.isEmpty()) { + throw new IllegalArgumentException("Encrypted private key requires a password"); + } + EncryptedPrivateKeyInfo encryptedInfo = new EncryptedPrivateKeyInfo(encrypted); + SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(encryptedInfo.getAlgName()); + SecretKey key = keyFactory.generateSecret(new PBEKeySpec(keyPassword.toCharArray())); + Cipher cipher = Cipher.getInstance(encryptedInfo.getAlgName()); + cipher.init(Cipher.DECRYPT_MODE, key, encryptedInfo.getAlgParameters()); + return encryptedInfo.getKeySpec(cipher).getEncoded(); + } + + private static X509TrustManager applyCrl(X509TrustManager delegate, String crlPath) + throws Exception { + if (crlPath == null || crlPath.isBlank()) return delegate; + Path file = Path.of(crlPath); + if (!Files.isRegularFile(file)) { + throw new IllegalArgumentException("CRL file not found: " + crlPath); + } + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Collection parsed; + try (FileInputStream input = new FileInputStream(file.toFile())) { + parsed = cf.generateCRLs(input); + } + List crls = + parsed.stream().filter(X509CRL.class::isInstance).map(X509CRL.class::cast).toList(); + if (crls.isEmpty()) throw new IllegalArgumentException("CRL file contains no X.509 CRL"); + log.info("Client SSL: loaded {} CRL(s) from {}", crls.size(), crlPath); + return new RevocationCheckingTrustManager(delegate, crls); } public static SSLContext createTrustAll() { - System.setProperty("jdk.internal.httpclient.disableHostnameVerification", "true"); try { SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init( - null, - new TrustManager[] { - new X509TrustManager() { - public void checkClientTrusted( - X509Certificate[] chain, String authType) {} - - public void checkServerTrusted( - X509Certificate[] chain, String authType) {} - - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } - }, - null); - log.warn("Trust-all SSL context created"); + ctx.init(null, new TrustManager[] {trustAllManager()}, null); + log.warn( + "Outbound TLS certificate-chain verification disabled; hostname verification remains enabled"); return ctx; } catch (Exception e) { - throw new RuntimeException(e); + throw new IllegalStateException("Failed to create trust-all SSL context", e); + } + } + + private static X509TrustManager trustAllManager() { + return new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + } + + private record RevocationCheckingTrustManager( + X509TrustManager delegate, List crls) implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) + throws java.security.cert.CertificateException { + delegate.checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) + throws java.security.cert.CertificateException { + delegate.checkServerTrusted(chain, authType); + for (X509Certificate certificate : chain) { + for (X509CRL crl : crls) { + if (crl.isRevoked(certificate)) { + throw new java.security.cert.CertificateException( + "Server certificate has been revoked: " + + certificate.getSerialNumber()); + } + } + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return delegate.getAcceptedIssuers(); } } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java index a214ecbc..8d89e20a 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/client/WorkflowEngineClientConfig.java @@ -35,7 +35,15 @@ public class WorkflowEngineClientConfig { private final boolean sslVerify; private final String caCertsPath; + private final String clientCertPath; + private final String clientKeyPath; + private final String clientKeyPassword; + private final String crlPath; private final long sendTimeoutSeconds; + private final long notificationAckTimeoutSeconds; + private final int sendExecutorCoreSize; + private final int sendExecutorMaxSize; + private final int sendExecutorQueueCapacity; private final AuthProvider authProvider; private final String credentialsConfigPath; private final Map>> credentialsConfig; @@ -47,7 +55,15 @@ public class WorkflowEngineClientConfig { private WorkflowEngineClientConfig(Builder b) { this.sslVerify = b.sslVerify; this.caCertsPath = b.caCertsPath; + this.clientCertPath = b.clientCertPath; + this.clientKeyPath = b.clientKeyPath; + this.clientKeyPassword = b.clientKeyPassword; + this.crlPath = b.crlPath; this.sendTimeoutSeconds = b.sendTimeoutSeconds; + this.notificationAckTimeoutSeconds = b.notificationAckTimeoutSeconds; + this.sendExecutorCoreSize = b.sendExecutorCoreSize; + this.sendExecutorMaxSize = b.sendExecutorMaxSize; + this.sendExecutorQueueCapacity = b.sendExecutorQueueCapacity; this.authProvider = b.authProvider; this.credentialsConfigPath = b.credentialsConfigPath; this.credentialsConfig = b.credentialsConfig; @@ -64,7 +80,15 @@ public static Builder builder() { public static class Builder { private boolean sslVerify = true; private String caCertsPath = null; + private String clientCertPath = null; + private String clientKeyPath = null; + private String clientKeyPassword = null; + private String crlPath = null; private long sendTimeoutSeconds = 600; + private long notificationAckTimeoutSeconds = 5; + private int sendExecutorCoreSize = 4; + private int sendExecutorMaxSize = 16; + private int sendExecutorQueueCapacity = 256; private AuthProvider authProvider; private String credentialsConfigPath = null; private Map>> credentialsConfig = null; @@ -93,6 +117,46 @@ public Builder caCertsPath(String v) { return this; } + public Builder clientCertPath(String v) { + this.clientCertPath = v; + return this; + } + + public Builder clientKeyPath(String v) { + this.clientKeyPath = v; + return this; + } + + public Builder clientKeyPassword(String v) { + this.clientKeyPassword = v; + return this; + } + + public Builder crlPath(String v) { + this.crlPath = v; + return this; + } + + public Builder notificationAckTimeoutSeconds(long v) { + this.notificationAckTimeoutSeconds = v; + return this; + } + + public Builder sendExecutorCoreSize(int v) { + this.sendExecutorCoreSize = v; + return this; + } + + public Builder sendExecutorMaxSize(int v) { + this.sendExecutorMaxSize = v; + return this; + } + + public Builder sendExecutorQueueCapacity(int v) { + this.sendExecutorQueueCapacity = v; + return this; + } + public Builder credentialsConfigPath(String v) { this.credentialsConfigPath = v; return this; @@ -124,6 +188,14 @@ public Builder customHandlers(List v) { } public WorkflowEngineClientConfig build() { + if (sendTimeoutSeconds <= 0 || notificationAckTimeoutSeconds <= 0) { + throw new IllegalArgumentException("Timeouts must be positive"); + } + if (sendExecutorCoreSize <= 0 + || sendExecutorMaxSize < sendExecutorCoreSize + || sendExecutorQueueCapacity <= 0) { + throw new IllegalArgumentException("Invalid send executor configuration"); + } return new WorkflowEngineClientConfig(this); } } diff --git a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java index 775a3478..13f8cbe6 100644 --- a/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java +++ b/workflow-engine/src/main/java/dev/openan/workflow/engine/control/DefaultControlPoint.java @@ -63,11 +63,12 @@ public CompletableFuture onTask( .sendMessage(request.getAgentName(), request.getMessage()) .thenApply( r -> { - boolean success = r.getText() != null && !r.getText().isEmpty(); + boolean success = isSuccessful(r); log.info( - "[DefaultCP] Response from {}: {} chars, success={}", + "[DefaultCP] Response from {}: {} chars, state={}, success={}", request.getAgentName(), r.getText() != null ? r.getText().length() : 0, + r.getTaskState(), success); return TaskResponse.builder() .success(success) @@ -84,7 +85,16 @@ public CompletableFuture onTask( .success(false) .error("Agent call failed: " + e.getMessage()) .build(); - }); + }); + } + + private static boolean isSuccessful( + dev.openan.workflow.engine.model.SendMessageResult result) { + String state = result.getTaskState(); + if (state == null || state.isBlank() || state.endsWith("UNSPECIFIED")) { + return result.getText() != null && !result.getText().isBlank(); + } + return state.endsWith("COMPLETED"); } @Override 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 6ad7c5fd..22884d79 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 @@ -128,17 +128,31 @@ public CompletableFuture run() { "[Executor] Starting workflow: {} ({} steps)", workflow.getName(), workflow.getSteps().size()); + try { + validateWorkflowGraph(); + } catch (IllegalArgumentException e) { + log.error("[Executor] Invalid workflow graph: {}", e.getMessage()); + emit(EventType.ERROR, Map.of("error", e.getMessage())); + return CompletableFuture.completedFuture( + ExecutionResult.builder() + .success(false) + .history(new ArrayList<>(executionHistory)) + .stepOutputs(new HashMap<>(stepOutputs)) + .error(e.getMessage()) + .build()); + } Deque pending = new ConcurrentLinkedDeque<>(); + Set activated = ConcurrentHashMap.newKeySet(); for (int i = 0; i < workflow.getSteps().size(); i++) { var s = workflow.getSteps().get(i); - if (s.getLayer() == 0 && contextBuilder.getStepPredecessors(s.getName()).isEmpty()) { + if (contextBuilder.getStepPredecessors(s.getName()).isEmpty()) { pending.add(i); + activated.add(i); } } Set executed = ConcurrentHashMap.newKeySet(); boolean[] failed = {false}; - Map deferCount = new ConcurrentHashMap<>(); - return executeSteps(pending, executed, failed, deferCount) + return executeSteps(pending, executed, activated, failed) .thenApply( v -> { emit(EventType.WORKFLOW_COMPLETE, Map.of()); @@ -169,8 +183,8 @@ public CompletableFuture run() { private CompletableFuture executeSteps( Deque pending, Set executed, - boolean[] failed, - Map deferCount) { + Set activated, + boolean[] failed) { if (pending.isEmpty() || failed[0]) { return CompletableFuture.completedFuture(null); } @@ -185,17 +199,20 @@ private CompletableFuture executeSteps( } var step = workflow.getSteps().get(idx); var preds = contextBuilder.getStepPredecessors(step.getName()); - if (preds.stream().allMatch(stepOutputs::containsKey)) { + boolean activePredecessorsComplete = + preds.stream() + .filter( + predecessor -> { + Integer predecessorIndex = + contextBuilder.findStepIndex(predecessor); + return predecessorIndex != null + && activated.contains(predecessorIndex); + }) + .allMatch(stepOutputs::containsKey); + if (activePredecessorsComplete) { readySteps.add(idx); } else { - int dc = deferCount.getOrDefault(idx, 0) + 1; - if (dc > workflow.getSteps().size()) { - log.warn("Step {} deferred too many times, skipping", step.getName()); - executed.add(idx); - } else { - deferCount.put(idx, dc); - deferredSteps.add(idx); - } + deferredSteps.add(idx); } } // Add deferred steps back @@ -204,16 +221,35 @@ private CompletableFuture executeSteps( } if (readySteps.isEmpty()) { if (!deferredSteps.isEmpty()) { - return CompletableFuture.runAsync( - () -> { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - }) - .thenCompose( - v -> executeSteps(pending, executed, failed, deferCount)); + String details = + deferredSteps.stream() + .map( + idx -> { + WorkflowStep step = workflow.getSteps().get(idx); + List missing = + contextBuilder.getStepPredecessors(step.getName()) + .stream() + .filter( + predecessor -> { + Integer predecessorIndex = + contextBuilder + .findStepIndex( + predecessor); + return predecessorIndex + != null + && activated.contains( + predecessorIndex) + && !stepOutputs.containsKey( + predecessor); + }) + .toList(); + return step.getName() + " <- " + missing; + }) + .collect(Collectors.joining(", ")); + return CompletableFuture.failedFuture( + new IllegalStateException( + "Workflow dependency deadlock; unresolved active predecessors: " + + details)); } return CompletableFuture.completedFuture(null); } @@ -222,14 +258,18 @@ private CompletableFuture executeSteps( for (int idx : readySteps) { executed.add(idx); var step = workflow.getSteps().get(idx); - stepFutures.add(executeStep(step, executed, pending, failed)); + stepFutures.add(executeStep(step, executed, activated, pending, failed)); } return CompletableFuture.allOf(stepFutures.toArray(new CompletableFuture[0])) - .thenCompose(v -> executeSteps(pending, executed, failed, deferCount)); + .thenCompose(v -> executeSteps(pending, executed, activated, failed)); } private CompletableFuture executeStep( - WorkflowStep step, Set executed, Deque pending, boolean[] failed) { + WorkflowStep step, + Set executed, + Set activated, + Deque pending, + boolean[] failed) { emit(EventType.STEP_START, Map.of("step", step.getName())); log.info("--- Executing step: {} ---", step.getName()); return executeSubtasks(step) @@ -258,6 +298,7 @@ private CompletableFuture executeStep( int nxt = nextIndices.get(i); if (!executed.contains(nxt) && !pending.contains(nxt)) { + activated.add(nxt); pending.addFirst(nxt); } } @@ -265,6 +306,54 @@ private CompletableFuture executeStep( }); } + private void validateWorkflowGraph() { + Map indices = new HashMap<>(); + for (int i = 0; i < workflow.getSteps().size(); i++) { + String name = workflow.getSteps().get(i).getName(); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Workflow step name must not be blank"); + } + if (indices.put(name, i) != null) { + throw new IllegalArgumentException("Duplicate workflow step name: " + name); + } + } + List> graph = new ArrayList<>(); + for (int i = 0; i < workflow.getSteps().size(); i++) graph.add(new ArrayList<>()); + for (int i = 0; i < workflow.getSteps().size(); i++) { + WorkflowStep step = workflow.getSteps().get(i); + if (step.getNext() == null) continue; + for (JumpCondition jump : step.getNext()) { + if (isTerminalRoute(jump.getStep())) continue; + Integer target = indices.get(jump.getStep()); + if (target == null) { + throw new IllegalArgumentException( + "Step " + step.getName() + " references missing step " + jump.getStep()); + } + graph.get(i).add(target); + } + } + int[] state = new int[workflow.getSteps().size()]; + for (int i = 0; i < state.length; i++) detectCycle(i, graph, state); + } + + private void detectCycle(int node, List> graph, int[] state) { + if (state[node] == 2) return; + if (state[node] == 1) { + throw new IllegalArgumentException( + "Workflow graph contains a cycle at step " + + workflow.getSteps().get(node).getName()); + } + state[node] = 1; + for (int next : graph.get(node)) detectCycle(next, graph, state); + state[node] = 2; + } + + private static boolean isTerminalRoute(String stepName) { + return "end".equals(stepName) + || "retry".equals(stepName) + || "endNode".equals(stepName); + } + private record StepResult( String taskDesc, String output, boolean success, Map results) {} @@ -504,9 +593,7 @@ private CompletableFuture> determineNextSteps( .allMatch(jc -> jc.getCondition() == null || jc.getCondition().isEmpty())) { List indices = new ArrayList<>(); for (var jc : step.getNext()) { - if (jc.getStep().equals("end") - || jc.getStep().equals("retry") - || jc.getStep().equals("endNode")) { + if (isTerminalRoute(jc.getStep())) { continue; } Integer idx = contextBuilder.findStepIndex(jc.getStep()); @@ -545,19 +632,24 @@ private CompletableFuture> determineNextSteps( decision.getNextStep(), "reason", decision.getReason())); - Integer idx = contextBuilder.findStepIndex(decision.getNextStep()); - if (idx == null) { - List allowed = - step.getNext().stream() - .map(JumpCondition::getStep) - .collect(Collectors.toList()); - log.warn( - "on_route returned '{}' for step '{}', not in allowed next {}; workflow will end.", - decision.getNextStep(), - step.getName(), - allowed); - return List.of(); + List allowed = + step.getNext().stream() + .map(JumpCondition::getStep) + .collect(Collectors.toList()); + if (!allowed.contains(decision.getNextStep())) { + throw new IllegalStateException( + "onRoute returned '" + + decision.getNextStep() + + "' for step '" + + step.getName() + + "', allowed next steps are " + + allowed); } + if (isTerminalRoute(decision.getNextStep())) return List.of(); + Integer idx = contextBuilder.findStepIndex(decision.getNextStep()); + if (idx == null) + throw new IllegalStateException( + "Route target does not exist: " + decision.getNextStep()); return List.of(idx); }); } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java index 5bc3f1f3..0c6e81f9 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/A2ATransportHeaderTest.java @@ -8,6 +8,8 @@ package dev.openan.workflow.engine.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; @@ -17,9 +19,11 @@ import org.a2aproject.sdk.spec.MessageSendParams; import org.junit.jupiter.api.Test; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CompletionException; import java.util.function.Consumer; class A2ATransportHeaderTest { @@ -54,6 +58,114 @@ void forwardsAuthorizationAndActiveExtensionHeadersToRuntime() throws Exception assertEquals(TASK_T_URI, capturedHeaders.get().get("A2A-Extensions")); } + @Test + void notificationTransportFailureIsNotReportedAsSubscriptionSuccess() throws Exception { + AgentCard card = agentCard(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + throw new IllegalStateException("gateway unavailable"); + } + + @Override + public void close() {} + }; + WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder().build(); + + try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + transport.sendNotificationStream( + card, + card.name(), + "subscribe", + "context-1", + Map.of(TASK_T_URI, "notification"), + null) + .join()); + assertEquals("gateway unavailable", error.getCause().getMessage()); + } + } + + @Test + void agentCardSecurityRequirementWithoutCredentialsFailsClosed() throws Exception { + AgentCard card = securedAgentCard(); + A2AJavaClientRuntime runtime = + new A2AJavaClientRuntime() { + @Override + public Iterable sendMessage( + AgentCard agentCard, + MessageSendParams params, + ClientCallContext callContext, + Consumer eventSink, + Consumer logSink) { + throw new AssertionError("unauthenticated request must not reach runtime"); + } + + @Override + public void close() {} + }; + + try (A2ATransport transport = + new A2ATransport(List.of(card), runtime, WorkflowEngineClientConfig.builder().build())) { + CompletionException error = + assertThrows( + CompletionException.class, + () -> + transport.send(card, card.name(), "diagnose", "context-1", null, null) + .join()); + assertTrue(error.getCause() instanceof SecurityException); + assertTrue(error.getCause().getMessage().contains("none are configured")); + } + } + + @Test + void authProviderCanSatisfySecurityRequirementWithoutCredentials() throws Exception { + AtomicReference> capturedHeaders = new AtomicReference<>(); + A2AJavaClientRuntime runtime = new CapturingRuntime(capturedHeaders); + AgentCard card = securedAgentCard(); + WorkflowEngineClientConfig config = + WorkflowEngineClientConfig.builder() + .authProvider( + (agentName, ignored, headers) -> + headers.put("Authorization", "Bearer gateway-token")) + .build(); + + try (A2ATransport transport = new A2ATransport(List.of(card), runtime, config)) { + transport.send(card, card.name(), "diagnose", "context-1", null, null).join(); + } + + assertEquals("Bearer gateway-token", capturedHeaders.get().get("Authorization")); + } + + @Test + void authenticationHeaderConflictsAreCaseInsensitive() { + Map headers = new HashMap<>(); + headers.put("authorization", "Bearer provider-token"); + + SecurityException error = + assertThrows( + SecurityException.class, + () -> + ClientCallContextFactory.mergeHeaders( + "Test Agent", + headers, + Map.of("Authorization", "Bearer credential-token"))); + + assertEquals( + "Authentication header conflict for agent Test Agent: Authorization", + error.getMessage()); + assertEquals(1, headers.size()); + } + private static AgentCard agentCard() throws Exception { String json = """ @@ -86,6 +198,40 @@ private static AgentCard agentCard() throws Exception { .readValue(json, AgentCard.class); } + private static AgentCard securedAgentCard() throws Exception { + String json = + """ + { + "name": "Test Agent", + "description": "test", + "version": "1.0", + "capabilities": {"streaming": false}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [], + "securitySchemes": { + "bearerAuth": { + "httpAuthSecurityScheme": {"scheme": "Bearer"} + } + }, + "securityRequirements": [ + {"schemes": {"bearerAuth": {}}} + ], + "supportedInterfaces": [ + { + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0", + "url": "https://agent.example.test/a2a/json", + "tenant": "" + } + ] + } + """; + return new ObjectMapper() + .registerModule(new AgentCardJacksonModule()) + .readValue(json, AgentCard.class); + } + private record CapturingRuntime(AtomicReference> capturedHeaders) implements A2AJavaClientRuntime { diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java index 44aae2d6..87fd7d42 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentAuthManagerTest.java @@ -9,7 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; @@ -45,18 +45,16 @@ void loadsCredentialsFromClasspathPrefix() { } @Test - void missingFileDoesNotCreateCredentials() { - AgentAuthManager manager = - new AgentAuthManager("/nonexistent/missing-agent-credentials.json"); - - assertNull(manager.getConfig("Test Agent")); + void missingFileFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> new AgentAuthManager("/nonexistent/missing-agent-credentials.json")); } @Test - void missingClasspathResourceDoesNotCreateCredentials() { - AgentAuthManager manager = - new AgentAuthManager("classpath:nonexistent-credentials.json"); - - assertNull(manager.getConfig("Test Agent")); + void missingClasspathResourceFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> new AgentAuthManager("classpath:nonexistent-credentials.json")); } } diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java index 79d01b88..a435a3a4 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCardNormalizerTest.java @@ -88,8 +88,7 @@ void convertsOpenApiApiKeyScheme() { } @Test - @SuppressWarnings("unchecked") - void autoPopulatesSecurityRequirementsFromSchemes() { + void doesNotInventSecurityRequirementsFromSupportedSchemes() { Map card = Map.of( "name", @@ -99,12 +98,24 @@ void autoPopulatesSecurityRequirementsFromSchemes() { "bearerAuth", Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer")))); Map result = AgentCardNormalizer.normalize(card); - Object secReqs = result.get("securityRequirements"); - assertNotNull(secReqs); - List> reqs = (List>) secReqs; - assertEquals(1, reqs.size()); - Map schemesMap = (Map) reqs.get(0).get("schemes"); - assertNotNull(schemesMap.get("bearerAuth")); + assertFalse(result.containsKey("securityRequirements")); + } + + @Test + void preservesExplicitlyEmptySecurityRequirements() { + Map card = + Map.of( + "name", + "Agent1", + "securitySchemes", + Map.of( + "bearerAuth", + Map.of("httpAuthSecurityScheme", Map.of("scheme", "bearer"))), + "securityRequirements", + List.of()); + Map result = AgentCardNormalizer.normalize(card); + + assertEquals(List.of(), result.get("securityRequirements")); } @Test diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java new file mode 100644 index 00000000..590c85dd --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/AgentCredentialServiceTest.java @@ -0,0 +1,60 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; + +import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +class AgentCredentialServiceTest { + + @Test + void formLoginEncodesKeysAndValuesAsUtf8() throws Exception { + AtomicReference requestBody = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/token", + exchange -> { + requestBody.set( + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] response = "{\"access_token\":\"token-1\"}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + }); + server.start(); + try { + Map scheme = new HashMap<>(); + scheme.put("login_url", "http://127.0.0.1:" + server.getAddress().getPort() + "/token"); + scheme.put("content_type", "application/x-www-form-urlencoded"); + scheme.put("token_field", "access_token"); + scheme.put("request_fields", Map.of("user name", "a+b", "password", "密&码=")); + AgentCredentialService service = + new AgentCredentialService( + "agent", Map.of("bearer", scheme), HttpClient.newHttpClient()); + + String token = + service.getCredential( + "bearer", new ClientCallContext(new HashMap<>(), new HashMap<>())); + + assertEquals("token-1", token); + assertTrue(requestBody.get().contains("user+name=a%2Bb")); + assertTrue(requestBody.get().contains("password=%E5%AF%86%26%E7%A0%81%3D")); + } finally { + server.stop(0); + } + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java new file mode 100644 index 00000000..f5953fed --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/CredentialCryptoTest.java @@ -0,0 +1,41 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class CredentialCryptoTest { + private static final String KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + @AfterEach + void clearKey() { + System.clearProperty("A2AT_CRED_KEY"); + } + + @Test + void encryptedCredentialRoundTrips() { + System.setProperty("A2AT_CRED_KEY", KEY); + String encrypted = CredentialCrypto.encrypt("p@ss&中文"); + assertEquals("p@ss&中文", CredentialCrypto.decryptIfNeeded(encrypted)); + } + + @Test + void corruptedEncryptedCredentialFailsClosed() { + System.setProperty("A2AT_CRED_KEY", KEY); + assertThrows( + IllegalStateException.class, + () -> CredentialCrypto.decryptIfNeeded("enc:not-base64:also-not-base64")); + } + + @Test + void plaintextRemainsBackwardCompatible() { + assertEquals("plain", CredentialCrypto.decryptIfNeeded("plain")); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java index d7d07fb9..0321f1e0 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionRegistryTest.java @@ -90,6 +90,21 @@ void returnsEmptyForUnmatchedUris() { assertTrue(handlers.isEmpty()); } + @Test + void doesNotMatchKeywordThatIsOnlyPartOfAnotherPathSegment() { + ExtensionRegistry reg = new ExtensionRegistry(); + assertTrue( + reg.getHandlersForExtensions( + List.of("https://example.com/extensions/Task-T-Extended/v1")) + .isEmpty()); + } + + @Test + void ignoresMalformedUris() { + ExtensionRegistry reg = new ExtensionRegistry(); + assertTrue(reg.getHandlersForExtensions(List.of("https://bad uri/Task-T/v1")).isEmpty()); + } + @Test void nullUrisHandledGracefully() { ExtensionRegistry reg = new ExtensionRegistry(); diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java new file mode 100644 index 00000000..64091c59 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ExtensionSenderTest.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package dev.openan.workflow.engine.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +class ExtensionSenderTest { + + @Test + void notificationCallbackMethodIsAnInterfaceContract() throws Exception { + Method method = + ExtensionSender.class.getMethod( + "sendNotification", + String.class, + String.class, + String.class, + java.util.function.Consumer.class); + + assertTrue(Modifier.isAbstract(method.getModifiers())); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java new file mode 100644 index 00000000..b34195fd --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/ProtocolLoggerTest.java @@ -0,0 +1,44 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class ProtocolLoggerTest { + private static final String SETTING = + "WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS"; + + @AfterEach + void clearSetting() { + System.clearProperty(SETTING); + } + + @Test + void sensitiveHeadersAreRedactedByDefault() { + System.setProperty(SETTING, "false"); + String formatted = + ProtocolLogger.formatHeaders( + Map.of( + "Authorization", "Bearer secret-token", + "X-Request-Id", "request-1")); + assertTrue(formatted.contains("Authorization: ***")); + assertFalse(formatted.contains("secret-token")); + assertTrue(formatted.contains("X-Request-Id: request-1")); + } + + @Test + void sensitiveHeadersCanBeExplicitlyEnabledForControlledDiagnostics() { + System.setProperty(SETTING, "true"); + String formatted = + ProtocolLogger.formatHeaders(Map.of("Authorization", "Bearer visible")); + assertTrue(formatted.contains("Bearer visible")); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java new file mode 100644 index 00000000..ab3bb3e0 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/client/SslContextFactoryTest.java @@ -0,0 +1,65 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.concurrent.ExecutorService; + +class SslContextFactoryTest { + private static final String HOSTNAME_PROPERTY = + "jdk.internal.httpclient.disableHostnameVerification"; + + @Test + void trustAllContextDoesNotMutateJvmHostnameVerification() { + String previous = System.getProperty(HOSTNAME_PROPERTY); + try { + System.clearProperty(HOSTNAME_PROPERTY); + SslContextFactory.createTrustAll(); + assertNull(System.getProperty(HOSTNAME_PROPERTY)); + } finally { + if (previous == null) System.clearProperty(HOSTNAME_PROPERTY); + else System.setProperty(HOSTNAME_PROPERTY, previous); + } + } + + @Test + void invalidConfiguredCaFailsClosed() { + assertThrows( + IllegalStateException.class, + () -> SslContextFactory.create(true, "missing-ca-file.pem")); + } + + @Test + void insecureServerVerificationStillLoadsClientIdentity() { + assertThrows( + IllegalStateException.class, + () -> + SslContextFactory.create( + false, + null, + "missing-client-cert.pem", + "missing-client-key.pem", + null, + null)); + } + + @Test + void runtimeClosesItsOwnedExecutor() throws Exception { + DefaultA2AJavaClientRuntime runtime = new DefaultA2AJavaClientRuntime(); + Field field = DefaultA2AJavaClientRuntime.class.getDeclaredField("httpClientExecutor"); + field.setAccessible(true); + ExecutorService executor = (ExecutorService) field.get(runtime); + assertFalse(executor.isShutdown()); + runtime.close(); + assertTrue(executor.isShutdown()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java new file mode 100644 index 00000000..52765193 --- /dev/null +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/control/DefaultControlPointTest.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.control; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.openan.workflow.engine.StubWorkflowEngineClient; +import dev.openan.workflow.engine.model.TaskRequest; + +import org.junit.jupiter.api.Test; + +class DefaultControlPointTest { + + private static final TaskRequest REQUEST = + TaskRequest.builder().agentName("agent").stepName("step").message("run").build(); + + @Test + void completedTaskWithEmptyTextIsSuccessful() { + StubWorkflowEngineClient client = + new StubWorkflowEngineClient("agent") + .withDefaultResponse("") + .withDefaultTaskState("TASK_STATE_COMPLETED"); + assertTrue(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + } + + @Test + void failedTaskWithErrorTextIsNotSuccessful() { + StubWorkflowEngineClient client = + new StubWorkflowEngineClient("agent") + .withDefaultResponse("remote error") + .withDefaultTaskState("TASK_STATE_FAILED"); + assertFalse(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + } + + @Test + void messageOnlyLegacyResponseFallsBackToText() { + StubWorkflowEngineClient client = + new StubWorkflowEngineClient("agent") + .withDefaultResponse("ok") + .withDefaultTaskState(""); + assertTrue(new DefaultControlPoint().onTask(REQUEST, client).join().isSuccess()); + } +} diff --git a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java index d443388c..598b27c3 100644 --- a/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java +++ b/workflow-engine/src/test/java/dev/openan/workflow/engine/core/WorkflowExecutorTest.java @@ -211,7 +211,7 @@ public CompletableFuture onRoute( } @Test - void onRouteInvalidStepEndsWorkflow() { + void onRouteInvalidStepFailsWorkflow() { WorkflowStep s1 = WorkflowStep.builder() .name("s1") @@ -256,8 +256,9 @@ public CompletableFuture onRoute( WorkflowExecutor exec = new WorkflowExecutor(wf, cp, stub, recordingCallback(), "", "zh"); ExecutionResult result = exec.run().join(); - // s1 executes, then invalid route ends workflow (s2 never runs) - assertTrue(result.isSuccess()); + // s1 executes, then an invalid route is reported as a workflow error (s2 never runs). + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("allowed next steps")); assertEquals(1, stub.getSentCount()); } @@ -475,6 +476,135 @@ void noSubtasksStepSucceeds() { assertEquals(0, stub.getSentCount()); } + @Test + void missingStaticRouteTargetFailsBeforeExecution() { + WorkflowStep step = + WorkflowStep.builder() + .name("start") + .layer(0) + .subtasks(List.of(task("A", "run"))) + .next(List.of(jump("missing", ""))) + .build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A"); + ExecutionResult result = + new WorkflowExecutor( + Workflow.builder().name("invalid").steps(List.of(step)).build(), + autoCp(), + stub, + recordingCallback(), + "", + "zh") + .run() + .join(); + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("missing step")); + assertEquals(0, stub.getSentCount()); + } + + @Test + void cyclicWorkflowFailsBeforeExecution() { + WorkflowStep first = + WorkflowStep.builder() + .name("first") + .subtasks(List.of(task("A", "run"))) + .next(List.of(jump("second", ""))) + .build(); + WorkflowStep second = + WorkflowStep.builder() + .name("second") + .subtasks(List.of(task("B", "run"))) + .next(List.of(jump("first", ""))) + .build(); + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B"); + ExecutionResult result = + new WorkflowExecutor( + Workflow.builder() + .name("cycle") + .steps(List.of(first, second)) + .build(), + autoCp(), + stub, + recordingCallback(), + "", + "zh") + .run() + .join(); + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("cycle")); + assertEquals(0, stub.getSentCount()); + } + + @Test + void mergeDoesNotWaitForUnselectedConditionalBranch() { + WorkflowStep start = + WorkflowStep.builder() + .name("start") + .layer(0) + .subtasks(List.of(task("A", "start"))) + .next(List.of(jump("left", "left"), jump("right", "right"))) + .build(); + WorkflowStep left = + WorkflowStep.builder() + .name("left") + .layer(1) + .subtasks(List.of(task("B", "left"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep right = + WorkflowStep.builder() + .name("right") + .layer(1) + .subtasks(List.of(task("C", "right"))) + .next(List.of(jump("merge", ""))) + .build(); + WorkflowStep merge = + WorkflowStep.builder() + .name("merge") + .layer(2) + .subtasks(List.of(task("M", "merge"))) + .build(); + ControlPoint chooseRight = + new ControlPoint() { + @Override + public CompletableFuture onTask( + TaskRequest request, WorkflowEngineClient engineClient) { + return engineClient.sendMessage(request.getAgentName(), request.getMessage()) + .thenApply( + response -> + TaskResponse.builder() + .success(true) + .output(response.getText()) + .build()); + } + + @Override + public CompletableFuture onRoute( + String stepName, + Map results, + List conditions) { + return CompletableFuture.completedFuture( + RouteDecision.builder().nextStep("right").reason("test").build()); + } + }; + StubWorkflowEngineClient stub = new StubWorkflowEngineClient("A", "B", "C", "M"); + ExecutionResult result = + new WorkflowExecutor( + Workflow.builder() + .name("conditional-merge") + .steps(List.of(start, left, right, merge)) + .build(), + chooseRight, + stub, + recordingCallback(), + "", + "zh") + .run() + .join(); + assertTrue(result.isSuccess()); + assertEquals(List.of("A", "C", "M"), + stub.getSentMessages().stream().map(message -> message.agentName).toList()); + } + @Test void selfLoopStepCallsOnSelfTaskNotOnTask() { // A SELF_LOOP step must dispatch via onSelfTask (local callback), From a6cdc1024fcd35e2fa494ff0836e2ec8fb5e6917 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 19 Aug 2026 11:04:26 +0800 Subject: [PATCH 2/3] feat(starter): expose A2A server execution settings Signed-off-by: Zhoujie628 --- spring-boot-starter/pom.xml | 17 ++++ .../engine/spring/A2AAutoConfiguration.java | 29 ++++--- .../workflow/engine/spring/A2AController.java | 11 +-- .../workflow/engine/spring/A2AProperties.java | 85 +++++++++++++++++++ .../spring/A2AAutoConfigurationTest.java | 61 +++++++++++++ 5 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java diff --git a/spring-boot-starter/pom.xml b/spring-boot-starter/pom.xml index bceed148..ec97c6be 100644 --- a/spring-boot-starter/pom.xml +++ b/spring-boot-starter/pom.xml @@ -56,6 +56,19 @@ spring-boot-autoconfigure ${spring-boot.version} + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + true + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + @@ -67,6 +80,10 @@ ${project.build.sourceEncoding} + + org.apache.maven.plugins + maven-surefire-plugin + org.apache.maven.plugins maven-source-plugin diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java index b4d3da29..e186024b 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AAutoConfiguration.java @@ -98,13 +98,17 @@ public AgentCard agentCard(A2AProperties props, ResourceLoader resourceLoader) @Bean @ConditionalOnMissingBean - public A2AConfigProvider a2aConfigProvider() { + public A2AConfigProvider a2aConfigProvider(A2AProperties properties) { return new A2AConfigProvider() { @Override public String getValue(String key) { return switch (key) { - case "a2a.blocking.agent.timeout.seconds" -> "30"; - case "a2a.blocking.consumption.timeout.seconds" -> "5"; + case "a2a.blocking.agent.timeout.seconds" -> + Integer.toString(properties.getAgentTimeoutSeconds()); + case "a2a.blocking.consumption.timeout.seconds" -> + Integer.toString(properties.getConsumptionTimeoutSeconds()); + case "a2a.blocking.reconciliation.timeout.seconds" -> + Integer.toString(properties.getReconciliationTimeoutSeconds()); default -> null; }; } @@ -143,18 +147,23 @@ public PushNotificationConfigStore pushStore() { @Bean(destroyMethod = "shutdownNow") @ConditionalOnMissingBean - public ExecutorService agentExecutorPool() { + public ExecutorService agentExecutorPool(A2AProperties properties) { + if (properties.getExecutorMaxSize() < properties.getExecutorCoreSize()) { + throw new IllegalArgumentException( + "a2at.server.executor-max-size must be >= executor-core-size"); + } return new ThreadPoolExecutor( - 8, - 8, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>(), + properties.getExecutorCoreSize(), + properties.getExecutorMaxSize(), + properties.getExecutorKeepAliveSeconds(), + TimeUnit.SECONDS, + new LinkedBlockingQueue<>(properties.getExecutorQueueCapacity()), r -> { Thread t = new Thread(r, "a2a-agent-executor"); t.setDaemon(true); return t; - }); + }, + new ThreadPoolExecutor.CallerRunsPolicy()); } @Bean diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java index 76da25cd..2e408adf 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AController.java @@ -121,13 +121,10 @@ public void onSubscribe(Flow.Subscription s) { public void onNext(StreamingEventKind item) { try { StreamResponse sr = ProtoUtils.ToProto.streamResponse(item); - String json = JsonFormat.printer().print(sr); - String compact = - json.replace("\r\n", "\n") - .replace('\r', '\n') - .lines() - .map(String::trim) - .reduce("", String::concat); + String compact = + JsonFormat.printer() + .omittingInsignificantWhitespace() + .print(sr); String sse = String.format(Locale.ROOT, "id:%d%n", seq.incrementAndGet()) + "data:" diff --git a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java index 91c9cd2d..71281a3b 100644 --- a/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java +++ b/spring-boot-starter/src/main/java/dev/openan/workflow/engine/spring/A2AProperties.java @@ -36,6 +36,27 @@ public class A2AProperties { /** URL path prefix for A2A endpoints (extracted from AgentCard by default). */ private String pathPrefix = "/a2a/json"; + /** Timeout for blocking agent execution. */ + private int agentTimeoutSeconds = 30; + + /** Timeout for consuming and persisting events in blocking calls. */ + private int consumptionTimeoutSeconds = 5; + + /** Timeout for TaskStore reconciliation polling in blocking calls. */ + private int reconciliationTimeoutSeconds = 1; + + /** Core server executor thread count. */ + private int executorCoreSize = 8; + + /** Maximum server executor thread count. */ + private int executorMaxSize = 8; + + /** Maximum number of queued server tasks. */ + private int executorQueueCapacity = 100; + + /** Idle timeout for server executor threads above the core size. */ + private int executorKeepAliveSeconds = 60; + public String getAgentCard() { return agentCard; } @@ -51,4 +72,68 @@ public String getPathPrefix() { public void setPathPrefix(String pathPrefix) { this.pathPrefix = pathPrefix; } + + public int getAgentTimeoutSeconds() { + return agentTimeoutSeconds; + } + + public void setAgentTimeoutSeconds(int agentTimeoutSeconds) { + this.agentTimeoutSeconds = positive(agentTimeoutSeconds, "agentTimeoutSeconds"); + } + + public int getConsumptionTimeoutSeconds() { + return consumptionTimeoutSeconds; + } + + public void setConsumptionTimeoutSeconds(int consumptionTimeoutSeconds) { + this.consumptionTimeoutSeconds = + positive(consumptionTimeoutSeconds, "consumptionTimeoutSeconds"); + } + + public int getReconciliationTimeoutSeconds() { + return reconciliationTimeoutSeconds; + } + + public void setReconciliationTimeoutSeconds(int reconciliationTimeoutSeconds) { + this.reconciliationTimeoutSeconds = + positive(reconciliationTimeoutSeconds, "reconciliationTimeoutSeconds"); + } + + public int getExecutorCoreSize() { + return executorCoreSize; + } + + public void setExecutorCoreSize(int executorCoreSize) { + this.executorCoreSize = positive(executorCoreSize, "executorCoreSize"); + } + + public int getExecutorMaxSize() { + return executorMaxSize; + } + + public void setExecutorMaxSize(int executorMaxSize) { + this.executorMaxSize = positive(executorMaxSize, "executorMaxSize"); + } + + public int getExecutorQueueCapacity() { + return executorQueueCapacity; + } + + public void setExecutorQueueCapacity(int executorQueueCapacity) { + this.executorQueueCapacity = positive(executorQueueCapacity, "executorQueueCapacity"); + } + + public int getExecutorKeepAliveSeconds() { + return executorKeepAliveSeconds; + } + + public void setExecutorKeepAliveSeconds(int executorKeepAliveSeconds) { + this.executorKeepAliveSeconds = + positive(executorKeepAliveSeconds, "executorKeepAliveSeconds"); + } + + private static int positive(int value, String name) { + if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); + return value; + } } diff --git a/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java new file mode 100644 index 00000000..1efba5e2 --- /dev/null +++ b/spring-boot-starter/src/test/java/dev/openan/workflow/engine/spring/A2AAutoConfigurationTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Huawei Technologies Co., Ltd. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.openan.workflow.engine.spring; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.a2aproject.sdk.server.config.A2AConfigProvider; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; + +class A2AAutoConfigurationTest { + + @Test + void blockingTimeoutsComeFromProperties() { + A2AProperties properties = new A2AProperties(); + properties.setAgentTimeoutSeconds(90); + properties.setConsumptionTimeoutSeconds(12); + properties.setReconciliationTimeoutSeconds(3); + + A2AConfigProvider provider = + new A2AAutoConfiguration().a2aConfigProvider(properties); + + assertEquals("90", provider.getValue("a2a.blocking.agent.timeout.seconds")); + assertEquals("12", provider.getValue("a2a.blocking.consumption.timeout.seconds")); + assertEquals("3", provider.getValue("a2a.blocking.reconciliation.timeout.seconds")); + } + + @Test + void executorUsesConfiguredBounds() { + A2AProperties properties = new A2AProperties(); + properties.setExecutorCoreSize(2); + properties.setExecutorMaxSize(4); + properties.setExecutorQueueCapacity(7); + properties.setExecutorKeepAliveSeconds(15); + + ExecutorService executor = new A2AAutoConfiguration().agentExecutorPool(properties); + try { + ThreadPoolExecutor pool = (ThreadPoolExecutor) executor; + assertEquals(2, pool.getCorePoolSize()); + assertEquals(4, pool.getMaximumPoolSize()); + assertEquals(7, pool.getQueue().remainingCapacity()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void executorRejectsMaxBelowCore() { + A2AProperties properties = new A2AProperties(); + properties.setExecutorCoreSize(4); + properties.setExecutorMaxSize(2); + assertThrows( + IllegalArgumentException.class, + () -> new A2AAutoConfiguration().agentExecutorPool(properties)); + } +} From 1cf96653b1fc87a5e781ae7ef17c02e8a26868a0 Mon Sep 17 00:00:00 2001 From: Zhoujie628 Date: Wed, 19 Aug 2026 11:04:38 +0800 Subject: [PATCH 3/3] docs: sync engine security and integration guides Signed-off-by: Zhoujie628 --- .env.example | 26 ++++++++++++++ CHANGELOG.md | 32 +++++++++++++++-- README.md | 2 +- SECURITY.md | 5 +-- docs/en/API_REFERENCE.md | 66 +++++++++++++++++++++++------------- docs/en/DESIGN.md | 26 +++++++------- docs/en/INTEGRATION_GUIDE.md | 38 ++++++++++++++++----- docs/zh/API_REFERENCE.md | 60 +++++++++++++++++++++----------- docs/zh/DESIGN.md | 13 ++++--- docs/zh/INTEGRATION_GUIDE.md | 38 ++++++++++++++++----- 10 files changed, 219 insertions(+), 87 deletions(-) diff --git a/.env.example b/.env.example index eb9a19bf..717dc1bb 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,13 @@ # A2A-T SDK Configuration # Copy this file to .env and fill in your values +# Full A2A/Order protocol messages are emitted by the dedicated PROTOCOL DEBUG logger. +# Keep sensitive values hidden by default; set true only during controlled local debugging. +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false +# Set false to omit request/response bodies; large bodies are truncated at this character count. +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true +WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 + # Prompt runtime A2AT_LANGUAGE=zh-CN # classpath: prompt_resources are bundled inside the a2a-t-resources jar on the classpath @@ -47,3 +54,22 @@ A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory # Credential encryption key (32-byte hex, used for enc: prefixed passwords in credentials JSON) # Generate with: openssl rand -hex 32 A2AT_CRED_KEY=your-32-byte-hex-key-here + +# Workbench outbound transport: order (Eastcom SDK), mock, or direct +A2A_TRANSPORT_MODE=order +A2A_MOCK_GATEWAY_URL=http://127.0.0.1:26400 + +# Eastcom instruction platform. Configure either a default NE or per-agent NE routes. +# true starts the local RSocket-RPC protocol simulator; never enable it in production. +EASTCOM_ORDER_SIMULATOR_ENABLED=false +EASTCOM_ORDER_HOST=10.x.x.x +EASTCOM_ORDER_PORT=0 +EASTCOM_ORDER_USERNAME=your-username +EASTCOM_ORDER_PASSWORD=your-password +EASTCOM_ORDER_CLIENT_ID= +EASTCOM_ORDER_CLIENT_SECRET= +EASTCOM_ORDER_DEFAULT_NE= +EASTCOM_ORDER_CITY1_NE=city1-omc +EASTCOM_ORDER_CITY2_NE=city2-omc +EASTCOM_ORDER_LOGIN_TIMEOUT_SECONDS=15 +EASTCOM_ORDER_TIMEOUT_SECONDS=600 diff --git a/CHANGELOG.md b/CHANGELOG.md index a78cf6de..b5fc2c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Client TLS options for custom CA, mTLS client certificate/private key, encrypted PKCS#8 keys, and CRL checking +- Configurable bounded executors for client sends and Spring Boot server request handling +- Configurable Notification-T acknowledgement timeout and protocol-log body controls + +### Changed + +- TLS configuration is now per client; it no longer changes JVM-wide hostname-verification properties +- Credential login uses the same TLS policy as agent traffic and encrypted credentials now fail closed +- AgentCards that declare security requirements now fail closed when neither matching credentials nor an `AuthProvider` is configured +- `securitySchemes` no longer implies non-empty `securityRequirements`; an `AuthProvider` can be the sole authentication source +- Notification-T distinguishes acknowledgement timeout from real stream failure and keeps its lifecycle independent from + workflow request transports +- Workflow execution validates graph targets and cycles up front and waits only for activated predecessors at joins +- Protocol headers are redacted by default; sensitive values require an explicit diagnostic opt-in +- Agent clients and transport executors are reused and closed through their owning runtime + +### Fixed + +- Task success is derived from the A2A task state instead of non-empty response text alone +- Conflicting authentication headers from `AuthProvider` and credential configuration now fail fast +- Extension URI matching now uses exact URI path segments instead of substring matching +- Notification callback implementations can no longer silently discard the supplied callback +- Header construction is split into isolated auth, credential, and extension contributors with conflict detection + ## [1.0.0] - 2026-07-28 First public release. The SDK ships a clean transport-facade architecture with single-responsibility decision interfaces @@ -16,8 +42,8 @@ and full A2A-T extension support. Full A2A-T extension support and multi-protoco - `A2ATransport`: shared wire layer owning the A2A client runtime, auth manager, agent-card map, and streaming event extraction -- `ExtensionSender` / `DefaultExtensionSender`: one-shot pre-positioning facade for Authorization-T and Notification-T - (long-lived SSE subscription) +- `ExtensionSender` / `DefaultExtensionSender`: pre-positioning facade for one-shot Authorization-T and long-lived + Notification-T SSE subscriptions - `ControlPoint` / `ExtensionSender` split: flow decisions (`onTask` / `onSelfTask` / `onRoute` / `onNegotiation`) on `ControlPoint`; Authorization-T / Notification-T pre-positioning on `ExtensionSender` - `NegotiationStrategy`: pluggable clarification strategy injected into `DefaultControlPoint` @@ -37,7 +63,7 @@ and full A2A-T extension support. Full A2A-T extension support and multi-protoco generation, Negotiation-T auto-loop, event callback, ControlPoint/ExtensionCallback wiring) - Pre-positioning sends moved from `WorkflowEngineClient` to `ExtensionSender` - `ExtensionRegistry` auto-registers only Task-T and Negotiation-T (in-workflow handlers); Authorization-T / - Notification-T are one-shot pre-positioning operations + Notification-T are pre-positioning operations with request and subscription lifecycles respectively ## [0.3.0] - 2026-07-25 diff --git a/README.md b/README.md index 7c7dc528..93b195d9 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ List agentCards = registry.fetchAgentCards(); // 3. Create transport + engine client A2ATransport transport = new A2ATransport(agentCards, null, WorkflowEngineClientConfig.builder() - .sslVerify(false) + .sslVerify(true) .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") .build()); diff --git a/SECURITY.md b/SECURITY.md index 5f653206..70bdfdfb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,5 +38,6 @@ within 90 days. Valid reports will be credited in the release notes. See [Integration Guide](docs/en/INTEGRATION_GUIDE.md#53-custom-authentication-authprovider). - **TLS/HTTPS**: All agent communication supports HTTPS with configurable - certificate verification. Self-signed certificates are supported for - development via `sslVerify(false)`. \ No newline at end of file + certificate verification. Self-signed certificates should be trusted through + `caCertsPath`; `sslVerify(false)` is limited to controlled local diagnostics and + does not disable hostname verification. diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index f007b9dd..f85a370e 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -79,8 +79,8 @@ public interface WorkflowEngineClient { } ``` -> One-shot pre-positioning (Authorization-T / Notification-T) lives on -> `ExtensionSender`, not here. See the `ExtensionSender` section below. +> Pre-positioning lives on `ExtensionSender`, not here: Authorization-T is a one-shot request and Notification-T is a +> long-lived subscription. See the `ExtensionSender` section below. #### sendMessage @@ -108,10 +108,9 @@ After receiving: ### ExtensionSender -One-shot pre-positioning facade over the same `A2ATransport`. Sends Authorization-T / Notification-T (and any one-shot -extension) to agents before the workflow starts. Bypasses Task-T prompt generation and the Negotiation-T auto-loop, and -does not emit events through the global -`EventCallback` (the returned `CompletableFuture` is the callback). +Pre-positioning facade over the same `A2ATransport`. Sends one-shot Authorization-T requests, establishes long-lived +Notification-T subscriptions, and sends other one-shot extensions before a workflow starts. It bypasses Task-T prompt +generation and the Negotiation-T auto-loop; later Notification-T events are delivered through the subscription callback. ```java public interface ExtensionSender { @@ -127,7 +126,7 @@ public interface ExtensionSender { CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput); - // Convenience: Notification-T (long-lived SSE + event callback) + // Interface contract: Notification-T (long-lived SSE + event callback) CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, Consumer> eventCallback); @@ -138,11 +137,11 @@ public interface ExtensionSender { |------------------------|-----------------|-------------------------------------------------| | `agentName` | `String` | Target agent name (must match `AgentCard.name`) | | `instruction` | `String` | Short instruction text; becomes `parts[].text` in the A2A message body | -| `naturalLanguageInput` | `String` | Natural language input passed to the A2A-T SDK to generate a structured extension prompt. The generated value is placed in the message `metadata` under the extension URI key (e.g. `https://.../Authorization-T/v1`). Falls back to this text as-is when SDK generation is unavailable | +| `naturalLanguageInput` | `String` | Natural-language extension metadata. Task-T is structured by the A2A-T SDK; Authorization-T and Notification-T currently use this value directly | | `extension` | `A2ATExtension` | Extension enum (never hardcode URIs) | | `eventCallback` | `Consumer>` | Optional SSE event callback (`sendNotification` only). Recovery results pushed by the agent are received here in real time; the Map contains `agent`, `text`, `metadata`, `state`. Null drops subsequent events | -**Wire format**: The resulting A2A message sent to the agent has `parts[].text = instruction` and `metadata = { "": "" }`. For example, Authorization-T produces: +**Wire format**: The resulting A2A message sent to the agent has `parts[].text = instruction` and `metadata = { "": "" }`. For example, Authorization-T produces: ```json { @@ -157,21 +156,32 @@ public interface ExtensionSender { Builder-based configuration for the workflow engine client. -| Property | Type | Default | Description | -|-------------------------|--------------------------|---------|-------------------------------------| -| `sslVerify` | `boolean` | `true` | TLS certificate verification | -| `caCertsPath` | `String` | null | Path to CA certs PEM file | -| `sendTimeoutSeconds` | `long` | `600` | SSE stream timeout (10 min default) | -| `authProvider` | `AuthProvider` | null | Custom auth provider | -| `credentialsConfigPath` | `String` | null | Path to credentials JSON | -| `credentialsConfig` | `Map` | null | Inline credentials config | -| `a2atEnvPath` | `String` | null | Path to `.env` file | -| `maxNegotiationRounds` | `int` | `3` | Max negotiation auto-loop rounds | -| `customHandlers` | `List` | null | Custom extension handlers | +| Property | Type | Default | Description | +|---------------------------------|--------------------------|---------|----------------------------------------------| +| `sslVerify` | `boolean` | `true` | TLS chain verification for HTTP/JSON-RPC; disabling it keeps hostname checks and the mTLS client identity. Disabling it uses plaintext for default gRPC | +| `caCertsPath` | `String` | null | Path to CA certs PEM file | +| `clientCertPath` | `String` | null | Path to the mTLS client certificate chain; default gRPC requires `sslVerify=true` | +| `clientKeyPath` | `String` | null | Path to a PKCS#8 PEM/DER mTLS private key | +| `clientKeyPassword` | `String` | null | Password for an encrypted PKCS#8 private key | +| `crlPath` | `String` | null | X.509 CRL for HTTP/JSON-RPC; the default gRPC runtime rejects this unsupported combination | +| `sendTimeoutSeconds` | `long` | `600` | SSE stream timeout (10 min default) | +| `notificationAckTimeoutSeconds` | `long` | `5` | Wait for the first Notification-T ACK/event | +| `sendExecutorCoreSize` | `int` | `4` | Send executor core threads | +| `sendExecutorMaxSize` | `int` | `16` | Send executor maximum threads | +| `sendExecutorQueueCapacity` | `int` | `256` | Bounded send executor queue capacity | +| `authProvider` | `AuthProvider` | null | Custom auth provider | +| `credentialsConfigPath` | `String` | null | Path to credentials JSON; explicit missing or malformed files fail startup | +| `credentialsConfig` | `Map` | null | Inline credentials config; must match AgentCard security requirements | +| `a2atEnvPath` | `String` | null | Path to `.env` file | +| `maxNegotiationRounds` | `int` | `3` | Max negotiation auto-loop rounds | +| `customHandlers` | `List` | null | Custom extension handlers | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() - .sslVerify(false) + .sslVerify(true) + .caCertsPath("ca.pem") + .clientCertPath("client-cert.pem") + .clientKeyPath("client-key.pem") .sendTimeoutSeconds(900) .a2atEnvPath(".env") .credentialsConfigPath("creds.json") @@ -191,8 +201,7 @@ public interface AuthProvider { } ``` -Called for every message send. The `headers` map is mutable; add -`Authorization`, custom headers, etc. Runs before credentials-based auth. +Called for every message send. The `headers` map is mutable; add `Authorization`, custom headers, etc. `AuthProvider` can be the sole authentication source, including when `securityRequirements` is non-empty and credentials are not configured. When credentials are also configured, both sets are computed independently and merged; different values for the same header name throw `SecurityException` instead of silently overwriting either value. ### ExtensionHandler @@ -610,12 +619,23 @@ Configuration properties prefixed with `a2at.server`: |----------|---------|-------------| | `a2at.server.agent-card` | `classpath:agentcard.json` | Path to the AgentCard JSON file (classpath: or file: prefix supported) | | `a2at.server.path-prefix` | `/a2a/json` | URL path prefix for A2A endpoints | +| `a2at.server.agent-timeout-seconds` | `30` | Agent execution timeout in seconds | +| `a2at.server.consumption-timeout-seconds` | `5` | Consumption timeout in seconds | +| `a2at.server.reconciliation-timeout-seconds` | `1` | Reconciliation wait timeout in seconds | +| `a2at.server.executor-core-size` | `8` | Server executor core threads | +| `a2at.server.executor-max-size` | `8` | Server executor maximum threads | +| `a2at.server.executor-queue-capacity` | `100` | Bounded server executor queue capacity | +| `a2at.server.executor-keep-alive-seconds` | `60` | Non-core thread keep-alive in seconds | ```yaml a2at: server: agent-card: classpath:agentcard/my_agent.json path-prefix: /a2a/json + agent-timeout-seconds: 30 + executor-core-size: 8 + executor-max-size: 16 + executor-queue-capacity: 200 ``` ### A2AAutoConfiguration diff --git a/docs/en/DESIGN.md b/docs/en/DESIGN.md index 9f606f2c..b77a28ec 100644 --- a/docs/en/DESIGN.md +++ b/docs/en/DESIGN.md @@ -59,13 +59,13 @@ state, and metadata. - **`WorkflowEngineClient`** - the workflow execution send path. Owns Task-T prompt generation (before send), the Negotiation-T auto-loop (after receive), the global `EventCallback`, and the `ControlPoint` wiring. This is the facade the executor calls during workflow execution. -- **`ExtensionSender`** - one-shot pre-positioning. Sends Authorization-T and Notification-T messages to agents *before* - the workflow starts. It bypasses Task-T generation and the negotiation loop and does not emit events through the - global callback - the returned result *is* the callback. +- **`ExtensionSender`** - pre-positioning facade. Sends one-shot Authorization-T requests or establishes long-lived + Notification-T subscriptions *before* the workflow starts. It bypasses Task-T generation and the negotiation loop; + Notification-T events use the subscription callback rather than the workflow-global callback. #### Why a shared transport with two facades? -Both the workflow send path and the one-shot pre-positioning path need the same wire-level machinery: an HTTP client, +Both the workflow send path and the pre-positioning path need the same wire-level machinery: an HTTP client, TLS configuration, auth interceptors, agent-card resolution, and SSE parsing. Putting that machinery on either facade would either (a) force a caller that only wants to pre-position to hold the full workflow facade, or (b) duplicate the wire code across two classes. The shared-transport / two-facade design avoids both: @@ -133,10 +133,9 @@ both): ### 4.2 Pre-positioning extensions -One-shot sends that happen before the workflow starts, via `ExtensionSender`: +Established through `ExtensionSender` before a workflow and decoupled from any single workflow transport: -- **Authorization-T** - Sends an authorization pre-positioning request. The prompt value is generated by the A2A-T SDK; - the engine falls back to the raw natural-language input when SDK generation is unavailable. +- **Authorization-T** - Sends a one-shot authorization request. The current implementation uses the caller-supplied natural-language input directly as the metadata value. - **Notification-T** - Establishes a result subscription. Opens a long-lived SSE stream so later recovery results flow back through the response stream. @@ -145,9 +144,8 @@ stream, not through `onNotification`. That hook only fires when an agent voluntarily includes a Notification-T payload in a `sendMessage` task response. -Prompt generation methods for Authorization-T and Notification-T are reserved on `ExtensionSender`; Task-T uses the -SDK's `generateTaskPrompt`. Negotiation-T and the reserved generators will be wired to the A2A-T SDK as that support -lands upstream. +Task-T uses the SDK's `generateTaskPrompt`. Authorization-T and Notification-T currently use caller-supplied metadata +values because the upstream SDK does not expose corresponding prompt generators. ### 4.3 Extension handler chain @@ -176,8 +174,8 @@ A step's `next` list holds `JumpCondition(step, condition)` entries. The routing - **No `next`** - terminal; the step completes the branch. - **All conditions empty** - unconditional fan-out: dispatch every non-terminal next step in parallel. - **Has conditions** - conditional: call `ControlPoint.onRoute`, which returns a single `RouteDecision.nextStep`. The - engine enforces that the returned step is among the declared conditions; an invalid step ends the workflow with a - warning. + engine enforces that the returned step is among the declared conditions; an invalid return fails the workflow with an + error. This makes conditional branches an N-choose-1 selection and keeps unconditional fan-out as automatic parallel dispatch. @@ -280,8 +278,8 @@ The SDK is standalone: it does not depend on the orchestration center. work. Avoids both forced-facade coupling and wire-code duplication. 2. **In-workflow vs pre-positioning extensions** - Task-T and Negotiation-T are part of the `sendMessage` chain; - Authorization-T and Notification-T are one-shot sends before the workflow. The registry auto-registers only the - in-workflow pair. + Authorization-T is a one-shot request sent before the workflow, while Notification-T is a long-lived subscription + established before the workflow. The registry auto-registers only the in-workflow pair. 3. **Auto-negotiation loop** - the engine owns the resend loop so hosts only supply clarification text (`onNegotiation`), never the protocol mechanics of resending. diff --git a/docs/en/INTEGRATION_GUIDE.md b/docs/en/INTEGRATION_GUIDE.md index 9b7153b1..820efd6b 100644 --- a/docs/en/INTEGRATION_GUIDE.md +++ b/docs/en/INTEGRATION_GUIDE.md @@ -141,7 +141,7 @@ public class MyControlPoint extends DefaultControlPoint { `onNegotiation` defaults to a generic clarification. Override only what you need. -**Pre-positioning (Authorization-T / Notification-T)**: These are one-shot operations sent via `ExtensionSender` before the workflow starts. The send result is returned directly as a `SendMessageResult` -- no separate callback interface is needed. +**Pre-positioning (Authorization-T / Notification-T)**: Both are established through `ExtensionSender` before a workflow starts, but they have different lifecycles. Authorization-T is a one-shot request that ends after its response. Notification-T is a long-lived subscription on a transport independent of any single workflow; its first event or acknowledgement is returned as `SendMessageResult`, and later events are delivered to the callback until that subscription transport is explicitly closed. **Self-loop steps (SelfLoop)**: When a step is the workflow-executing agent's own task (e.g. merging multiple agents' diagnostic results), set `stepType` to `SELF_LOOP`. The engine calls `onSelfTask` locally instead of sending an A2A-T @@ -159,7 +159,7 @@ ExecutionResult result = ExecutePsop.builder() .lang("zh") .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") - .sslVerify(false) + .sslVerify(true) .onFinish((r, history) -> { System.out.println("Result: " + r.isSuccess()); }) @@ -267,7 +267,7 @@ Paste the output into the `value` field of the credentials JSON. > The `.env` file should not be committed to version control. Add it to `.gitignore`. ### 5.3 Custom Authentication (AuthProvider) -When the AgentCard has no `securitySchemes`, or uses a non-standard auth mechanism, implement the `AuthProvider` interface. It has a single method: +When tokens are obtained by the workbench or an external identity service, or the mechanism is non-standard, implement the `AuthProvider` interface. It has a single method: ```java public interface AuthProvider { @@ -297,7 +297,7 @@ public class SsoAuthProvider implements AuthProvider { // Register WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .authProvider(new SsoAuthProvider(mySsoClient)) - .sslVerify(false) + .sslVerify(true) .a2atEnvPath(".env") .build(); ``` @@ -327,7 +327,9 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() **Notes:** - `applyAuth` is called on every message send; implement token caching/refresh logic inside -- If both a credentials file and `AuthProvider` are configured, both run: `AuthProvider` first, credentials-based auth second +- `securitySchemes` lists authentication methods the agent supports; `securityRequirements` marks the methods required by this integration. Empty `securityRequirements` disables built-in credential authentication, but `AuthProvider` is still called +- `AuthProvider` can be the sole authentication source even when `securityRequirements` is non-empty +- If both credentials and `AuthProvider` are configured, their headers are generated independently and merged; different values for the same header fail fast - On auth failure (e.g. token retrieval throws), the exception propagates to `send()` and the request is blocked ## 6. AgentCard Definition @@ -454,18 +456,28 @@ notification channel. ## 8. HTTPS Configuration ```java -// Dev: self-signed certs, skip verification +// Controlled local diagnostics only: skip chain validation, but still verify the host name .sslVerify(false) // Production: enable verification + custom CA certs .sslVerify(true) .caCertsPath("/path/to/ca-certs.pem") + +// Optional mTLS and CRL. Private keys support PKCS#8 PEM/DER; encrypted keys require a password +.clientCertPath("/path/to/client-cert.pem") +.clientKeyPath("/path/to/client-key.pem") +.clientKeyPassword("change-me") +.crlPath("/path/to/revocations.crl") ``` +For HTTP/JSON-RPC, TLS policy is scoped to the current client and never changes JVM-wide hostname verification; +disabling chain verification still loads the mTLS client identity. Keep `sslVerify(true)` in production and trust +self-signed certificates through `caCertsPath`. The default gRPC runtime uses plaintext when `sslVerify(false)` is set, +so mTLS and `crlPath` cannot be combined with that mode and fail fast instead of being ignored. + ## 9. Logging -The engine has a dedicated `PROTOCOL` logger that outputs full protocol-level request/response messages (headers + -body). Configure in `log4j2.properties`: +The dedicated `PROTOCOL` logger emits protocol requests and responses. Bodies are enabled by default and truncated to a configurable size. Sensitive headers such as Authorization, cookies, API keys, tokens, and secrets are redacted by default. Configure the logger in `log4j2.properties`: ```properties logger.PROTOCOL.name=PROTOCOL @@ -474,7 +486,15 @@ logger.PROTOCOL.additivity=false logger.PROTOCOL.appenderRef=console ``` -Set to `debug` to see full message bodies. +Control the content with environment variables or same-named JVM system properties: + +```properties +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true +WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false +``` + +Enable sensitive headers only for isolated, controlled local diagnostics; the engine emits a security warning when this opt-in is active. ## 10. Event Callback diff --git a/docs/zh/API_REFERENCE.md b/docs/zh/API_REFERENCE.md index a724d5a7..e80f19bf 100644 --- a/docs/zh/API_REFERENCE.md +++ b/docs/zh/API_REFERENCE.md @@ -80,7 +80,7 @@ public interface WorkflowEngineClient { } ``` -> 一次性预置(Authorization-T / Notification-T)位于 `ExtensionSender`,不在本接口。见下方 `ExtensionSender` 章节。 +> 前置操作位于 `ExtensionSender`,不在本接口:Authorization-T 是一次性请求,Notification-T 是长连接订阅。见下方 `ExtensionSender` 章节。 #### sendMessage @@ -108,8 +108,8 @@ public interface WorkflowEngineClient { ### ExtensionSender -基于同一 `A2ATransport` 的一次性预置门面。在工作流开始前向 Agent 发送 Authorization-T / Notification-T(及任何一次性扩展)。跳过 -Task-T 提示词生成和 Negotiation-T 自动循环,也不通过全局 `EventCallback` 发送事件(返回的 `CompletableFuture` 即为回调)。 +基于同一 `A2ATransport` 的前置操作门面。在工作流开始前发送一次性 Authorization-T 请求、建立长连接 Notification-T 订阅,或发送其他一次性扩展。跳过 +Task-T 提示词生成和 Negotiation-T 自动循环;Notification-T 后续事件通过订阅回调交付。 ```java public interface ExtensionSender { @@ -125,7 +125,7 @@ public interface ExtensionSender { CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput); - // 便捷方法:Notification-T(长连接 SSE + 事件回调) + // 接口契约:Notification-T(长连接 SSE + 事件回调) CompletableFuture sendNotification( String agentName, String instruction, String naturalLanguageInput, Consumer> eventCallback); @@ -136,11 +136,11 @@ public interface ExtensionSender { |------------------------|-----------------|-------------------------------------| | `agentName` | `String` | 目标智能体名称(须匹配 `AgentCard.name`) | | `instruction` | `String` | 简短指令文本;成为 A2A 消息体的 `parts[].text` | -| `naturalLanguageInput` | `String` | 自然语言输入,传给 A2A-T SDK 生成结构化扩展提示词。生成的值放入消息 `metadata` 中对应扩展 URI 键下(如 `https://.../Authorization-T/v1`)。SDK 不可用时回退为此文本 | +| `naturalLanguageInput` | `String` | 扩展 metadata 的自然语言输入。Task-T 由 A2A-T SDK 生成结构化提示词;Authorization-T / Notification-T 当前直接使用此值 | | `extension` | `A2ATExtension` | 扩展枚举(勿硬编码 URI) | | `eventCallback` | `Consumer>` | 可选 SSE 事件回调(`sendNotification` 专用)。OMC 后续推送的抢通结果通过此回调实时接收,回调 Map 含 `agent`、`text`、`metadata`、`state`。不传(null)时后续事件被丢弃 | -**报文格式**:发送给智能体的 A2A 消息结构为 `parts[].text = instruction`,`metadata = { "<扩展URI>": "" }`。以 Authorization-T 为例: +**报文格式**:发送给智能体的 A2A 消息结构为 `parts[].text = instruction`,`metadata = { "<扩展URI>": "<扩展值>" }`。以 Authorization-T 为例: ```json { @@ -155,21 +155,32 @@ public interface ExtensionSender { 工作流引擎客户端的 Builder 配置。 -| 属性 | 类型 | 默认值 | 说明 | -|-------------------------|--------------------------|--------|----------------------------| -| `sslVerify` | `boolean` | `true` | TLS 证书验证 | -| `caCertsPath` | `String` | null | CA 证书 PEM 文件路径 | -| `sendTimeoutSeconds` | `long` | `600` | SSE 流超时(默认 10 分钟) | -| `authProvider` | `AuthProvider` | null | 自定义认证提供器 | -| `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径 | -| `credentialsConfig` | `Map` | null | 内联凭证配置 | -| `a2atEnvPath` | `String` | null | `.env` 文件路径 | -| `maxNegotiationRounds` | `int` | `3` | 协商自动循环最大轮数 | -| `customHandlers` | `List` | null | 自定义扩展处理器 | +| 属性 | 类型 | 默认值 | 说明 | +|---------------------------------|--------------------------|--------|-------------------------------------------| +| `sslVerify` | `boolean` | `true` | HTTP/JSON-RPC 的 TLS 证书链验证;关闭时仍校验主机名并保留 mTLS 客户端身份。默认 gRPC 关闭时使用 plaintext | +| `caCertsPath` | `String` | null | CA 证书 PEM 文件路径 | +| `clientCertPath` | `String` | null | mTLS 客户端证书链路径;默认 gRPC 需配合 `sslVerify=true` | +| `clientKeyPath` | `String` | null | mTLS PKCS#8 PEM/DER 私钥路径 | +| `clientKeyPassword` | `String` | null | 加密 PKCS#8 私钥密码 | +| `crlPath` | `String` | null | HTTP/JSON-RPC 的 X.509 CRL 路径;默认 gRPC runtime 暂不支持并会拒绝启动 | +| `sendTimeoutSeconds` | `long` | `600` | SSE 流超时(默认 10 分钟) | +| `notificationAckTimeoutSeconds` | `long` | `5` | Notification-T 首个 ACK/事件等待时间 | +| `sendExecutorCoreSize` | `int` | `4` | 发送线程池核心线程数 | +| `sendExecutorMaxSize` | `int` | `16` | 发送线程池最大线程数 | +| `sendExecutorQueueCapacity` | `int` | `256` | 发送线程池有界队列容量 | +| `authProvider` | `AuthProvider` | null | 自定义认证提供器 | +| `credentialsConfigPath` | `String` | null | 凭证 JSON 文件路径;显式配置缺失或损坏时启动失败 | +| `credentialsConfig` | `Map` | null | 内联凭证配置;AgentCard 声明安全要求时必须匹配 | +| `a2atEnvPath` | `String` | null | `.env` 文件路径 | +| `maxNegotiationRounds` | `int` | `3` | 协商自动循环最大轮数 | +| `customHandlers` | `List` | null | 自定义扩展处理器 | ```java WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() - .sslVerify(false) + .sslVerify(true) + .caCertsPath("ca.pem") + .clientCertPath("client-cert.pem") + .clientKeyPath("client-key.pem") .sendTimeoutSeconds(900) .a2atEnvPath(".env") .credentialsConfigPath("creds.json") @@ -189,7 +200,7 @@ public interface AuthProvider { } ``` -每次消息发送时调用。`headers` 是可变 Map;直接添加 `Authorization`、自定义头等。先于基于凭证的认证执行。 +每次消息发送时调用。`headers` 是可变 Map;直接添加 `Authorization`、自定义头等。`AuthProvider` 可作为唯一认证来源,包括 AgentCard 的 `securityRequirements` 非空但未配置 credentials 的场景。若同时配置 credentials,两者分别计算后合并;若同名 Header 生成不同值,引擎会抛出 `SecurityException`,不会静默覆盖。 ### ExtensionHandler @@ -605,12 +616,23 @@ ExecutionResult result = executor.run().join(); |-------------------------|-------------------------------|-----------------------------------------------| | `a2at.server.agent-card` | `classpath:agentcard.json` | AgentCard JSON 文件路径(支持 classpath: 或 file: 前缀) | | `a2at.server.path-prefix` | `/a2a/json` | A2A 端点的 URL 路径前缀 | +| `a2at.server.agent-timeout-seconds` | `30` | Agent 执行超时(秒) | +| `a2at.server.consumption-timeout-seconds` | `5` | 消费超时(秒) | +| `a2at.server.reconciliation-timeout-seconds` | `1` | 协调等待超时(秒) | +| `a2at.server.executor-core-size` | `8` | 服务端执行器核心线程数 | +| `a2at.server.executor-max-size` | `8` | 服务端执行器最大线程数 | +| `a2at.server.executor-queue-capacity` | `100` | 服务端执行器有界队列容量 | +| `a2at.server.executor-keep-alive-seconds` | `60` | 非核心线程存活时间(秒) | ```yaml a2at: server: agent-card: classpath:agentcard/my_agent.json path-prefix: /a2a/json + agent-timeout-seconds: 30 + executor-core-size: 8 + executor-max-size: 16 + executor-queue-capacity: 200 ``` ### A2AAutoConfiguration diff --git a/docs/zh/DESIGN.md b/docs/zh/DESIGN.md index 052bd231..66dc9379 100644 --- a/docs/zh/DESIGN.md +++ b/docs/zh/DESIGN.md @@ -52,12 +52,12 @@ graph TD - **`WorkflowEngineClient`** — 工作流执行发送路径。拥有 Task-T 提示词生成(发送前)、 Negotiation-T 自动循环(接收后)、全局 `EventCallback`、`ControlPoint` 装配。 这是执行器在工作流执行期间调用的门面。 -- **`ExtensionSender`** — 一次性前置下发。在工作流启动前向智能体发送 Authorization-T 和 Notification-T 消息。 +- **`ExtensionSender`** — 前置下发门面。在工作流启动前发送一次性 Authorization-T 请求或建立长连接 Notification-T 订阅。 绕过 Task-T 生成和协商循环,不通过全局回调发射事件 — 返回的结果就是回调。 #### 为什么共享 transport + 两个门面? -工作流发送路径和一次性前置下发路径都需要相同的通信层机制:HTTP 客户端、TLS 配置、 +工作流发送路径和前置下发路径都需要相同的通信层机制:HTTP 客户端、TLS 配置、 认证拦截器、智能体卡片解析、SSE 解析。把这些机制放在任一门面上要么 (a) 强制只想做前置下发的 调用方持有完整工作流门面,要么 (b) 在两个类中重复通信代码。共享 transport + 两个门面的设计 避免了这两个问题。 @@ -117,10 +117,9 @@ Authorization-T 和 Notification-T 是预置操作,通过 `ExtensionSender` ### 4.2 前置下发扩展 -一次性发送,在工作流启动前通过 `ExtensionSender` 完成: +在工作流启动前通过 `ExtensionSender` 建立,并与单次工作流 transport 解耦: -- **Authorization-T** — 发送授权前置请求。提示词值由 A2A-T SDK 生成; - SDK 不可用时回退到原始自然语言输入。 +- **Authorization-T** — 发送一次性授权前置请求。当前直接使用调用方提供的自然语言输入作为 metadata 值。 - **Notification-T** — 建立结果订阅。打开长连接 SSE 流,后续抢通结果通过该流返回。 订阅结果(如后续推送的抢通结果)通过 `sendNotification` 响应流返回, @@ -153,7 +152,7 @@ graph TD - **无 `next`** — 终端步骤,完成该分支。 - **所有条件为空** — 无条件扇出:并行下发每个非终端下一步。 - **有条件** — 条件路由:调用 `ControlPoint.onRoute`,返回单个 `RouteDecision.nextStep`。 - 引擎强制要求返回的步骤在声明的条件中;无效步骤以警告结束工作流。 + 引擎强制要求返回的步骤在声明的条件中;无效返回值会使工作流失败并报告错误。 这使得条件分支是 N 选 1 选择,无条件扇出是自动并行下发。 @@ -253,7 +252,7 @@ SDK 是独立的:不依赖编排中心。 避免强制门面耦合和通信代码重复。 2. **工作流内扩展 vs 前置下发扩展** — Task-T 和 Negotiation-T 是 `sendMessage` 链的一部分; - Authorization-T 和 Notification-T 是工作流启动前的一次性发送。 + Authorization-T 是工作流启动前的一次性请求,Notification-T 是工作流启动前建立的长连接订阅。 注册表只自动注册工作流内的一对。 3. **自动协商循环** — 引擎拥有重发循环,宿主只提供澄清文本(`onNegotiation`), diff --git a/docs/zh/INTEGRATION_GUIDE.md b/docs/zh/INTEGRATION_GUIDE.md index 1b6d7042..6f1c6700 100644 --- a/docs/zh/INTEGRATION_GUIDE.md +++ b/docs/zh/INTEGRATION_GUIDE.md @@ -138,7 +138,7 @@ public class MyControlPoint extends DefaultControlPoint { `onNegotiation` 默认返回通用文本。只需覆盖你关心的方法。 -**前置操作(Authorization-T / Notification-T)**:这两个扩展是工作流开始前通过 `ExtensionSender` 发送的一次性操作。发送结果直接通过返回的 `SendMessageResult` 获取,无需额外的回调接口。 +**前置操作(Authorization-T / Notification-T)**:两者都在工作流开始前通过 `ExtensionSender` 建立,但生命周期不同。Authorization-T 是响应完成即结束的一次性请求;Notification-T 是独立于单次工作流 transport 的长连接订阅,首个事件或 ACK 通过返回的 `SendMessageResult` 获取,后续事件通过回调持续接收,直到显式关闭订阅 transport。 **自环节点(SelfLoop)**:当一个步骤是工作流执行智能体自身的任务(例如汇总多个智能体的诊断结果),把 `stepType` 设为 `SELF_LOOP`。引擎会调用 `onSelfTask` 本地处理,而不是通过 A2A-T 协议给智能体自己发消息。`onSelfTask` 不接收 `engineClient` @@ -155,7 +155,7 @@ ExecutionResult result = ExecutePsop.builder() .lang("zh") .a2atEnvPath(".env") .credentialsConfigPath("credentials.json") - .sslVerify(false) + .sslVerify(true) .onFinish((r, history) -> { System.out.println("执行结果: " + r.isSuccess()); }) @@ -263,7 +263,7 @@ enc:uHQcTeKZMVNRM9Ga:o5vm4weRozBXBs04phrLq7j7+/yRVyDsrw== > `.env` 文件不应提交到版本库,建议加入 `.gitignore`。 ### 5.3 自定义认证(AuthProvider) -当 AgentCard 没有声明 `securitySchemes`,或使用非标准认证方式时,实现 `AuthProvider` 接口。接口只有一个方法: +当令牌由工作台或外部认证服务获取,或使用非标准认证方式时,实现 `AuthProvider` 接口。接口只有一个方法: ```java public interface AuthProvider { @@ -293,7 +293,7 @@ public class SsoAuthProvider implements AuthProvider { // 注册 WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() .authProvider(new SsoAuthProvider(mySsoClient)) - .sslVerify(false) + .sslVerify(true) .a2atEnvPath(".env") .build(); ``` @@ -323,7 +323,9 @@ WorkflowEngineClientConfig config = WorkflowEngineClientConfig.builder() **注意事项:** - `applyAuth` 每次发消息都会调用,内部可自行实现 token 缓存和刷新逻辑 -- 如果同时配了凭证文件和 `AuthProvider`,两者都生效:`AuthProvider` 先执行,凭证文件的认证后执行 +- `securitySchemes` 表示智能体支持的认证方式;`securityRequirements` 表示当前对接强制要求的认证方式。`securityRequirements` 为空时不启用内置凭证认证,但 `AuthProvider` 仍会被调用 +- 只配置 `AuthProvider` 时,它可以作为唯一认证来源,即使 `securityRequirements` 非空 +- 如果同时配了凭证文件和 `AuthProvider`,两者分别生成 Header 后合并;同名不同值会 fail-fast - 认证失败时(如 token 获取异常),抛出的异常会传播到 `send()` 方法,请求会被拦截,不会发出 ## 6. AgentCard 定义 @@ -445,17 +447,27 @@ sender.sendNotification( ## 8. HTTPS 配置 ```java -// 开发环境:自签证书跳过验证 +// 仅用于受控的本地诊断:跳过证书链校验,但仍校验主机名 .sslVerify(false) // 生产环境:启用验证 + 自定义 CA 证书 .sslVerify(true) .caCertsPath("/path/to/ca-certs.pem") + +// 可选:mTLS 与 CRL。私钥支持 PKCS#8 PEM/DER;加密私钥需提供密码 +.clientCertPath("/path/to/client-cert.pem") +.clientKeyPath("/path/to/client-key.pem") +.clientKeyPassword("change-me") +.crlPath("/path/to/revocations.crl") ``` +HTTP/JSON-RPC 的 TLS 策略只作用于当前客户端,不修改 JVM 全局主机名校验设置;关闭证书链校验时仍会加载 mTLS +客户端身份。生产环境应保持 `sslVerify(true)`;自签证书通过 `caCertsPath` 建立信任。默认 gRPC runtime 在 +`sslVerify(false)` 时使用 plaintext,因此不能同时配置 mTLS 或 `crlPath`,这些组合会 fail-fast。 + ## 9. 日志 -引擎设有专用 `PROTOCOL` 日志器,输出完整的协议层请求/响应报文(含 Header 和 Body)。在 `log4j2.properties` 中配置: +引擎设有专用 `PROTOCOL` 日志器输出协议层请求/响应。Body 默认输出并按长度截断;`Authorization`、Cookie、API Key、Token、Secret 等敏感 Header 默认脱敏。在 `log4j2.properties` 中配置: ```properties logger.PROTOCOL.name=PROTOCOL @@ -464,7 +476,15 @@ logger.PROTOCOL.additivity=false logger.PROTOCOL.appenderRef=console ``` -设为 `debug` 可查看完整报文体。 +以下环境变量或同名 JVM system property 控制内容: + +```properties +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_BODY=true +WORKFLOW_ENGINE_PROTOCOL_MAX_BODY_CHARS=100000 +WORKFLOW_ENGINE_PROTOCOL_INCLUDE_SENSITIVE_HEADERS=false +``` + +只有在隔离、受控的本地联调中才可临时打开敏感 Header;打开时引擎会打印安全告警。 ## 10. 事件回调 @@ -550,7 +570,7 @@ WorkflowEngineClientConfig.builder() | `ExecutePsop.Builder` | 工作流执行入口 | | `ControlPoint` / `DefaultControlPoint` | 业务决策实现(onTask、onSelfTask、onRoute、onNegotiation 等) | | `WorkflowEngineClient` / `DefaultWorkflowEngineClient` | 工作流发送(sendMessage、认证、扩展) | -| `ExtensionSender` / `DefaultExtensionSender` | 一次性前置(sendAuthorization、sendNotification) | +| `ExtensionSender` / `DefaultExtensionSender` | 前置授权请求与长连接通知订阅 | | `A2ATransport` | 共享通信层(httpx runtime、认证、SSE 消费) | | `WorkflowEngineClientConfig` | 配置(SSL、认证、A2A-T、协商轮数、自定义 Handler) | | `AuthProvider` | 自定义认证 |