mirror of
https://github.com/openai/codex.git
synced 2026-03-04 05:33:19 +00:00
Compare commits
1 Commits
external_a
...
xl/plugins
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ce0d4ff4 |
@@ -932,6 +932,27 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProductSurface": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
@@ -3203,6 +3224,30 @@
|
||||
"title": "Skills/config/writeRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/install"
|
||||
],
|
||||
"title": "Plugin/installRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginInstallParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/installRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -787,6 +787,30 @@
|
||||
"title": "Skills/config/writeRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/install"
|
||||
],
|
||||
"title": "Plugin/installRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/PluginInstallParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/installRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -10795,6 +10819,34 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstallResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ProductSurface": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
|
||||
@@ -1276,6 +1276,30 @@
|
||||
"title": "Skills/config/writeRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"plugin/install"
|
||||
],
|
||||
"title": "Plugin/installRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/PluginInstallParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Plugin/installRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -8168,6 +8192,34 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
"type": "object"
|
||||
},
|
||||
"PluginInstallResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ProductSurface": {
|
||||
"enum": [
|
||||
"chatgpt",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"marketplaceName": {
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplaceName",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams";
|
||||
import type { LoginAccountParams } from "./v2/LoginAccountParams";
|
||||
import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams";
|
||||
import type { ModelListParams } from "./v2/ModelListParams";
|
||||
import type { PluginInstallParams } from "./v2/PluginInstallParams";
|
||||
import type { ReviewStartParams } from "./v2/ReviewStartParams";
|
||||
import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams";
|
||||
import type { SkillsListParams } from "./v2/SkillsListParams";
|
||||
@@ -47,4 +48,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
|
||||
/**
|
||||
* Request from the client to the server.
|
||||
*/
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
|
||||
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
|
||||
|
||||
@@ -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 PluginInstallParams = { marketplaceName: string, pluginName: string, cwd?: string | null, };
|
||||
@@ -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 PluginInstallResponse = Record<string, never>;
|
||||
@@ -123,6 +123,8 @@ export type { OverriddenMetadata } from "./OverriddenMetadata";
|
||||
export type { PatchApplyStatus } from "./PatchApplyStatus";
|
||||
export type { PatchChangeKind } from "./PatchChangeKind";
|
||||
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
|
||||
export type { PluginInstallParams } from "./PluginInstallParams";
|
||||
export type { PluginInstallResponse } from "./PluginInstallResponse";
|
||||
export type { ProductSurface } from "./ProductSurface";
|
||||
export type { ProfileV2 } from "./ProfileV2";
|
||||
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
|
||||
|
||||
@@ -260,6 +260,10 @@ client_request_definitions! {
|
||||
params: v2::SkillsConfigWriteParams,
|
||||
response: v2::SkillsConfigWriteResponse,
|
||||
},
|
||||
PluginInstall => "plugin/install" {
|
||||
params: v2::PluginInstallParams,
|
||||
response: v2::PluginInstallResponse,
|
||||
},
|
||||
TurnStart => "turn/start" {
|
||||
params: v2::TurnStartParams,
|
||||
inspect_params: true,
|
||||
|
||||
@@ -2467,6 +2467,21 @@ pub struct SkillsConfigWriteResponse {
|
||||
pub effective_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstallParams {
|
||||
pub marketplace_name: String,
|
||||
pub plugin_name: String,
|
||||
#[ts(optional = nullable)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstallResponse {}
|
||||
|
||||
impl From<CoreSkillMetadata> for SkillMetadata {
|
||||
fn from(value: CoreSkillMetadata) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -151,6 +151,7 @@ Example with notification opt-out:
|
||||
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
|
||||
- `app/list` — list available apps.
|
||||
- `skills/config/write` — write user-level skill config by path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplaceName` (**under development; do not call from production clients yet**).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
|
||||
|
||||
@@ -77,6 +77,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
|
||||
use codex_app_server_protocol::MockExperimentalMethodResponse;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::ProductSurface as ApiProductSurface;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
|
||||
@@ -193,6 +195,8 @@ use codex_core::mcp::collect_mcp_snapshot;
|
||||
use codex_core::mcp::group_tools_by_server;
|
||||
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_core::parse_cursor;
|
||||
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
use codex_core::read_head_for_summary;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::rollout_date_parts;
|
||||
@@ -648,6 +652,10 @@ impl CodexMessageProcessor {
|
||||
self.skills_config_write(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::PluginInstall { request_id, params } => {
|
||||
self.plugin_install(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::TurnStart { request_id, params } => {
|
||||
self.turn_start(
|
||||
to_connection_request_id(request_id),
|
||||
@@ -4678,6 +4686,56 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) {
|
||||
let PluginInstallParams {
|
||||
marketplace_name,
|
||||
plugin_name,
|
||||
cwd,
|
||||
} = params;
|
||||
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let request = PluginInstallRequest {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
cwd: cwd.unwrap_or_else(|| self.config.cwd.clone()),
|
||||
};
|
||||
|
||||
match plugins_manager.install_plugin(request).await {
|
||||
Ok(_) => {
|
||||
plugins_manager.clear_cache();
|
||||
self.thread_manager.skills_manager().clear_cache();
|
||||
self.outgoing
|
||||
.send_response(request_id, PluginInstallResponse {})
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_invalid_request() {
|
||||
self.send_invalid_request_error(request_id, err.to_string())
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
match err {
|
||||
CorePluginInstallError::Config(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to persist installed plugin config: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginInstallError::Join(err) => {
|
||||
self.send_internal_error(
|
||||
request_id,
|
||||
format!("failed to install plugin: {err}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CorePluginInstallError::Marketplace(_) | CorePluginInstallError::Store(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn turn_start(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::marketplace::MarketplaceError;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginInstallRequest;
|
||||
use super::store::PluginInstallResult;
|
||||
use super::store::PluginStore;
|
||||
use super::store::PluginStoreError;
|
||||
@@ -34,6 +35,13 @@ use tracing::warn;
|
||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
pub cwd: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin {
|
||||
pub config_name: String,
|
||||
@@ -149,10 +157,17 @@ impl PluginsManager {
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallResult, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&request.cwd,
|
||||
&request.plugin_name,
|
||||
&request.marketplace_name,
|
||||
)?;
|
||||
let store = self.store.clone();
|
||||
let result = tokio::task::spawn_blocking(move || store.install(request))
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
|
||||
})
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
|
||||
ConfigService::new_with_defaults(self.codex_home.clone())
|
||||
.write_value(ConfigValueWriteParams {
|
||||
@@ -174,6 +189,9 @@ impl PluginsManager {
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginInstallError {
|
||||
#[error("{0}")]
|
||||
Marketplace(#[from] MarketplaceError),
|
||||
|
||||
#[error("{0}")]
|
||||
Store(#[from] PluginStoreError),
|
||||
|
||||
@@ -188,6 +206,10 @@ impl PluginInstallError {
|
||||
fn join(source: tokio::task::JoinError) -> Self {
|
||||
Self::Join(source)
|
||||
}
|
||||
|
||||
pub fn is_invalid_request(&self) -> bool {
|
||||
matches!(self, Self::Marketplace(_) | Self::Store(_))
|
||||
}
|
||||
}
|
||||
|
||||
fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
|
||||
@@ -719,12 +741,36 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_plugin(
|
||||
&repo_root.join(".agents/plugins"),
|
||||
"sample-plugin",
|
||||
"sample-plugin",
|
||||
);
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./sample-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
source_path: tmp.path().join("sample-plugin"),
|
||||
marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
marketplace_name: "debug".to_string(),
|
||||
cwd: repo_root.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
370
codex-rs/core/src/plugins/marketplace.rs
Normal file
370
codex-rs/core/src/plugins/marketplace.rs
Normal file
@@ -0,0 +1,370 @@
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use crate::git_info::get_git_repo_root;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedMarketplacePlugin {
|
||||
pub plugin_id: PluginId,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceError {
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
context: &'static str,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("invalid marketplace file `{path}`: {message}")]
|
||||
InvalidMarketplaceFile { path: PathBuf, message: String },
|
||||
|
||||
#[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")]
|
||||
PluginNotFound {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"multiple marketplace plugin entries matched `{plugin_name}` in marketplace `{marketplace_name}`"
|
||||
)]
|
||||
DuplicatePlugin {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPlugin(String),
|
||||
}
|
||||
|
||||
impl MarketplaceError {
|
||||
fn io(context: &'static str, source: io::Error) -> Self {
|
||||
Self::Io { context, source }
|
||||
}
|
||||
}
|
||||
|
||||
// For now, marketplace discovery always reads from disk so installs see the latest
|
||||
// marketplace.json contents without any in-memory cache invalidation.
|
||||
pub fn resolve_marketplace_plugin(
|
||||
cwd: &Path,
|
||||
plugin_name: &str,
|
||||
marketplace_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
resolve_marketplace_plugin_from_paths(
|
||||
&discover_marketplace_paths(cwd),
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_marketplace_plugin_from_paths(
|
||||
marketplace_paths: &[PathBuf],
|
||||
plugin_name: &str,
|
||||
marketplace_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
for marketplace_path in marketplace_paths {
|
||||
let marketplace = load_marketplace(&marketplace_path)?;
|
||||
let discovered_marketplace_name = marketplace.name;
|
||||
let mut matches = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|plugin| plugin.name == plugin_name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if discovered_marketplace_name != marketplace_name || matches.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches.len() > 1 {
|
||||
return Err(MarketplaceError::DuplicatePlugin {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(plugin) = matches.pop() {
|
||||
let plugin_id = PluginId::new(plugin.name, marketplace_name.to_string()).map_err(
|
||||
|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
},
|
||||
)?;
|
||||
return Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(&marketplace_path, plugin.source)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
if let Some(repo_root) = get_git_repo_root(cwd) {
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = home_dir() {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file() {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?;
|
||||
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_plugin_source_path(
|
||||
marketplace_path: &Path,
|
||||
source: MarketplacePluginSource,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
match source {
|
||||
MarketplacePluginSource::Local { path } => {
|
||||
let Some(path) = path.strip_prefix("./") else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must start with `./`".to_string(),
|
||||
});
|
||||
};
|
||||
if path.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let relative_source_path = Path::new(path);
|
||||
if relative_source_path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must stay within the marketplace directory"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let source_path = marketplace_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(relative_source_path);
|
||||
AbsolutePathBuf::try_from(source_path).map_err(|err| {
|
||||
MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: format!("plugin source path must resolve to an absolute path: {err}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MarketplaceFile {
|
||||
name: String,
|
||||
plugins: Vec<MarketplacePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MarketplacePlugin {
|
||||
name: String,
|
||||
source: MarketplacePluginSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "lowercase")]
|
||||
enum MarketplacePluginSource {
|
||||
Local { path: String },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join("nested")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved =
|
||||
resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1"))
|
||||
.unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_reports_missing_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{"name":"codex-curated","plugins":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin `missing` was not found in marketplace `codex-curated`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let home_marketplace = home_root.join(".agents/plugins/marketplace.json");
|
||||
let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
|
||||
fs::write(
|
||||
home_marketplace.clone(),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_marketplace.clone(),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin_from_paths(
|
||||
&[repo_marketplace, home_marketplace],
|
||||
"local-plugin",
|
||||
"codex-curated",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/repo-plugin"),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "../plugin-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err =
|
||||
resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"invalid marketplace file `{}`: local plugin source path must start with `./`",
|
||||
repo_root.join(".agents/plugins/marketplace.json").display()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod store;
|
||||
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginsManager;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub use store::PluginId;
|
||||
pub use store::PluginInstallRequest;
|
||||
pub use store::PluginInstallResult;
|
||||
|
||||
@@ -7,14 +7,13 @@ use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_MARKETPLACE_NAME: &str = "debug";
|
||||
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub source_path: PathBuf,
|
||||
pub marketplace_name: Option<String>,
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginIdError {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -24,34 +23,32 @@ pub struct PluginId {
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginStoreError> {
|
||||
validate_plugin_segment(&plugin_name, "plugin name")
|
||||
.map_err(PluginStoreError::InvalidPluginKey)?;
|
||||
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
|
||||
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(PluginStoreError::InvalidPluginKey)?;
|
||||
.map_err(PluginIdError::Invalid)?;
|
||||
Ok(Self {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(plugin_key: &str) -> Result<Self, PluginStoreError> {
|
||||
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
|
||||
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
|
||||
return Err(PluginStoreError::InvalidPluginKey(format!(
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
};
|
||||
if plugin_name.is_empty() || marketplace_name.is_empty() {
|
||||
return Err(PluginStoreError::InvalidPluginKey(format!(
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
}
|
||||
|
||||
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginStoreError::InvalidPluginKey(message) => {
|
||||
PluginStoreError::InvalidPluginKey(format!("{message} in `{plugin_key}`"))
|
||||
PluginIdError::Invalid(message) => {
|
||||
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,29 +94,24 @@ impl PluginStore {
|
||||
|
||||
pub fn install(
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
source_path: PathBuf,
|
||||
plugin_id: PluginId,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
let source_path = request.source_path;
|
||||
if !source_path.is_dir() {
|
||||
return Err(PluginStoreError::InvalidPlugin(format!(
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source path is not a directory: {}",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let plugin_name = plugin_name_for_source(&source_path)?;
|
||||
let marketplace_name = request
|
||||
.marketplace_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_MARKETPLACE_NAME.to_string());
|
||||
if plugin_name != plugin_id.plugin_name {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
|
||||
plugin_id.plugin_name
|
||||
)));
|
||||
}
|
||||
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
|
||||
let plugin_id = match PluginId::new(plugin_name, marketplace_name) {
|
||||
Ok(plugin_id) => plugin_id,
|
||||
Err(PluginStoreError::InvalidPluginKey(message)) => {
|
||||
return Err(PluginStoreError::InvalidPlugin(message));
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let installed_path = self
|
||||
.plugin_root(&plugin_id, &plugin_version)
|
||||
.into_path_buf();
|
||||
@@ -151,10 +143,7 @@ pub enum PluginStoreError {
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPlugin(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPluginKey(String),
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl PluginStoreError {
|
||||
@@ -166,14 +155,14 @@ impl PluginStoreError {
|
||||
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
||||
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return Err(PluginStoreError::InvalidPlugin(format!(
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"missing plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let manifest = load_plugin_manifest(source_path).ok_or_else(|| {
|
||||
PluginStoreError::InvalidPlugin(format!(
|
||||
PluginStoreError::Invalid(format!(
|
||||
"missing or invalid plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
))
|
||||
@@ -181,7 +170,7 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
|
||||
|
||||
let plugin_name = plugin_manifest_name(&manifest, source_path);
|
||||
validate_plugin_segment(&plugin_name, "plugin name")
|
||||
.map_err(PluginStoreError::InvalidPlugin)
|
||||
.map_err(PluginStoreError::Invalid)
|
||||
.map(|_| plugin_name)
|
||||
}
|
||||
|
||||
@@ -265,19 +254,17 @@ mod tests {
|
||||
fn install_copies_plugin_into_default_marketplace() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(PluginInstallRequest {
|
||||
source_path: tmp.path().join("sample-plugin"),
|
||||
marketplace_name: None,
|
||||
})
|
||||
.install(tmp.path().join("sample-plugin"), plugin_id.clone())
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: installed_path.clone(),
|
||||
}
|
||||
@@ -290,19 +277,16 @@ mod tests {
|
||||
fn install_uses_manifest_name_for_destination_and_key() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
||||
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(PluginInstallRequest {
|
||||
source_path: tmp.path().join("source-dir"),
|
||||
marketplace_name: Some("market".to_string()),
|
||||
})
|
||||
.install(tmp.path().join("source-dir"), plugin_id.clone())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id: PluginId::new("manifest-name".to_string(), "market".to_string())
|
||||
.unwrap(),
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"),
|
||||
}
|
||||
@@ -342,10 +326,10 @@ mod tests {
|
||||
write_plugin(tmp.path(), "source-dir", "../../etc");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(PluginInstallRequest {
|
||||
source_path: tmp.path().join("source-dir"),
|
||||
marketplace_name: None,
|
||||
})
|
||||
.install(
|
||||
tmp.path().join("source-dir"),
|
||||
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
@@ -356,19 +340,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn install_rejects_marketplace_names_with_path_separators() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "sample-plugin");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(PluginInstallRequest {
|
||||
source_path: tmp.path().join("source-dir"),
|
||||
marketplace_name: Some("../../etc".to_string()),
|
||||
})
|
||||
.unwrap_err();
|
||||
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
tmp.path().join("source-dir"),
|
||||
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user