mirror of
https://github.com/openai/codex.git
synced 2026-05-21 19:45:26 +00:00
Compare commits
1 Commits
fcoury/app
...
lt/native-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549ee17ac4 |
537
codex-rs/core/src/tools/handlers/io.rs
Normal file
537
codex-rs/core/src/tools/handlers/io.rs
Normal 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()
|
||||
}
|
||||
191
codex-rs/core/src/tools/handlers/io_spec.rs
Normal file
191
codex-rs/core/src/tools/handlers/io_spec.rs
Normal 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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
Reference in New Issue
Block a user