use std::time::Duration; use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const WATCHER_TIMEOUT: Duration = Duration::from_secs(20); fn write_skill(root: &TempDir, name: &str) -> Result<()> { let skill_dir = root.path().join("skills").join(name); std::fs::create_dir_all(&skill_dir)?; let content = format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n"); std::fs::write(skill_dir.join("SKILL.md"), content)?; Ok(()) } fn write_plugins_enabled_config_with_base_url( codex_home: &std::path::Path, base_url: &str, ) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), format!( r#"chatgpt_base_url = "{base_url}" [features] plugins = true "#, ), ) } fn write_plugin_with_skill( repo_root: &std::path::Path, plugin_name: &str, skill_name: &str, ) -> Result<()> { std::fs::create_dir_all(repo_root.join(".git"))?; std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; std::fs::write( repo_root.join(".agents/plugins/marketplace.json"), format!( r#"{{ "name": "local-marketplace", "plugins": [ {{ "name": "{plugin_name}", "source": {{ "source": "local", "path": "./{plugin_name}" }} }} ] }}"# ), )?; let plugin_root = repo_root.join(plugin_name); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), format!(r#"{{"name":"{plugin_name}"}}"#), )?; let skill_dir = plugin_root.join("skills").join(skill_name); std::fs::create_dir_all(&skill_dir)?; std::fs::write( skill_dir.join("SKILL.md"), format!("---\nname: {skill_name}\ndescription: {skill_name} description\n---\n\n# Body\n"), )?; Ok(()) } #[tokio::test] async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&extra_root, "extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let SkillsListResponse { data } = to_response(response)?; assert_eq!(data.len(), 1); assert_eq!(data[0].cwd.as_path(), cwd.path()); assert!( data[0] .skills .iter() .any(|skill| skill.name == "extra-skill") ); Ok(()) } #[tokio::test] async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; let server = MockServer::start().await; write_skill(&codex_home, "home-skill")?; write_plugin_with_skill(repo_root.path(), "demo-plugin", "plugin-skill")?; write_plugins_enabled_config_with_base_url( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123") .plan_type("team"), AuthCredentialsStoreMode::File, )?; Mock::given(method("GET")) .and(path("/backend-api/accounts/account-123/settings")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( ResponseTemplate::new(200).set_body_string(r#"{"beta_settings":{"plugins":false}}"#), ) .mount(&server) .await; let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![repo_root.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: None, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let SkillsListResponse { data } = to_response(response)?; assert_eq!(data.len(), 1); assert!( data[0] .skills .iter() .any(|skill| skill.name == "home-skill"), "non-plugin skills should remain available" ); assert!( data[0] .skills .iter() .all(|skill| skill.name != "demo-plugin:plugin-skill"), "plugin skills should be hidden when workspace Codex plugins are disabled" ); Ok(()) } #[tokio::test] async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&codex_home, "home-skill")?; write_skill(&extra_root, "extra-skill")?; let mut mcp = McpProcess::new_with_env( codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], ) .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let SkillsListResponse { data } = to_response(response)?; assert_eq!(data.len(), 1); assert_eq!(data[0].cwd, cwd.path().to_path_buf()); assert_eq!(data[0].errors, Vec::new()); assert!( data[0] .skills .iter() .any(|skill| skill.name == "home-skill") ); assert!( data[0] .skills .iter() .all(|skill| skill.name != "extra-skill") ); Ok(()) } #[tokio::test] async fn skills_list_rejects_relative_extra_user_roots() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![std::path::PathBuf::from("relative/skills")], }]), }) .await?; let err = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert!( err.error .message .contains("perCwdExtraUserRoots extraUserRoots paths must be absolute"), "unexpected error: {}", err.error.message ); Ok(()) } #[tokio::test] async fn skills_list_accepts_relative_cwds() -> Result<()> { let codex_home = TempDir::new()?; let relative_cwd = std::path::PathBuf::from("relative-cwd"); std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![relative_cwd.clone()], force_reload: true, per_cwd_extra_user_roots: None, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let SkillsListResponse { data } = to_response(response)?; assert_eq!(data.len(), 1); assert_eq!(data[0].cwd, relative_cwd); assert_eq!(data[0].errors, Vec::new()); Ok(()) } #[tokio::test] async fn skills_list_ignores_per_cwd_extra_roots_for_unknown_cwd() -> Result<()> { let codex_home = TempDir::new()?; let requested_cwd = TempDir::new()?; let unknown_cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&extra_root, "ignored-extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![requested_cwd.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: unknown_cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let SkillsListResponse { data } = to_response(response)?; assert_eq!(data.len(), 1); assert_eq!(data[0].cwd.as_path(), requested_cwd.path()); assert!( data[0] .skills .iter() .all(|skill| skill.name != "ignored-extra-skill") ); Ok(()) } #[tokio::test] async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; write_skill(&extra_root, "late-extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; // Seed the cwd cache first without extra roots. let first_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, per_cwd_extra_user_roots: None, }) .await?; let first_response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(first_request_id)), ) .await??; let SkillsListResponse { data: first_data } = to_response(first_response)?; assert_eq!(first_data.len(), 1); assert!( first_data[0] .skills .iter() .all(|skill| skill.name != "late-extra-skill") ); let second_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .await?; let second_response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)), ) .await??; let SkillsListResponse { data: second_data } = to_response(second_response)?; assert_eq!(second_data.len(), 1); assert!( second_data[0] .skills .iter() .all(|skill| skill.name != "late-extra-skill") ); let third_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { cwd: cwd.path().to_path_buf(), extra_user_roots: vec![extra_root.path().to_path_buf()], }]), }) .await?; let third_response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(third_request_id)), ) .await??; let SkillsListResponse { data: third_data } = to_response(third_response)?; assert_eq!(third_data.len(), 1); assert!( third_data[0] .skills .iter() .any(|skill| skill.name == "late-extra-skill") ); Ok(()) } #[tokio::test] async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { let codex_home = TempDir::new()?; write_skill(&codex_home, "demo")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_request_id = mcp .send_thread_start_request(ThreadStartParams { model: None, model_provider: None, service_tier: None, cwd: None, approval_policy: None, approvals_reviewer: None, sandbox: None, permission_profile: None, config: None, service_name: None, base_instructions: None, developer_instructions: None, personality: None, ephemeral: None, session_start_source: None, dynamic_tools: None, environments: None, mock_experimental_field: None, experimental_raw_events: false, persist_extended_history: false, }) .await?; let _: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(thread_start_request_id)), ) .await??; let skill_path = codex_home .path() .join("skills") .join("demo") .join("SKILL.md"); std::fs::write( &skill_path, "---\nname: demo\ndescription: updated\n---\n\n# Updated\n", )?; let notification = timeout( WATCHER_TIMEOUT, mcp.read_stream_until_notification_message("skills/changed"), ) .await??; let params = notification .params .context("skills/changed params must be present")?; let notification: SkillsChangedNotification = serde_json::from_value(params)?; assert_eq!(notification, SkillsChangedNotification {}); Ok(()) }