mirror of
https://github.com/openai/codex.git
synced 2026-03-14 02:33:48 +00:00
Compare commits
1 Commits
dev/steve/
...
dev/sayan/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6f7c04bf |
@@ -8672,8 +8672,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5457,8 +5457,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -51,8 +51,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -125,8 +125,12 @@
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
"description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
// 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 PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
|
||||
export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array<string>, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null,
|
||||
/**
|
||||
* Starter prompts for the plugin. Capped at 3 entries with a maximum of
|
||||
* 128 characters per entry.
|
||||
*/
|
||||
defaultPrompt: Array<string> | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
|
||||
|
||||
@@ -3144,7 +3144,9 @@ pub struct PluginInterface {
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
/// Starter prompts for the plugin. Capped at 3 entries with a maximum of
|
||||
/// 128 characters per entry.
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -407,7 +407,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
"websiteURL": "https://openai.com/",
|
||||
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
||||
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
||||
"defaultPrompt": "Starter prompt for trying a plugin",
|
||||
"defaultPrompt": [
|
||||
"Starter prompt for trying a plugin",
|
||||
"Find my next action"
|
||||
],
|
||||
"brandColor": "#3B82F6",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
@@ -466,6 +469,13 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
interface.terms_of_service_url.as_deref(),
|
||||
Some("https://openai.com/policies/row-terms-of-use/")
|
||||
);
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec![
|
||||
"Starter prompt for trying a plugin".to_string(),
|
||||
"Find my next action".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
interface.composer_icon,
|
||||
Some(AbsolutePathBuf::try_from(
|
||||
@@ -488,6 +498,72 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r##"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"defaultPrompt": "Starter prompt for trying a plugin"
|
||||
}
|
||||
}"##,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
let plugin = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.flat_map(|marketplace| marketplace.plugins.iter())
|
||||
.find(|plugin| plugin.name == "demo-plugin")
|
||||
.expect("expected demo-plugin entry");
|
||||
assert_eq!(
|
||||
plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -58,7 +58,10 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
|
||||
"websiteURL": "https://openai.com/",
|
||||
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
||||
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
||||
"defaultPrompt": "Starter prompt for trying a plugin",
|
||||
"defaultPrompt": [
|
||||
"Draft the reply",
|
||||
"Find my next action"
|
||||
],
|
||||
"brandColor": "#3B82F6",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
@@ -162,6 +165,18 @@ enabled = true
|
||||
.and_then(|interface| interface.category.as_deref()),
|
||||
Some("Design")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec![
|
||||
"Draft the reply".to_string(),
|
||||
"Find my next action".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(response.plugin.skills.len(), 1);
|
||||
assert_eq!(
|
||||
response.plugin.skills[0].name,
|
||||
@@ -183,6 +198,70 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r##"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"defaultPrompt": "Starter prompt for trying a plugin"
|
||||
}
|
||||
}"##,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -43,7 +46,7 @@ pub struct PluginManifestInterfaceSummary {
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
@@ -75,7 +78,7 @@ struct PluginManifestInterface {
|
||||
#[serde(alias = "termsOfServiceURL")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_prompt: Option<String>,
|
||||
default_prompt: Option<PluginManifestDefaultPrompt>,
|
||||
#[serde(default)]
|
||||
brand_color: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -86,6 +89,21 @@ struct PluginManifestInterface {
|
||||
screenshots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PluginManifestDefaultPrompt {
|
||||
String(String),
|
||||
List(Vec<PluginManifestDefaultPromptEntry>),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PluginManifestDefaultPromptEntry {
|
||||
String(String),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
@@ -128,7 +146,7 @@ pub(crate) fn plugin_manifest_interface(
|
||||
website_url: interface.website_url.clone(),
|
||||
privacy_policy_url: interface.privacy_policy_url.clone(),
|
||||
terms_of_service_url: interface.terms_of_service_url.clone(),
|
||||
default_prompt: interface.default_prompt.clone(),
|
||||
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
|
||||
brand_color: interface.brand_color.clone(),
|
||||
composer_icon: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
@@ -190,6 +208,99 @@ fn resolve_interface_asset_path(
|
||||
resolve_manifest_path(plugin_root, field, path)
|
||||
}
|
||||
|
||||
fn resolve_default_prompts(
|
||||
plugin_root: &Path,
|
||||
value: Option<&PluginManifestDefaultPrompt>,
|
||||
) -> Option<Vec<String>> {
|
||||
match value? {
|
||||
PluginManifestDefaultPrompt::String(prompt) => {
|
||||
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
|
||||
.map(|prompt| vec![prompt])
|
||||
}
|
||||
PluginManifestDefaultPrompt::List(values) => {
|
||||
let mut prompts = Vec::new();
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
match item {
|
||||
PluginManifestDefaultPromptEntry::String(prompt) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
if let Some(prompt) =
|
||||
resolve_default_prompt_str(plugin_root, &field, prompt)
|
||||
{
|
||||
prompts.push(prompt);
|
||||
}
|
||||
}
|
||||
PluginManifestDefaultPromptEntry::Invalid(value) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
&field,
|
||||
&format!("expected a string, found {}", json_value_type(value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(!prompts.is_empty()).then_some(prompts)
|
||||
}
|
||||
PluginManifestDefaultPrompt::Invalid(value) => {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!(
|
||||
"expected a string or array of strings, found {}",
|
||||
json_value_type(value)
|
||||
),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
|
||||
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if prompt.is_empty() {
|
||||
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
|
||||
return None;
|
||||
}
|
||||
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
field,
|
||||
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"ignoring {field}: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
fn json_value_type(value: &JsonValue) -> &'static str {
|
||||
match value {
|
||||
JsonValue::Null => "null",
|
||||
JsonValue::Bool(_) => "boolean",
|
||||
JsonValue::Number(_) => "number",
|
||||
JsonValue::String(_) => "string",
|
||||
JsonValue::Array(_) => "array",
|
||||
JsonValue::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_manifest_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
@@ -232,3 +343,112 @@ fn resolve_manifest_path(
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_DEFAULT_PROMPT_LEN;
|
||||
use super::PluginManifest;
|
||||
use super::plugin_manifest_interface;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_manifest(plugin_root: &Path, interface: &str) {
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "demo-plugin",
|
||||
"interface": {interface}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write manifest");
|
||||
}
|
||||
|
||||
fn load_manifest(plugin_root: &Path) -> PluginManifest {
|
||||
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
|
||||
let contents = fs::read_to_string(manifest_path).expect("read manifest");
|
||||
serde_json::from_str(&contents).expect("parse manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_accepts_legacy_default_prompt_string() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": " Summarize my inbox "
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec!["Summarize my inbox".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_normalizes_default_prompt_array() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
&format!(
|
||||
r#"{{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": [
|
||||
" Summarize my inbox ",
|
||||
123,
|
||||
"{too_long}",
|
||||
" ",
|
||||
"Draft the reply ",
|
||||
"Find my next action",
|
||||
"Archive old mail"
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec![
|
||||
"Summarize my inbox".to_string(),
|
||||
"Draft the reply".to_string(),
|
||||
"Find my next action".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": { "text": "Summarize my inbox" }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
|
||||
assert_eq!(interface.default_prompt, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user