Codex Handbook
app-server/tests/suite/v2/fs.rs 881 lines
use anyhow::Context;use anyhow::Result;use app_test_support::TestAppServer;use app_test_support::to_response;use base64::Engine;use base64::engine::general_purpose::STANDARD;use codex_app_server_protocol::FsChangedNotification;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::FsUnwatchParams;use codex_app_server_protocol::FsWatchResponse;use codex_app_server_protocol::FsWriteFileParams;use codex_app_server_protocol::JSONRPCNotification;use codex_app_server_protocol::RequestId;use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;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;// macOS and Windows Bazel CI can spend tens of seconds starting app-server// subprocesses or processing test RPCs under load.#[cfg(any(target_os = "macos", windows))]const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);#[cfg(not(any(target_os = "macos", windows)))]const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2);async fn initialized_mcp(codex_home: &TempDir) -> Result<TestAppServer> {    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    Ok(mcp)}async fn expect_error_message(    mcp: &mut TestAppServer,    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(())}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(),            "isSymlink".to_string(),            "modifiedAtMs".to_string(),        ]    );    let stat: FsGetMetadataResponse = to_response(response)?;    assert_eq!(        stat,        FsGetMetadataResponse {            is_directory: false,            is_file: true,            is_symlink: false,            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_return_error_when_local_environment_is_disabled() -> Result<()> {    let codex_home = TempDir::new()?;    let absolute_file = codex_home.path().join("absolute.txt");    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let read_id = mcp        .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams {            path: absolute_path(absolute_file),        })        .await?;    expect_error_message(&mut mcp, read_id, "local filesystem is not configured").await?;    Ok(())}#[cfg(unix)]#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn fs_get_metadata_reports_symlink() -> Result<()> {    let codex_home = TempDir::new()?;    let file_path = codex_home.path().join("note.txt");    let symlink_path = codex_home.path().join("note-link.txt");    std::fs::write(&file_path, "hello")?;    symlink(&file_path, &symlink_path)?;    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(symlink_path),        })        .await?;    let response = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let stat: FsGetMetadataResponse = to_response(response)?;    assert_eq!(stat.is_directory, false);    assert_eq!(stat.is_file, true);    assert_eq!(stat.is_symlink, true);    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(&copy_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(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications()-> Result<()> {    let codex_home = TempDir::new()?;    let git_dir = codex_home.path().join("repo").join(".git");    let fetch_head = git_dir.join("FETCH_HEAD");    std::fs::create_dir_all(&git_dir)?;    std::fs::write(&fetch_head, "old\n")?;    let mut mcp = initialized_mcp(&codex_home).await?;    let watch_id = "watch-git-dir".to_string();    let watch_request_id = mcp        .send_fs_watch_request(codex_app_server_protocol::FsWatchParams {            watch_id: watch_id.clone(),            path: absolute_path(git_dir.clone()),        })        .await?;    let watch_response: FsWatchResponse = to_response(        timeout(            DEFAULT_READ_TIMEOUT,            mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),        )        .await??,    )?;    assert_eq!(watch_response.path, absolute_path(git_dir.clone()));    std::fs::write(&fetch_head, "updated\n")?;    // Kernel file watching is not reliable in every sandboxed test environment.    // Keep validating notification shape when the backend does emit, but do not    // fail the whole suite if no OS event arrives.    if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {        assert_eq!(changed.watch_id, watch_id.clone());        assert_eq!(            changed.changed_paths,            vec![absolute_path(fetch_head.clone())]        );    }    while timeout(        Duration::from_millis(200),        mcp.read_stream_until_notification_message("fs/changed"),    )    .await    .is_ok()    {}    let unwatch_request_id = mcp        .send_fs_unwatch_request(FsUnwatchParams { watch_id })        .await?;    timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)),    )    .await??;    std::fs::write(git_dir.join("packed-refs"), "refs\n")?;    let maybe_notification = timeout(        Duration::from_millis(1500),        mcp.read_stream_until_notification_message("fs/changed"),    )    .await;    assert!(        maybe_notification.is_err(),        "fs/unwatch should stop future change notifications"    );    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> {    let codex_home = TempDir::new()?;    let git_dir = codex_home.path().join("repo").join(".git");    let head_path = git_dir.join("HEAD");    std::fs::create_dir_all(&git_dir)?;    std::fs::write(&head_path, "ref: refs/heads/main\n")?;    let mut mcp = initialized_mcp(&codex_home).await?;    let watch_id = "watch-head".to_string();    let watch_request_id = mcp        .send_fs_watch_request(codex_app_server_protocol::FsWatchParams {            watch_id: watch_id.clone(),            path: absolute_path(head_path.clone()),        })        .await?;    let watch_response: FsWatchResponse = to_response(        timeout(            DEFAULT_READ_TIMEOUT,            mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),        )        .await??,    )?;    assert_eq!(watch_response.path, absolute_path(head_path.clone()));    replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?;    if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {        assert_eq!(            changed,            FsChangedNotification {                watch_id,                changed_paths: vec![absolute_path(head_path.clone())],            }        );    }    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn fs_watch_allows_missing_file_targets() -> Result<()> {    let codex_home = TempDir::new()?;    let git_dir = codex_home.path().join("repo").join(".git");    let fetch_head = git_dir.join("FETCH_HEAD");    std::fs::create_dir_all(&git_dir)?;    let mut mcp = initialized_mcp(&codex_home).await?;    let watch_id = "watch-fetch-head".to_string();    let watch_request_id = mcp        .send_fs_watch_request(codex_app_server_protocol::FsWatchParams {            watch_id: watch_id.clone(),            path: absolute_path(fetch_head.clone()),        })        .await?;    let watch_response: FsWatchResponse = to_response(        timeout(            DEFAULT_READ_TIMEOUT,            mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),        )        .await??,    )?;    assert_eq!(watch_response.path, absolute_path(fetch_head.clone()));    replace_file_atomically(&fetch_head, "origin/main\n")?;    if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {        assert_eq!(            changed,            FsChangedNotification {                watch_id,                changed_paths: vec![absolute_path(fetch_head.clone())],            }        );    }    Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn fs_watch_rejects_relative_paths() -> Result<()> {    let codex_home = TempDir::new()?;    let mut mcp = initialized_mcp(&codex_home).await?;    let watch_id = mcp        .send_raw_request(            "fs/watch",            Some(json!({ "watchId": "watch-relative", "path": "relative-path" })),        )        .await?;    expect_error_message(        &mut mcp,        watch_id,        "Invalid request: AbsolutePathBuf deserialized without a base path",    )    .await?;    Ok(())}fn fs_changed_notification(notification: JSONRPCNotification) -> Result<FsChangedNotification> {    let params = notification        .params        .context("fs/changed notification should include params")?;    Ok(serde_json::from_value::<FsChangedNotification>(params)?)}async fn maybe_fs_changed_notification(    mcp: &mut TestAppServer,) -> Result<Option<FsChangedNotification>> {    match timeout(        OPTIONAL_FS_CHANGE_TIMEOUT,        mcp.read_stream_until_notification_message("fs/changed"),    )    .await    {        Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)),        Err(_) => Ok(None),    }}fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> {    let temp_path = path.with_extension("lock");    std::fs::write(&temp_path, contents)?;    #[cfg(windows)]    match std::fs::remove_file(path) {        Ok(()) => {}        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}        Err(err) => return Err(err.into()),    }    std::fs::rename(temp_path, path)?;    Ok(())}