mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
Merge origin/main into rhan/surface-updates (resolve #14374 conflicts)
This commit is contained in:
@@ -159,7 +159,8 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_reject_approval_policy_requires_experimental_api_capability() -> Result<()> {
|
||||
async fn thread_start_granular_approval_policy_requires_experimental_api_capability() -> Result<()>
|
||||
{
|
||||
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
@@ -180,7 +181,7 @@ async fn thread_start_reject_approval_policy_requires_experimental_api_capabilit
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
approval_policy: Some(AskForApproval::Reject {
|
||||
approval_policy: Some(AskForApproval::Granular {
|
||||
sandbox_approval: true,
|
||||
rules: false,
|
||||
skill_approval: false,
|
||||
@@ -196,7 +197,7 @@ async fn thread_start_reject_approval_policy_requires_experimental_api_capabilit
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_experimental_capability_error(error, "askForApproval.reject");
|
||||
assert_experimental_capability_error(error, "askForApproval.granular");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
613
codex-rs/app-server/tests/suite/v2/fs.rs
Normal file
613
codex-rs/app-server/tests/suite/v2/fs.rs
Normal file
@@ -0,0 +1,613 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use codex_app_server_protocol::FsCopyParams;
|
||||
use codex_app_server_protocol::FsGetMetadataResponse;
|
||||
use codex_app_server_protocol::FsReadDirectoryEntry;
|
||||
use codex_app_server_protocol::FsReadFileResponse;
|
||||
use codex_app_server_protocol::FsWriteFileParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::symlink;
|
||||
#[cfg(unix)]
|
||||
use std::process::Command;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
Ok(mcp)
|
||||
}
|
||||
|
||||
async fn expect_error_message(
|
||||
mcp: &mut McpProcess,
|
||||
request_id: i64,
|
||||
expected_message: &str,
|
||||
) -> Result<()> {
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.message, expected_message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
|
||||
assert!(
|
||||
path.is_absolute(),
|
||||
"path must be absolute: {}",
|
||||
path.display()
|
||||
);
|
||||
AbsolutePathBuf::try_from(path).expect("path should be absolute")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let file_path = codex_home.path().join("note.txt");
|
||||
std::fs::write(&file_path, "hello")?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams {
|
||||
path: absolute_path(file_path.clone()),
|
||||
})
|
||||
.await?;
|
||||
let response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let result = response
|
||||
.result
|
||||
.as_object()
|
||||
.context("fs/getMetadata result should be an object")?;
|
||||
let mut keys = result.keys().cloned().collect::<Vec<_>>();
|
||||
keys.sort();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"createdAtMs".to_string(),
|
||||
"isDirectory".to_string(),
|
||||
"isFile".to_string(),
|
||||
"modifiedAtMs".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let stat: FsGetMetadataResponse = to_response(response)?;
|
||||
assert_eq!(
|
||||
stat,
|
||||
FsGetMetadataResponse {
|
||||
is_directory: false,
|
||||
is_file: true,
|
||||
created_at_ms: stat.created_at_ms,
|
||||
modified_at_ms: stat.modified_at_ms,
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
stat.modified_at_ms > 0,
|
||||
"modifiedAtMs should be populated for existing files"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_dir = codex_home.path().join("source");
|
||||
let nested_dir = source_dir.join("nested");
|
||||
let source_file = source_dir.join("root.txt");
|
||||
let copied_dir = codex_home.path().join("copied");
|
||||
let copy_file_path = codex_home.path().join("copy.txt");
|
||||
let nested_file = nested_dir.join("note.txt");
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
|
||||
let create_directory_request_id = mcp
|
||||
.send_fs_create_directory_request(codex_app_server_protocol::FsCreateDirectoryParams {
|
||||
path: absolute_path(nested_dir.clone()),
|
||||
recursive: None,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(create_directory_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let write_request_id = mcp
|
||||
.send_fs_write_file_request(FsWriteFileParams {
|
||||
path: absolute_path(nested_file.clone()),
|
||||
data_base64: STANDARD.encode("hello from app-server"),
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let root_write_request_id = mcp
|
||||
.send_fs_write_file_request(FsWriteFileParams {
|
||||
path: absolute_path(source_file.clone()),
|
||||
data_base64: STANDARD.encode("hello from source root"),
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(root_write_request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let read_request_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(nested_file.clone()),
|
||||
})
|
||||
.await?;
|
||||
let read_response: FsReadFileResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
assert_eq!(
|
||||
read_response,
|
||||
FsReadFileResponse {
|
||||
data_base64: STANDARD.encode("hello from app-server"),
|
||||
}
|
||||
);
|
||||
|
||||
let copy_file_request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(nested_file.clone()),
|
||||
destination_path: absolute_path(copy_file_path.clone()),
|
||||
recursive: false,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(copy_file_request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(©_file_path)?,
|
||||
"hello from app-server"
|
||||
);
|
||||
|
||||
let copy_dir_request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(source_dir.clone()),
|
||||
destination_path: absolute_path(copied_dir.clone()),
|
||||
recursive: true,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(copy_dir_request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(copied_dir.join("nested").join("note.txt"))?,
|
||||
"hello from app-server"
|
||||
);
|
||||
|
||||
let read_directory_request_id = mcp
|
||||
.send_fs_read_directory_request(codex_app_server_protocol::FsReadDirectoryParams {
|
||||
path: absolute_path(source_dir.clone()),
|
||||
})
|
||||
.await?;
|
||||
let readdir_response = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_directory_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let mut entries =
|
||||
to_response::<codex_app_server_protocol::FsReadDirectoryResponse>(readdir_response)?
|
||||
.entries;
|
||||
entries.sort_by(|left, right| left.file_name.cmp(&right.file_name));
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
FsReadDirectoryEntry {
|
||||
file_name: "nested".to_string(),
|
||||
is_directory: true,
|
||||
is_file: false,
|
||||
},
|
||||
FsReadDirectoryEntry {
|
||||
file_name: "root.txt".to_string(),
|
||||
is_directory: false,
|
||||
is_file: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let remove_request_id = mcp
|
||||
.send_fs_remove_request(codex_app_server_protocol::FsRemoveParams {
|
||||
path: absolute_path(copied_dir.clone()),
|
||||
recursive: None,
|
||||
force: None,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(remove_request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
!copied_dir.exists(),
|
||||
"fs/remove should default to recursive+force for directory trees"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_write_file_accepts_base64_bytes() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let file_path = codex_home.path().join("blob.bin");
|
||||
let bytes = [0_u8, 1, 2, 255];
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let write_request_id = mcp
|
||||
.send_fs_write_file_request(FsWriteFileParams {
|
||||
path: absolute_path(file_path.clone()),
|
||||
data_base64: STANDARD.encode(bytes),
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(std::fs::read(&file_path)?, bytes);
|
||||
|
||||
let read_request_id = mcp
|
||||
.send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {
|
||||
path: absolute_path(file_path),
|
||||
})
|
||||
.await?;
|
||||
let read_response: FsReadFileResponse = to_response(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
assert_eq!(
|
||||
read_response,
|
||||
FsReadFileResponse {
|
||||
data_base64: STANDARD.encode(bytes),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_write_file_rejects_invalid_base64() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let file_path = codex_home.path().join("blob.bin");
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_write_file_request(FsWriteFileParams {
|
||||
path: absolute_path(file_path),
|
||||
data_base64: "%%%".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert!(
|
||||
error
|
||||
.error
|
||||
.message
|
||||
.starts_with("fs/writeFile requires valid base64 dataBase64:"),
|
||||
"unexpected error message: {}",
|
||||
error.error.message
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_methods_reject_relative_paths() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let absolute_file = codex_home.path().join("absolute.txt");
|
||||
std::fs::write(&absolute_file, "hello")?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
|
||||
let read_id = mcp
|
||||
.send_raw_request("fs/readFile", Some(json!({ "path": "relative.txt" })))
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
read_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let write_id = mcp
|
||||
.send_raw_request(
|
||||
"fs/writeFile",
|
||||
Some(json!({
|
||||
"path": "relative.txt",
|
||||
"dataBase64": STANDARD.encode("hello"),
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
write_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let create_directory_id = mcp
|
||||
.send_raw_request(
|
||||
"fs/createDirectory",
|
||||
Some(json!({
|
||||
"path": "relative-dir",
|
||||
"recursive": null,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
create_directory_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let get_metadata_id = mcp
|
||||
.send_raw_request("fs/getMetadata", Some(json!({ "path": "relative.txt" })))
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
get_metadata_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let read_directory_id = mcp
|
||||
.send_raw_request("fs/readDirectory", Some(json!({ "path": "relative-dir" })))
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
read_directory_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let remove_id = mcp
|
||||
.send_raw_request(
|
||||
"fs/remove",
|
||||
Some(json!({
|
||||
"path": "relative.txt",
|
||||
"recursive": null,
|
||||
"force": null,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
remove_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let copy_source_id = mcp
|
||||
.send_raw_request(
|
||||
"fs/copy",
|
||||
Some(json!({
|
||||
"sourcePath": "relative.txt",
|
||||
"destinationPath": absolute_file.clone(),
|
||||
"recursive": false,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
copy_source_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let copy_destination_id = mcp
|
||||
.send_raw_request(
|
||||
"fs/copy",
|
||||
Some(json!({
|
||||
"sourcePath": absolute_file,
|
||||
"destinationPath": "relative-copy.txt",
|
||||
"recursive": false,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
copy_destination_id,
|
||||
"Invalid request: AbsolutePathBuf deserialized without a base path",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_copy_rejects_directory_without_recursive() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_dir = codex_home.path().join("source");
|
||||
std::fs::create_dir_all(&source_dir)?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(source_dir),
|
||||
destination_path: absolute_path(codex_home.path().join("dest")),
|
||||
recursive: false,
|
||||
})
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"fs/copy requires recursive: true when sourcePath is a directory"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_dir = codex_home.path().join("source");
|
||||
std::fs::create_dir_all(source_dir.join("nested"))?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(source_dir.clone()),
|
||||
destination_path: absolute_path(source_dir.join("nested").join("copy")),
|
||||
recursive: true,
|
||||
})
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"fs/copy cannot copy a directory to itself or one of its descendants"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_dir = codex_home.path().join("source");
|
||||
let nested_dir = source_dir.join("nested");
|
||||
let copied_dir = codex_home.path().join("copied");
|
||||
std::fs::create_dir_all(&nested_dir)?;
|
||||
symlink("nested", source_dir.join("nested-link"))?;
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(source_dir),
|
||||
destination_path: absolute_path(copied_dir.clone()),
|
||||
recursive: true,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let copied_link = copied_dir.join("nested-link");
|
||||
let metadata = std::fs::symlink_metadata(&copied_link)?;
|
||||
assert!(metadata.file_type().is_symlink());
|
||||
assert_eq!(std::fs::read_link(copied_link)?, PathBuf::from("nested"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_dir = codex_home.path().join("source");
|
||||
let copied_dir = codex_home.path().join("copied");
|
||||
std::fs::create_dir_all(&source_dir)?;
|
||||
std::fs::write(source_dir.join("note.txt"), "hello")?;
|
||||
let fifo_path = source_dir.join("named-pipe");
|
||||
let output = Command::new("mkfifo").arg(&fifo_path).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"mkfifo failed: stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(source_dir),
|
||||
destination_path: absolute_path(copied_dir.clone()),
|
||||
recursive: true,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(copied_dir.join("note.txt"))?,
|
||||
"hello"
|
||||
);
|
||||
assert!(!copied_dir.join("named-pipe").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let fifo_path = codex_home.path().join("named-pipe");
|
||||
let output = Command::new("mkfifo").arg(&fifo_path).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"mkfifo failed: stdout={} stderr={}",
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let mut mcp = initialized_mcp(&codex_home).await?;
|
||||
let request_id = mcp
|
||||
.send_fs_copy_request(FsCopyParams {
|
||||
source_path: absolute_path(fifo_path),
|
||||
destination_path: absolute_path(codex_home.path().join("copied")),
|
||||
recursive: false,
|
||||
})
|
||||
.await?;
|
||||
expect_error_message(
|
||||
&mut mcp,
|
||||
request_id,
|
||||
"fs/copy only supports regular files, directories, and symlinks",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -46,9 +46,15 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
|
||||
let JSONRPCMessage::Response(response) = message else {
|
||||
anyhow::bail!("expected initialize response, got {message:?}");
|
||||
};
|
||||
let InitializeResponse { user_agent } = to_response::<InitializeResponse>(response)?;
|
||||
let InitializeResponse {
|
||||
user_agent,
|
||||
platform_family,
|
||||
platform_os,
|
||||
} = to_response::<InitializeResponse>(response)?;
|
||||
|
||||
assert!(user_agent.starts_with("codex_vscode/"));
|
||||
assert_eq!(platform_family, std::env::consts::FAMILY);
|
||||
assert_eq!(platform_os, std::env::consts::OS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -80,9 +86,15 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> {
|
||||
let JSONRPCMessage::Response(response) = message else {
|
||||
anyhow::bail!("expected initialize response, got {message:?}");
|
||||
};
|
||||
let InitializeResponse { user_agent } = to_response::<InitializeResponse>(response)?;
|
||||
let InitializeResponse {
|
||||
user_agent,
|
||||
platform_family,
|
||||
platform_os,
|
||||
} = to_response::<InitializeResponse>(response)?;
|
||||
|
||||
assert!(user_agent.starts_with("codex_originator_via_env_var/"));
|
||||
assert_eq!(platform_family, std::env::consts::FAMILY);
|
||||
assert_eq!(platform_os, std::env::consts::OS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
|
||||
const CONNECTOR_ID: &str = "calendar";
|
||||
const CONNECTOR_NAME: &str = "Calendar";
|
||||
const TOOL_NAME: &str = "calendar_confirm_action";
|
||||
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar-confirm-action";
|
||||
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
|
||||
const TOOL_CALL_ID: &str = "call-calendar-confirm";
|
||||
const ELICITATION_MESSAGE: &str = "Allow this request?";
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ mod connection_handling_websocket_unix;
|
||||
mod dynamic_tools;
|
||||
mod experimental_api;
|
||||
mod experimental_feature_list;
|
||||
mod fs;
|
||||
mod initialize;
|
||||
mod mcp_server_elicitation;
|
||||
mod model_list;
|
||||
@@ -19,6 +20,7 @@ mod output_schema;
|
||||
mod plan_item;
|
||||
mod plugin_install;
|
||||
mod plugin_list;
|
||||
mod plugin_read;
|
||||
mod plugin_uninstall;
|
||||
mod rate_limits;
|
||||
mod realtime_conversation;
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::DEFAULT_CLIENT_NAME;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::start_analytics_events_server;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
@@ -136,6 +138,85 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
let analytics_server = start_analytics_events_server().await?;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_analytics_config(codex_home.path(), &analytics_server.uri())?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
|
||||
let payloads = timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = analytics_server.received_requests().await else {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
};
|
||||
if !requests.is_empty() {
|
||||
break requests;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&payloads[0].body).expect("analytics payload");
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"events": [{
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"plugin_id": "sample-plugin@debug",
|
||||
"plugin_name": "sample-plugin",
|
||||
"marketplace_name": "debug",
|
||||
"has_skills": false,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
"product_client_id": DEFAULT_CLIENT_NAME,
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
let connectors = vec![
|
||||
@@ -461,6 +542,13 @@ connectors = true
|
||||
)
|
||||
}
|
||||
|
||||
fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!("chatgpt_base_url = \"{base_url}\"\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_marketplace(
|
||||
repo_root: &std::path::Path,
|
||||
marketplace_name: &str,
|
||||
|
||||
303
codex-rs/app-server/tests/suite/v2/plugin_read.rs
Normal file
303
codex-rs/app-server/tests/suite/v2/plugin_read.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
"category": "Design"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r##"{
|
||||
"name": "demo-plugin",
|
||||
"description": "Longer manifest description",
|
||||
"interface": {
|
||||
"displayName": "Plugin Display Name",
|
||||
"shortDescription": "Short description for subtitle",
|
||||
"longDescription": "Long description for details page",
|
||||
"developerName": "OpenAI",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"websiteURL": "https://openai.com/",
|
||||
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
||||
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
||||
"defaultPrompt": "Starter prompt for trying a plugin",
|
||||
"brandColor": "#3B82F6",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
"screenshots": ["./assets/screenshot1.png"]
|
||||
}
|
||||
}"##,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join("skills/thread-summarizer/SKILL.md"),
|
||||
r#"---
|
||||
name: thread-summarizer
|
||||
description: Summarize email threads
|
||||
---
|
||||
|
||||
# Thread Summarizer
|
||||
"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".app.json"),
|
||||
r#"{
|
||||
"apps": {
|
||||
"gmail": {
|
||||
"id": "gmail"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"demo": {
|
||||
"command": "demo-server"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."demo-plugin@codex-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.plugin.marketplace_name, "codex-curated");
|
||||
assert_eq!(response.plugin.marketplace_path, marketplace_path);
|
||||
assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated");
|
||||
assert_eq!(response.plugin.summary.name, "demo-plugin");
|
||||
assert_eq!(
|
||||
response.plugin.description.as_deref(),
|
||||
Some("Longer manifest description")
|
||||
);
|
||||
assert_eq!(response.plugin.summary.installed, true);
|
||||
assert_eq!(response.plugin.summary.enabled, true);
|
||||
assert_eq!(
|
||||
response.plugin.summary.install_policy,
|
||||
PluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.summary.auth_policy,
|
||||
PluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("Plugin Display Name")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.category.as_deref()),
|
||||
Some("Design")
|
||||
);
|
||||
assert_eq!(response.plugin.skills.len(), 1);
|
||||
assert_eq!(
|
||||
response.plugin.skills[0].name,
|
||||
"demo-plugin:thread-summarizer"
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.skills[0].description,
|
||||
"Summarize email threads"
|
||||
);
|
||||
assert_eq!(response.plugin.apps.len(), 1);
|
||||
assert_eq!(response.plugin.apps[0].id, "gmail");
|
||||
assert_eq!(response.plugin.apps[0].name, "gmail");
|
||||
assert_eq!(
|
||||
response.plugin.apps[0].install_url.as_deref(),
|
||||
Some("https://chatgpt.com/apps/gmail/gmail")
|
||||
);
|
||||
assert_eq!(response.plugin.mcp_servers.len(), 1);
|
||||
assert_eq!(response.plugin.mcp_servers[0], "demo");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
plugin_name: "missing-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("plugin `missing-plugin` was not found")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(&plugin_root)?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("missing or invalid .codex-plugin/plugin.json")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_installed_plugin(
|
||||
codex_home: &TempDir,
|
||||
marketplace_name: &str,
|
||||
plugin_name: &str,
|
||||
) -> Result<()> {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace_name)
|
||||
.join(plugin_name)
|
||||
.join("local/.codex-plugin");
|
||||
std::fs::create_dir_all(&plugin_root)?;
|
||||
std::fs::write(
|
||||
plugin_root.join("plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::DEFAULT_CLIENT_NAME;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::start_analytics_events_server;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -64,6 +70,78 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
||||
let analytics_server = start_analytics_events_server().await?;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_installed_plugin(&codex_home, "debug", "sample-plugin")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
"chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n",
|
||||
analytics_server.uri()
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginUninstallResponse = to_response(response)?;
|
||||
assert_eq!(response, PluginUninstallResponse {});
|
||||
|
||||
let payloads = timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = analytics_server.received_requests().await else {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
};
|
||||
if !requests.is_empty() {
|
||||
break requests;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&payloads[0].body).expect("analytics payload");
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"events": [{
|
||||
"event_type": "codex_plugin_uninstalled",
|
||||
"event_params": {
|
||||
"plugin_id": "sample-plugin@debug",
|
||||
"plugin_name": "sample-plugin",
|
||||
"marketplace_name": "debug",
|
||||
"has_skills": false,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
"product_client_id": DEFAULT_CLIENT_NAME,
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_installed_plugin(
|
||||
codex_home: &TempDir,
|
||||
marketplace_name: &str,
|
||||
|
||||
@@ -51,7 +51,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
vec![],
|
||||
vec![
|
||||
json!({
|
||||
"type": "conversation.output_audio.delta",
|
||||
"type": "response.output_audio.delta",
|
||||
"delta": "AQID",
|
||||
"sample_rate": 24_000,
|
||||
"channels": 1,
|
||||
@@ -403,6 +403,10 @@ sandbox_mode = "read-only"
|
||||
model_provider = "mock_provider"
|
||||
experimental_realtime_ws_base_url = "{realtime_server_uri}"
|
||||
|
||||
[realtime]
|
||||
version = "v2"
|
||||
type = "conversational"
|
||||
|
||||
[features]
|
||||
{realtime_feature_key} = {realtime_enabled}
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
|
||||
service_tier: None,
|
||||
cwd: None,
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox: None,
|
||||
config: None,
|
||||
service_name: None,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_fake_rollout;
|
||||
use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
@@ -22,11 +24,19 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -212,6 +222,102 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/requirements"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.insert_header("content-type", "text/html")
|
||||
.set_body_string("<html>nope</html>"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "code": "refresh_token_invalidated" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let model_server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
create_config_toml_with_chatgpt_base_url(
|
||||
codex_home.path(),
|
||||
&model_server.uri(),
|
||||
&chatgpt_base_url,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.plan_type("business")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123")
|
||||
.account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let conversation_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
None,
|
||||
)?;
|
||||
|
||||
let refresh_token_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_token_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let fork_id = mcp
|
||||
.send_thread_fork_request(ThreadForkParams {
|
||||
thread_id: conversation_id,
|
||||
..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("failed to load configuration"),
|
||||
"unexpected fork error: {}",
|
||||
fork_err.error.message
|
||||
);
|
||||
assert_eq!(
|
||||
fork_err.error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
"detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.",
|
||||
}))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -398,3 +504,31 @@ stream_max_retries = 0
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_chatgpt_base_url(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
chatgpt_base_url: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
chatgpt_base_url = "{chatgpt_base_url}"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_apply_patch_sse_response;
|
||||
use app_test_support::create_fake_rollout_with_text_elements;
|
||||
@@ -8,6 +9,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
|
||||
use app_test_support::create_shell_command_sse_response;
|
||||
use app_test_support::rollout_path;
|
||||
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::CommandExecutionApprovalDecision;
|
||||
@@ -36,6 +38,8 @@ use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -60,6 +64,11 @@ use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
|
||||
@@ -1410,6 +1419,98 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/requirements"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.insert_header("content-type", "text/html")
|
||||
.set_body_string("<html>nope</html>"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "code": "refresh_token_invalidated" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let model_server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
create_config_toml_with_chatgpt_base_url(
|
||||
codex_home.path(),
|
||||
&model_server.uri(),
|
||||
&chatgpt_base_url,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.plan_type("business")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123")
|
||||
.account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let conversation_id = create_fake_rollout_with_text_elements(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Vec::new(),
|
||||
Some("mock_provider"),
|
||||
None,
|
||||
)?;
|
||||
let refresh_token_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_token_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: conversation_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
err.error.message.contains("failed to load configuration"),
|
||||
"unexpected error message: {}",
|
||||
err.error.message
|
||||
);
|
||||
assert_eq!(
|
||||
err.error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
"detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.",
|
||||
}))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -1736,6 +1837,37 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_chatgpt_base_url(
|
||||
codex_home: &std::path::Path,
|
||||
server_uri: &str,
|
||||
chatgpt_base_url: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "gpt-5.2-codex"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
chatgpt_base_url = "{chatgpt_base_url}"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
personality = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_required_broken_mcp(
|
||||
codex_home: &std::path::Path,
|
||||
server_uri: &str,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_mock_responses_server_repeating_assistant;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
@@ -11,15 +13,23 @@ use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::ThreadStartedNotification;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_app_server_protocol::ThreadStatusChangedNotification;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -318,6 +328,88 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/requirements"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.insert_header("content-type", "text/html")
|
||||
.set_body_string("<html>nope</html>"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "code": "refresh_token_invalidated" }
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let model_server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
create_config_toml_with_chatgpt_base_url(
|
||||
codex_home.path(),
|
||||
&model_server.uri(),
|
||||
&chatgpt_base_url,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.plan_type("business")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123")
|
||||
.account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let refresh_token_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_token_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let req_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
|
||||
let err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(req_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert!(
|
||||
err.error.message.contains("failed to load configuration"),
|
||||
"unexpected error message: {}",
|
||||
err.error.message
|
||||
);
|
||||
assert_eq!(
|
||||
err.error.data,
|
||||
Some(json!({
|
||||
"reason": "cloudRequirements",
|
||||
"errorCode": "Auth",
|
||||
"action": "relogin",
|
||||
"statusCode": 401,
|
||||
"detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.",
|
||||
}))
|
||||
);
|
||||
|
||||
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");
|
||||
@@ -342,6 +434,34 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_chatgpt_base_url(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
chatgpt_base_url: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
chatgpt_base_url = "{chatgpt_base_url}"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_required_broken_mcp(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
|
||||
@@ -1380,6 +1380,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
}],
|
||||
cwd: Some(first_cwd.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![first_cwd.try_into()?],
|
||||
read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess,
|
||||
@@ -1418,6 +1419,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
}],
|
||||
cwd: Some(second_cwd.clone()),
|
||||
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
|
||||
model: Some("mock-model".to_string()),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
|
||||
Reference in New Issue
Block a user