增强SuperAgent失败安全诊断日志

This commit is contained in:
andy
2026-07-22 14:44:21 +07:00
parent 2fb518be09
commit 71191a10b0
3 changed files with 373 additions and 32 deletions

View File

@@ -20,6 +20,9 @@ import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -29,10 +32,19 @@ import org.springframework.stereotype.Service;
@Service
public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
private static final Logger log = LoggerFactory.getLogger(SuperAgentOpenApiClientImpl.class);
private static final String CSRF_HEADER_NAME = "X-CSRF-Token";
private static final String CSRF_COOKIE_NAME = "csrf_token";
private static final int CSRF_TOKEN_BYTES = 32;
private static final int SAFE_LOG_VALUE_MAX_LENGTH = 512;
private static final SecureRandom CSRF_RANDOM = new SecureRandom();
private static final Pattern JSON_SECRET_PATTERN = Pattern.compile(
"(?i)(\"(?:api[_-]?key|token|secret|password|cookie|authorization|csrf)\"\\s*:\\s*\")[^\"]*(\")");
private static final Pattern HEADER_SECRET_PATTERN = Pattern.compile(
"(?i)((?:authorization|cookie|x-csrf-token|csrf[_-]?token)\\s*:\\s*)[^\\s,;]+");
private static final Pattern TEXT_SECRET_PATTERN = Pattern.compile(
"(?i)((?:api[_-]?key|token|secret|password|cookie|authorization|csrf[_-]?token)\\s*[=:]\\s*)[^\\s,;}]+");
private static final Pattern URL_PATTERN = Pattern.compile("(?i)\\b(?:https?|oss)://[^\\s,;\"'<>]+");
private final SuperAgentOpenApiProperties properties;
private final SuperAgentOpenApiSseParser sseParser;
@@ -104,29 +116,38 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
*/
private String createSession(HttpClient httpClient, URI baseUri, SuperAgentOpenApiMessageRequest request)
throws Exception {
Map<String, Object> body = new LinkedHashMap<>();
body.put("external_subject_id", textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId()));
body.put("idempotency_key", request.idempotencyKey() + "-session");
body.put("metadata", request.metadata());
HttpRequest httpRequest = baseRequest(baseUri.resolve("/api/open/agent-sessions"), request.requestId())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
require2xx(httpResponse.statusCode(), httpResponse.body());
String response = httpResponse.body();
JsonNode json = objectMapper.readTree(response == null ? "{}" : response);
String sessionId = text(json, "session_id");
if (sessionId == null) {
sessionId = text(json, "id");
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("external_subject_id", textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId()));
body.put("idempotency_key", request.idempotencyKey() + "-session");
body.put("metadata", request.metadata());
HttpRequest httpRequest = baseRequest(baseUri.resolve("/api/open/agent-sessions"), request.requestId())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<String> httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
require2xx(httpResponse.statusCode(), httpResponse.body());
String response = httpResponse.body();
JsonNode json = objectMapper.readTree(response == null ? "{}" : response);
String sessionId = text(json, "session_id");
if (sessionId == null) {
sessionId = text(json, "id");
}
if (sessionId == null) {
throw new SuperAgentOpenApiException("SuperAgent 创建 session 响应缺少 session_id。");
}
return sessionId;
} catch (SuperAgentOpenApiException exception) {
logOpenApiFailure("create_session", request, null, null, exception);
throw exception;
} catch (Exception exception) {
restoreInterruptedFlag(exception);
logOpenApiFailure("create_session", request, null, null, exception);
throw exception;
}
if (sessionId == null) {
throw new SuperAgentOpenApiException("SuperAgent 创建 session 响应缺少 session_id。");
}
return sessionId;
}
/**
@@ -148,22 +169,47 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8))
.build();
HttpResponse<InputStream> httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofInputStream());
require2xxOrClose(httpResponse.statusCode(), httpResponse.body());
SuperAgentOpenApiSseParser.ParsedState state = sseParser.newState(sessionId);
HttpResponse<InputStream> httpResponse;
try {
httpResponse = httpClient.send(
httpRequest,
HttpResponse.BodyHandlers.ofInputStream());
require2xxOrClose(httpResponse.statusCode(), httpResponse.body());
} catch (SuperAgentOpenApiException exception) {
logOpenApiFailure("messages_stream", request, sessionId, state, exception);
throw exception;
} catch (Exception exception) {
restoreInterruptedFlag(exception);
logOpenApiFailure("messages_stream", request, sessionId, state, exception);
throw exception;
}
applyRunLocation(baseUri, httpResponse, state);
try {
consumeResponse(httpResponse.body(), state, traceConsumer);
} catch (SuperAgentOpenApiException exception) {
if (!state.endSeen() && state.runUri() != null) {
recover(httpClient, request.requestId(), state, traceConsumer);
try {
recover(httpClient, request.requestId(), state, traceConsumer);
} catch (SuperAgentOpenApiException recoveryException) {
logOpenApiFailure("sse_recover", request, sessionId, state, recoveryException);
throw recoveryException;
} catch (Exception recoveryException) {
restoreInterruptedFlag(recoveryException);
logOpenApiFailure("sse_recover", request, sessionId, state, recoveryException);
throw recoveryException;
}
} else {
logOpenApiFailure("sse_consume", request, sessionId, state, exception);
throw exception;
}
}
return sseParser.requireSuccessfulResult(state);
try {
return sseParser.requireSuccessfulResult(state);
} catch (SuperAgentOpenApiException exception) {
logOpenApiFailure("sse_result", request, sessionId, state, exception);
throw exception;
}
}
/**
@@ -231,10 +277,7 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
*/
private void require2xx(int statusCode, String responseBody) {
if (statusCode < 200 || statusCode >= 300) {
String safeBody = responseBody == null ? "" : responseBody;
if (safeBody.length() > 512) {
safeBody = safeBody.substring(0, 512);
}
String safeBody = responseBody == null ? "" : safeLogText(responseBody);
throw new SuperAgentOpenApiException("SuperAgent Open API HTTP 调用失败status=" + statusCode + "body=" + safeBody);
}
}
@@ -363,6 +406,131 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
consumeResponse(response.body(), state, traceConsumer);
}
/**
* HTTP 调用被中断时恢复中断标记,避免上层线程池误判当前线程仍可继续执行。
*/
private void restoreInterruptedFlag(Exception exception) {
if (exception instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
/**
* 记录 SuperAgent 失败边界诊断。只打印可定位的安全摘要,不打印邮件正文、附件 URL、鉴权头或 Secret。
*/
private void logOpenApiFailure(
String phase,
SuperAgentOpenApiMessageRequest request,
String sessionId,
SuperAgentOpenApiSseParser.ParsedState state,
Exception exception) {
if (!log.isWarnEnabled()) {
return;
}
log.warn(
"SuperAgent Open API failed. phase={}, request_id={}, external_subject_id={}, "
+ "idempotency_key={}, message_chars={}, session_id={}, run_id={}, run_uri_path={}, "
+ "last_event_id={}, event_types={}, failure_code={}, failure_message={}, metadata={}, "
+ "safe_error_summary={}, exception_chain={}",
safeLogText(phase),
safeLogText(request.requestId()),
safeLogText(textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId())),
safeLogText(request.idempotencyKey()),
request.message() == null ? 0 : request.message().length(),
safeLogText(sessionId),
state == null ? null : safeLogText(state.runId()),
state == null ? null : safeUriPath(state.runUri()),
state == null ? null : safeLogText(state.lastEventId()),
state == null ? null : state.eventTypes(),
state == null ? null : safeLogText(state.failureCode()),
state == null ? null : safeLogText(state.failureMessage()),
safeMetadataSummary(request.metadata()),
safeLogText(exception.getMessage()),
safeExceptionChain(exception));
}
/**
* 输出可排查的 metadata 摘要,只保留内部 ID / hotel 等定位字段。
*/
private Map<String, String> safeMetadataSummary(Map<String, Object> metadata) {
Map<String, String> summary = new LinkedHashMap<>();
if (metadata == null || metadata.isEmpty()) {
return summary;
}
putSafeMetadata(summary, metadata, "debug_run_id");
putSafeMetadata(summary, metadata, "dispatch_run_id");
putSafeMetadata(summary, metadata, "source_message_id");
putSafeMetadata(summary, metadata, "hotel_id");
putSafeMetadata(summary, metadata, "external_message_id");
putSafeMetadata(summary, metadata, "provider");
putSafeMetadata(summary, metadata, "schema_version");
return summary;
}
/**
* 追加单个安全 metadata 字段。
*/
private void putSafeMetadata(Map<String, String> summary, Map<String, Object> metadata, String fieldName) {
Object value = metadata.get(fieldName);
if (value == null) {
return;
}
summary.put(fieldName, safeLogText(String.valueOf(value)));
}
/**
* 生成异常链短摘要,避免日志输出完整堆栈和潜在敏感内容。
*/
private String safeExceptionChain(Throwable throwable) {
StringBuilder builder = new StringBuilder();
Throwable current = throwable;
int depth = 0;
while (current != null && depth < 4) {
if (!builder.isEmpty()) {
builder.append(" <- ");
}
builder.append(current.getClass().getSimpleName());
String message = safeLogText(current.getMessage());
if (message != null && !message.isBlank()) {
builder.append(": ").append(message);
}
current = current.getCause();
depth++;
}
return builder.toString();
}
/**
* 输出 URI 的路径部分,避免在日志里写入完整域名、签名参数或外链。
*/
private String safeUriPath(String uri) {
if (blank(uri)) {
return null;
}
try {
URI parsedUri = URI.create(uri);
return safeLogText(parsedUri.getPath());
} catch (Exception exception) {
return safeLogText(uri);
}
}
/**
* 对日志文本做 Secret / URL 脱敏和长度截断。
*/
private String safeLogText(String value) {
if (value == null) {
return null;
}
String sanitized = JSON_SECRET_PATTERN.matcher(value).replaceAll("$1***$2");
sanitized = HEADER_SECRET_PATTERN.matcher(sanitized).replaceAll("$1***");
sanitized = TEXT_SECRET_PATTERN.matcher(sanitized).replaceAll("$1***");
sanitized = URL_PATTERN.matcher(sanitized).replaceAll("***URL***");
return sanitized.length() > SAFE_LOG_VALUE_MAX_LENGTH
? sanitized.substring(0, SAFE_LOG_VALUE_MAX_LENGTH)
: sanitized;
}
/**
* 读取 JSON 文本字段。
*/

View File

@@ -589,5 +589,26 @@ public class SuperAgentOpenApiSseParser {
public String lastEventId() {
return lastEventId;
}
/**
* 返回已消费的 SSE 事件类型,用于失败诊断。
*/
public List<String> eventTypes() {
return List.copyOf(eventTypes);
}
/**
* 返回 SSE 失败事件中的错误码,用于失败诊断。
*/
public String failureCode() {
return failureCode;
}
/**
* 返回 SSE 失败事件中的错误摘要,用于失败诊断。
*/
public String failureMessage() {
return failureMessage;
}
}
}

View File

@@ -35,7 +35,11 @@ import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSession;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
@ExtendWith(OutputCaptureExtension.class)
class SuperAgentOpenApiClientImplTest {
@Test
@@ -232,6 +236,50 @@ class SuperAgentOpenApiClientImplTest {
assertThat(httpClient.streamPostCount()).isEqualTo(1);
}
@Test
void shouldLogSafeRequestAndResponseSummaryWhenSseRunFails(CapturedOutput output) throws Exception {
FailureSseHttpClient httpClient = new FailureSseHttpClient();
SuperAgentOpenApiProperties properties = openApiProperties();
properties.setApiKey("secret-open-api-key");
properties.setExternalSubjectId("th-hotel-agentbus-source-message");
SuperAgentOpenApiClientImpl client = new SuperAgentOpenApiClientImpl(
properties,
new SuperAgentOpenApiSseParser(new ObjectMapper()),
new ObjectMapper(),
httpClient);
assertThatThrownBy(() -> client.invokeMailDebug(new SuperAgentMailDebugRequest(
"MAIL_BODY_SECRET_TOKEN https://oss.secret/mail-body.html",
"debug-eml-2079814223967580162",
Map.of(
"debug_run_id", "2079814223967580162",
"source_message_id", "2079159429322088449",
"external_message_id", "AAMk-secret-message-id"))))
.isInstanceOf(SuperAgentOpenApiException.class)
.hasMessageContaining("open_agent_run_failed");
assertThat(httpClient.streamPostCount()).isEqualTo(1);
assertThat(output)
.contains("SuperAgent Open API failed")
.contains("phase=sse_result")
.contains("request_id=debug-eml-2079814223967580162")
.contains("external_subject_id=th-hotel-agentbus-source-message")
.contains("idempotency_key=debug-eml-2079814223967580162")
.contains("session_id=session-failure-001")
.contains("run_id=run-failure-001")
.contains("last_event_id=evt-3")
.contains("failure_code=open_agent_run_failed")
.contains("metadata={debug_run_id=2079814223967580162, source_message_id=2079159429322088449");
assertThat(output)
.doesNotContain("MAIL_BODY_SECRET_TOKEN")
.doesNotContain("secret-open-api-key")
.doesNotContain("secret-token")
.doesNotContain("https://oss.secret")
.doesNotContain("Authorization")
.doesNotContain("Cookie")
.doesNotContain("csrf_token");
}
private SuperAgentOpenApiProperties openApiProperties() {
SuperAgentOpenApiProperties properties = new SuperAgentOpenApiProperties();
properties.setEnabled(true);
@@ -354,6 +402,110 @@ class SuperAgentOpenApiClientImplTest {
}
}
private static final class FailureSseHttpClient extends HttpClient {
private final AtomicInteger streamPostCount = new AtomicInteger();
private int streamPostCount() {
return streamPostCount.get();
}
@Override
public Optional<CookieHandler> cookieHandler() {
return Optional.empty();
}
@Override
public Optional<Duration> connectTimeout() {
return Optional.empty();
}
@Override
public Redirect followRedirects() {
return Redirect.NEVER;
}
@Override
public Optional<ProxySelector> proxy() {
return Optional.empty();
}
@Override
public SSLContext sslContext() {
try {
return SSLContext.getDefault();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
@Override
public SSLParameters sslParameters() {
return new SSLParameters();
}
@Override
public Optional<Authenticator> authenticator() {
return Optional.empty();
}
@Override
public Version version() {
return Version.HTTP_1_1;
}
@Override
public Optional<Executor> executor() {
return Optional.empty();
}
@Override
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
String path = request.uri().getPath();
if (path.endsWith("/api/open/agent-sessions")) {
return response(request, 200, "{\"session_id\":\"session-failure-001\"}");
}
if (path.endsWith("/api/open/agent-sessions/session-failure-001/messages/stream")) {
streamPostCount.incrementAndGet();
byte[] response = """
id: evt-1
event: metadata
data: {"run_id":"run-failure-001","resolved_profile_id":"profile-failure-001"}
id: evt-2
event: error
data: {"code":"open_agent_run_failed","message":"Open agent run failed with token=secret-token and https://oss.secret/file.pdf"}
id: evt-3
event: end
data: {}
""".getBytes(StandardCharsets.UTF_8);
return response(request, 200, new ByteArrayInputStream(response));
}
throw new AssertionError("Unexpected request path: " + path);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler,
HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@SuppressWarnings("unchecked")
private <T> HttpResponse<T> response(HttpRequest request, int statusCode, Object body) {
return new SimpleHttpResponse<>(request, statusCode, (T) body);
}
}
private record SimpleHttpResponse<T>(
HttpRequest request,
int statusCode,