Codex Handbook
app-server/tests/suite/v2/plugin_share.rs 1466 lines
use std::path::Path;use std::path::PathBuf;use std::time::Duration;use anyhow::Result;use app_test_support::ChatGptAuthFixture;use app_test_support::TestAppServer;use app_test_support::to_response;use app_test_support::write_chatgpt_auth;use codex_app_server_protocol::JSONRPCError;use codex_app_server_protocol::JSONRPCResponse;use codex_app_server_protocol::PluginAuthPolicy;use codex_app_server_protocol::PluginInstallPolicy;use codex_app_server_protocol::PluginInterface;use codex_app_server_protocol::PluginListParams;use codex_app_server_protocol::PluginListResponse;use codex_app_server_protocol::PluginShareCheckoutResponse;use codex_app_server_protocol::PluginShareContext;use codex_app_server_protocol::PluginShareDeleteResponse;use codex_app_server_protocol::PluginShareDiscoverability;use codex_app_server_protocol::PluginShareListItem;use codex_app_server_protocol::PluginShareListResponse;use codex_app_server_protocol::PluginSharePrincipal;use codex_app_server_protocol::PluginSharePrincipalRole;use codex_app_server_protocol::PluginSharePrincipalType;use codex_app_server_protocol::PluginShareSaveResponse;use codex_app_server_protocol::PluginShareUpdateTargetsResponse;use codex_app_server_protocol::PluginSource;use codex_app_server_protocol::PluginSummary;use codex_app_server_protocol::RequestId;use codex_config::types::AuthCredentialsStoreMode;use codex_utils_absolute_path::AbsolutePathBuf;use flate2::Compression;use flate2::write::GzEncoder;use pretty_assertions::assert_eq;use serde_json::json;use tempfile::TempDir;use tokio::time::timeout;use wiremock::Mock;use wiremock::MockServer;use wiremock::ResponseTemplate;use wiremock::matchers::body_json;use wiremock::matchers::header;use wiremock::matchers::method;use wiremock::matchers::path;use wiremock::matchers::query_param;const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =    "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";#[tokio::test]async fn plugin_share_save_uploads_local_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let plugin_root = TempDir::new()?;    let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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,    )?;    write_corrupt_plugin_share_local_path_mapping(codex_home.path())?;    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace/upload-url"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "file_id": "file_123",            "upload_url": format!("{}/upload/file_123", server.uri()),            "etag": "\"upload_etag_123\"",        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("PUT"))        .and(path("/upload/file_123"))        .and(header("x-ms-blob-type", "BlockBlob"))        .and(header("content-type", "application/gzip"))        .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\""))        .expect(1)        .mount(&server)        .await;    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .and(body_json(json!({            "file_id": "file_123",            "etag": "\"upload_etag_123\"",        })))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "plugin_id": "plugins_123",            "share_url": "https://chatgpt.example/plugins/share/share-key-1",        })))        .expect(1)        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?;    let request_id = mcp        .send_raw_request(            "plugin/share/save",            Some(json!({                "pluginPath": expected_plugin_path.clone(),            })),        )        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareSaveResponse = to_response(response)?;    assert_eq!(        response,        PluginShareSaveResponse {            remote_plugin_id: "plugins_123".to_string(),            share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),        }    );    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/workspace/created"))        .and(query_param("limit", "200"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [remote_plugin_json("plugins_123")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", "WORKSPACE"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [installed_remote_plugin_json("plugins_123")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    let request_id = mcp        .send_raw_request("plugin/share/list", Some(json!({})))        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareListResponse = to_response(response)?;    assert_eq!(        response,        PluginShareListResponse {            data: vec![PluginShareListItem {                plugin: PluginSummary {                    id: "demo-plugin@workspace-shared-with-me".to_string(),                    remote_plugin_id: Some("plugins_123".to_string()),                    local_version: None,                    name: "demo-plugin".to_string(),                    share_context: Some(expected_share_context("plugins_123")),                    source: PluginSource::Remote,                    installed: true,                    enabled: true,                    install_policy: PluginInstallPolicy::Available,                    auth_policy: PluginAuthPolicy::OnUse,                    availability: codex_app_server_protocol::PluginAvailability::Available,                    interface: Some(expected_plugin_interface()),                    keywords: Vec::new(),                },                local_plugin_path: Some(expected_plugin_path),            }],        }    );    Ok(())}#[tokio::test]async fn plugin_share_save_forwards_access_policy() -> Result<()> {    let codex_home = TempDir::new()?;    let plugin_root = TempDir::new()?;    let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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,    )?;    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace/upload-url"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "file_id": "file_123",            "upload_url": format!("{}/upload/file_123", server.uri()),            "etag": "\"upload_etag_123\"",        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("PUT"))        .and(path("/upload/file_123"))        .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\""))        .expect(1)        .mount(&server)        .await;    Mock::given(method("POST"))        .and(path("/backend-api/public/plugins/workspace"))        .and(body_json(json!({            "file_id": "file_123",            "etag": "\"upload_etag_123\"",            "discoverability": "UNLISTED",            "share_targets": [                {                    "principal_type": "user",                    "principal_id": "user-1",                    "role": "editor",                },                {                    "principal_type": "workspace",                    "principal_id": "account-123",                    "role": "reader",                },            ],        })))        .respond_with(ResponseTemplate::new(201).set_body_json(json!({            "plugin_id": "plugins_123",            "share_url": "https://chatgpt.example/plugins/share/share-key-1",        })))        .expect(1)        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?;    let request_id = mcp        .send_raw_request(            "plugin/share/save",            Some(json!({                "pluginPath": expected_plugin_path,                "discoverability": "UNLISTED",                "shareTargets": [                    {                        "principalType": "user",                        "principalId": "user-1",                        "role": "editor",                    },                ],            })),        )        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareSaveResponse = to_response(response)?;    assert_eq!(        response,        PluginShareSaveResponse {            remote_plugin_id: "plugins_123".to_string(),            share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),        }    );    Ok(())}#[tokio::test]async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> {    let codex_home = TempDir::new()?;    let plugin_root = TempDir::new()?;    let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/save",            Some(json!({                "pluginPath": AbsolutePathBuf::try_from(plugin_path)?,                "discoverability": "LISTED",            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert_eq!(        error.error.message,        "discoverability LISTED is not supported for plugin/share/save; use UNLISTED or PRIVATE"    );    Ok(())}#[tokio::test]async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> {    let codex_home = TempDir::new()?;    let plugin_root = TempDir::new()?;    let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;    let server = MockServer::start().await;    std::fs::write(        codex_home.path().join("config.toml"),        format!(            r#"chatgpt_base_url = "{}/backend-api"[features]plugins = trueremote_plugin = trueplugin_sharing = false"#,            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 = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/save",            Some(json!({                "pluginPath": AbsolutePathBuf::try_from(plugin_path)?,            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert_eq!(error.error.message, "plugin sharing is disabled");    assert!(        server            .received_requests()            .await            .expect("wiremock should record requests")            .is_empty()    );    Ok(())}#[tokio::test]async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> {    let codex_home = TempDir::new()?;    let plugin_root = TempDir::new()?;    let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/save",            Some(json!({                "pluginPath": AbsolutePathBuf::try_from(plugin_path)?,                "discoverability": "UNLISTED",                "shareTargets": [                    {                        "principalType": "workspace",                        "principalId": "account-123",                        "role": "reader",                    },                ],            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert_eq!(        error.error.message,        "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access"    );    let request_id = mcp        .send_raw_request(            "plugin/share/updateTargets",            Some(json!({                "remotePluginId": "plugins_123",                "discoverability": "UNLISTED",                "shareTargets": [                    {                        "principalType": "workspace",                        "principalId": "account-123",                        "role": "reader",                    },                ],            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert_eq!(        error.error.message,        "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access"    );    Ok(())}#[tokio::test]async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let plugin_root = TempDir::new()?;    let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/save",            Some(json!({                "pluginPath": AbsolutePathBuf::try_from(plugin_path)?,                "remotePluginId": "plugins_123",                "discoverability": "PRIVATE",                "shareTargets": [                    {                        "principalType": "user",                        "principalId": "user-1",                        "role": "reader",                    },                ],            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert_eq!(        error.error.message,        "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings"    );    Ok(())}#[tokio::test]async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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,    )?;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/workspace/created"))        .and(query_param("limit", "200"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [remote_plugin_json("plugins_123")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", "WORKSPACE"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [installed_remote_plugin_json("plugins_123")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request("plugin/share/list", Some(json!({})))        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareListResponse = to_response(response)?;    assert_eq!(        response,        PluginShareListResponse {            data: vec![PluginShareListItem {                plugin: PluginSummary {                    id: "demo-plugin@workspace-shared-with-me".to_string(),                    remote_plugin_id: Some("plugins_123".to_string()),                    local_version: None,                    name: "demo-plugin".to_string(),                    share_context: Some(expected_share_context("plugins_123")),                    source: PluginSource::Remote,                    installed: true,                    enabled: true,                    install_policy: PluginInstallPolicy::Available,                    auth_policy: PluginAuthPolicy::OnUse,                    availability: codex_app_server_protocol::PluginAvailability::Available,                    interface: Some(expected_plugin_interface()),                    keywords: Vec::new(),                },                local_plugin_path: None,            }],        }    );    Ok(())}#[tokio::test]async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> {    let codex_home = TempDir::new()?;    let home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 bundle_url = mount_remote_plugin_bundle(        &server,        "demo-plugin",        remote_plugin_bundle_tar_gz_bytes("demo-plugin")?,    )    .await;    mount_remote_plugin_detail_with_bundle(        &server,        "plugins_123",        "demo-plugin",        &bundle_url,        "WORKSPACE",    )    .await;    mount_empty_remote_installed_plugins(&server, "WORKSPACE").await;    let home_env = home.path().to_string_lossy().into_owned();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("HOME", Some(home_env.as_str())),            ("USERPROFILE", Some(home_env.as_str())),            (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),        ],    )    .await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/checkout",            Some(json!({                "remotePluginId": "plugins_123",            })),        )        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareCheckoutResponse = to_response(response)?;    let plugin_path = AbsolutePathBuf::try_from(home.path().join("plugins/demo-plugin"))?;    let marketplace_path =        AbsolutePathBuf::try_from(home.path().join(".agents/plugins/marketplace.json"))?;    assert_eq!(        response,        PluginShareCheckoutResponse {            remote_plugin_id: "plugins_123".to_string(),            plugin_id: "demo-plugin@codex-curated".to_string(),            plugin_name: "demo-plugin".to_string(),            plugin_path: plugin_path.clone(),            marketplace_name: "codex-curated".to_string(),            marketplace_path: marketplace_path.clone(),            remote_version: Some("1.2.3".to_string()),        }    );    assert!(        plugin_path            .as_path()            .join(".codex-plugin/plugin.json")            .is_file()    );    let marketplace: serde_json::Value =        serde_json::from_str(&std::fs::read_to_string(marketplace_path.as_path())?)?;    assert_eq!(        marketplace,        json!({            "name": "codex-curated",            "interface": {                "displayName": "Personal",            },            "plugins": [                {                    "name": "demo-plugin",                    "source": {                        "source": "local",                        "path": "./plugins/demo-plugin",                    },                    "policy": {                        "installation": "AVAILABLE",                        "authentication": "ON_USE",                    },                },            ],        })    );    let mapping: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(        codex_home            .path()            .join(".tmp/plugin-share-local-paths-v1.json"),    )?)?;    assert_eq!(        mapping,        json!({            "localPluginPathsByRemotePluginId": {                "plugins_123": plugin_path.clone(),            },        })    );    let request_id = mcp        .send_plugin_list_request(PluginListParams {            cwds: None,            marketplace_kinds: Some(vec![                codex_app_server_protocol::PluginListMarketplaceKind::Local,            ]),        })        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginListResponse = to_response(response)?;    assert_eq!(response.marketplaces.len(), 1);    assert_eq!(response.marketplaces[0].name, "codex-curated");    assert_eq!(response.marketplaces[0].plugins[0].name, "demo-plugin");    assert_eq!(        response.marketplaces[0].plugins[0]            .share_context            .as_ref()            .map(|context| context.remote_plugin_id.as_str()),        Some("plugins_123")    );    std::fs::write(plugin_path.as_path().join("local-edit.txt"), "keep")?;    let request_id = mcp        .send_raw_request(            "plugin/share/checkout",            Some(json!({                "remotePluginId": "plugins_123",            })),        )        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareCheckoutResponse = to_response(response)?;    assert_eq!(response.plugin_path, plugin_path);    assert_eq!(        std::fs::read_to_string(plugin_path.as_path().join("local-edit.txt"))?,        "keep"    );    Ok(())}#[tokio::test]async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 bundle_url = format!("{}/bundles/global-plugin.tar.gz", server.uri());    mount_remote_plugin_detail_with_bundle(        &server,        "plugins_global",        "global-plugin",        &bundle_url,        "GLOBAL",    )    .await;    mount_empty_remote_installed_plugins(&server, "GLOBAL").await;    let home_env = home.path().to_string_lossy().into_owned();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("HOME", Some(home_env.as_str())),            ("USERPROFILE", Some(home_env.as_str())),            (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),        ],    )    .await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/checkout",            Some(json!({                "remotePluginId": "plugins_global",            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert!(        error            .error            .message            .contains("not available for plugin/share/checkout")    );    assert!(!home.path().join("plugins/global-plugin").exists());    Ok(())}#[tokio::test]async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> Result<()> {    let codex_home = TempDir::new()?;    let home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 marketplace_path = home.path().join(".agents/plugins/marketplace.json");    std::fs::create_dir_all(        marketplace_path            .parent()            .expect("marketplace path has parent"),    )?;    std::fs::write(        &marketplace_path,        serde_json::to_string_pretty(&json!({            "name": "codex-curated",            "plugins": [                {                    "name": "demo-plugin",                    "source": {                        "source": "local",                        "path": "./other/demo-plugin",                    },                },            ],        }))?,    )?;    let bundle_url = mount_remote_plugin_bundle(        &server,        "demo-plugin",        remote_plugin_bundle_tar_gz_bytes("demo-plugin")?,    )    .await;    mount_remote_plugin_detail_with_bundle(        &server,        "plugins_123",        "demo-plugin",        &bundle_url,        "WORKSPACE",    )    .await;    mount_empty_remote_installed_plugins(&server, "WORKSPACE").await;    let home_env = home.path().to_string_lossy().into_owned();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("HOME", Some(home_env.as_str())),            ("USERPROFILE", Some(home_env.as_str())),            (TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),        ],    )    .await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/checkout",            Some(json!({                "remotePluginId": "plugins_123",            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert!(        error            .error            .message            .contains("marketplace already contains plugin `demo-plugin`")    );    assert!(!home.path().join("plugins/demo-plugin").exists());    assert!(        !codex_home            .path()            .join(".tmp/plugin-share-local-paths-v1.json")            .exists()    );    Ok(())}#[tokio::test]async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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,    )?;    Mock::given(method("PUT"))        .and(path("/backend-api/ps/plugins/plugins_123/shares"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .and(body_json(json!({            "discoverability": "UNLISTED",            "targets": [                {                    "principal_type": "user",                    "principal_id": "user-1",                    "role": "editor",                },                {                    "principal_type": "workspace",                    "principal_id": "account-123",                    "role": "reader",                },            ],        })))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "principals": [                {                    "principal_type": "user",                    "principal_id": "owner-1",                    "role": "owner",                    "name": "Owner",                },                {                    "principal_type": "user",                    "principal_id": "user-1",                    "role": "editor",                    "name": "Gavin",                },                {                    "principal_type": "workspace",                    "principal_id": "account-123",                    "role": "reader",                    "name": "Workspace",                },            ],            "discoverability": "UNLISTED",        })))        .expect(1)        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/updateTargets",            Some(json!({                "remotePluginId": "plugins_123",                "discoverability": "UNLISTED",                "shareTargets": [                    {                        "principalType": "user",                        "principalId": "user-1",                        "role": "editor",                    },                ],            })),        )        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareUpdateTargetsResponse = to_response(response)?;    assert_eq!(        response,        PluginShareUpdateTargetsResponse {            principals: vec![                PluginSharePrincipal {                    principal_type: PluginSharePrincipalType::User,                    principal_id: "owner-1".to_string(),                    role: PluginSharePrincipalRole::Owner,                    name: "Owner".to_string(),                },                PluginSharePrincipal {                    principal_type: PluginSharePrincipalType::User,                    principal_id: "user-1".to_string(),                    role: PluginSharePrincipalRole::Editor,                    name: "Gavin".to_string(),                },                PluginSharePrincipal {                    principal_type: PluginSharePrincipalType::Workspace,                    principal_id: "account-123".to_string(),                    role: PluginSharePrincipalRole::Reader,                    name: "Workspace".to_string(),                },            ],            discoverability: codex_app_server_protocol::PluginShareDiscoverability::Unlisted,        }    );    Ok(())}#[tokio::test]async fn plugin_share_update_targets_rejects_when_plugin_sharing_disabled() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    std::fs::write(        codex_home.path().join("config.toml"),        format!(            r#"chatgpt_base_url = "{}/backend-api"[features]plugins = trueremote_plugin = trueplugin_sharing = false"#,            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 = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/updateTargets",            Some(json!({                "remotePluginId": "plugins_123",                "discoverability": "UNLISTED",                "shareTargets": [],            })),        )        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32600);    assert_eq!(error.error.message, "plugin sharing is disabled");    Ok(())}#[tokio::test]async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", 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 local_plugin_path = AbsolutePathBuf::try_from(codex_home.path().join("local-plugin"))?;    write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &local_plugin_path)?;    Mock::given(method("DELETE"))        .and(path("/backend-api/public/plugins/workspace/plugins_123"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(204))        .expect(1)        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_raw_request(            "plugin/share/delete",            Some(json!({                "remotePluginId": "plugins_123",            })),        )        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareDeleteResponse = to_response(response)?;    assert_eq!(response, PluginShareDeleteResponse {});    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/workspace/created"))        .and(query_param("limit", "200"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [remote_plugin_json("plugins_123")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", "WORKSPACE"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [installed_remote_plugin_json("plugins_123")],            "pagination": empty_pagination_json(),        })))        .expect(1)        .mount(&server)        .await;    let request_id = mcp        .send_raw_request("plugin/share/list", Some(json!({})))        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginShareListResponse = to_response(response)?;    assert_eq!(        response,        PluginShareListResponse {            data: vec![PluginShareListItem {                plugin: PluginSummary {                    id: "demo-plugin@workspace-shared-with-me".to_string(),                    remote_plugin_id: Some("plugins_123".to_string()),                    local_version: None,                    name: "demo-plugin".to_string(),                    share_context: Some(expected_share_context("plugins_123")),                    source: PluginSource::Remote,                    installed: true,                    enabled: true,                    install_policy: PluginInstallPolicy::Available,                    auth_policy: PluginAuthPolicy::OnUse,                    availability: codex_app_server_protocol::PluginAvailability::Available,                    interface: Some(expected_plugin_interface()),                    keywords: Vec::new(),                },                local_plugin_path: None,            }],        }    );    Ok(())}fn write_remote_plugin_config(codex_home: &Path, base_url: &str) -> std::io::Result<()> {    std::fs::write(        codex_home.join("config.toml"),        format!(            r#"chatgpt_base_url = "{base_url}"[features]plugins = trueremote_plugin = true"#        ),    )}async fn mount_remote_plugin_bundle(    server: &MockServer,    plugin_name: &str,    body: Vec<u8>,) -> String {    let bundle_path = format!("/bundles/{plugin_name}.tar.gz");    Mock::given(method("GET"))        .and(path(bundle_path.clone()))        .respond_with(            ResponseTemplate::new(200)                .insert_header("content-type", "application/gzip")                .set_body_bytes(body),        )        .expect(1)        .mount(server)        .await;    format!("{}{}", server.uri(), bundle_path)}async fn mount_remote_plugin_detail_with_bundle(    server: &MockServer,    remote_plugin_id: &str,    plugin_name: &str,    bundle_url: &str,    scope: &str,) {    Mock::given(method("GET"))        .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))        .and(query_param("includeDownloadUrls", "true"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "id": remote_plugin_id,            "name": plugin_name,            "scope": scope,            "discoverability": "PRIVATE",            "share_url": "https://chatgpt.example/plugins/share/share-key-1",            "share_principals": [                {                    "principal_type": "user",                    "principal_id": "user-owner__account-123",                    "role": "owner",                    "name": "Owner",                },            ],            "installation_policy": "AVAILABLE",            "authentication_policy": "ON_USE",            "release": {                "version": "1.2.3",                "bundle_download_url": bundle_url,                "display_name": "Demo Plugin",                "description": "Demo plugin description",                "interface": {                    "short_description": "A demo plugin",                    "capabilities": ["Read", "Write"],                },                "skills": [],            },        })))        .mount(server)        .await;}async fn mount_empty_remote_installed_plugins(server: &MockServer, scope: &str) {    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", scope))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "plugins": [],            "pagination": {                "next_page_token": null,            },        })))        .mount(server)        .await;}fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {    json!({        "id": plugin_id,        "name": "demo-plugin",        "scope": "WORKSPACE",        "discoverability": "PRIVATE",        "share_url": "https://chatgpt.example/plugins/share/share-key-1",        "share_principals": [            {                "principal_type": "user",                "principal_id": "user-owner__account-123",                "role": "owner",                "name": "Owner"            },            {                "principal_type": "user",                "principal_id": "user-reader__account-123",                "role": "reader",                "name": "Reader"            }        ],        "installation_policy": "AVAILABLE",        "authentication_policy": "ON_USE",        "release": {            "version": "0.1.0",            "display_name": "Demo Plugin",            "description": "Demo plugin description",            "interface": {                "short_description": "A demo plugin",                "capabilities": ["Read", "Write"]            },            "skills": []        }    })}fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {    let mut plugin = remote_plugin_json(plugin_id);    let serde_json::Value::Object(fields) = &mut plugin else {        unreachable!("plugin json should be an object");    };    fields.insert("enabled".to_string(), json!(true));    fields.insert("disabled_skill_names".to_string(), json!([]));    plugin}fn empty_pagination_json() -> serde_json::Value {    json!({        "next_page_token": null    })}fn expected_plugin_interface() -> PluginInterface {    PluginInterface {        display_name: Some("Demo Plugin".to_string()),        short_description: Some("A demo plugin".to_string()),        long_description: None,        developer_name: None,        category: None,        capabilities: vec!["Read".to_string(), "Write".to_string()],        website_url: None,        privacy_policy_url: None,        terms_of_service_url: None,        default_prompt: None,        brand_color: None,        composer_icon: None,        composer_icon_url: None,        logo: None,        logo_url: None,        screenshots: Vec::new(),        screenshot_urls: Vec::new(),    }}fn expected_share_context(plugin_id: &str) -> PluginShareContext {    PluginShareContext {        remote_plugin_id: plugin_id.to_string(),        remote_version: Some("0.1.0".to_string()),        discoverability: Some(PluginShareDiscoverability::Private),        share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),        creator_account_user_id: None,        creator_name: None,        share_principals: Some(vec![            PluginSharePrincipal {                principal_type: PluginSharePrincipalType::User,                principal_id: "user-owner__account-123".to_string(),                role: PluginSharePrincipalRole::Owner,                name: "Owner".to_string(),            },            PluginSharePrincipal {                principal_type: PluginSharePrincipalType::User,                principal_id: "user-reader__account-123".to_string(),                role: PluginSharePrincipalRole::Reader,                name: "Reader".to_string(),            },        ]),    }}fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf> {    let plugin_path = root.join(plugin_name);    write_file(        &plugin_path.join(".codex-plugin/plugin.json"),        &format!(r#"{{"name":"{plugin_name}"}}"#),    )?;    write_file(        &plugin_path.join("skills/example/SKILL.md"),        "# Example\n\nA test skill.\n",    )?;    Ok(plugin_path)}fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {    let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);    let skill = "# Example\n\nA test skill.\n";    let encoder = GzEncoder::new(Vec::new(), Compression::default());    let mut tar = tar::Builder::new(encoder);    for (path, contents, mode) in [        (            ".codex-plugin/plugin.json",            manifest.as_bytes(),            /*mode*/ 0o644,        ),        (            "skills/example/SKILL.md",            skill.as_bytes(),            /*mode*/ 0o644,        ),    ] {        let mut header = tar::Header::new_gnu();        header.set_size(contents.len() as u64);        header.set_mode(mode);        header.set_cksum();        tar.append_data(&mut header, path, contents)?;    }    Ok(tar.into_inner()?.finish()?)}fn write_corrupt_plugin_share_local_path_mapping(codex_home: &Path) -> std::io::Result<()> {    write_file(        &codex_home.join(".tmp/plugin-share-local-paths-v1.json"),        "not-json",    )}fn write_plugin_share_local_path_mapping(    codex_home: &Path,    remote_plugin_id: &str,    plugin_path: &AbsolutePathBuf,) -> std::io::Result<()> {    let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();    local_plugin_paths_by_remote_plugin_id.insert(        remote_plugin_id.to_string(),        serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,    );    let contents = serde_json::to_string_pretty(&json!({        "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,    }))    .map_err(std::io::Error::other)?;    write_file(        &codex_home.join(".tmp/plugin-share-local-paths-v1.json"),        &format!("{contents}\n"),    )}fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {    let Some(parent) = path.parent() else {        return Err(std::io::Error::other(format!(            "file path `{}` should have a parent",            path.display()        )));    };    std::fs::create_dir_all(parent)?;    std::fs::write(path, contents)}