diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1452eb590..2b37c3901 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1033,6 +1033,10 @@ mod tests { for (session_source, expected_source) in [ (SessionSource::Exec, ApiSessionSource::Exec), (SessionSource::Cli, ApiSessionSource::Cli), + ( + SessionSource::Custom("atlas".to_string()), + ApiSessionSource::Custom("atlas".to_string()), + ), ] { let client = start_test_client(session_source).await; let parsed: ThreadStartResponse = client diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6eb901a5..32b42f517 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2894,6 +2894,7 @@ "vscode", "exec", "appServer", + "custom", "subAgent", "subAgentReview", "subAgentCompact", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8bb9f2548..93557e9c1 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1880,6 +1880,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bc889ef3c..b05076081 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10984,6 +10984,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -13097,6 +13110,7 @@ "vscode", "exec", "appServer", + "custom", "subAgent", "subAgentReview", "subAgentCompact", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 25155d483..794d71999 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8744,6 +8744,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -10857,6 +10870,7 @@ "vscode", "exec", "appServer", + "custom", "subAgent", "subAgentReview", "subAgentCompact", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 04765cf48..266741500 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -826,6 +826,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index c5cf1364c..d6a3fddfa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -14,6 +14,7 @@ "vscode", "exec", "appServer", + "custom", "subAgent", "subAgentReview", "subAgentCompact", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 936630400..c4410dada 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 57dea225e..9e4fa363a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 295938ba8..762c585f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 774c3cade..556021037 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -826,6 +826,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 518f560a2..b24218778 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index a6746e1eb..7f3f848e6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -826,6 +826,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index a2307578d..70b995b24 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 64c00271f..ae9ebb57d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -584,6 +584,19 @@ ], "type": "string" }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomSessionSource", + "type": "object" + }, { "additionalProperties": false, "properties": { diff --git a/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts b/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts index e5e746e38..a80b013b2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/SessionSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SubAgentSource } from "./SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "subagent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "mcp" | { "custom": string } | { "subagent": SubAgentSource } | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts index b35b421fc..852e6ded9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SessionSource.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SubAgentSource } from "../SubAgentSource"; -export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | { "subAgent": SubAgentSource } | "unknown"; +export type SessionSource = "cli" | "vscode" | "exec" | "appServer" | { "custom": string } | { "subAgent": SubAgentSource } | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSourceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSourceKind.ts index 0a464e3d8..6ff9160e1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSourceKind.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSourceKind.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadSourceKind = "cli" | "vscode" | "exec" | "appServer" | "subAgent" | "subAgentReview" | "subAgentCompact" | "subAgentThreadSpawn" | "subAgentOther" | "unknown"; +export type ThreadSourceKind = "cli" | "vscode" | "exec" | "appServer" | "custom" | "subAgent" | "subAgentReview" | "subAgentCompact" | "subAgentThreadSpawn" | "subAgentOther" | "unknown"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 09d891c29..80c17dc5b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1467,6 +1467,7 @@ pub enum SessionSource { VsCode, Exec, AppServer, + Custom(String), SubAgent(CoreSubAgentSource), #[serde(other)] Unknown, @@ -1479,6 +1480,7 @@ impl From for SessionSource { CoreSessionSource::VSCode => SessionSource::VsCode, CoreSessionSource::Exec => SessionSource::Exec, CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::Custom(source) => SessionSource::Custom(source), CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), CoreSessionSource::Unknown => SessionSource::Unknown, } @@ -1492,6 +1494,7 @@ impl From for CoreSessionSource { SessionSource::VsCode => CoreSessionSource::VSCode, SessionSource::Exec => CoreSessionSource::Exec, SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Custom(source) => CoreSessionSource::Custom(source), SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), SessionSource::Unknown => CoreSessionSource::Unknown, } @@ -2951,6 +2954,7 @@ pub enum ThreadSourceKind { VsCode, Exec, AppServer, + Custom, SubAgent, SubAgentReview, SubAgentCompact, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 0b52d2ce6..14a39cfff 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -256,7 +256,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per - `limit` — server defaults to a reasonable page size if unset. - `sortKey` — `created_at` (default) or `updated_at`. - `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. -- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`). +- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`, and custom product sources). - `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default). - `cwd` — restrict results to threads whose session cwd exactly matches this path. - `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c70be7e8e..d370c8cbf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -423,7 +423,10 @@ impl CodexMessageProcessor { Ok(config) => self .thread_manager .plugins_manager() - .maybe_start_curated_repo_sync_for_config(&config), + .maybe_start_curated_repo_sync_for_config( + &config, + &self.thread_manager.session_source(), + ), Err(err) => warn!("failed to load latest config for curated plugin sync: {err:?}"), } } @@ -5302,6 +5305,7 @@ impl CodexMessageProcessor { force_reload, per_cwd_extra_user_roots, } = params; + let session_source = self.thread_manager.session_source(); let cwds = if cwds.is_empty() { vec![self.config.cwd.clone()] } else { @@ -5346,9 +5350,12 @@ impl CodexMessageProcessor { let extra_roots = extra_roots_by_cwd .get(&cwd) .map_or(&[][..], std::vec::Vec::as_slice); - let outcome = skills_manager - .skills_for_cwd_with_extra_user_roots(&cwd, force_reload, extra_roots) - .await; + let outcome = codex_core::skills::filter_skill_load_outcome_for_session_source( + skills_manager + .skills_for_cwd_with_extra_user_roots(&cwd, force_reload, extra_roots) + .await, + &session_source, + ); let errors = errors_to_info(&outcome.errors); let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); data.push(codex_app_server_protocol::SkillsListEntry { @@ -5364,6 +5371,7 @@ impl CodexMessageProcessor { async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) { let plugins_manager = self.thread_manager.plugins_manager(); + let session_source = self.thread_manager.session_source(); let PluginListParams { cwds, force_remote_sync, @@ -5417,15 +5425,13 @@ impl CodexMessageProcessor { Ok::, MarketplaceError>( marketplaces .into_iter() - .map(|marketplace| PluginMarketplaceEntry { - name: marketplace.name, - path: marketplace.path, - interface: marketplace.interface.map(|interface| MarketplaceInterface { - display_name: interface.display_name, - }), - plugins: marketplace + .filter_map(|marketplace| { + let plugins = marketplace .plugins .into_iter() + .filter(|plugin| { + session_source.matches_product_restriction(&plugin.policy.products) + }) .map(|plugin| PluginSummary { id: plugin.id, installed: plugin.installed, @@ -5436,7 +5442,18 @@ impl CodexMessageProcessor { auth_policy: plugin.policy.authentication.into(), interface: plugin.interface.map(plugin_interface_to_info), }) - .collect(), + .collect::>(); + + (!plugins.is_empty()).then_some(PluginMarketplaceEntry { + name: marketplace.name, + path: marketplace.path, + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins, + }) }) .collect(), ) @@ -5511,6 +5528,11 @@ impl CodexMessageProcessor { return; } }; + let session_source = self.thread_manager.session_source(); + let plugin_skills = codex_core::skills::filter_skills_for_session_source( + outcome.plugin.skills, + &session_source, + ); let app_summaries = plugin_app_helpers::load_plugin_app_summaries(&config, &outcome.plugin.apps).await; let plugin = PluginDetail { @@ -5527,7 +5549,7 @@ impl CodexMessageProcessor { interface: outcome.plugin.interface.map(plugin_interface_to_info), }, description: outcome.plugin.description, - skills: plugin_skills_to_info(&outcome.plugin.skills), + skills: plugin_skills_to_info(&plugin_skills), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, }; diff --git a/codex-rs/app-server/src/filters.rs b/codex-rs/app-server/src/filters.rs index a59750961..de6807ab4 100644 --- a/codex-rs/app-server/src/filters.rs +++ b/codex-rs/app-server/src/filters.rs @@ -1,17 +1,24 @@ use codex_app_server_protocol::ThreadSourceKind; -use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +fn interactive_source_kinds() -> Vec { + vec![ + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ThreadSourceKind::Custom, + ] +} + pub(crate) fn compute_source_filters( source_kinds: Option>, ) -> (Vec, Option>) { let Some(source_kinds) = source_kinds else { - return (INTERACTIVE_SESSION_SOURCES.to_vec(), None); + return (Vec::new(), Some(interactive_source_kinds())); }; if source_kinds.is_empty() { - return (INTERACTIVE_SESSION_SOURCES.to_vec(), None); + return (Vec::new(), Some(interactive_source_kinds())); } let requires_post_filter = source_kinds.iter().any(|kind| { @@ -19,6 +26,7 @@ pub(crate) fn compute_source_filters( kind, ThreadSourceKind::Exec | ThreadSourceKind::AppServer + | ThreadSourceKind::Custom | ThreadSourceKind::SubAgent | ThreadSourceKind::SubAgentReview | ThreadSourceKind::SubAgentCompact @@ -38,6 +46,7 @@ pub(crate) fn compute_source_filters( ThreadSourceKind::VsCode => Some(CoreSessionSource::VSCode), ThreadSourceKind::Exec | ThreadSourceKind::AppServer + | ThreadSourceKind::Custom | ThreadSourceKind::SubAgent | ThreadSourceKind::SubAgentReview | ThreadSourceKind::SubAgentCompact @@ -56,6 +65,7 @@ pub(crate) fn source_kind_matches(source: &CoreSessionSource, filter: &[ThreadSo ThreadSourceKind::VsCode => matches!(source, CoreSessionSource::VSCode), ThreadSourceKind::Exec => matches!(source, CoreSessionSource::Exec), ThreadSourceKind::AppServer => matches!(source, CoreSessionSource::Mcp), + ThreadSourceKind::Custom => matches!(source, CoreSessionSource::Custom(_)), ThreadSourceKind::SubAgent => matches!(source, CoreSessionSource::SubAgent(_)), ThreadSourceKind::SubAgentReview => { matches!( @@ -92,16 +102,16 @@ mod tests { fn compute_source_filters_defaults_to_interactive_sources() { let (allowed_sources, filter) = compute_source_filters(None); - assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); - assert_eq!(filter, None); + assert_eq!(allowed_sources, Vec::new()); + assert_eq!(filter, Some(interactive_source_kinds())); } #[test] fn compute_source_filters_empty_means_interactive_sources() { let (allowed_sources, filter) = compute_source_filters(Some(Vec::new())); - assert_eq!(allowed_sources, INTERACTIVE_SESSION_SOURCES.to_vec()); - assert_eq!(filter, None); + assert_eq!(allowed_sources, Vec::new()); + assert_eq!(filter, Some(interactive_source_kinds())); } #[test] @@ -125,6 +135,15 @@ mod tests { assert_eq!(filter, Some(source_kinds)); } + #[test] + fn compute_source_filters_custom_requires_post_filtering() { + let source_kinds = vec![ThreadSourceKind::Custom]; + let (allowed_sources, filter) = compute_source_filters(Some(source_kinds.clone())); + + assert_eq!(allowed_sources, Vec::new()); + assert_eq!(filter, Some(source_kinds)); + } + #[test] fn source_kind_matches_distinguishes_subagent_variants() { let parent_thread_id = @@ -154,4 +173,12 @@ mod tests { &[ThreadSourceKind::SubAgentReview] )); } + + #[test] + fn source_kind_matches_custom_sources() { + let custom = CoreSessionSource::Custom("atlas".to_string()); + + assert!(source_kind_matches(&custom, &[ThreadSourceKind::Custom])); + assert!(!source_kind_matches(&custom, &[ThreadSourceKind::Cli])); + } } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4288d1539..1716172f2 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -808,6 +808,10 @@ mod tests { for (requested_source, expected_source) in [ (SessionSource::Cli, ApiSessionSource::Cli), (SessionSource::Exec, ApiSessionSource::Exec), + ( + SessionSource::Custom("atlas".to_string()), + ApiSessionSource::Custom("atlas".to_string()), + ), ] { let client = start_test_client(requested_source).await; let response = client diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 85804098b..8b4afc23d 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -336,6 +336,7 @@ pub async fn run_main( loader_overrides, default_analytics_enabled, AppServerTransport::Stdio, + SessionSource::VSCode, ) .await } @@ -346,6 +347,7 @@ pub async fn run_main_with_transport( loader_overrides: LoaderOverrides, default_analytics_enabled: bool, transport: AppServerTransport, + session_source: SessionSource, ) -> IoResult<()> { let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -621,7 +623,7 @@ pub async fn run_main_with_transport( feedback: feedback.clone(), log_db, config_warnings, - session_source: SessionSource::VSCode, + session_source, enable_codex_api_key_env: false, }); let mut thread_created_rx = processor.thread_created_receiver(); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 11380154f..799522d73 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -4,6 +4,7 @@ use codex_app_server::run_main_with_transport; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_core::config_loader::LoaderOverrides; +use codex_protocol::protocol::SessionSource; use codex_utils_cli::CliConfigOverrides; use std::path::PathBuf; @@ -21,6 +22,17 @@ struct AppServerArgs { default_value = AppServerTransport::DEFAULT_LISTEN_URL )] listen: AppServerTransport, + + /// Session source stamped into new threads started by this app-server. + /// + /// Known values such as `vscode`, `cli`, `exec`, and `mcp` map to built-in + /// sources. Any other non-empty value is recorded as a custom source. + #[arg( + long = "session-source", + value_name = "SOURCE", + default_value = "vscode" + )] + session_source: String, } fn main() -> anyhow::Result<()> { @@ -32,6 +44,8 @@ fn main() -> anyhow::Result<()> { ..Default::default() }; let transport = args.listen; + let session_source = SessionSource::from_startup_arg(args.session_source.as_str()) + .map_err(|err| anyhow::anyhow!("invalid --session-source: {err}"))?; run_main_with_transport( arg0_paths, @@ -39,6 +53,7 @@ fn main() -> anyhow::Result<()> { loader_overrides, /*default_analytics_enabled*/ false, transport, + session_source, ) .await?; Ok(()) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f7ea2c705..63a8180b8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -231,7 +231,7 @@ impl MessageProcessor { // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. thread_manager .plugins_manager() - .maybe_start_curated_repo_sync_for_config(&config); + .maybe_start_curated_repo_sync_for_config(&config, &thread_manager.session_source()); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager: auth_manager.clone(), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 430a400a2..32d0e5eba 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -95,7 +95,11 @@ pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; impl McpProcess { pub async fn new(codex_home: &Path) -> anyhow::Result { - Self::new_with_env(codex_home, &[]).await + Self::new_with_env_and_args(codex_home, &[], &[]).await + } + + pub async fn new_with_args(codex_home: &Path, args: &[&str]) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, &[], args).await } /// Creates a new MCP process, allowing tests to override or remove @@ -106,6 +110,14 @@ impl McpProcess { pub async fn new_with_env( codex_home: &Path, env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + Self::new_with_env_and_args(codex_home, env_overrides, &[]).await + } + + pub async fn new_with_env_and_args( + codex_home: &Path, + env_overrides: &[(&str, Option<&str>)], + args: &[&str], ) -> anyhow::Result { let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find binary for codex-app-server")?; @@ -118,6 +130,7 @@ impl McpProcess { cmd.env("CODEX_HOME", codex_home); cmd.env("RUST_LOG", "info"); cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); + cmd.args(args); for (k, v) in env_overrides { match v { diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index bde356475..74baa2c30 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -25,6 +25,8 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::SkillsListResponse; use codex_core::auth::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -476,6 +478,92 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_filters_product_restricted_plugin_skills() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + None, + None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + + let plugin_root = repo_root.path().join("sample-plugin"); + write_plugin_skill( + &plugin_root, + "all-products", + "Visible to every product", + &[], + )?; + write_plugin_skill( + &plugin_root, + "chatgpt-only", + "Visible to ChatGPT", + &["CHATGPT"], + )?; + write_plugin_skill(&plugin_root, "atlas-only", "Visible to Atlas", &["ATLAS"])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = + McpProcess::new_with_args(codex_home.path(), &["--session-source", "chatgpt"]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path, + plugin_name: "sample-plugin".to_string(), + force_remote_sync: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + + let request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![codex_home.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 response: SkillsListResponse = to_response(response)?; + + let mut skills = response + .data + .into_iter() + .flat_map(|entry| entry.skills.into_iter()) + .map(|skill| skill.name) + .filter(|name| name.starts_with("sample-plugin:")) + .collect::>(); + skills.sort_unstable(); + + assert_eq!( + skills, + vec![ + "sample-plugin:all-products".to_string(), + "sample-plugin:chatgpt-only".to_string(), + ] + ); + Ok(()) +} + #[derive(Clone)] struct AppsServerState { response: Arc>, @@ -647,6 +735,16 @@ plugins = true ) } +fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + r#" +[features] +plugins = true +"#, + ) +} + fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, @@ -716,3 +814,32 @@ fn write_plugin_source( )?; Ok(()) } + +fn write_plugin_skill( + plugin_root: &std::path::Path, + skill_name: &str, + description: &str, + products: &[&str], +) -> Result<()> { + 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!("---\ndescription: {description}\n---\n\n# {skill_name}\n"), + )?; + + if !products.is_empty() { + let products = products + .iter() + .map(|product| format!(" - {product}")) + .collect::>() + .join("\n"); + std::fs::create_dir_all(skill_dir.join("agents"))?; + std::fs::write( + skill_dir.join("agents/openai.yaml"), + format!("policy:\n products:\n{products}\n"), + )?; + } + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index c409fdeb3..278cf00ab 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -377,6 +377,179 @@ enabled = false Ok(()) } +#[tokio::test] +async fn plugin_list_filters_plugins_for_custom_session_source_products() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "all-products", + "source": { + "source": "local", + "path": "./all-products" + } + }, + { + "name": "chatgpt-only", + "source": { + "source": "local", + "path": "./chatgpt-only" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + "products": ["CHATGPT"] + } + }, + { + "name": "atlas-only", + "source": { + "source": "local", + "path": "./atlas-only" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + "products": ["ATLAS"] + } + }, + { + "name": "codex-only", + "source": { + "source": "local", + "path": "./codex-only" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + "products": ["CODEX"] + } + } + ] +}"#, + )?; + + let mut mcp = + McpProcess::new_with_args(codex_home.path(), &["--session-source", "chatgpt"]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + force_remote_sync: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "codex-curated") + .expect("expected marketplace entry"); + + assert_eq!( + marketplace + .plugins + .into_iter() + .map(|plugin| plugin.name) + .collect::>(), + vec!["all-products".to_string(), "chatgpt-only".to_string()] + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_defaults_non_custom_session_source_to_codex_products() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "all-products", + "source": { + "source": "local", + "path": "./all-products" + } + }, + { + "name": "chatgpt-only", + "source": { + "source": "local", + "path": "./chatgpt-only" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + "products": ["CHATGPT"] + } + }, + { + "name": "codex-only", + "source": { + "source": "local", + "path": "./codex-only" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + "products": ["CODEX"] + } + } + ] +}"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + force_remote_sync: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "codex-curated") + .expect("expected marketplace entry"); + + assert_eq!( + marketplace + .plugins + .into_iter() + .map(|plugin| plugin.name) + .collect::>(), + vec!["all-products".to_string(), "codex-only".to_string()] + ); + Ok(()) +} + #[tokio::test] async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 05b568b7b..698035fea 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -331,6 +331,17 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, + /// Session source stamped into new threads started by this app-server. + /// + /// Known values such as `vscode`, `cli`, `exec`, and `mcp` map to built-in + /// sources. Any other non-empty value is recorded as a custom source. + #[arg( + long = "session-source", + value_name = "SOURCE", + default_value = "vscode" + )] + session_source: String, + /// Controls whether analytics are enabled by default. /// /// Analytics are disabled by default for app-server. Users have to explicitly opt in @@ -643,12 +654,17 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { None => { reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?; let transport = app_server_cli.listen; + let session_source = codex_protocol::protocol::SessionSource::from_startup_arg( + app_server_cli.session_source.as_str(), + ) + .map_err(|err| anyhow::anyhow!("invalid --session-source: {err}"))?; codex_app_server::run_main_with_transport( arg0_paths.clone(), root_config_overrides, codex_core::config_loader::LoaderOverrides::default(), app_server_cli.analytics_default_enabled, transport, + session_source, ) .await?; } @@ -1615,6 +1631,7 @@ mod tests { app_server.listen, codex_app_server::AppServerTransport::Stdio ); + assert_eq!(app_server.session_source, "vscode"); } #[test] @@ -1624,6 +1641,13 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn app_server_session_source_accepts_custom_value() { + let app_server = + app_server_from_args(["codex", "app-server", "--session-source", "atlas"].as_ref()); + assert_eq!(app_server.session_source, "atlas"); + } + #[test] fn remote_flag_parses_for_interactive_root() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "ws://127.0.0.1:4500"]) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index efde7d848..7b947249b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2385,11 +2385,12 @@ impl Session { &per_turn_config, ) .await; - let skills_outcome = Arc::new( + let skills_outcome = Arc::new(crate::skills::filter_skill_load_outcome_for_session_source( self.services .skills_manager .skills_for_config(&per_turn_config), - ); + &session_configuration.session_source, + )); let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), &self.services.session_telemetry, @@ -4773,17 +4774,24 @@ mod handlers { cwds: Vec, force_reload: bool, ) { - let cwds = if cwds.is_empty() { + let (cwds, session_source) = if cwds.is_empty() { let state = sess.state.lock().await; - vec![state.session_configuration.cwd.clone()] + ( + vec![state.session_configuration.cwd.clone()], + state.session_configuration.session_source.clone(), + ) } else { - cwds + let state = sess.state.lock().await; + (cwds, state.session_configuration.session_source.clone()) }; let skills_manager = &sess.services.skills_manager; let mut skills = Vec::new(); for cwd in cwds { - let outcome = skills_manager.skills_for_cwd(&cwd, force_reload).await; + let outcome = crate::skills::filter_skill_load_outcome_for_session_source( + skills_manager.skills_for_cwd(&cwd, force_reload).await, + &session_source, + ); let errors = super::errors_to_info(&outcome.errors); let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths); skills.push(SkillsListEntry { diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 22c536f21..7344e588f 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -44,6 +44,7 @@ use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::MergeStrategy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; @@ -938,7 +939,11 @@ impl PluginsManager { }) } - pub fn maybe_start_curated_repo_sync_for_config(self: &Arc, config: &Config) { + pub fn maybe_start_curated_repo_sync_for_config( + self: &Arc, + config: &Config, + session_source: &SessionSource, + ) { if plugins_feature_enabled_from_stack(&config.config_layer_stack) { let mut configured_curated_plugin_ids = configured_plugins_from_stack(&config.config_layer_stack) @@ -961,11 +966,15 @@ impl PluginsManager { }) .collect::>(); configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key); - self.start_curated_repo_sync(configured_curated_plugin_ids); + self.start_curated_repo_sync(configured_curated_plugin_ids, session_source.clone()); } } - fn start_curated_repo_sync(self: &Arc, configured_curated_plugin_ids: Vec) { + fn start_curated_repo_sync( + self: &Arc, + configured_curated_plugin_ids: Vec, + session_source: SessionSource, + ) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; } @@ -980,6 +989,7 @@ impl PluginsManager { codex_home.as_path(), &curated_plugin_version, &configured_curated_plugin_ids, + &session_source, ) { Ok(cache_refreshed) => { if cache_refreshed { @@ -1205,6 +1215,7 @@ fn refresh_curated_plugin_cache( codex_home: &Path, plugin_version: &str, configured_curated_plugin_ids: &[PluginId], + session_source: &SessionSource, ) -> Result { let store = PluginStore::new(codex_home.to_path_buf()); let curated_marketplace_path = AbsolutePathBuf::try_from( @@ -1215,9 +1226,12 @@ fn refresh_curated_plugin_cache( .map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?; let mut plugin_sources = HashMap::::new(); + let mut product_restricted_plugin_names = HashSet::::new(); for plugin in curated_marketplace.plugins { let plugin_name = plugin.name; - if plugin_sources.contains_key(&plugin_name) { + if plugin_sources.contains_key(&plugin_name) + || product_restricted_plugin_names.contains(&plugin_name) + { warn!( plugin = plugin_name, marketplace = OPENAI_CURATED_MARKETPLACE_NAME, @@ -1228,16 +1242,32 @@ fn refresh_curated_plugin_cache( let source_path = match plugin.source { MarketplacePluginSource::Local { path } => path, }; - plugin_sources.insert(plugin_name, source_path); + if session_source.matches_product_restriction(&plugin.policy.products) { + plugin_sources.insert(plugin_name, source_path); + } else { + product_restricted_plugin_names.insert(plugin_name); + } } let mut cache_refreshed = false; for plugin_id in configured_curated_plugin_ids { + // Curated plugin cache entries are intentionally sticky across session source changes. + // Product restrictions gate refresh for this source, but do not retroactively evict an + // already-active cache entry from a shared CODEX_HOME. if store.active_plugin_version(plugin_id).as_deref() == Some(plugin_version) { continue; } let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { + if product_restricted_plugin_names.contains(&plugin_id.plugin_name) { + info!( + plugin = plugin_id.plugin_name, + marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + session_source = %session_source, + "skipping curated plugin cache refresh for product-restricted plugin" + ); + continue; + } warn!( plugin = plugin_id.plugin_name, marketplace = OPENAI_CURATED_MARKETPLACE_NAME, diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index d113d56a9..17b7fd3e7 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -9,10 +9,12 @@ use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; use crate::plugins::MarketplacePluginInstallPolicy; use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; +use crate::plugins::test_support::write_curated_plugin; use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::protocol::SessionSource; use pretty_assertions::assert_eq; use std::fs; use tempfile::TempDir; @@ -1032,6 +1034,12 @@ async fn list_marketplaces_includes_curated_repo_marketplace() { r#"{"name":"linear"}"#, ) .unwrap(); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) @@ -1627,8 +1635,13 @@ fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() { ); assert!( - refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) - .expect("cache refresh should succeed") + refresh_curated_plugin_cache( + tmp.path(), + TEST_CURATED_PLUGIN_SHA, + &[plugin_id], + &SessionSource::Cli, + ) + .expect("cache refresh should succeed") ); assert!( @@ -1658,8 +1671,13 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren .unwrap(); assert!( - refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) - .expect("cache refresh should recreate missing configured plugin") + refresh_curated_plugin_cache( + tmp.path(), + TEST_CURATED_PLUGIN_SHA, + &[plugin_id], + &SessionSource::Cli, + ) + .expect("cache refresh should recreate missing configured plugin") ); assert!( @@ -1688,8 +1706,64 @@ fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_curren ); assert!( - !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) - .expect("cache refresh should be a no-op when configured plugins are current") + !refresh_curated_plugin_cache( + tmp.path(), + TEST_CURATED_PLUGIN_SHA, + &[plugin_id], + &SessionSource::Cli, + ) + .expect("cache refresh should be a no-op when configured plugins are current") + ); +} + +#[test] +fn refresh_curated_plugin_cache_skips_product_restricted_plugins_for_session_source() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_file( + &curated_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "plugins": [ + {{ + "name": "chatgpt-plugin", + "source": {{ + "source": "local", + "path": "./plugins/chatgpt-plugin" + }}, + "policy": {{ + "products": ["CHATGPT"] + }} + }} + ] +}}"# + ), + ); + write_curated_plugin(&curated_root, "chatgpt-plugin"); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let plugin_id = PluginId::new( + "chatgpt-plugin".to_string(), + OPENAI_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + + assert!( + !refresh_curated_plugin_cache( + tmp.path(), + TEST_CURATED_PLUGIN_SHA, + &[plugin_id], + &SessionSource::Cli, + ) + .expect("cache refresh should skip disallowed product plugin") + ); + + assert!( + !tmp.path() + .join(format!( + "plugins/cache/openai-curated/chatgpt-plugin/{TEST_CURATED_PLUGIN_SHA}" + )) + .exists() ); } diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index ff6bdecc8..e7e0d2e4d 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -54,8 +54,6 @@ pub enum MarketplacePluginSource { pub struct MarketplacePluginPolicy { pub installation: MarketplacePluginInstallPolicy, pub authentication: MarketplacePluginAuthPolicy, - // TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of - // only carrying it through core marketplace metadata. pub products: Vec, } diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 8c311c5d3..2a3975141 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -20,4 +20,6 @@ pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; pub use model::SkillPolicy; +pub use model::filter_skill_load_outcome_for_session_source; +pub use model::filter_skills_for_session_source; pub use render::render_skills_section; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 0949300ec..f4fa1515a 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Product; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use serde::Deserialize; @@ -42,13 +43,18 @@ impl SkillMetadata { .and_then(|policy| policy.allow_implicit_invocation) .unwrap_or(true) } + + pub fn matches_product_restriction(&self, session_source: &SessionSource) -> bool { + match &self.policy { + Some(policy) => session_source.matches_product_restriction(&policy.products), + None => true, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct SkillPolicy { pub allow_implicit_invocation: Option, - // TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and - // storing this metadata. pub products: Vec, } @@ -115,3 +121,39 @@ impl SkillLoadOutcome { .map(|skill| (skill, self.is_skill_enabled(skill))) } } + +pub fn filter_skill_load_outcome_for_session_source( + mut outcome: SkillLoadOutcome, + session_source: &SessionSource, +) -> SkillLoadOutcome { + outcome + .skills + .retain(|skill| skill.matches_product_restriction(session_source)); + outcome.implicit_skills_by_scripts_dir = Arc::new( + outcome + .implicit_skills_by_scripts_dir + .iter() + .filter(|(_, skill)| skill.matches_product_restriction(session_source)) + .map(|(path, skill)| (path.clone(), skill.clone())) + .collect(), + ); + outcome.implicit_skills_by_doc_path = Arc::new( + outcome + .implicit_skills_by_doc_path + .iter() + .filter(|(_, skill)| skill.matches_product_restriction(session_source)) + .map(|(path, skill)| (path.clone(), skill.clone())) + .collect(), + ); + outcome +} + +pub fn filter_skills_for_session_source( + skills: Vec, + session_source: &SessionSource, +) -> Vec { + skills + .into_iter() + .filter(|skill| skill.matches_product_restriction(session_source)) + .collect() +} diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index e2c86a6e6..633f68b6a 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -276,7 +276,7 @@ impl SessionTelemetry { account_email, originator: sanitize_metric_tag_value(originator.as_str()), service_name: None, - session_source: session_source.to_string(), + session_source: sanitize_metric_tag_value(session_source.to_string().as_str()), model: model.to_owned(), slug: slug.to_owned(), log_user_prompts, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e5277c16b..3965e2872 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2272,6 +2272,7 @@ pub enum SessionSource { VSCode, Exec, Mcp, + Custom(String), SubAgent(SubAgentSource), #[serde(other)] Unknown, @@ -2302,6 +2303,7 @@ impl fmt::Display for SessionSource { SessionSource::VSCode => f.write_str("vscode"), SessionSource::Exec => f.write_str("exec"), SessionSource::Mcp => f.write_str("mcp"), + SessionSource::Custom(source) => f.write_str(source), SessionSource::SubAgent(sub_source) => write!(f, "subagent_{sub_source}"), SessionSource::Unknown => f.write_str("unknown"), } @@ -2309,6 +2311,23 @@ impl fmt::Display for SessionSource { } impl SessionSource { + pub fn from_startup_arg(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("session source must not be empty"); + } + + let normalized = trimmed.to_ascii_lowercase(); + Ok(match normalized.as_str() { + "cli" => SessionSource::Cli, + "vscode" => SessionSource::VSCode, + "exec" => SessionSource::Exec, + "mcp" | "appserver" | "app-server" | "app_server" => SessionSource::Mcp, + "unknown" => SessionSource::Unknown, + _ => SessionSource::Custom(trimmed.to_string()), + }) + } + pub fn get_nickname(&self) -> Option { match self { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => { @@ -2332,6 +2351,25 @@ impl SessionSource { _ => None, } } + + pub fn restriction_product(&self) -> Option { + match self { + SessionSource::Custom(source) => Product::from_session_source_name(source), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::SubAgent(_) + | SessionSource::Unknown => Some(Product::Codex), + } + } + + pub fn matches_product_restriction(&self, products: &[Product]) -> bool { + products.is_empty() + || self + .restriction_product() + .is_some_and(|product| products.contains(&product)) + } } impl fmt::Display for SubAgentSource { @@ -2923,6 +2961,18 @@ pub enum Product { #[serde(alias = "ATLAS")] Atlas, } + +impl Product { + pub fn from_session_source_name(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "chatgpt" => Some(Self::Chatgpt), + "codex" => Some(Self::Codex), + "atlas" => Some(Self::Atlas), + _ => None, + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] @@ -3423,6 +3473,92 @@ mod tests { .any(|root| root.is_path_writable(path)) } + #[test] + fn session_source_from_startup_arg_maps_known_values() { + assert_eq!( + SessionSource::from_startup_arg("vscode").unwrap(), + SessionSource::VSCode + ); + assert_eq!( + SessionSource::from_startup_arg("app-server").unwrap(), + SessionSource::Mcp + ); + } + + #[test] + fn session_source_from_startup_arg_preserves_custom_values() { + assert_eq!( + SessionSource::from_startup_arg("atlas").unwrap(), + SessionSource::Custom("atlas".to_string()) + ); + } + + #[test] + fn session_source_restriction_product_defaults_non_custom_sources_to_codex() { + assert_eq!( + SessionSource::Cli.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::VSCode.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Exec.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Mcp.restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::SubAgent(SubAgentSource::Review).restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Unknown.restriction_product(), + Some(Product::Codex) + ); + } + + #[test] + fn session_source_restriction_product_maps_custom_sources_to_products() { + assert_eq!( + SessionSource::Custom("chatgpt".to_string()).restriction_product(), + Some(Product::Chatgpt) + ); + assert_eq!( + SessionSource::Custom("ATLAS".to_string()).restriction_product(), + Some(Product::Atlas) + ); + assert_eq!( + SessionSource::Custom("codex".to_string()).restriction_product(), + Some(Product::Codex) + ); + assert_eq!( + SessionSource::Custom("atlas-dev".to_string()).restriction_product(), + None + ); + } + + #[test] + fn session_source_matches_product_restriction() { + assert!( + SessionSource::Custom("chatgpt".to_string()) + .matches_product_restriction(&[Product::Chatgpt]) + ); + assert!( + !SessionSource::Custom("chatgpt".to_string()) + .matches_product_restriction(&[Product::Codex]) + ); + assert!(SessionSource::VSCode.matches_product_restriction(&[Product::Codex])); + assert!( + !SessionSource::Custom("atlas-dev".to_string()) + .matches_product_restriction(&[Product::Atlas]) + ); + assert!(SessionSource::Custom("atlas-dev".to_string()).matches_product_restriction(&[])); + } + fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec { let mut paths = vec![cwd.to_path_buf()]; paths.extend( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 56fd66f06..54a7a2c66 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1991,7 +1991,7 @@ impl App { // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. thread_manager .plugins_manager() - .maybe_start_curated_repo_sync_for_config(&config); + .maybe_start_curated_repo_sync_for_config(&config, &SessionSource::Cli); let mut model = thread_manager .get_models_manager() .get_default_model(&config.model, RefreshStrategy::Offline) diff --git a/sdk/python/src/codex_app_server/generated/v2_all.py b/sdk/python/src/codex_app_server/generated/v2_all.py index 0ff2c5897..d1b02ccc9 100644 --- a/sdk/python/src/codex_app_server/generated/v2_all.py +++ b/sdk/python/src/codex_app_server/generated/v2_all.py @@ -3131,6 +3131,7 @@ class ThreadSourceKind(Enum): vscode = "vscode" exec = "exec" app_server = "appServer" + custom = "custom" sub_agent = "subAgent" sub_agent_review = "subAgentReview" sub_agent_compact = "subAgentCompact" @@ -5810,11 +5811,19 @@ class SubAgentSessionSource(BaseModel): sub_agent: Annotated[SubAgentSource, Field(alias="subAgent")] -class SessionSource(RootModel[SessionSourceValue | SubAgentSessionSource]): +class CustomSessionSource(BaseModel): + model_config = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + custom: str + + +class SessionSource(RootModel[SessionSourceValue | CustomSessionSource | SubAgentSessionSource]): model_config = ConfigDict( populate_by_name=True, ) - root: SessionSourceValue | SubAgentSessionSource + root: SessionSourceValue | CustomSessionSource | SubAgentSessionSource class Thread(BaseModel):