mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix(app-server): thread history redaction for remote clients (#22178)
## Summary Remote clients can still receive large `thread/resume` histories when prior turns include MCP tool call payloads or image-generation results. This adds a temporary response-only redaction path for the known remote client names. Longer term we will move towards fully paginated APIs backed by SQLite. ## Changes - Redact MCP tool call payload-bearing fields in `thread/resume` responses for `codex_chatgpt_android_remote` and `codex_chatgpt_ios_remote`. - Drop `imageGeneration` items from those `thread/resume` responses. - Keep redaction out of persisted rollout files, `thread/read`, `thread/turns/list`, live notifications, and token usage replay. - Cover the behavior with app-server helper tests and a v2 resume integration test that checks both remote clients plus a non-target control client. ## Testing - `cargo test -p codex-app-server thread_resume_redaction` - `cargo test -p codex-app-server thread_resume_redacts_payloads_for_chatgpt_remote_clients`
This commit is contained in:
@@ -15,6 +15,7 @@ use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
@@ -51,10 +52,14 @@ use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ImageGenerationEndEvent;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::McpToolCallEndEvent;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource as RolloutSessionSource;
|
||||
@@ -77,6 +82,7 @@ use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
@@ -376,6 +382,203 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<()> {
|
||||
for client_name in ["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"] {
|
||||
let remote_thread = resume_redaction_fixture(Some(client_name)).await?;
|
||||
let remote_turn = remote_thread
|
||||
.turns
|
||||
.first()
|
||||
.expect("remote resume should include a turn");
|
||||
let remote_mcp_item = remote_turn
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
|
||||
.expect("remote resume should include redacted MCP item");
|
||||
let ThreadItem::McpToolCall {
|
||||
arguments,
|
||||
result,
|
||||
error,
|
||||
..
|
||||
} = remote_mcp_item
|
||||
else {
|
||||
unreachable!("matched MCP item");
|
||||
};
|
||||
assert_eq!(arguments, &json!("[redacted]"));
|
||||
let result = result.as_ref().expect("redacted MCP result");
|
||||
assert_eq!(
|
||||
result.content,
|
||||
vec![json!({
|
||||
"type": "text",
|
||||
"text": "[redacted]",
|
||||
})]
|
||||
);
|
||||
assert_eq!(result.structured_content, None);
|
||||
assert_eq!(result.meta, None);
|
||||
assert_eq!(error, &None);
|
||||
assert!(
|
||||
!remote_turn
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| matches!(item, ThreadItem::ImageGeneration { .. })),
|
||||
"remote resume should drop image generation items for {client_name}"
|
||||
);
|
||||
}
|
||||
|
||||
let normal_thread = resume_redaction_fixture(Some("some_other_client")).await?;
|
||||
let normal_turn = normal_thread
|
||||
.turns
|
||||
.first()
|
||||
.expect("normal resume should include a turn");
|
||||
let normal_mcp_item = normal_turn
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
|
||||
.expect("normal resume should include MCP item");
|
||||
let ThreadItem::McpToolCall {
|
||||
arguments, result, ..
|
||||
} = normal_mcp_item
|
||||
else {
|
||||
unreachable!("matched MCP item");
|
||||
};
|
||||
assert_eq!(arguments, &json!({"secret":"argument"}));
|
||||
let result = result.as_ref().expect("normal MCP result");
|
||||
assert_eq!(
|
||||
result.content,
|
||||
vec![json!({
|
||||
"type": "text",
|
||||
"text": "secret result",
|
||||
})]
|
||||
);
|
||||
assert_eq!(
|
||||
result.structured_content,
|
||||
Some(json!({"secret":"structured"}))
|
||||
);
|
||||
assert_eq!(result.meta, Some(json!({"secret":"meta"})));
|
||||
assert!(
|
||||
normal_turn.items.iter().any(|item| matches!(
|
||||
item,
|
||||
ThreadItem::ImageGeneration {
|
||||
result,
|
||||
revised_prompt,
|
||||
..
|
||||
} if result == "base64-image-result"
|
||||
&& revised_prompt.as_deref() == Some("secret revised prompt")
|
||||
)),
|
||||
"normal resume should keep image generation items"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resume_redaction_fixture(
|
||||
client_name: Option<&str>,
|
||||
) -> Result<codex_app_server_protocol::Thread> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let filename_ts = "2025-01-05T12-00-00";
|
||||
let meta_rfc3339 = "2025-01-05T12:00:00Z";
|
||||
let conversation_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
filename_ts,
|
||||
meta_rfc3339,
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
append_resume_redaction_history(
|
||||
codex_home.path(),
|
||||
filename_ts,
|
||||
meta_rfc3339,
|
||||
&conversation_id,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
if let Some(client_name) = client_name {
|
||||
let _ = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.initialize_with_client_info(ClientInfo {
|
||||
name: client_name.to_string(),
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
}),
|
||||
)
|
||||
.await??;
|
||||
} else {
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
}
|
||||
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: conversation_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
fn append_resume_redaction_history(
|
||||
codex_home: &Path,
|
||||
filename_ts: &str,
|
||||
meta_rfc3339: &str,
|
||||
conversation_id: &str,
|
||||
) -> Result<()> {
|
||||
let rollout_file_path = rollout_path(codex_home, filename_ts, conversation_id);
|
||||
let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?;
|
||||
let appended_rollout = [
|
||||
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: "mcp-1".to_string(),
|
||||
invocation: McpInvocation {
|
||||
server: "docs".to_string(),
|
||||
tool: "lookup".to_string(),
|
||||
arguments: Some(json!({"secret":"argument"})),
|
||||
},
|
||||
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
|
||||
duration: Duration::from_millis(8),
|
||||
result: Ok(CallToolResult {
|
||||
content: vec![json!({
|
||||
"type": "text",
|
||||
"text": "secret result",
|
||||
})],
|
||||
structured_content: Some(json!({"secret":"structured"})),
|
||||
is_error: Some(false),
|
||||
meta: Some(json!({"secret":"meta"})),
|
||||
}),
|
||||
}),
|
||||
EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
|
||||
call_id: "ig-1".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("secret revised prompt".to_string()),
|
||||
result: "base64-image-result".to_string(),
|
||||
saved_path: Some(test_absolute_path("/tmp/ig-1.png")),
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|payload| {
|
||||
Ok(json!({
|
||||
"timestamp": meta_rfc3339,
|
||||
"type": "event_msg",
|
||||
"payload": serde_json::to_value(payload)?,
|
||||
})
|
||||
.to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
.join("\n");
|
||||
std::fs::write(
|
||||
&rollout_file_path,
|
||||
format!("{persisted_rollout}{appended_rollout}\n"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
Reference in New Issue
Block a user