Forward install attempt IDs for remote plugins (#37773)

## Why

Clients need to correlate a remote plugin installation request with a specific
installation attempt.

## What changed

- Add the optional `installAttemptId` field to `PluginInstallParams`.
- Forward the value as `install_attempt_id` in the remote plugin install POST
  body.
- Preserve the existing empty request body when the field is omitted.

## Testing

- Cover protocol serialization and remote install request bodies with and
  without an attempt ID.

GitOrigin-RevId: ffdceff4f9329b650848e6428a9ba39646c242c3
This commit is contained in:
kylepatel-oai
2026-08-10 03:28:19 +00:00
committed by copyberry
parent c9c6c0daa9
commit 89a335ed50
16 changed files with 186 additions and 11 deletions
@@ -2179,6 +2179,13 @@
},
"PluginInstallParams": {
"properties": {
"installAttemptId": {
"description": "Client-generated identifier used to correlate one installation attempt.",
"type": [
"string",
"null"
]
},
"marketplacePath": {
"anyOf": [
{
@@ -14507,6 +14507,13 @@
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"installAttemptId": {
"description": "Client-generated identifier used to correlate one installation attempt.",
"type": [
"string",
"null"
]
},
"marketplacePath": {
"anyOf": [
{
@@ -10844,6 +10844,13 @@
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"installAttemptId": {
"description": "Client-generated identifier used to correlate one installation attempt.",
"type": [
"string",
"null"
]
},
"marketplacePath": {
"anyOf": [
{
@@ -7,6 +7,13 @@
}
},
"properties": {
"installAttemptId": {
"description": "Client-generated identifier used to correlate one installation attempt.",
"type": [
"string",
"null"
]
},
"marketplacePath": {
"anyOf": [
{
@@ -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 | null, remoteMarketplaceName?: string | null, pluginName: string, };
export type PluginInstallParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null,
/**
* Client-generated identifier used to correlate one installation attempt.
*/
installAttemptId?: string | null, pluginName: string, };
@@ -2118,6 +2118,7 @@ mod tests {
params: v2::PluginInstallParams {
marketplace_path: Some(absolute_path("/tmp/marketplace")),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "plugin-a".to_string(),
},
};
@@ -893,6 +893,9 @@ pub struct PluginInstallParams {
pub marketplace_path: Option<AbsolutePathBuf>,
#[ts(optional = nullable)]
pub remote_marketplace_name: Option<String>,
/// Client-generated identifier used to correlate one installation attempt.
#[ts(optional = nullable)]
pub install_attempt_id: Option<String>,
pub plugin_name: String,
}
@@ -3758,12 +3758,14 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
serde_json::to_value(PluginInstallParams {
marketplace_path: Some(marketplace_path.clone()),
remote_marketplace_name: None,
install_attempt_id: Some("94c79f7b-cceb-4415-9a3e-b51b2f718d43".to_string()),
plugin_name: "gmail".to_string(),
})
.unwrap(),
json!({
"marketplacePath": marketplace_path_json,
"remoteMarketplaceName": null,
"installAttemptId": "94c79f7b-cceb-4415-9a3e-b51b2f718d43",
"pluginName": "gmail",
}),
);
@@ -3778,6 +3780,7 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "gmail".to_string(),
},
);
@@ -3792,6 +3795,7 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated-remote".to_string()),
install_attempt_id: None,
plugin_name: "gmail".to_string(),
},
);
@@ -349,6 +349,7 @@ fn install_remote_plugin(client: &mut CodexClient, plugin: &RemotePluginExpectat
params: PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some(plugin.marketplace_name.clone()),
install_attempt_id: None,
plugin_name: plugin.remote_plugin_id.clone(),
},
},
+1 -1
View File
@@ -262,7 +262,7 @@ Example with notification opt-out:
- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth. For remote installs, clients may include an optional `installAttemptId`; app-server forwards it unchanged as `install_attempt_id` in the backend POST body, while omission preserves the legacy empty-body request (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `<plugin>@<marketplace>` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
@@ -1479,13 +1479,18 @@ impl PluginRequestProcessor {
let PluginInstallParams {
marketplace_path,
remote_marketplace_name,
install_attempt_id,
plugin_name,
} = params;
let marketplace_path = match (marketplace_path, remote_marketplace_name) {
(Some(marketplace_path), None) => marketplace_path,
(None, Some(remote_marketplace_name)) => {
return self
.remote_plugin_install_response(remote_marketplace_name, plugin_name)
.remote_plugin_install_response(
remote_marketplace_name,
plugin_name,
install_attempt_id,
)
.await;
}
(Some(_), Some(_)) | (None, None) => {
@@ -1580,6 +1585,7 @@ impl PluginRequestProcessor {
&self,
remote_marketplace_name: String,
remote_plugin_id: String,
install_attempt_id: Option<String>,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
@@ -1701,13 +1707,24 @@ impl PluginRequestProcessor {
// Cache first so a backend install cannot succeed when local materialization fails.
// If this backend call fails, the cache entry is harmless because remote installed state
// is still backend-gated.
let install_result = codex_core_plugins::remote::install_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&remote_plugin_id,
)
.await
let install_result = if let Some(install_attempt_id) = install_attempt_id.as_deref() {
codex_core_plugins::remote::install_remote_plugin_with_install_attempt_id(
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&remote_plugin_id,
install_attempt_id,
)
.await
} else {
codex_core_plugins::remote::install_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&remote_plugin_id,
)
.await
}
.map_err(|err| {
let error_type = remote_plugin_catalog_error_type(&err);
let sub_error_type = err.sub_error_type();
@@ -66,6 +66,7 @@ use wiremock::matchers::query_param;
// starts, which is noticeably slower on Windows CI.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000";
const INSTALL_ATTEMPT_ID: &str = "94c79f7b-cceb-4415-9a3e-b51b2f718d43";
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
@@ -112,6 +113,7 @@ async fn plugin_install_rejects_missing_install_source() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -146,6 +148,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> {
codex_home.path().join("marketplace.json"),
)?),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -184,6 +187,7 @@ plugins = false
.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated-remote".to_string()),
install_attempt_id: None,
plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(),
})
.await?;
@@ -268,6 +272,10 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
/*expected_count*/ 1,
)
.await?;
assert_eq!(
wait_for_remote_plugin_install_request_body(&server, REMOTE_PLUGIN_ID).await?,
Vec::<u8>::new()
);
wait_for_remote_plugin_request_count(
&server,
"GET",
@@ -535,6 +543,7 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated-remote".to_string()),
install_attempt_id: None,
plugin_name: "linear/../../oops".to_string(),
})
.await?;
@@ -840,6 +849,7 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -874,6 +884,7 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -
codex_home.path().join("missing-marketplace.json"),
)?),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "missing-plugin".to_string(),
})
.await?;
@@ -974,6 +985,7 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1027,6 +1039,7 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin()
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1079,6 +1092,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1144,6 +1158,7 @@ async fn plugin_install_failure_tracks_analytics_event() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1196,10 +1211,21 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
.await?;
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
let request_id = send_remote_plugin_install_request_with_attempt_id(
&mut mcp,
REMOTE_PLUGIN_ID,
INSTALL_ATTEMPT_ID,
)
.await?;
let response: PluginInstallResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
let request_body =
wait_for_remote_plugin_install_request_body(&server, REMOTE_PLUGIN_ID).await?;
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&request_body)?,
json!({"install_attempt_id": INSTALL_ATTEMPT_ID})
);
let payload = wait_for_plugin_analytics_payload(&server).await?;
assert_eq!(
@@ -1382,6 +1408,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1474,6 +1501,7 @@ async fn plugin_install_skips_mcp_oauth_for_chatgpt_dual_surface_plugin() -> Res
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1527,6 +1555,7 @@ url = "https://example.com/allowed-mcp"
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1581,6 +1610,7 @@ enabled = false
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1681,6 +1711,7 @@ url = {executor_url}
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1742,6 +1773,7 @@ async fn plugin_install_starts_mcp_oauth_with_formerly_disallowed_plugin_app() -
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1850,6 +1882,7 @@ async fn plugin_install_starts_mcp_oauth_through_configured_http_proxy() -> Resu
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -1913,6 +1946,7 @@ connectors = true
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -2165,6 +2199,7 @@ async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Resu
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -2249,6 +2284,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
install_attempt_id: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -2890,11 +2926,47 @@ async fn send_remote_plugin_install_request(
mcp.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
install_attempt_id: None,
plugin_name: remote_plugin_id.to_string(),
})
.await
}
async fn send_remote_plugin_install_request_with_attempt_id(
mcp: &mut TestAppServer,
remote_plugin_id: &str,
install_attempt_id: &str,
) -> Result<i64> {
mcp.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
install_attempt_id: Some(install_attempt_id.to_string()),
plugin_name: remote_plugin_id.to_string(),
})
.await
}
async fn wait_for_remote_plugin_install_request_body(
server: &MockServer,
remote_plugin_id: &str,
) -> Result<Vec<u8>> {
let path_suffix = format!("/ps/plugins/{remote_plugin_id}/install");
timeout(DEFAULT_TIMEOUT, async {
loop {
let Some(requests) = server.received_requests().await else {
bail!("wiremock did not record requests");
};
if let Some(request) = requests.iter().find(|request| {
request.method == "POST" && request.url.path().ends_with(&path_suffix)
}) {
return Ok::<Vec<u8>, anyhow::Error>(request.body.clone());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?
}
async fn wait_for_remote_plugin_request_count(
server: &MockServer,
method_name: &str,
+44
View File
@@ -1475,11 +1475,50 @@ fn app_declarations_from_remote_app_ids(app_ids: &[String]) -> Vec<AppDeclaratio
.collect()
}
#[derive(Serialize)]
struct RemotePluginInstallRequest<'a> {
install_attempt_id: &'a str,
}
pub async fn install_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
plugin_id: &str,
) -> Result<RemotePluginInstallResult, RemotePluginCatalogError> {
install_remote_plugin_inner(
config,
auth,
marketplace_name,
plugin_id,
/*install_attempt_id*/ None,
)
.await
}
pub async fn install_remote_plugin_with_install_attempt_id(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
plugin_id: &str,
install_attempt_id: &str,
) -> Result<RemotePluginInstallResult, RemotePluginCatalogError> {
install_remote_plugin_inner(
config,
auth,
marketplace_name,
plugin_id,
Some(install_attempt_id),
)
.await
}
async fn install_remote_plugin_inner(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
_marketplace_name: &str,
plugin_id: &str,
install_attempt_id: Option<&str>,
) -> Result<RemotePluginInstallResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
// Remote plugin IDs uniquely identify remote plugins, so the caller-provided
@@ -1492,6 +1531,11 @@ pub async fn install_remote_plugin(
.append_pair("includeAppsNeedingAuth", "true");
let url = url.to_string();
let request = authenticated_request(config.http_request(Method::POST, &url), auth);
let request = if let Some(install_attempt_id) = install_attempt_id {
request.json(&RemotePluginInstallRequest { install_attempt_id })
} else {
request
};
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
if response.id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
@@ -1134,6 +1134,7 @@ pub(super) async fn fetch_plugin_install(
params: PluginInstallParams {
marketplace_path,
remote_marketplace_name,
install_attempt_id: None,
plugin_name,
},
})