Add local diagnostic ZIP exports to the app server (#55940)

## What changed

Add a save RPC that accepts the existing report options and an absolute `savePath` on the server host. Save the report, selected attachments, and a manifest without uploading, and return `savedPath`, `threadId`, and `promptHash`. Local exports remain available when uploads are disabled by configuration.

Publish archives atomically without replacing existing files. Bound uncompressed contents to 200 MiB and attachments to 128, preserve duplicate attachment names safely, and fail without publishing a partial ZIP when a selected file cannot be read. Honor `includeLogs` and omit automatic rollouts for untouched threads without materializing them.

Update protocol schemas and generated TypeScript and Python types.

## Testing

Add coverage for local-only saves, disabled uploads, path validation, overwrite protection, log consent, archive budgets, duplicate names, missing files, and rollout selection before materialization and after writer shutdown.

GitOrigin-RevId: 222c0fe7ab923904de9c8a9f12ad27f7a0104875
This commit is contained in:
viktor-oai
2026-10-21 16:43:30 +00:00
committed by copyberry
parent 430a16799d
commit a3b685331f
34 changed files with 1481 additions and 120 deletions
+1 -1
View File
@@ -1756,7 +1756,7 @@
"additionalProperties": false,
"properties": {
"enabled": {
"description": "When `false`, disables the feedback flow across Codex product surfaces.",
"description": "When `false`, disables feedback uploads and background Guardian review retention. Explicit local exports remain available.",
"type": "boolean"
}
},
+12
View File
@@ -881,6 +881,18 @@ impl CodexThread {
self.rollout_path.clone()
}
/// Selects a local feedback rollout without materializing an untouched thread.
pub async fn feedback_rollout_path(&self) -> ThreadStoreResult<Option<PathBuf>> {
match self.session.live_thread() {
Some(thread) => thread.feedback_rollout_path().await.or_else(|error| {
// Shutdown can close or remove the writer before its thread leaves the manager.
// Retain the captured input; the archive reader still rejects missing files.
self.rollout_path().map(Some).ok_or(error)
}),
None => Ok(None),
}
}
/// Returns startup metadata without the one-time initial message replay.
pub fn startup_metadata(&self) -> &ThreadStartupMetadata {
&self.startup_metadata
+1 -1
View File
@@ -1182,7 +1182,7 @@ pub struct Config {
/// Voluntarily left as Optional because the default value might depend on the client.
pub analytics_enabled: Option<bool>,
/// When `false`, disables feedback collection across Codex product surfaces.
/// When `false`, disables feedback uploads and background Guardian review retention. Explicit local exports remain available.
/// Defaults to `true`.
pub feedback_enabled: bool,
+98 -77
View File
@@ -2088,13 +2088,15 @@ async fn interrupted_guardian_review_across_model_change_does_not_execute_the_co
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tracing_test::traced_test]
#[test_case(None, false; "legacy_fallback")]
#[test_case(None, true; "otel_enabled")]
#[test_case(Some("Acting model rejection instructions."), false; "catalog_override")]
#[test_case(Some(""), false; "empty_override")]
#[test_case(None, false, true; "legacy_fallback")]
#[test_case(None, true, true; "otel_enabled")]
#[test_case(Some("Acting model rejection instructions."), false, true; "catalog_override")]
#[test_case(Some(""), false, true; "empty_override")]
#[test_case(None, false, false; "feedback_disabled")]
async fn guardian_denial_rejects_tool_call_with_rationale(
rejection_instructions: Option<&'static str>,
log_assessments: bool,
feedback_enabled: bool,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -2142,6 +2144,7 @@ async fn guardian_denial_rejects_tool_call_with_rationale(
});
})
.with_config(move |config| {
config.feedback_enabled = feedback_enabled;
config.otel.log_guardian_assessments = log_assessments;
config.otel.exporter = OtelExporterKind::OtlpGrpc {
endpoint: "http://127.0.0.1:1".to_string(),
@@ -2241,80 +2244,98 @@ async fn guardian_denial_rejects_tool_call_with_rationale(
)
.await?;
let durable = state_db.list_guardian_review_records().await?;
assert_eq!(
durable.len(),
1,
"the Guardian capture must persist, not just fall back to memory"
);
let feedback = codex_feedback::guardian_review_failures(
Some(&state_db),
&[test.session_configured.thread_id],
)
.await
.attachment
.expect("failed Guardian review");
let record: serde_json::Value = serde_json::from_slice(&feedback.buffer)?;
assert_eq!(
record,
serde_json::from_slice::<serde_json::Value>(&durable[0].record)?
);
assert_eq!(record["instructions"], guardian_request.instructions_text());
assert!(
guardian_request.body_contains_text(record["action"].as_str().expect("reviewed action"))
);
let recorded_history: Vec<ResponseItem> = serde_json::from_value(record["history"].clone())?;
let inspection_request = requests
.iter()
.find(|request| {
request
if feedback_enabled {
assert_eq!(
durable.len(),
1,
"the Guardian capture must persist, not just fall back to memory"
);
let feedback = codex_feedback::guardian_review_failures(
Some(&state_db),
&[test.session_configured.thread_id],
)
.await
.attachment
.expect("failed Guardian review");
let record: serde_json::Value = serde_json::from_slice(&feedback.buffer)?;
assert_eq!(
record,
serde_json::from_slice::<serde_json::Value>(&durable[0].record)?
);
assert_eq!(record["instructions"], guardian_request.instructions_text());
assert!(
guardian_request
.body_contains_text(record["action"].as_str().expect("reviewed action"))
);
let recorded_history: Vec<ResponseItem> =
serde_json::from_value(record["history"].clone())?;
let inspection_request = requests
.iter()
.find(|request| {
request
.function_call_output_text("exec-guardian-inspect")
.is_some()
})
.expect("Guardian request following its inspection tool");
assert!(
inspection_request
.function_call_output_text("exec-guardian-inspect")
.is_some()
})
.expect("Guardian request following its inspection tool");
assert!(
inspection_request
.function_call_output_text("exec-guardian-inspect")
.expect("inspection output")
.contains("guardian-inspection-evidence")
);
let inspection_output = inspection_request
.input()
.into_iter()
.find(|item| {
item["type"] == "function_call_output" && item["call_id"] == "exec-guardian-inspect"
})
.expect("inspection response item");
assert!(recorded_history.contains(&serde_json::from_value(inspection_output)?));
assert!(recorded_history.iter().any(|item| {
matches!(item, ResponseItem::Message { role, content, .. }
if role == "assistant" && content.iter().any(|part| {
matches!(part, ContentItem::OutputText { text , .. }
if Some(text.as_str()) == record["decision"].as_str())
}))
}));
assert_eq!(
json!({
"reviewed_thread_id": record["reviewed_thread_id"],
"reviewer_thread_id": record["reviewer_thread_id"],
"status": record["status"],
"decision": serde_json::from_str::<serde_json::Value>(
record["decision"].as_str().expect("raw Guardian decision"),
)?,
"context_omitted": record["context_omitted"],
}),
json!({
"reviewed_thread_id": test.session_configured.thread_id,
"reviewer_thread_id": guardian_request.body_json()["client_metadata"]["thread_id"],
"status": "denied",
"decision": {
"risk_level": "high",
"user_authorization": "low",
"outcome": "deny",
"rationale": "The requested write has unacceptable test risk.",
},
"context_omitted": false,
})
);
.expect("inspection output")
.contains("guardian-inspection-evidence")
);
let inspection_output = inspection_request
.input()
.into_iter()
.find(|item| {
item["type"] == "function_call_output" && item["call_id"] == "exec-guardian-inspect"
})
.expect("inspection response item");
assert!(recorded_history.contains(&serde_json::from_value(inspection_output)?));
assert!(recorded_history.iter().any(|item| {
matches!(item, ResponseItem::Message { role, content, .. }
if role == "assistant" && content.iter().any(|part| {
matches!(part, ContentItem::OutputText { text , .. }
if Some(text.as_str()) == record["decision"].as_str())
}))
}));
assert_eq!(
json!({
"reviewed_thread_id": record["reviewed_thread_id"],
"reviewer_thread_id": record["reviewer_thread_id"],
"status": record["status"],
"decision": serde_json::from_str::<serde_json::Value>(
record["decision"].as_str().expect("raw Guardian decision"),
)?,
"context_omitted": record["context_omitted"],
}),
json!({
"reviewed_thread_id": test.session_configured.thread_id,
"reviewer_thread_id": guardian_request.body_json()["client_metadata"]["thread_id"],
"status": "denied",
"decision": {
"risk_level": "high",
"user_authorization": "low",
"outcome": "deny",
"rationale": "The requested write has unacceptable test risk.",
},
"context_omitted": false,
})
);
} else {
assert!(
durable.is_empty(),
"disabled feedback must not retain Guardian evidence"
);
assert!(
codex_feedback::guardian_review_failures(
Some(&state_db),
&[test.session_configured.thread_id]
)
.await
.attachment
.is_none()
);
}
let tool_output = requests
.iter()
@@ -443,3 +443,26 @@ async fn startup_tool_policy_controls_additional_permission_parameters(
fixture.codex.shutdown_and_wait().await?;
Ok(())
}
#[tokio::test]
async fn feedback_rollout_selection_survives_writer_shutdown() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let fixture = test_codex().build_with_auto_env(&server).await?;
let thread_id = fixture.session_configured.thread_id;
assert_eq!(fixture.codex.feedback_rollout_path().await?, None);
fixture
.thread_store
.persist_thread(thread_id, codex_thread_store::PersistContext::Standard)
.await?;
let path = fixture.codex.rollout_path().expect("local rollout");
fixture.codex.shutdown_and_wait().await?;
let expected = std::fs::read(&path)?;
assert_eq!(
fixture.codex.feedback_rollout_path().await?,
Some(path.clone())
);
assert_eq!(std::fs::read(&path)?, expected);
std::fs::remove_file(&path)?;
assert_eq!(fixture.codex.feedback_rollout_path().await?, Some(path));
Ok(())
}