From 7113d04f8acba43f62c0821112f2af7b7327b394 Mon Sep 17 00:00:00 2001 From: elky Date: Tue, 8 Sep 2026 08:49:35 +0800 Subject: [PATCH] fix(usage): preserve original captured HTTP headers --- apps/aether-gateway/src/executor/outcome.rs | 72 +++++------- apps/aether-gateway/src/tests/usage/local.rs | 4 +- .../aether-admin/src/observability/usage.rs | 54 +++++++++ .../adapters/postgres/src/usage/tests.rs | 6 +- .../contracts/src/repository/usage/policy.rs | 66 ++++++----- .../src/repository/usage/memory/tests.rs | 4 +- crates/aether-usage/runtime/src/write.rs | 105 ++++++------------ docs/operations/usage-header-capture.md | 17 +++ .../RequestDetailDrawer.body-capture.spec.ts | 34 ++++++ 9 files changed, 210 insertions(+), 152 deletions(-) create mode 100644 docs/operations/usage-header-capture.md diff --git a/apps/aether-gateway/src/executor/outcome.rs b/apps/aether-gateway/src/executor/outcome.rs index 4d06f48b9..729bf5caf 100644 --- a/apps/aether-gateway/src/executor/outcome.rs +++ b/apps/aether-gateway/src/executor/outcome.rs @@ -756,13 +756,8 @@ fn runtime_miss_client_error_body(api_format: Option<&str>, message: &str) -> Va } fn runtime_miss_original_headers_json(headers: &HeaderMap) -> Value { - let mut headers = crate::headers::collect_control_headers(headers); - for (name, value) in headers.iter_mut() { - if runtime_miss_sensitive_header(name) { - *value = runtime_miss_mask_header_value(value); - } - } - serde_json::to_value(headers).unwrap_or_else(|_| json!({})) + serde_json::to_value(crate::headers::collect_control_headers(headers)) + .unwrap_or_else(|_| json!({})) } fn runtime_miss_original_request_body_json( @@ -784,40 +779,6 @@ fn runtime_miss_original_request_body_json( }) } -fn runtime_miss_sensitive_header(name: &str) -> bool { - const SENSITIVE_HEADERS: &[&str] = &[ - "authorization", - "x-api-key", - "api-key", - "x-goog-api-key", - "cookie", - "proxy-authorization", - ]; - - SENSITIVE_HEADERS - .iter() - .any(|candidate| name.eq_ignore_ascii_case(candidate)) -} - -fn runtime_miss_mask_header_value(value: &str) -> String { - let value = value.trim(); - let char_count = value.chars().count(); - if char_count <= 8 { - return "****".to_string(); - } - - let prefix: String = value.chars().take(4).collect(); - let suffix: String = value - .chars() - .rev() - .take(4) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{prefix}****{suffix}") -} - async fn load_runtime_miss_candidate_contexts( state: &AppState, request_id: &str, @@ -1233,8 +1194,9 @@ mod tests { apply_runtime_miss_usage_routing, beautify_local_execution_client_error_message, insert_runtime_miss_candidate_usage_metadata, request_candidate_represents_provider_execution, runtime_miss_client_error_body, - select_last_runtime_miss_executed_candidate, select_last_runtime_miss_routing_candidate, - LocalExecutionRuntimeMissContext, RuntimeMissCandidateContext, + runtime_miss_original_headers_json, select_last_runtime_miss_executed_candidate, + select_last_runtime_miss_routing_candidate, LocalExecutionRuntimeMissContext, + RuntimeMissCandidateContext, }; use crate::constants::EXECUTION_PATH_LOCAL_EXECUTION_RUNTIME_MISS; use crate::state::LocalExecutionRuntimeMissDiagnostic; @@ -1266,6 +1228,30 @@ mod tests { ); } + #[test] + fn runtime_miss_usage_preserves_original_request_headers() { + let expected = json!({ + "authorization": "Bearer original-client-token", + "x-api-key": "short", + "api-key": "original-api-key", + "x-goog-api-key": "original-google-key", + "cookie": "session=original-client", + "proxy-authorization": "Basic original-proxy-token", + "originator": "codex-cli", + "session-id": "original-session", + "x-codex-turn-metadata": "{\"turn_id\":\"original-turn\"}" + }); + let mut headers = http::HeaderMap::new(); + for (name, value) in expected.as_object().unwrap() { + headers.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + http::HeaderValue::from_str(value.as_str().unwrap()).unwrap(), + ); + } + + assert_eq!(runtime_miss_original_headers_json(&headers), expected); + } + #[test] fn runtime_miss_usage_body_matches_claude_client_envelope() { let claude = runtime_miss_client_error_body(Some("claude:messages"), "busy"); diff --git a/apps/aether-gateway/src/tests/usage/local.rs b/apps/aether-gateway/src/tests/usage/local.rs index cb60dc59b..d31ed1120 100644 --- a/apps/aether-gateway/src/tests/usage/local.rs +++ b/apps/aether-gateway/src/tests/usage/local.rs @@ -475,7 +475,7 @@ async fn gateway_truncates_deep_request_echo_for_local_openai_chat_sync_usage_im ); assert_eq!( stored_usage.request_headers.as_ref().unwrap()["authorization"], - "[redacted]" + "Bearer sk-client-openai-local-report-sync-deep" ); gateway_handle.abort(); @@ -2094,7 +2094,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s assert!(stored_usage.error_message.is_none()); assert_eq!( stored_usage.request_headers.as_ref().unwrap()["authorization"], - "[redacted]" + "Bearer sk-client-claude-cli-usage-local-miss" ); assert!(stored_usage.request_body.is_none()); assert!(stored_usage.request_body_ref.is_none()); diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 85fbf540f..8a604590f 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -3398,6 +3398,60 @@ mod tests { )); } + #[test] + fn detail_payload_preserves_original_headers_with_or_without_bodies() { + let item = StoredRequestUsageAudit { + request_headers: Some(json!({ + "authorization": "Bearer original-client-token", + "originator": "codex-cli", + "session-id": "original-session", + "thread-id": "original-thread", + "x-codex-turn-metadata": "{\"turn_id\":\"original-turn\"}", + "x-openai-subagent": "reviewer" + })), + provider_request_headers: Some(json!({ + "authorization": "Bearer original-provider-token", + "x-api-key": "original-provider-key" + })), + response_headers: Some(json!({ + "set-cookie": ["session=original-upstream", "preference=original"], + "x-upstream-custom": "original-upstream-value" + })), + client_response_headers: Some(json!({ + "set-cookie": "session=original-client", + "x-client-custom": "original-client-value" + })), + ..sample_usage("completed", Some(200), None) + }; + + for include_bodies in [false, true] { + let payload = build_admin_usage_detail_payload( + &item, + &BTreeMap::new(), + &BTreeMap::new(), + false, + false, + None, + include_bodies, + None, + &BTreeMap::new(), + ); + + for (field, expected) in [ + ("request_headers", &item.request_headers), + ("provider_request_headers", &item.provider_request_headers), + ("response_headers", &item.response_headers), + ("client_response_headers", &item.client_response_headers), + ] { + assert_eq!( + &payload[field], + expected.as_ref().unwrap(), + "{field} should retain original values when include_bodies={include_bodies}" + ); + } + } + } + #[test] fn detail_payload_marks_reference_backed_bodies_as_available() { let item = StoredRequestUsageAudit { diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index 49469193c..a279f053e 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -283,11 +283,11 @@ async fn live_full_http_capture_round_trips_for_direct_and_batch_writes() { .unwrap(); assert_eq!( stored.request_headers, - Some(json!({"content-type": "application/json", "authorization": "[redacted]"})) + Some(json!({"content-type": "application/json", "authorization": "Bearer private"})) ); assert_eq!( stored.response_headers, - Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"})) + Some(json!({"content-type": "text/event-stream", "set-cookie": "private"})) ); for (field, expected) in [ (UsageBodyField::RequestBody, pending.request_body.as_ref()), @@ -704,7 +704,7 @@ async fn live_pending_batch_persists_auxiliary_state_and_preserves_terminal_conf .unwrap(); assert_eq!( captured.request_headers, - Some(json!({"x-request": "[redacted]"})) + Some(json!({"x-request": "request-value"})) ); assert_eq!( repository diff --git a/crates/aether-data/contracts/src/repository/usage/policy.rs b/crates/aether-data/contracts/src/repository/usage/policy.rs index d3dadbdf8..6bd29e166 100644 --- a/crates/aether-data/contracts/src/repository/usage/policy.rs +++ b/crates/aether-data/contracts/src/repository/usage/policy.rs @@ -367,31 +367,8 @@ pub fn sanitize_usage_capture_controls_for_persistence( usage } -pub fn usage_header_value_is_sensitive(name: &str) -> bool { - ![ - "accept", - "accept-encoding", - "content-encoding", - "content-length", - "content-type", - "transfer-encoding", - "x-request-id", - "x-trace-id", - ] - .iter() - .any(|candidate| name.trim().eq_ignore_ascii_case(candidate)) -} - pub fn sanitize_usage_headers_for_persistence(value: Option) -> Option { - let Value::Object(mut headers) = value? else { - return None; - }; - for (name, value) in &mut headers { - if usage_header_value_is_sensitive(name) && !value.is_null() { - *value = Value::String("[redacted]".to_string()); - } - } - Some(Value::Object(headers)) + value.filter(Value::is_object) } fn sanitize_usage_routing_fields( @@ -833,6 +810,35 @@ mod tests { ); } + #[test] + fn header_capture_preserves_original_values() { + let headers = json!({ + "Authorization": "Bearer original-token", + "originator": "codex-cli", + "user-agent": "codex-tui/test", + "session-id": "session-original", + "thread-id": "thread-original", + "x-client-request-id": "client-request-original", + "x-codex-beta-features": "feature-original", + "x-codex-parent-thread-id": "parent-thread-original", + "x-codex-turn-metadata": "{\"turn_id\":\"turn-original\"}", + "x-codex-turn-state": "turn-state-original", + "x-codex-window-id": "window-original", + "x-openai-internal-codex-responses-lite": "true", + "x-openai-subagent": "reviewer", + "set-cookie": ["session=original", "preference=original"], + "x-custom-header": "original-value", + "x-null": null + }); + assert_eq!( + super::sanitize_usage_headers_for_persistence(Some(headers.clone())), + Some(headers) + ); + for invalid in [json!(null), json!("headers"), json!(["headers"])] { + assert!(super::sanitize_usage_headers_for_persistence(Some(invalid)).is_none()); + } + } + #[test] fn auxiliary_capture_projection_preserves_captures_and_honors_disabled_states() { let mut input = usage_with_http_capture(); @@ -843,7 +849,7 @@ mod tests { assert_eq!( usage.request_headers, - Some(json!({"authorization": "[redacted]"})) + Some(json!({"authorization": "Bearer secret"})) ); assert!(usage.request_body.is_none()); assert!(usage.request_body_ref.is_none()); @@ -896,15 +902,19 @@ mod tests { assert!(captured.client_response_body_ref.is_none()); assert_eq!( captured.request_headers, - Some(json!({"Content-Type": "application/json", "Authorization": "[redacted]"})) + Some(json!({"Content-Type": "application/json", "Authorization": "Bearer secret"})) ); assert_eq!( captured.provider_request_headers, - Some(json!({"x-api-key": "[redacted]"})) + Some(json!({"x-api-key": "secret"})) ); assert_eq!( captured.response_headers, - Some(json!({"set-cookie": "[redacted]"})) + Some(json!({"set-cookie": "secret"})) + ); + assert_eq!( + captured.client_response_headers, + input.client_response_headers ); assert!(captured.error_message.is_none()); } diff --git a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs index ca00f279f..ab0114bc7 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs @@ -155,7 +155,7 @@ async fn upsert_preserves_full_http_captures_across_lifecycle_updates() { ); assert_eq!( stored_pending.request_headers, - Some(json!({"content-type": "application/json", "authorization": "[redacted]"})) + Some(json!({"content-type": "application/json", "authorization": "Bearer private"})) ); let mut streaming = sample_upsert_usage_record(&pending.request_id); @@ -190,7 +190,7 @@ async fn upsert_preserves_full_http_captures_across_lifecycle_updates() { ); assert_eq!( stored_terminal.response_headers, - Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"})) + Some(json!({"content-type": "text/event-stream", "set-cookie": "private"})) ); let found = repository diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index 11ae333e9..497553936 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -865,12 +865,12 @@ pub fn build_terminal_usage_context_seed( request_type, is_stream: plan.stream, routing: build_runtime_routing_seed(plan, context), - request_headers: mask_sensitive_headers_in_json_value(context_value( + request_headers: validate_usage_headers_in_json_value(context_value( context, "original_headers", )), request_body: request_capture.request_body, - provider_request_headers: mask_sensitive_headers_in_json_value( + provider_request_headers: validate_usage_headers_in_json_value( context_usage_value(context, "provider_request_headers") .or_else(|| headers_to_json(&plan.headers)), ), @@ -2497,7 +2497,7 @@ fn sanitize_usage_event_data(mut data: UsageEventData) -> UsageEventData { } fn sanitize_usage_header_capture(value: Option) -> Option { - mask_sensitive_headers_in_json_value(value).map(capture_usage_storage_value) + validate_usage_headers_in_json_value(value).map(capture_usage_storage_value) } fn trim_owned_non_empty_string(value: String) -> Option { @@ -2720,30 +2720,19 @@ fn headers_to_json(headers: &BTreeMap) -> Option { return None; } Some(Value::Object(Map::from_iter(headers.iter().map( - |(key, value)| (key.clone(), Value::String(mask_header_value(key, value))), + |(key, value)| (key.clone(), Value::String(value.clone())), )))) } const REDACTED_USAGE_VALUE: &str = "[redacted]"; -fn is_sensitive_header(name: &str) -> bool { - aether_data_contracts::repository::usage::usage_header_value_is_sensitive(name) -} - -fn mask_header_value(name: &str, value: &str) -> String { - if !is_sensitive_header(name) { - return value.to_string(); - } - mask_sensitive_header_value(value) -} - fn mask_sensitive_header_value(_value: &str) -> String { REDACTED_USAGE_VALUE.to_string() } /// Non-object values cannot be established as a valid header map and are /// discarded instead of being persisted verbatim. -fn mask_sensitive_headers_in_json_value(value: Option) -> Option { +fn validate_usage_headers_in_json_value(value: Option) -> Option { aether_data_contracts::repository::usage::sanitize_usage_headers_for_persistence(value) } @@ -3509,11 +3498,10 @@ mod tests { build_terminal_usage_event_from_seed, build_usage_event_data_seed, build_usage_event_data_seed_describing_request_bodies, decode_body_for_storage, extract_token_counts_from_json, extract_token_counts_from_value, headers_to_json, - mask_header_value, mask_sensitive_body_fields, mask_sensitive_headers_in_json_value, - parse_sse_body_for_storage, resolve_error_message, trim_owned_non_empty_string, - LifecycleUsageSeed, TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed, - UsageRoutingSeed, UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH, - REDACTED_USAGE_VALUE, + mask_sensitive_body_fields, parse_sse_body_for_storage, resolve_error_message, + trim_owned_non_empty_string, validate_usage_headers_in_json_value, LifecycleUsageSeed, + TerminalUsageSeed, UsageBodyRefsSeed, UsageBodyStatesSeed, UsageRoutingSeed, + UsageTerminalState, MAX_USAGE_CAPTURE_BYTES, MAX_USAGE_CAPTURE_DEPTH, }; use crate::{ build_upsert_usage_record_from_event, GatewayStreamReportRequest, GatewaySyncReportRequest, @@ -5660,14 +5648,14 @@ mod tests { assert_eq!( event.data.response_headers, Some(json!({ - "authorization": REDACTED_USAGE_VALUE, + "authorization": "Bearer very-secret-token", "content-type": "application/json" })) ); assert_eq!( event.data.client_response_headers, Some(json!({ - "authorization": REDACTED_USAGE_VALUE, + "authorization": "Bearer very-secret-token", "content-type": "application/json" })) ); @@ -6560,7 +6548,7 @@ mod tests { } #[test] - fn manual_terminal_seed_event_builder_sanitizes_headers_and_metadata_but_preserves_bodies() { + fn manual_terminal_seed_event_builder_sanitizes_metadata_but_preserves_headers_and_bodies() { let event = build_terminal_usage_event_from_seed(TerminalUsageSeed { terminal_state: UsageTerminalState::Completed, client_contract: "openai:chat".to_string(), @@ -6631,26 +6619,26 @@ mod tests { assert_eq!( event.data.request_headers, Some(json!({ - "authorization": REDACTED_USAGE_VALUE, + "authorization": "Bearer very-secret-token", "accept": "application/json" })) ); assert_eq!( event.data.provider_request_headers, Some(json!({ - "x-api-key": REDACTED_USAGE_VALUE + "x-api-key": "sk-proj-super-secret" })) ); assert_eq!( event.data.response_headers, Some(json!({ - "set-cookie": REDACTED_USAGE_VALUE + "set-cookie": "session=extremely-secret-cookie" })) ); assert_eq!( event.data.client_response_headers, Some(json!({ - "authorization": REDACTED_USAGE_VALUE + "authorization": "Bearer client-secret" })) ); assert_eq!( @@ -6727,7 +6715,7 @@ mod tests { } #[test] - fn usage_event_data_seed_masks_headers_before_outcome_paths_use_it() { + fn usage_event_data_seed_preserves_headers_before_outcome_paths_use_it() { let plan = ExecutionPlan { request_id: "req-seed-sanitize-1".to_string(), candidate_id: Some("cand-seed-sanitize-1".to_string()), @@ -6766,7 +6754,7 @@ mod tests { assert_eq!( data.request_headers, Some(json!({ - "authorization": REDACTED_USAGE_VALUE, + "authorization": "Bearer outcome-secret", "accept": "application/json" })) ); @@ -6945,38 +6933,7 @@ mod tests { } #[test] - fn masks_known_sensitive_header_values() { - let token = "Bearer eyJhbGciOiJSUzI1NiJ9.payload-here.signature-tail"; - let masked = mask_header_value("authorization", token); - assert_eq!(masked, REDACTED_USAGE_VALUE); - - // 大小写不敏感 - assert_eq!( - mask_header_value("Authorization", "12345678"), - REDACTED_USAGE_VALUE, - ); - assert_eq!( - mask_header_value("X-Api-Key", "abcdefghij"), - REDACTED_USAGE_VALUE, - ); - - // Unknown custom headers are redacted by default. - assert_eq!( - mask_header_value("user-agent", "codex-tui/0.1"), - REDACTED_USAGE_VALUE, - ); - assert_eq!( - mask_header_value("x-custom-auth", "tenant-secret"), - REDACTED_USAGE_VALUE, - ); - assert_eq!( - mask_header_value("content-type", "application/json"), - "application/json", - ); - } - - #[test] - fn headers_to_json_masks_sensitive_headers_at_source() { + fn headers_to_json_preserves_original_headers_at_source() { let mut headers = BTreeMap::new(); headers.insert( "authorization".to_string(), @@ -6995,17 +6952,17 @@ mod tests { .get("authorization") .and_then(|v| v.as_str()) .expect("authorization should be string"); - assert_eq!(auth, REDACTED_USAGE_VALUE); + assert_eq!(auth, "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature"); let api_key = object .get("x-api-key") .and_then(|v| v.as_str()) .expect("x-api-key should be string"); - assert_eq!(api_key, REDACTED_USAGE_VALUE); + assert_eq!(api_key, "sk-proj-1234567890abcdef"); assert_eq!( object.get("user-agent").and_then(|v| v.as_str()), - Some(REDACTED_USAGE_VALUE), + Some("codex-tui/0.1"), ); } @@ -7015,27 +6972,27 @@ mod tests { } #[test] - fn mask_sensitive_headers_in_json_value_handles_object_form() { + fn validate_usage_headers_in_json_value_handles_object_form() { let value = json!({ "Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature", "Cookie": "session=verylongcookievalue1234", "Accept": "application/json", }); - let masked = - mask_sensitive_headers_in_json_value(Some(value)).expect("masked value should exist"); - let object = masked.as_object().expect("expected object"); + let captured = + validate_usage_headers_in_json_value(Some(value)).expect("captured value should exist"); + let object = captured.as_object().expect("expected object"); let auth = object .get("Authorization") .and_then(|v| v.as_str()) .expect("Authorization should be string"); - assert_eq!(auth, REDACTED_USAGE_VALUE); + assert_eq!(auth, "Bearer eyJhbGciOiJSUzI1NiJ9.body.signature"); let cookie = object .get("Cookie") .and_then(|v| v.as_str()) .expect("Cookie should be string"); - assert_eq!(cookie, REDACTED_USAGE_VALUE); + assert_eq!(cookie, "session=verylongcookievalue1234"); assert_eq!( object.get("Accept").and_then(|v| v.as_str()), @@ -7044,11 +7001,11 @@ mod tests { } #[test] - fn mask_sensitive_headers_discards_non_object() { + fn validate_usage_headers_discards_non_object() { // None 输入返回 None - assert!(mask_sensitive_headers_in_json_value(None).is_none()); + assert!(validate_usage_headers_in_json_value(None).is_none()); // 非 object 不是可验证的 header map,直接丢弃。 - let masked = mask_sensitive_headers_in_json_value(Some(json!("not an object"))); + let masked = validate_usage_headers_in_json_value(Some(json!("not an object"))); assert_eq!(masked, None); } diff --git a/docs/operations/usage-header-capture.md b/docs/operations/usage-header-capture.md new file mode 100644 index 000000000..b3d4e1a65 --- /dev/null +++ b/docs/operations/usage-header-capture.md @@ -0,0 +1,17 @@ +# Usage header capture + +Usage HTTP captures preserve original header values for client requests, +provider requests, provider responses, and client responses. Header maps are +validated as JSON objects but their values are not redacted. Capture settings +and existing access controls still apply. + +These records can contain credentials such as Authorization, API keys, and +cookies, as well as session identifiers and client metadata. Restrict access +to usage details, database records, exports, and backups accordingly. + +Previously stored `[redacted]` values cannot be recovered. Original values +are available only for new captures after deploying this change. + +The request detail drawer reads these values directly from the administrator +usage-detail API, including when bodies are not loaded. No frontend setting +can recover header values that were already replaced during capture. diff --git a/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.body-capture.spec.ts b/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.body-capture.spec.ts index 81539a6c0..1094309bd 100644 --- a/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.body-capture.spec.ts +++ b/frontend/src/features/usage/components/__tests__/RequestDetailDrawer.body-capture.spec.ts @@ -88,6 +88,40 @@ async function expectBody(text: string) { function lastSignal() { return mocks.getRequestBody.mock.calls[mocks.getRequestBody.mock.calls.length - 1][2] as AbortSignal } describe('RequestDetailDrawer body capture', () => { + it('displays original values for all four captured header directions and copies the selected headers', async () => { + const detail = { + ...buildDetail(false), + request_headers: { + authorization: 'Bearer original-client-token', + originator: 'codex-cli', + 'session-id': 'original-session', + 'x-codex-turn-metadata': '{"turn_id":"original-turn"}', + }, + provider_request_headers: { authorization: 'Bearer original-provider-token', 'x-api-key': 'original-provider-key' }, + response_headers: { 'set-cookie': 'session=original-upstream', 'x-upstream-custom': 'original-upstream-value' }, + client_response_headers: { 'set-cookie': 'session=original-client', 'x-client-custom': 'original-client-value' }, + } + mocks.getRequestDetail.mockResolvedValue(detail) + await openDrawer() + + for (const [tab, dataSource, expected] of [ + ['请求头', '客户端', detail.request_headers], + ['请求头', '提供商', detail.provider_request_headers], + ['响应头', '提供商', detail.response_headers], + ['响应头', '客户端', detail.client_response_headers], + ] as const) { + findButton(tab)!.click() + await source(dataSource) + await vi.waitFor(() => { + const text = document.body.querySelector('[data-testid="captured-body"]')?.textContent + expect(JSON.parse(text ?? 'null')).toEqual(expected) + }) + } + document.body.querySelector('button[title="复制"]')!.click() + await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(JSON.stringify(detail.client_response_headers, null, 2), false)) + expect(mocks.getRequestBody).not.toHaveBeenCalled() + }) + it('uses only shallow details and loads a single binary body on demand', async () => { await openDrawer() expect(mocks.getRequestBody).not.toHaveBeenCalled()