From 44d992c14e0bb741c24aef305ba9e7b99a4a70ac Mon Sep 17 00:00:00 2001 From: edwardysun3 Date: Tue, 11 Aug 2026 19:11:25 +0000 Subject: [PATCH] Track artifact operations from trusted plugin markers (#38057) ## What changed - Recognize validated create and edit marker commands from the trusted presentations, documents, spreadsheets, and PDF plugins. - Emit a `codex_artifact_operation` analytics event plus started-count and expected-output-count metrics when a recognized marker starts through unified exec. - Attribute remote executor commands only when their plugin cache path and script contents match a trusted local plugin, including primary-runtime plugins. ## Testing - Cover supported marker combinations and reject mismatched plugins, paths, arguments, counts, and output formats. - Cover local, relocated remote, and Windows-style plugin attribution, including rejection of modified remote scripts. GitOrigin-RevId: 475cc1689aa7f371a24dec0a8bdb6f7fd5a2e922 --- .../analytics/src/analytics_client_tests.rs | 60 ++++ codex-rs/analytics/src/client.rs | 15 + codex-rs/analytics/src/client_tests.rs | 40 +++ codex-rs/analytics/src/events.rs | 57 ++++ codex-rs/analytics/src/facts.rs | 27 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 15 + .../core-plugins/src/artifact_operation.rs | 108 +++++++ .../src/artifact_operation_tests.rs | 151 ++++++++++ codex-rs/core-plugins/src/lib.rs | 4 + .../core-plugins/src/marketplace_policy.rs | 27 +- .../src/marketplace_policy_tests.rs | 11 + .../core-plugins/src/script_attribution.rs | 275 ++++++++++++++---- .../src/script_attribution_tests.rs | 152 ++++++++++ codex-rs/core/src/session/turn_context.rs | 13 + codex-rs/core/src/tools/events.rs | 52 ++++ .../core/src/unified_exec/process_manager.rs | 19 +- codex-rs/otel/src/metrics/names.rs | 3 + 18 files changed, 971 insertions(+), 60 deletions(-) create mode 100644 codex-rs/core-plugins/src/artifact_operation.rs create mode 100644 codex-rs/core-plugins/src/artifact_operation_tests.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 22cc870e96..9a32244a24 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -41,12 +41,16 @@ use crate::events::codex_app_metadata; use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; +use crate::events::current_runtime_metadata; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::ArtifactOperation; +use crate::facts::ArtifactOperationInput; +use crate::facts::ArtifactOperationLifecycle; use crate::facts::CodeModeToolCallFact; use crate::facts::CodeModeToolCallStatus; use crate::facts::CodexCompactionEvent; @@ -3745,6 +3749,62 @@ fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() { assert_eq!(queue.should_enqueue_plugin_used(&turn_2, &plugin), true); } +#[tokio::test] +async fn reducer_ingests_artifact_operation_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ArtifactOperation( + ArtifactOperationInput { + tracking: test_tracking_context("thread-1", "turn-1"), + operation: ArtifactOperation { + item_id: "call-1".to_string(), + lifecycle: ArtifactOperationLifecycle::Started, + occurred_at_ms: 1_786_000_000_000, + plugin_id: "presentations@openai-primary-runtime".to_string(), + script_path: "skills/presentations/container_tools/mark_artifact_operation_started.mjs".to_string(), + skill: "presentations".to_string(), + artifact_type: "presentation".to_string(), + operation_kind: "create".to_string(), + expected_output_count: 2, + output_format: "pptx".to_string(), + execution_backend: "unified_exec".to_string(), + }, + }, + )), + &mut events, + ) + .await; + + assert!(events[0].can_send_with_api_key_auth()); + assert_eq!( + serde_json::to_value(events).expect("serialize events"), + json!([{ + "event_type": "codex_artifact_operation", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "item_id": "call-1", + "lifecycle": "started", + "occurred_at_ms": 1_786_000_000_000_u64, + "product_client_id": TEST_PRODUCT_CLIENT_ID, + "runtime": serde_json::to_value(current_runtime_metadata()) + .expect("serialize runtime metadata"), + "model_slug": "gpt-5", + "plugin_id": "presentations@openai-primary-runtime", + "script_path": "skills/presentations/container_tools/mark_artifact_operation_started.mjs", + "skill": "presentations", + "artifact_type": "presentation", + "operation_kind": "create", + "expected_output_count": 2, + "output_format": "pptx", + "execution_backend": "unified_exec" + } + }]) + ); +} + #[tokio::test] async fn reducer_ingests_skill_invoked_fact() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bbf83296ec..5b081e53b3 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -9,6 +9,8 @@ use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::ArtifactOperation; +use crate::facts::ArtifactOperationInput; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::ExternalAgentConfigImportCompletedInput; @@ -269,6 +271,19 @@ impl AnalyticsEventsClient { ))); } + pub fn track_artifact_operation( + &self, + tracking: TrackEventsContext, + operation: ArtifactOperation, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ArtifactOperation(ArtifactOperationInput { + tracking, + operation, + }), + )); + } + pub fn track_initialize( &self, connection_id: u64, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 327c4ce058..b89a3bf422 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -42,8 +42,16 @@ use crate::events::ThreadArchiveEventParams; #[cfg(debug_assertions)] use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; +#[cfg(debug_assertions)] +use crate::events::codex_artifact_operation_event_request; use crate::facts::AnalyticsFact; +#[cfg(debug_assertions)] +use crate::facts::ArtifactOperation; +#[cfg(debug_assertions)] +use crate::facts::ArtifactOperationLifecycle; use crate::facts::InvocationType; +#[cfg(debug_assertions)] +use crate::facts::TrackEventsContext; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; @@ -138,6 +146,32 @@ fn sample_skill_track_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEv }) } +#[cfg(debug_assertions)] +fn sample_artifact_operation_event(thread_id: &str) -> TrackEventRequest { + TrackEventRequest::ArtifactOperation(codex_artifact_operation_event_request( + TrackEventsContext { + model_slug: "gpt-5.1-codex".to_string(), + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + product_client_id: "codex_desktop".to_string(), + }, + ArtifactOperation { + item_id: format!("item-{thread_id}"), + lifecycle: ArtifactOperationLifecycle::Started, + occurred_at_ms: 1, + plugin_id: "presentations@openai-primary-runtime".to_string(), + script_path: "skills/presentations/container_tools/mark_artifact_operation_started.mjs" + .to_string(), + skill: "presentations".to_string(), + artifact_type: "presentation".to_string(), + operation_kind: "create".to_string(), + expected_output_count: 1, + output_format: "pptx".to_string(), + execution_backend: "unified_exec".to_string(), + }, + )) +} + fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest { sample_skill_track_event(thread_id, /*plugin_id*/ None) } @@ -393,6 +427,7 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() { sample_plugin_used_track_event("plugin-used", Some("sample@test")), sample_skill_track_event("plugin-skill", Some("sample@test")), sample_mcp_tool_call_event("plugin-mcp", Some("sample@test")), + sample_artifact_operation_event("plugin-artifact"), ], ) .await; @@ -442,6 +477,11 @@ async fn api_key_auth_sends_only_plugin_events_to_codex_backend() { "plugin_id": "sample@test", "thread_id": "plugin-mcp", }), + serde_json::json!({ + "event_type": "codex_artifact_operation", + "plugin_id": "presentations@openai-primary-runtime", + "thread_id": "plugin-artifact", + }), ] ); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 34a71dc012..f3ca378b6b 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -2,6 +2,8 @@ use std::time::Instant; use crate::facts::AcceptedLineFingerprint; use crate::facts::AppInvocation; +use crate::facts::ArtifactOperation; +use crate::facts::ArtifactOperationLifecycle; use crate::facts::CodexCompactionEvent; use crate::facts::CodexErrKind; use crate::facts::CodexGoalEvent; @@ -72,6 +74,7 @@ pub(crate) enum TrackEventRequest { Goal(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), + ArtifactOperation(CodexArtifactOperationEventRequest), CommandExecution(CodexCommandExecutionEventRequest), FileChange(CodexFileChangeEventRequest), McpToolCall(CodexMcpToolCallEventRequest), @@ -93,6 +96,59 @@ pub(crate) enum TrackEventRequest { ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest), } +#[derive(Serialize)] +pub(crate) struct CodexArtifactOperationEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) item_id: String, + pub(crate) lifecycle: ArtifactOperationLifecycle, + pub(crate) occurred_at_ms: u64, + pub(crate) product_client_id: String, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) model_slug: String, + pub(crate) plugin_id: String, + pub(crate) script_path: String, + pub(crate) skill: String, + pub(crate) artifact_type: String, + pub(crate) operation_kind: String, + pub(crate) expected_output_count: u32, + pub(crate) output_format: String, + pub(crate) execution_backend: String, +} + +#[derive(Serialize)] +pub(crate) struct CodexArtifactOperationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexArtifactOperationEventParams, +} + +pub(crate) fn codex_artifact_operation_event_request( + tracking: TrackEventsContext, + operation: ArtifactOperation, +) -> CodexArtifactOperationEventRequest { + CodexArtifactOperationEventRequest { + event_type: "codex_artifact_operation", + event_params: CodexArtifactOperationEventParams { + thread_id: tracking.thread_id, + turn_id: tracking.turn_id, + item_id: operation.item_id, + lifecycle: operation.lifecycle, + occurred_at_ms: operation.occurred_at_ms, + product_client_id: tracking.product_client_id, + runtime: current_runtime_metadata(), + model_slug: tracking.model_slug, + plugin_id: operation.plugin_id, + script_path: operation.script_path, + skill: operation.skill, + artifact_type: operation.artifact_type, + operation_kind: operation.operation_kind, + expected_output_count: operation.expected_output_count, + output_format: operation.output_format, + execution_backend: operation.execution_backend, + }, + } +} + impl TrackEventRequest { pub(crate) fn should_send_in_isolated_request(&self) -> bool { matches!(self, Self::AcceptedLineFingerprints(_)) @@ -103,6 +159,7 @@ impl TrackEventRequest { Self::PluginUsed(event) => event.event_params.plugin.plugin_id.is_some(), Self::SkillInvocation(event) => event.event_params.plugin_id.is_some(), Self::McpToolCall(event) => event.event_params.plugin_id.is_some(), + Self::ArtifactOperation(event) => !event.event_params.plugin_id.is_empty(), _ => false, } } diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 0547aa1119..0354606579 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -45,6 +45,27 @@ pub struct TrackEventsContext { pub product_client_id: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactOperationLifecycle { + Started, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArtifactOperation { + pub item_id: String, + pub lifecycle: ArtifactOperationLifecycle, + pub occurred_at_ms: u64, + pub plugin_id: String, + pub script_path: String, + pub skill: String, + pub artifact_type: String, + pub operation_kind: String, + pub expected_output_count: u32, + pub output_format: String, + pub execution_backend: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum CodeModeToolCallFact { CellStarted { @@ -489,6 +510,7 @@ pub(crate) enum AnalyticsFact { } pub(crate) enum CustomAnalyticsFact { + ArtifactOperation(ArtifactOperationInput), CodeModeToolCall(CodeModeToolCallFact), SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), @@ -511,6 +533,11 @@ pub(crate) enum CustomAnalyticsFact { ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput), } +pub(crate) struct ArtifactOperationInput { + pub tracking: TrackEventsContext, + pub operation: ArtifactOperation, +} + pub(crate) struct SkillInvokedInput { pub tracking: TrackEventsContext, pub invocations: Vec, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 29ab3bbbc0..6af9e79a58 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -26,6 +26,8 @@ pub use events::GuardianReviewedAction; pub use facts::AcceptedLineFingerprint; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; +pub use facts::ArtifactOperation; +pub use facts::ArtifactOperationLifecycle; pub use facts::CodeModeToolCallFact; pub use facts::CodeModeToolCallStatus; pub use facts::CodexCompactionEvent; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 4e5190982b..1f945fa8e7 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -61,6 +61,7 @@ use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; use crate::events::WebSearchActionKind; use crate::events::codex_app_metadata; +use crate::events::codex_artifact_operation_event_request; use crate::events::codex_compaction_event_params; use crate::events::codex_goal_event_params; use crate::events::codex_hook_run_metadata; @@ -74,6 +75,7 @@ use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; +use crate::facts::ArtifactOperationInput; use crate::facts::CodeModeToolCallFact; use crate::facts::CodeModeToolCallStatus; use crate::facts::CodexCompactionEvent; @@ -550,6 +552,9 @@ impl AnalyticsReducer { self.ingest_server_request_aborted(completed_at_ms, request_id, out); } AnalyticsFact::Custom(input) => match input { + CustomAnalyticsFact::ArtifactOperation(input) => { + self.ingest_artifact_operation(input, out); + } CustomAnalyticsFact::CodeModeToolCall(input) => { self.ingest_code_mode_tool_call(input, out); } @@ -614,6 +619,16 @@ impl AnalyticsReducer { } } + fn ingest_artifact_operation( + &mut self, + input: ArtifactOperationInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ArtifactOperation( + codex_artifact_operation_event_request(input.tracking, input.operation), + )); + } + fn ingest_code_mode_tool_call( &mut self, input: CodeModeToolCallFact, diff --git a/codex-rs/core-plugins/src/artifact_operation.rs b/codex-rs/core-plugins/src/artifact_operation.rs new file mode 100644 index 0000000000..0bfc14f63e --- /dev/null +++ b/codex-rs/core-plugins/src/artifact_operation.rs @@ -0,0 +1,108 @@ +use crate::PluginCommandAttribution; +use crate::command_script_arguments; + +const PRIMARY_RUNTIME_MARKETPLACE_NAME: &str = "openai-primary-runtime"; +const MAX_EXPECTED_OUTPUT_COUNT: u32 = 100; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ArtifactOperation { + pub plugin_name: &'static str, + pub script_path: &'static str, + pub artifact_type: &'static str, + pub operation_kind: &'static str, + pub expected_output_count: u32, + pub output_format: &'static str, +} + +struct ArtifactSkill { + plugin_name: &'static str, + script_path: &'static str, + artifact_type: &'static str, + output_formats: &'static [&'static str], +} + +const ARTIFACT_SKILLS: &[ArtifactSkill] = &[ + ArtifactSkill { + plugin_name: "presentations", + script_path: "skills/presentations/container_tools/mark_artifact_operation_started.mjs", + artifact_type: "presentation", + output_formats: &["ppt", "pptx"], + }, + ArtifactSkill { + plugin_name: "documents", + script_path: "skills/documents/container_tools/mark_artifact_operation_started.mjs", + artifact_type: "document", + output_formats: &["doc", "docx"], + }, + ArtifactSkill { + plugin_name: "spreadsheets", + script_path: "skills/spreadsheets/container_tools/mark_artifact_operation_started.mjs", + artifact_type: "spreadsheet", + output_formats: &["csv", "tsv", "xls", "xlsm", "xlsx"], + }, + ArtifactSkill { + plugin_name: "pdf", + script_path: "skills/pdf/container_tools/mark_artifact_operation_started.mjs", + artifact_type: "pdf", + output_formats: &["pdf"], + }, +]; + +pub fn recognize_artifact_operation( + attribution: Option<&PluginCommandAttribution>, + command: &[String], +) -> Option { + let attribution = attribution?; + if attribution.plugin_id.marketplace_name != PRIMARY_RUNTIME_MARKETPLACE_NAME { + return None; + } + let skill = ARTIFACT_SKILLS.iter().find(|skill| { + attribution.plugin_id.plugin_name == skill.plugin_name + && attribution.normalized_relative_path == skill.script_path + })?; + let script_arguments = command_script_arguments(command)?; + let [ + operation_kind_flag, + operation_kind, + expected_output_count_flag, + expected_output_count, + output_format_flag, + output_format, + ] = script_arguments.as_slice() + else { + return None; + }; + if operation_kind_flag != "--operation-kind" + || expected_output_count_flag != "--expected-output-count" + || output_format_flag != "--output-format" + { + return None; + } + let operation_kind = match operation_kind.as_str() { + "create" => "create", + "edit" => "edit", + _ => return None, + }; + let expected_output_count = expected_output_count.parse::().ok()?; + if !(1..=MAX_EXPECTED_OUTPUT_COUNT).contains(&expected_output_count) { + return None; + } + let output_format = skill + .output_formats + .iter() + .copied() + .find(|known_format| known_format.eq_ignore_ascii_case(output_format))?; + + Some(ArtifactOperation { + plugin_name: skill.plugin_name, + script_path: skill.script_path, + artifact_type: skill.artifact_type, + operation_kind, + expected_output_count, + output_format, + }) +} + +#[cfg(test)] +#[path = "artifact_operation_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/artifact_operation_tests.rs b/codex-rs/core-plugins/src/artifact_operation_tests.rs new file mode 100644 index 0000000000..dc1e738f23 --- /dev/null +++ b/codex-rs/core-plugins/src/artifact_operation_tests.rs @@ -0,0 +1,151 @@ +use super::*; +use codex_plugin::PluginId; +use pretty_assertions::assert_eq; + +#[test] +fn recognizes_supported_artifact_markers() { + for skill in ARTIFACT_SKILLS { + for operation_kind in ["create", "edit"] { + let attribution = attribution(skill.plugin_name, skill.script_path); + assert_eq!( + recognize_artifact_operation( + Some(&attribution), + &marker_command( + skill.script_path, + operation_kind, + "2", + skill.output_formats[0], + ), + ), + Some(ArtifactOperation { + plugin_name: skill.plugin_name, + script_path: skill.script_path, + artifact_type: skill.artifact_type, + operation_kind, + expected_output_count: 2, + output_format: skill.output_formats[0], + }) + ); + } + } +} + +#[test] +fn rejects_untrusted_mismatched_or_invalid_markers() { + let presentation = &ARTIFACT_SKILLS[0]; + let cases = [ + ( + None, + marker_command(presentation.script_path, "create", "1", "pptx"), + ), + ( + Some(PluginCommandAttribution { + plugin_id: PluginId::parse("documents@openai-primary-runtime").expect("plugin id"), + normalized_relative_path: presentation.script_path.to_string(), + }), + marker_command(presentation.script_path, "create", "1", "pptx"), + ), + ( + Some(attribution( + "presentations", + "skills/presentations/container_tools/render_slides.mjs", + )), + marker_command(presentation.script_path, "create", "1", "pptx"), + ), + ( + Some(attribution( + presentation.plugin_name, + presentation.script_path, + )), + command_with_arguments(presentation.script_path, Vec::new()), + ), + ( + Some(attribution( + presentation.plugin_name, + presentation.script_path, + )), + command_with_arguments( + presentation.script_path, + marker_arguments("read", "1", "pptx"), + ), + ), + ( + Some(attribution( + presentation.plugin_name, + presentation.script_path, + )), + command_with_arguments( + presentation.script_path, + marker_arguments("create", "0", "pptx"), + ), + ), + ( + Some(attribution( + presentation.plugin_name, + presentation.script_path, + )), + command_with_arguments( + presentation.script_path, + marker_arguments("create", "101", "pptx"), + ), + ), + ( + Some(attribution( + presentation.plugin_name, + presentation.script_path, + )), + command_with_arguments( + presentation.script_path, + marker_arguments("create", "1", "html"), + ), + ), + ]; + + for (attribution, command) in cases { + assert_eq!( + recognize_artifact_operation(attribution.as_ref(), &command), + None + ); + } +} + +fn attribution(plugin_name: &str, script_path: &str) -> PluginCommandAttribution { + PluginCommandAttribution { + plugin_id: PluginId::parse(&format!("{plugin_name}@openai-primary-runtime")) + .expect("plugin id"), + normalized_relative_path: script_path.to_string(), + } +} + +fn marker_command( + script_path: &str, + operation_kind: &str, + expected_output_count: &str, + output_format: &str, +) -> Vec { + command_with_arguments( + script_path, + marker_arguments(operation_kind, expected_output_count, output_format), + ) +} + +fn command_with_arguments(script_path: &str, arguments: Vec) -> Vec { + [vec!["node".to_string(), script_path.to_string()], arguments].concat() +} + +fn marker_arguments( + operation_kind: &str, + expected_output_count: &str, + output_format: &str, +) -> Vec { + [ + "--operation-kind", + operation_kind, + "--expected-output-count", + expected_output_count, + "--output-format", + output_format, + ] + .map(str::to_string) + .to_vec() +} diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 71d2ce70bb..9681bb27a9 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -1,4 +1,5 @@ mod app_mcp_routing; +mod artifact_operation; mod command_migration; mod discoverable; mod error_subtype; @@ -43,6 +44,8 @@ pub type LoadedPlugin = codex_plugin::LoadedPlugin; pub use app_mcp_routing::apps_route_available; +pub use artifact_operation::ArtifactOperation; +pub use artifact_operation::recognize_artifact_operation; pub use command_migration::CommandDescriptionMode; pub use command_migration::CommandMigrationProfile; pub use command_migration::RewriteProfile as CommandRewriteProfile; @@ -78,3 +81,4 @@ pub use remote::RecommendedPlugin; pub use remote::RecommendedPluginsMode; pub use script_attribution::PluginCommandAttribution; pub use script_attribution::TrustedPluginRoots; +pub use script_attribution::command_script_arguments; diff --git a/codex-rs/core-plugins/src/marketplace_policy.rs b/codex-rs/core-plugins/src/marketplace_policy.rs index a4c99a81cf..5c13a4059d 100644 --- a/codex-rs/core-plugins/src/marketplace_policy.rs +++ b/codex-rs/core-plugins/src/marketplace_policy.rs @@ -488,13 +488,34 @@ fn managed_local_marketplace_name(codex_home: &Path, root: &Path) -> Option<&'st } } - let runtime_root = dirs::cache_dir()? - .join("codex-runtimes/codex-primary-runtime/plugins") - .join(OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME); + let runtime_root = primary_runtime_marketplace_root()?; paths_match_after_normalization(root, &runtime_root) .then_some(OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME) } +pub(crate) fn primary_runtime_marketplace_root() -> Option { + Some( + primary_runtime_cache_dir()? + .join("codex-runtimes/codex-primary-runtime/plugins") + .join(OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME), + ) +} + +#[cfg(target_os = "windows")] +fn primary_runtime_cache_dir() -> Option { + primary_runtime_cache_dir_from_user_profile(std::env::var_os("USERPROFILE").map(PathBuf::from)) +} + +#[cfg(target_os = "windows")] +fn primary_runtime_cache_dir_from_user_profile(user_profile: Option) -> Option { + user_profile.map(|profile| profile.join(".cache")) +} + +#[cfg(not(target_os = "windows"))] +fn primary_runtime_cache_dir() -> Option { + dirs::cache_dir() +} + fn git_hostname(url: &str) -> Option { if let Ok(url) = Url::parse(url) { return url.host_str().map(str::to_ascii_lowercase); diff --git a/codex-rs/core-plugins/src/marketplace_policy_tests.rs b/codex-rs/core-plugins/src/marketplace_policy_tests.rs index 75c0a2f17c..16d1e0d9c5 100644 --- a/codex-rs/core-plugins/src/marketplace_policy_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_policy_tests.rs @@ -7,12 +7,23 @@ use codex_config::RequirementsLayerEntry; use codex_config::compose_requirements; use pretty_assertions::assert_eq; use std::fs; +#[cfg(target_os = "windows")] +use std::path::PathBuf; use tempfile::TempDir; fn config_layer_stack(requirements_toml: &str) -> ConfigLayerStack { config_layer_stack_with_user_config(requirements_toml, /*user_config*/ None) } +#[cfg(target_os = "windows")] +#[test] +fn primary_runtime_cache_uses_user_profile_on_windows() { + assert_eq!( + primary_runtime_cache_dir_from_user_profile(Some(PathBuf::from(r"C:\Users\user"))), + Some(PathBuf::from(r"C:\Users\user\.cache")) + ); +} + fn config_layer_stack_with_user_config( requirements_toml: &str, user_config: Option<(&str, AbsolutePathBuf)>, diff --git a/codex-rs/core-plugins/src/script_attribution.rs b/codex-rs/core-plugins/src/script_attribution.rs index e2d28d3aa5..1b8024272d 100644 --- a/codex-rs/core-plugins/src/script_attribution.rs +++ b/codex-rs/core-plugins/src/script_attribution.rs @@ -1,20 +1,26 @@ use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; use crate::OPENAI_CURATED_MARKETPLACE_NAME; +use crate::OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME; use crate::PluginLoadOutcome; use crate::loader::curated_plugin_cache_version; -use crate::marketplace::load_marketplace; +use crate::marketplace::MarketplacePluginSource; +use crate::marketplace::find_marketplace_plugin; +use crate::marketplace_policy::primary_runtime_marketplace_root; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::startup_sync::curated_plugins_api_marketplace_path; use crate::startup_sync::curated_plugins_repo_path; use crate::startup_sync::read_curated_plugins_sha; use crate::store::DEFAULT_PLUGIN_VERSION; use crate::store::PluginStore; +use crate::store::plugin_version_for_source; +use codex_exec_server::ExecutorFileSystem; use codex_plugin::PluginId; use codex_protocol::items::is_safe_plugin_relative_path; use codex_shell_command::bash::extract_bash_command; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_command::parse_command::is_pathish; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use std::collections::HashSet; use std::path::Component; use std::path::Path; @@ -43,8 +49,8 @@ impl PluginCommandAttribution { } /// Active first-party roots eligible for command attribution. -/// Trusted means OpenAI-shipped synced code or a server-installed global -/// remote plugin cache entry, not a local override. +/// Trusted means OpenAI-shipped synced or bundled runtime code, or a +/// server-installed global remote plugin cache entry, not a local override. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TrustedPluginRoots { roots: Vec, @@ -52,6 +58,7 @@ pub struct TrustedPluginRoots { impl TrustedPluginRoots { pub fn from_plugin_load_outcome(loaded_plugins: &PluginLoadOutcome, codex_home: &Path) -> Self { + let primary_runtime_marketplace_root = primary_runtime_marketplace_root(); let Ok(store) = PluginStore::try_new(codex_home.to_path_buf()) else { return Self::default(); }; @@ -62,44 +69,12 @@ impl TrustedPluginRoots { .filter(|plugin| plugin.is_active()) .filter_map(|plugin| { let plugin_id = PluginId::parse(&plugin.config_name).ok()?; - let expected_root = match plugin_id.marketplace_name.as_str() { - REMOTE_GLOBAL_MARKETPLACE_NAME => { - let active_version = store.active_plugin_version(&plugin_id)?; - if active_version == DEFAULT_PLUGIN_VERSION - || store.remote_plugin_id(&plugin_id).ok().flatten().is_none() - { - return None; - } - store.plugin_root(&plugin_id, &active_version) - } - OPENAI_CURATED_MARKETPLACE_NAME | OPENAI_API_CURATED_MARKETPLACE_NAME => { - let curated_sha = read_curated_plugins_sha(codex_home)?; - let expected_root = store - .plugin_root(&plugin_id, &curated_plugin_cache_version(&curated_sha)); - let marketplace_path = match plugin_id.marketplace_name.as_str() { - OPENAI_CURATED_MARKETPLACE_NAME => { - curated_plugins_repo_path(codex_home) - .join(".agents/plugins/marketplace.json") - } - OPENAI_API_CURATED_MARKETPLACE_NAME => { - curated_plugins_api_marketplace_path(codex_home) - } - _ => return None, - }; - let marketplace_path = AbsolutePathBuf::try_from(marketplace_path).ok()?; - let marketplace = load_marketplace(&marketplace_path).ok()?; - if marketplace.name != plugin_id.marketplace_name - || !marketplace - .plugins - .iter() - .any(|plugin| plugin.name == plugin_id.plugin_name) - { - return None; - } - expected_root - } - _ => return None, - }; + let expected_root = Self::expected_plugin_root( + &store, + codex_home, + &plugin_id, + primary_runtime_marketplace_root.as_deref(), + )?; if plugin.root != expected_root || !expected_root.as_path().is_dir() { return None; } @@ -113,6 +88,74 @@ impl TrustedPluginRoots { Self { roots } } + fn expected_plugin_root( + store: &PluginStore, + codex_home: &Path, + plugin_id: &PluginId, + primary_runtime_marketplace_root: Option<&Path>, + ) -> Option { + match plugin_id.marketplace_name.as_str() { + REMOTE_GLOBAL_MARKETPLACE_NAME => { + let active_version = store.active_plugin_version(plugin_id)?; + if active_version == DEFAULT_PLUGIN_VERSION + || store.remote_plugin_id(plugin_id).ok().flatten().is_none() + { + return None; + } + Some(store.plugin_root(plugin_id, &active_version)) + } + OPENAI_CURATED_MARKETPLACE_NAME | OPENAI_API_CURATED_MARKETPLACE_NAME => { + let curated_sha = read_curated_plugins_sha(codex_home)?; + let expected_root = + store.plugin_root(plugin_id, &curated_plugin_cache_version(&curated_sha)); + let marketplace_path = match plugin_id.marketplace_name.as_str() { + OPENAI_CURATED_MARKETPLACE_NAME => curated_plugins_repo_path(codex_home) + .join(".agents/plugins/marketplace.json"), + OPENAI_API_CURATED_MARKETPLACE_NAME => { + curated_plugins_api_marketplace_path(codex_home) + } + _ => return None, + }; + Self::marketplace_plugin(marketplace_path, plugin_id)?; + Some(expected_root) + } + OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME => { + let marketplace_root = primary_runtime_marketplace_root?; + let expected_source_root = AbsolutePathBuf::from_absolute_path_checked( + marketplace_root + .join("plugins") + .join(&plugin_id.plugin_name), + ) + .ok()?; + let marketplace_plugin = Self::marketplace_plugin( + marketplace_root.join(".agents/plugins/marketplace.json"), + plugin_id, + )?; + let MarketplacePluginSource::Local { path: source_root } = + marketplace_plugin.source + else { + return None; + }; + if source_root != expected_source_root { + return None; + } + let plugin_version = plugin_version_for_source(source_root.as_path()).ok()?; + Some(store.plugin_root(plugin_id, &plugin_version)) + } + _ => None, + } + } + + fn marketplace_plugin( + marketplace_path: std::path::PathBuf, + plugin_id: &PluginId, + ) -> Option { + let marketplace_path = + AbsolutePathBuf::from_absolute_path_checked(marketplace_path).ok()?; + let plugin = find_marketplace_plugin(&marketplace_path, &plugin_id.plugin_name).ok()?; + (plugin.plugin_id == *plugin_id).then_some(plugin) + } + /// Resolves one exact command to one trusted plugin script. /// /// Complex shell syntax, missing files, symlink escapes, and overlapping @@ -123,11 +166,11 @@ impl TrustedPluginRoots { cwd: &AbsolutePathBuf, ) -> Option { let command = single_plain_command(command)?; - let script = script_argument(command.as_slice())?; - let script = if Path::new(script).is_absolute() { - AbsolutePathBuf::from_absolute_path_checked(script).ok()? + let invocation = script_invocation(command.as_slice())?; + let script = if Path::new(invocation.script).is_absolute() { + AbsolutePathBuf::from_absolute_path_checked(invocation.script).ok()? } else { - cwd.join(script) + cwd.join(invocation.script) } .canonicalize() .ok()?; @@ -149,6 +192,115 @@ impl TrustedPluginRoots { let attribution = matches.next()?; matches.next().is_none().then_some(attribution) } + + /// Resolves a trusted script on the selected executor filesystem. + /// + /// Remote commands can use a path convention that the app-server host cannot + /// canonicalize. Match the target-native path to one trusted local plugin + /// script, then require the executor-side file to have the same contents. + pub async fn resolve_executor_attribution( + &self, + command: &[String], + cwd: &PathUri, + file_system: &dyn ExecutorFileSystem, + ) -> Option { + let command = single_plain_command(command)?; + let invocation = script_invocation(command.as_slice())?; + let script = cwd.join(invocation.script).ok()?; + let candidate = self.local_candidate_for_executor_script(&script)?; + let script = file_system + .canonicalize(&script, /*sandbox*/ None) + .await + .ok()?; + if !executor_plugin_root_matches(&script, &candidate.attribution) { + return None; + } + let metadata = file_system + .get_metadata(&script, /*sandbox*/ None) + .await + .ok()?; + if !metadata.is_file || metadata.size != candidate.contents.len() as u64 { + return None; + } + let contents = file_system + .read_file(&script, /*sandbox*/ None) + .await + .ok()?; + (contents == candidate.contents).then_some(candidate.attribution) + } + + fn local_candidate_for_executor_script( + &self, + script: &PathUri, + ) -> Option { + let suffixes = normalized_script_suffixes(script); + let mut matches = self.roots.iter().filter_map(|root| { + let (script, normalized_relative_path) = suffixes.iter().find_map(|suffix| { + let script = root.root.join(suffix).canonicalize().ok()?; + let relative_path = script.as_path().strip_prefix(root.root.as_path()).ok()?; + if !script.as_path().is_file() { + return None; + } + let normalized_relative_path = normalized_relative_script_path(relative_path)?; + Some((script, normalized_relative_path)) + })?; + Some(ExecutorAttributionCandidate { + attribution: PluginCommandAttribution { + plugin_id: root.plugin_id.clone(), + normalized_relative_path, + }, + contents: std::fs::read(script.as_path()).ok()?, + }) + }); + let candidate = matches.next()?; + matches.next().is_none().then_some(candidate) + } +} + +struct ExecutorAttributionCandidate { + attribution: PluginCommandAttribution, + contents: Vec, +} + +fn normalized_script_suffixes(script: &PathUri) -> Vec { + let path = script.inferred_native_path_string().replace('\\', "/"); + let components = path + .split('/') + .filter(|component| !component.is_empty()) + .collect::>(); + (0..components.len()) + .filter_map(|start| { + let suffix = components[start..].join("/"); + is_safe_plugin_relative_path(&suffix).then_some(suffix) + }) + .collect() +} + +fn executor_plugin_root_matches(script: &PathUri, attribution: &PluginCommandAttribution) -> bool { + let relative_depth = attribution.normalized_relative_path.split('/').count(); + let Some(root) = script.ancestors().nth(relative_depth) else { + return false; + }; + let path = root.inferred_native_path_string().replace('\\', "/"); + let components = path + .split('/') + .filter(|component| !component.is_empty()) + .collect::>(); + let [.., plugins, cache, marketplace, plugin, version] = components.as_slice() else { + return false; + }; + *plugins == "plugins" + && *cache == "cache" + && *marketplace == attribution.plugin_id.marketplace_name.as_str() + && *plugin == attribution.plugin_id.plugin_name.as_str() + && !version.is_empty() +} + +/// Returns the structurally parsed arguments following a single script command. +/// Callers must keep the values in-process and must not log or serialize them. +pub fn command_script_arguments(command: &[String]) -> Option> { + let command = single_plain_command(command)?; + Some(script_invocation(command.as_slice())?.arguments.to_vec()) } /// Converts a path already proven to be below a trusted plugin root into the @@ -186,14 +338,22 @@ fn single_plain_command(command: &[String]) -> Option> { Some(command.to_vec()) } -fn script_argument(command: &[String]) -> Option<&str> { +struct ScriptInvocation<'a> { + script: &'a str, + arguments: &'a [String], +} + +fn script_invocation(command: &[String]) -> Option> { let [program, args @ ..] = command else { return None; }; if let Some(interpreter) = interpreter_name(program) { - return interpreter_script_argument(&interpreter, args); + return interpreter_script_invocation(&interpreter, args); } - is_pathish(program).then_some(program) + is_pathish(program).then_some(ScriptInvocation { + script: program, + arguments: args, + }) } fn interpreter_name(program: &str) -> Option { @@ -218,25 +378,32 @@ fn interpreter_name(program: &str) -> Option { .then(|| basename.to_string()) } -fn interpreter_script_argument<'a>(interpreter: &str, args: &'a [String]) -> Option<&'a str> { +fn interpreter_script_invocation<'a>( + interpreter: &str, + args: &'a [String], +) -> Option> { if matches!(interpreter, "powershell" | "pwsh") { - let [file_flag, script, ..] = args else { + let [file_flag, script, arguments @ ..] = args else { return None; }; return (file_flag.eq_ignore_ascii_case("-file") && !script.starts_with('-')) - .then_some(script); + .then_some(ScriptInvocation { script, arguments }); } let mut args = args; loop { match args { - [separator, script, ..] if separator == "--" && !script.starts_with('-') => { - return Some(script); + [separator, script, arguments @ ..] + if separator == "--" && !script.starts_with('-') => + { + return Some(ScriptInvocation { script, arguments }); } [flag, remaining @ ..] if safe_interpreter_flag(interpreter, flag) => { args = remaining; } - [script, ..] if !script.starts_with('-') => return Some(script), + [script, arguments @ ..] if !script.starts_with('-') => { + return Some(ScriptInvocation { script, arguments }); + } _ => return None, } } diff --git a/codex-rs/core-plugins/src/script_attribution_tests.rs b/codex-rs/core-plugins/src/script_attribution_tests.rs index 641806d166..8616af8f50 100644 --- a/codex-rs/core-plugins/src/script_attribution_tests.rs +++ b/codex-rs/core-plugins/src/script_attribution_tests.rs @@ -10,6 +10,7 @@ use crate::test_support::write_curated_plugin_sha_with; use crate::test_support::write_openai_api_curated_marketplace; use crate::test_support::write_openai_curated_marketplace; use codex_plugin::PluginLoadOutcome; +use codex_utils_path_uri::PathUri; use codex_utils_plugins::SkillDiscoveryMode; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -93,12 +94,152 @@ fn script_fixture() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) { let script = script.canonicalize().expect("canonical script"); (temp, root, script) } + +#[test] +fn resolves_primary_runtime_scripts_from_the_installed_plugin_cache() { + let temp = TempDir::new().expect("temp dir"); + let marketplace_root = temp.path().join("openai-primary-runtime"); + let source_root = marketplace_root.join("plugins/presentations"); + fs::create_dir_all(source_root.join(".codex-plugin")).expect("create manifest directory"); + fs::write( + source_root.join(".codex-plugin/plugin.json"), + r#"{"name":"presentations","version":"0.1.29"}"#, + ) + .expect("write plugin manifest"); + let relative_script_path = + "skills/presentations/container_tools/mark_artifact_operation_started.mjs"; + let source_script = source_root.join(relative_script_path); + fs::create_dir_all(source_script.parent().expect("script parent")).expect("create scripts"); + fs::write(&source_script, "#!/usr/bin/env node\n").expect("write script"); + fs::create_dir_all(marketplace_root.join(".agents/plugins")) + .expect("create marketplace directory"); + fs::write( + marketplace_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-primary-runtime", + "plugins": [ + { + "name": "presentations", + "source": { + "source": "local", + "path": "./plugins/presentations" + } + } + ] +}"#, + ) + .expect("write marketplace manifest"); + let plugin_id = PluginId::parse("presentations@openai-primary-runtime").expect("plugin id"); + let plugin_root = PluginStore::new(temp.path().to_path_buf()) + .install(path(&source_root), plugin_id.clone()) + .expect("install plugin") + .installed_path; + let script = plugin_root.join(relative_script_path); + let store = PluginStore::new(temp.path().to_path_buf()); + assert_eq!( + TrustedPluginRoots::expected_plugin_root( + &store, + temp.path(), + &plugin_id, + Some(&marketplace_root), + ), + Some(plugin_root.clone()) + ); + let roots = TrustedPluginRoots { + roots: vec![TrustedPluginRoot { + plugin_id: plugin_id.clone(), + root: plugin_root.canonicalize().expect("canonical plugin root"), + }], + }; + + assert_eq!( + roots.resolve_attribution( + &command(&[ + "node", + script.to_string_lossy().as_ref(), + "--operation-kind", + "create", + ]), + &path(temp.path()), + ), + Some(PluginCommandAttribution { + plugin_id, + normalized_relative_path: relative_script_path.to_string(), + }) + ); +} fn roots_for(codex_home: &Path, plugins: Vec) -> TrustedPluginRoots { TrustedPluginRoots::from_plugin_load_outcome( &PluginLoadOutcome::from_plugins(plugins), codex_home, ) } + +#[tokio::test] +async fn resolves_relocated_script_through_executor_filesystem() { + let (temp, root, script) = script_fixture(); + let roots = roots_for( + temp.path(), + vec![loaded_plugin( + "sample@openai-curated", + root.as_path(), + ENABLED, + )], + ); + let executor = TempDir::new().expect("executor temp dir"); + let executor_root = executor + .path() + .join("plugins/cache/openai-curated/sample/remote-version"); + let executor_script = executor_root.join("scripts/run.py"); + fs::create_dir_all(executor_script.parent().expect("script parent")) + .expect("create executor scripts"); + fs::copy(script.as_path(), &executor_script).expect("copy script to executor"); + let cwd = PathUri::from_host_native_path(&executor_root).expect("executor root URI"); + let environment = + codex_exec_server::Environment::create_for_tests(/*exec_server_url*/ None) + .expect("local executor environment"); + + assert_eq!( + roots + .resolve_executor_attribution( + &command(&["python", "scripts/run.py"]), + &cwd, + environment.get_filesystem().as_ref(), + ) + .await, + Some(PluginCommandAttribution { + plugin_id: PluginId::parse("sample@openai-curated").expect("plugin id"), + normalized_relative_path: "scripts/run.py".to_string(), + }) + ); + + fs::write(&executor_script, "print('modified')\n").expect("modify executor script"); + assert_eq!( + roots + .resolve_executor_attribution( + &command(&["python", "scripts/run.py"]), + &cwd, + environment.get_filesystem().as_ref(), + ) + .await, + None + ); +} + +#[test] +fn recognizes_windows_executor_plugin_cache_root() { + let attribution = PluginCommandAttribution { + plugin_id: PluginId::parse("presentations@openai-primary-runtime").expect("plugin id"), + normalized_relative_path: + "skills/presentations/container_tools/mark_artifact_operation_started.mjs".to_string(), + }; + let script = PathUri::parse( + "file:///C:/Users/user/.codex/plugins/cache/openai-primary-runtime/presentations/0.1.29/skills/presentations/container_tools/mark_artifact_operation_started.mjs", + ) + .expect("Windows script URI"); + + assert!(executor_plugin_root_matches(&script, &attribution)); +} fn assert_untrusted(codex_home: &Path, config_name: &str, root: &Path) { assert!( roots_for(codex_home, vec![loaded_plugin(config_name, root, ENABLED)]) @@ -243,6 +384,17 @@ fn resolves_local_attribution_for_safe_interpreters_and_wrappers() { ] { assert_eq!(roots.resolve_attribution(&command, &root), expected); } + + let wrapped_command = command(&[ + "bash", + "-lc", + &format!("node {script} --operation-kind create"), + ]); + assert_eq!(roots.resolve_attribution(&wrapped_command, &root), expected); + assert_eq!( + command_script_arguments(&wrapped_command), + Some(command(&["--operation-kind", "create"])) + ); } #[test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b315603670..f79840d617 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -5,6 +5,7 @@ use crate::shell_snapshot::ShellSnapshotFile; use crate::tools::sandboxing::executor_windows_sandbox_level; use codex_core_plugins::PluginCommandAttribution; use codex_core_plugins::TrustedPluginRoots; +use codex_exec_server::ExecutorFileSystem; use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; use codex_protocol::SessionId; @@ -205,6 +206,18 @@ impl TurnContext { .resolve_attribution(command, cwd) } + pub(crate) async fn plugin_attribution_for_executor_command( + &self, + command: &[String], + cwd: &PathUri, + file_system: &dyn ExecutorFileSystem, + ) -> Option { + self.extension_data + .get::()? + .resolve_executor_attribution(command, cwd, file_system) + .await + } + pub(crate) fn approval_policy(&self) -> AskForApproval { self.config.permissions.approval_policy.value() } diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 40b37eaee3..f98d44b1bc 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -3,8 +3,14 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::sandboxing::ToolError; +use codex_analytics::ArtifactOperation; +use codex_analytics::ArtifactOperationLifecycle; +use codex_analytics::build_track_events_context; use codex_apply_patch::AppliedPatchDelta; use codex_core_plugins::PluginCommandAttribution; +use codex_core_plugins::recognize_artifact_operation; +use codex_otel::ARTIFACT_OPERATION_EXPECTED_OUTPUT_COUNT_METRIC; +use codex_otel::ARTIFACT_OPERATION_STARTED_METRIC; use codex_protocol::error::CodexErrorDetails; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; @@ -101,6 +107,52 @@ fn tracker_update_for_known_delta<'a>( } async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommandInput<'_>) { + if exec_input.source == ExecCommandSource::UnifiedExecStartup + && let Some(attribution) = exec_input.plugin_attribution + && let Some(operation) = recognize_artifact_operation(Some(attribution), exec_input.command) + { + let metric_tags = [ + ("skill", operation.plugin_name), + ("artifact_type", operation.artifact_type), + ("operation_kind", operation.operation_kind), + ("output_format", operation.output_format), + ("execution_backend", "unified_exec"), + ]; + ctx.turn.session_telemetry.counter( + ARTIFACT_OPERATION_STARTED_METRIC, + /*inc*/ 1, + &metric_tags, + ); + ctx.turn.session_telemetry.histogram( + ARTIFACT_OPERATION_EXPECTED_OUTPUT_COUNT_METRIC, + i64::from(operation.expected_output_count), + &metric_tags, + ); + ctx.session + .services + .analytics_events_client + .track_artifact_operation( + build_track_events_context( + ctx.turn.model_info.slug.clone(), + ctx.session.thread_id.to_string(), + ctx.turn.sub_id.clone(), + ctx.turn.originator.clone(), + ), + ArtifactOperation { + item_id: ctx.call_id.to_string(), + lifecycle: ArtifactOperationLifecycle::Started, + occurred_at_ms: codex_analytics::now_unix_millis(), + plugin_id: attribution.plugin_id.as_key(), + script_path: operation.script_path.to_string(), + skill: operation.plugin_name.to_string(), + artifact_type: operation.artifact_type.to_string(), + operation_kind: operation.operation_kind.to_string(), + expected_output_count: operation.expected_output_count, + output_format: operation.output_format.to_string(), + execution_backend: "unified_exec".to_string(), + }, + ); + } let (plugin_id, script_path) = plugin_attribution_fields(exec_input.plugin_attribution); ctx.session .emit_turn_item_started( diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5fb57600e0..d42fc29ebe 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -466,12 +466,25 @@ impl UnifiedExecProcessManager { &context.call_id, /*turn_diff_tracker*/ None, ); - let plugin_attribution = cwd.to_abs_path().ok().and_then(|cwd| { + let plugin_attribution = if request.turn_environment.environment.is_remote() { + let file_system = request.turn_environment.environment.get_filesystem(); context .step_context .turn - .plugin_attribution_for_command(&request.command, &cwd) - }); + .plugin_attribution_for_executor_command( + &request.command, + &cwd, + file_system.as_ref(), + ) + .await + } else { + cwd.to_abs_path().ok().and_then(|cwd| { + context + .step_context + .turn + .plugin_attribution_for_command(&request.command, &cwd) + }) + }; let emitter = ToolEmitter::unified_exec( &request.command, cwd.clone(), diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 43a8dfb5d1..32834fe480 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -1,6 +1,9 @@ pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec"; +pub const ARTIFACT_OPERATION_STARTED_METRIC: &str = "codex.artifact.operation.started"; +pub const ARTIFACT_OPERATION_EXPECTED_OUTPUT_COUNT_METRIC: &str = + "codex.artifact.operation.expected_output_count"; pub const PROCESS_START_METRIC: &str = "codex.process.start"; pub const API_CALL_COUNT_METRIC: &str = "codex.api_request"; pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms";