Files
codex/codex-rs/app-server/tests/suite/v2/plugin_share.rs
acrognale-oai a4354e2d27 Expose selected workspace routing in app-server account reads (#45529)
## What changed

- Add experimental `account/read.workspaceRouting` metadata containing the selected ChatGPT workspace ID, resolved HTTPS backend origin, and routing override (`us`, `us_cr`, or `NO_CONSTRAINT`).
- Discover and cache routing through `accounts/check` for saved logins, new logins, and workspace switches. Return `null` for signed-out accounts, API-only accounts, and saved credentials without a selected workspace.
- Validate discovered origins against required `chatgpt_base_url` origins. Return errors for failed or malformed discovery and retry on later reads.
- Wait for requirements and routing before publishing account updates, including to newly initialized connections. Clear routing on logout, discard stale discovery results, and guard queued notifications against account changes.

## Testing

Add unit and integration coverage for origin resolution and validation, discovery failures and retries, startup discovery, workspace switching, logout, configuration changes during discovery, and authentication changes while notifications wait for queue capacity.

GitOrigin-RevId: 2c5367bb01dd7543b08d374f44444323e40f1981
2026-09-14 22:31:32 +00:00

1551 lines
52 KiB
Rust

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::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCError;
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",
"can_publish_to_workspace": true,
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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: PluginShareSaveResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response,
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
can_publish_to_workspace: Some(true),
}
);
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: PluginShareListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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()),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
installed: true,
installed_at: None,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
must_show_installation_interstitial: Some(false),
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
disabled_reason: None,
eligible_plan_types: None,
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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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: PluginShareSaveResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response,
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
can_publish_to_workspace: None,
}
);
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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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 = true
plugin_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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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")
.iter()
.all(|request| request.url.path() == "/backend-api/wham/accounts/check")
);
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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_raw_request("plugin/share/list", Some(json!({})))
.await?;
let response: PluginShareListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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()),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
installed: true,
installed_at: None,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
must_show_installation_interstitial: Some(false),
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
disabled_reason: None,
eligible_plan_types: None,
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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("HOME", Some(home_env.as_str())),
("USERPROFILE", Some(home_env.as_str())),
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
])
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_raw_request(
"plugin/share/checkout",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let response: PluginShareCheckoutResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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,
]),
force_refetch: false,
})
.await?;
let response: PluginListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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: PluginShareCheckoutResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("HOME", Some(home_env.as_str())),
("USERPROFILE", Some(home_env.as_str())),
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
])
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_env_overrides(&[
("HOME", Some(home_env.as_str())),
("USERPROFILE", Some(home_env.as_str())),
(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1")),
])
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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: PluginShareUpdateTargetsResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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_publishes_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,
)?;
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": "LISTED",
"targets": [],
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"principals": [
{
"principal_type": "user",
"principal_id": "owner-1",
"role": "owner",
"name": "Owner",
},
],
"discoverability": "LISTED",
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_raw_request(
"plugin/share/updateTargets",
Some(json!({
"remotePluginId": "plugins_123",
"discoverability": "LISTED",
"shareTargets": [],
})),
)
.await?;
let response: PluginShareUpdateTargetsResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
response,
PluginShareUpdateTargetsResponse {
principals: vec![PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "owner-1".to_string(),
role: PluginSharePrincipalRole::Owner,
name: "Owner".to_string(),
}],
discoverability: codex_app_server_protocol::PluginShareDiscoverability::Listed,
}
);
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 = true
plugin_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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.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::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = mcp
.send_raw_request(
"plugin/share/delete",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let response: PluginShareDeleteResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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: PluginShareListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
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()),
version: Some("0.1.0".to_string()),
local_version: Some("0.1.0".to_string()),
name: "demo-plugin".to_string(),
share_context: Some(expected_share_context("plugins_123")),
source: PluginSource::Remote,
installed: true,
installed_at: None,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
must_show_installation_interstitial: Some(false),
auth_policy: PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
disabled_reason: None,
eligible_plan_types: None,
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 = 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",
"can_publish_to_workspace": true,
"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",
"must_show_installation_interstitial": false,
"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_dark: None,
logo_url: None,
logo_url_dark: 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(),
},
]),
can_publish_to_workspace: Some(true),
}
}
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)
}