mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
[app-server] feat: add v2 command execution approval flow (#6758)
This PR adds the API V2 version of the command‑execution approval flow
for the shell tool.
This PR wires the new RPC (`item/commandExecution/requestApproval`, V2
only) and related events (`item/started`, `item/completed`, and
`item/commandExecution/delta`, which are emitted in both V1 and V2)
through the app-server
protocol. The new approval RPC is only sent when the user initiates a
turn with the new `turn/start` API so we don't break backwards
compatibility with VSCE.
The approach I took was to make as few changes to the Codex core as
possible, leveraging existing `EventMsg` core events, and translating
those in app-server. I did have to add additional fields to
`EventMsg::ExecCommandEndEvent` to capture the command's input so that
app-server can statelessly transform these events to a
`ThreadItem::CommandExecution` item for the `item/completed` event.
Once we stabilize the API and it's complete enough for our partners, we
can work on migrating the core to be aware of command execution items as
a first-class concept.
**Note**: We'll need followup work to make sure these APIs work for the
unified exec tool, but will wait til that's stable and landed before
doing a pass on app-server.
Example payloads below:
```
{
"method": "item/started",
"params": {
"item": {
"aggregatedOutput": null,
"command": "/bin/zsh -lc 'touch /tmp/should-trigger-approval'",
"cwd": "/Users/owen/repos/codex/codex-rs",
"durationMs": null,
"exitCode": null,
"id": "call_lNWWsbXl1e47qNaYjFRs0dyU",
"parsedCmd": [
{
"cmd": "touch /tmp/should-trigger-approval",
"type": "unknown"
}
],
"status": "inProgress",
"type": "commandExecution"
}
}
}
```
```
{
"id": 0,
"method": "item/commandExecution/requestApproval",
"params": {
"itemId": "call_lNWWsbXl1e47qNaYjFRs0dyU",
"parsedCmd": [
{
"cmd": "touch /tmp/should-trigger-approval",
"type": "unknown"
}
],
"reason": "Need to create file in /tmp which is outside workspace sandbox",
"risk": null,
"threadId": "019a93e8-0a52-7fe3-9808-b6bc40c0989a",
"turnId": "1"
}
}
```
```
{
"id": 0,
"result": {
"acceptSettings": {
"forSession": false
},
"decision": "accept"
}
}
```
```
{
"params": {
"item": {
"aggregatedOutput": null,
"command": "/bin/zsh -lc 'touch /tmp/should-trigger-approval'",
"cwd": "/Users/owen/repos/codex/codex-rs",
"durationMs": 224,
"exitCode": 0,
"id": "call_lNWWsbXl1e47qNaYjFRs0dyU",
"parsedCmd": [
{
"cmd": "touch /tmp/should-trigger-approval",
"type": "unknown"
}
],
"status": "completed",
"type": "commandExecution"
}
}
}
```
This commit is contained in:
@@ -474,6 +474,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
|
||||
// Trigger an exec approval request with a short, single-line command
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-short".into(),
|
||||
turn_id: "turn-short".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: Some(
|
||||
@@ -517,6 +518,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
// Multiline command: modal should show full command, history records decision only
|
||||
let ev_multi = ExecApprovalRequestEvent {
|
||||
call_id: "call-multi".into(),
|
||||
turn_id: "turn-multi".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: Some(
|
||||
@@ -568,6 +570,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
let long = format!("echo {}", "a".repeat(200));
|
||||
let ev_long = ExecApprovalRequestEvent {
|
||||
call_id: "call-long".into(),
|
||||
turn_id: "turn-long".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), long],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: None,
|
||||
@@ -599,38 +602,64 @@ fn begin_exec_with_source(
|
||||
call_id: &str,
|
||||
raw_cmd: &str,
|
||||
source: ExecCommandSource,
|
||||
) {
|
||||
) -> ExecCommandBeginEvent {
|
||||
// Build the full command vec and parse it using core's parser,
|
||||
// then convert to protocol variants for the event payload.
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
|
||||
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command);
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let interaction_input = None;
|
||||
let event = ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
command,
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input: None,
|
||||
}),
|
||||
msg: EventMsg::ExecCommandBegin(event.clone()),
|
||||
});
|
||||
event
|
||||
}
|
||||
|
||||
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
|
||||
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent);
|
||||
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent {
|
||||
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent)
|
||||
}
|
||||
|
||||
fn end_exec(chat: &mut ChatWidget, call_id: &str, stdout: &str, stderr: &str, exit_code: i32) {
|
||||
fn end_exec(
|
||||
chat: &mut ChatWidget,
|
||||
begin_event: ExecCommandBeginEvent,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: i32,
|
||||
) {
|
||||
let aggregated = if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}{stderr}")
|
||||
};
|
||||
let ExecCommandBeginEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
} = begin_event;
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
id: call_id.clone(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
call_id,
|
||||
turn_id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
aggregated_output: aggregated.clone(),
|
||||
@@ -804,13 +833,13 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
begin_exec(&mut chat, "call-1", "echo done");
|
||||
let begin = begin_exec(&mut chat, "call-1", "echo done");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet");
|
||||
|
||||
// End command successfully
|
||||
end_exec(&mut chat, "call-1", "done", "", 0);
|
||||
end_exec(&mut chat, begin, "done", "", 0);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
// Exec end now finalizes and flushes the exec cell immediately.
|
||||
@@ -834,12 +863,12 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
begin_exec(&mut chat, "call-2", "false");
|
||||
let begin = begin_exec(&mut chat, "call-2", "false");
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet");
|
||||
|
||||
// End command with failure
|
||||
end_exec(&mut chat, "call-2", "", "Bloop", 2);
|
||||
end_exec(&mut chat, begin, "", "Bloop", 2);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
// Exec end with failure should also flush immediately.
|
||||
@@ -857,7 +886,7 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
fn exec_history_shows_unified_exec_startup_commands() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
begin_exec_with_source(
|
||||
let begin = begin_exec_with_source(
|
||||
&mut chat,
|
||||
"call-startup",
|
||||
"echo unified exec startup",
|
||||
@@ -868,13 +897,7 @@ fn exec_history_shows_unified_exec_startup_commands() {
|
||||
"exec begin should not flush until completion"
|
||||
);
|
||||
|
||||
end_exec(
|
||||
&mut chat,
|
||||
"call-startup",
|
||||
"echo unified exec startup\n",
|
||||
"",
|
||||
0,
|
||||
);
|
||||
end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
||||
@@ -1589,29 +1612,29 @@ fn exec_history_extends_previous_when_consecutive() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// 1) Start "ls -la" (List)
|
||||
begin_exec(&mut chat, "call-ls", "ls -la");
|
||||
let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la");
|
||||
assert_snapshot!("exploring_step1_start_ls", active_blob(&chat));
|
||||
|
||||
// 2) Finish "ls -la"
|
||||
end_exec(&mut chat, "call-ls", "", "", 0);
|
||||
end_exec(&mut chat, begin_ls, "", "", 0);
|
||||
assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat));
|
||||
|
||||
// 3) Start "cat foo.txt" (Read)
|
||||
begin_exec(&mut chat, "call-cat-foo", "cat foo.txt");
|
||||
let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt");
|
||||
assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat));
|
||||
|
||||
// 4) Complete "cat foo.txt"
|
||||
end_exec(&mut chat, "call-cat-foo", "hello from foo", "", 0);
|
||||
end_exec(&mut chat, begin_cat_foo, "hello from foo", "", 0);
|
||||
assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat));
|
||||
|
||||
// 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt)
|
||||
begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt");
|
||||
end_exec(&mut chat, "call-sed-range", "chunk", "", 0);
|
||||
let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt");
|
||||
end_exec(&mut chat, begin_sed_range, "chunk", "", 0);
|
||||
assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat));
|
||||
|
||||
// 6) Start & complete "cat bar.txt"
|
||||
begin_exec(&mut chat, "call-cat-bar", "cat bar.txt");
|
||||
end_exec(&mut chat, "call-cat-bar", "hello from bar", "", 0);
|
||||
let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt");
|
||||
end_exec(&mut chat, begin_cat_bar, "hello from bar", "", 0);
|
||||
assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat));
|
||||
}
|
||||
|
||||
@@ -1648,6 +1671,7 @@ fn approval_modal_exec_snapshot() {
|
||||
// Inject an exec approval request to display the approval modal.
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd".into(),
|
||||
turn_id: "turn-approve-cmd".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: Some(
|
||||
@@ -1695,6 +1719,7 @@ fn approval_modal_exec_without_reason_snapshot() {
|
||||
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd-noreason".into(),
|
||||
turn_id: "turn-approve-cmd-noreason".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: None,
|
||||
@@ -1904,6 +1929,7 @@ fn status_widget_and_approval_modal_snapshot() {
|
||||
// Now show an approval modal (e.g. exec approval).
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-exec".into(),
|
||||
turn_id: "turn-approve-exec".into(),
|
||||
command: vec!["echo".into(), "hello world".into()],
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
reason: Some(
|
||||
@@ -2605,24 +2631,28 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }),
|
||||
});
|
||||
|
||||
let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()];
|
||||
let parsed_cmd = vec![
|
||||
ParsedCommand::Search {
|
||||
query: Some("Change Approved".into()),
|
||||
path: None,
|
||||
cmd: "rg \"Change Approved\"".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "diff_render.rs".into(),
|
||||
cmd: "cat diff_render.rs".into(),
|
||||
path: "diff_render.rs".into(),
|
||||
},
|
||||
];
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
chat.handle_codex_event(Event {
|
||||
id: "c1".into(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "c1".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
parsed_cmd: vec![
|
||||
ParsedCommand::Search {
|
||||
query: Some("Change Approved".into()),
|
||||
path: None,
|
||||
cmd: "rg \"Change Approved\"".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "diff_render.rs".into(),
|
||||
cmd: "cat diff_render.rs".into(),
|
||||
path: "diff_render.rs".into(),
|
||||
},
|
||||
],
|
||||
turn_id: "turn-1".into(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
}),
|
||||
@@ -2631,6 +2661,12 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
id: "c1".into(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: String::new(),
|
||||
|
||||
Reference in New Issue
Block a user