mirror of
https://github.com/openai/codex.git
synced 2026-05-13 07:42:40 +00:00
Compare commits
7 Commits
starr/exec
...
xli-codex/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87e3eac01 | ||
|
|
e053379807 | ||
|
|
ee04afd7ae | ||
|
|
c0bef9620a | ||
|
|
1352f5c5e6 | ||
|
|
d94e2e1463 | ||
|
|
a17fd11990 |
@@ -1233,6 +1233,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"MarketplaceAddParams": {
|
||||
"properties": {
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sparsePaths": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"McpResourceReadParams": {
|
||||
"properties": {
|
||||
"server": {
|
||||
@@ -4025,6 +4051,30 @@
|
||||
"title": "Skills/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"marketplace/add"
|
||||
],
|
||||
"title": "Marketplace/addRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/MarketplaceAddParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Marketplace/addRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -627,6 +627,30 @@
|
||||
"title": "Skills/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"marketplace/add"
|
||||
],
|
||||
"title": "Marketplace/addRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/MarketplaceAddParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Marketplace/addRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -9060,6 +9084,55 @@
|
||||
"title": "LogoutAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceAddParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sparsePaths": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"title": "MarketplaceAddParams",
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceAddResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"alreadyAdded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"installedRoot": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alreadyAdded",
|
||||
"installedRoot",
|
||||
"marketplaceName"
|
||||
],
|
||||
"title": "MarketplaceAddResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceInterface": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
|
||||
@@ -1209,6 +1209,30 @@
|
||||
"title": "Skills/listRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"marketplace/add"
|
||||
],
|
||||
"title": "Marketplace/addRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/MarketplaceAddParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Marketplace/addRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -5856,6 +5880,55 @@
|
||||
"title": "LogoutAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceAddParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sparsePaths": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"title": "MarketplaceAddParams",
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceAddResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"alreadyAdded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"installedRoot": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alreadyAdded",
|
||||
"installedRoot",
|
||||
"marketplaceName"
|
||||
],
|
||||
"title": "MarketplaceAddResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"MarketplaceInterface": {
|
||||
"properties": {
|
||||
"displayName": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"refName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sparsePaths": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"title": "MarketplaceAddParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"alreadyAdded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"installedRoot": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alreadyAdded",
|
||||
"installedRoot",
|
||||
"marketplaceName"
|
||||
],
|
||||
"title": "MarketplaceAddResponse",
|
||||
"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 MarketplaceAddParams = { source: string, refName?: string | null, sparsePaths?: Array<string> | null, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// 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";
|
||||
|
||||
export type MarketplaceAddResponse = { marketplaceName: string, installedRoot: AbsolutePathBuf, alreadyAdded: boolean, };
|
||||
@@ -149,6 +149,8 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"
|
||||
export type { LoginAccountParams } from "./LoginAccountParams";
|
||||
export type { LoginAccountResponse } from "./LoginAccountResponse";
|
||||
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
|
||||
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
|
||||
export type { MarketplaceAddResponse } from "./MarketplaceAddResponse";
|
||||
export type { MarketplaceInterface } from "./MarketplaceInterface";
|
||||
export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
|
||||
export type { McpAuthStatus } from "./McpAuthStatus";
|
||||
|
||||
@@ -331,6 +331,10 @@ client_request_definitions! {
|
||||
params: v2::SkillsListParams,
|
||||
response: v2::SkillsListResponse,
|
||||
},
|
||||
MarketplaceAdd => "marketplace/add" {
|
||||
params: v2::MarketplaceAddParams,
|
||||
response: v2::MarketplaceAddResponse,
|
||||
},
|
||||
PluginList => "plugin/list" {
|
||||
params: v2::PluginListParams,
|
||||
response: v2::PluginListResponse,
|
||||
|
||||
@@ -3326,6 +3326,26 @@ pub struct SkillsListResponse {
|
||||
pub data: Vec<SkillsListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct MarketplaceAddParams {
|
||||
pub source: String,
|
||||
#[ts(optional = nullable)]
|
||||
pub ref_name: Option<String>,
|
||||
#[ts(optional = nullable)]
|
||||
pub sparse_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct MarketplaceAddResponse {
|
||||
pub marketplace_name: String,
|
||||
pub installed_root: AbsolutePathBuf,
|
||||
pub already_added: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -8352,6 +8372,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(MarketplaceAddParams {
|
||||
source: "owner/repo".to_string(),
|
||||
ref_name: None,
|
||||
sparse_paths: None,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"source": "owner/repo",
|
||||
"refName": null,
|
||||
"sparsePaths": null,
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(MarketplaceAddParams {
|
||||
source: "owner/repo".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
sparse_paths: Some(vec!["plugins/foo".to_string()]),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"source": "owner/repo",
|
||||
"refName": "main",
|
||||
"sparsePaths": ["plugins/foo"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_install_params_serialization_uses_force_remote_sync() {
|
||||
let marketplace_path = if cfg!(windows) {
|
||||
|
||||
@@ -179,6 +179,7 @@ Example with notification opt-out:
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `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.
|
||||
- `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. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**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.
|
||||
|
||||
@@ -76,6 +76,8 @@ use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::LoginApiKeyParams;
|
||||
use codex_app_server_protocol::LogoutAccountResponse;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
use codex_app_server_protocol::MarketplaceInterface;
|
||||
use codex_app_server_protocol::McpResourceReadParams;
|
||||
use codex_app_server_protocol::McpResourceReadResponse;
|
||||
@@ -228,6 +230,7 @@ use codex_core::find_thread_names_by_ids;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::parse_cursor;
|
||||
use codex_core::path_utils;
|
||||
use codex_core::plugins::MarketplaceAddError;
|
||||
use codex_core::plugins::MarketplaceError;
|
||||
use codex_core::plugins::MarketplacePluginSource;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
@@ -235,6 +238,7 @@ use codex_core::plugins::PluginInstallError as CorePluginInstallError;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
use codex_core::plugins::PluginReadRequest;
|
||||
use codex_core::plugins::PluginUninstallError as CorePluginUninstallError;
|
||||
use codex_core::plugins::add_marketplace as add_marketplace_to_codex_home;
|
||||
use codex_core::plugins::load_plugin_apps;
|
||||
use codex_core::plugins::load_plugin_mcp_servers;
|
||||
use codex_core::read_head_for_summary;
|
||||
@@ -927,6 +931,10 @@ impl CodexMessageProcessor {
|
||||
self.skills_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::MarketplaceAdd { request_id, params } => {
|
||||
self.marketplace_add(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginList { request_id, params } => {
|
||||
self.plugin_list(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -6483,6 +6491,39 @@ impl CodexMessageProcessor {
|
||||
.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(),
|
||||
codex_core::plugins::MarketplaceAddRequest {
|
||||
source: params.source,
|
||||
ref_name: params.ref_name,
|
||||
sparse_paths: params.sparse_paths.unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
MarketplaceAddResponse {
|
||||
marketplace_name: outcome.marketplace_name,
|
||||
installed_root: outcome.installed_root,
|
||||
already_added: outcome.already_added,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(MarketplaceAddError::InvalidRequest(message)) => {
|
||||
self.send_invalid_request_error(request_id, message).await;
|
||||
}
|
||||
Err(MarketplaceAddError::Internal(message)) => {
|
||||
self.send_internal_error(request_id, message).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_read(&self, request_id: ConnectionRequestId, params: PluginReadParams) {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let PluginReadParams {
|
||||
|
||||
@@ -47,6 +47,7 @@ use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::McpResourceReadParams;
|
||||
use codex_app_server_protocol::McpServerToolCallParams;
|
||||
use codex_app_server_protocol::MockExperimentalMethodParams;
|
||||
@@ -514,6 +515,15 @@ impl McpProcess {
|
||||
self.send_request("skills/list", params).await
|
||||
}
|
||||
|
||||
/// Send a `marketplace/add` JSON-RPC request.
|
||||
pub async fn send_marketplace_add_request(
|
||||
&mut self,
|
||||
params: MarketplaceAddParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("marketplace/add", params).await
|
||||
}
|
||||
|
||||
/// Send a `plugin/install` JSON-RPC request.
|
||||
pub async fn send_plugin_install_request(
|
||||
&mut self,
|
||||
|
||||
40
codex-rs/app-server/tests/suite/v2/marketplace_add.rs
Normal file
40
codex-rs/app-server/tests/suite/v2/marketplace_add.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_marketplace_add_request(MarketplaceAddParams {
|
||||
source: "./marketplace".to_string(),
|
||||
ref_name: None,
|
||||
sparse_paths: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error.message.contains(
|
||||
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
|
||||
),
|
||||
"unexpected error: {}",
|
||||
err.error.message
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod experimental_api;
|
||||
mod experimental_feature_list;
|
||||
mod fs;
|
||||
mod initialize;
|
||||
mod marketplace_add;
|
||||
mod mcp_resource;
|
||||
mod mcp_server_elicitation;
|
||||
mod mcp_server_status;
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use clap::Parser;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use codex_core::plugins::validate_plugin_segment;
|
||||
use codex_core::plugins::MarketplaceAddRequest;
|
||||
use codex_core::plugins::add_marketplace;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
mod metadata;
|
||||
mod ops;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct MarketplaceCli {
|
||||
@@ -51,14 +39,6 @@ struct AddMarketplaceArgs {
|
||||
sparse_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum MarketplaceSource {
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl MarketplaceCli {
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let MarketplaceCli {
|
||||
@@ -87,449 +67,41 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
|
||||
sparse_paths,
|
||||
} = args;
|
||||
|
||||
let source = parse_marketplace_source(&source, ref_name)?;
|
||||
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let install_root = marketplace_install_root(&codex_home);
|
||||
fs::create_dir_all(&install_root).with_context(|| {
|
||||
format!(
|
||||
"failed to create marketplace install directory {}",
|
||||
install_root.display()
|
||||
)
|
||||
})?;
|
||||
let install_metadata =
|
||||
metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
|
||||
if let Some(existing_root) = metadata::installed_marketplace_root_for_source(
|
||||
&codex_home,
|
||||
&install_root,
|
||||
&install_metadata,
|
||||
)? {
|
||||
let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| {
|
||||
format!(
|
||||
"failed to validate installed marketplace at {}",
|
||||
existing_root.display()
|
||||
)
|
||||
})?;
|
||||
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
|
||||
let outcome = add_marketplace(
|
||||
codex_home.to_path_buf(),
|
||||
MarketplaceAddRequest {
|
||||
source,
|
||||
ref_name,
|
||||
sparse_paths,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if outcome.already_added {
|
||||
println!(
|
||||
"Marketplace `{marketplace_name}` is already added from {}.",
|
||||
source.display()
|
||||
"Marketplace `{}` is already added from {}.",
|
||||
outcome.marketplace_name, outcome.source_display
|
||||
);
|
||||
println!("Installed marketplace root: {}", existing_root.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let staging_root = ops::marketplace_staging_root(&install_root);
|
||||
fs::create_dir_all(&staging_root).with_context(|| {
|
||||
format!(
|
||||
"failed to create marketplace staging directory {}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
let staged_dir = tempfile::Builder::new()
|
||||
.prefix("marketplace-add-")
|
||||
.tempdir_in(&staging_root)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create temporary marketplace directory in {}",
|
||||
staging_root.display()
|
||||
)
|
||||
})?;
|
||||
let staged_root = staged_dir.path().to_path_buf();
|
||||
|
||||
let MarketplaceSource::Git { url, ref_name } = &source;
|
||||
ops::clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
|
||||
|
||||
let marketplace_name = validate_marketplace_source_root(&staged_root)
|
||||
.with_context(|| format!("failed to validate marketplace from {}", source.display()))?;
|
||||
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
bail!(
|
||||
"marketplace `{OPENAI_CURATED_MARKETPLACE_NAME}` is reserved and cannot be added from {}",
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
|
||||
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
|
||||
if destination.exists() {
|
||||
bail!(
|
||||
"marketplace `{marketplace_name}` is already added from a different source; remove it before adding {}",
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
ops::replace_marketplace_root(&staged_root, &destination)
|
||||
.with_context(|| format!("failed to install marketplace at {}", destination.display()))?;
|
||||
if let Err(err) = record_added_marketplace(&codex_home, &marketplace_name, &install_metadata) {
|
||||
if let Err(rollback_err) = fs::rename(&destination, &staged_root) {
|
||||
bail!(
|
||||
"{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}",
|
||||
destination.display()
|
||||
);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Added marketplace `{marketplace_name}` from {}.",
|
||||
source.display()
|
||||
);
|
||||
println!("Installed marketplace root: {}", destination.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_added_marketplace(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
install_metadata: &metadata::MarketplaceInstallMetadata,
|
||||
) -> Result<()> {
|
||||
let source = install_metadata.config_source();
|
||||
let last_updated = utc_timestamp_now()?;
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: &last_updated,
|
||||
source_type: install_metadata.config_source_type(),
|
||||
source: &source,
|
||||
ref_name: install_metadata.ref_name(),
|
||||
sparse_paths: install_metadata.sparse_paths(),
|
||||
};
|
||||
record_user_marketplace(codex_home, marketplace_name, &update).with_context(|| {
|
||||
format!("failed to add marketplace `{marketplace_name}` to user config.toml")
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_marketplace_source_root(root: &Path) -> Result<String> {
|
||||
let marketplace_name = validate_marketplace_root(root)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?;
|
||||
Ok(marketplace_name)
|
||||
}
|
||||
|
||||
fn parse_marketplace_source(
|
||||
source: &str,
|
||||
explicit_ref: Option<String>,
|
||||
) -> Result<MarketplaceSource> {
|
||||
let source = source.trim();
|
||||
if source.is_empty() {
|
||||
bail!("marketplace source must not be empty");
|
||||
}
|
||||
|
||||
let (base_source, parsed_ref) = split_source_ref(source);
|
||||
let ref_name = explicit_ref.or(parsed_ref);
|
||||
|
||||
if looks_like_local_path(&base_source) {
|
||||
bail!(
|
||||
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
|
||||
);
|
||||
}
|
||||
|
||||
if is_ssh_git_url(&base_source) || is_git_url(&base_source) {
|
||||
let url = normalize_git_url(&base_source);
|
||||
return Ok(MarketplaceSource::Git { url, ref_name });
|
||||
}
|
||||
|
||||
if looks_like_github_shorthand(&base_source) {
|
||||
let url = format!("https://github.com/{base_source}.git");
|
||||
return Ok(MarketplaceSource::Git { url, ref_name });
|
||||
}
|
||||
|
||||
bail!("invalid marketplace source format: {source}");
|
||||
}
|
||||
|
||||
fn split_source_ref(source: &str) -> (String, Option<String>) {
|
||||
if let Some((base, ref_name)) = source.rsplit_once('#') {
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
}
|
||||
if !source.contains("://")
|
||||
&& !is_ssh_git_url(source)
|
||||
&& let Some((base, ref_name)) = source.rsplit_once('@')
|
||||
{
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
}
|
||||
(source.to_string(), None)
|
||||
}
|
||||
|
||||
fn non_empty_ref(ref_name: &str) -> Option<String> {
|
||||
let ref_name = ref_name.trim();
|
||||
(!ref_name.is_empty()).then(|| ref_name.to_string())
|
||||
}
|
||||
|
||||
fn normalize_git_url(url: &str) -> String {
|
||||
let url = url.trim_end_matches('/');
|
||||
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
|
||||
format!("{url}.git")
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_local_path(source: &str) -> bool {
|
||||
source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('/')
|
||||
|| source.starts_with("~/")
|
||||
|| source == "."
|
||||
|| source == ".."
|
||||
}
|
||||
|
||||
fn is_ssh_git_url(source: &str) -> bool {
|
||||
source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':')
|
||||
}
|
||||
|
||||
fn is_git_url(source: &str) -> bool {
|
||||
source.starts_with("http://") || source.starts_with("https://")
|
||||
}
|
||||
|
||||
fn looks_like_github_shorthand(source: &str) -> bool {
|
||||
let mut segments = source.split('/');
|
||||
let owner = segments.next();
|
||||
let repo = segments.next();
|
||||
let extra = segments.next();
|
||||
owner.is_some_and(is_github_shorthand_segment)
|
||||
&& repo.is_some_and(is_github_shorthand_segment)
|
||||
&& extra.is_none()
|
||||
}
|
||||
|
||||
fn is_github_shorthand_segment(segment: &str) -> bool {
|
||||
!segment.is_empty()
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
fn safe_marketplace_dir_name(marketplace_name: &str) -> Result<String> {
|
||||
let safe = marketplace_name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let safe = safe.trim_matches('.').to_string();
|
||||
if safe.is_empty() || safe == ".." {
|
||||
bail!("marketplace name `{marketplace_name}` cannot be used as an install directory");
|
||||
}
|
||||
Ok(safe)
|
||||
}
|
||||
|
||||
fn ensure_marketplace_destination_is_inside_install_root(
|
||||
install_root: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<()> {
|
||||
let install_root = install_root.canonicalize().with_context(|| {
|
||||
format!(
|
||||
"failed to resolve marketplace install root {}",
|
||||
install_root.display()
|
||||
)
|
||||
})?;
|
||||
let destination_parent = destination
|
||||
.parent()
|
||||
.context("marketplace destination has no parent")?
|
||||
.canonicalize()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to resolve marketplace destination parent {}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
if !destination_parent.starts_with(&install_root) {
|
||||
bail!(
|
||||
"marketplace destination {} is outside install root {}",
|
||||
destination.display(),
|
||||
install_root.display()
|
||||
println!(
|
||||
"Added marketplace `{}` from {}.",
|
||||
outcome.marketplace_name, outcome.source_display
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Installed marketplace root: {}",
|
||||
outcome.installed_root.as_path().display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn utc_timestamp_now() -> Result<String> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system clock is before Unix epoch")?;
|
||||
Ok(format_utc_timestamp(duration.as_secs() as i64))
|
||||
}
|
||||
|
||||
fn format_utc_timestamp(seconds_since_epoch: i64) -> String {
|
||||
const SECONDS_PER_DAY: i64 = 86_400;
|
||||
let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY);
|
||||
let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let hour = seconds_of_day / 3_600;
|
||||
let minute = (seconds_of_day % 3_600) / 60;
|
||||
let second = seconds_of_day % 60;
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
||||
}
|
||||
|
||||
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
|
||||
let days = days_since_epoch + 719_468;
|
||||
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
impl MarketplaceSource {
|
||||
fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Git { url, ref_name } => {
|
||||
if let Some(ref_name) = ref_name {
|
||||
format!("{url}#{ref_name}")
|
||||
} else {
|
||||
url.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_parses_ref_suffix() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_url_parses_fragment_ref() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"https://example.com/team/repo.git#v1",
|
||||
/*explicit_ref*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://example.com/team/repo.git".to_string(),
|
||||
ref_name: Some("v1".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ref_overrides_source_ref() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"owner/repo@main",
|
||||
/*explicit_ref*/ Some("release".to_string()),
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("release".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_and_git_url_normalize_to_same_source() {
|
||||
let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap();
|
||||
let git_url = parse_marketplace_source(
|
||||
"https://github.com/owner/repo.git",
|
||||
/*explicit_ref*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(shorthand, git_url);
|
||||
assert_eq!(
|
||||
shorthand,
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_github_https_source_parses_as_git_url() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://gitlab.com/owner/repo".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_source_is_rejected() {
|
||||
let err =
|
||||
parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid marketplace source format"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_path_source_is_rejected() {
|
||||
let err = parse_marketplace_source("./marketplace", /*explicit_ref*/ None).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("local marketplace sources are not supported yet"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_url_parses_as_git_url() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"ssh://git@github.com/owner/repo.git#main",
|
||||
/*explicit_ref*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "ssh://git@github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() {
|
||||
assert_eq!(
|
||||
format_utc_timestamp(/*seconds_since_epoch*/ 0),
|
||||
"1970-01-01T00:00:00Z"
|
||||
);
|
||||
assert_eq!(
|
||||
format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200),
|
||||
"2026-04-10T00:00:00Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_paths_parse_before_or_after_source() {
|
||||
let sparse_before_source =
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
use super::MarketplaceSource;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_core::plugins::validate_marketplace_root;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct MarketplaceInstallMetadata {
|
||||
source: InstalledMarketplaceSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum InstalledMarketplaceSource {
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
sparse_paths: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn installed_marketplace_root_for_source(
|
||||
codex_home: &Path,
|
||||
install_root: &Path,
|
||||
install_metadata: &MarketplaceInstallMetadata,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let config = match std::fs::read_to_string(&config_path) {
|
||||
Ok(config) => config,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("failed to read user config {}", config_path.display()));
|
||||
}
|
||||
};
|
||||
let config: toml::Value = toml::from_str(&config)
|
||||
.with_context(|| format!("failed to parse user config {}", config_path.display()))?;
|
||||
let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for (marketplace_name, marketplace) in marketplaces {
|
||||
if !install_metadata.matches_config(marketplace) {
|
||||
continue;
|
||||
}
|
||||
let root = install_root.join(marketplace_name);
|
||||
if validate_marketplace_root(&root).is_ok() {
|
||||
return Ok(Some(root));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
impl MarketplaceInstallMetadata {
|
||||
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
|
||||
let source = match source {
|
||||
MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git {
|
||||
url: url.clone(),
|
||||
ref_name: ref_name.clone(),
|
||||
sparse_paths: sparse_paths.to_vec(),
|
||||
},
|
||||
};
|
||||
Self { source }
|
||||
}
|
||||
|
||||
pub(super) fn config_source_type(&self) -> &'static str {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { .. } => "git",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn config_source(&self) -> String {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { url, .. } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn ref_name(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sparse_paths(&self) -> &[String] {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_config(&self, marketplace: &toml::Value) -> bool {
|
||||
marketplace.get("source_type").and_then(toml::Value::as_str)
|
||||
== Some(self.config_source_type())
|
||||
&& marketplace.get("source").and_then(toml::Value::as_str)
|
||||
== Some(self.config_source().as_str())
|
||||
&& marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name()
|
||||
&& config_sparse_paths(marketplace) == self.sparse_paths()
|
||||
}
|
||||
}
|
||||
|
||||
fn config_sparse_paths(marketplace: &toml::Value) -> Vec<String> {
|
||||
marketplace
|
||||
.get("sparse_paths")
|
||||
.and_then(toml::Value::as_array)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(toml::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn installed_marketplace_root_for_source_propagates_config_read_errors() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::create_dir(&config_path)?;
|
||||
|
||||
let install_root = codex_home.path().join("marketplaces");
|
||||
let source = MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
};
|
||||
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
|
||||
|
||||
let err = installed_marketplace_root_for_source(
|
||||
codex_home.path(),
|
||||
&install_root,
|
||||
&install_metadata,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("failed to read user config {}", config_path.display())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub(super) fn clone_git_source(
|
||||
url: &str,
|
||||
ref_name: Option<&str>,
|
||||
sparse_paths: &[String],
|
||||
destination: &Path,
|
||||
) -> Result<()> {
|
||||
let destination = destination.to_string_lossy().to_string();
|
||||
if sparse_paths.is_empty() {
|
||||
run_git(&["clone", url, destination.as_str()], /*cwd*/ None)?;
|
||||
if let Some(ref_name) = ref_name {
|
||||
run_git(&["checkout", ref_name], Some(Path::new(&destination)))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
url,
|
||||
destination.as_str(),
|
||||
],
|
||||
/*cwd*/ None,
|
||||
)?;
|
||||
let mut sparse_args = vec!["sparse-checkout", "set"];
|
||||
sparse_args.extend(sparse_paths.iter().map(String::as_str));
|
||||
let destination = Path::new(&destination);
|
||||
run_git(&sparse_args, Some(destination))?;
|
||||
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<()> {
|
||||
let mut command = Command::new("git");
|
||||
command.args(args);
|
||||
command.env("GIT_TERMINAL_PROMPT", "0");
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
let output = command
|
||||
.output()
|
||||
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
bail!(
|
||||
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
||||
args.join(" "),
|
||||
output.status,
|
||||
stdout.trim(),
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> Result<()> {
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if destination.exists() {
|
||||
bail!(
|
||||
"marketplace destination already exists: {}",
|
||||
destination.display()
|
||||
);
|
||||
}
|
||||
|
||||
fs::rename(staged_root, destination).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".staging")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn replace_marketplace_root_rejects_existing_destination() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let staged_root = temp_dir.path().join("staged");
|
||||
let destination = temp_dir.path().join("destination");
|
||||
fs::create_dir_all(&staged_root).unwrap();
|
||||
fs::write(staged_root.join("marker.txt"), "staged").unwrap();
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
fs::write(destination.join("marker.txt"), "installed").unwrap();
|
||||
|
||||
let err = replace_marketplace_root(&staged_root, &destination).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("marketplace destination already exists"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(staged_root.join("marker.txt")).unwrap(),
|
||||
"staged"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("marker.txt")).unwrap(),
|
||||
"installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
257
codex-rs/core/src/plugins/marketplace_add.rs
Normal file
257
codex-rs/core/src/plugins/marketplace_add.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use super::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use super::marketplace_install_root;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::Builder;
|
||||
|
||||
mod install;
|
||||
mod metadata;
|
||||
mod source;
|
||||
|
||||
use install::clone_git_source;
|
||||
use install::ensure_marketplace_destination_is_inside_install_root;
|
||||
use install::marketplace_staging_root;
|
||||
use install::replace_marketplace_root;
|
||||
use install::safe_marketplace_dir_name;
|
||||
use metadata::MarketplaceInstallMetadata;
|
||||
use metadata::installed_marketplace_root_for_source;
|
||||
use metadata::record_added_marketplace_entry;
|
||||
use source::MarketplaceSource;
|
||||
use source::parse_marketplace_source;
|
||||
use source::validate_marketplace_source_root;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceAddRequest {
|
||||
pub source: String,
|
||||
pub ref_name: Option<String>,
|
||||
pub sparse_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceAddOutcome {
|
||||
pub marketplace_name: String,
|
||||
pub source_display: String,
|
||||
pub installed_root: AbsolutePathBuf,
|
||||
pub already_added: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceAddError {
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("{0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
pub async fn add_marketplace(
|
||||
codex_home: PathBuf,
|
||||
request: MarketplaceAddRequest,
|
||||
) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {
|
||||
tokio::task::spawn_blocking(move || add_marketplace_sync(codex_home.as_path(), request))
|
||||
.await
|
||||
.map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))?
|
||||
}
|
||||
|
||||
fn add_marketplace_sync(
|
||||
codex_home: &Path,
|
||||
request: MarketplaceAddRequest,
|
||||
) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {
|
||||
add_marketplace_sync_with_cloner(codex_home, request, clone_git_source)
|
||||
}
|
||||
|
||||
fn add_marketplace_sync_with_cloner<F>(
|
||||
codex_home: &Path,
|
||||
request: MarketplaceAddRequest,
|
||||
clone_source: F,
|
||||
) -> Result<MarketplaceAddOutcome, MarketplaceAddError>
|
||||
where
|
||||
F: Fn(&str, Option<&str>, &[String], &Path) -> Result<(), MarketplaceAddError>,
|
||||
{
|
||||
let MarketplaceAddRequest {
|
||||
source,
|
||||
ref_name,
|
||||
sparse_paths,
|
||||
} = request;
|
||||
let source = parse_marketplace_source(&source, ref_name)?;
|
||||
|
||||
let install_root = marketplace_install_root(codex_home);
|
||||
fs::create_dir_all(&install_root).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to create marketplace install directory {}: {err}",
|
||||
install_root.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
|
||||
if let Some(existing_root) =
|
||||
installed_marketplace_root_for_source(codex_home, &install_root, &install_metadata)?
|
||||
{
|
||||
let marketplace_name = validate_marketplace_source_root(&existing_root)?;
|
||||
record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)?;
|
||||
return Ok(MarketplaceAddOutcome {
|
||||
marketplace_name,
|
||||
source_display: source.display(),
|
||||
installed_root: AbsolutePathBuf::try_from(existing_root).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to resolve installed marketplace root: {err}"
|
||||
))
|
||||
})?,
|
||||
already_added: true,
|
||||
});
|
||||
}
|
||||
|
||||
let staging_root = marketplace_staging_root(&install_root);
|
||||
fs::create_dir_all(&staging_root).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to create marketplace staging directory {}: {err}",
|
||||
staging_root.display()
|
||||
))
|
||||
})?;
|
||||
let staged_root = Builder::new()
|
||||
.prefix("marketplace-add-")
|
||||
.tempdir_in(&staging_root)
|
||||
.map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to create temporary marketplace directory in {}: {err}",
|
||||
staging_root.display()
|
||||
))
|
||||
})?;
|
||||
let staged_root = staged_root.keep();
|
||||
|
||||
let MarketplaceSource::Git { url, ref_name } = &source;
|
||||
clone_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
|
||||
|
||||
let marketplace_name = validate_marketplace_source_root(&staged_root)?;
|
||||
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
return Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
|
||||
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
|
||||
if destination.exists() {
|
||||
return Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"marketplace '{marketplace_name}' is already added from a different source; remove it before adding {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
|
||||
replace_marketplace_root(&staged_root, &destination).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to install marketplace at {}: {err}",
|
||||
destination.display()
|
||||
))
|
||||
})?;
|
||||
if let Err(err) =
|
||||
record_added_marketplace_entry(codex_home, &marketplace_name, &install_metadata)
|
||||
{
|
||||
if let Err(rollback_err) = fs::rename(&destination, &staged_root) {
|
||||
return Err(MarketplaceAddError::Internal(format!(
|
||||
"{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}",
|
||||
destination.display()
|
||||
)));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(MarketplaceAddOutcome {
|
||||
marketplace_name,
|
||||
source_display: source.display(),
|
||||
installed_root: AbsolutePathBuf::try_from(destination).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to resolve installed marketplace root: {err}"
|
||||
))
|
||||
})?,
|
||||
already_added: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn add_marketplace_sync_installs_marketplace_and_updates_config() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let source_root = TempDir::new()?;
|
||||
write_marketplace_source(source_root.path(), "remote copy")?;
|
||||
|
||||
let result = add_marketplace_sync_with_cloner(
|
||||
codex_home.path(),
|
||||
MarketplaceAddRequest {
|
||||
source: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
sparse_paths: Vec::new(),
|
||||
},
|
||||
|_url, _ref_name, _sparse_paths, destination| {
|
||||
copy_dir_all(source_root.path(), destination)
|
||||
.map_err(|err| MarketplaceAddError::Internal(err.to_string()))
|
||||
},
|
||||
)?;
|
||||
|
||||
assert_eq!(result.marketplace_name, "debug");
|
||||
assert_eq!(result.source_display, "https://github.com/owner/repo.git");
|
||||
assert!(!result.already_added);
|
||||
assert!(
|
||||
result
|
||||
.installed_root
|
||||
.as_path()
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
);
|
||||
|
||||
let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;
|
||||
assert!(config.contains("[marketplaces.debug]"));
|
||||
assert!(config.contains("source_type = \"git\""));
|
||||
assert!(config.contains("source = \"https://github.com/owner/repo.git\""));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_marketplace_source(source: &Path, marker: &str) -> std::io::Result<()> {
|
||||
fs::create_dir_all(source.join(".agents/plugins"))?;
|
||||
fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
|
||||
fs::write(
|
||||
source.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/sample"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
fs::write(
|
||||
source.join("plugins/sample/.codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)?;
|
||||
fs::write(source.join("plugins/sample/marker.txt"), marker)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(destination)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let source_path = entry.path();
|
||||
let destination_path = destination.join(entry.file_name());
|
||||
if source_path.is_dir() {
|
||||
copy_dir_all(&source_path, &destination_path)?;
|
||||
} else {
|
||||
fs::copy(&source_path, &destination_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
137
codex-rs/core/src/plugins/marketplace_add/install.rs
Normal file
137
codex-rs/core/src/plugins/marketplace_add/install.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use super::MarketplaceAddError;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub(super) fn clone_git_source(
|
||||
url: &str,
|
||||
ref_name: Option<&str>,
|
||||
sparse_paths: &[String],
|
||||
destination: &Path,
|
||||
) -> Result<(), MarketplaceAddError> {
|
||||
let destination_string = destination.to_string_lossy().to_string();
|
||||
if sparse_paths.is_empty() {
|
||||
run_git(
|
||||
&["clone", url, destination_string.as_str()],
|
||||
/*cwd*/ None,
|
||||
)?;
|
||||
if let Some(ref_name) = ref_name {
|
||||
run_git(
|
||||
&["checkout", ref_name],
|
||||
Some(Path::new(&destination_string)),
|
||||
)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
run_git(
|
||||
&[
|
||||
"clone",
|
||||
"--filter=blob:none",
|
||||
"--no-checkout",
|
||||
url,
|
||||
destination_string.as_str(),
|
||||
],
|
||||
/*cwd*/ None,
|
||||
)?;
|
||||
let mut sparse_args = vec!["sparse-checkout", "set"];
|
||||
sparse_args.extend(sparse_paths.iter().map(String::as_str));
|
||||
run_git(&sparse_args, Some(destination))?;
|
||||
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn safe_marketplace_dir_name(
|
||||
marketplace_name: &str,
|
||||
) -> Result<String, MarketplaceAddError> {
|
||||
let safe = marketplace_name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let safe = safe.trim_matches('.').to_string();
|
||||
if safe.is_empty() || safe == ".." {
|
||||
return Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"marketplace name '{marketplace_name}' cannot be used as an install directory"
|
||||
)));
|
||||
}
|
||||
Ok(safe)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_marketplace_destination_is_inside_install_root(
|
||||
install_root: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), MarketplaceAddError> {
|
||||
let install_root = install_root.canonicalize().map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to resolve marketplace install root {}: {err}",
|
||||
install_root.display()
|
||||
))
|
||||
})?;
|
||||
let destination_parent = destination
|
||||
.parent()
|
||||
.ok_or_else(|| {
|
||||
MarketplaceAddError::Internal("marketplace destination has no parent".to_string())
|
||||
})?
|
||||
.canonicalize()
|
||||
.map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to resolve marketplace destination parent {}: {err}",
|
||||
destination.display()
|
||||
))
|
||||
})?;
|
||||
if !destination_parent.starts_with(&install_root) {
|
||||
return Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"marketplace destination {} is outside install root {}",
|
||||
destination.display(),
|
||||
install_root.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn replace_marketplace_root(
|
||||
staged_root: &Path,
|
||||
destination: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::rename(staged_root, destination)
|
||||
}
|
||||
|
||||
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".staging")
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> {
|
||||
let mut command = Command::new("git");
|
||||
command.args(args);
|
||||
command.env("GIT_TERMINAL_PROMPT", "0");
|
||||
if let Some(cwd) = cwd {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
let output = command.output().map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!("failed to run git {}: {err}", args.join(" ")))
|
||||
})?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Err(MarketplaceAddError::Internal(format!(
|
||||
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
||||
args.join(" "),
|
||||
output.status,
|
||||
stdout.trim(),
|
||||
stderr.trim()
|
||||
)))
|
||||
}
|
||||
230
codex-rs/core/src/plugins/marketplace_add/metadata.rs
Normal file
230
codex-rs/core/src/plugins/marketplace_add/metadata.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use super::MarketplaceAddError;
|
||||
use super::MarketplaceSource;
|
||||
use crate::plugins::validate_marketplace_root;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct MarketplaceInstallMetadata {
|
||||
source: InstalledMarketplaceSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum InstalledMarketplaceSource {
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
sparse_paths: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn record_added_marketplace_entry(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
install_metadata: &MarketplaceInstallMetadata,
|
||||
) -> Result<(), MarketplaceAddError> {
|
||||
let source = install_metadata.config_source();
|
||||
let timestamp = utc_timestamp_now()?;
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: ×tamp,
|
||||
source_type: install_metadata.config_source_type(),
|
||||
source: &source,
|
||||
ref_name: install_metadata.ref_name(),
|
||||
sparse_paths: install_metadata.sparse_paths(),
|
||||
};
|
||||
|
||||
record_user_marketplace(codex_home, marketplace_name, &update).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to add marketplace '{marketplace_name}' to user config.toml: {err}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn installed_marketplace_root_for_source(
|
||||
codex_home: &Path,
|
||||
install_root: &Path,
|
||||
install_metadata: &MarketplaceInstallMetadata,
|
||||
) -> Result<Option<PathBuf>, MarketplaceAddError> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let config = match fs::read_to_string(&config_path) {
|
||||
Ok(config) => config,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(MarketplaceAddError::Internal(format!(
|
||||
"failed to read user config {}: {err}",
|
||||
config_path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let config: toml::Value = toml::from_str(&config).map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!(
|
||||
"failed to parse user config {}: {err}",
|
||||
config_path.display()
|
||||
))
|
||||
})?;
|
||||
let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for (marketplace_name, marketplace) in marketplaces {
|
||||
if !install_metadata.matches_config(marketplace) {
|
||||
continue;
|
||||
}
|
||||
let root = install_root.join(marketplace_name);
|
||||
if validate_marketplace_root(&root).is_ok() {
|
||||
return Ok(Some(root));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
impl MarketplaceInstallMetadata {
|
||||
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
|
||||
let source = match source {
|
||||
MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git {
|
||||
url: url.clone(),
|
||||
ref_name: ref_name.clone(),
|
||||
sparse_paths: sparse_paths.to_vec(),
|
||||
},
|
||||
};
|
||||
Self { source }
|
||||
}
|
||||
|
||||
fn config_source_type(&self) -> &'static str {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { .. } => "git",
|
||||
}
|
||||
}
|
||||
|
||||
fn config_source(&self) -> String {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { url, .. } => url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ref_name(&self) -> Option<&str> {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sparse_paths(&self) -> &[String] {
|
||||
match &self.source {
|
||||
InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_config(&self, marketplace: &toml::Value) -> bool {
|
||||
marketplace.get("source_type").and_then(toml::Value::as_str)
|
||||
== Some(self.config_source_type())
|
||||
&& marketplace.get("source").and_then(toml::Value::as_str)
|
||||
== Some(self.config_source().as_str())
|
||||
&& marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name()
|
||||
&& config_sparse_paths(marketplace) == self.sparse_paths()
|
||||
}
|
||||
}
|
||||
|
||||
fn config_sparse_paths(marketplace: &toml::Value) -> Vec<String> {
|
||||
marketplace
|
||||
.get("sparse_paths")
|
||||
.and_then(toml::Value::as_array)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(toml::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn utc_timestamp_now() -> Result<String, MarketplaceAddError> {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| {
|
||||
MarketplaceAddError::Internal(format!("system clock is before Unix epoch: {err}"))
|
||||
})?;
|
||||
Ok(format_utc_timestamp(duration.as_secs() as i64))
|
||||
}
|
||||
|
||||
fn format_utc_timestamp(seconds_since_epoch: i64) -> String {
|
||||
const SECONDS_PER_DAY: i64 = 86_400;
|
||||
let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY);
|
||||
let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY);
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
let hour = seconds_of_day / 3_600;
|
||||
let minute = (seconds_of_day % 3_600) / 60;
|
||||
let second = seconds_of_day % 60;
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
||||
}
|
||||
|
||||
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
|
||||
let days = days_since_epoch + 719_468;
|
||||
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() {
|
||||
assert_eq!(
|
||||
format_utc_timestamp(/*seconds_since_epoch*/ 0),
|
||||
"1970-01-01T00:00:00Z"
|
||||
);
|
||||
assert_eq!(
|
||||
format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200),
|
||||
"2026-04-10T00:00:00Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_marketplace_root_for_source_propagates_config_read_errors() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
fs::create_dir(&config_path).unwrap();
|
||||
|
||||
let install_root = codex_home.path().join("marketplaces");
|
||||
let source = MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
};
|
||||
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
|
||||
|
||||
let err = installed_marketplace_root_for_source(
|
||||
codex_home.path(),
|
||||
&install_root,
|
||||
&install_metadata,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string().contains(&format!(
|
||||
"failed to read user config {}:",
|
||||
config_path.display()
|
||||
)),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
255
codex-rs/core/src/plugins/marketplace_add/source.rs
Normal file
255
codex-rs/core/src/plugins/marketplace_add/source.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use super::MarketplaceAddError;
|
||||
use crate::plugins::validate_marketplace_root;
|
||||
use crate::plugins::validate_plugin_segment;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum MarketplaceSource {
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn parse_marketplace_source(
|
||||
source: &str,
|
||||
explicit_ref: Option<String>,
|
||||
) -> Result<MarketplaceSource, MarketplaceAddError> {
|
||||
let source = source.trim();
|
||||
if source.is_empty() {
|
||||
return Err(MarketplaceAddError::InvalidRequest(
|
||||
"marketplace source must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (base_source, parsed_ref) = split_source_ref(source);
|
||||
let ref_name = explicit_ref.or(parsed_ref);
|
||||
|
||||
if looks_like_local_path(&base_source) {
|
||||
return Err(MarketplaceAddError::InvalidRequest(
|
||||
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if is_ssh_git_url(&base_source) || is_git_url(&base_source) {
|
||||
return Ok(MarketplaceSource::Git {
|
||||
url: normalize_git_url(&base_source),
|
||||
ref_name,
|
||||
});
|
||||
}
|
||||
|
||||
if looks_like_github_shorthand(&base_source) {
|
||||
return Ok(MarketplaceSource::Git {
|
||||
url: format!("https://github.com/{base_source}.git"),
|
||||
ref_name,
|
||||
});
|
||||
}
|
||||
|
||||
Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"invalid marketplace source format: {source}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) fn validate_marketplace_source_root(root: &Path) -> Result<String, MarketplaceAddError> {
|
||||
let marketplace_name = validate_marketplace_root(root)
|
||||
.map_err(|err| MarketplaceAddError::InvalidRequest(err.to_string()))?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(MarketplaceAddError::InvalidRequest)?;
|
||||
Ok(marketplace_name)
|
||||
}
|
||||
|
||||
fn split_source_ref(source: &str) -> (String, Option<String>) {
|
||||
if let Some((base, ref_name)) = source.rsplit_once('#') {
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
}
|
||||
if !source.contains("://")
|
||||
&& !is_ssh_git_url(source)
|
||||
&& let Some((base, ref_name)) = source.rsplit_once('@')
|
||||
{
|
||||
return (base.to_string(), non_empty_ref(ref_name));
|
||||
}
|
||||
(source.to_string(), None)
|
||||
}
|
||||
|
||||
fn non_empty_ref(ref_name: &str) -> Option<String> {
|
||||
let ref_name = ref_name.trim();
|
||||
(!ref_name.is_empty()).then(|| ref_name.to_string())
|
||||
}
|
||||
|
||||
fn normalize_git_url(url: &str) -> String {
|
||||
let url = url.trim_end_matches('/');
|
||||
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
|
||||
format!("{url}.git")
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_local_path(source: &str) -> bool {
|
||||
source.starts_with("./")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('/')
|
||||
|| source.starts_with("~/")
|
||||
|| source == "."
|
||||
|| source == ".."
|
||||
}
|
||||
|
||||
fn is_ssh_git_url(source: &str) -> bool {
|
||||
source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':')
|
||||
}
|
||||
|
||||
fn is_git_url(source: &str) -> bool {
|
||||
source.starts_with("http://") || source.starts_with("https://")
|
||||
}
|
||||
|
||||
fn looks_like_github_shorthand(source: &str) -> bool {
|
||||
let mut segments = source.split('/');
|
||||
let owner = segments.next();
|
||||
let repo = segments.next();
|
||||
let extra = segments.next();
|
||||
owner.is_some_and(is_github_shorthand_segment)
|
||||
&& repo.is_some_and(is_github_shorthand_segment)
|
||||
&& extra.is_none()
|
||||
}
|
||||
|
||||
fn is_github_shorthand_segment(segment: &str) -> bool {
|
||||
!segment.is_empty()
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
|
||||
}
|
||||
|
||||
impl MarketplaceSource {
|
||||
pub(super) fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Git { url, ref_name } => match ref_name {
|
||||
Some(ref_name) => format!("{url}#{ref_name}"),
|
||||
None => url.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_parses_ref_suffix() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_url_parses_fragment_ref() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"https://example.com/team/repo.git#v1",
|
||||
/*explicit_ref*/ None
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://example.com/team/repo.git".to_string(),
|
||||
ref_name: Some("v1".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ref_overrides_source_ref() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("owner/repo@main", Some("release".to_string())).unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("release".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_shorthand_and_git_url_normalize_to_same_source() {
|
||||
let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap();
|
||||
let git_url = parse_marketplace_source(
|
||||
"https://github.com/owner/repo.git",
|
||||
/*explicit_ref*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(shorthand, git_url);
|
||||
assert_eq!(
|
||||
shorthand,
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://github.com/owner/repo.git".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_github_https_source_parses_as_git_url() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "https://gitlab.com/owner/repo".to_string(),
|
||||
ref_name: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_url_source_is_rejected() {
|
||||
let err =
|
||||
parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("invalid marketplace source format"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_marketplace_source_rejects_local_directory_source() {
|
||||
let err = parse_marketplace_source("./marketplace", /*explicit_ref*/ None).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_url_parses_as_git_url() {
|
||||
assert_eq!(
|
||||
parse_marketplace_source(
|
||||
"ssh://git@github.com/owner/repo.git#main",
|
||||
/*explicit_ref*/ None,
|
||||
)
|
||||
.unwrap(),
|
||||
MarketplaceSource::Git {
|
||||
url: "ssh://git@github.com/owner/repo.git".to_string(),
|
||||
ref_name: Some("main".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod installed_marketplaces;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod marketplace_add;
|
||||
mod mentions;
|
||||
mod remote;
|
||||
mod render;
|
||||
@@ -58,6 +59,10 @@ pub use marketplace::MarketplacePluginInstallPolicy;
|
||||
pub use marketplace::MarketplacePluginPolicy;
|
||||
pub use marketplace::MarketplacePluginSource;
|
||||
pub use marketplace::validate_marketplace_root;
|
||||
pub use marketplace_add::MarketplaceAddError;
|
||||
pub use marketplace_add::MarketplaceAddOutcome;
|
||||
pub use marketplace_add::MarketplaceAddRequest;
|
||||
pub use marketplace_add::add_marketplace;
|
||||
pub use remote::RemotePluginFetchError;
|
||||
pub use remote::fetch_remote_featured_plugin_ids;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
|
||||
Reference in New Issue
Block a user