feat: support remote_sync for plugin install/uninstall. (#14878)
- Added forceRemoteSync to plugin/install and plugin/uninstall. - With forceRemoteSync=true, we update the remote plugin status first, then apply the local change only if the backend call succeeds. - Kept plugin/list(forceRemoteSync=true) as the main recon path, and for now it treats remote enabled=false as uninstall. We will eventually migrate to plugin/installed for more precise state handling.
This commit is contained in:
parent
49c2b66ece
commit
1d85fe79ed
17 changed files with 743 additions and 101 deletions
|
|
@ -1280,6 +1280,10 @@
|
|||
},
|
||||
"PluginInstallParams": {
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
|
|
@ -1329,6 +1333,10 @@
|
|||
},
|
||||
"PluginUninstallParams": {
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9180,6 +9180,10 @@
|
|||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
|
|
@ -9500,6 +9504,10 @@
|
|||
"PluginUninstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5968,6 +5968,10 @@
|
|||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
|
|
@ -6288,6 +6292,10 @@
|
|||
"PluginUninstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@
|
|||
}
|
||||
},
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,8 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string, };
|
||||
export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string,
|
||||
/**
|
||||
* When true, apply the remote plugin change before the local install flow.
|
||||
*/
|
||||
forceRemoteSync?: boolean, };
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@
|
|||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginUninstallParams = { pluginId: string, };
|
||||
export type PluginUninstallParams = { pluginId: string,
|
||||
/**
|
||||
* When true, apply the remote plugin change before the local uninstall flow.
|
||||
*/
|
||||
forceRemoteSync?: boolean, };
|
||||
|
|
|
|||
|
|
@ -3396,6 +3396,9 @@ pub struct SkillsConfigWriteResponse {
|
|||
pub struct PluginInstallParams {
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
pub plugin_name: String,
|
||||
/// When true, apply the remote plugin change before the local install flow.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub force_remote_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
|
@ -3411,6 +3414,9 @@ pub struct PluginInstallResponse {
|
|||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginUninstallParams {
|
||||
pub plugin_id: String,
|
||||
/// When true, apply the remote plugin change before the local uninstall flow.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub force_remote_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
|
@ -7571,6 +7577,69 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_install_params_serialization_uses_force_remote_sync() {
|
||||
let marketplace_path = if cfg!(windows) {
|
||||
r"C:\plugins\marketplace.json"
|
||||
} else {
|
||||
"/plugins/marketplace.json"
|
||||
};
|
||||
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
|
||||
let marketplace_path_json = marketplace_path.as_path().display().to_string();
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginInstallParams {
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
plugin_name: "gmail".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"pluginName": "gmail",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "gmail".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"pluginName": "gmail",
|
||||
"forceRemoteSync": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_uninstall_params_serialization_uses_force_remote_sync() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginUninstallParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"pluginId": "gmail@openai-curated",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginUninstallParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"pluginId": "gmail@openai-curated",
|
||||
"forceRemoteSync": true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_error_info_serializes_http_status_code_in_camel_case() {
|
||||
let value = CodexErrorInfo::ResponseTooManyFailedAttempts {
|
||||
|
|
|
|||
|
|
@ -5679,6 +5679,7 @@ impl CodexMessageProcessor {
|
|||
let PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name,
|
||||
force_remote_sync,
|
||||
} = params;
|
||||
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
|
||||
|
||||
|
|
@ -5688,7 +5689,23 @@ impl CodexMessageProcessor {
|
|||
marketplace_path,
|
||||
};
|
||||
|
||||
match plugins_manager.install_plugin(request).await {
|
||||
let install_result = if force_remote_sync {
|
||||
let config = match self.load_latest_config(config_cwd.clone()).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let auth = self.auth_manager.auth().await;
|
||||
plugins_manager
|
||||
.install_plugin_with_remote_sync(&config, auth.as_ref(), request)
|
||||
.await
|
||||
} else {
|
||||
plugins_manager.install_plugin(request).await
|
||||
};
|
||||
|
||||
match install_result {
|
||||
Ok(result) => {
|
||||
let config = match self.load_latest_config(config_cwd).await {
|
||||
Ok(config) => config,
|
||||
|
|
@ -5789,6 +5806,13 @@ impl CodexMessageProcessor {
|
|||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginInstallError::Remote(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to enable remote plugin: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginInstallError::Join(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
|
|
@ -5813,9 +5837,29 @@ impl CodexMessageProcessor {
|
|||
request_id: ConnectionRequestId,
|
||||
params: PluginUninstallParams,
|
||||
) {
|
||||
let PluginUninstallParams {
|
||||
plugin_id,
|
||||
force_remote_sync,
|
||||
} = params;
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
|
||||
match plugins_manager.uninstall_plugin(params.plugin_id).await {
|
||||
let uninstall_result = if force_remote_sync {
|
||||
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let auth = self.auth_manager.auth().await;
|
||||
plugins_manager
|
||||
.uninstall_plugin_with_remote_sync(&config, auth.as_ref(), plugin_id)
|
||||
.await
|
||||
} else {
|
||||
plugins_manager.uninstall_plugin(plugin_id).await
|
||||
};
|
||||
|
||||
match uninstall_result {
|
||||
Ok(()) => {
|
||||
self.clear_plugin_related_caches();
|
||||
self.outgoing
|
||||
|
|
@ -5837,6 +5881,13 @@ impl CodexMessageProcessor {
|
|||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginUninstallError::Remote(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to uninstall remote plugin: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginUninstallError::Join(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ 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;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
|
|
@ -86,6 +92,7 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -
|
|||
codex_home.path().join("missing-marketplace.json"),
|
||||
)?,
|
||||
plugin_name: "missing-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
@ -124,6 +131,7 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
|
|||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
@ -138,6 +146,76 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()>
|
||||
{
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_plugin_remote_sync_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 repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/plugins/sample-plugin@debug/enable"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#),
|
||||
)
|
||||
.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_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
let analytics_server = start_analytics_events_server().await?;
|
||||
|
|
@ -172,6 +250,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
|||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
|
|
@ -285,6 +364,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
|||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
@ -367,6 +447,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
|||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
@ -549,6 +630,23 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::
|
|||
)
|
||||
}
|
||||
|
||||
fn write_plugin_remote_sync_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 = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_marketplace(
|
||||
repo_root: &std::path::Path,
|
||||
marketplace_name: &str,
|
||||
|
|
|
|||
|
|
@ -630,6 +630,7 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
|
|||
)?;
|
||||
write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail", "calendar"])?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "gmail")?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "calendar")?;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
|
|
@ -676,14 +677,14 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
|
|||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("linear@openai-curated".to_string(), true, true),
|
||||
("gmail@openai-curated".to_string(), true, false),
|
||||
("gmail@openai-curated".to_string(), false, false),
|
||||
("calendar@openai-curated".to_string(), false, false),
|
||||
]
|
||||
);
|
||||
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
|
||||
assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#));
|
||||
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
|
||||
assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#));
|
||||
|
||||
assert!(
|
||||
|
|
@ -693,12 +694,10 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
|
|||
.is_dir()
|
||||
);
|
||||
assert!(
|
||||
codex_home
|
||||
!codex_home
|
||||
.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
.join("plugins/cache/openai-curated/gmail")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!codex_home
|
||||
|
|
@ -741,6 +740,9 @@ plugins = true
|
|||
[plugins."linear@openai-curated"]
|
||||
enabled = false
|
||||
|
||||
[plugins."gmail@openai-curated"]
|
||||
enabled = false
|
||||
|
||||
[plugins."calendar@openai-curated"]
|
||||
enabled = true
|
||||
"#
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ 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::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
|
|
@ -38,6 +44,7 @@ enabled = true
|
|||
|
||||
let params = PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
force_remote_sync: false,
|
||||
};
|
||||
|
||||
let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?;
|
||||
|
|
@ -70,6 +77,74 @@ enabled = true
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_installed_plugin(&codex_home, "debug", "sample-plugin")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"chatgpt_base_url = "{}/backend-api/"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample-plugin@debug"]
|
||||
enabled = 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,
|
||||
)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/plugins/sample-plugin@debug/uninstall"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#),
|
||||
)
|
||||
.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_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginUninstallResponse = to_response(response)?;
|
||||
assert_eq!(response, PluginUninstallResponse {});
|
||||
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/debug/sample-plugin")
|
||||
.exists()
|
||||
);
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
||||
let analytics_server = start_analytics_events_server().await?;
|
||||
|
|
@ -97,6 +172,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
|||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@ use super::marketplace::MarketplaceError;
|
|||
use super::marketplace::MarketplacePluginAuthPolicy;
|
||||
use super::marketplace::MarketplacePluginInstallPolicy;
|
||||
use super::marketplace::MarketplacePluginSourceSummary;
|
||||
use super::marketplace::ResolvedMarketplacePlugin;
|
||||
use super::marketplace::list_marketplaces;
|
||||
use super::marketplace::load_marketplace_summary;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::plugin_manifest_paths;
|
||||
use super::read_curated_plugins_sha;
|
||||
use super::remote::RemotePluginFetchError;
|
||||
use super::remote::RemotePluginMutationError;
|
||||
use super::remote::enable_remote_plugin;
|
||||
use super::remote::fetch_remote_plugin_status;
|
||||
use super::remote::uninstall_remote_plugin;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
|
|
@ -31,7 +37,6 @@ use crate::config::profile::ConfigProfile;
|
|||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::features::Feature;
|
||||
use crate::features::FeatureOverrides;
|
||||
use crate::features::Features;
|
||||
|
|
@ -55,7 +60,6 @@ use std::sync::Arc;
|
|||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use toml_edit::value;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
|
@ -64,7 +68,6 @@ const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
|||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
const REMOTE_PLUGIN_SYNC_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
|
|
@ -311,6 +314,7 @@ pub struct RemotePluginSyncResult {
|
|||
/// Plugin ids whose local config was changed to enabled.
|
||||
pub enabled_plugin_ids: Vec<String>,
|
||||
/// Plugin ids whose local config was changed to disabled.
|
||||
/// This is not populated by `sync_plugins_from_remote`.
|
||||
pub disabled_plugin_ids: Vec<String>,
|
||||
/// Plugin ids removed from local cache or plugin config.
|
||||
pub uninstalled_plugin_ids: Vec<String>,
|
||||
|
|
@ -384,29 +388,24 @@ pub enum PluginRemoteSyncError {
|
|||
}
|
||||
|
||||
impl PluginRemoteSyncError {
|
||||
fn auth_token(source: std::io::Error) -> Self {
|
||||
Self::AuthToken(source)
|
||||
}
|
||||
|
||||
fn request(url: String, source: reqwest::Error) -> Self {
|
||||
Self::Request { url, source }
|
||||
}
|
||||
|
||||
fn join(source: tokio::task::JoinError) -> Self {
|
||||
Self::Join(source)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemotePluginStatusSummary {
|
||||
name: String,
|
||||
#[serde(default = "default_remote_marketplace_name")]
|
||||
marketplace_name: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn default_remote_marketplace_name() -> String {
|
||||
OPENAI_CURATED_MARKETPLACE_NAME.to_string()
|
||||
impl From<RemotePluginFetchError> for PluginRemoteSyncError {
|
||||
fn from(value: RemotePluginFetchError) -> Self {
|
||||
match value {
|
||||
RemotePluginFetchError::AuthRequired => Self::AuthRequired,
|
||||
RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode,
|
||||
RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source),
|
||||
RemotePluginFetchError::Request { url, source } => Self::Request { url, source },
|
||||
RemotePluginFetchError::UnexpectedStatus { url, status, body } => {
|
||||
Self::UnexpectedStatus { url, status, body }
|
||||
}
|
||||
RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PluginsManager {
|
||||
|
|
@ -486,6 +485,30 @@ impl PluginsManager {
|
|||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
|
||||
self.install_resolved_plugin(resolved).await
|
||||
}
|
||||
|
||||
pub async fn install_plugin_with_remote_sync(
|
||||
&self,
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
|
||||
let plugin_id = resolved.plugin_id.as_key();
|
||||
// This only forwards the backend mutation before the local install flow. We rely on
|
||||
// `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra
|
||||
// reconcile pass here.
|
||||
enable_remote_plugin(config, auth, &plugin_id)
|
||||
.await
|
||||
.map_err(PluginInstallError::from)?;
|
||||
self.install_resolved_plugin(resolved).await
|
||||
}
|
||||
|
||||
async fn install_resolved_plugin(
|
||||
&self,
|
||||
resolved: ResolvedMarketplacePlugin,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let auth_policy = resolved.auth_policy;
|
||||
let plugin_version =
|
||||
if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
|
|
@ -545,6 +568,27 @@ impl PluginsManager {
|
|||
|
||||
pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> {
|
||||
let plugin_id = PluginId::parse(&plugin_id)?;
|
||||
self.uninstall_plugin_id(plugin_id).await
|
||||
}
|
||||
|
||||
pub async fn uninstall_plugin_with_remote_sync(
|
||||
&self,
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: String,
|
||||
) -> Result<(), PluginUninstallError> {
|
||||
let plugin_id = PluginId::parse(&plugin_id)?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
// This only forwards the backend mutation before the local uninstall flow. We rely on
|
||||
// `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra
|
||||
// reconcile pass here.
|
||||
uninstall_remote_plugin(config, auth, &plugin_key)
|
||||
.await
|
||||
.map_err(PluginUninstallError::from)?;
|
||||
self.uninstall_plugin_id(plugin_id).await
|
||||
}
|
||||
|
||||
async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> {
|
||||
let plugin_telemetry = self
|
||||
.store
|
||||
.active_plugin_root(&plugin_id)
|
||||
|
|
@ -581,7 +625,9 @@ impl PluginsManager {
|
|||
auth: Option<&CodexAuth>,
|
||||
) -> Result<RemotePluginSyncResult, PluginRemoteSyncError> {
|
||||
info!("starting remote plugin sync");
|
||||
let remote_plugins = fetch_remote_plugin_status(config, auth).await?;
|
||||
let remote_plugins = fetch_remote_plugin_status(config, auth)
|
||||
.await
|
||||
.map_err(PluginRemoteSyncError::from)?;
|
||||
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
|
||||
let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
let curated_marketplace_path = AbsolutePathBuf::try_from(
|
||||
|
|
@ -640,7 +686,7 @@ impl PluginsManager {
|
|||
));
|
||||
}
|
||||
|
||||
let mut remote_enabled_by_name = HashMap::<String, bool>::new();
|
||||
let mut remote_installed_plugin_names = HashSet::<String>::new();
|
||||
for plugin in remote_plugins {
|
||||
if plugin.marketplace_name != marketplace_name {
|
||||
return Err(PluginRemoteSyncError::UnknownRemoteMarketplace {
|
||||
|
|
@ -655,10 +701,13 @@ impl PluginsManager {
|
|||
);
|
||||
continue;
|
||||
}
|
||||
if remote_enabled_by_name
|
||||
.insert(plugin.name.clone(), plugin.enabled)
|
||||
.is_some()
|
||||
{
|
||||
// For now, sync treats remote `enabled = false` as uninstall rather than a distinct
|
||||
// disabled state.
|
||||
// TODO: Switch sync to `plugins/installed` so install and enable states stay distinct.
|
||||
if !plugin.enabled {
|
||||
continue;
|
||||
}
|
||||
if !remote_installed_plugin_names.insert(plugin.name.clone()) {
|
||||
return Err(PluginRemoteSyncError::DuplicateRemotePlugin {
|
||||
plugin_name: plugin.name,
|
||||
});
|
||||
|
|
@ -669,7 +718,7 @@ impl PluginsManager {
|
|||
let mut installs = Vec::new();
|
||||
let mut uninstalls = Vec::new();
|
||||
let mut result = RemotePluginSyncResult::default();
|
||||
let remote_plugin_count = remote_enabled_by_name.len();
|
||||
let remote_plugin_count = remote_installed_plugin_names.len();
|
||||
let local_plugin_count = local_plugins.len();
|
||||
|
||||
for (plugin_name, plugin_id, source_path, current_enabled, installed_version) in
|
||||
|
|
@ -677,7 +726,7 @@ impl PluginsManager {
|
|||
{
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let is_installed = installed_version.is_some();
|
||||
if let Some(enabled) = remote_enabled_by_name.get(&plugin_name).copied() {
|
||||
if remote_installed_plugin_names.contains(&plugin_name) {
|
||||
if !is_installed {
|
||||
installs.push((
|
||||
source_path,
|
||||
|
|
@ -689,16 +738,11 @@ impl PluginsManager {
|
|||
result.installed_plugin_ids.push(plugin_key.clone());
|
||||
}
|
||||
|
||||
if current_enabled != Some(enabled) {
|
||||
if enabled {
|
||||
result.enabled_plugin_ids.push(plugin_key.clone());
|
||||
} else {
|
||||
result.disabled_plugin_ids.push(plugin_key.clone());
|
||||
}
|
||||
|
||||
if current_enabled != Some(true) {
|
||||
result.enabled_plugin_ids.push(plugin_key.clone());
|
||||
config_edits.push(ConfigEdit::SetPath {
|
||||
segments: vec!["plugins".to_string(), plugin_key, "enabled".to_string()],
|
||||
value: value(enabled),
|
||||
value: value(true),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -990,52 +1034,14 @@ impl PluginsManager {
|
|||
}
|
||||
}
|
||||
|
||||
async fn fetch_remote_plugin_status(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginStatusSummary>, PluginRemoteSyncError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(PluginRemoteSyncError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(PluginRemoteSyncError::UnsupportedAuthMode);
|
||||
}
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/list");
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(PluginRemoteSyncError::auth_token)?;
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_PLUGIN_SYNC_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| PluginRemoteSyncError::request(url.clone(), source))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(PluginRemoteSyncError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
serde_json::from_str(&body).map_err(|source| PluginRemoteSyncError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginInstallError {
|
||||
#[error("{0}")]
|
||||
Marketplace(#[from] MarketplaceError),
|
||||
|
||||
#[error("{0}")]
|
||||
Remote(#[from] RemotePluginMutationError),
|
||||
|
||||
#[error("{0}")]
|
||||
Store(#[from] PluginStoreError),
|
||||
|
||||
|
|
@ -1070,6 +1076,9 @@ pub enum PluginUninstallError {
|
|||
#[error("{0}")]
|
||||
InvalidPluginId(#[from] PluginIdError),
|
||||
|
||||
#[error("{0}")]
|
||||
Remote(#[from] RemotePluginMutationError),
|
||||
|
||||
#[error("{0}")]
|
||||
Store(#[from] PluginStoreError),
|
||||
|
||||
|
|
|
|||
|
|
@ -1312,6 +1312,11 @@ async fn sync_plugins_from_remote_reconciles_cache_and_config() {
|
|||
"linear/local",
|
||||
"linear",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/openai-curated"),
|
||||
"gmail/local",
|
||||
"gmail",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/openai-curated"),
|
||||
"calendar/local",
|
||||
|
|
@ -1325,6 +1330,9 @@ plugins = true
|
|||
[plugins."linear@openai-curated"]
|
||||
enabled = false
|
||||
|
||||
[plugins."gmail@openai-curated"]
|
||||
enabled = false
|
||||
|
||||
[plugins."calendar@openai-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
|
|
@ -1358,10 +1366,13 @@ enabled = true
|
|||
assert_eq!(
|
||||
result,
|
||||
RemotePluginSyncResult {
|
||||
installed_plugin_ids: vec!["gmail@openai-curated".to_string()],
|
||||
installed_plugin_ids: Vec::new(),
|
||||
enabled_plugin_ids: vec!["linear@openai-curated".to_string()],
|
||||
disabled_plugin_ids: vec!["gmail@openai-curated".to_string()],
|
||||
uninstalled_plugin_ids: vec!["calendar@openai-curated".to_string()],
|
||||
disabled_plugin_ids: Vec::new(),
|
||||
uninstalled_plugin_ids: vec![
|
||||
"gmail@openai-curated".to_string(),
|
||||
"calendar@openai-curated".to_string(),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -1371,11 +1382,9 @@ enabled = true
|
|||
.is_dir()
|
||||
);
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
!tmp.path()
|
||||
.join("plugins/cache/openai-curated/gmail")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!tmp.path()
|
||||
|
|
@ -1385,9 +1394,8 @@ enabled = true
|
|||
|
||||
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
|
||||
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
|
||||
assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
assert!(config.contains("enabled = false"));
|
||||
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
|
||||
assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#));
|
||||
|
||||
let synced_config = load_config(tmp.path(), tmp.path()).await;
|
||||
|
|
@ -1405,7 +1413,7 @@ enabled = true
|
|||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("linear@openai-curated".to_string(), true, true),
|
||||
("gmail@openai-curated".to_string(), true, false),
|
||||
("gmail@openai-curated".to_string(), false, false),
|
||||
("calendar@openai-curated".to_string(), false, false),
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ mod injection;
|
|||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod remote;
|
||||
mod render;
|
||||
mod store;
|
||||
mod toggles;
|
||||
|
|
|
|||
266
codex-rs/core/src/plugins/remote.rs
Normal file
266
codex-rs/core/src/plugins/remote.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub(crate) struct RemotePluginStatusSummary {
|
||||
pub(crate) name: String,
|
||||
#[serde(default = "default_remote_marketplace_name")]
|
||||
pub(crate) marketplace_name: String,
|
||||
pub(crate) enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemotePluginMutationResponse {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginMutationError {
|
||||
#[error("chatgpt authentication required for remote plugin mutation")]
|
||||
AuthRequired,
|
||||
|
||||
#[error(
|
||||
"chatgpt authentication required for remote plugin mutation; api key auth is not supported"
|
||||
)]
|
||||
UnsupportedAuthMode,
|
||||
|
||||
#[error("failed to read auth token for remote plugin mutation: {0}")]
|
||||
AuthToken(#[source] std::io::Error),
|
||||
|
||||
#[error("invalid chatgpt base url for remote plugin mutation: {0}")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
#[error("chatgpt base url cannot be used for plugin mutation")]
|
||||
InvalidBaseUrlPath,
|
||||
|
||||
#[error("failed to send remote plugin mutation request to {url}: {source}")]
|
||||
Request {
|
||||
url: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
|
||||
UnexpectedStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
#[error("failed to parse remote plugin mutation response from {url}: {source}")]
|
||||
Decode {
|
||||
url: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
UnexpectedEnabledState {
|
||||
plugin_id: String,
|
||||
expected_enabled: bool,
|
||||
actual_enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RemotePluginFetchError {
|
||||
#[error("chatgpt authentication required to sync remote plugins")]
|
||||
AuthRequired,
|
||||
|
||||
#[error(
|
||||
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
|
||||
)]
|
||||
UnsupportedAuthMode,
|
||||
|
||||
#[error("failed to read auth token for remote plugin sync: {0}")]
|
||||
AuthToken(#[source] std::io::Error),
|
||||
|
||||
#[error("failed to send remote plugin sync request to {url}: {source}")]
|
||||
Request {
|
||||
url: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
|
||||
UnexpectedStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
#[error("failed to parse remote plugin sync response from {url}: {source}")]
|
||||
Decode {
|
||||
url: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_remote_plugin_status(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginFetchError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(RemotePluginFetchError::UnsupportedAuthMode);
|
||||
}
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/list");
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginFetchError::AuthToken)?;
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginFetchError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_remote_plugin(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginMutationError> {
|
||||
post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn uninstall_remote_plugin(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginMutationError> {
|
||||
post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginMutationError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(RemotePluginMutationError::UnsupportedAuthMode);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
fn default_remote_marketplace_name() -> String {
|
||||
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
|
||||
}
|
||||
|
||||
async fn post_remote_plugin_mutation(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
action: &str,
|
||||
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginMutationError::AuthToken)?;
|
||||
let mut request = client
|
||||
.post(url.clone())
|
||||
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginMutationError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
let parsed: RemotePluginMutationResponse =
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let expected_enabled = action == "enable";
|
||||
if parsed.id != plugin_id {
|
||||
return Err(RemotePluginMutationError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: parsed.id,
|
||||
});
|
||||
}
|
||||
if parsed.enabled != expected_enabled {
|
||||
return Err(RemotePluginMutationError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled,
|
||||
actual_enabled: parsed.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn remote_plugin_mutation_url(
|
||||
config: &Config,
|
||||
plugin_id: &str,
|
||||
action: &str,
|
||||
) -> Result<String, RemotePluginMutationError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginMutationError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("plugins");
|
||||
segments.push(plugin_id);
|
||||
segments.push(action);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
|
@ -339,6 +339,7 @@ class CodexErrorInfo(
|
|||
class CollabAgentStatus(Enum):
|
||||
pending_init = "pendingInit"
|
||||
running = "running"
|
||||
interrupted = "interrupted"
|
||||
completed = "completed"
|
||||
errored = "errored"
|
||||
shutdown = "shutdown"
|
||||
|
|
@ -746,6 +747,7 @@ class DynamicToolSpec(BaseModel):
|
|||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
defer_loading: Annotated[bool | None, Field(alias="deferLoading")] = None
|
||||
description: str
|
||||
input_schema: Annotated[Any, Field(alias="inputSchema")]
|
||||
name: str
|
||||
|
|
@ -1631,6 +1633,13 @@ class PluginInstallParams(BaseModel):
|
|||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
force_remote_sync: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
alias="forceRemoteSync",
|
||||
description="When true, apply the remote plugin change before the local install flow.",
|
||||
),
|
||||
] = None
|
||||
marketplace_path: Annotated[AbsolutePathBuf, Field(alias="marketplacePath")]
|
||||
plugin_name: Annotated[str, Field(alias="pluginName")]
|
||||
|
||||
|
|
@ -1657,7 +1666,13 @@ class PluginInterface(BaseModel):
|
|||
capabilities: list[str]
|
||||
category: str | None = None
|
||||
composer_icon: Annotated[AbsolutePathBuf | None, Field(alias="composerIcon")] = None
|
||||
default_prompt: Annotated[str | None, Field(alias="defaultPrompt")] = None
|
||||
default_prompt: Annotated[
|
||||
list[str] | None,
|
||||
Field(
|
||||
alias="defaultPrompt",
|
||||
description="Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
),
|
||||
] = None
|
||||
developer_name: Annotated[str | None, Field(alias="developerName")] = None
|
||||
display_name: Annotated[str | None, Field(alias="displayName")] = None
|
||||
logo: AbsolutePathBuf | None = None
|
||||
|
|
@ -1729,6 +1744,13 @@ class PluginUninstallParams(BaseModel):
|
|||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
)
|
||||
force_remote_sync: Annotated[
|
||||
bool | None,
|
||||
Field(
|
||||
alias="forceRemoteSync",
|
||||
description="When true, apply the remote plugin change before the local uninstall flow.",
|
||||
),
|
||||
] = None
|
||||
plugin_id: Annotated[str, Field(alias="pluginId")]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue