mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
**Summary**
This PR makes “ApprovalDecision::AcceptForSession / don’t ask again this
session” actually work for `apply_patch` approvals by caching approvals
based on absolute file paths in codex-core, properly wiring it through
app-server v2, and exposing the choice in both TUI and TUI2.
- This brings `apply_patch` calls to be at feature-parity with general
shell commands, which also have a "Yes, and don't ask again" option.
- This also fixes VSCE's "Allow this session" button to actually work.
While we're at it, also split the app-server v2 protocol's
`ApprovalDecision` enum so execpolicy amendments are only available for
command execution approvals.
**Key changes**
- Core: per-session patch approval allowlist keyed by absolute file
paths
- Handles multi-file patches and renames/moves by recording both source
and destination paths for `Update { move_path: Some(...) }`.
- Extend the `Approvable` trait and `ApplyPatchRuntime` to work with
multiple keys, because an `apply_patch` tool call can modify multiple
files. For a request to be auto-approved, we will need to check that all
file paths have been approved previously.
- App-server v2: honor AcceptForSession for file changes
- File-change approval responses now map AcceptForSession to
ReviewDecision::ApprovedForSession (no longer downgraded to plain
Approved).
- Replace `ApprovalDecision` with two enums:
`CommandExecutionApprovalDecision` and `FileChangeApprovalDecision`
- TUI / TUI2: expose “don’t ask again for these files this session”
- Patch approval overlays now include a third option (“Yes, and don’t
ask again for these files this session (s)”).
- Snapshot updates for the approval modal.
**Tests added/updated**
- Core:
- Integration test that proves ApprovedForSession on a patch skips the
next patch prompt for the same file
- App-server:
- v2 integration test verifying
FileChangeApprovalDecision::AcceptForSession works properly
**User-visible behavior**
- When the user approves a patch “for session”, future patches touching
only those previously approved file(s) will no longer prompt gain during
that session (both via app-server v2 and TUI/TUI2).
**Manual testing**
Tested both TUI and TUI2 - see screenshots below.
TUI:
<img width="1082" height="355" alt="image"
src="https://github.com/user-attachments/assets/adcf45ad-d428-498d-92fc-1a0a420878d9"
/>
TUI2:
<img width="1089" height="438" alt="image"
src="https://github.com/user-attachments/assets/dd768b1a-2f5f-4bd6-98fd-e52c1d3abd9e"
/>
127 lines
4.4 KiB
Rust
127 lines
4.4 KiB
Rust
use crate::codex::TurnContext;
|
|
use crate::function_tool::FunctionCallError;
|
|
use crate::protocol::FileChange;
|
|
use crate::safety::SafetyCheck;
|
|
use crate::safety::assess_patch_safety;
|
|
use crate::tools::sandboxing::ExecApprovalRequirement;
|
|
use codex_apply_patch::ApplyPatchAction;
|
|
use codex_apply_patch::ApplyPatchFileChange;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
pub const CODEX_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch";
|
|
|
|
pub(crate) enum InternalApplyPatchInvocation {
|
|
/// The `apply_patch` call was handled programmatically, without any sort
|
|
/// of sandbox, because the user explicitly approved it. This is the
|
|
/// result to use with the `shell` function call that contained `apply_patch`.
|
|
Output(Result<String, FunctionCallError>),
|
|
|
|
/// The `apply_patch` call was approved, either automatically because it
|
|
/// appears that it should be allowed based on the user's sandbox policy
|
|
/// *or* because the user explicitly approved it. In either case, we use
|
|
/// exec with [`CODEX_APPLY_PATCH_ARG1`] to realize the `apply_patch` call,
|
|
/// but [`ApplyPatchExec::auto_approved`] is used to determine the sandbox
|
|
/// used with the `exec()`.
|
|
DelegateToExec(ApplyPatchExec),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ApplyPatchExec {
|
|
pub(crate) action: ApplyPatchAction,
|
|
pub(crate) auto_approved: bool,
|
|
pub(crate) exec_approval_requirement: ExecApprovalRequirement,
|
|
}
|
|
|
|
pub(crate) async fn apply_patch(
|
|
turn_context: &TurnContext,
|
|
action: ApplyPatchAction,
|
|
) -> InternalApplyPatchInvocation {
|
|
match assess_patch_safety(
|
|
&action,
|
|
turn_context.approval_policy,
|
|
&turn_context.sandbox_policy,
|
|
&turn_context.cwd,
|
|
) {
|
|
SafetyCheck::AutoApprove {
|
|
user_explicitly_approved,
|
|
..
|
|
} => InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
|
|
action,
|
|
auto_approved: !user_explicitly_approved,
|
|
exec_approval_requirement: ExecApprovalRequirement::Skip {
|
|
bypass_sandbox: false,
|
|
proposed_execpolicy_amendment: None,
|
|
},
|
|
}),
|
|
SafetyCheck::AskUser => {
|
|
// Delegate the approval prompt (including cached approvals) to the
|
|
// tool runtime, consistent with how shell/unified_exec approvals
|
|
// are orchestrator-driven.
|
|
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
|
|
action,
|
|
auto_approved: false,
|
|
exec_approval_requirement: ExecApprovalRequirement::NeedsApproval {
|
|
reason: None,
|
|
proposed_execpolicy_amendment: None,
|
|
},
|
|
})
|
|
}
|
|
SafetyCheck::Reject { reason } => InternalApplyPatchInvocation::Output(Err(
|
|
FunctionCallError::RespondToModel(format!("patch rejected: {reason}")),
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn convert_apply_patch_to_protocol(
|
|
action: &ApplyPatchAction,
|
|
) -> HashMap<PathBuf, FileChange> {
|
|
let changes = action.changes();
|
|
let mut result = HashMap::with_capacity(changes.len());
|
|
for (path, change) in changes {
|
|
let protocol_change = match change {
|
|
ApplyPatchFileChange::Add { content } => FileChange::Add {
|
|
content: content.clone(),
|
|
},
|
|
ApplyPatchFileChange::Delete { content } => FileChange::Delete {
|
|
content: content.clone(),
|
|
},
|
|
ApplyPatchFileChange::Update {
|
|
unified_diff,
|
|
move_path,
|
|
new_content: _new_content,
|
|
} => FileChange::Update {
|
|
unified_diff: unified_diff.clone(),
|
|
move_path: move_path.clone(),
|
|
},
|
|
};
|
|
result.insert(path.clone(), protocol_change);
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn convert_apply_patch_maps_add_variant() {
|
|
let tmp = tempdir().expect("tmp");
|
|
let p = tmp.path().join("a.txt");
|
|
// Create an action with a single Add change
|
|
let action = ApplyPatchAction::new_add_for_test(&p, "hello".to_string());
|
|
|
|
let got = convert_apply_patch_to_protocol(&action);
|
|
|
|
assert_eq!(
|
|
got.get(&p),
|
|
Some(&FileChange::Add {
|
|
content: "hello".to_string()
|
|
})
|
|
);
|
|
}
|
|
}
|