Codex Handbook
core/src/tools/handlers/extension_tools.rs 604 lines
use std::sync::Arc;use std::sync::Weak;use codex_protocol::items::TurnItem;use codex_tools::ConversationHistory;use codex_tools::ExtensionTurnItem;use codex_tools::ToolCall as ExtensionToolCall;use codex_tools::ToolEnvironment;use codex_tools::ToolName;use codex_tools::ToolSearchInfo;use codex_tools::ToolSpec;use codex_tools::TurnItemEmissionFuture;use codex_tools::TurnItemEmitter;use crate::sandboxing::SandboxPermissions;use crate::session::session::Session;use crate::session::turn_context::TurnContext;use crate::stream_events_utils::TurnItemContributorPolicy;use crate::stream_events_utils::finalize_turn_item;use crate::tools::context::ToolInvocation;use crate::tools::context::ToolPayload;use crate::tools::handlers::apply_granted_turn_permissions;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::ToolExecutor;pub(crate) struct ExtensionToolAdapter(Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);impl ExtensionToolAdapter {    pub(crate) fn new(executor: Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>) -> Self {        Self(executor)    }}impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {    fn tool_name(&self) -> ToolName {        self.0.tool_name()    }    fn spec(&self) -> ToolSpec {        self.0.spec()    }    fn exposure(&self) -> crate::tools::registry::ToolExposure {        self.0.exposure()    }    fn supports_parallel_tool_calls(&self) -> bool {        self.0.supports_parallel_tool_calls()    }    fn search_info(&self) -> Option<ToolSearchInfo> {        self.0.search_info()    }    fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(async move { self.0.handle(to_extension_call(&invocation).await).await })    }}impl CoreToolRuntime for ExtensionToolAdapter {    fn matches_kind(&self, payload: &ToolPayload) -> bool {        matches!(payload, ToolPayload::Function { .. })    }}struct CoreTurnItemEmitter {    session: Weak<Session>,    turn: Weak<TurnContext>,}fn extension_turn_item(item: ExtensionTurnItem) -> TurnItem {    match item {        ExtensionTurnItem::WebSearch(item) => TurnItem::WebSearch(item),        ExtensionTurnItem::ImageGeneration(mut item) => {            item.saved_path = None;            TurnItem::ImageGeneration(item)        }    }}impl TurnItemEmitter for CoreTurnItemEmitter {    fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> {        Box::pin(async move {            let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {                return;            };            session                .emit_turn_item_started(turn.as_ref(), &extension_turn_item(item))                .await;        })    }    fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> {        Box::pin(async move {            let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {                return;            };            let mut item = extension_turn_item(item);            finalize_turn_item(                session.as_ref(),                turn.as_ref(),                TurnItemContributorPolicy::Run(turn.extension_data.as_ref()),                &mut item,                turn.collaboration_mode.mode == codex_protocol::config_types::ModeKind::Plan,            )            .await;            session.emit_turn_item_completed(turn.as_ref(), item).await;        })    }}async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {    let conversation_history =        ConversationHistory::new(invocation.session.clone_history().await.into_raw_items());    let mut environments = Vec::with_capacity(invocation.turn.environments.turn_environments.len());    for environment in &invocation.turn.environments.turn_environments {        // TODO(anp): Migrate extension ToolEnvironment and granted-permission lookup to PathUri        // so extensions can receive foreign environment cwd values.        let Ok(native_cwd) = environment.cwd().to_abs_path() else {            continue;        };        let additional_permissions = apply_granted_turn_permissions(            invocation.session.as_ref(),            &environment.environment_id,            native_cwd.as_path(),            SandboxPermissions::UseDefault,            /*additional_permissions*/ None,        )        .await        .additional_permissions;        let file_system_sandbox_context = invocation            .turn            .file_system_sandbox_context(additional_permissions, environment.cwd());        environments.push(ToolEnvironment {            environment_id: environment.environment_id.clone(),            cwd: native_cwd,            file_system: environment.environment.get_filesystem(),            file_system_sandbox_context,        });    }    ExtensionToolCall {        turn_id: invocation.turn.sub_id.clone(),        call_id: invocation.call_id.clone(),        tool_name: invocation.tool_name.clone(),        model: invocation.turn.model_info.slug.clone(),        truncation_policy: invocation.turn.truncation_policy,        conversation_history,        turn_item_emitter: Arc::new(CoreTurnItemEmitter {            session: Arc::downgrade(&invocation.session),            turn: Arc::downgrade(&invocation.turn),        }),        environments,        payload: invocation.payload.clone(),    }}#[cfg(test)]mod tests {    use std::sync::Arc;    use codex_extension_api::ExtensionData;    use codex_extension_api::TurnItemContributor;    use codex_protocol::items::TurnItem;    use codex_protocol::items::WebSearchItem;    use codex_protocol::models::ContentItem;    use codex_protocol::models::ResponseItem;    use codex_protocol::models::WebSearchAction;    use codex_protocol::protocol::EventMsg;    use codex_tools::ExtensionTurnItem;    use codex_utils_absolute_path::test_support::PathExt;    use codex_utils_absolute_path::test_support::test_path_buf;    use pretty_assertions::assert_eq;    use serde_json::json;    use tokio::sync::Mutex;    use super::CoreTurnItemEmitter;    use super::ExtensionToolAdapter;    use crate::tools::context::ToolCallSource;    use crate::tools::context::ToolInvocation;    use crate::tools::context::ToolPayload;    use crate::tools::hook_names::HookToolName;    use crate::tools::registry::CoreToolRuntime;    use crate::tools::registry::PostToolUsePayload;    use crate::tools::registry::PreToolUsePayload;    use crate::turn_diff_tracker::TurnDiffTracker;    struct StubExtensionExecutor;    impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for StubExtensionExecutor {        fn tool_name(&self) -> codex_tools::ToolName {            codex_tools::ToolName::plain("extension_echo")        }        fn spec(&self) -> codex_tools::ToolSpec {            codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {                name: "extension_echo".to_string(),                description: "Echoes arguments.".to_string(),                strict: true,                parameters: codex_tools::parse_tool_input_schema(&json!({                    "type": "object",                    "properties": {                        "message": { "type": "string" },                    },                    "required": ["message"],                    "additionalProperties": false,                }))                .expect("extension schema should parse"),                output_schema: None,                defer_loading: None,            })        }        fn handle(&self, _call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {            Box::pin(async {                Ok(                    Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))                        as Box<dyn codex_tools::ToolOutput>,                )            })        }    }    struct CapturingExtensionExecutor {        captured_call: Arc<Mutex<Option<codex_tools::ToolCall>>>,    }    impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for CapturingExtensionExecutor {        fn tool_name(&self) -> codex_tools::ToolName {            codex_tools::ToolName::plain("extension_echo")        }        fn spec(&self) -> codex_tools::ToolSpec {            codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {                name: "extension_echo".to_string(),                description: "Captures arguments.".to_string(),                strict: false,                parameters: codex_tools::JsonSchema::default(),                output_schema: None,                defer_loading: None,            })        }        fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {            Box::pin(self.handle_call(call))        }    }    impl CapturingExtensionExecutor {        async fn handle_call(            &self,            call: codex_tools::ToolCall,        ) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {            let item = ExtensionTurnItem::WebSearch(WebSearchItem {                id: call.call_id.clone(),                query: "rust trait object".to_string(),                action: WebSearchAction::Search {                    query: Some("rust trait object".to_string()),                    queries: None,                },            });            call.turn_item_emitter.emit_started(item.clone()).await;            call.turn_item_emitter.emit_completed(item).await;            *self.captured_call.lock().await = Some(call);            Ok(                Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))                    as Box<dyn codex_tools::ToolOutput>,            )        }    }    #[tokio::test]    async fn exposes_generic_hook_payloads() {        let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor));        let (session, turn) = crate::session::tests::make_session_and_context().await;        let invocation = ToolInvocation {            session: session.into(),            turn: turn.into(),            cancellation_token: tokio_util::sync::CancellationToken::new(),            tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),            call_id: "call-extension".to_string(),            tool_name: codex_tools::ToolName::plain("extension_echo"),            source: ToolCallSource::Direct,            payload: ToolPayload::Function {                arguments: json!({ "message": "hello" }).to_string(),            },        };        let output = codex_tools::JsonToolOutput::new(json!({ "ok": true }));        assert_eq!(            CoreToolRuntime::pre_tool_use_payload(&handler, &invocation),            Some(PreToolUsePayload {                tool_name: HookToolName::new("extension_echo"),                tool_input: json!({ "message": "hello" }),            })        );        assert_eq!(            CoreToolRuntime::post_tool_use_payload(&handler, &invocation, &output),            Some(PostToolUsePayload {                tool_name: HookToolName::new("extension_echo"),                tool_use_id: "call-extension".to_string(),                tool_input: json!({ "message": "hello" }),                tool_response: json!({ "ok": true }),            })        );    }    #[tokio::test]    async fn passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call() {        let captured_call = Arc::new(Mutex::new(None));        let handler = ExtensionToolAdapter::new(Arc::new(CapturingExtensionExecutor {            captured_call: Arc::clone(&captured_call),        }));        let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;        let weak_session = Arc::downgrade(&session);        let weak_turn = Arc::downgrade(&turn);        let turn_id = turn.sub_id.clone();        let model = turn.model_info.slug.clone();        let truncation_policy = turn.truncation_policy;        let expected_sandbox_cwds = turn            .environments            .turn_environments            .iter()            .map(|environment| Some(environment.cwd().clone()))            .collect::<Vec<_>>();        let history_item = ResponseItem::Message {            id: None,            role: "user".to_string(),            content: vec![ContentItem::InputText {                text: "extension history".to_string(),            }],            phase: None,            metadata: None,        };        session            .record_conversation_items(&turn, std::slice::from_ref(&history_item))            .await;        let raw_history_event = rx.recv().await.expect("history raw response item event");        let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else {            panic!("expected raw response item event");        };        assert_eq!(raw_history_item.item, history_item);        let invocation = ToolInvocation {            session,            turn,            cancellation_token: tokio_util::sync::CancellationToken::new(),            tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),            call_id: "call-extension".to_string(),            tool_name: codex_tools::ToolName::plain("extension_echo"),            source: ToolCallSource::Direct,            payload: ToolPayload::Function {                arguments: json!({ "message": "hello" }).to_string(),            },        };        crate::tools::registry::ToolExecutor::handle(&handler, invocation)            .await            .expect("extension call should succeed");        let captured_call = captured_call.lock().await.clone().expect("captured call");        assert!(weak_session.upgrade().is_none());        assert!(weak_turn.upgrade().is_none());        assert_eq!(captured_call.turn_id, turn_id);        assert_eq!(captured_call.call_id, "call-extension");        assert_eq!(            captured_call.tool_name,            codex_tools::ToolName::plain("extension_echo")        );        assert_eq!(captured_call.model, model);        assert_eq!(captured_call.truncation_policy, truncation_policy);        assert_eq!(            captured_call                .environments                .iter()                .map(|environment| environment.file_system_sandbox_context.cwd.clone())                .collect::<Vec<_>>(),            expected_sandbox_cwds        );        assert_eq!(            captured_call.conversation_history.items(),            std::slice::from_ref(&history_item)        );        match captured_call.payload {            ToolPayload::Function { arguments } => {                assert_eq!(arguments, json!({ "message": "hello" }).to_string());            }            payload => panic!("expected function payload, got {payload:?}"),        }        let started = rx.recv().await.expect("item started event");        let EventMsg::ItemStarted(started) = started.msg else {            panic!("expected item started event");        };        let TurnItem::WebSearch(started_item) = started.item else {            panic!("expected web search item");        };        let begin = rx.recv().await.expect("legacy web search begin event");        let EventMsg::WebSearchBegin(begin) = begin.msg else {            panic!("expected legacy web search begin event");        };        let completed = rx.recv().await.expect("item completed event");        let EventMsg::ItemCompleted(completed) = completed.msg else {            panic!("expected item completed event");        };        let TurnItem::WebSearch(completed_item) = completed.item else {            panic!("expected web search item");        };        let end = rx.recv().await.expect("legacy web search end event");        let EventMsg::WebSearchEnd(end) = end.msg else {            panic!("expected legacy web search end event");        };        let expected = WebSearchItem {            id: "call-extension".to_string(),            query: "rust trait object".to_string(),            action: WebSearchAction::Search {                query: Some("rust trait object".to_string()),                queries: None,            },        };        assert_eq!(started_item, expected);        assert_eq!(completed_item, expected);        assert_eq!(begin.call_id, expected.id);        assert_eq!(end.call_id, expected.id);        assert_eq!(end.query, expected.query);        assert_eq!(end.action, expected.action);    }    struct ImageGenerationExtensionExecutor;    #[derive(Debug)]    struct ExtensionTurnItemContributorRan;    struct RecordExtensionTurnItemContributor;    impl TurnItemContributor for RecordExtensionTurnItemContributor {        fn contribute<'a>(            &'a self,            _thread_store: &'a ExtensionData,            turn_store: &'a ExtensionData,            _item: &'a mut TurnItem,        ) -> codex_extension_api::ExtensionFuture<'a, Result<(), String>> {            Box::pin(async move {                turn_store.insert(ExtensionTurnItemContributorRan);                Ok(())            })        }    }    #[tokio::test]    async fn extension_completion_runs_turn_item_contributors() {        let (mut session, turn) = crate::session::tests::make_session_and_context().await;        let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();        builder.turn_item_contributor(Arc::new(RecordExtensionTurnItemContributor));        session.services.extensions = Arc::new(builder.build());        let session = Arc::new(session);        let turn = Arc::new(turn);        let emitter = CoreTurnItemEmitter {            session: Arc::downgrade(&session),            turn: Arc::downgrade(&turn),        };        codex_tools::TurnItemEmitter::emit_completed(            &emitter,            ExtensionTurnItem::WebSearch(WebSearchItem {                id: "search-1".to_string(),                query: "contributors".to_string(),                action: WebSearchAction::Other,            }),        )        .await;        assert!(            turn.extension_data                .get::<ExtensionTurnItemContributorRan>()                .is_some()        );    }    impl codex_extension_api::ToolExecutor<codex_tools::ToolCall> for ImageGenerationExtensionExecutor {        fn tool_name(&self) -> codex_tools::ToolName {            codex_tools::ToolName::namespaced("image_gen", "imagegen")        }        fn spec(&self) -> codex_tools::ToolSpec {            codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {                name: "imagegen".to_string(),                description: "Generates an image.".to_string(),                strict: false,                parameters: codex_tools::JsonSchema::default(),                output_schema: None,                defer_loading: None,            })        }        fn handle(&self, call: codex_tools::ToolCall) -> codex_tools::ToolExecutorFuture<'_> {            Box::pin(self.handle_call(call))        }    }    impl ImageGenerationExtensionExecutor {        async fn handle_call(            &self,            call: codex_tools::ToolCall,        ) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {            call.turn_item_emitter                .emit_started(ExtensionTurnItem::ImageGeneration(                    codex_protocol::items::ImageGenerationItem {                        id: call.call_id.clone(),                        status: "in_progress".to_string(),                        revised_prompt: None,                        result: String::new(),                        saved_path: None,                    },                ))                .await;            call.turn_item_emitter                .emit_completed(ExtensionTurnItem::ImageGeneration(                    codex_protocol::items::ImageGenerationItem {                        id: call.call_id,                        status: "completed".to_string(),                        revised_prompt: Some("A tiny blue square".to_string()),                        result: "cG5n".to_string(),                        saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()),                    },                ))                .await;            Ok(                Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))                    as Box<dyn codex_tools::ToolOutput>,            )        }    }    #[tokio::test]    async fn image_generation_publication_is_finalized_by_core() {        let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor));        let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;        let expected_path = crate::stream_events_utils::image_generation_artifact_path(            &turn.config.codex_home,            &session.thread_id.to_string(),            "call-image",        );        let invocation = ToolInvocation {            session,            turn,            cancellation_token: tokio_util::sync::CancellationToken::new(),            tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),            call_id: "call-image".to_string(),            tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"),            source: ToolCallSource::Direct,            payload: ToolPayload::Function {                arguments: "{}".to_string(),            },        };        crate::tools::registry::ToolExecutor::handle(&handler, invocation)            .await            .expect("extension call should succeed");        let started = rx.recv().await.expect("item started event");        let EventMsg::ItemStarted(started) = started.msg else {            panic!("expected item started event");        };        let TurnItem::ImageGeneration(started_item) = started.item else {            panic!("expected image generation item");        };        let begin = rx.recv().await.expect("legacy image start event");        assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_)));        let completed = rx.recv().await.expect("item completed event");        let EventMsg::ItemCompleted(completed) = completed.msg else {            panic!("expected item completed event");        };        let TurnItem::ImageGeneration(completed_item) = completed.item else {            panic!("expected image generation item");        };        let end = rx.recv().await.expect("legacy image end event");        assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_)));        assert_eq!(            started_item,            codex_protocol::items::ImageGenerationItem {                id: "call-image".to_string(),                status: "in_progress".to_string(),                revised_prompt: None,                result: String::new(),                saved_path: None,            }        );        assert_eq!(            completed_item,            codex_protocol::items::ImageGenerationItem {                id: "call-image".to_string(),                status: "completed".to_string(),                revised_prompt: Some("A tiny blue square".to_string()),                result: "cG5n".to_string(),                saved_path: Some(expected_path.clone()),            }        );        assert_eq!(            std::fs::read(&expected_path).expect("generated artifact should be saved"),            b"png"        );    }}