thread-store: reject directory rollout paths

This commit is contained in:
Michael Bolin
2026-06-01 11:13:20 -07:00
parent c0ea566bb5
commit 950dba3cea
3 changed files with 136 additions and 6 deletions

View File

@@ -17,6 +17,7 @@ use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -770,6 +771,120 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
Ok(())
}
#[tokio::test]
async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let parent_thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Parent message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let side_thread_id = {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: parent_thread_id,
ephemeral: true,
..Default::default()
})
.await?;
let fork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
)
.await??;
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
assert!(thread.ephemeral);
assert_eq!(thread.path, None);
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "continue".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let _: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
thread.id
};
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let codex_home_path = codex_home.path().to_path_buf();
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: side_thread_id.clone(),
path: Some(codex_home_path.clone()),
..Default::default()
})
.await?;
let resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??;
assert!(
resume_err.error.message.contains("path is a directory"),
"unexpected resume error: {}",
resume_err.error.message
);
assert!(
!resume_err.error.message.contains("Is a directory"),
"resume should reject the directory before rollout reading: {}",
resume_err.error.message
);
let fork_id = mcp
.send_thread_fork_request(ThreadForkParams {
thread_id: side_thread_id,
path: Some(codex_home_path),
..Default::default()
})
.await?;
let fork_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(fork_id)),
)
.await??;
assert!(
fork_err.error.message.contains("path is a directory"),
"unexpected fork error: {}",
fork_err.error.message
);
assert!(
!fork_err.error.message.contains("Is a directory"),
"fork should reject the directory before rollout reading: {}",
fork_err.error.message
);
Ok(())
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");

View File

@@ -256,16 +256,12 @@ mod path {
pub(super) async fn existing_rollout_path(path: &Path) -> Option<PathBuf> {
let plain_path = plain_rollout_path(path);
if tokio::fs::try_exists(plain_path.as_path())
.await
.unwrap_or(false)
if matches!(tokio::fs::metadata(plain_path.as_path()).await, Ok(metadata) if metadata.is_file())
{
return Some(plain_path);
}
let compressed_path = compressed_rollout_path(plain_path.as_path());
if tokio::fs::try_exists(compressed_path.as_path())
.await
.unwrap_or(false)
if matches!(tokio::fs::metadata(compressed_path.as_path()).await, Ok(metadata) if metadata.is_file())
{
return Some(compressed_path);
}

View File

@@ -143,6 +143,25 @@ async fn resolve_requested_rollout_path(
} else {
rollout_path
};
match tokio::fs::metadata(path.as_path()).await {
Ok(metadata) if metadata.is_dir() => {
return Err(ThreadStoreError::InvalidRequest {
message: format!(
"failed to resolve rollout path `{}`: path is a directory",
path.display()
),
});
}
Ok(metadata) if !metadata.is_file() => {
return Err(ThreadStoreError::InvalidRequest {
message: format!(
"failed to resolve rollout path `{}`: path is not a file",
path.display()
),
});
}
_ => {}
}
let Some(path) = codex_rollout::existing_rollout_path(path.as_path()).await else {
return Err(ThreadStoreError::InvalidRequest {
message: format!(