mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-17 00:17:46 +08:00
fix(usage): preserve original captured HTTP headers
This commit is contained in:
@@ -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::<Vec<_>>()
|
||||
.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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value>) -> Option<Value> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Value>) -> Option<Value> {
|
||||
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<String> {
|
||||
@@ -2720,30 +2720,19 @@ fn headers_to_json(headers: &BTreeMap<String, String>) -> Option<Value> {
|
||||
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<Value>) -> Option<Value> {
|
||||
fn validate_usage_headers_in_json_value(value: Option<Value>) -> Option<Value> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
+34
@@ -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<HTMLButtonElement>('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()
|
||||
|
||||
Reference in New Issue
Block a user