Codex Handbook
app-server/tests/suite/v2/plugin_read.rs 2179 lines
use std::sync::Arc;use std::sync::Mutex as StdMutex;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 axum::Json;use axum::Router;use axum::extract::State;use axum::http::HeaderMap;use axum::http::StatusCode;use axum::http::Uri;use axum::http::header::AUTHORIZATION;use axum::routing::get;use codex_app_server_protocol::AppInfo;use codex_app_server_protocol::AppMetadata;use codex_app_server_protocol::AppTemplateSummary;use codex_app_server_protocol::AppTemplateUnavailableReason;use codex_app_server_protocol::HookEventName;use codex_app_server_protocol::JSONRPCError;use codex_app_server_protocol::JSONRPCResponse;use codex_app_server_protocol::PluginAuthPolicy;use codex_app_server_protocol::PluginAvailability;use codex_app_server_protocol::PluginInstallPolicy;use codex_app_server_protocol::PluginReadParams;use codex_app_server_protocol::PluginReadResponse;use codex_app_server_protocol::PluginShareDiscoverability;use codex_app_server_protocol::PluginSharePrincipal;use codex_app_server_protocol::PluginSharePrincipalRole;use codex_app_server_protocol::PluginSharePrincipalType;use codex_app_server_protocol::PluginSkillReadParams;use codex_app_server_protocol::PluginSkillReadResponse;use codex_app_server_protocol::PluginSource;use codex_app_server_protocol::RequestId;use codex_config::types::AuthCredentialsStoreMode;use codex_utils_absolute_path::AbsolutePathBuf;use pretty_assertions::assert_eq;use serde_json::json;use tempfile::TempDir;use tokio::net::TcpListener;use tokio::task::JoinHandle;use tokio::time::timeout;use wiremock::Mock;use wiremock::MockServer;use wiremock::ResponseTemplate;use wiremock::matchers::header;use wiremock::matchers::method;use wiremock::matchers::path;use wiremock::matchers::query_param;const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);#[tokio::test]async fn plugin_read_rejects_missing_read_source() -> Result<()> {    let codex_home = TempDir::new()?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: None,            remote_marketplace_name: None,            plugin_name: "sample-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("requires exactly one of marketplacePath or remoteMarketplaceName")    );    Ok(())}#[tokio::test]async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {    let codex_home = TempDir::new()?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                codex_home.path().join("marketplace.json"),            )?),            remote_marketplace_name: Some("openai-curated-remote".to_string()),            plugin_name: "sample-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("requires exactly one of marketplacePath or remoteMarketplaceName")    );    Ok(())}#[tokio::test]async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> 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 = trueapps = true"#,            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 detail_body = r#"{  "id": "plugins~Plugin_00000000000000000000000000000000",  "name": "example-plugin",  "scope": "GLOBAL",  "installation_policy": "AVAILABLE",  "authentication_policy": "ON_USE",  "release": {    "version": "1.2.1",    "display_name": "Example Plugin",    "description": "Example plugin",    "app_ids": [],    "app_manifest": {      "apps": {        "example-server": {          "id": "example-app"        }      }    },    "keywords": [],    "interface": {      "short_description": "Example plugin",      "capabilities": [],      "default_prompt": "Use the legacy example prompt",      "default_prompts": []    },    "skills": [],    "mcp_servers": [      {        "key": "example-server",        "metadata": {          "command": "example-mcp"        }      },      {        "key": "other-server",        "metadata": {          "command": "other-mcp"        }      }    ]  }}"#;    let installed_body = r#"{  "plugins": [],  "pagination": {    "limit": 50,    "next_page_token": null  }}"#;    Mock::given(method("GET"))        .and(path(            "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000",        ))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_string(detail_body))        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", "GLOBAL"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_string(installed_body))        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/connectors/directory/list"))        .and(query_param("external_logos", "true"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "apps": [                AppInfo {                    id: "example-app".to_string(),                    name: "Example App".to_string(),                    description: Some("Example app connector".to_string()),                    logo_url: Some("https://example.com/example.png".to_string()),                    logo_url_dark: None,                    distribution_channel: Some("featured".to_string()),                    branding: None,                    app_metadata: None,                    labels: None,                    install_url: None,                    is_accessible: false,                    is_enabled: true,                    plugin_display_names: Vec::new(),                }            ],            "next_token": null        })))        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: None,            remote_marketplace_name: Some("openai-curated-remote".to_string()),            plugin_name: "plugins~Plugin_00000000000000000000000000000000".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, "openai-curated-remote");    assert_eq!(        response.plugin.summary.id,        "example-plugin@openai-curated-remote"    );    assert_eq!(        response.plugin.summary.remote_plugin_id.as_deref(),        Some("plugins~Plugin_00000000000000000000000000000000")    );    assert_eq!(response.plugin.summary.name, "example-plugin");    assert_eq!(response.plugin.summary.source, PluginSource::Remote);    assert_eq!(response.plugin.summary.share_context, None);    assert_eq!(        response            .plugin            .summary            .interface            .as_ref()            .and_then(|interface| interface.default_prompt.clone()),        Some(vec!["Use the legacy example prompt".to_string()])    );    assert_eq!(        response.plugin.mcp_servers,        vec!["other-server".to_string()]    );    assert_eq!(        response            .plugin            .apps            .iter()            .map(|app| app.id.as_str())            .collect::<Vec<_>>(),        vec!["example-app"]    );    Ok(())}#[tokio::test]async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_catalog_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 detail_body = r#"{  "id": "plugins~Plugin_11111111111111111111111111111111",  "name": "shared-linear",  "scope": "WORKSPACE",  "discoverability": "PRIVATE",  "creator_account_user_id": "user-gavin__account-123",  "creator_name": "Gavin",  "share_url": "https://chatgpt.example/plugins/share/share-key-1",  "share_principals": [    {      "principal_type": "user",      "principal_id": "user-gavin__account-123",      "role": "owner",      "name": "Gavin"    },    {      "principal_type": "user",      "principal_id": "user-ada__account-123",      "role": "reader",      "name": "Ada"    }  ],  "installation_policy": "AVAILABLE",  "authentication_policy": "ON_USE",  "release": {    "version": "2.3.4",    "display_name": "Shared Linear",    "description": "Track shared work",    "app_ids": [],    "keywords": [],    "interface": {},    "skills": []  }}"#;    let installed_body = r#"{  "plugins": [],  "pagination": {    "limit": 50,    "next_page_token": null  }}"#;    Mock::given(method("GET"))        .and(path(            "/backend-api/ps/plugins/plugins~Plugin_11111111111111111111111111111111",        ))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_string(detail_body))        .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_string(installed_body))        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    for remote_marketplace_name in [        "workspace-shared-with-me-private",        "workspace-shared-with-me",    ] {        let request_id = mcp            .send_plugin_read_request(PluginReadParams {                marketplace_path: None,                remote_marketplace_name: Some(remote_marketplace_name.to_string()),                plugin_name: "plugins~Plugin_11111111111111111111111111111111".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, "workspace-shared-with-me");        assert_eq!(            response.plugin.summary.id,            "shared-linear@workspace-shared-with-me"        );        assert_eq!(            response.plugin.summary.remote_plugin_id.as_deref(),            Some("plugins~Plugin_11111111111111111111111111111111")        );        let share_context = response            .plugin            .summary            .share_context            .as_ref()            .expect("expected share context");        assert_eq!(            share_context.remote_plugin_id,            "plugins~Plugin_11111111111111111111111111111111"        );        assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4"));        assert_eq!(            share_context.discoverability,            Some(PluginShareDiscoverability::Private)        );        assert_eq!(            share_context.creator_account_user_id.as_deref(),            Some("user-gavin__account-123")        );        assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));        assert_eq!(            share_context.share_url.as_deref(),            Some("https://chatgpt.example/plugins/share/share-key-1")        );        assert_eq!(            share_context.share_principals,            Some(vec![                PluginSharePrincipal {                    principal_type: PluginSharePrincipalType::User,                    principal_id: "user-gavin__account-123".to_string(),                    role: PluginSharePrincipalRole::Owner,                    name: "Gavin".to_string(),                },                PluginSharePrincipal {                    principal_type: PluginSharePrincipalType::User,                    principal_id: "user-ada__account-123".to_string(),                    role: PluginSharePrincipalRole::Reader,                    name: "Ada".to_string(),                },            ])        );    }    Ok(())}#[tokio::test]async fn plugin_read_includes_share_url_for_admin_disabled_remote_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_catalog_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 detail_body = r#"{  "id": "plugins~Plugin_00000000000000000000000000000000",  "name": "example-plugin",  "scope": "GLOBAL",  "share_url": "https://chatgpt.example/plugins/share/example-plugin",  "status": "DISABLED_BY_ADMIN",  "installation_policy": "AVAILABLE",  "authentication_policy": "ON_USE",  "release": {    "display_name": "Example Plugin",    "description": "Exercise example workflows",    "app_ids": [],    "app_templates": [      {        "template_id": "templated_apps_SourceControlEnterprise",        "name": "Source Control Enterprise",        "description": "Connect source control",        "category": "Developer Tools",        "canonical_connector_id": "source_control_enterprise",        "logo_url": "https://example.com/source-control-light.png",        "logo_url_dark": "https://example.com/source-control-dark.png",        "materialized_app_ids": ["asdk_app_source_control"],        "reason": null      },      {        "template_id": "templated_apps_DataWarehouse",        "name": "Data Warehouse",        "description": null,        "canonical_connector_id": null,        "logo_url": null,        "logo_url_dark": null,        "materialized_app_ids": [],        "reason": "NOT_CONFIGURED_FOR_WORKSPACE"      }    ],    "keywords": ["workflow", "example"],    "interface": {      "short_description": "Run example workflows",      "capabilities": ["Read", "Write"],      "default_prompt": "Use the legacy example prompt",      "default_prompts": ["Create an example item", "Review example projects"],      "logo_url": "https://example.com/example-plugin.png",      "screenshot_urls": ["https://example.com/example-plugin-shot.png"]    },    "skills": [      {        "name": "plan-work",        "description": "Plan example work",        "plugin_release_skill_id": "skill-1",        "interface": {          "display_name": "Plan Work",          "short_description": "Create a plan from issues"        }      }    ]  }}"#;    let installed_body = r#"{  "plugins": [    {      "id": "plugins~Plugin_00000000000000000000000000000000",      "name": "example-plugin",      "scope": "GLOBAL",      "installation_policy": "AVAILABLE",      "authentication_policy": "ON_USE",      "release": {        "display_name": "Example Plugin",        "description": "Exercise example workflows",        "app_ids": [],        "interface": {          "short_description": "Run example workflows",          "capabilities": ["Read", "Write"],          "logo_url": "https://example.com/example-plugin.png",          "screenshot_urls": ["https://example.com/example-plugin-shot.png"]        },        "skills": [          {            "name": "plan-work",            "description": "Plan example work",            "plugin_release_skill_id": "skill-1",            "interface": {              "display_name": "Plan Work",              "short_description": "Create a plan from issues"            }          }        ]      },      "enabled": false,      "disabled_skill_names": ["plan-work"]    }  ],  "pagination": {    "limit": 50,    "next_page_token": null  }}"#;    Mock::given(method("GET"))        .and(path(            "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000",        ))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_string(detail_body))        .mount(&server)        .await;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/installed"))        .and(query_param("scope", "GLOBAL"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_string(installed_body))        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: None,            remote_marketplace_name: Some("openai-curated-remote".to_string()),            plugin_name: "plugins~Plugin_00000000000000000000000000000000".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, "openai-curated-remote");    assert_eq!(response.plugin.marketplace_path, None);    assert_eq!(response.plugin.summary.source, PluginSource::Remote);    assert_eq!(        response.plugin.summary.id,        "example-plugin@openai-curated-remote"    );    assert_eq!(        response.plugin.summary.remote_plugin_id.as_deref(),        Some("plugins~Plugin_00000000000000000000000000000000")    );    assert_eq!(response.plugin.summary.name, "example-plugin");    assert_eq!(response.plugin.summary.installed, true);    assert_eq!(response.plugin.summary.enabled, false);    assert_eq!(        response.plugin.summary.availability,        PluginAvailability::DisabledByAdmin    );    assert_eq!(response.plugin.summary.share_context, None);    assert_eq!(        response.plugin.share_url.as_deref(),        Some("https://chatgpt.example/plugins/share/example-plugin")    );    assert_eq!(        response.plugin.description.as_deref(),        Some("Exercise example workflows")    );    assert_eq!(        response.plugin.summary.keywords,        vec!["workflow".to_string(), "example".to_string()]    );    assert_eq!(        response            .plugin            .summary            .interface            .as_ref()            .and_then(|interface| interface.default_prompt.clone()),        Some(vec![            "Create an example item".to_string(),            "Review example projects".to_string(),        ])    );    assert_eq!(response.plugin.skills.len(), 1);    assert_eq!(response.plugin.skills[0].name, "plan-work");    assert_eq!(response.plugin.skills[0].path, None);    assert_eq!(response.plugin.skills[0].enabled, false);    assert_eq!(response.plugin.apps.len(), 0);    assert_eq!(        response.plugin.app_templates,        vec![            AppTemplateSummary {                template_id: "templated_apps_SourceControlEnterprise".to_string(),                name: "Source Control Enterprise".to_string(),                description: Some("Connect source control".to_string()),                category: Some("Developer Tools".to_string()),                canonical_connector_id: Some("source_control_enterprise".to_string()),                logo_url: Some("https://example.com/source-control-light.png".to_string()),                logo_url_dark: Some("https://example.com/source-control-dark.png".to_string()),                materialized_app_ids: vec!["asdk_app_source_control".to_string()],                reason: None,            },            AppTemplateSummary {                template_id: "templated_apps_DataWarehouse".to_string(),                name: "Data Warehouse".to_string(),                description: None,                category: None,                canonical_connector_id: None,                logo_url: None,                logo_url_dark: None,                materialized_app_ids: Vec::new(),                reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace),            },        ]    );    Ok(())}#[tokio::test]async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_catalog_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 skill_body = r##"{  "plugin_id": "plugins~Plugin_00000000000000000000000000000000",  "status": "ENABLED",  "plugin_release_id": "release-1",  "name": "plan-work",  "description": "Plan work from Linear issues",  "plugin_release_skill_id": "skill-1",  "skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."}"##;    Mock::given(method("GET"))        .and(path(            "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",        ))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_string(skill_body))        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_skill_read_request(PluginSkillReadParams {            remote_marketplace_name: "openai-curated-remote".to_string(),            remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),            skill_name: "plan-work".to_string(),        })        .await?;    let response: JSONRPCResponse = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let response: PluginSkillReadResponse = to_response(response)?;    assert_eq!(        response,        PluginSkillReadResponse {            contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),        }    );    Ok(())}#[tokio::test]async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {    let codex_home = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_catalog_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/plugins~Plugin_missing"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"detail":"not found"}"#))        .mount(&server)        .await;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: None,            remote_marketplace_name: Some("openai-curated-remote".to_string()),            plugin_name: "plugins~Plugin_missing".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("read remote plugin details: remote plugin catalog request")    );    Ok(())}#[tokio::test]async fn plugin_read_rejects_remote_marketplace_when_plugins_are_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 = falseremote_plugin = true"#,            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_plugin_read_request(PluginReadParams {            marketplace_path: None,            remote_marketplace_name: Some("openai-curated-remote".to_string()),            plugin_name: "linear".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("remote plugin read is not enabled")    );    Ok(())}#[tokio::test]async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {    let codex_home = TempDir::new()?;    write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: None,            remote_marketplace_name: Some("openai-curated-remote".to_string()),            plugin_name: "linear/../../oops".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("invalid remote plugin id"));    assert!(        err.error            .message            .contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")    );    Ok(())}#[tokio::test]async fn plugin_read_returns_canonical_openai_curated_marketplace_name() -> Result<()> {    let codex_home = TempDir::new()?;    let repo_root = TempDir::new()?;    write_plugin_marketplace(        repo_root.path(),        "openai-curated",        "demo-plugin",        "./demo-plugin",    )?;    std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;    std::fs::write(        repo_root            .path()            .join("demo-plugin/.codex-plugin/plugin.json"),        r#"{  "name": "demo-plugin",  "description": "OpenAI curated plugin"}"#,    )?;    std::fs::write(        codex_home.path().join("config.toml"),        r#"[features]plugins = true[plugins."demo-plugin@openai-curated"]enabled = true"#,    )?;    write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?;    let mut mcp = TestAppServer::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: Some(marketplace_path.clone()),            remote_marketplace_name: None,            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, "openai-curated");    assert_eq!(response.plugin.marketplace_path, Some(marketplace_path));    assert_eq!(response.plugin.summary.id, "demo-plugin@openai-curated");    assert_eq!(response.plugin.summary.name, "demo-plugin");    Ok(())}#[tokio::test]async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<()> {    let codex_home = TempDir::new()?;    let repo_root = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_catalog_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_plugin_marketplace(        repo_root.path(),        "codex-curated",        "demo-plugin",        "./demo-plugin",    )?;    std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;    std::fs::write(        repo_root            .path()            .join("demo-plugin/.codex-plugin/plugin.json"),        r#"{"name":"demo-plugin","version":"1.2.3"}"#,    )?;    std::fs::write(        repo_root.path().join("demo-plugin/.mcp.json"),        r#"{"mcpServers":{"demo":{"command":"demo-mcp"}}}"#,    )?;    let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;    write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/plugins_123"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "id": "plugins_123",            "name": "demo-plugin",            "scope": "WORKSPACE",            "discoverability": "UNLISTED",            "creator_account_user_id": "user-owner__account-123",            "creator_name": "Owner",            "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-editor__account-123",                    "role": "editor",                    "name": "Editor",                },            ],            "installation_policy": "AVAILABLE",            "authentication_policy": "ON_USE",            "release": {                "version": "1.2.4",                "display_name": "Demo Plugin",                "description": "Shared local plugin",                "app_ids": [],                "keywords": [],                "interface": {},                "skills": []            }        })))        .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_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            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.summary.remote_plugin_id, None);    assert_eq!(        response.plugin.summary.local_version.as_deref(),        Some("1.2.3")    );    let share_context = response        .plugin        .summary        .share_context        .as_ref()        .expect("expected share context");    assert_eq!(share_context.remote_plugin_id, "plugins_123");    assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));    assert_eq!(        share_context.discoverability,        Some(PluginShareDiscoverability::Unlisted)    );    assert_eq!(        share_context.share_url.as_deref(),        Some("https://chatgpt.example/plugins/share/share-key-1")    );    assert_eq!(        share_context.creator_account_user_id.as_deref(),        Some("user-owner__account-123")    );    assert_eq!(share_context.creator_name.as_deref(), Some("Owner"));    assert_eq!(        share_context.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-editor__account-123".to_string(),                role: PluginSharePrincipalRole::Editor,                name: "Editor".to_string(),            },        ])    );    Ok(())}#[tokio::test]async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> {    let codex_home = TempDir::new()?;    let repo_root = TempDir::new()?;    let server = MockServer::start().await;    write_remote_plugin_catalog_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_plugin_marketplace(        repo_root.path(),        "codex-curated",        "demo-plugin",        "./demo-plugin",    )?;    std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;    std::fs::write(        repo_root            .path()            .join("demo-plugin/.codex-plugin/plugin.json"),        r#"{"name":"demo-plugin","version":"1.2.3"}"#,    )?;    std::fs::write(        repo_root.path().join("demo-plugin/.mcp.json"),        r#"{"mcpServers":{"demo":{"command":"demo-mcp"}}}"#,    )?;    let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;    write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;    Mock::given(method("GET"))        .and(path("/backend-api/ps/plugins/plugins_123"))        .and(header("authorization", "Bearer chatgpt-token"))        .and(header("chatgpt-account-id", "account-123"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "id": "plugins_123",            "name": "demo-plugin",            "scope": "WORKSPACE",            "discoverability": "UNLISTED",            "creator_account_user_id": "user-owner__account-123",            "creator_name": "Owner",            "share_url": "https://chatgpt.example/plugins/share/share-key-1",            "share_principals": null,            "installation_policy": "AVAILABLE",            "authentication_policy": "ON_USE",            "release": {                "version": "1.2.4",                "display_name": "Demo Plugin",                "description": "Shared local plugin",                "app_ids": [],                "keywords": [],                "interface": {},                "skills": []            }        })))        .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_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            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.summary.remote_plugin_id, None);    assert_eq!(        response.plugin.summary.local_version.as_deref(),        Some("1.2.3")    );    let share_context = response        .plugin        .summary        .share_context        .as_ref()        .expect("expected share context");    assert_eq!(share_context.remote_plugin_id, "plugins_123");    assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));    assert_eq!(share_context.discoverability, None);    assert_eq!(share_context.share_url, None);    assert_eq!(share_context.creator_account_user_id, None);    assert_eq!(share_context.creator_name, None);    assert_eq!(share_context.share_principals, None);    Ok(())}#[tokio::test]async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> {    let codex_home = TempDir::new()?;    let repo_root = TempDir::new()?;    write_plugins_enabled_config(&codex_home)?;    write_plugin_marketplace(        repo_root.path(),        "codex-curated",        "demo-plugin",        "./demo-plugin",    )?;    write_plugin_source(repo_root.path(), "demo-plugin", &[])?;    let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;    write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            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.summary.remote_plugin_id, None);    assert_eq!(response.plugin.summary.local_version, None);    let share_context = response        .plugin        .summary        .share_context        .as_ref()        .expect("expected share context");    assert_eq!(share_context.remote_plugin_id, "plugins_123");    assert_eq!(share_context.remote_version, None);    assert_eq!(share_context.discoverability, None);    assert_eq!(share_context.share_url, None);    assert_eq!(share_context.creator_account_user_id, None);    assert_eq!(share_context.creator_name, None);    assert_eq!(share_context.share_principals, None);    Ok(())}#[tokio::test]async fn plugin_read_fails_on_malformed_share_mapping() -> Result<()> {    let codex_home = TempDir::new()?;    let repo_root = TempDir::new()?;    write_plugins_enabled_config(&codex_home)?;    write_plugin_marketplace(        repo_root.path(),        "codex-curated",        "demo-plugin",        "./demo-plugin",    )?;    write_plugin_source(repo_root.path(), "demo-plugin", &[])?;    std::fs::create_dir_all(codex_home.path().join(".tmp"))?;    std::fs::write(        codex_home            .path()            .join(".tmp/plugin-share-local-paths-v1.json"),        "not valid json\n",    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            plugin_name: "demo-plugin".to_string(),        })        .await?;    let error: JSONRPCError = timeout(        DEFAULT_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(error.error.code, -32603);    assert!(        error            .error            .message            .contains("failed to load plugin share local path mapping")    );    Ok(())}#[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("hooks"))?;    std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?;    std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?;    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"      },      "policy": {        "installation": "AVAILABLE",        "authentication": "ON_INSTALL"      },      "category": "Design"    }  ]}"#,    )?;    std::fs::write(        plugin_root.join(".codex-plugin/plugin.json"),        r##"{  "name": "demo-plugin",  "description": "Longer manifest description",  "keywords": ["api-key", "developer tools"],  "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": [      "Draft the reply",      "Find my next action"    ],    "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-summarizerdescription: Summarize email threads---# Thread Summarizer"#,    )?;    std::fs::write(        plugin_root.join("skills/chatgpt-only/SKILL.md"),        r#"---name: chatgpt-onlydescription: Visible only for ChatGPT---# ChatGPT Only"#,    )?;    std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer/agents"))?;    std::fs::write(        plugin_root.join("skills/thread-summarizer/agents/openai.yaml"),        r#"policy:  products:    - CODEX"#,    )?;    std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only/agents"))?;    std::fs::write(        plugin_root.join("skills/chatgpt-only/agents/openai.yaml"),        r#"policy:  products:    - CHATGPT"#,    )?;    std::fs::write(        plugin_root.join(".app.json"),        r#"{  "apps": {    "gmail": {      "id": "gmail",      "category": "Communication"    }  }}"#,    )?;    std::fs::write(        plugin_root.join(".mcp.json"),        r#"{  "mcpServers": {    "demo": {      "command": "demo-server"    }  }}"#,    )?;    std::fs::write(        plugin_root.join("hooks/hooks.json"),        r#"{  "hooks": {    "SessionStart": [      {        "hooks": [          {            "type": "command",            "command": "echo startup"          }        ]      }    ],    "PreToolUse": [      {        "hooks": [          {            "type": "command",            "command": "echo first"          },          {            "type": "command",            "command": "echo second"          }        ]      }    ]  }}"#,    )?;    std::fs::write(        codex_home.path().join("config.toml"),        r#"[features]plugins = true[[skills.config]]name = "demo-plugin:thread-summarizer"enabled = false[plugins."demo-plugin@codex-curated"]enabled = true[hooks.state."demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0"]enabled = false"#,    )?;    write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?;    let mut mcp = TestAppServer::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: Some(marketplace_path.clone()),            remote_marketplace_name: None,            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, Some(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            .summary            .interface            .as_ref()            .and_then(|interface| interface.default_prompt.clone()),        Some(vec![            "Draft the reply".to_string(),            "Find my next action".to_string()        ])    );    assert_eq!(        response.plugin.summary.keywords,        vec!["api-key".to_string(), "developer tools".to_string()]    );    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!(!response.plugin.skills[0].enabled);    assert_eq!(        response.plugin.hooks,        vec![            codex_app_server_protocol::PluginHookSummary {                key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(),                event_name: HookEventName::PreToolUse,            },            codex_app_server_protocol::PluginHookSummary {                key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(),                event_name: HookEventName::PreToolUse,            },            codex_app_server_protocol::PluginHookSummary {                key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(),                event_name: HookEventName::SessionStart,            },        ]    );    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.apps[0].category.as_deref(),        Some("Communication")    );    assert_eq!(response.plugin.mcp_servers.len(), 1);    assert_eq!(response.plugin.mcp_servers[0], "demo");    Ok(())}#[tokio::test]async fn plugin_read_returns_app_metadata_category() -> Result<()> {    let connectors = vec![        AppInfo {            id: "alpha".to_string(),            name: "Alpha".to_string(),            description: Some("Alpha connector".to_string()),            logo_url: Some("https://example.com/alpha.png".to_string()),            logo_url_dark: None,            distribution_channel: Some("featured".to_string()),            branding: None,            app_metadata: Some(AppMetadata {                review: None,                categories: Some(vec!["Productivity".to_string()]),                sub_categories: None,                seo_description: None,                screenshots: None,                developer: None,                version: None,                version_id: None,                version_notes: None,                first_party_type: None,                first_party_requires_install: None,                show_in_composer_when_unlinked: None,            }),            labels: None,            install_url: None,            is_accessible: false,            is_enabled: true,            plugin_display_names: Vec::new(),        },        AppInfo {            id: "beta".to_string(),            name: "Beta".to_string(),            description: Some("Beta connector".to_string()),            logo_url: None,            logo_url_dark: None,            distribution_channel: None,            branding: None,            app_metadata: None,            labels: None,            install_url: None,            is_accessible: false,            is_enabled: true,            plugin_display_names: Vec::new(),        },    ];    let (server_url, server_handle) = start_apps_server(connectors).await?;    let codex_home = TempDir::new()?;    write_connectors_config(codex_home.path(), &server_url)?;    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",    )?;    write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;    let marketplace_path =        AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(marketplace_path),            remote_marketplace_name: None,            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: PluginReadResponse = to_response(response)?;    assert_eq!(        response            .plugin            .apps            .iter()            .map(|app| (app.id.as_str(), app.category.as_deref()))            .collect::<Vec<_>>(),        vec![("alpha", Some("Productivity")), ("beta", None)]    );    server_handle.abort();    let _ = server_handle.await;    Ok(())}#[tokio::test]async fn plugin_read_hides_apps_for_api_key_auth() -> Result<()> {    let connectors = vec![AppInfo {        id: "alpha".to_string(),        name: "Alpha".to_string(),        description: Some("Alpha connector".to_string()),        logo_url: Some("https://example.com/alpha.png".to_string()),        logo_url_dark: None,        distribution_channel: Some("featured".to_string()),        branding: None,        app_metadata: Some(AppMetadata {            review: None,            categories: Some(vec!["Productivity".to_string()]),            sub_categories: None,            seo_description: None,            screenshots: None,            developer: None,            version: None,            version_id: None,            version_notes: None,            first_party_type: None,            first_party_requires_install: None,            show_in_composer_when_unlinked: None,        }),        labels: None,        install_url: None,        is_accessible: false,        is_enabled: true,        plugin_display_names: Vec::new(),    }];    let (server_url, server_handle) = start_apps_server(connectors).await?;    let codex_home = TempDir::new()?;    write_connectors_config(codex_home.path(), &server_url)?;    std::fs::write(        codex_home.path().join("auth.json"),        r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#,    )?;    let repo_root = TempDir::new()?;    write_plugin_marketplace(        repo_root.path(),        "debug",        "sample-plugin",        "./sample-plugin",    )?;    write_plugin_source(repo_root.path(), "sample-plugin", &["alpha"])?;    std::fs::write(        repo_root.path().join("sample-plugin/.mcp.json"),        r#"{"mcpServers":{"alpha":{"command":"alpha-mcp"}}}"#,    )?;    let marketplace_path =        AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("CODEX_ACCESS_TOKEN", None),            ("CODEX_API_KEY", None),            ("OPENAI_API_KEY", None),        ],    )    .await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(marketplace_path),            remote_marketplace_name: None,            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: PluginReadResponse = to_response(response)?;    assert!(response.plugin.apps.is_empty());    assert_eq!(response.plugin.mcp_servers, vec!["alpha".to_string()]);    server_handle.abort();    let _ = server_handle.await;    Ok(())}#[tokio::test]async fn plugin_read_accepts_legacy_string_default_prompt() -> 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::write(        repo_root.path().join(".agents/plugins/marketplace.json"),        r#"{  "name": "codex-curated",  "plugins": [    {      "name": "demo-plugin",      "source": {        "source": "local",        "path": "./plugins/demo-plugin"      }    }  ]}"#,    )?;    std::fs::write(        plugin_root.join(".codex-plugin/plugin.json"),        r##"{  "name": "demo-plugin",  "interface": {    "defaultPrompt": "Starter prompt for trying a plugin"  }}"##,    )?;    write_plugins_enabled_config(&codex_home)?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            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            .summary            .interface            .as_ref()            .and_then(|interface| interface.default_prompt.clone()),        Some(vec!["Starter prompt for trying a plugin".to_string()])    );    Ok(())}#[tokio::test]async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Result<()> {    let codex_home = TempDir::new()?;    let repo_root = TempDir::new()?;    let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo");    let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)        .unwrap()        .to_string();    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"),        format!(            r#"{{  "name": "debug",  "plugins": [    {{      "name": "toolkit",      "source": {{        "source": "git-subdir",        "url": "{missing_remote_repo_url}",        "path": "plugins/toolkit"      }}    }}  ]}}"#        ),    )?;    write_plugins_enabled_config(&codex_home)?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            plugin_name: "toolkit".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)?;    let expected_description = format!(        "This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`."    );    assert_eq!(        response.plugin.description.as_deref(),        Some(expected_description.as_str())    );    assert!(!response.plugin.summary.installed);    assert!(response.plugin.skills.is_empty());    assert!(response.plugin.apps.is_empty());    assert!(response.plugin.mcp_servers.is_empty());    assert!(        !codex_home            .path()            .join("plugins/.marketplace-plugin-source-staging")            .exists()    );    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"      }    }  ]}"#,    )?;    write_plugins_enabled_config(&codex_home)?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            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"      }    }  ]}"#,    )?;    write_plugins_enabled_config(&codex_home)?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_plugin_read_request(PluginReadParams {            marketplace_path: Some(AbsolutePathBuf::try_from(                repo_root.path().join(".agents/plugins/marketplace.json"),            )?),            remote_marketplace_name: None,            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 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(())}fn write_plugins_enabled_config(codex_home: &TempDir) -> Result<()> {    std::fs::write(        codex_home.path().join("config.toml"),        r#"[features]plugins = true"#,    )?;    Ok(())}#[derive(Clone)]struct AppsServerState {    response: Arc<StdMutex<serde_json::Value>>,}async fn start_apps_server(connectors: Vec<AppInfo>) -> Result<(String, JoinHandle<()>)> {    let state = Arc::new(AppsServerState {        response: Arc::new(StdMutex::new(            json!({ "apps": connectors, "next_token": null }),        )),    });    let listener = TcpListener::bind("127.0.0.1:0").await?;    let addr = listener.local_addr()?;    let router = Router::new()        .route("/connectors/directory/list", get(list_directory_connectors))        .route(            "/connectors/directory/list_workspace",            get(list_directory_connectors),        )        .with_state(state);    let handle = tokio::spawn(async move {        let _ = axum::serve(listener, router).await;    });    Ok((format!("http://{addr}"), handle))}async fn list_directory_connectors(    State(state): State<Arc<AppsServerState>>,    headers: HeaderMap,    uri: Uri,) -> Result<impl axum::response::IntoResponse, StatusCode> {    let bearer_ok = headers        .get(AUTHORIZATION)        .and_then(|value| value.to_str().ok())        .is_some_and(|value| value == "Bearer chatgpt-token");    let account_ok = headers        .get("chatgpt-account-id")        .and_then(|value| value.to_str().ok())        .is_some_and(|value| value == "account-123");    let external_logos_ok = uri        .query()        .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));    if !bearer_ok || !account_ok {        Err(StatusCode::UNAUTHORIZED)    } else if !external_logos_ok {        Err(StatusCode::BAD_REQUEST)    } else {        let response = state            .response            .lock()            .unwrap_or_else(std::sync::PoisonError::into_inner)            .clone();        Ok(Json(response))    }}fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {    std::fs::write(        codex_home.join("config.toml"),        format!(            r#"chatgpt_base_url = "{base_url}"cli_auth_credentials_store = "file"mcp_oauth_credentials_store = "file"[features]plugins = trueconnectors = true"#        ),    )}fn write_remote_plugin_catalog_config(    codex_home: &std::path::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"#        ),    )}fn write_plugin_marketplace(    repo_root: &std::path::Path,    marketplace_name: &str,    plugin_name: &str,    source_path: &str,) -> std::io::Result<()> {    std::fs::create_dir_all(repo_root.join(".git"))?;    std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;    std::fs::write(        repo_root.join(".agents/plugins/marketplace.json"),        format!(            r#"{{  "name": "{marketplace_name}",  "plugins": [    {{      "name": "{plugin_name}",      "source": {{        "source": "local",        "path": "{source_path}"      }}    }}  ]}}"#        ),    )}fn write_plugin_source(    repo_root: &std::path::Path,    plugin_name: &str,    app_ids: &[&str],) -> Result<()> {    let plugin_root = repo_root.join(plugin_name);    std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;    std::fs::write(        plugin_root.join(".codex-plugin/plugin.json"),        format!(r#"{{"name":"{plugin_name}"}}"#),    )?;    let apps = app_ids        .iter()        .map(|app_id| ((*app_id).to_string(), json!({ "id": app_id })))        .collect::<serde_json::Map<_, _>>();    std::fs::write(        plugin_root.join(".app.json"),        serde_json::to_vec_pretty(&json!({ "apps": apps }))?,    )?;    Ok(())}fn write_plugin_share_local_path_mapping(    codex_home: &std::path::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)?;    std::fs::create_dir_all(codex_home.join(".tmp"))?;    std::fs::write(        codex_home.join(".tmp/plugin-share-local-paths-v1.json"),        format!("{contents}\n"),    )}