mirror of
https://github.com/openai/codex.git
synced 2026-06-02 11:22:01 +00:00
feat(core): add model workspace mutation tools
This commit is contained in:
@@ -110,6 +110,7 @@ pub(crate) mod web_search;
|
||||
pub(crate) mod windows_sandbox_read_grants;
|
||||
pub use thread_manager::ForkSnapshot;
|
||||
pub use thread_manager::NewThread;
|
||||
pub use thread_manager::RuntimeWorkspaceReplayOverrides;
|
||||
pub use thread_manager::StartThreadOptions;
|
||||
pub use thread_manager::ThreadManager;
|
||||
pub use thread_manager::ThreadShutdownReport;
|
||||
|
||||
@@ -213,6 +213,7 @@ use self::config_lock::validate_config_lock_if_configured;
|
||||
#[cfg(test)]
|
||||
use self::handlers::submission_dispatch_span;
|
||||
use self::handlers::submission_loop;
|
||||
pub(crate) use self::handlers::thread_settings_applied_event;
|
||||
pub(crate) use self::input_queue::TurnInput;
|
||||
pub(crate) use self::input_queue::TurnInputQueue;
|
||||
use self::review::spawn_review_thread;
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(crate) mod tool_search_spec;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
pub(crate) mod view_image_spec;
|
||||
mod workspace_mutation;
|
||||
pub(crate) mod workspace_mutation_spec;
|
||||
|
||||
use codex_sandboxing::policy_transforms::intersect_permission_profiles;
|
||||
use codex_sandboxing::policy_transforms::merge_permission_profiles;
|
||||
@@ -73,6 +75,7 @@ pub use unified_exec::ExecCommandHandler;
|
||||
pub(crate) use unified_exec::ExecCommandHandlerOptions;
|
||||
pub use unified_exec::WriteStdinHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
pub(crate) use workspace_mutation::WorkspaceMutationHandler;
|
||||
|
||||
pub(crate) fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
|
||||
where
|
||||
|
||||
346
codex-rs/core/src/tools/handlers/workspace_mutation.rs
Normal file
346
codex-rs/core/src/tools/handlers/workspace_mutation.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::SessionSettingsUpdate;
|
||||
use crate::session::thread_settings_applied_event;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::boxed_tool_output;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::workspace_mutation_spec::create_add_workspace_root_tool;
|
||||
use crate::tools::handlers::workspace_mutation_spec::create_set_working_directory_tool;
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsArgs;
|
||||
use codex_protocol::request_permissions::WorkspaceMutationApprovalRequest;
|
||||
use codex_protocol::request_permissions::WorkspaceMutationOperation;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::io;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceMutation {
|
||||
SetWorkingDirectory,
|
||||
AddWorkspaceRoot,
|
||||
}
|
||||
|
||||
pub(crate) struct WorkspaceMutationHandler {
|
||||
mutation: WorkspaceMutation,
|
||||
}
|
||||
|
||||
impl WorkspaceMutationHandler {
|
||||
pub(crate) fn set_working_directory() -> Self {
|
||||
Self {
|
||||
mutation: WorkspaceMutation::SetWorkingDirectory,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_workspace_root() -> Self {
|
||||
Self {
|
||||
mutation: WorkspaceMutation::AddWorkspaceRoot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WorkspaceMutationArgs {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WorkspaceMutationResult {
|
||||
changed: bool,
|
||||
cwd: AbsolutePathBuf,
|
||||
workspace_roots: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WorkspaceMutationError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
cwd: AbsolutePathBuf,
|
||||
workspace_roots: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for WorkspaceMutationHandler {
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(match self.mutation {
|
||||
WorkspaceMutation::SetWorkingDirectory => "set_working_directory",
|
||||
WorkspaceMutation::AddWorkspaceRoot => "add_workspace_root",
|
||||
})
|
||||
}
|
||||
|
||||
fn spec(&self) -> ToolSpec {
|
||||
match self.mutation {
|
||||
WorkspaceMutation::SetWorkingDirectory => create_set_working_directory_tool(),
|
||||
WorkspaceMutation::AddWorkspaceRoot => create_add_workspace_root_tool(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
cancellation_token,
|
||||
call_id,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"workspace mutation handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let args: WorkspaceMutationArgs = parse_arguments(&arguments)?;
|
||||
let current = turn.runtime_workspace.snapshot().await;
|
||||
let requested = current.cwd.join(args.path);
|
||||
let Some(environment) = turn.environments.primary() else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"workspace mutation is unavailable without an execution environment".to_string(),
|
||||
));
|
||||
};
|
||||
let fs = environment.environment.get_filesystem();
|
||||
let canonical = match fs.canonicalize(&requested, /*sandbox*/ None).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return workspace_error(
|
||||
io_error_code(&err),
|
||||
err.to_string(),
|
||||
current.cwd,
|
||||
current.workspace_roots,
|
||||
);
|
||||
}
|
||||
};
|
||||
let metadata = match fs.get_metadata(&canonical, /*sandbox*/ None).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
return workspace_error(
|
||||
io_error_code(&err),
|
||||
err.to_string(),
|
||||
current.cwd,
|
||||
current.workspace_roots,
|
||||
);
|
||||
}
|
||||
};
|
||||
if !metadata.is_directory {
|
||||
return workspace_error(
|
||||
"not_a_directory",
|
||||
format!(
|
||||
"workspace mutation target is not a directory: {}",
|
||||
canonical.as_path().display()
|
||||
),
|
||||
current.cwd,
|
||||
current.workspace_roots,
|
||||
);
|
||||
}
|
||||
|
||||
let mut workspace_roots = current.workspace_roots.clone();
|
||||
if !workspace_roots
|
||||
.iter()
|
||||
.any(|root| canonical.as_path().starts_with(root.as_path()))
|
||||
{
|
||||
workspace_roots.push(canonical.clone());
|
||||
}
|
||||
let cwd = match self.mutation {
|
||||
WorkspaceMutation::SetWorkingDirectory => canonical.clone(),
|
||||
WorkspaceMutation::AddWorkspaceRoot => current.cwd.clone(),
|
||||
};
|
||||
let changed = cwd != current.cwd || workspace_roots != current.workspace_roots;
|
||||
if !changed {
|
||||
return workspace_success(/*changed*/ false, cwd, workspace_roots);
|
||||
}
|
||||
|
||||
let preview = session
|
||||
.preview_settings(&SessionSettingsUpdate {
|
||||
cwd: matches!(self.mutation, WorkspaceMutation::SetWorkingDirectory)
|
||||
.then(|| cwd.to_path_buf()),
|
||||
workspace_roots: Some(workspace_roots.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
|
||||
let current_policy = current.permission_profile.file_system_sandbox_policy();
|
||||
let preview_policy = preview.permission_profile.file_system_sandbox_policy();
|
||||
if matches!(self.mutation, WorkspaceMutation::SetWorkingDirectory)
|
||||
&& !preview_policy.can_read_path_with_cwd(canonical.as_path(), cwd.as_path())
|
||||
{
|
||||
return workspace_error(
|
||||
"permission_denied",
|
||||
format!(
|
||||
"working directory is not readable under the active permission profile: {}",
|
||||
canonical.as_path().display()
|
||||
),
|
||||
current.cwd,
|
||||
current.workspace_roots,
|
||||
);
|
||||
}
|
||||
let requested_permissions = if preview_policy
|
||||
.can_write_path_with_cwd(canonical.as_path(), cwd.as_path())
|
||||
&& !current_policy.can_write_path_with_cwd(canonical.as_path(), current.cwd.as_path())
|
||||
{
|
||||
Some(FileSystemPermissions::from_read_write_roots(
|
||||
/*read*/ None,
|
||||
Some(vec![canonical.clone()]),
|
||||
))
|
||||
} else if preview_policy.can_read_path_with_cwd(canonical.as_path(), cwd.as_path())
|
||||
&& !current_policy.can_read_path_with_cwd(canonical.as_path(), current.cwd.as_path())
|
||||
{
|
||||
Some(FileSystemPermissions::from_read_write_roots(
|
||||
Some(vec![canonical.clone()]),
|
||||
/*write*/ None,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(file_system) = requested_permissions {
|
||||
let response = session
|
||||
.request_workspace_permissions_for_cwd(
|
||||
&turn,
|
||||
call_id,
|
||||
RequestPermissionsArgs {
|
||||
reason: Some(match self.mutation {
|
||||
WorkspaceMutation::SetWorkingDirectory => format!(
|
||||
"switch this session's working directory to `{}`",
|
||||
canonical.as_path().display()
|
||||
),
|
||||
WorkspaceMutation::AddWorkspaceRoot => format!(
|
||||
"add `{}` to this session's workspace",
|
||||
canonical.as_path().display()
|
||||
),
|
||||
}),
|
||||
permissions: RequestPermissionProfile {
|
||||
file_system: Some(file_system),
|
||||
network: None,
|
||||
},
|
||||
},
|
||||
current.cwd.clone(),
|
||||
WorkspaceMutationApprovalRequest {
|
||||
operation: match self.mutation {
|
||||
WorkspaceMutation::SetWorkingDirectory => {
|
||||
WorkspaceMutationOperation::SetWorkingDirectory
|
||||
}
|
||||
WorkspaceMutation::AddWorkspaceRoot => {
|
||||
WorkspaceMutationOperation::AddWorkspaceRoot
|
||||
}
|
||||
},
|
||||
target: canonical.clone(),
|
||||
resulting_workspace_roots: workspace_roots.clone(),
|
||||
},
|
||||
cancellation_token,
|
||||
)
|
||||
.await;
|
||||
let Some(response) = response else {
|
||||
return workspace_error(
|
||||
"approval_denied",
|
||||
"workspace mutation approval was cancelled".to_string(),
|
||||
current.cwd,
|
||||
current.workspace_roots,
|
||||
);
|
||||
};
|
||||
if response.permissions.is_empty()
|
||||
|| !matches!(response.scope, PermissionGrantScope::Session)
|
||||
{
|
||||
return workspace_error(
|
||||
"approval_denied",
|
||||
"workspace mutation requires session-scoped approval".to_string(),
|
||||
current.cwd,
|
||||
current.workspace_roots,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
session
|
||||
.update_runtime_workspace(
|
||||
turn.as_ref(),
|
||||
matches!(self.mutation, WorkspaceMutation::SetWorkingDirectory)
|
||||
.then_some(cwd.clone()),
|
||||
workspace_roots.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
thread_settings_applied_event(session.as_ref()).await,
|
||||
)
|
||||
.await;
|
||||
workspace_success(/*changed*/ true, cwd, workspace_roots)
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreToolRuntime for WorkspaceMutationHandler {
|
||||
fn execution_barrier(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn cancel_suffix_on_failure(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_success(
|
||||
changed: bool,
|
||||
cwd: AbsolutePathBuf,
|
||||
workspace_roots: Vec<AbsolutePathBuf>,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
workspace_output(
|
||||
WorkspaceMutationResult {
|
||||
changed,
|
||||
cwd,
|
||||
workspace_roots,
|
||||
},
|
||||
/*success*/ true,
|
||||
)
|
||||
}
|
||||
|
||||
fn workspace_error(
|
||||
code: &'static str,
|
||||
message: String,
|
||||
cwd: AbsolutePathBuf,
|
||||
workspace_roots: Vec<AbsolutePathBuf>,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
workspace_output(
|
||||
WorkspaceMutationError {
|
||||
code,
|
||||
message,
|
||||
cwd,
|
||||
workspace_roots,
|
||||
},
|
||||
/*success*/ false,
|
||||
)
|
||||
}
|
||||
|
||||
fn workspace_output(
|
||||
output: impl Serialize,
|
||||
success: bool,
|
||||
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
|
||||
let content = serde_json::to_string(&output).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize workspace mutation result: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(boxed_tool_output(FunctionToolOutput::from_text(
|
||||
content,
|
||||
Some(success),
|
||||
)))
|
||||
}
|
||||
|
||||
fn io_error_code(err: &io::Error) -> &'static str {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => "path_not_found",
|
||||
io::ErrorKind::PermissionDenied => "permission_denied",
|
||||
_ => "resolution_failed",
|
||||
}
|
||||
}
|
||||
38
codex-rs/core/src/tools/handlers/workspace_mutation_spec.rs
Normal file
38
codex-rs/core/src/tools/handlers/workspace_mutation_spec.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolSpec;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) fn create_set_working_directory_tool() -> ToolSpec {
|
||||
create_workspace_mutation_tool(
|
||||
"set_working_directory",
|
||||
"Changes the active working directory and adds it as a workspace root when needed.",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn create_add_workspace_root_tool() -> ToolSpec {
|
||||
create_workspace_mutation_tool(
|
||||
"add_workspace_root",
|
||||
"Adds a workspace root without changing the active working directory.",
|
||||
)
|
||||
}
|
||||
|
||||
fn create_workspace_mutation_tool(name: &str, summary: &str) -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: name.to_string(),
|
||||
description: format!(
|
||||
"{summary}\n\nRelative paths resolve from the active working directory. Later tool calls in the same batch start after this mutation succeeds and are cancelled if it fails."
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"path".to_string(),
|
||||
JsonSchema::string(Some("Existing directory path.".to_string())),
|
||||
)]),
|
||||
Some(vec!["path".to_string()]),
|
||||
/*additional_properties*/ Some(false.into()),
|
||||
),
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::ToolSearchHandler;
|
||||
use crate::tools::handlers::UpdateGoalHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
use crate::tools::handlers::WorkspaceMutationHandler;
|
||||
use crate::tools::handlers::WriteStdinHandler;
|
||||
use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler;
|
||||
use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler;
|
||||
@@ -610,6 +611,11 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
|
||||
planned_tools.add(RequestPermissionsHandler);
|
||||
}
|
||||
|
||||
if matches!(environment_mode, ToolEnvironmentMode::Single) {
|
||||
planned_tools.add(WorkspaceMutationHandler::set_working_directory());
|
||||
planned_tools.add(WorkspaceMutationHandler::add_workspace_root());
|
||||
}
|
||||
|
||||
if tool_suggest_enabled(turn_context)
|
||||
&& let Some(discoverable_tools) =
|
||||
context.discoverable_tools.filter(|tools| !tools.is_empty())
|
||||
|
||||
@@ -421,14 +421,21 @@ async fn environment_count_controls_environment_backed_tools() {
|
||||
"exec_command",
|
||||
"apply_patch",
|
||||
"view_image",
|
||||
"set_working_directory",
|
||||
"add_workspace_root",
|
||||
]);
|
||||
no_environment.assert_registered_lacks(&[
|
||||
"shell_command",
|
||||
"exec_command",
|
||||
"apply_patch",
|
||||
"view_image",
|
||||
"set_working_directory",
|
||||
"add_workspace_root",
|
||||
]);
|
||||
|
||||
let single_environment = probe(|_| {}).await;
|
||||
single_environment.assert_visible_contains(&["set_working_directory", "add_workspace_root"]);
|
||||
|
||||
let multiple_environments = probe(|turn| {
|
||||
duplicate_primary_environment(turn);
|
||||
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
|
||||
@@ -437,6 +444,7 @@ async fn environment_count_controls_environment_backed_tools() {
|
||||
})
|
||||
.await;
|
||||
multiple_environments.assert_visible_contains(&["exec_command", "apply_patch", "view_image"]);
|
||||
multiple_environments.assert_visible_lacks(&["set_working_directory", "add_workspace_root"]);
|
||||
assert!(has_parameter(
|
||||
multiple_environments.visible_spec("exec_command"),
|
||||
"environment_id"
|
||||
|
||||
@@ -9,6 +9,8 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
@@ -16,7 +18,16 @@ use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||
use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_permissions::WorkspaceMutationOperation;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -29,7 +40,10 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::skip_if_sandbox;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -51,6 +65,84 @@ fn tool_names(body: &Value) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn workspace_write_excluding_tmp() -> PermissionProfile {
|
||||
PermissionProfile::workspace_write_with(
|
||||
&[],
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*exclude_tmpdir_env_var*/ true,
|
||||
/*exclude_slash_tmp*/ true,
|
||||
)
|
||||
}
|
||||
|
||||
async fn submit_workspace_mutation_turn(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(permission_profile, test.config.cwd.as_path());
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
environments: None,
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
||||
cwd: Some(test.config.cwd.to_path_buf()),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
|
||||
mode: codex_protocol::config_types::ModeKind::Default,
|
||||
settings: codex_protocol::config_types::Settings {
|
||||
model: session_model,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expect_workspace_mutation_request(
|
||||
test: &TestCodex,
|
||||
expected_call_id: &str,
|
||||
) -> RequestPermissionsEvent {
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match event {
|
||||
EventMsg::RequestPermissions(request) => {
|
||||
assert_eq!(request.call_id, expected_call_id);
|
||||
request
|
||||
}
|
||||
EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_completion(test: &TestCodex) {
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -82,7 +174,14 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()>
|
||||
tools.contains(&"update_plan".to_string()),
|
||||
"non-environment tool should remain available; got {tools:?}"
|
||||
);
|
||||
for environment_tool in ["exec_command", "write_stdin", "apply_patch", "view_image"] {
|
||||
for environment_tool in [
|
||||
"exec_command",
|
||||
"write_stdin",
|
||||
"apply_patch",
|
||||
"view_image",
|
||||
"set_working_directory",
|
||||
"add_workspace_root",
|
||||
] {
|
||||
assert!(
|
||||
!tools.contains(&environment_tool.to_string()),
|
||||
"{environment_tool} should be omitted for explicit empty turn environments; got {tools:?}"
|
||||
@@ -120,7 +219,7 @@ async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<(
|
||||
Some(vec![TurnEnvironmentSelection {
|
||||
environment_id: "local".to_string(),
|
||||
cwd: test.config.cwd.clone(),
|
||||
workspace_roots: Vec::new(),
|
||||
workspace_roots: test.config.workspace_roots.clone(),
|
||||
}]),
|
||||
)
|
||||
.await?;
|
||||
@@ -130,10 +229,458 @@ async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<(
|
||||
tools.contains(&"exec_command".to_string()),
|
||||
"environment tool should remain available with selected local environment; got {tools:?}"
|
||||
);
|
||||
assert!(tools.contains(&"set_working_directory".to_string()));
|
||||
assert!(tools.contains(&"add_workspace_root".to_string()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_mutation_updates_same_batch_shell_cwd() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("test-gpt-5-codex");
|
||||
let test = builder.build(&server).await?;
|
||||
let next_cwd = test.config.cwd.join("workspace-mutation-next");
|
||||
fs::create_dir_all(next_cwd.as_path())?;
|
||||
let mutation_call_id = "set-cwd";
|
||||
let shell_call_id = "pwd";
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
mutation_call_id,
|
||||
"set_working_directory",
|
||||
&serde_json::to_string(&json!({ "path": next_cwd }))?,
|
||||
),
|
||||
ev_function_call(
|
||||
shell_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&json!({
|
||||
"command": "pwd",
|
||||
"login": false,
|
||||
"timeout_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("change directories and print the working directory")
|
||||
.await?;
|
||||
|
||||
let request = second_mock.single_request();
|
||||
let mutation_output = request
|
||||
.function_call_output_text(mutation_call_id)
|
||||
.expect("mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&mutation_output)?,
|
||||
json!({
|
||||
"changed": true,
|
||||
"cwd": next_cwd,
|
||||
"workspace_roots": test.config.workspace_roots,
|
||||
})
|
||||
);
|
||||
let shell_output = request
|
||||
.function_call_output_text(shell_call_id)
|
||||
.expect("shell output");
|
||||
assert!(shell_output.contains(next_cwd.as_path().to_string_lossy().as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_mutations_run_in_model_provided_order() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("test-gpt-5-codex");
|
||||
let test = builder.build(&server).await?;
|
||||
let first_cwd = test.config.cwd.join("workspace-mutation-first");
|
||||
let second_cwd = first_cwd.join("nested");
|
||||
fs::create_dir_all(second_cwd.as_path())?;
|
||||
let first_mutation_call_id = "set-first-cwd";
|
||||
let second_mutation_call_id = "set-second-cwd";
|
||||
let shell_call_id = "pwd";
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
first_mutation_call_id,
|
||||
"set_working_directory",
|
||||
&serde_json::to_string(&json!({ "path": "workspace-mutation-first" }))?,
|
||||
),
|
||||
ev_function_call(
|
||||
second_mutation_call_id,
|
||||
"set_working_directory",
|
||||
&serde_json::to_string(&json!({ "path": "nested" }))?,
|
||||
),
|
||||
ev_function_call(
|
||||
shell_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&json!({
|
||||
"command": "pwd",
|
||||
"login": false,
|
||||
"timeout_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("change directories twice and print the working directory")
|
||||
.await?;
|
||||
|
||||
let request = second_mock.single_request();
|
||||
let first_mutation_output = request
|
||||
.function_call_output_text(first_mutation_call_id)
|
||||
.expect("first mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&first_mutation_output)?,
|
||||
json!({
|
||||
"changed": true,
|
||||
"cwd": first_cwd,
|
||||
"workspace_roots": test.config.workspace_roots,
|
||||
})
|
||||
);
|
||||
let second_mutation_output = request
|
||||
.function_call_output_text(second_mutation_call_id)
|
||||
.expect("second mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&second_mutation_output)?,
|
||||
json!({
|
||||
"changed": true,
|
||||
"cwd": second_cwd,
|
||||
"workspace_roots": test.config.workspace_roots,
|
||||
})
|
||||
);
|
||||
let shell_output = request
|
||||
.function_call_output_text(shell_call_id)
|
||||
.expect("shell output");
|
||||
assert!(shell_output.contains(second_cwd.as_path().to_string_lossy().as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn add_workspace_root_under_existing_root_is_noop() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("test-gpt-5-codex");
|
||||
let test = builder.build(&server).await?;
|
||||
let covered_child = test.config.cwd.join("covered-child");
|
||||
fs::create_dir_all(covered_child.as_path())?;
|
||||
let mutation_call_id = "add-covered-root";
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
mutation_call_id,
|
||||
"add_workspace_root",
|
||||
&serde_json::to_string(&json!({ "path": covered_child }))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("add an already covered workspace root")
|
||||
.await?;
|
||||
|
||||
let output = second_mock
|
||||
.single_request()
|
||||
.function_call_output_text(mutation_call_id)
|
||||
.expect("mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&output)?,
|
||||
json!({
|
||||
"changed": false,
|
||||
"cwd": test.config.cwd,
|
||||
"workspace_roots": test.config.workspace_roots,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_mutation_requires_session_scoped_approval_and_cancels_suffix() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("test-gpt-5-codex");
|
||||
let test = builder.build(&server).await?;
|
||||
let external_root = tempfile::tempdir()?;
|
||||
let external_root = AbsolutePathBuf::try_from(external_root.path().canonicalize()?)?;
|
||||
let mutation_call_id = "add-external-root";
|
||||
let suffix_call_id = "suffix-pwd";
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
mutation_call_id,
|
||||
"add_workspace_root",
|
||||
&serde_json::to_string(&json!({ "path": external_root }))?,
|
||||
),
|
||||
ev_function_call(
|
||||
suffix_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&json!({
|
||||
"command": "pwd",
|
||||
"login": false,
|
||||
"timeout_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_workspace_mutation_turn(
|
||||
&test,
|
||||
"add an external workspace root and print cwd",
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_excluding_tmp(),
|
||||
)
|
||||
.await?;
|
||||
let request = expect_workspace_mutation_request(&test, mutation_call_id).await;
|
||||
let mut expected_workspace_roots = test.config.workspace_roots.clone();
|
||||
expected_workspace_roots.push(external_root.clone());
|
||||
let expected_permissions = RequestPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
/*read*/ None,
|
||||
Some(vec![external_root.clone()]),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(request.permissions, expected_permissions);
|
||||
assert_eq!(
|
||||
request.workspace_mutation,
|
||||
Some(
|
||||
codex_protocol::request_permissions::WorkspaceMutationApprovalRequest {
|
||||
operation: WorkspaceMutationOperation::AddWorkspaceRoot,
|
||||
target: external_root.clone(),
|
||||
resulting_workspace_roots: expected_workspace_roots,
|
||||
}
|
||||
)
|
||||
);
|
||||
test.codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: mutation_call_id.to_string(),
|
||||
response: RequestPermissionsResponse {
|
||||
permissions: expected_permissions,
|
||||
scope: PermissionGrantScope::Turn,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
|
||||
let mutation_output = responses
|
||||
.function_call_output_text(mutation_call_id)
|
||||
.expect("mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&mutation_output)?,
|
||||
json!({
|
||||
"code": "approval_denied",
|
||||
"message": "workspace mutation requires session-scoped approval",
|
||||
"cwd": test.config.cwd,
|
||||
"workspace_roots": test.config.workspace_roots,
|
||||
})
|
||||
);
|
||||
let suffix_output = responses
|
||||
.function_call_output_text(suffix_call_id)
|
||||
.expect("cancelled suffix output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&suffix_output)?,
|
||||
json!({
|
||||
"code": "dependency_cancelled",
|
||||
"message": format!("cancelled because workspace mutation `{mutation_call_id}` failed"),
|
||||
"failed_mutation_call_id": mutation_call_id,
|
||||
"failed_mutation_code": "approval_denied",
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn approved_workspace_root_is_available_to_same_batch_shell() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("test-gpt-5-codex");
|
||||
let test = builder.build(&server).await?;
|
||||
let external_root = tempfile::tempdir()?;
|
||||
let external_root = AbsolutePathBuf::try_from(external_root.path().canonicalize()?)?;
|
||||
let mutation_call_id = "approve-external-root";
|
||||
let shell_call_id = "pwd-external-root";
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
mutation_call_id,
|
||||
"add_workspace_root",
|
||||
&serde_json::to_string(&json!({ "path": external_root }))?,
|
||||
),
|
||||
ev_function_call(
|
||||
shell_call_id,
|
||||
"shell_command",
|
||||
&serde_json::to_string(&json!({
|
||||
"command": "pwd",
|
||||
"workdir": external_root,
|
||||
"login": false,
|
||||
"timeout_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_workspace_mutation_turn(
|
||||
&test,
|
||||
"add an external workspace root and use it immediately",
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_excluding_tmp(),
|
||||
)
|
||||
.await?;
|
||||
let request = expect_workspace_mutation_request(&test, mutation_call_id).await;
|
||||
test.codex
|
||||
.submit(Op::RequestPermissionsResponse {
|
||||
id: mutation_call_id.to_string(),
|
||||
response: RequestPermissionsResponse {
|
||||
permissions: request.permissions,
|
||||
scope: PermissionGrantScope::Session,
|
||||
strict_auto_review: false,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
|
||||
let mut expected_workspace_roots = test.config.workspace_roots.clone();
|
||||
expected_workspace_roots.push(external_root.clone());
|
||||
let mutation_output = responses
|
||||
.function_call_output_text(mutation_call_id)
|
||||
.expect("mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&mutation_output)?,
|
||||
json!({
|
||||
"changed": true,
|
||||
"cwd": test.config.cwd,
|
||||
"workspace_roots": expected_workspace_roots,
|
||||
})
|
||||
);
|
||||
let shell_output = responses
|
||||
.function_call_output_text(shell_call_id)
|
||||
.expect("shell output");
|
||||
assert!(shell_output.contains(external_root.as_path().to_string_lossy().as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn set_working_directory_rejects_unreadable_target() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_model("test-gpt-5-codex");
|
||||
let test = builder.build(&server).await?;
|
||||
let external_root = tempfile::tempdir()?;
|
||||
let external_root = AbsolutePathBuf::try_from(external_root.path().canonicalize()?)?;
|
||||
let mutation_call_id = "set-unreadable-cwd";
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(
|
||||
mutation_call_id,
|
||||
"set_working_directory",
|
||||
&serde_json::to_string(&json!({ "path": external_root }))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let permission_profile = PermissionProfile::from_runtime_permissions(
|
||||
&FileSystemSandboxPolicy::restricted(Vec::new()),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
);
|
||||
|
||||
test.submit_turn_with_approval_and_permission_profile(
|
||||
"change to an unreadable directory",
|
||||
AskForApproval::OnRequest,
|
||||
permission_profile,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let output = second_mock
|
||||
.single_request()
|
||||
.function_call_output_text(mutation_call_id)
|
||||
.expect("mutation output");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&output)?,
|
||||
json!({
|
||||
"code": "permission_denied",
|
||||
"message": format!(
|
||||
"working directory is not readable under the active permission profile: {}",
|
||||
external_root.as_path().display()
|
||||
),
|
||||
"cwd": test.config.cwd,
|
||||
"workspace_roots": test.config.workspace_roots,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
Reference in New Issue
Block a user