feat(tui) experimental /sandbox-setup command

This commit is contained in:
Dylan Hurd
2026-03-09 23:45:04 -07:00
parent b879894222
commit 1db113b96c
13 changed files with 282 additions and 2 deletions
+4
View File
@@ -638,6 +638,7 @@ fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
)]),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -682,6 +683,7 @@ fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std
)]),
}),
network: None,
macos: None,
})?;
assert_eq!(
@@ -708,6 +710,7 @@ fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Resu
let config = load_workspace_permission_profile(PermissionProfileToml {
filesystem: None,
network: None,
macos: None,
})?;
assert_eq!(
@@ -741,6 +744,7 @@ fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result
entries: BTreeMap::new(),
}),
network: None,
macos: None,
})?;
assert_eq!(
+5 -1
View File
@@ -1980,7 +1980,11 @@ impl Config {
mut file_system_sandbox_policy,
network_sandbox_policy,
macos_seatbelt_profile_extensions,
) = compile_permission_profile(permissions, default_permissions)?;
) = compile_permission_profile(
permissions,
default_permissions,
&mut startup_warnings,
)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
+14
View File
@@ -379,6 +379,20 @@ fn filesystem_permission_toml_from_entry(
":slash_tmp".to_string(),
FilesystemPermissionToml::Access(access),
)),
FileSystemSpecialPath::Unknown {
path,
subpath: None,
} => Ok((path.clone(), FilesystemPermissionToml::Access(access))),
FileSystemSpecialPath::Unknown {
path,
subpath: Some(subpath),
} => Ok((
path.clone(),
FilesystemPermissionToml::Scoped(BTreeMap::from([(
subpath.to_string_lossy().to_string(),
access,
)])),
)),
},
}
}
+1 -1
View File
@@ -444,7 +444,7 @@ mod tests {
"pause should block the unified exec yield timeout"
);
assert!(
response.output.contains("unified-exec-done"),
response.truncated_output().contains("unified-exec-done"),
"exec_command should wait for output after the pause lifts"
);
assert!(
@@ -0,0 +1,15 @@
Inspect the current project before asking for any permissions.
Start by using non-mutating discovery tools to understand how this project is run, built, tested, or set up. Read the most relevant project files first, such as README files, manifest files, lockfiles, task runners, devcontainer or Docker files, and any repo-local instructions that indicate required tools, services, directories, or network access.
After you have concrete evidence from the repo, call the `request_permissions` tool with the narrowest permission profile that would help you run or set up this project effectively. Include a short reason that references the task you are trying to unblock.
Be strict about scope:
- Request only permissions that are justified by the project contents you found.
- Prefer the smallest possible filesystem read/write path list.
- Request network access only if the repo indicates it is needed for dependency installation, fetching services, or other project setup steps.
- Request macOS permissions only if the repo clearly implies they are needed.
- Do not ask for broad or speculative permissions.
- Do not ask for permissions that the current workspace access already provides.
Once the permission request is made, briefly explain what you inspected and why those permissions were selected.
@@ -405,6 +405,7 @@ pub(crate) struct ChatComposer {
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
request_permissions_tool_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
@@ -441,6 +442,7 @@ impl ChatComposer {
personality_command_enabled: self.personality_command_enabled,
realtime_conversation_enabled: self.realtime_conversation_enabled,
audio_device_selection_enabled: self.audio_device_selection_enabled,
request_permissions_tool_enabled: self.request_permissions_tool_enabled,
allow_elevate_sandbox: self.windows_degraded_sandbox_active,
}
}
@@ -525,6 +527,7 @@ impl ChatComposer {
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
request_permissions_tool_enabled: false,
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
@@ -615,6 +618,10 @@ impl ChatComposer {
self.audio_device_selection_enabled = enabled;
}
pub fn set_request_permissions_tool_enabled(&mut self, enabled: bool) {
self.request_permissions_tool_enabled = enabled;
}
/// Compatibility shim for tests that still toggle the removed steer mode flag.
#[cfg(test)]
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
@@ -3468,6 +3475,7 @@ impl ChatComposer {
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let request_permissions_tool_enabled = self.request_permissions_tool_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
@@ -3477,6 +3485,7 @@ impl ChatComposer {
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
request_permissions_tool_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
);
@@ -6430,6 +6439,36 @@ mod tests {
}
}
#[test]
fn slash_popup_sandbox_setup_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_request_permissions_tool_enabled(true);
type_chars_humanlike(&mut composer, &['/', 's', 'a', 'n', 'd']);
let mut terminal = match Terminal::new(TestBackend::new(60, 6)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
insta::assert_snapshot!("slash_popup_sand", terminal.backend());
}
#[test]
fn slash_popup_resume_for_res_ui() {
use ratatui::Terminal;
@@ -6669,6 +6708,50 @@ mod tests {
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn sandbox_setup_command_disabled_while_task_running_keeps_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_request_permissions_tool_enabled(true);
composer.set_task_running(true);
composer
.textarea
.set_text_clearing_elements("/sandbox-setup");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(InputResult::None, result);
assert_eq!("/sandbox-setup", composer.textarea.text());
let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("disabled while a task is in progress"));
found_error = true;
break;
}
}
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn voice_transcription_disabled_treats_space_as_normal_input() {
use crossterm::event::KeyCode;
@@ -42,6 +42,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) request_permissions_tool_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
@@ -54,6 +55,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
personality_command_enabled: value.personality_command_enabled,
realtime_conversation_enabled: value.realtime_conversation_enabled,
audio_device_selection_enabled: value.audio_device_selection_enabled,
request_permissions_tool_enabled: value.request_permissions_tool_enabled,
allow_elevate_sandbox: value.windows_degraded_sandbox_active,
}
}
@@ -511,6 +513,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
request_permissions_tool_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -533,6 +536,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
request_permissions_tool_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -555,6 +559,7 @@ mod tests {
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
request_permissions_tool_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -585,6 +590,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
request_permissions_tool_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -607,6 +613,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
request_permissions_tool_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -644,4 +651,47 @@ mod tests {
"expected no /debug* command in popup menu, got {cmds:?}"
);
}
#[test]
fn sandbox_setup_command_hidden_when_request_permissions_tool_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
request_permissions_tool_enabled: false,
..CommandPopupFlags::default()
},
);
popup.on_composer_text_change("/sandbox-setup".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"sandbox-setup"),
"expected '/sandbox-setup' to be hidden when request_permissions_tool is disabled, got {cmds:?}"
);
}
#[test]
fn sandbox_setup_command_visible_when_request_permissions_tool_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
request_permissions_tool_enabled: true,
..CommandPopupFlags::default()
},
);
popup.on_composer_text_change("/sandbox-setup".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "sandbox-setup"),
other => panic!("expected sandbox-setup to be selected for exact match, got {other:?}"),
}
}
}
+5
View File
@@ -320,6 +320,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_request_permissions_tool_enabled(&mut self, enabled: bool) {
self.composer.set_request_permissions_tool_enabled(enabled);
self.request_redraw();
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.composer.set_voice_transcription_enabled(enabled);
self.request_redraw();
@@ -16,6 +16,7 @@ pub(crate) struct BuiltinCommandFlags {
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) request_permissions_tool_enabled: bool,
pub(crate) allow_elevate_sandbox: bool,
}
@@ -33,6 +34,9 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.filter(|(_, cmd)| {
flags.request_permissions_tool_enabled || *cmd != SlashCommand::SandboxSetup
})
.collect()
}
@@ -64,6 +68,7 @@ mod tests {
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: true,
request_permissions_tool_enabled: true,
allow_elevate_sandbox: true,
}
}
@@ -0,0 +1,10 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› /sand "
" "
" "
" /sandbox-setup scan project and request the permissions "
" needed to run it "
+40
View File
@@ -179,6 +179,7 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change";
const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override";
const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override";
const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection";
const SANDBOX_SETUP_PROMPT: &str = include_str!("../prompt_for_sandbox_setup_command.md");
/// Choose the keybinding used to edit the most-recently queued message.
///
@@ -3297,6 +3298,12 @@ impl ChatWidget {
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget.bottom_pane.set_request_permissions_tool_enabled(
widget
.config
.features
.enabled(Feature::RequestPermissionsTool),
);
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3481,6 +3488,12 @@ impl ChatWidget {
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget.bottom_pane.set_request_permissions_tool_enabled(
widget
.config
.features
.enabled(Feature::RequestPermissionsTool),
);
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3657,6 +3670,12 @@ impl ChatWidget {
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget.bottom_pane.set_request_permissions_tool_enabled(
widget
.config
.features
.enabled(Feature::RequestPermissionsTool),
);
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -4038,6 +4057,23 @@ impl ChatWidget {
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::SandboxSetup => {
let user_message = UserMessage {
text: SANDBOX_SETUP_PROMPT.to_string(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
@@ -7333,6 +7369,10 @@ impl ChatWidget {
if feature == Feature::Personality {
self.sync_personality_command_enabled();
}
if feature == Feature::RequestPermissionsTool {
self.bottom_pane
.set_request_permissions_tool_enabled(enabled);
}
if feature == Feature::Plugins {
self.refresh_plugin_mentions();
}
+45
View File
@@ -5515,6 +5515,51 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}
#[tokio::test]
async fn sandbox_setup_slash_command_submits_prompt() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RequestPermissionsTool, true);
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
};
chat.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.bottom_pane
.set_composer_text("/sandbox-setup".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(items.len(), 1);
assert_eq!(
items[0],
UserInput::Text {
text: SANDBOX_SETUP_PROMPT.to_string(),
text_elements: Vec::new(),
}
);
}
#[tokio::test]
async fn collaboration_modes_defaults_to_code_on_startup() {
let codex_home = tempdir().expect("tempdir");
+5
View File
@@ -16,6 +16,7 @@ pub enum SlashCommand {
Fast,
Approvals,
Permissions,
SandboxSetup,
#[strum(serialize = "setup-default-sandbox")]
ElevateSandbox,
#[strum(serialize = "sandbox-add-read-dir")]
@@ -99,6 +100,9 @@ impl SlashCommand {
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Approvals => "choose what Codex is allowed to do",
SlashCommand::Permissions => "choose what Codex is allowed to do",
SlashCommand::SandboxSetup => {
"scan project and request the permissions needed to run it"
}
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::SandboxReadRoot => {
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
@@ -144,6 +148,7 @@ impl SlashCommand {
| SlashCommand::Personality
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::SandboxSetup
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental