增强SuperAgent失败安全诊断日志
This commit is contained in:
@@ -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 文本字段。
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user