mirror of
https://github.com/openai/codex.git
synced 2026-11-02 08:19:52 +00:00
## What changed - Add an optional `source` selector to `externalAgentConfig/detect`, while preserving the existing behavior for omitted, `null`, and unrecognized values. - Pass `claude-code` as the source for TUI detection and import requests. - Move source-specific migration behavior behind an adapter boundary and consolidate session migration into `codex-external-agent-migration`. ## Testing - Cover explicit, omitted, and unknown detection source values with app-server protocol tests. - Add unit coverage for the extracted migration and hook helpers. GitOrigin-RevId: 63bd3dcb485f3ad7bd996022db4e351506aeea77
2973 lines
99 KiB
Rust
2973 lines
99 KiB
Rust
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use std::io;
|
|
use tempfile::TempDir;
|
|
|
|
const EXTERNAL_AGENT_PROJECT_CONFIG_FILE: &str = ".claude.json";
|
|
const EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR: &str = ".claude-plugin";
|
|
const SOURCE_EXTERNAL_AGENT_NAME: &str = "claude";
|
|
const SOURCE_EXTERNAL_AGENT_DISPLAY_NAME: &str = "Claude";
|
|
const SOURCE_EXTERNAL_AGENT_PRODUCT_NAME: &str = "Claude Code";
|
|
const SOURCE_EXTERNAL_AGENT_UPPER_NAME: &str = "CLAUDE";
|
|
const SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME: &str = "CLAUDE-CODE";
|
|
|
|
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
(root, external_agent_home, codex_home)
|
|
}
|
|
|
|
fn service_for_paths(
|
|
external_agent_home: PathBuf,
|
|
codex_home: PathBuf,
|
|
) -> ExternalAgentConfigService {
|
|
ExternalAgentConfigService::new_for_test(codex_home, external_agent_home)
|
|
}
|
|
|
|
fn github_plugin_details() -> MigrationDetails {
|
|
MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["formatter".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn assert_single_plugin_raw_error(
|
|
raw_errors: &[ExternalAgentConfigImportRawError],
|
|
failure_stage: &str,
|
|
source: &str,
|
|
error_type: Option<&str>,
|
|
) {
|
|
assert_eq!(raw_errors.len(), 1);
|
|
let raw_error = &raw_errors[0];
|
|
assert_eq!(
|
|
raw_error.item_type,
|
|
ExternalAgentConfigMigrationItemType::Plugins
|
|
);
|
|
assert_eq!(raw_error.failure_stage, failure_stage);
|
|
assert_eq!(raw_error.error_type.as_deref(), error_type);
|
|
assert_eq!(raw_error.cwd, None);
|
|
assert_eq!(raw_error.source.as_deref(), Some(source));
|
|
assert!(!raw_error.message.is_empty());
|
|
}
|
|
|
|
fn import_success(
|
|
item_type: ExternalAgentConfigMigrationItemType,
|
|
cwd: Option<PathBuf>,
|
|
source: impl Into<String>,
|
|
target: impl Into<String>,
|
|
) -> ExternalAgentConfigImportSuccess {
|
|
ExternalAgentConfigImportSuccess {
|
|
item_type,
|
|
cwd,
|
|
source: Some(source.into()),
|
|
target: Some(target.into()),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_lists_config_skills_and_agents_md() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let agents_skills = codex_home
|
|
.parent()
|
|
.map(|parent| parent.join(".agents").join("skills"))
|
|
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
|
|
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills");
|
|
fs::write(
|
|
external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_NAME} rules"),
|
|
)
|
|
.expect("write external agent md");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","env":{{"FOO":"bar"}}}}"#),
|
|
)
|
|
.expect("write settings");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home.clone())
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
let expected = vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: format!(
|
|
"Migrate {} into {}",
|
|
external_agent_home.join("settings.json").display(),
|
|
codex_home.join("config.toml").display()
|
|
),
|
|
cwd: None,
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
|
description: format!(
|
|
"Migrate skills from {} to {}",
|
|
external_agent_home.join("skills").display(),
|
|
agents_skills.display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
skills: named_migrations(vec!["skill-a".to_string()]),
|
|
..Default::default()
|
|
}),
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: format!(
|
|
"Migrate {} to {}",
|
|
external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD).display(),
|
|
codex_home.join("AGENTS.md").display()
|
|
),
|
|
cwd: None,
|
|
details: None,
|
|
},
|
|
];
|
|
|
|
assert_eq!(items, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_lists_recent_sessions() {
|
|
let (root, external_agent_home, codex_home) = fixture_paths();
|
|
let project_root = root.path().join("repo");
|
|
let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
|
let session_path = external_agent_home
|
|
.join("projects")
|
|
.join("repo")
|
|
.join("session.jsonl");
|
|
fs::create_dir_all(&project_root).expect("create project root");
|
|
fs::create_dir_all(session_path.parent().expect("session parent")).expect("create sessions");
|
|
fs::write(
|
|
&session_path,
|
|
serde_json::json!({
|
|
"type": "user",
|
|
"cwd": &project_root,
|
|
"timestamp": &recent_timestamp,
|
|
"message": { "content": "first request" },
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write session");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Sessions,
|
|
description: format!(
|
|
"Migrate recent sessions from {}",
|
|
external_agent_home.join("projects").display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: Vec::new(),
|
|
sessions: vec![ExternalAgentSessionMigration {
|
|
path: session_path,
|
|
cwd: project_root,
|
|
title: Some("first request".to_string()),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_lists_agents_md_for_each_cwd() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
let nested = repo_root.join("nested").join("child");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(&nested).expect("create nested");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
|
|
)
|
|
.expect("write source");
|
|
|
|
let items = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![nested, repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
let expected = vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: format!(
|
|
"Migrate {} to {}",
|
|
repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(),
|
|
repo_root.join("AGENTS.md").display(),
|
|
),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: format!(
|
|
"Migrate {} to {}",
|
|
repo_root.join(EXTERNAL_AGENT_CONFIG_MD).display(),
|
|
repo_root.join("AGENTS.md").display(),
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: None,
|
|
},
|
|
];
|
|
|
|
assert_eq!(items, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
let codex_home = root.path().join(".codex");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("skills")
|
|
.join("skill-a"),
|
|
)
|
|
.expect("create repo skills");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::write(codex_home.join("config.toml"), "this is not valid = [toml")
|
|
.expect("write invalid codex config");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{"env":{"FOO":"bar"}}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("skills")
|
|
.join("skill-a")
|
|
.join("SKILL.md"),
|
|
format!(
|
|
"Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities."
|
|
),
|
|
)
|
|
.expect("write skill");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
|
|
)
|
|
.expect("write agents");
|
|
|
|
let items = service_for_paths(root.path().join(EXTERNAL_AGENT_DIR), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: format!(
|
|
"Migrate {} into {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("settings.json")
|
|
.display(),
|
|
repo_root.join(".codex").join("config.toml").display()
|
|
),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
|
description: format!(
|
|
"Migrate skills from {} to {}",
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("skills").display(),
|
|
repo_root.join(".agents").join("skills").display()
|
|
),
|
|
cwd: Some(repo_root.clone()),
|
|
details: Some(MigrationDetails {
|
|
skills: named_migrations(vec!["skill-a".to_string()]),
|
|
..Default::default()
|
|
}),
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: format!(
|
|
"Migrate {} to {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join(EXTERNAL_AGENT_CONFIG_MD)
|
|
.display(),
|
|
repo_root.join("AGENTS.md").display(),
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: None,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_lists_mcp_hooks_commands_and_subagents() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("commands")
|
|
.join("pr"),
|
|
)
|
|
.expect("create commands");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents");
|
|
fs::write(
|
|
repo_root.join(".mcp.json"),
|
|
r#"{"mcpServers":{"docs":{"command":"docs-server"}}}"#,
|
|
)
|
|
.expect("write mcp");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"http","url":"https://example.invalid/hook"}]}]}}"#,
|
|
)
|
|
.expect("write hooks");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("commands")
|
|
.join("pr")
|
|
.join("review.md"),
|
|
"---\ndescription: Review PR\n---\nReview the pull request carefully.\n",
|
|
)
|
|
.expect("write command");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("agents")
|
|
.join("researcher.md"),
|
|
"---\nname: researcher\ndescription: Research role\n---\nResearch carefully.\n",
|
|
)
|
|
.expect("write subagent");
|
|
|
|
let items = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: format!(
|
|
"Migrate MCP servers from {} into {}",
|
|
repo_root.display(),
|
|
repo_root.join(".codex").join("config.toml").display()
|
|
),
|
|
cwd: Some(repo_root.clone()),
|
|
details: Some(MigrationDetails {
|
|
mcp_servers: vec![NamedMigration {
|
|
name: "docs".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Hooks,
|
|
description: format!(
|
|
"Migrate hooks from {} to {}",
|
|
repo_root.join(EXTERNAL_AGENT_DIR).display(),
|
|
repo_root.join(".codex").join("hooks.json").display()
|
|
),
|
|
cwd: Some(repo_root.clone()),
|
|
details: Some(MigrationDetails {
|
|
hooks: vec![NamedMigration {
|
|
name: "PreToolUse".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Commands,
|
|
description: format!(
|
|
"Migrate commands from {} to {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("commands")
|
|
.display(),
|
|
repo_root.join(".agents").join("skills").display()
|
|
),
|
|
cwd: Some(repo_root.clone()),
|
|
details: Some(MigrationDetails {
|
|
commands: vec![NamedMigration {
|
|
name: "source-command-pr-review".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Subagents,
|
|
description: format!(
|
|
"Migrate subagents from {} to {}",
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("agents").display(),
|
|
repo_root.join(".codex").join("agents").display()
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: Some(MigrationDetails {
|
|
subagents: vec![NamedMigration {
|
|
name: "researcher".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"UnsupportedEvent":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#,
|
|
)
|
|
.expect("write hooks");
|
|
|
|
let items = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_migrates_mcp_hooks_commands_and_subagents() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("commands")
|
|
.join("pr"),
|
|
)
|
|
.expect("create commands");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR).join("agents")).expect("create agents");
|
|
fs::write(
|
|
repo_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"docs": {
|
|
"command": "docs-server",
|
|
"args": ["--stdio"],
|
|
"headers": {"X-Ignored": "unsupported for stdio"},
|
|
"env": {"DOCS_TOKEN": "${DOCS_TOKEN}", "STATIC": "yes"}
|
|
},
|
|
"api": {
|
|
"url": "https://example.com/mcp",
|
|
"args": ["ignored-for-http"],
|
|
"env": {"IGNORED": "unsupported for http"},
|
|
"headers": {
|
|
"Authorization": "Bearer ${API_TOKEN}",
|
|
"X-Team": "${TEAM}"
|
|
}
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write mcp");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo external-agent","timeout":3},{"type":"prompt","prompt":"skip"}]}],"Stop":[{"matcher":"ignored","hooks":[{"command":"echo done"}]}]}}"#,
|
|
)
|
|
.expect("write hooks");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("commands")
|
|
.join("pr")
|
|
.join("review.md"),
|
|
"---\ndescription: Review PR\n---\nReview the pull request carefully.\n",
|
|
)
|
|
.expect("write command");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("agents")
|
|
.join("researcher.md"),
|
|
format!("---\nname: researcher\ndescription: Research role\npermissionMode: acceptEdits\nskills: [deep-research]\ntools: Bash, Read\ndisallowedTools: WebFetch\neffort: high\n---\nResearch with {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} carefully.\n"),
|
|
)
|
|
.expect("write subagent");
|
|
|
|
service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.import(vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Hooks,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Commands,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Subagents,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
let config: TomlValue = toml::from_str(
|
|
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
|
)
|
|
.expect("parse config");
|
|
let expected_config: TomlValue = toml::from_str(
|
|
r#"
|
|
[mcp_servers.api]
|
|
url = "https://example.com/mcp"
|
|
bearer_token_env_var = "API_TOKEN"
|
|
|
|
[mcp_servers.api.env_http_headers]
|
|
X-Team = "TEAM"
|
|
|
|
[mcp_servers.docs]
|
|
command = "docs-server"
|
|
args = ["--stdio"]
|
|
env_vars = ["DOCS_TOKEN"]
|
|
|
|
[mcp_servers.docs.env]
|
|
STATIC = "yes"
|
|
"#,
|
|
)
|
|
.expect("parse expected config");
|
|
assert_eq!(config, expected_config);
|
|
let mcp_servers = config
|
|
.get("mcp_servers")
|
|
.cloned()
|
|
.ok_or_else(|| io::Error::other("missing mcp_servers"))
|
|
.expect("mcp servers");
|
|
let _supported_mcp_config: std::collections::HashMap<
|
|
String,
|
|
codex_config::types::McpServerConfig,
|
|
> = mcp_servers
|
|
.try_into()
|
|
.expect("migrated MCP config should be supported");
|
|
|
|
let hooks: JsonValue = serde_json::from_str(
|
|
&fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"),
|
|
)
|
|
.expect("parse hooks");
|
|
let _supported_hooks: codex_config::HooksFile =
|
|
serde_json::from_value(hooks.clone()).expect("migrated hooks should be supported");
|
|
assert_eq!(
|
|
hooks,
|
|
serde_json::json!({
|
|
"hooks": {
|
|
"PreToolUse": [{
|
|
"matcher": "Bash",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo external-agent",
|
|
"timeout": 3
|
|
}]
|
|
}],
|
|
"Stop": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo done"
|
|
}]
|
|
}]
|
|
}
|
|
})
|
|
);
|
|
assert!(
|
|
!repo_root
|
|
.join(".codex")
|
|
.join("hooks.migration-notes.md")
|
|
.exists()
|
|
);
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(
|
|
repo_root
|
|
.join(".agents")
|
|
.join("skills")
|
|
.join("source-command-pr-review")
|
|
.join("SKILL.md")
|
|
)
|
|
.expect("read command skill"),
|
|
"---\nname: \"source-command-pr-review\"\ndescription: \"Review PR\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nReview the pull request carefully.\n"
|
|
);
|
|
|
|
let agent: TomlValue = toml::from_str(
|
|
&fs::read_to_string(
|
|
repo_root
|
|
.join(".codex")
|
|
.join("agents")
|
|
.join("researcher.toml"),
|
|
)
|
|
.expect("read agent"),
|
|
)
|
|
.expect("parse agent");
|
|
let expected_agent: TomlValue = toml::from_str(
|
|
r#"
|
|
name = "researcher"
|
|
description = "Research role"
|
|
model_reasoning_effort = "high"
|
|
sandbox_mode = "workspace-write"
|
|
developer_instructions = """
|
|
Research with Codex carefully."""
|
|
"#,
|
|
)
|
|
.expect("parse expected agent");
|
|
assert_eq!(agent, expected_agent);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_mcp_preserves_existing_same_named_server() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::write(
|
|
repo_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"mixedTransport": {
|
|
"command": "mcp-remote-proxy",
|
|
"args": [
|
|
"https://example.com/mixed-transport",
|
|
"--transport",
|
|
"http"
|
|
],
|
|
"url": "https://example.com/mixed-transport"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write mcp");
|
|
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
|
|
let existing_config = r#"[mcp_servers.mixedTransport]
|
|
url = "https://example.com/mixed-transport"
|
|
"#;
|
|
fs::write(
|
|
repo_root.join(".codex").join("config.toml"),
|
|
existing_config,
|
|
)
|
|
.expect("write config");
|
|
|
|
let service = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
);
|
|
assert_eq!(
|
|
service
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect"),
|
|
Vec::<ExternalAgentConfigMigrationItem>::new()
|
|
);
|
|
|
|
service
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
|
existing_config
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_mcp_lists_only_missing_servers() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::write(
|
|
repo_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"docs": {"command": "docs-server"},
|
|
"mixedTransport": {"command": "mcp-remote-proxy"}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write mcp");
|
|
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
|
|
fs::write(
|
|
repo_root.join(".codex").join("config.toml"),
|
|
r#"[mcp_servers.mixedTransport]
|
|
url = "https://example.com/mixed-transport"
|
|
"#,
|
|
)
|
|
.expect("write config");
|
|
|
|
let items = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: format!(
|
|
"Migrate MCP servers from {} into {}",
|
|
repo_root.display(),
|
|
repo_root.join(".codex").join("config.toml").display()
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: Some(MigrationDetails {
|
|
mcp_servers: vec![NamedMigration {
|
|
name: "docs".to_string(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let agents_skills = codex_home
|
|
.parent()
|
|
.map(|parent| parent.join(".agents").join("skills"))
|
|
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
|
|
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create skills");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","permissions":{{"ask":["git push"]}},"env":{{"FOO":"bar","CI":false,"MAX_RETRIES":3,"MY_TEAM":"codex","IGNORED":null,"LIST":["a","b"],"MAP":{{"x":1}}}},"sandbox":{{"enabled":true,"network":{{"allowLocalBinding":true}}}}}}"#),
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
external_agent_home
|
|
.join("skills")
|
|
.join("skill-a")
|
|
.join("SKILL.md"),
|
|
format!(
|
|
"Use {SOURCE_EXTERNAL_AGENT_PRODUCT_NAME} and {SOURCE_EXTERNAL_AGENT_UPPER_NAME} utilities."
|
|
),
|
|
)
|
|
.expect("write skill");
|
|
fs::write(
|
|
external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
|
|
)
|
|
.expect("write agents");
|
|
|
|
service_for_paths(external_agent_home, codex_home.clone())
|
|
.import(vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"),
|
|
"Codex guidance"
|
|
);
|
|
|
|
let config: TomlValue =
|
|
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
|
|
.expect("parse config");
|
|
let expected: TomlValue = toml::from_str(
|
|
r#"
|
|
sandbox_mode = "workspace-write"
|
|
|
|
[shell_environment_policy]
|
|
inherit = "core"
|
|
|
|
[shell_environment_policy.set]
|
|
CI = "false"
|
|
FOO = "bar"
|
|
MAX_RETRIES = "3"
|
|
MY_TEAM = "codex"
|
|
"#,
|
|
)
|
|
.expect("parse expected config");
|
|
assert_eq!(config, expected);
|
|
assert_eq!(
|
|
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
|
|
.expect("read copied skill"),
|
|
"Use Codex and Codex utilities."
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_home_config_uses_local_settings_over_project_settings() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{"env":{"FOO":"project","PROJECT_ONLY":"yes"},"sandbox":{"enabled":false}}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
external_agent_home.join("settings.local.json"),
|
|
r#"{"env":{"FOO":"local","LOCAL_ONLY":true},"sandbox":{"enabled":true}}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
service_for_paths(external_agent_home, codex_home.clone())
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
let config: TomlValue =
|
|
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
|
|
.expect("parse config");
|
|
let expected: TomlValue = toml::from_str(
|
|
r#"
|
|
sandbox_mode = "workspace-write"
|
|
|
|
[shell_environment_policy]
|
|
inherit = "core"
|
|
|
|
[shell_environment_policy.set]
|
|
FOO = "local"
|
|
LOCAL_ONLY = "true"
|
|
PROJECT_ONLY = "yes"
|
|
"#,
|
|
)
|
|
.expect("parse expected config");
|
|
assert_eq!(config, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_home_config_ignores_invalid_local_settings() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{"env":{"FOO":"project"},"sandbox":{"enabled":false}}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
external_agent_home.join("settings.local.json"),
|
|
"{invalid json",
|
|
)
|
|
.expect("write local settings");
|
|
|
|
service_for_paths(external_agent_home, codex_home.clone())
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
|
|
"[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"project\"\n"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_home_skips_empty_config_migration() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
format!(r#"{{"model":"{SOURCE_EXTERNAL_AGENT_NAME}","sandbox":{{"enabled":false}}}}"#),
|
|
)
|
|
.expect("write settings");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description: String::new(),
|
|
cwd: None,
|
|
success_count: 0,
|
|
error_count: 0,
|
|
successes: Vec::new(),
|
|
raw_errors: Vec::new(),
|
|
}]
|
|
);
|
|
assert!(!codex_home.join("config.toml").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_local_plugins_returns_completed_status() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let marketplace_root = external_agent_home.join("my-marketplace");
|
|
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
|
|
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"enabledPlugins": {
|
|
"cloudflare@my-plugins": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"my-plugins": {
|
|
"source": "local",
|
|
"path": marketplace_root
|
|
}
|
|
}
|
|
}))
|
|
.expect("serialize settings"),
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "my-plugins",
|
|
"plugins": [
|
|
{
|
|
"name": "cloudflare",
|
|
"source": "./plugins/cloudflare"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin").join("plugin.json"),
|
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
|
)
|
|
.expect("write plugin manifest");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "my-plugins".to_string(),
|
|
plugin_names: vec!["cloudflare".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.pending_plugin_imports,
|
|
Vec::<PendingPluginImport>::new()
|
|
);
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: String::new(),
|
|
cwd: None,
|
|
success_count: 1,
|
|
error_count: 0,
|
|
successes: vec![ExternalAgentConfigImportSuccess {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
cwd: None,
|
|
source: Some("cloudflare@my-plugins".to_string()),
|
|
target: Some("cloudflare@my-plugins".to_string()),
|
|
}],
|
|
raw_errors: Vec::new(),
|
|
}]
|
|
);
|
|
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
|
|
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
|
|
assert!(config.contains("enabled = true"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_git_plugins_returns_pending_async_status() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "owner/debug-marketplace"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: String::new(),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["formatter".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.pending_plugin_imports,
|
|
vec![PendingPluginImport {
|
|
cwd: None,
|
|
description: String::new(),
|
|
details: MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["formatter".to_string()],
|
|
}],
|
|
..Default::default()
|
|
},
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: String::new(),
|
|
cwd: None,
|
|
success_count: 0,
|
|
error_count: 0,
|
|
successes: Vec::new(),
|
|
raw_errors: Vec::new(),
|
|
}]
|
|
);
|
|
assert!(!codex_home.join("config.toml").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_skips_config_when_target_already_has_supported_fields() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"
|
|
sandbox_mode = "workspace-write"
|
|
|
|
[shell_environment_policy]
|
|
inherit = "core"
|
|
|
|
[shell_environment_policy.set]
|
|
FOO = "bar"
|
|
"#,
|
|
)
|
|
.expect("write config");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_skips_skills_when_all_skill_directories_exist() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let agents_skills = codex_home
|
|
.parent()
|
|
.map(|parent| parent.join(".agents").join("skills"))
|
|
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
|
|
fs::create_dir_all(external_agent_home.join("skills").join("skill-a")).expect("create source");
|
|
fs::create_dir_all(agents_skills.join("skill-a")).expect("create target");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_agents_md_from_nested_cwd_rewrites_terms_and_skips_non_empty_targets() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo-a");
|
|
let nested_cwd = repo_root.join("nested");
|
|
let repo_with_existing_target = root.path().join("repo-b");
|
|
fs::create_dir_all(&nested_cwd).expect("create nested cwd");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
|
fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!(
|
|
"{SOURCE_EXTERNAL_AGENT_PRODUCT_NAME}\n{SOURCE_EXTERNAL_AGENT_NAME}\n{SOURCE_EXTERNAL_AGENT_UPPER_PRODUCT_NAME}\nSee {EXTERNAL_AGENT_CONFIG_MD}\n"
|
|
),
|
|
)
|
|
.expect("write source");
|
|
fs::write(
|
|
repo_with_existing_target.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
"new source",
|
|
)
|
|
.expect("write source");
|
|
fs::write(
|
|
repo_with_existing_target.join("AGENTS.md"),
|
|
"keep existing target",
|
|
)
|
|
.expect("write target");
|
|
|
|
let outcome = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.import(vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(nested_cwd.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(repo_with_existing_target.clone()),
|
|
details: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![
|
|
ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(nested_cwd.clone()),
|
|
success_count: 1,
|
|
error_count: 0,
|
|
successes: vec![import_success(
|
|
ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
Some(nested_cwd),
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_CONFIG_MD)
|
|
.display()
|
|
.to_string(),
|
|
repo_root.join("AGENTS.md").display().to_string(),
|
|
)],
|
|
raw_errors: Vec::new(),
|
|
},
|
|
ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(repo_with_existing_target.clone()),
|
|
success_count: 0,
|
|
error_count: 0,
|
|
successes: Vec::new(),
|
|
raw_errors: Vec::new(),
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
|
|
"Codex\nCodex\nCodex\nSee AGENTS.md\n"
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(repo_with_existing_target.join("AGENTS.md"))
|
|
.expect("read existing target"),
|
|
"keep existing target"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_agents_md_overwrites_empty_targets() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
|
|
)
|
|
.expect("write source");
|
|
fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target");
|
|
|
|
let outcome = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
success_count: 1,
|
|
error_count: 0,
|
|
successes: vec![import_success(
|
|
ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
Some(repo_root.clone()),
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_CONFIG_MD)
|
|
.display()
|
|
.to_string(),
|
|
repo_root.join("AGENTS.md").display().to_string(),
|
|
)],
|
|
raw_errors: Vec::new(),
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
|
|
"Codex guidance"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_prefers_non_empty_external_agent_agents_source() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
|
|
fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), " \n\t").expect("write empty root source");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
|
|
)
|
|
.expect("write external agent source");
|
|
|
|
let items = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: format!(
|
|
"Migrate {} to {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join(EXTERNAL_AGENT_CONFIG_MD)
|
|
.display(),
|
|
repo_root.join("AGENTS.md").display(),
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: None,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
|
|
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{"hooks":{"Stop":[{"hooks":[{"command":"echo done"}]}]}}"#,
|
|
)
|
|
.expect("write hooks");
|
|
fs::write(
|
|
repo_root.join(".codex").join("config.toml"),
|
|
"[features]\ncodex_hooks = false\n",
|
|
)
|
|
.expect("write config");
|
|
|
|
let outcome = service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Hooks,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::Hooks,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
success_count: 1,
|
|
error_count: 0,
|
|
successes: vec![import_success(
|
|
ExternalAgentConfigMigrationItemType::Hooks,
|
|
Some(repo_root.clone()),
|
|
"Stop",
|
|
"Stop",
|
|
)],
|
|
raw_errors: Vec::new(),
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
|
"[features]\ncodex_hooks = false\n"
|
|
);
|
|
let hooks: JsonValue = serde_json::from_str(
|
|
&fs::read_to_string(repo_root.join(".codex").join("hooks.json")).expect("read hooks"),
|
|
)
|
|
.expect("parse hooks");
|
|
assert_eq!(
|
|
hooks,
|
|
serde_json::json!({
|
|
"hooks": {
|
|
"Stop": [{
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "echo done"
|
|
}]
|
|
}]
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{"disabledMcpjsonServers":["blocked"]}"#,
|
|
)
|
|
.expect("write home settings");
|
|
fs::write(
|
|
root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE),
|
|
serde_json::json!({
|
|
"projects": {
|
|
repo_root.display().to_string(): {
|
|
"mcpServers": {
|
|
"allowed": {"command": "allowed-server"},
|
|
"blocked": {"command": "blocked-server"}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write external agent project config");
|
|
|
|
let outcome = service_for_paths(external_agent_home, root.path().join(".codex"))
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.item_results,
|
|
vec![ExternalAgentConfigImportItemResult {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
success_count: 1,
|
|
error_count: 0,
|
|
successes: vec![import_success(
|
|
ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
Some(repo_root.clone()),
|
|
"allowed",
|
|
"allowed",
|
|
)],
|
|
raw_errors: Vec::new(),
|
|
}]
|
|
);
|
|
let config: TomlValue = toml::from_str(
|
|
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
|
)
|
|
.expect("parse config");
|
|
let expected: TomlValue = toml::from_str(
|
|
r#"
|
|
[mcp_servers.allowed]
|
|
command = "allowed-server"
|
|
"#,
|
|
)
|
|
.expect("parse expected config");
|
|
assert_eq!(config, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
|
|
fs::write(
|
|
repo_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"project-disabled": {"command": "project-disabled-server"},
|
|
"local-disabled": {"command": "local-disabled-server"},
|
|
"local-enabled": {"command": "local-enabled-server"}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write mcp");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledMcpjsonServers": ["project-disabled", "local-disabled"],
|
|
"disabledMcpjsonServers": ["project-disabled"]
|
|
}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("settings.local.json"),
|
|
r#"{
|
|
"enabledMcpjsonServers": ["local-enabled", "local-disabled"],
|
|
"disabledMcpjsonServers": ["local-disabled"]
|
|
}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
service_for_paths(external_agent_home, root.path().join(".codex"))
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
let config: TomlValue = toml::from_str(
|
|
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
|
)
|
|
.expect("parse config");
|
|
let expected: TomlValue = toml::from_str(
|
|
r#"
|
|
[mcp_servers.local-enabled]
|
|
command = "local-enabled-server"
|
|
"#,
|
|
)
|
|
.expect("parse expected config");
|
|
assert_eq!(config, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missing() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(external_agent_home.join("settings.json"), "{ invalid json")
|
|
.expect("write invalid home settings");
|
|
fs::write(
|
|
root.path().join(EXTERNAL_AGENT_PROJECT_CONFIG_FILE),
|
|
serde_json::json!({
|
|
"projects": {
|
|
repo_root.display().to_string(): {
|
|
"mcpServers": {
|
|
"docs": {"command": "docs-server"}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.to_string(),
|
|
)
|
|
.expect("write external agent project config");
|
|
|
|
service_for_paths(external_agent_home, root.path().join(".codex"))
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
let config: TomlValue = toml::from_str(
|
|
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
|
|
)
|
|
.expect("parse config");
|
|
let expected: TomlValue = toml::from_str(
|
|
r#"
|
|
[mcp_servers.docs]
|
|
command = "docs-server"
|
|
"#,
|
|
)
|
|
.expect("parse expected config");
|
|
assert_eq!(config, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_repo_uses_non_empty_external_agent_agents_source() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
|
|
fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "").expect("write empty root source");
|
|
fs::write(
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join(EXTERNAL_AGENT_CONFIG_MD),
|
|
format!("{SOURCE_EXTERNAL_AGENT_DISPLAY_NAME} code guidance"),
|
|
)
|
|
.expect("write external agent source");
|
|
|
|
service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.import(vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: String::new(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
}])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
|
|
"Codex guidance"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_continues_after_failed_migration_item() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git");
|
|
fs::write(repo_root.join(EXTERNAL_AGENT_CONFIG_MD), "Claude guidance").expect("write source");
|
|
|
|
service_for_paths(
|
|
root.path().join(EXTERNAL_AGENT_DIR),
|
|
root.path().join(".codex"),
|
|
)
|
|
.import(vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: "invalid plugin migration".to_string(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: "valid agents migration".to_string(),
|
|
cwd: Some(repo_root.clone()),
|
|
details: None,
|
|
},
|
|
])
|
|
.await;
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
|
|
"Codex guidance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn migration_metric_tags_for_skills_include_skills_count() {
|
|
assert_eq!(
|
|
migration_metric_tags(ExternalAgentConfigMigrationItemType::Skills, Some(3)),
|
|
vec![
|
|
("migration_type", "skills".to_string()),
|
|
("skills_count", "3".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_lists_enabled_plugins_from_settings() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true,
|
|
"deployer@acme-tools": true,
|
|
"analyzer@security-plugins": false
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
external_agent_home.join("settings.json").display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["deployer".to_string(), "formatter".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_uses_materialized_known_marketplace_for_inline_npm_source() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let marketplace_root = external_agent_home
|
|
.join("plugins")
|
|
.join("marketplaces")
|
|
.join("acme-tools");
|
|
fs::create_dir_all(external_agent_home.join("plugins"))
|
|
.expect("create external agent plugins dir");
|
|
fs::create_dir_all(&marketplace_root).expect("create installed marketplace dir");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": {
|
|
"source": "settings",
|
|
"name": "acme-tools",
|
|
"plugins": [{
|
|
"name": "formatter",
|
|
"source": {
|
|
"source": "npm",
|
|
"package": "@acme/formatter"
|
|
}
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"acme-tools": {
|
|
"source": {
|
|
"source": "settings",
|
|
"name": "acme-tools",
|
|
"plugins": [{
|
|
"name": "formatter",
|
|
"source": {
|
|
"source": "npm",
|
|
"package": "@acme/formatter",
|
|
},
|
|
}],
|
|
},
|
|
"installLocation": "plugins/marketplaces/acme-tools",
|
|
"lastUpdated": "2026-07-09T00:16:23.611Z",
|
|
}
|
|
}))
|
|
.expect("serialize known marketplaces"),
|
|
)
|
|
.expect("write known marketplaces");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
external_agent_home.join("settings.json").display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["formatter".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision() {
|
|
let (root, external_agent_home, _codex_home) = fixture_paths();
|
|
let source_root = root.path().join("repo");
|
|
let scoped_marketplace = source_root.join("repo-marketplace");
|
|
let cached_marketplace = external_agent_home.join("plugins/marketplaces/debug");
|
|
fs::create_dir_all(&scoped_marketplace).expect("create scoped marketplace");
|
|
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
|
|
fs::write(
|
|
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"debug": {
|
|
"source": {
|
|
"source": "github",
|
|
"repo": "acme/global-marketplace",
|
|
},
|
|
"installLocation": cached_marketplace,
|
|
}
|
|
}))
|
|
.expect("serialize known marketplaces"),
|
|
)
|
|
.expect("write known marketplaces");
|
|
let settings = serde_json::json!({
|
|
"extraKnownMarketplaces": {
|
|
"debug": {
|
|
"source": {
|
|
"source": "directory",
|
|
"path": "./repo-marketplace",
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let import_sources =
|
|
source_cla::marketplace_import_sources(&settings, &external_agent_home, &source_root);
|
|
|
|
assert_eq!(
|
|
import_sources.get("debug"),
|
|
Some(&MarketplaceImportSource {
|
|
source: source_root.join("./repo-marketplace").display().to_string(),
|
|
ref_name: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn marketplace_import_sources_prefers_supported_declaration_over_materialization() {
|
|
let (_root, external_agent_home, _codex_home) = fixture_paths();
|
|
let cached_marketplace = external_agent_home.join("plugins/marketplaces/acme-tools");
|
|
fs::create_dir_all(&cached_marketplace).expect("create cached marketplace");
|
|
fs::write(
|
|
external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH),
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"acme-tools": {
|
|
"source": {
|
|
"source": "git",
|
|
"url": "https://git.example.com/acme/tools.git",
|
|
"ref": "release",
|
|
},
|
|
"installLocation": cached_marketplace,
|
|
}
|
|
}))
|
|
.expect("serialize known marketplaces"),
|
|
)
|
|
.expect("write known marketplaces");
|
|
|
|
let import_sources = source_cla::marketplace_import_sources(
|
|
&serde_json::json!({}),
|
|
&external_agent_home,
|
|
&external_agent_home,
|
|
);
|
|
|
|
assert_eq!(
|
|
import_sources.get("acme-tools"),
|
|
Some(&MarketplaceImportSource {
|
|
source: "https://git.example.com/acme/tools.git".to_string(),
|
|
ref_name: Some("release".to_string()),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true,
|
|
"legacy@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write project settings");
|
|
fs::write(
|
|
external_agent_home.join("settings.local.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": false,
|
|
"deployer@acme-tools": true
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write local settings");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
external_agent_home.join("settings.json").display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["deployer".to_string(), "legacy".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_skips_plugins_that_are_already_configured_in_codex() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true,
|
|
"deployer@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write repo settings");
|
|
fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"
|
|
[plugins."formatter@acme-tools"]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.expect("write codex config");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("settings.json")
|
|
.display()
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["deployer".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_skips_plugins_that_are_disabled_in_codex() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write repo settings");
|
|
fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"
|
|
[plugins."formatter@acme-tools"]
|
|
enabled = false
|
|
"#,
|
|
)
|
|
.expect("write codex config");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_skips_plugins_without_explicit_enabled_in_codex() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write repo settings");
|
|
fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"
|
|
[plugins."formatter@acme-tools"]
|
|
"#,
|
|
)
|
|
.expect("write codex config");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_requires_details() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
|
|
let err = service_for_paths(external_agent_home, codex_home)
|
|
.import_plugins(/*cwd*/ None, /*details*/ None)
|
|
.await
|
|
.expect_err("expected missing details error");
|
|
|
|
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
|
assert_eq!(err.to_string(), "plugins migration item is missing details");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_does_not_skip_plugins_only_configured_in_project_codex() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(repo_root.join(".codex")).expect("create repo codex dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write repo settings");
|
|
fs::write(
|
|
repo_root.join(".codex").join("config.toml"),
|
|
r#"
|
|
[plugins."formatter@acme-tools"]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.expect("write project codex config");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("settings.json")
|
|
.display()
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec!["formatter".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_skips_plugins_without_marketplace_source() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_skips_plugins_with_invalid_marketplace_source() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "github"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_filters_plugins_against_installed_marketplace() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
let marketplace_root = codex_home.join(".tmp").join("marketplaces").join("debug");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(marketplace_root.join(".agents").join("plugins"))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(
|
|
marketplace_root
|
|
.join("plugins")
|
|
.join("sample")
|
|
.join(".codex-plugin"),
|
|
)
|
|
.expect("create sample plugin");
|
|
fs::create_dir_all(
|
|
marketplace_root
|
|
.join("plugins")
|
|
.join("available")
|
|
.join(".codex-plugin"),
|
|
)
|
|
.expect("create available plugin");
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"sample@debug": true,
|
|
"available@debug": true,
|
|
"missing@debug": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"debug": {
|
|
"source": "owner/debug-marketplace"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write repo settings");
|
|
fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"
|
|
[marketplaces.debug]
|
|
source_type = "git"
|
|
source = "owner/debug-marketplace"
|
|
"#,
|
|
)
|
|
.expect("write codex config");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(".agents")
|
|
.join("plugins")
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/sample"
|
|
},
|
|
"policy": {
|
|
"installation": "NOT_AVAILABLE"
|
|
}
|
|
},
|
|
{
|
|
"name": "available",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/available"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
marketplace_root
|
|
.join("plugins")
|
|
.join("sample")
|
|
.join(".codex-plugin")
|
|
.join("plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.expect("write sample plugin manifest");
|
|
fs::write(
|
|
marketplace_root
|
|
.join("plugins")
|
|
.join("available")
|
|
.join(".codex-plugin")
|
|
.join("plugin.json"),
|
|
r#"{"name":"available"}"#,
|
|
)
|
|
.expect("write available plugin manifest");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("settings.json")
|
|
.display()
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "debug".to_string(),
|
|
plugin_names: vec!["available".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_requires_source_marketplace_details() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "github",
|
|
"repo": "acme-corp/external-agent-plugins"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home)
|
|
.import_plugins(
|
|
/*cwd*/ None,
|
|
Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "other-tools".to_string(),
|
|
plugin_names: github_plugin_details().plugins[0].plugin_names.clone(),
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.expect("import plugins");
|
|
|
|
assert_eq!(outcome.succeeded_marketplaces, Vec::<String>::new());
|
|
assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new());
|
|
assert_eq!(outcome.failed_marketplaces, vec!["other-tools".to_string()]);
|
|
assert_eq!(
|
|
outcome.failed_plugin_ids,
|
|
vec!["formatter@other-tools".to_string()]
|
|
);
|
|
assert_single_plugin_raw_error(
|
|
&outcome.raw_errors,
|
|
"plugin_import",
|
|
"formatter@other-tools",
|
|
/*error_type*/ None,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"formatter@acme-tools": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"acme-tools": {
|
|
"source": "local",
|
|
"path": "./external_plugins/acme-tools"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home)
|
|
.import_plugins(/*cwd*/ None, Some(github_plugin_details()))
|
|
.await
|
|
.expect("import plugins");
|
|
|
|
assert_eq!(outcome.succeeded_marketplaces, Vec::<String>::new());
|
|
assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new());
|
|
assert_eq!(outcome.failed_marketplaces, vec!["acme-tools".to_string()]);
|
|
assert_eq!(
|
|
outcome.failed_plugin_ids,
|
|
vec!["formatter@acme-tools".to_string()]
|
|
);
|
|
assert_single_plugin_raw_error(
|
|
&outcome.raw_errors,
|
|
"plugin_import",
|
|
"formatter@acme-tools",
|
|
/*error_type*/ None,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_supports_external_agent_plugin_marketplace_layout() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let marketplace_root = external_agent_home.join("my-marketplace");
|
|
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
|
|
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
serde_json::to_string_pretty(&serde_json::json!({
|
|
"enabledPlugins": {
|
|
"cloudflare@my-plugins": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"my-plugins": {
|
|
"source": "local",
|
|
"path": marketplace_root
|
|
}
|
|
}
|
|
}))
|
|
.expect("serialize settings"),
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "my-plugins",
|
|
"plugins": [
|
|
{
|
|
"name": "cloudflare",
|
|
"source": "./plugins/cloudflare"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin").join("plugin.json"),
|
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
|
)
|
|
.expect("write plugin manifest");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
|
.import_plugins(
|
|
/*cwd*/ None,
|
|
Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "my-plugins".to_string(),
|
|
plugin_names: vec!["cloudflare".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.expect("import plugins");
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
PluginImportOutcome {
|
|
succeeded_marketplaces: vec!["my-plugins".to_string()],
|
|
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
|
|
failed_marketplaces: Vec::new(),
|
|
failed_plugin_ids: Vec::new(),
|
|
raw_errors: Vec::new(),
|
|
}
|
|
);
|
|
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
|
|
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
|
|
assert!(config.contains("enabled = true"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_supports_relative_external_agent_plugin_marketplace_path() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let marketplace_root = external_agent_home.join("my-marketplace");
|
|
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
|
|
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"cloudflare@my-plugins": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"my-plugins": {
|
|
"source": "directory",
|
|
"path": "./my-marketplace"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "my-plugins",
|
|
"plugins": [
|
|
{
|
|
"name": "cloudflare",
|
|
"source": "./plugins/cloudflare"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin").join("plugin.json"),
|
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
|
)
|
|
.expect("write plugin manifest");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
external_agent_home.join("settings.json").display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "my-plugins".to_string(),
|
|
plugin_names: vec!["cloudflare".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_home_infers_external_official_marketplace_when_missing_from_settings() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
format!(
|
|
r#"{{
|
|
"enabledPlugins": {{
|
|
"sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true
|
|
}}
|
|
}}"#
|
|
),
|
|
)
|
|
.expect("write settings");
|
|
|
|
let items = service_for_paths(external_agent_home.clone(), codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: true,
|
|
cwds: None,
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
external_agent_home.join("settings.json").display()
|
|
),
|
|
cwd: None,
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
|
|
plugin_names: vec!["sample".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_supports_relative_external_agent_plugin_marketplace_path() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let marketplace_root = external_agent_home.join("my-marketplace");
|
|
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
|
|
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"cloudflare@my-plugins": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"my-plugins": {
|
|
"source": "directory",
|
|
"path": "./my-marketplace"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "my-plugins",
|
|
"plugins": [
|
|
{
|
|
"name": "cloudflare",
|
|
"source": "./plugins/cloudflare"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin").join("plugin.json"),
|
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
|
)
|
|
.expect("write plugin manifest");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
|
.import_plugins(
|
|
/*cwd*/ None,
|
|
Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "my-plugins".to_string(),
|
|
plugin_names: vec!["cloudflare".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.expect("import plugins");
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
PluginImportOutcome {
|
|
succeeded_marketplaces: vec!["my-plugins".to_string()],
|
|
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
|
|
failed_marketplaces: Vec::new(),
|
|
failed_plugin_ids: Vec::new(),
|
|
raw_errors: Vec::new(),
|
|
}
|
|
);
|
|
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
|
|
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
|
|
assert!(config.contains("enabled = true"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_infers_external_official_marketplace_when_missing_from_settings() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
fs::create_dir_all(&external_agent_home).expect("create external agent home");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
external_agent_home.join("settings.json"),
|
|
format!(
|
|
r#"{{
|
|
"enabledPlugins": {{
|
|
"sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}": true
|
|
}}
|
|
}}"#
|
|
),
|
|
)
|
|
.expect("write settings");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home)
|
|
.import_plugins(
|
|
/*cwd*/ None,
|
|
Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(),
|
|
plugin_names: vec!["sample".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.expect("import plugins");
|
|
|
|
assert_eq!(
|
|
outcome.succeeded_marketplaces,
|
|
vec![EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string()]
|
|
);
|
|
assert_eq!(outcome.succeeded_plugin_ids, Vec::<String>::new());
|
|
assert_eq!(outcome.failed_marketplaces, Vec::<String>::new());
|
|
assert_eq!(
|
|
outcome.failed_plugin_ids,
|
|
vec![format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}")]
|
|
);
|
|
assert_single_plugin_raw_error(
|
|
&outcome.raw_errors,
|
|
"plugin_import",
|
|
&format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"),
|
|
Some("plugin_not_found"),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace_path() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
let marketplace_root = repo_root.join("my-marketplace");
|
|
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"cloudflare@my-plugins": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"my-plugins": {
|
|
"source": "directory",
|
|
"path": "./my-marketplace"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "my-plugins",
|
|
"plugins": [
|
|
{
|
|
"name": "cloudflare",
|
|
"source": "./plugins/cloudflare"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin").join("plugin.json"),
|
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
|
)
|
|
.expect("write plugin manifest");
|
|
|
|
let items = service_for_paths(external_agent_home, codex_home)
|
|
.detect(ExternalAgentConfigDetectOptions {
|
|
include_home: false,
|
|
cwds: Some(vec![repo_root.clone()]),
|
|
})
|
|
.await
|
|
.expect("detect");
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
repo_root
|
|
.join(EXTERNAL_AGENT_DIR)
|
|
.join("settings.json")
|
|
.display()
|
|
),
|
|
cwd: Some(repo_root),
|
|
details: Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "my-plugins".to_string(),
|
|
plugin_names: vec!["cloudflare".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn import_plugins_supports_project_relative_external_agent_plugin_marketplace_path() {
|
|
let root = TempDir::new().expect("create tempdir");
|
|
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
|
|
let codex_home = root.path().join(".codex");
|
|
let repo_root = root.path().join("repo");
|
|
let marketplace_root = repo_root.join("my-marketplace");
|
|
let plugin_root = marketplace_root.join("plugins").join("cloudflare");
|
|
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
|
|
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create repo external agent dir");
|
|
fs::create_dir_all(marketplace_root.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR))
|
|
.expect("create marketplace manifest dir");
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
|
|
fs::write(
|
|
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
|
r#"{
|
|
"enabledPlugins": {
|
|
"cloudflare@my-plugins": true
|
|
},
|
|
"extraKnownMarketplaces": {
|
|
"my-plugins": {
|
|
"source": "directory",
|
|
"path": "./my-marketplace"
|
|
}
|
|
}
|
|
}"#,
|
|
)
|
|
.expect("write settings");
|
|
fs::write(
|
|
marketplace_root
|
|
.join(EXTERNAL_AGENT_PLUGIN_MANIFEST_DIR)
|
|
.join("marketplace.json"),
|
|
r#"{
|
|
"name": "my-plugins",
|
|
"plugins": [
|
|
{
|
|
"name": "cloudflare",
|
|
"source": "./plugins/cloudflare"
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.expect("write marketplace manifest");
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin").join("plugin.json"),
|
|
r#"{"name":"cloudflare","version":"0.1.0"}"#,
|
|
)
|
|
.expect("write plugin manifest");
|
|
|
|
let outcome = service_for_paths(external_agent_home, codex_home.clone())
|
|
.import_plugins(
|
|
Some(repo_root.as_path()),
|
|
Some(MigrationDetails {
|
|
plugins: vec![PluginsMigration {
|
|
marketplace_name: "my-plugins".to_string(),
|
|
plugin_names: vec!["cloudflare".to_string()],
|
|
}],
|
|
..Default::default()
|
|
}),
|
|
)
|
|
.await
|
|
.expect("import plugins");
|
|
|
|
assert_eq!(
|
|
outcome,
|
|
PluginImportOutcome {
|
|
succeeded_marketplaces: vec!["my-plugins".to_string()],
|
|
succeeded_plugin_ids: vec!["cloudflare@my-plugins".to_string()],
|
|
failed_marketplaces: Vec::new(),
|
|
failed_plugin_ids: Vec::new(),
|
|
raw_errors: Vec::new(),
|
|
}
|
|
);
|
|
let config = fs::read_to_string(codex_home.join("config.toml")).expect("read config");
|
|
assert!(config.contains(r#"[plugins."cloudflare@my-plugins"]"#));
|
|
assert!(config.contains("enabled = true"));
|
|
}
|
|
|
|
#[test]
|
|
fn import_skills_returns_only_new_skill_directory_names() {
|
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
|
let agents_skills = codex_home
|
|
.parent()
|
|
.map(|parent| parent.join(".agents").join("skills"))
|
|
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
|
|
fs::create_dir_all(external_agent_home.join("skills").join("skill-a"))
|
|
.expect("create source a");
|
|
fs::create_dir_all(external_agent_home.join("skills").join("skill-b"))
|
|
.expect("create source b");
|
|
fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target");
|
|
|
|
let copied_names = service_for_paths(external_agent_home, codex_home)
|
|
.import_skills(/*cwd*/ None)
|
|
.expect("import skills");
|
|
|
|
assert_eq!(copied_names, vec!["skill-b".to_string()]);
|
|
}
|