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