Compare commits

...

1 Commits

Author SHA1 Message Date
Liang-Ting Jiang
549ee17ac4 Add native environment io tool POC 2026-05-14 14:24:15 -07:00
6 changed files with 937 additions and 0 deletions

View File

@@ -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<ToolSpec> {
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<Output = bool> + Send {
let is_mutating = self.kind.is_mutating();
async move { is_mutating }
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
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<String>,
}
#[derive(Deserialize)]
struct WriteFileArgs {
path: String,
content: String,
#[serde(default)]
environment_id: Option<String>,
}
#[derive(Deserialize)]
struct CreateDirectoryArgs {
path: String,
#[serde(default)]
recursive: Option<bool>,
#[serde(default)]
environment_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EditFileArgs {
path: String,
edits: Vec<TextEdit>,
#[serde(default)]
dry_run: Option<bool>,
#[serde(default)]
environment_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TextEdit {
old_text: String,
new_text: String,
}
#[derive(Deserialize)]
struct ListAllowedDirectoriesArgs {
#[serde(default)]
environment_id: Option<String>,
}
struct ResolvedIoPath {
environment_id: String,
path: AbsolutePathBuf,
fs: Arc<dyn ExecutorFileSystem>,
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<IoToolOutput, FunctionCallError> {
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<IoToolOutput, FunctionCallError> {
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<IoToolOutput, FunctionCallError> {
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<IoToolOutput, FunctionCallError> {
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<IoToolOutput, FunctionCallError> {
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<IoToolOutput, FunctionCallError> {
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<IoToolOutput, FunctionCallError> {
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::<Vec<_>>();
let writable_roots = policy
.get_writable_roots_with_cwd(cwd.as_path())
.into_iter()
.map(|root| path_string(&root.root))
.collect::<Vec<_>>();
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<ResolvedIoPath, FunctionCallError> {
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<AbsolutePathBuf, FunctionCallError> {
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()
}

View File

@@ -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<String, JsonSchema>,
options: IoToolOptions,
) {
if options.include_environment_id {
properties.insert(
"environment_id".to_string(),
JsonSchema::string(Some(
"Optional environment id from the <environment_context> 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()
}

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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();

View File

@@ -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<TestCodex> {
builder.build_remote_aware(server).await
}
async fn io_tool_test(server: &wiremock::MockServer) -> Result<TestCodex> {
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(()));