Add app-server marketplace upgrade RPC (#19074)

## Summary
- add a v2 `marketplace/upgrade` app-server RPC that mirrors the
existing configured Git marketplace upgrade path
- expose typed request/response/error payloads and regenerate
JSON/TypeScript schema fixtures
- add app-server integration coverage for all, named, already
up-to-date, and invalid marketplace upgrade requests

## Tests
- `just write-app-server-schema`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server marketplace_upgrade`
- `just fix -p codex-app-server-protocol`
- `just fix -p codex-app-server`
- `just fmt`
This commit is contained in:
xli-oai
2026-04-23 13:00:46 -07:00
committed by GitHub
Unverified
parent 491a3058f6
commit 0d6a90cd6b
18 changed files with 806 additions and 8 deletions
@@ -1721,6 +1721,17 @@
],
"type": "object"
},
"MarketplaceUpgradeParams": {
"properties": {
"marketplaceName": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"McpResourceReadParams": {
"properties": {
"server": {
@@ -4904,6 +4915,30 @@
"title": "Marketplace/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"marketplace/upgrade"
],
"title": "Marketplace/upgradeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/MarketplaceUpgradeParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Marketplace/upgradeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -689,6 +689,30 @@
"title": "Marketplace/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"marketplace/upgrade"
],
"title": "Marketplace/upgradeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/MarketplaceUpgradeParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Marketplace/upgradeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -10318,6 +10342,64 @@
"title": "MarketplaceRemoveResponse",
"type": "object"
},
"MarketplaceUpgradeErrorInfo": {
"properties": {
"marketplaceName": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"marketplaceName",
"message"
],
"type": "object"
},
"MarketplaceUpgradeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": [
"string",
"null"
]
}
},
"title": "MarketplaceUpgradeParams",
"type": "object"
},
"MarketplaceUpgradeResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"errors": {
"items": {
"$ref": "#/definitions/v2/MarketplaceUpgradeErrorInfo"
},
"type": "array"
},
"selectedMarketplaces": {
"items": {
"type": "string"
},
"type": "array"
},
"upgradedRoots": {
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"errors",
"selectedMarketplaces",
"upgradedRoots"
],
"title": "MarketplaceUpgradeResponse",
"type": "object"
},
"McpAuthStatus": {
"enum": [
"unsupported",
@@ -1380,6 +1380,30 @@
"title": "Marketplace/removeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"marketplace/upgrade"
],
"title": "Marketplace/upgradeRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/MarketplaceUpgradeParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Marketplace/upgradeRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7033,6 +7057,64 @@
"title": "MarketplaceRemoveResponse",
"type": "object"
},
"MarketplaceUpgradeErrorInfo": {
"properties": {
"marketplaceName": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"marketplaceName",
"message"
],
"type": "object"
},
"MarketplaceUpgradeParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": [
"string",
"null"
]
}
},
"title": "MarketplaceUpgradeParams",
"type": "object"
},
"MarketplaceUpgradeResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"errors": {
"items": {
"$ref": "#/definitions/MarketplaceUpgradeErrorInfo"
},
"type": "array"
},
"selectedMarketplaces": {
"items": {
"type": "string"
},
"type": "array"
},
"upgradedRoots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"errors",
"selectedMarketplaces",
"upgradedRoots"
],
"title": "MarketplaceUpgradeResponse",
"type": "object"
},
"McpAuthStatus": {
"enum": [
"unsupported",
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"marketplaceName": {
"type": [
"string",
"null"
]
}
},
"title": "MarketplaceUpgradeParams",
"type": "object"
}
@@ -0,0 +1,51 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"MarketplaceUpgradeErrorInfo": {
"properties": {
"marketplaceName": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"marketplaceName",
"message"
],
"type": "object"
}
},
"properties": {
"errors": {
"items": {
"$ref": "#/definitions/MarketplaceUpgradeErrorInfo"
},
"type": "array"
},
"selectedMarketplaces": {
"items": {
"type": "string"
},
"type": "array"
},
"upgradedRoots": {
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"errors",
"selectedMarketplaces",
"upgradedRoots"
],
"title": "MarketplaceUpgradeResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MarketplaceUpgradeErrorInfo = { marketplaceName: string, message: string, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MarketplaceUpgradeParams = { marketplaceName?: string | null, };
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// 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";
import type { MarketplaceUpgradeErrorInfo } from "./MarketplaceUpgradeErrorInfo";
export type MarketplaceUpgradeResponse = { selectedMarketplaces: Array<string>, upgradedRoots: Array<AbsolutePathBuf>, errors: Array<MarketplaceUpgradeErrorInfo>, };
@@ -178,6 +178,9 @@ export type { MarketplaceInterface } from "./MarketplaceInterface";
export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
export type { MarketplaceRemoveParams } from "./MarketplaceRemoveParams";
export type { MarketplaceRemoveResponse } from "./MarketplaceRemoveResponse";
export type { MarketplaceUpgradeErrorInfo } from "./MarketplaceUpgradeErrorInfo";
export type { MarketplaceUpgradeParams } from "./MarketplaceUpgradeParams";
export type { MarketplaceUpgradeResponse } from "./MarketplaceUpgradeResponse";
export type { McpAuthStatus } from "./McpAuthStatus";
export type { McpElicitationArrayType } from "./McpElicitationArrayType";
export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema";
@@ -357,6 +357,10 @@ client_request_definitions! {
params: v2::MarketplaceRemoveParams,
response: v2::MarketplaceRemoveResponse,
},
MarketplaceUpgrade => "marketplace/upgrade" {
params: v2::MarketplaceUpgradeParams,
response: v2::MarketplaceUpgradeResponse,
},
PluginList => "plugin/list" {
params: v2::PluginListParams,
response: v2::PluginListResponse,
@@ -4104,6 +4104,31 @@ pub struct MarketplaceRemoveResponse {
pub installed_root: Option<AbsolutePathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MarketplaceUpgradeParams {
#[ts(optional = nullable)]
pub marketplace_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MarketplaceUpgradeResponse {
pub selected_marketplaces: Vec<String>,
pub upgraded_roots: Vec<AbsolutePathBuf>,
pub errors: Vec<MarketplaceUpgradeErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MarketplaceUpgradeErrorInfo {
pub marketplace_name: String,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -9790,6 +9815,36 @@ mod tests {
);
}
#[test]
fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() {
assert_eq!(
serde_json::to_value(MarketplaceUpgradeParams {
marketplace_name: None,
})
.unwrap(),
json!({
"marketplaceName": null,
}),
);
assert_eq!(
serde_json::from_value::<MarketplaceUpgradeParams>(json!({})).unwrap(),
MarketplaceUpgradeParams {
marketplace_name: None,
},
);
assert_eq!(
serde_json::to_value(MarketplaceUpgradeParams {
marketplace_name: Some("debug".to_string()),
})
.unwrap(),
json!({
"marketplaceName": "debug",
}),
);
}
#[test]
fn plugin_marketplace_entry_serializes_remote_only_path_as_null() {
assert_eq!(
@@ -10036,6 +10091,37 @@ mod tests {
);
}
#[test]
fn marketplace_upgrade_response_serializes_camel_case_fields() {
let upgraded_root = if cfg!(windows) {
r"C:\marketplaces\debug"
} else {
"/tmp/marketplaces/debug"
};
let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap();
let upgraded_root_json = upgraded_root.as_path().display().to_string();
assert_eq!(
serde_json::to_value(MarketplaceUpgradeResponse {
selected_marketplaces: vec!["debug".to_string()],
upgraded_roots: vec![upgraded_root],
errors: vec![MarketplaceUpgradeErrorInfo {
marketplace_name: "broken".to_string(),
message: "failed to clone".to_string(),
}],
})
.unwrap(),
json!({
"selectedMarketplaces": ["debug"],
"upgradedRoots": [upgraded_root_json],
"errors": [{
"marketplaceName": "broken",
"message": "failed to clone",
}],
}),
);
}
#[test]
fn codex_error_info_serializes_http_status_code_in_camel_case() {
let value = CodexErrorInfo::ResponseTooManyFailedAttempts {
+1
View File
@@ -192,6 +192,7 @@ Example with notification opt-out:
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
@@ -88,6 +88,9 @@ use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceInterface;
use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeErrorInfo;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::McpResourceReadParams;
use codex_app_server_protocol::McpResourceReadResponse;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
@@ -980,6 +983,10 @@ impl CodexMessageProcessor {
self.marketplace_remove(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::MarketplaceUpgrade { request_id, params } => {
self.marketplace_upgrade(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginList { request_id, params } => {
self.plugin_list(to_connection_request_id(request_id), params)
.await;
@@ -6776,6 +6783,61 @@ impl CodexMessageProcessor {
}
}
}
async fn marketplace_upgrade(
&self,
request_id: ConnectionRequestId,
params: MarketplaceUpgradeParams,
) {
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 plugins_manager = self.thread_manager.plugins_manager();
let MarketplaceUpgradeParams { marketplace_name } = params;
let result = tokio::task::spawn_blocking(move || {
plugins_manager
.upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref())
})
.await;
match result {
Ok(Ok(outcome)) => {
self.outgoing
.send_response(
request_id,
MarketplaceUpgradeResponse {
selected_marketplaces: outcome.selected_marketplaces,
upgraded_roots: outcome.upgraded_roots,
errors: outcome
.errors
.into_iter()
.map(|err| MarketplaceUpgradeErrorInfo {
marketplace_name: err.marketplace_name,
message: err.message,
})
.collect(),
},
)
.await;
}
Ok(Err(message)) => {
self.send_invalid_request_error(request_id, message).await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to upgrade marketplaces: {err}"),
)
.await;
}
}
}
async fn marketplace_add(&self, request_id: ConnectionRequestId, params: MarketplaceAddParams) {
let result = add_marketplace_to_codex_home(
self.config.codex_home.to_path_buf(),
@@ -49,6 +49,7 @@ use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::McpResourceReadParams;
use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::MockExperimentalMethodParams;
@@ -565,6 +566,15 @@ impl McpProcess {
self.send_request("marketplace/remove", params).await
}
/// Send a `marketplace/upgrade` JSON-RPC request.
pub async fn send_marketplace_upgrade_request(
&mut self,
params: MarketplaceUpgradeParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("marketplace/upgrade", params).await
}
/// Send a `plugin/install` JSON-RPC request.
pub async fn send_plugin_install_request(
&mut self,
@@ -0,0 +1,303 @@
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::RequestId;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
fn run_git(cwd: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git").current_dir(cwd).args(args).output()?;
if !output.status.success() {
anyhow::bail!(
"git {} failed in {}: {}",
args.join(" "),
cwd.display(),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn write_marketplace_files(root: &Path, marketplace_name: &str, marker: &str) -> Result<()> {
std::fs::create_dir_all(root.join(".agents/plugins"))?;
std::fs::write(
root.join(".agents/plugins/marketplace.json"),
format!(r#"{{"name":"{marketplace_name}","plugins":[]}}"#),
)?;
std::fs::write(root.join("marker.txt"), marker)?;
Ok(())
}
fn init_marketplace_repo(root: &Path, marketplace_name: &str, marker: &str) -> Result<String> {
run_git(root, &["init"])?;
run_git(root, &["config", "user.email", "codex@example.com"])?;
run_git(root, &["config", "user.name", "Codex Tests"])?;
write_marketplace_files(root, marketplace_name, marker)?;
run_git(root, &["add", "."])?;
run_git(root, &["commit", "-m", "initial marketplace"])?;
run_git(root, &["rev-parse", "HEAD"])
}
fn commit_marketplace_marker(root: &Path, marker: &str) -> Result<String> {
std::fs::write(root.join("marker.txt"), marker)?;
run_git(root, &["add", "marker.txt"])?;
run_git(root, &["commit", "-m", "update marker"])?;
run_git(root, &["rev-parse", "HEAD"])
}
fn configured_git_marketplace_update<'a>(
source: &'a str,
last_revision: Option<&'a str>,
) -> MarketplaceConfigUpdate<'a> {
MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision,
source_type: "git",
source,
ref_name: None,
sparse_paths: &[],
}
}
fn configured_local_marketplace_update(source: &str) -> MarketplaceConfigUpdate<'_> {
MarketplaceConfigUpdate {
last_updated: "2026-04-13T00:00:00Z",
last_revision: None,
source_type: "local",
source,
ref_name: None,
sparse_paths: &[],
}
}
fn record_git_marketplace(
codex_home: &Path,
marketplace_name: &str,
source: &Path,
last_revision: &str,
) -> Result<()> {
let source = source.display().to_string();
record_user_marketplace(
codex_home,
marketplace_name,
&configured_git_marketplace_update(&source, Some(last_revision)),
)?;
Ok(())
}
fn disable_plugin_startup_tasks(codex_home: &Path) -> Result<()> {
let config_path = codex_home.join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(
config_path,
format!("{config}\n[features]\nplugins = false\n"),
)?;
Ok(())
}
fn marketplace_install_root(codex_home: &Path) -> std::path::PathBuf {
codex_home.join(INSTALLED_MARKETPLACES_DIR)
}
fn expected_installed_root(codex_home: &Path, marketplace_name: &str) -> Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(
marketplace_install_root(&codex_home.canonicalize()?).join(marketplace_name),
)
.context("expected installed root should be absolute")
}
async fn send_marketplace_upgrade(
mcp: &mut McpProcess,
marketplace_name: Option<&str>,
) -> Result<MarketplaceUpgradeResponse> {
let request_id = mcp
.send_marketplace_upgrade_request(MarketplaceUpgradeParams {
marketplace_name: marketplace_name.map(str::to_string),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
#[tokio::test]
async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> {
let codex_home = TempDir::new()?;
let debug_source = TempDir::new()?;
let tools_source = TempDir::new()?;
let debug_old_revision = init_marketplace_repo(debug_source.path(), "debug", "debug old")?;
let tools_old_revision = init_marketplace_repo(tools_source.path(), "tools", "tools old")?;
let debug_new_revision = commit_marketplace_marker(debug_source.path(), "debug new")?;
let tools_new_revision = commit_marketplace_marker(tools_source.path(), "tools new")?;
record_git_marketplace(
codex_home.path(),
"debug",
debug_source.path(),
&debug_old_revision,
)?;
record_git_marketplace(
codex_home.path(),
"tools",
tools_source.path(),
&tools_old_revision,
)?;
disable_plugin_startup_tasks(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let debug_root = expected_installed_root(codex_home.path(), "debug")?;
let tools_root = expected_installed_root(codex_home.path(), "tools")?;
let response = send_marketplace_upgrade(&mut mcp, /*marketplace_name*/ None).await?;
assert_eq!(
response,
MarketplaceUpgradeResponse {
selected_marketplaces: vec!["debug".to_string(), "tools".to_string()],
upgraded_roots: vec![debug_root.clone(), tools_root.clone()],
errors: Vec::new(),
}
);
assert_eq!(
std::fs::read_to_string(debug_root.as_path().join("marker.txt"))?,
"debug new"
);
assert_eq!(
std::fs::read_to_string(tools_root.as_path().join("marker.txt"))?,
"tools new"
);
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains(&debug_new_revision));
assert!(config.contains(&tools_new_revision));
Ok(())
}
#[tokio::test]
async fn marketplace_upgrade_named_marketplace_only() -> Result<()> {
let codex_home = TempDir::new()?;
let debug_source = TempDir::new()?;
let tools_source = TempDir::new()?;
let debug_old_revision = init_marketplace_repo(debug_source.path(), "debug", "debug old")?;
let tools_old_revision = init_marketplace_repo(tools_source.path(), "tools", "tools old")?;
commit_marketplace_marker(debug_source.path(), "debug new")?;
commit_marketplace_marker(tools_source.path(), "tools new")?;
record_git_marketplace(
codex_home.path(),
"debug",
debug_source.path(),
&debug_old_revision,
)?;
record_git_marketplace(
codex_home.path(),
"tools",
tools_source.path(),
&tools_old_revision,
)?;
disable_plugin_startup_tasks(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let tools_root = expected_installed_root(codex_home.path(), "tools")?;
let response = send_marketplace_upgrade(&mut mcp, Some("tools")).await?;
assert_eq!(
response,
MarketplaceUpgradeResponse {
selected_marketplaces: vec!["tools".to_string()],
upgraded_roots: vec![tools_root.clone()],
errors: Vec::new(),
}
);
assert_eq!(
std::fs::read_to_string(tools_root.as_path().join("marker.txt"))?,
"tools new"
);
assert!(
!marketplace_install_root(codex_home.path())
.join("debug")
.exists()
);
Ok(())
}
#[tokio::test]
async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Result<()> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
let old_revision = init_marketplace_repo(source.path(), "debug", "debug old")?;
commit_marketplace_marker(source.path(), "debug new")?;
record_git_marketplace(codex_home.path(), "debug", source.path(), &old_revision)?;
disable_plugin_startup_tasks(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let first_response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?;
assert!(first_response.errors.is_empty());
let response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?;
assert_eq!(
response,
MarketplaceUpgradeResponse {
selected_marketplaces: vec!["debug".to_string()],
upgraded_roots: Vec::new(),
errors: Vec::new(),
}
);
Ok(())
}
#[tokio::test]
async fn marketplace_upgrade_rejects_unknown_or_non_git_marketplace() -> Result<()> {
let codex_home = TempDir::new()?;
let local_source = TempDir::new()?;
record_user_marketplace(
codex_home.path(),
"local-only",
&configured_local_marketplace_update(&local_source.path().display().to_string()),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
for marketplace_name in ["missing", "local-only"] {
let request_id = mcp
.send_marketplace_upgrade_request(MarketplaceUpgradeParams {
marketplace_name: Some(marketplace_name.to_string()),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert_eq!(
err.error.message,
format!("marketplace `{marketplace_name}` is not configured as a Git marketplace"),
);
}
Ok(())
}
@@ -19,6 +19,7 @@ mod fs;
mod initialize;
mod marketplace_add;
mod marketplace_remove;
mod marketplace_upgrade;
mod mcp_resource;
mod mcp_server_elicitation;
mod mcp_server_status;
@@ -1,4 +1,5 @@
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
@@ -46,9 +47,10 @@ pub(super) fn clone_git_source(
destination: &Path,
timeout: Duration,
) -> Result<String, String> {
let git_destination = git_path_arg(destination);
if sparse_paths.is_empty() {
let output = run_git_command_with_timeout(
git_command().arg("clone").arg(source).arg(destination),
git_command().arg("clone").arg(source).arg(&git_destination),
"git clone marketplace source",
timeout,
)?;
@@ -57,7 +59,7 @@ pub(super) fn clone_git_source(
let output = run_git_command_with_timeout(
git_command()
.arg("-C")
.arg(destination)
.arg(&git_destination)
.arg("checkout")
.arg(ref_name),
"git checkout marketplace ref",
@@ -65,7 +67,7 @@ pub(super) fn clone_git_source(
)?;
ensure_git_success(&output, "git checkout marketplace ref")?;
}
return git_worktree_revision(destination, timeout);
return git_worktree_revision(&git_destination, timeout);
}
let output = run_git_command_with_timeout(
@@ -74,7 +76,7 @@ pub(super) fn clone_git_source(
.arg("--filter=blob:none")
.arg("--no-checkout")
.arg(source)
.arg(destination),
.arg(&git_destination),
"git clone marketplace source",
timeout,
)?;
@@ -83,7 +85,7 @@ pub(super) fn clone_git_source(
let mut sparse_checkout = git_command();
sparse_checkout
.arg("-C")
.arg(destination)
.arg(&git_destination)
.arg("sparse-checkout")
.arg("set")
.args(sparse_paths);
@@ -97,14 +99,14 @@ pub(super) fn clone_git_source(
let output = run_git_command_with_timeout(
git_command()
.arg("-C")
.arg(destination)
.arg(&git_destination)
.arg("checkout")
.arg(ref_name.unwrap_or("HEAD")),
"git checkout marketplace ref",
timeout,
)?;
ensure_git_success(&output, "git checkout marketplace ref")?;
git_worktree_revision(destination, timeout)
git_worktree_revision(&git_destination, timeout)
}
fn git_worktree_revision(destination: &Path, timeout: Duration) -> Result<String, String> {
@@ -139,6 +141,28 @@ fn git_command() -> Command {
command
}
#[cfg(windows)]
fn git_path_arg(path: &Path) -> PathBuf {
strip_windows_verbatim_path_prefix(&path.to_string_lossy())
.map(PathBuf::from)
.unwrap_or_else(|| path.to_path_buf())
}
#[cfg(not(windows))]
fn git_path_arg(path: &Path) -> PathBuf {
path.to_path_buf()
}
#[cfg(any(windows, test))]
fn strip_windows_verbatim_path_prefix(path: &str) -> Option<String> {
let stripped = path.strip_prefix(r"\\?\")?;
let stripped = stripped
.strip_prefix(r"UNC\")
.map(|unc_path| format!(r"\\{unc_path}"))
.unwrap_or_else(|| stripped.to_string());
Some(stripped)
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
@@ -201,6 +225,8 @@ fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
mod tests {
use super::git_command;
use super::is_full_git_sha;
use super::strip_windows_verbatim_path_prefix;
use pretty_assertions::assert_eq;
use std::ffi::OsStr;
#[test]
@@ -226,6 +252,27 @@ mod tests {
assert_eq!(command_env(&command, "PATH"), None);
}
#[test]
fn strips_windows_verbatim_disk_prefix_for_git() {
assert_eq!(
strip_windows_verbatim_path_prefix(r"\\?\C:\Users\alice\marketplace"),
Some(r"C:\Users\alice\marketplace".to_string())
);
}
#[test]
fn strips_windows_verbatim_unc_prefix_for_git() {
assert_eq!(
strip_windows_verbatim_path_prefix(r"\\?\UNC\server\share\marketplace"),
Some(r"\\server\share\marketplace".to_string())
);
}
#[test]
fn leaves_non_verbatim_path_without_rewrite() {
assert_eq!(strip_windows_verbatim_path_prefix(r"C:\Users\alice"), None);
}
fn command_env<'a>(
command: &'a std::process::Command,
name: &str,