diff --git a/codex-rs/core/src/tools/handlers/io.rs b/codex-rs/core/src/tools/handlers/io.rs new file mode 100644 index 000000000000..0f3bfb8699de --- /dev/null +++ b/codex-rs/core/src/tools/handlers/io.rs @@ -0,0 +1,537 @@ +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::FileMetadata; +use codex_exec_server::FileSystemSandboxContext; +use codex_exec_server::ReadDirectoryEntry; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; + +use crate::function_tool::FunctionCallError; +use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::io_spec::IO_NAMESPACE; +use crate::tools::handlers::parse_arguments; +use crate::tools::handlers::resolve_tool_environment; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IoToolKind { + ReadFile, + WriteFile, + EditFile, + CreateDirectory, + ListDirectory, + GetFileInfo, + ListAllowedDirectories, +} + +impl IoToolKind { + pub(crate) const ALL: [Self; 7] = [ + Self::ReadFile, + Self::WriteFile, + Self::EditFile, + Self::CreateDirectory, + Self::ListDirectory, + Self::GetFileInfo, + Self::ListAllowedDirectories, + ]; + + fn name(self) -> &'static str { + match self { + Self::ReadFile => "read_file", + Self::WriteFile => "write_file", + Self::EditFile => "edit_file", + Self::CreateDirectory => "create_directory", + Self::ListDirectory => "list_directory", + Self::GetFileInfo => "get_file_info", + Self::ListAllowedDirectories => "list_allowed_directories", + } + } + + fn is_mutating(self) -> bool { + matches!( + self, + Self::WriteFile | Self::EditFile | Self::CreateDirectory + ) + } +} + +pub struct IoToolHandler { + kind: IoToolKind, +} + +impl IoToolHandler { + pub(crate) fn new(kind: IoToolKind) -> Self { + Self { kind } + } +} + +impl ToolHandler for IoToolHandler { + type Output = IoToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::namespaced(IO_NAMESPACE, self.kind.name()) + } + + fn spec(&self) -> Option { + None + } + + fn supports_parallel_tool_calls(&self) -> bool { + !self.kind.is_mutating() + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn is_mutating( + &self, + _invocation: &ToolInvocation, + ) -> impl std::future::Future + Send { + let is_mutating = self.kind.is_mutating(); + async move { is_mutating } + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolPayload::Function { arguments } = invocation.payload else { + return Err(FunctionCallError::RespondToModel(format!( + "io.{} handler received unsupported payload", + self.kind.name() + ))); + }; + + match self.kind { + IoToolKind::ReadFile => read_file(invocation.turn.as_ref(), &arguments).await, + IoToolKind::WriteFile => write_file(invocation.turn.as_ref(), &arguments).await, + IoToolKind::EditFile => edit_file(invocation.turn.as_ref(), &arguments).await, + IoToolKind::CreateDirectory => { + create_directory(invocation.turn.as_ref(), &arguments).await + } + IoToolKind::ListDirectory => list_directory(invocation.turn.as_ref(), &arguments).await, + IoToolKind::GetFileInfo => get_file_info(invocation.turn.as_ref(), &arguments).await, + IoToolKind::ListAllowedDirectories => { + list_allowed_directories(invocation.turn.as_ref(), &arguments).await + } + } + } +} + +#[derive(Deserialize)] +struct PathArgs { + path: String, + #[serde(default)] + environment_id: Option, +} + +#[derive(Deserialize)] +struct WriteFileArgs { + path: String, + content: String, + #[serde(default)] + environment_id: Option, +} + +#[derive(Deserialize)] +struct CreateDirectoryArgs { + path: String, + #[serde(default)] + recursive: Option, + #[serde(default)] + environment_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditFileArgs { + path: String, + edits: Vec, + #[serde(default)] + dry_run: Option, + #[serde(default)] + environment_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TextEdit { + old_text: String, + new_text: String, +} + +#[derive(Deserialize)] +struct ListAllowedDirectoriesArgs { + #[serde(default)] + environment_id: Option, +} + +struct ResolvedIoPath { + environment_id: String, + path: AbsolutePathBuf, + fs: Arc, + sandbox: FileSystemSandboxContext, +} + +pub struct IoToolOutput { + display: String, + result: Value, +} + +impl IoToolOutput { + fn text(display: String, result: Value) -> Self { + Self { display, result } + } +} + +impl ToolOutput for IoToolOutput { + fn log_preview(&self) -> String { + self.display.clone() + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + body: FunctionCallOutputBody::Text(self.display.clone()), + success: Some(true), + }, + } + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> Value { + self.result.clone() + } +} + +async fn read_file(turn: &TurnContext, arguments: &str) -> Result { + let args: PathArgs = parse_arguments(arguments)?; + let resolved = resolve_io_path(turn, &args.path, args.environment_id.as_deref())?; + let content = resolved + .fs + .read_file_text(&resolved.path, Some(&resolved.sandbox)) + .await + .map_err(|error| io_error("read_file", &resolved.path, error))?; + + Ok(IoToolOutput::text(content.clone(), Value::String(content))) +} + +async fn write_file( + turn: &TurnContext, + arguments: &str, +) -> Result { + let args: WriteFileArgs = parse_arguments(arguments)?; + let resolved = resolve_io_path(turn, &args.path, args.environment_id.as_deref())?; + let bytes = args.content.into_bytes(); + let bytes_written = bytes.len(); + resolved + .fs + .write_file(&resolved.path, bytes, Some(&resolved.sandbox)) + .await + .map_err(|error| io_error("write_file", &resolved.path, error))?; + + let environment_id = resolved.environment_id.clone(); + let result = json!({ + "path": path_string(&resolved.path), + "environment_id": environment_id, + "bytes_written": bytes_written, + }); + Ok(IoToolOutput::text( + format!( + "Wrote {bytes_written} bytes to `{}` in environment `{}`.", + resolved.path.display(), + resolved.environment_id + ), + result, + )) +} + +async fn edit_file(turn: &TurnContext, arguments: &str) -> Result { + let args: EditFileArgs = parse_arguments(arguments)?; + if args.edits.is_empty() { + return Err(FunctionCallError::RespondToModel( + "io.edit_file requires at least one edit".to_string(), + )); + } + + let resolved = resolve_io_path(turn, &args.path, args.environment_id.as_deref())?; + let original = resolved + .fs + .read_file_text(&resolved.path, Some(&resolved.sandbox)) + .await + .map_err(|error| io_error("edit_file", &resolved.path, error))?; + let mut updated = original.clone(); + for (index, edit) in args.edits.iter().enumerate() { + if edit.old_text.is_empty() { + return Err(FunctionCallError::RespondToModel(format!( + "io.edit_file edit {} has empty oldText", + index + 1 + ))); + } + let matches = updated.matches(&edit.old_text).count(); + match matches { + 0 => { + return Err(FunctionCallError::RespondToModel(format!( + "io.edit_file edit {} did not match `{}`", + index + 1, + resolved.path.display() + ))); + } + 1 => { + updated = updated.replacen(&edit.old_text, &edit.new_text, 1); + } + _ => { + return Err(FunctionCallError::RespondToModel(format!( + "io.edit_file edit {} matched `{}` {matches} times; oldText must match exactly once", + index + 1, + resolved.path.display() + ))); + } + } + } + + let dry_run = args.dry_run.unwrap_or(false); + if !dry_run { + resolved + .fs + .write_file( + &resolved.path, + updated.clone().into_bytes(), + Some(&resolved.sandbox), + ) + .await + .map_err(|error| io_error("edit_file", &resolved.path, error))?; + } + + let environment_id = resolved.environment_id.clone(); + let result = json!({ + "path": path_string(&resolved.path), + "environment_id": environment_id, + "edits_applied": args.edits.len(), + "dry_run": dry_run, + }); + let verb = if dry_run { "Validated" } else { "Applied" }; + Ok(IoToolOutput::text( + format!( + "{verb} {} edit(s) for `{}` in environment `{}`.", + args.edits.len(), + resolved.path.display(), + resolved.environment_id + ), + result, + )) +} + +async fn create_directory( + turn: &TurnContext, + arguments: &str, +) -> Result { + let args: CreateDirectoryArgs = parse_arguments(arguments)?; + let resolved = resolve_io_path(turn, &args.path, args.environment_id.as_deref())?; + let recursive = args.recursive.unwrap_or(true); + resolved + .fs + .create_directory( + &resolved.path, + CreateDirectoryOptions { recursive }, + Some(&resolved.sandbox), + ) + .await + .map_err(|error| io_error("create_directory", &resolved.path, error))?; + + let environment_id = resolved.environment_id.clone(); + let result = json!({ + "path": path_string(&resolved.path), + "environment_id": environment_id, + "recursive": recursive, + }); + Ok(IoToolOutput::text( + format!( + "Created directory `{}` in environment `{}`.", + resolved.path.display(), + resolved.environment_id + ), + result, + )) +} + +async fn list_directory( + turn: &TurnContext, + arguments: &str, +) -> Result { + let args: PathArgs = parse_arguments(arguments)?; + let resolved = resolve_io_path(turn, &args.path, args.environment_id.as_deref())?; + let entries = resolved + .fs + .read_directory(&resolved.path, Some(&resolved.sandbox)) + .await + .map_err(|error| io_error("list_directory", &resolved.path, error))?; + let result = Value::Array(entries.iter().map(entry_to_json).collect()); + Ok(IoToolOutput::text(pretty_json(&result), result)) +} + +async fn get_file_info( + turn: &TurnContext, + arguments: &str, +) -> Result { + let args: PathArgs = parse_arguments(arguments)?; + let resolved = resolve_io_path(turn, &args.path, args.environment_id.as_deref())?; + let metadata = resolved + .fs + .get_metadata(&resolved.path, Some(&resolved.sandbox)) + .await + .map_err(|error| io_error("get_file_info", &resolved.path, error))?; + let result = metadata_to_json(&resolved, &metadata); + Ok(IoToolOutput::text(pretty_json(&result), result)) +} + +async fn list_allowed_directories( + turn: &TurnContext, + arguments: &str, +) -> Result { + let args: ListAllowedDirectoriesArgs = parse_arguments(arguments)?; + let Some(turn_environment) = resolve_tool_environment(turn, args.environment_id.as_deref())? + else { + return Err(FunctionCallError::RespondToModel( + "io.list_allowed_directories is unavailable in this session".to_string(), + )); + }; + let policy = turn.file_system_sandbox_policy(); + let cwd = turn_environment.cwd.clone(); + let readable_roots = policy + .get_readable_roots_with_cwd(cwd.as_path()) + .into_iter() + .map(|path| path_string(&path)) + .collect::>(); + let writable_roots = policy + .get_writable_roots_with_cwd(cwd.as_path()) + .into_iter() + .map(|root| path_string(&root.root)) + .collect::>(); + let result = json!({ + "environment_id": turn_environment.environment_id.clone(), + "cwd": path_string(&cwd), + "readable_roots": readable_roots, + "writable_roots": writable_roots, + }); + Ok(IoToolOutput::text(pretty_json(&result), result)) +} + +fn resolve_io_path( + turn: &TurnContext, + path: &str, + environment_id: Option<&str>, +) -> Result { + let (environment_id, path) = parse_path_ref(path, environment_id)?; + let Some(turn_environment) = resolve_tool_environment(turn, environment_id)? else { + return Err(FunctionCallError::RespondToModel( + "io filesystem tools are unavailable in this session".to_string(), + )); + }; + let abs_path = resolve_environment_path(turn_environment, path)?; + let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); + sandbox.cwd = Some(turn_environment.cwd.clone()); + Ok(ResolvedIoPath { + environment_id: turn_environment.environment_id.clone(), + path: abs_path, + fs: turn_environment.environment.get_filesystem(), + sandbox, + }) +} + +fn parse_path_ref<'a>( + path: &'a str, + environment_id: Option<&'a str>, +) -> Result<(Option<&'a str>, &'a str), FunctionCallError> { + let Some(rest) = path.strip_prefix("env://") else { + return Ok((environment_id, path)); + }; + let (authority, path_after_authority) = match rest.split_once('/') { + Some((authority, path)) => (authority, path), + None => (rest, ""), + }; + if authority.is_empty() { + return Err(FunctionCallError::RespondToModel( + "env filesystem refs must include an environment, for example `env://current/path`" + .to_string(), + )); + } + if Path::new(path_after_authority).is_absolute() { + return Err(FunctionCallError::RespondToModel(format!( + "env filesystem ref `{path}` must not contain an absolute path after the environment id" + ))); + } + if authority == "current" { + return Ok((environment_id, path_after_authority)); + } + if let Some(argument_environment_id) = environment_id + && argument_environment_id != authority + { + return Err(FunctionCallError::RespondToModel(format!( + "path `{path}` targets environment `{authority}` but environment_id is `{argument_environment_id}`" + ))); + } + Ok((Some(authority), path_after_authority)) +} + +fn resolve_environment_path( + turn_environment: &TurnEnvironment, + path: &str, +) -> Result { + if path.is_empty() { + return Ok(turn_environment.cwd.clone()); + } + Ok(turn_environment.cwd.join(path)) +} + +fn entry_to_json(entry: &ReadDirectoryEntry) -> Value { + json!({ + "name": entry.file_name, + "is_directory": entry.is_directory, + "is_file": entry.is_file, + }) +} + +fn metadata_to_json(resolved: &ResolvedIoPath, metadata: &FileMetadata) -> Value { + json!({ + "path": path_string(&resolved.path), + "environment_id": resolved.environment_id.clone(), + "is_directory": metadata.is_directory, + "is_file": metadata.is_file, + "is_symlink": metadata.is_symlink, + "created_at_ms": metadata.created_at_ms, + "modified_at_ms": metadata.modified_at_ms, + }) +} + +fn pretty_json(value: &Value) -> String { + serde_json::to_string_pretty(value) + .unwrap_or_else(|error| format!("failed to serialize io result: {error}")) +} + +fn io_error(operation: &str, path: &AbsolutePathBuf, error: std::io::Error) -> FunctionCallError { + FunctionCallError::RespondToModel(format!( + "io.{operation} failed for `{}`: {error}", + path.display() + )) +} + +fn path_string(path: &AbsolutePathBuf) -> String { + path.to_string_lossy().into_owned() +} diff --git a/codex-rs/core/src/tools/handlers/io_spec.rs b/codex-rs/core/src/tools/handlers/io_spec.rs new file mode 100644 index 000000000000..e81138426d18 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/io_spec.rs @@ -0,0 +1,191 @@ +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use std::collections::BTreeMap; + +pub(crate) const IO_NAMESPACE: &str = "io"; +pub(crate) const IO_EXPERIMENTAL_TOOL: &str = "io"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct IoToolOptions { + pub include_environment_id: bool, +} + +pub(crate) fn create_io_tool_namespace(options: IoToolOptions) -> ToolSpec { + ToolSpec::Namespace(ResponsesApiNamespace { + name: IO_NAMESPACE.to_string(), + description: "Native Code Mode file I/O for the selected Codex environment. Paths are resolved inside the target environment filesystem and may be relative, absolute, or explicit `env://current/...` refs.".to_string(), + tools: vec![ + tool( + "read_file", + "Read the complete contents of a UTF-8 text file from the selected environment filesystem.", + path_parameters(options, &["path"]), + ), + tool( + "write_file", + "Create or overwrite a UTF-8 text file in the selected environment filesystem.", + write_file_parameters(options), + ), + tool( + "edit_file", + "Apply exact text replacements to a UTF-8 text file in the selected environment filesystem.", + edit_file_parameters(options), + ), + tool( + "create_directory", + "Create a directory in the selected environment filesystem.", + create_directory_parameters(options), + ), + tool( + "list_directory", + "List entries in a directory from the selected environment filesystem.", + path_parameters(options, &["path"]), + ), + tool( + "get_file_info", + "Get basic metadata for a file or directory in the selected environment filesystem.", + path_parameters(options, &["path"]), + ), + tool( + "list_allowed_directories", + "List readable and writable filesystem roots for the selected environment.", + environment_parameters(options), + ), + ], + }) +} + +fn tool(name: &str, description: &str, parameters: JsonSchema) -> ResponsesApiNamespaceTool { + ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: name.to_string(), + description: description.to_string(), + strict: false, + defer_loading: None, + parameters, + output_schema: None, + }) +} + +fn environment_parameters(options: IoToolOptions) -> JsonSchema { + let mut properties = BTreeMap::new(); + maybe_insert_environment_id(&mut properties, options); + JsonSchema::object(properties, /*required*/ None, Some(false.into())) +} + +fn path_parameters(options: IoToolOptions, required: &[&str]) -> JsonSchema { + let mut properties = BTreeMap::from([( + "path".to_string(), + JsonSchema::string(Some(path_description())), + )]); + maybe_insert_environment_id(&mut properties, options); + JsonSchema::object( + properties, + Some(required.iter().map(|name| (*name).to_string()).collect()), + Some(false.into()), + ) +} + +fn write_file_parameters(options: IoToolOptions) -> JsonSchema { + let mut properties = BTreeMap::from([ + ( + "path".to_string(), + JsonSchema::string(Some(path_description())), + ), + ( + "content".to_string(), + JsonSchema::string(Some("UTF-8 text content to write to the file.".to_string())), + ), + ]); + maybe_insert_environment_id(&mut properties, options); + JsonSchema::object( + properties, + Some(vec!["path".to_string(), "content".to_string()]), + Some(false.into()), + ) +} + +fn create_directory_parameters(options: IoToolOptions) -> JsonSchema { + let mut properties = BTreeMap::from([ + ( + "path".to_string(), + JsonSchema::string(Some(path_description())), + ), + ( + "recursive".to_string(), + JsonSchema::boolean(Some( + "Whether to create parent directories. Defaults to true.".to_string(), + )), + ), + ]); + maybe_insert_environment_id(&mut properties, options); + JsonSchema::object( + properties, + Some(vec!["path".to_string()]), + Some(false.into()), + ) +} + +fn edit_file_parameters(options: IoToolOptions) -> JsonSchema { + let edit_schema = JsonSchema::object( + BTreeMap::from([ + ( + "oldText".to_string(), + JsonSchema::string(Some( + "Text to replace. Must match exactly once.".to_string(), + )), + ), + ( + "newText".to_string(), + JsonSchema::string(Some("Replacement text.".to_string())), + ), + ]), + Some(vec!["oldText".to_string(), "newText".to_string()]), + Some(false.into()), + ); + let mut properties = BTreeMap::from([ + ( + "path".to_string(), + JsonSchema::string(Some(path_description())), + ), + ( + "edits".to_string(), + JsonSchema::array( + edit_schema, + Some("Exact text replacements to apply in order.".to_string()), + ), + ), + ( + "dryRun".to_string(), + JsonSchema::boolean(Some( + "When true, validate and preview the edit without writing. Defaults to false." + .to_string(), + )), + ), + ]); + maybe_insert_environment_id(&mut properties, options); + JsonSchema::object( + properties, + Some(vec!["path".to_string(), "edits".to_string()]), + Some(false.into()), + ) +} + +fn maybe_insert_environment_id( + properties: &mut BTreeMap, + options: IoToolOptions, +) { + if options.include_environment_id { + properties.insert( + "environment_id".to_string(), + JsonSchema::string(Some( + "Optional environment id from the block. If omitted, uses the primary environment.".to_string(), + )), + ); + } +} + +fn path_description() -> String { + "Path in the selected environment filesystem. Relative paths are resolved against that environment's cwd; absolute paths are interpreted inside that environment. `env://current/...` may be used for explicit environment refs.".to_string() +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 24bddc2c5f61..56962d4589cb 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -5,6 +5,8 @@ pub(crate) mod apply_patch_spec; mod dynamic; mod goal; pub(crate) mod goal_spec; +pub(crate) mod io; +pub(crate) mod io_spec; mod mcp; mod mcp_resource; pub(crate) mod mcp_resource_spec; @@ -53,6 +55,7 @@ pub use dynamic::DynamicToolHandler; pub use goal::CreateGoalHandler; pub use goal::GetGoalHandler; pub use goal::UpdateGoalHandler; +pub use io::IoToolHandler; pub use mcp::McpHandler; pub use mcp_resource::ListMcpResourceTemplatesHandler; pub use mcp_resource::ListMcpResourcesHandler; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 4361cfe4fd40..f6b870d0aa32 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -8,6 +8,7 @@ use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; use crate::tools::handlers::GetGoalHandler; +use crate::tools::handlers::IoToolHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; use crate::tools::handlers::LocalShellHandler; @@ -27,6 +28,10 @@ use crate::tools::handlers::ViewImageHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; +use crate::tools::handlers::io::IoToolKind; +use crate::tools::handlers::io_spec::IO_EXPERIMENTAL_TOOL; +use crate::tools::handlers::io_spec::IoToolOptions; +use crate::tools::handlers::io_spec::create_io_tool_namespace; use crate::tools::handlers::multi_agents::CloseAgentHandler; use crate::tools::handlers::multi_agents::ResumeAgentHandler; use crate::tools::handlers::multi_agents::SendInputHandler; @@ -269,6 +274,27 @@ pub fn build_tool_registry_builder( builder.register_handler(Arc::new(TestSyncHandler)); } + if config.environment_mode.has_environment() + && config + .experimental_supported_tools + .iter() + .any(|tool| tool == IO_EXPERIMENTAL_TOOL) + { + let include_environment_id = + matches!(config.environment_mode, ToolEnvironmentMode::Multiple); + for kind in IoToolKind::ALL { + builder.register_handler(Arc::new(IoToolHandler::new(kind))); + } + if config.namespace_tools { + builder.push_spec( + create_io_tool_namespace(IoToolOptions { + include_environment_id, + }), + /*supports_parallel_tool_calls*/ false, + ); + } + } + if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { web_search_mode: config.web_search_mode, web_search_config: config.web_search_config.as_ref(), diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 3fa8797c506d..6e65ac5d279d 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -3,6 +3,7 @@ use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool; use crate::tools::handlers::goal_spec::create_create_goal_tool; use crate::tools::handlers::goal_spec::create_get_goal_tool; use crate::tools::handlers::goal_spec::create_update_goal_tool; +use crate::tools::handlers::io_spec::IO_NAMESPACE; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; @@ -229,6 +230,68 @@ fn exec_command_spec_includes_environment_id_only_for_multiple_selected_environm ); } +#[test] +fn io_namespace_registers_for_experimental_supported_tool() { + let mut model_info = model_info(); + model_info.experimental_supported_tools = vec!["io".to_string()]; + let available_models = Vec::new(); + let features = Features::with_defaults(); + let config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + permission_profile: &PermissionProfile::Disabled, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_environment_mode(ToolEnvironmentMode::Multiple); + + let (tools, registry) = build_specs( + &config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + + assert_eq!( + namespace_function_names(&tools, IO_NAMESPACE), + vec![ + "read_file", + "write_file", + "edit_file", + "create_directory", + "list_directory", + "get_file_info", + "list_allowed_directories", + ] + ); + for name in [ + "read_file", + "write_file", + "edit_file", + "create_directory", + "list_directory", + "get_file_info", + "list_allowed_directories", + ] { + assert!( + registry.has_handler(&ToolName::namespaced(IO_NAMESPACE, name)), + "io handler {name} should be registered" + ); + } + + let read_file = find_namespace_function_tool(&tools, IO_NAMESPACE, "read_file"); + let (read_file_properties, _) = expect_object_schema(&read_file.parameters); + assert!(read_file_properties.contains_key("environment_id")); + + let list_allowed = + find_namespace_function_tool(&tools, IO_NAMESPACE, "list_allowed_directories"); + let (list_allowed_properties, _) = expect_object_schema(&list_allowed.parameters); + assert!(list_allowed_properties.contains_key("environment_id")); +} + #[test] fn test_build_specs_collab_tools_enabled() { let model_info = model_info(); diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 0bd449188c1a..ea9f35175c2d 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -7,6 +7,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; +use codex_models_manager::bundled_models_response; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -21,6 +22,7 @@ use core_test_support::get_remote_test_env; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -50,6 +52,22 @@ async fn unified_exec_test(server: &wiremock::MockServer) -> Result { builder.build_remote_aware(server).await } +async fn io_tool_test(server: &wiremock::MockServer) -> Result { + let mut builder = test_codex().with_config(|config| { + let mut model_catalog = + bundled_models_response().expect("bundled models.json should parse"); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + model.experimental_supported_tools = vec!["io".to_string()]; + config.model = Some("gpt-5.4".to_string()); + config.model_catalog = Some(model_catalog); + }); + builder.build_remote_aware(server).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> { let Some(_remote_env) = get_remote_test_env() else { @@ -258,6 +276,105 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn io_read_file_routes_to_selected_remote_environment() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let test = io_tool_test(&server).await?; + let local_cwd = TempDir::new()?; + fs::write(local_cwd.path().join("marker.txt"), "local-routing")?; + let local_selection = TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: local_cwd.path().abs(), + }; + let remote_cwd = PathBuf::from(format!( + "/tmp/codex-io-routing-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + test.fs() + .create_directory( + &remote_cwd, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .write_file( + &remote_cwd.join("marker.txt"), + b"remote-routing".to_vec(), + /*sandbox*/ None, + ) + .await?; + + let call_id = "call-io-read-file"; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-io-1"), + ev_function_call_with_namespace( + call_id, + "io", + "read_file", + &serde_json::to_string(&json!({ + "path": "env://current/marker.txt", + "environment_id": REMOTE_ENVIRONMENT_ID, + }))?, + ), + ev_completed("resp-io-1"), + ]), + sse(vec![ + ev_response_created("resp-io-2"), + ev_assistant_message("msg-io-1", "done"), + ev_completed("resp-io-2"), + ]), + ], + ) + .await; + + test.submit_turn_with_environments( + "read remote marker through io", + Some(vec![ + local_selection, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: remote_cwd.clone(), + }, + ]), + ) + .await?; + + let output = response_mock + .function_call_output_text(call_id) + .with_context(|| format!("missing function_call_output for {call_id}"))?; + assert!( + output.contains("remote-routing"), + "unexpected io output: {output}", + ); + assert!( + !output.contains("local-routing"), + "io read should not route to local: {output}", + ); + + test.fs() + .remove( + &remote_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> { skip_if_no_network!(Ok(()));