From ee40dfd92aef2e23dd53eab7184d53b64f53cc7a Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 15 Dec 2025 20:19:33 -0800 Subject: [PATCH 01/11] chore(apply-patch) move invocation parsing lib.rs has grown quite large, and mixes two responsibilities: 1. executing patch operations 2. parsing apply_patch invocations via a shell command This PR splits out (2) into its own file, so we can work with it more easily. We are explicitly NOT moving tests in this PR, to ensure behavior stays the same and we can avoid losing coverage via merge conflicts. Tests are moved in a subsequent PR. --- codex-rs/apply-patch/src/invocation.rs | 369 +++++++++++++++++++++++++ codex-rs/apply-patch/src/lib.rs | 360 +----------------------- 2 files changed, 376 insertions(+), 353 deletions(-) create mode 100644 codex-rs/apply-patch/src/invocation.rs diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs new file mode 100644 index 0000000000..17875de81f --- /dev/null +++ b/codex-rs/apply-patch/src/invocation.rs @@ -0,0 +1,369 @@ +use std::collections::HashMap; +use std::path::Path; +use std::sync::LazyLock; + +use tree_sitter::Parser; +use tree_sitter::Query; +use tree_sitter::QueryCursor; +use tree_sitter::StreamingIterator; +use tree_sitter_bash::LANGUAGE as BASH; + +use crate::ApplyPatchAction; +use crate::ApplyPatchArgs; +use crate::ApplyPatchError; +use crate::ApplyPatchFileChange; +use crate::ApplyPatchFileUpdate; +use crate::IoError; +use crate::MaybeApplyPatchVerified; +use crate::parser::Hunk; +use crate::parser::ParseError; +use crate::parser::parse_patch; +use crate::unified_diff_from_chunks; +use std::str::Utf8Error; +use tree_sitter::LanguageError; + +const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ApplyPatchShell { + Unix, + PowerShell, + Cmd, +} + +#[derive(Debug, PartialEq)] +pub enum MaybeApplyPatch { + Body(ApplyPatchArgs), + ShellParseError(ExtractHeredocError), + PatchParseError(ParseError), + NotApplyPatch, +} + +#[derive(Debug, PartialEq)] +pub enum ExtractHeredocError { + CommandDidNotStartWithApplyPatch, + FailedToLoadBashGrammar(LanguageError), + HeredocNotUtf8(Utf8Error), + FailedToParsePatchIntoAst, + FailedToFindHeredocBody, +} + +fn classify_shell_name(shell: &str) -> Option { + std::path::Path::new(shell) + .file_stem() + .and_then(|name| name.to_str()) + .map(str::to_ascii_lowercase) +} + +fn classify_shell(shell: &str, flag: &str) -> Option { + classify_shell_name(shell).and_then(|name| match name.as_str() { + "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), + "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { + Some(ApplyPatchShell::PowerShell) + } + "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd), + _ => None, + }) +} + +fn can_skip_flag(shell: &str, flag: &str) -> bool { + classify_shell_name(shell).is_some_and(|name| { + matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile") + }) +} + +fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> { + match argv { + [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| { + let script = script.as_str(); + (shell_type, script) + }), + [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => { + classify_shell(shell, flag).map(|shell_type| { + let script = script.as_str(); + (shell_type, script) + }) + } + _ => None, + } +} + +fn extract_apply_patch_from_shell( + shell: ApplyPatchShell, + script: &str, +) -> std::result::Result<(String, Option), ExtractHeredocError> { + match shell { + ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => { + extract_apply_patch_from_bash(script) + } + } +} + +// TODO: make private once we remove tests in lib.rs +pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { + match argv { + // Direct invocation: apply_patch + [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { + Ok(source) => MaybeApplyPatch::Body(source), + Err(e) => MaybeApplyPatch::PatchParseError(e), + }, + // Shell heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... + _ => match parse_shell_script(argv) { + Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) { + Ok((body, workdir)) => match parse_patch(&body) { + Ok(mut source) => { + source.workdir = workdir; + MaybeApplyPatch::Body(source) + } + Err(e) => MaybeApplyPatch::PatchParseError(e), + }, + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => { + MaybeApplyPatch::NotApplyPatch + } + Err(e) => MaybeApplyPatch::ShellParseError(e), + }, + None => MaybeApplyPatch::NotApplyPatch, + }, + } +} + +/// cwd must be an absolute path so that we can resolve relative paths in the +/// patch. +pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified { + // Detect a raw patch body passed directly as the command or as the body of a shell + // script. In these cases, report an explicit error rather than applying the patch. + if let [body] = argv + && parse_patch(body).is_ok() + { + return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); + } + if let Some((_, script)) = parse_shell_script(argv) + && parse_patch(script).is_ok() + { + return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); + } + + match maybe_parse_apply_patch(argv) { + MaybeApplyPatch::Body(ApplyPatchArgs { + patch, + hunks, + workdir, + }) => { + let effective_cwd = workdir + .as_ref() + .map(|dir| { + let path = Path::new(dir); + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } + }) + .unwrap_or_else(|| cwd.to_path_buf()); + let mut changes = HashMap::new(); + for hunk in hunks { + let path = hunk.resolve_path(&effective_cwd); + match hunk { + Hunk::AddFile { contents, .. } => { + changes.insert(path, ApplyPatchFileChange::Add { content: contents }); + } + Hunk::DeleteFile { .. } => { + let content = match std::fs::read_to_string(&path) { + Ok(content) => content, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError( + ApplyPatchError::IoError(IoError { + context: format!("Failed to read {}", path.display()), + source: e, + }), + ); + } + }; + changes.insert(path, ApplyPatchFileChange::Delete { content }); + } + Hunk::UpdateFile { + move_path, chunks, .. + } => { + let ApplyPatchFileUpdate { + unified_diff, + content: contents, + } = match unified_diff_from_chunks(&path, &chunks) { + Ok(diff) => diff, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError(e); + } + }; + changes.insert( + path, + ApplyPatchFileChange::Update { + unified_diff, + move_path: move_path.map(|p| effective_cwd.join(p)), + new_content: contents, + }, + ); + } + } + } + MaybeApplyPatchVerified::Body(ApplyPatchAction { + changes, + patch, + cwd: effective_cwd, + }) + } + MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), + MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), + MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch, + } +} + +/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script +/// that invokes the apply_patch tool using a heredoc. +/// +/// Supported top‑level forms (must be the only top‑level statement): +/// - `apply_patch <<'EOF'\n...\nEOF` +/// - `cd && apply_patch <<'EOF'\n...\nEOF` +/// +/// Notes about matching: +/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the +/// heredoc‑redirected statement is the only top‑level statement. +/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`). +/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted +/// strings, no second argument). +/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch` +/// or `applypatch`. +/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match. +/// +/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or +/// `(heredoc_body, None)` for the direct form. Errors are returned if the script +/// cannot be parsed or does not match the allowed patterns. +fn extract_apply_patch_from_bash( + src: &str, +) -> std::result::Result<(String, Option), ExtractHeredocError> { + // This function uses a Tree-sitter query to recognize one of two + // whole-script forms, each expressed as a single top-level statement: + // + // 1. apply_patch <<'EOF'\n...\nEOF + // 2. cd && apply_patch <<'EOF'\n...\nEOF + // + // Key ideas when reading the query: + // - dots (`.`) between named nodes enforces adjacency among named children and + // anchor to the start/end of the expression. + // - we match a single redirected_statement directly under program with leading + // and trailing anchors (`.`). This ensures it is the only top-level statement + // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match). + // + // Overall, we want to be conservative and only match the intended forms, as other + // forms are likely to be model errors, or incorrectly interpreted by later code. + // + // If you're editing this query, it's helpful to start by creating a debugging binary + // which will let you see the AST of an arbitrary bash script passed in, and optionally + // also run an arbitrary query against the AST. This is useful for understanding + // how tree-sitter parses the script and whether the query syntax is correct. Be sure + // to test both positive and negative cases. + static APPLY_PATCH_QUERY: LazyLock = LazyLock::new(|| { + let language = BASH.into(); + #[expect(clippy::expect_used)] + Query::new( + &language, + r#" + ( + program + . (redirected_statement + body: (command + name: (command_name (word) @apply_name) .) + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + + ( + program + . (redirected_statement + body: (list + . (command + name: (command_name (word) @cd_name) . + argument: [ + (word) @cd_path + (string (string_content) @cd_path) + (raw_string) @cd_raw_string + ] .) + "&&" + . (command + name: (command_name (word) @apply_name)) + .) + (#eq? @cd_name "cd") + (#any-of? @apply_name "apply_patch" "applypatch") + redirect: (heredoc_redirect + . (heredoc_start) + . (heredoc_body) @heredoc + . (heredoc_end) + .)) + .) + "#, + ) + .expect("valid bash query") + }); + + let lang = BASH.into(); + let mut parser = Parser::new(); + parser + .set_language(&lang) + .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?; + let tree = parser + .parse(src, None) + .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?; + + let bytes = src.as_bytes(); + let root = tree.root_node(); + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes); + while let Some(m) = matches.next() { + let mut heredoc_text: Option = None; + let mut cd_path: Option = None; + + for capture in m.captures.iter() { + let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize]; + match name { + "heredoc" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .trim_end_matches('\n') + .to_string(); + heredoc_text = Some(text); + } + "cd_path" => { + let text = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)? + .to_string(); + cd_path = Some(text); + } + "cd_raw_string" => { + let raw = capture + .node + .utf8_text(bytes) + .map_err(ExtractHeredocError::HeredocNotUtf8)?; + let trimmed = raw + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(raw); + cd_path = Some(trimmed.to_string()); + } + _ => {} + } + } + + if let Some(heredoc) = heredoc_text { + return Ok((heredoc, cd_path)); + } + } + + Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) +} diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 645f396845..051533f370 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -1,3 +1,4 @@ +mod invocation; mod parser; mod seek_sequence; mod standalone_executable; @@ -5,8 +6,6 @@ mod standalone_executable; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use std::str::Utf8Error; -use std::sync::LazyLock; use anyhow::Context; use anyhow::Result; @@ -17,27 +16,15 @@ use parser::UpdateFileChunk; pub use parser::parse_patch; use similar::TextDiff; use thiserror::Error; -use tree_sitter::LanguageError; -use tree_sitter::Parser; -use tree_sitter::Query; -use tree_sitter::QueryCursor; -use tree_sitter::StreamingIterator; -use tree_sitter_bash::LANGUAGE as BASH; +pub use invocation::maybe_parse_apply_patch_verified; pub use standalone_executable::main; +use crate::invocation::ExtractHeredocError; + /// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); -const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ApplyPatchShell { - Unix, - PowerShell, - Cmd, -} - #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { #[error(transparent)] @@ -86,14 +73,6 @@ impl PartialEq for IoError { } } -#[derive(Debug, PartialEq)] -pub enum MaybeApplyPatch { - Body(ApplyPatchArgs), - ShellParseError(ExtractHeredocError), - PatchParseError(ParseError), - NotApplyPatch, -} - /// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument /// parsed into hunks. #[derive(Debug, PartialEq)] @@ -103,84 +82,6 @@ pub struct ApplyPatchArgs { pub workdir: Option, } -fn classify_shell_name(shell: &str) -> Option { - std::path::Path::new(shell) - .file_stem() - .and_then(|name| name.to_str()) - .map(str::to_ascii_lowercase) -} - -fn classify_shell(shell: &str, flag: &str) -> Option { - classify_shell_name(shell).and_then(|name| match name.as_str() { - "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), - "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { - Some(ApplyPatchShell::PowerShell) - } - "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd), - _ => None, - }) -} - -fn can_skip_flag(shell: &str, flag: &str) -> bool { - classify_shell_name(shell).is_some_and(|name| { - matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile") - }) -} - -fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> { - match argv { - [shell, flag, script] => classify_shell(shell, flag).map(|shell_type| { - let script = script.as_str(); - (shell_type, script) - }), - [shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => { - classify_shell(shell, flag).map(|shell_type| { - let script = script.as_str(); - (shell_type, script) - }) - } - _ => None, - } -} - -fn extract_apply_patch_from_shell( - shell: ApplyPatchShell, - script: &str, -) -> std::result::Result<(String, Option), ExtractHeredocError> { - match shell { - ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => { - extract_apply_patch_from_bash(script) - } - } -} - -pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { - match argv { - // Direct invocation: apply_patch - [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { - Ok(source) => MaybeApplyPatch::Body(source), - Err(e) => MaybeApplyPatch::PatchParseError(e), - }, - // Shell heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... - _ => match parse_shell_script(argv) { - Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) { - Ok((body, workdir)) => match parse_patch(&body) { - Ok(mut source) => { - source.workdir = workdir; - MaybeApplyPatch::Body(source) - } - Err(e) => MaybeApplyPatch::PatchParseError(e), - }, - Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => { - MaybeApplyPatch::NotApplyPatch - } - Err(e) => MaybeApplyPatch::ShellParseError(e), - }, - None => MaybeApplyPatch::NotApplyPatch, - }, - } -} - #[derive(Debug, PartialEq)] pub enum ApplyPatchFileChange { Add { @@ -269,256 +170,6 @@ impl ApplyPatchAction { } } -/// cwd must be an absolute path so that we can resolve relative paths in the -/// patch. -pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified { - // Detect a raw patch body passed directly as the command or as the body of a shell - // script. In these cases, report an explicit error rather than applying the patch. - if let [body] = argv - && parse_patch(body).is_ok() - { - return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); - } - if let Some((_, script)) = parse_shell_script(argv) - && parse_patch(script).is_ok() - { - return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); - } - - match maybe_parse_apply_patch(argv) { - MaybeApplyPatch::Body(ApplyPatchArgs { - patch, - hunks, - workdir, - }) => { - let effective_cwd = workdir - .as_ref() - .map(|dir| { - let path = Path::new(dir); - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } - }) - .unwrap_or_else(|| cwd.to_path_buf()); - let mut changes = HashMap::new(); - for hunk in hunks { - let path = hunk.resolve_path(&effective_cwd); - match hunk { - Hunk::AddFile { contents, .. } => { - changes.insert(path, ApplyPatchFileChange::Add { content: contents }); - } - Hunk::DeleteFile { .. } => { - let content = match std::fs::read_to_string(&path) { - Ok(content) => content, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::IoError(IoError { - context: format!("Failed to read {}", path.display()), - source: e, - }), - ); - } - }; - changes.insert(path, ApplyPatchFileChange::Delete { content }); - } - Hunk::UpdateFile { - move_path, chunks, .. - } => { - let ApplyPatchFileUpdate { - unified_diff, - content: contents, - } = match unified_diff_from_chunks(&path, &chunks) { - Ok(diff) => diff, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError(e); - } - }; - changes.insert( - path, - ApplyPatchFileChange::Update { - unified_diff, - move_path: move_path.map(|p| effective_cwd.join(p)), - new_content: contents, - }, - ); - } - } - } - MaybeApplyPatchVerified::Body(ApplyPatchAction { - changes, - patch, - cwd: effective_cwd, - }) - } - MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), - MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), - MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch, - } -} - -/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script -/// that invokes the apply_patch tool using a heredoc. -/// -/// Supported top‑level forms (must be the only top‑level statement): -/// - `apply_patch <<'EOF'\n...\nEOF` -/// - `cd && apply_patch <<'EOF'\n...\nEOF` -/// -/// Notes about matching: -/// - Parsed with Tree‑sitter Bash and a strict query that uses anchors so the -/// heredoc‑redirected statement is the only top‑level statement. -/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`). -/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted -/// strings, no second argument). -/// - The apply command is validated in‑query via `#any-of?` to allow `apply_patch` -/// or `applypatch`. -/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match. -/// -/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or -/// `(heredoc_body, None)` for the direct form. Errors are returned if the script -/// cannot be parsed or does not match the allowed patterns. -fn extract_apply_patch_from_bash( - src: &str, -) -> std::result::Result<(String, Option), ExtractHeredocError> { - // This function uses a Tree-sitter query to recognize one of two - // whole-script forms, each expressed as a single top-level statement: - // - // 1. apply_patch <<'EOF'\n...\nEOF - // 2. cd && apply_patch <<'EOF'\n...\nEOF - // - // Key ideas when reading the query: - // - dots (`.`) between named nodes enforces adjacency among named children and - // anchor to the start/end of the expression. - // - we match a single redirected_statement directly under program with leading - // and trailing anchors (`.`). This ensures it is the only top-level statement - // (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match). - // - // Overall, we want to be conservative and only match the intended forms, as other - // forms are likely to be model errors, or incorrectly interpreted by later code. - // - // If you're editing this query, it's helpful to start by creating a debugging binary - // which will let you see the AST of an arbitrary bash script passed in, and optionally - // also run an arbitrary query against the AST. This is useful for understanding - // how tree-sitter parses the script and whether the query syntax is correct. Be sure - // to test both positive and negative cases. - static APPLY_PATCH_QUERY: LazyLock = LazyLock::new(|| { - let language = BASH.into(); - #[expect(clippy::expect_used)] - Query::new( - &language, - r#" - ( - program - . (redirected_statement - body: (command - name: (command_name (word) @apply_name) .) - (#any-of? @apply_name "apply_patch" "applypatch") - redirect: (heredoc_redirect - . (heredoc_start) - . (heredoc_body) @heredoc - . (heredoc_end) - .)) - .) - - ( - program - . (redirected_statement - body: (list - . (command - name: (command_name (word) @cd_name) . - argument: [ - (word) @cd_path - (string (string_content) @cd_path) - (raw_string) @cd_raw_string - ] .) - "&&" - . (command - name: (command_name (word) @apply_name)) - .) - (#eq? @cd_name "cd") - (#any-of? @apply_name "apply_patch" "applypatch") - redirect: (heredoc_redirect - . (heredoc_start) - . (heredoc_body) @heredoc - . (heredoc_end) - .)) - .) - "#, - ) - .expect("valid bash query") - }); - - let lang = BASH.into(); - let mut parser = Parser::new(); - parser - .set_language(&lang) - .map_err(ExtractHeredocError::FailedToLoadBashGrammar)?; - let tree = parser - .parse(src, None) - .ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?; - - let bytes = src.as_bytes(); - let root = tree.root_node(); - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes); - while let Some(m) = matches.next() { - let mut heredoc_text: Option = None; - let mut cd_path: Option = None; - - for capture in m.captures.iter() { - let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize]; - match name { - "heredoc" => { - let text = capture - .node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)? - .trim_end_matches('\n') - .to_string(); - heredoc_text = Some(text); - } - "cd_path" => { - let text = capture - .node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)? - .to_string(); - cd_path = Some(text); - } - "cd_raw_string" => { - let raw = capture - .node - .utf8_text(bytes) - .map_err(ExtractHeredocError::HeredocNotUtf8)?; - let trimmed = raw - .strip_prefix('\'') - .and_then(|s| s.strip_suffix('\'')) - .unwrap_or(raw); - cd_path = Some(trimmed.to_string()); - } - _ => {} - } - } - - if let Some(heredoc) = heredoc_text { - return Ok((heredoc, cd_path)); - } - } - - Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) -} - -#[derive(Debug, PartialEq)] -pub enum ExtractHeredocError { - CommandDidNotStartWithApplyPatch, - FailedToLoadBashGrammar(LanguageError), - HeredocNotUtf8(Utf8Error), - FailedToParsePatchIntoAst, - FailedToFindHeredocBody, -} - /// Applies the patch and prints the result to stdout/stderr. pub fn apply_patch( patch: &str, @@ -893,6 +544,9 @@ pub fn print_summary( #[cfg(test)] mod tests { + use crate::invocation::MaybeApplyPatch; + use crate::invocation::maybe_parse_apply_patch; + use super::*; use assert_matches::assert_matches; use pretty_assertions::assert_eq; From e83f9ca0a5b6370bc8defa844b92d9efcc0d4494 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 16 Dec 2025 09:16:16 -0800 Subject: [PATCH 02/11] chore(apply-patch) move invocation tests ## Summary: This PR is a pure copy and paste of tests from lib.rs into invocation.rs, to colocate logic and tests. ## Testing - [x] Purely a test refactor --- codex-rs/apply-patch/src/invocation.rs | 444 +++++++++++++++++++++++++ codex-rs/apply-patch/src/lib.rs | 361 -------------------- 2 files changed, 444 insertions(+), 361 deletions(-) diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 17875de81f..7623aef80d 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -367,3 +367,447 @@ fn extract_apply_patch_from_bash( Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::PathBuf; + use std::string::ToString; + use tempfile::tempdir; + + /// Helper to construct a patch with the given body. + fn wrap_patch(body: &str) -> String { + format!("*** Begin Patch\n{body}\n*** End Patch") + } + + fn strs_to_strings(strs: &[&str]) -> Vec { + strs.iter().map(ToString::to_string).collect() + } + + // Test helpers to reduce repetition when building bash -lc heredoc scripts + fn args_bash(script: &str) -> Vec { + strs_to_strings(&["bash", "-lc", script]) + } + + fn args_powershell(script: &str) -> Vec { + strs_to_strings(&["powershell.exe", "-Command", script]) + } + + fn args_powershell_no_profile(script: &str) -> Vec { + strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script]) + } + + fn args_pwsh(script: &str) -> Vec { + strs_to_strings(&["pwsh", "-NoProfile", "-Command", script]) + } + + fn args_cmd(script: &str) -> Vec { + strs_to_strings(&["cmd.exe", "/c", script]) + } + + fn heredoc_script(prefix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" + ) + } + + fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" + ) + } + + fn expected_single_add() -> Vec { + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string(), + }] + } + + fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir.as_deref(), expected_workdir); + assert_eq!(hunks, expected_single_add()); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + fn assert_match(script: &str, expected_workdir: Option<&str>) { + let args = args_bash(script); + assert_match_args(args, expected_workdir); + } + + fn assert_not_match(script: &str) { + let args = args_bash(script); + assert_matches!( + maybe_parse_apply_patch(&args), + MaybeApplyPatch::NotApplyPatch + ); + } + + #[test] + fn test_implicit_patch_single_arg_is_error() { + let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); + let args = vec![patch]; + let dir = tempdir().unwrap(); + assert_matches!( + maybe_parse_apply_patch_verified(&args, dir.path()), + MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) + ); + } + + #[test] + fn test_implicit_patch_bash_script_is_error() { + let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; + let args = args_bash(script); + let dir = tempdir().unwrap(); + assert_matches!( + maybe_parse_apply_patch_verified(&args, dir.path()), + MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) + ); + } + + #[test] + fn test_literal() { + let args = strs_to_strings(&[ + "apply_patch", + r#"*** Begin Patch +*** Add File: foo ++hi +*** End Patch +"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[test] + fn test_literal_applypatch() { + let args = strs_to_strings(&[ + "applypatch", + r#"*** Begin Patch +*** Add File: foo ++hi +*** End Patch +"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[test] + fn test_heredoc() { + assert_match(&heredoc_script(""), None); + } + + #[test] + fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, None); + } + + #[test] + fn test_heredoc_applypatch() { + let args = strs_to_strings(&[ + "bash", + "-lc", + r#"applypatch <<'PATCH' +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +PATCH"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir, None); + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[test] + fn test_powershell_heredoc() { + let script = heredoc_script(""); + assert_match_args(args_powershell(&script), None); + } + #[test] + fn test_powershell_heredoc_no_profile() { + let script = heredoc_script(""); + assert_match_args(args_powershell_no_profile(&script), None); + } + #[test] + fn test_pwsh_heredoc() { + let script = heredoc_script(""); + assert_match_args(args_pwsh(&script), None); + } + + #[test] + fn test_cmd_heredoc_with_cd() { + let script = heredoc_script("cd foo && "); + assert_match_args(args_cmd(&script), Some("foo")); + } + + #[test] + fn test_heredoc_with_leading_cd() { + assert_match(&heredoc_script("cd foo && "), Some("foo")); + } + + #[test] + fn test_cd_with_semicolon_is_ignored() { + assert_not_match(&heredoc_script("cd foo; ")); + } + + #[test] + fn test_cd_or_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar || ")); + } + + #[test] + fn test_cd_pipe_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar | ")); + } + + #[test] + fn test_cd_single_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); + } + + #[test] + fn test_cd_double_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); + } + + #[test] + fn test_echo_and_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("echo foo && ")); + } + + #[test] + fn test_apply_patch_with_arg_is_ignored() { + let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; + assert_not_match(script); + } + + #[test] + fn test_double_cd_then_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd foo && cd bar && ")); + } + + #[test] + fn test_cd_two_args_is_ignored() { + assert_not_match(&heredoc_script("cd foo bar && ")); + } + + #[test] + fn test_cd_then_apply_patch_then_extra_is_ignored() { + let script = heredoc_script_ps("cd bar && ", " && echo done"); + assert_not_match(&script); + } + + #[test] + fn test_echo_then_cd_and_apply_patch_is_ignored() { + // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. + assert_not_match(&heredoc_script("echo foo; cd bar && ")); + } + + #[test] + fn test_unified_diff_last_line_replacement() { + // Replace the very last line of the file. + let dir = tempdir().unwrap(); + let path = dir.path().join("last.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo + bar +-baz ++BAZ +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -2,2 +2,2 @@ + bar +-baz ++BAZ +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nbar\nBAZ\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_unified_diff_insert_at_eof() { + // Insert a new line at end‑of‑file. + let dir = tempdir().unwrap(); + let path = dir.path().join("insert.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++quux +*** End of File +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -3 +3,2 @@ + baz ++quux +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nbar\nbaz\nquux\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { + let session_dir = tempdir().unwrap(); + let relative_path = "source.txt"; + + // Note that we need this file to exist for the patch to be "verified" + // and parsed correctly. + let session_file_path = session_dir.path().join(relative_path); + fs::write(&session_file_path, "session directory content\n").unwrap(); + + let argv = vec![ + "apply_patch".to_string(), + r#"*** Begin Patch +*** Update File: source.txt +@@ +-session directory content ++updated session directory content +*** End Patch"# + .to_string(), + ]; + + let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + + // Verify the patch contents - as otherwise we may have pulled contents + // from the wrong file (as we're using relative paths) + assert_eq!( + result, + MaybeApplyPatchVerified::Body(ApplyPatchAction { + changes: HashMap::from([( + session_dir.path().join(relative_path), + ApplyPatchFileChange::Update { + unified_diff: r#"@@ -1 +1 @@ +-session directory content ++updated session directory content +"# + .to_string(), + move_path: None, + new_content: "updated session directory content\n".to_string(), + }, + )]), + patch: argv[1].clone(), + cwd: session_dir.path().to_path_buf(), + }) + ); + } + + #[test] + fn test_apply_patch_resolves_move_path_with_effective_cwd() { + let session_dir = tempdir().unwrap(); + let worktree_rel = "alt"; + let worktree_dir = session_dir.path().join(worktree_rel); + fs::create_dir_all(&worktree_dir).unwrap(); + + let source_name = "old.txt"; + let dest_name = "renamed.txt"; + let source_path = worktree_dir.join(source_name); + fs::write(&source_path, "before\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {source_name} +*** Move to: {dest_name} +@@ +-before ++after"# + )); + + let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); + let argv = vec!["bash".into(), "-lc".into(), shell_script]; + + let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let action = match result { + MaybeApplyPatchVerified::Body(action) => action, + other => panic!("expected verified body, got {other:?}"), + }; + + assert_eq!(action.cwd, worktree_dir); + + let change = action + .changes() + .get(&worktree_dir.join(source_name)) + .expect("source file change present"); + + match change { + ApplyPatchFileChange::Update { move_path, .. } => { + assert_eq!( + move_path.as_deref(), + Some(worktree_dir.join(dest_name).as_path()) + ); + } + other => panic!("expected update change, got {other:?}"), + } + } +} diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 051533f370..f58055f450 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -544,11 +544,7 @@ pub fn print_summary( #[cfg(test)] mod tests { - use crate::invocation::MaybeApplyPatch; - use crate::invocation::maybe_parse_apply_patch; - use super::*; - use assert_matches::assert_matches; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -559,270 +555,6 @@ mod tests { format!("*** Begin Patch\n{body}\n*** End Patch") } - fn strs_to_strings(strs: &[&str]) -> Vec { - strs.iter().map(ToString::to_string).collect() - } - - // Test helpers to reduce repetition when building bash -lc heredoc scripts - fn args_bash(script: &str) -> Vec { - strs_to_strings(&["bash", "-lc", script]) - } - - fn args_powershell(script: &str) -> Vec { - strs_to_strings(&["powershell.exe", "-Command", script]) - } - - fn args_powershell_no_profile(script: &str) -> Vec { - strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script]) - } - - fn args_pwsh(script: &str) -> Vec { - strs_to_strings(&["pwsh", "-NoProfile", "-Command", script]) - } - - fn args_cmd(script: &str) -> Vec { - strs_to_strings(&["cmd.exe", "/c", script]) - } - - fn heredoc_script(prefix: &str) -> String { - format!( - "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" - ) - } - - fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { - format!( - "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" - ) - } - - fn expected_single_add() -> Vec { - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string(), - }] - } - - fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { - assert_eq!(workdir.as_deref(), expected_workdir); - assert_eq!(hunks, expected_single_add()); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - fn assert_match(script: &str, expected_workdir: Option<&str>) { - let args = args_bash(script); - assert_match_args(args, expected_workdir); - } - - fn assert_not_match(script: &str) { - let args = args_bash(script); - assert_matches!( - maybe_parse_apply_patch(&args), - MaybeApplyPatch::NotApplyPatch - ); - } - - #[test] - fn test_implicit_patch_single_arg_is_error() { - let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); - let args = vec![patch]; - let dir = tempdir().unwrap(); - assert_matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), - MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - ); - } - - #[test] - fn test_implicit_patch_bash_script_is_error() { - let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; - let args = args_bash(script); - let dir = tempdir().unwrap(); - assert_matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), - MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - ); - } - - #[test] - fn test_literal() { - let args = strs_to_strings(&[ - "apply_patch", - r#"*** Begin Patch -*** Add File: foo -+hi -*** End Patch -"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_literal_applypatch() { - let args = strs_to_strings(&[ - "applypatch", - r#"*** Begin Patch -*** Add File: foo -+hi -*** End Patch -"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_heredoc() { - assert_match(&heredoc_script(""), None); - } - - #[test] - fn test_heredoc_non_login_shell() { - let script = heredoc_script(""); - let args = strs_to_strings(&["bash", "-c", &script]); - assert_match_args(args, None); - } - - #[test] - fn test_heredoc_applypatch() { - let args = strs_to_strings(&[ - "bash", - "-lc", - r#"applypatch <<'PATCH' -*** Begin Patch -*** Add File: foo -+hi -*** End Patch -PATCH"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { - assert_eq!(workdir, None); - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_powershell_heredoc() { - let script = heredoc_script(""); - assert_match_args(args_powershell(&script), None); - } - #[test] - fn test_powershell_heredoc_no_profile() { - let script = heredoc_script(""); - assert_match_args(args_powershell_no_profile(&script), None); - } - #[test] - fn test_pwsh_heredoc() { - let script = heredoc_script(""); - assert_match_args(args_pwsh(&script), None); - } - - #[test] - fn test_cmd_heredoc_with_cd() { - let script = heredoc_script("cd foo && "); - assert_match_args(args_cmd(&script), Some("foo")); - } - - #[test] - fn test_heredoc_with_leading_cd() { - assert_match(&heredoc_script("cd foo && "), Some("foo")); - } - - #[test] - fn test_cd_with_semicolon_is_ignored() { - assert_not_match(&heredoc_script("cd foo; ")); - } - - #[test] - fn test_cd_or_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd bar || ")); - } - - #[test] - fn test_cd_pipe_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd bar | ")); - } - - #[test] - fn test_cd_single_quoted_path_with_spaces() { - assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); - } - - #[test] - fn test_cd_double_quoted_path_with_spaces() { - assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); - } - - #[test] - fn test_echo_and_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("echo foo && ")); - } - - #[test] - fn test_apply_patch_with_arg_is_ignored() { - let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; - assert_not_match(script); - } - - #[test] - fn test_double_cd_then_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd foo && cd bar && ")); - } - - #[test] - fn test_cd_two_args_is_ignored() { - assert_not_match(&heredoc_script("cd foo bar && ")); - } - - #[test] - fn test_cd_then_apply_patch_then_extra_is_ignored() { - let script = heredoc_script_ps("cd bar && ", " && echo done"); - assert_not_match(&script); - } - - #[test] - fn test_echo_then_cd_and_apply_patch_is_ignored() { - // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. - assert_not_match(&heredoc_script("echo foo; cd bar && ")); - } - #[test] fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); @@ -1311,99 +1043,6 @@ g ); } - #[test] - fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { - let session_dir = tempdir().unwrap(); - let relative_path = "source.txt"; - - // Note that we need this file to exist for the patch to be "verified" - // and parsed correctly. - let session_file_path = session_dir.path().join(relative_path); - fs::write(&session_file_path, "session directory content\n").unwrap(); - - let argv = vec![ - "apply_patch".to_string(), - r#"*** Begin Patch -*** Update File: source.txt -@@ --session directory content -+updated session directory content -*** End Patch"# - .to_string(), - ]; - - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); - - // Verify the patch contents - as otherwise we may have pulled contents - // from the wrong file (as we're using relative paths) - assert_eq!( - result, - MaybeApplyPatchVerified::Body(ApplyPatchAction { - changes: HashMap::from([( - session_dir.path().join(relative_path), - ApplyPatchFileChange::Update { - unified_diff: r#"@@ -1 +1 @@ --session directory content -+updated session directory content -"# - .to_string(), - move_path: None, - new_content: "updated session directory content\n".to_string(), - }, - )]), - patch: argv[1].clone(), - cwd: session_dir.path().to_path_buf(), - }) - ); - } - - #[test] - fn test_apply_patch_resolves_move_path_with_effective_cwd() { - let session_dir = tempdir().unwrap(); - let worktree_rel = "alt"; - let worktree_dir = session_dir.path().join(worktree_rel); - fs::create_dir_all(&worktree_dir).unwrap(); - - let source_name = "old.txt"; - let dest_name = "renamed.txt"; - let source_path = worktree_dir.join(source_name); - fs::write(&source_path, "before\n").unwrap(); - - let patch = wrap_patch(&format!( - r#"*** Update File: {source_name} -*** Move to: {dest_name} -@@ --before -+after"# - )); - - let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); - let argv = vec!["bash".into(), "-lc".into(), shell_script]; - - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); - let action = match result { - MaybeApplyPatchVerified::Body(action) => action, - other => panic!("expected verified body, got {other:?}"), - }; - - assert_eq!(action.cwd, worktree_dir); - - let change = action - .changes() - .get(&worktree_dir.join(source_name)) - .expect("source file change present"); - - match change { - ApplyPatchFileChange::Update { move_path, .. } => { - assert_eq!( - move_path.as_deref(), - Some(worktree_dir.join(dest_name).as_path()) - ); - } - other => panic!("expected update change, got {other:?}"), - } - } - #[test] fn test_apply_patch_fails_on_write_error() { let dir = tempdir().unwrap(); From 89bb2dc95a3141fea4734443139907b15bfcde20 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 16 Dec 2025 10:42:13 -0800 Subject: [PATCH 03/11] chore(apply-patch) move invocation tests ## Summary: This PR is a pure copy and paste of tests from lib.rs into invocation.rs, to colocate logic and tests. ## Testing - [x] Purely a test refactor --- codex-rs/apply-patch/src/invocation.rs | 444 +++++++++++++++++++++++++ codex-rs/apply-patch/src/lib.rs | 361 -------------------- 2 files changed, 444 insertions(+), 361 deletions(-) diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 17875de81f..7623aef80d 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -367,3 +367,447 @@ fn extract_apply_patch_from_bash( Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::PathBuf; + use std::string::ToString; + use tempfile::tempdir; + + /// Helper to construct a patch with the given body. + fn wrap_patch(body: &str) -> String { + format!("*** Begin Patch\n{body}\n*** End Patch") + } + + fn strs_to_strings(strs: &[&str]) -> Vec { + strs.iter().map(ToString::to_string).collect() + } + + // Test helpers to reduce repetition when building bash -lc heredoc scripts + fn args_bash(script: &str) -> Vec { + strs_to_strings(&["bash", "-lc", script]) + } + + fn args_powershell(script: &str) -> Vec { + strs_to_strings(&["powershell.exe", "-Command", script]) + } + + fn args_powershell_no_profile(script: &str) -> Vec { + strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script]) + } + + fn args_pwsh(script: &str) -> Vec { + strs_to_strings(&["pwsh", "-NoProfile", "-Command", script]) + } + + fn args_cmd(script: &str) -> Vec { + strs_to_strings(&["cmd.exe", "/c", script]) + } + + fn heredoc_script(prefix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" + ) + } + + fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { + format!( + "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" + ) + } + + fn expected_single_add() -> Vec { + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string(), + }] + } + + fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir.as_deref(), expected_workdir); + assert_eq!(hunks, expected_single_add()); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + fn assert_match(script: &str, expected_workdir: Option<&str>) { + let args = args_bash(script); + assert_match_args(args, expected_workdir); + } + + fn assert_not_match(script: &str) { + let args = args_bash(script); + assert_matches!( + maybe_parse_apply_patch(&args), + MaybeApplyPatch::NotApplyPatch + ); + } + + #[test] + fn test_implicit_patch_single_arg_is_error() { + let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); + let args = vec![patch]; + let dir = tempdir().unwrap(); + assert_matches!( + maybe_parse_apply_patch_verified(&args, dir.path()), + MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) + ); + } + + #[test] + fn test_implicit_patch_bash_script_is_error() { + let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; + let args = args_bash(script); + let dir = tempdir().unwrap(); + assert_matches!( + maybe_parse_apply_patch_verified(&args, dir.path()), + MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) + ); + } + + #[test] + fn test_literal() { + let args = strs_to_strings(&[ + "apply_patch", + r#"*** Begin Patch +*** Add File: foo ++hi +*** End Patch +"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[test] + fn test_literal_applypatch() { + let args = strs_to_strings(&[ + "applypatch", + r#"*** Begin Patch +*** Add File: foo ++hi +*** End Patch +"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[test] + fn test_heredoc() { + assert_match(&heredoc_script(""), None); + } + + #[test] + fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, None); + } + + #[test] + fn test_heredoc_applypatch() { + let args = strs_to_strings(&[ + "bash", + "-lc", + r#"applypatch <<'PATCH' +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +PATCH"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { + assert_eq!(workdir, None); + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + + #[test] + fn test_powershell_heredoc() { + let script = heredoc_script(""); + assert_match_args(args_powershell(&script), None); + } + #[test] + fn test_powershell_heredoc_no_profile() { + let script = heredoc_script(""); + assert_match_args(args_powershell_no_profile(&script), None); + } + #[test] + fn test_pwsh_heredoc() { + let script = heredoc_script(""); + assert_match_args(args_pwsh(&script), None); + } + + #[test] + fn test_cmd_heredoc_with_cd() { + let script = heredoc_script("cd foo && "); + assert_match_args(args_cmd(&script), Some("foo")); + } + + #[test] + fn test_heredoc_with_leading_cd() { + assert_match(&heredoc_script("cd foo && "), Some("foo")); + } + + #[test] + fn test_cd_with_semicolon_is_ignored() { + assert_not_match(&heredoc_script("cd foo; ")); + } + + #[test] + fn test_cd_or_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar || ")); + } + + #[test] + fn test_cd_pipe_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd bar | ")); + } + + #[test] + fn test_cd_single_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); + } + + #[test] + fn test_cd_double_quoted_path_with_spaces() { + assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); + } + + #[test] + fn test_echo_and_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("echo foo && ")); + } + + #[test] + fn test_apply_patch_with_arg_is_ignored() { + let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; + assert_not_match(script); + } + + #[test] + fn test_double_cd_then_apply_patch_is_ignored() { + assert_not_match(&heredoc_script("cd foo && cd bar && ")); + } + + #[test] + fn test_cd_two_args_is_ignored() { + assert_not_match(&heredoc_script("cd foo bar && ")); + } + + #[test] + fn test_cd_then_apply_patch_then_extra_is_ignored() { + let script = heredoc_script_ps("cd bar && ", " && echo done"); + assert_not_match(&script); + } + + #[test] + fn test_echo_then_cd_and_apply_patch_is_ignored() { + // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. + assert_not_match(&heredoc_script("echo foo; cd bar && ")); + } + + #[test] + fn test_unified_diff_last_line_replacement() { + // Replace the very last line of the file. + let dir = tempdir().unwrap(); + let path = dir.path().join("last.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo + bar +-baz ++BAZ +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -2,2 +2,2 @@ + bar +-baz ++BAZ +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nbar\nBAZ\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_unified_diff_insert_at_eof() { + // Insert a new line at end‑of‑file. + let dir = tempdir().unwrap(); + let path = dir.path().join("insert.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++quux +*** End of File +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -3 +3,2 @@ + baz ++quux +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nbar\nbaz\nquux\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { + let session_dir = tempdir().unwrap(); + let relative_path = "source.txt"; + + // Note that we need this file to exist for the patch to be "verified" + // and parsed correctly. + let session_file_path = session_dir.path().join(relative_path); + fs::write(&session_file_path, "session directory content\n").unwrap(); + + let argv = vec![ + "apply_patch".to_string(), + r#"*** Begin Patch +*** Update File: source.txt +@@ +-session directory content ++updated session directory content +*** End Patch"# + .to_string(), + ]; + + let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + + // Verify the patch contents - as otherwise we may have pulled contents + // from the wrong file (as we're using relative paths) + assert_eq!( + result, + MaybeApplyPatchVerified::Body(ApplyPatchAction { + changes: HashMap::from([( + session_dir.path().join(relative_path), + ApplyPatchFileChange::Update { + unified_diff: r#"@@ -1 +1 @@ +-session directory content ++updated session directory content +"# + .to_string(), + move_path: None, + new_content: "updated session directory content\n".to_string(), + }, + )]), + patch: argv[1].clone(), + cwd: session_dir.path().to_path_buf(), + }) + ); + } + + #[test] + fn test_apply_patch_resolves_move_path_with_effective_cwd() { + let session_dir = tempdir().unwrap(); + let worktree_rel = "alt"; + let worktree_dir = session_dir.path().join(worktree_rel); + fs::create_dir_all(&worktree_dir).unwrap(); + + let source_name = "old.txt"; + let dest_name = "renamed.txt"; + let source_path = worktree_dir.join(source_name); + fs::write(&source_path, "before\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {source_name} +*** Move to: {dest_name} +@@ +-before ++after"# + )); + + let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); + let argv = vec!["bash".into(), "-lc".into(), shell_script]; + + let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let action = match result { + MaybeApplyPatchVerified::Body(action) => action, + other => panic!("expected verified body, got {other:?}"), + }; + + assert_eq!(action.cwd, worktree_dir); + + let change = action + .changes() + .get(&worktree_dir.join(source_name)) + .expect("source file change present"); + + match change { + ApplyPatchFileChange::Update { move_path, .. } => { + assert_eq!( + move_path.as_deref(), + Some(worktree_dir.join(dest_name).as_path()) + ); + } + other => panic!("expected update change, got {other:?}"), + } + } +} diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 051533f370..f58055f450 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -544,11 +544,7 @@ pub fn print_summary( #[cfg(test)] mod tests { - use crate::invocation::MaybeApplyPatch; - use crate::invocation::maybe_parse_apply_patch; - use super::*; - use assert_matches::assert_matches; use pretty_assertions::assert_eq; use std::fs; use std::string::ToString; @@ -559,270 +555,6 @@ mod tests { format!("*** Begin Patch\n{body}\n*** End Patch") } - fn strs_to_strings(strs: &[&str]) -> Vec { - strs.iter().map(ToString::to_string).collect() - } - - // Test helpers to reduce repetition when building bash -lc heredoc scripts - fn args_bash(script: &str) -> Vec { - strs_to_strings(&["bash", "-lc", script]) - } - - fn args_powershell(script: &str) -> Vec { - strs_to_strings(&["powershell.exe", "-Command", script]) - } - - fn args_powershell_no_profile(script: &str) -> Vec { - strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script]) - } - - fn args_pwsh(script: &str) -> Vec { - strs_to_strings(&["pwsh", "-NoProfile", "-Command", script]) - } - - fn args_cmd(script: &str) -> Vec { - strs_to_strings(&["cmd.exe", "/c", script]) - } - - fn heredoc_script(prefix: &str) -> String { - format!( - "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH" - ) - } - - fn heredoc_script_ps(prefix: &str, suffix: &str) -> String { - format!( - "{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}" - ) - } - - fn expected_single_add() -> Vec { - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string(), - }] - } - - fn assert_match_args(args: Vec, expected_workdir: Option<&str>) { - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { - assert_eq!(workdir.as_deref(), expected_workdir); - assert_eq!(hunks, expected_single_add()); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - fn assert_match(script: &str, expected_workdir: Option<&str>) { - let args = args_bash(script); - assert_match_args(args, expected_workdir); - } - - fn assert_not_match(script: &str) { - let args = args_bash(script); - assert_matches!( - maybe_parse_apply_patch(&args), - MaybeApplyPatch::NotApplyPatch - ); - } - - #[test] - fn test_implicit_patch_single_arg_is_error() { - let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string(); - let args = vec![patch]; - let dir = tempdir().unwrap(); - assert_matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), - MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - ); - } - - #[test] - fn test_implicit_patch_bash_script_is_error() { - let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch"; - let args = args_bash(script); - let dir = tempdir().unwrap(); - assert_matches!( - maybe_parse_apply_patch_verified(&args, dir.path()), - MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation) - ); - } - - #[test] - fn test_literal() { - let args = strs_to_strings(&[ - "apply_patch", - r#"*** Begin Patch -*** Add File: foo -+hi -*** End Patch -"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_literal_applypatch() { - let args = strs_to_strings(&[ - "applypatch", - r#"*** Begin Patch -*** Add File: foo -+hi -*** End Patch -"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => { - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_heredoc() { - assert_match(&heredoc_script(""), None); - } - - #[test] - fn test_heredoc_non_login_shell() { - let script = heredoc_script(""); - let args = strs_to_strings(&["bash", "-c", &script]); - assert_match_args(args, None); - } - - #[test] - fn test_heredoc_applypatch() { - let args = strs_to_strings(&[ - "bash", - "-lc", - r#"applypatch <<'PATCH' -*** Begin Patch -*** Add File: foo -+hi -*** End Patch -PATCH"#, - ]); - - match maybe_parse_apply_patch(&args) { - MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => { - assert_eq!(workdir, None); - assert_eq!( - hunks, - vec![Hunk::AddFile { - path: PathBuf::from("foo"), - contents: "hi\n".to_string() - }] - ); - } - result => panic!("expected MaybeApplyPatch::Body got {result:?}"), - } - } - - #[test] - fn test_powershell_heredoc() { - let script = heredoc_script(""); - assert_match_args(args_powershell(&script), None); - } - #[test] - fn test_powershell_heredoc_no_profile() { - let script = heredoc_script(""); - assert_match_args(args_powershell_no_profile(&script), None); - } - #[test] - fn test_pwsh_heredoc() { - let script = heredoc_script(""); - assert_match_args(args_pwsh(&script), None); - } - - #[test] - fn test_cmd_heredoc_with_cd() { - let script = heredoc_script("cd foo && "); - assert_match_args(args_cmd(&script), Some("foo")); - } - - #[test] - fn test_heredoc_with_leading_cd() { - assert_match(&heredoc_script("cd foo && "), Some("foo")); - } - - #[test] - fn test_cd_with_semicolon_is_ignored() { - assert_not_match(&heredoc_script("cd foo; ")); - } - - #[test] - fn test_cd_or_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd bar || ")); - } - - #[test] - fn test_cd_pipe_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd bar | ")); - } - - #[test] - fn test_cd_single_quoted_path_with_spaces() { - assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar")); - } - - #[test] - fn test_cd_double_quoted_path_with_spaces() { - assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar")); - } - - #[test] - fn test_echo_and_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("echo foo && ")); - } - - #[test] - fn test_apply_patch_with_arg_is_ignored() { - let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"; - assert_not_match(script); - } - - #[test] - fn test_double_cd_then_apply_patch_is_ignored() { - assert_not_match(&heredoc_script("cd foo && cd bar && ")); - } - - #[test] - fn test_cd_two_args_is_ignored() { - assert_not_match(&heredoc_script("cd foo bar && ")); - } - - #[test] - fn test_cd_then_apply_patch_then_extra_is_ignored() { - let script = heredoc_script_ps("cd bar && ", " && echo done"); - assert_not_match(&script); - } - - #[test] - fn test_echo_then_cd_and_apply_patch_is_ignored() { - // Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match. - assert_not_match(&heredoc_script("echo foo; cd bar && ")); - } - #[test] fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); @@ -1311,99 +1043,6 @@ g ); } - #[test] - fn test_apply_patch_should_resolve_absolute_paths_in_cwd() { - let session_dir = tempdir().unwrap(); - let relative_path = "source.txt"; - - // Note that we need this file to exist for the patch to be "verified" - // and parsed correctly. - let session_file_path = session_dir.path().join(relative_path); - fs::write(&session_file_path, "session directory content\n").unwrap(); - - let argv = vec![ - "apply_patch".to_string(), - r#"*** Begin Patch -*** Update File: source.txt -@@ --session directory content -+updated session directory content -*** End Patch"# - .to_string(), - ]; - - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); - - // Verify the patch contents - as otherwise we may have pulled contents - // from the wrong file (as we're using relative paths) - assert_eq!( - result, - MaybeApplyPatchVerified::Body(ApplyPatchAction { - changes: HashMap::from([( - session_dir.path().join(relative_path), - ApplyPatchFileChange::Update { - unified_diff: r#"@@ -1 +1 @@ --session directory content -+updated session directory content -"# - .to_string(), - move_path: None, - new_content: "updated session directory content\n".to_string(), - }, - )]), - patch: argv[1].clone(), - cwd: session_dir.path().to_path_buf(), - }) - ); - } - - #[test] - fn test_apply_patch_resolves_move_path_with_effective_cwd() { - let session_dir = tempdir().unwrap(); - let worktree_rel = "alt"; - let worktree_dir = session_dir.path().join(worktree_rel); - fs::create_dir_all(&worktree_dir).unwrap(); - - let source_name = "old.txt"; - let dest_name = "renamed.txt"; - let source_path = worktree_dir.join(source_name); - fs::write(&source_path, "before\n").unwrap(); - - let patch = wrap_patch(&format!( - r#"*** Update File: {source_name} -*** Move to: {dest_name} -@@ --before -+after"# - )); - - let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); - let argv = vec!["bash".into(), "-lc".into(), shell_script]; - - let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); - let action = match result { - MaybeApplyPatchVerified::Body(action) => action, - other => panic!("expected verified body, got {other:?}"), - }; - - assert_eq!(action.cwd, worktree_dir); - - let change = action - .changes() - .get(&worktree_dir.join(source_name)) - .expect("source file change present"); - - match change { - ApplyPatchFileChange::Update { move_path, .. } => { - assert_eq!( - move_path.as_deref(), - Some(worktree_dir.join(dest_name).as_path()) - ); - } - other => panic!("expected update change, got {other:?}"), - } - } - #[test] fn test_apply_patch_fails_on_write_error() { let dir = tempdir().unwrap(); From 3b52dd7b7d94d5fbd23e6315166e410fef4d308c Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:19:11 -0700 Subject: [PATCH 04/11] Add request_permissions profile persistence core support Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 35 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/lib.rs | 1 + .../src/permission_profile_persistence.rs | 179 ++++++++++++++++++ .../core/tests/suite/request_permissions.rs | 7 + .../tests/suite/request_permissions_tool.rs | 2 + codex-rs/protocol/src/protocol.rs | 9 + codex-rs/protocol/src/request_permissions.rs | 8 + 9 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 codex-rs/core/src/permission_profile_persistence.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b84..ae74c2d9f9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::permission_profile_persistence::persistence_target_for_permissions; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -2998,7 +2999,11 @@ impl Session { call_id, turn_id: turn_context.sub_id.clone(), reason: args.reason, - permissions: args.permissions, + permissions: args.permissions.clone(), + permissions_profile_persistence: persistence_target_for_permissions( + turn_context.config.as_ref(), + &args.permissions.into(), + ), }); self.send_event(turn_context, event).await; rx_response.await.ok() @@ -4253,8 +4258,18 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } - Op::RequestPermissionsResponse { id, response } => { - handlers::request_permissions_response(&sess, id, response).await; + Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => { + handlers::request_permissions_response( + &sess, + id, + response, + persist_permissions, + ) + .await; false } Op::DynamicToolResponse { id, response } => { @@ -4400,6 +4415,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::permission_profile_persistence::persist_permissions_for_profile; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; @@ -4420,6 +4436,7 @@ mod handlers { use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; + use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -4681,7 +4698,19 @@ mod handlers { sess: &Arc, id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) { + if let Some(action) = persist_permissions.as_ref() + && let Err(err) = persist_permissions_for_profile(sess.as_ref(), action).await + { + let message = format!("Failed to update permissions profile: {err}"); + tracing::warn!("{message}"); + sess.send_event_raw(Event { + id: id.clone(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } sess.notify_request_permissions_response(&id, response) .await; } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..279f60f5fc 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -764,6 +764,7 @@ async fn handle_request_permissions( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await; } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..dda6da513a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -200,6 +200,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { }), ..RequestPermissionProfile::default() }, + permissions_profile_persistence: None, }, &cancel_token, ) @@ -234,6 +235,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { Op::RequestPermissionsResponse { id: call_id, response: expected_response, + persist_permissions: None, } ); } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f..ae4e450052 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -54,6 +54,7 @@ mod network_policy_decision; pub mod network_proxy_loader; mod original_image_detail; mod packages; +mod permission_profile_persistence; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/permission_profile_persistence.rs b/codex-rs/core/src/permission_profile_persistence.rs new file mode 100644 index 0000000000..2e10fdde3f --- /dev/null +++ b/codex-rs/core/src/permission_profile_persistence.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeMap; +use std::io; + +use toml_edit::value; + +use crate::codex::Session; +use crate::config::Config; +use crate::config::deserialize_config_toml_with_base; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::request_permissions::PermissionProfilePersistence; + +pub(crate) fn persistence_target_for_permissions( + config: &Config, + permissions: &PermissionProfile, +) -> Option { + if !is_supported_filesystem_only_request(permissions) { + return None; + } + + let user_layer = config.config_layer_stack.get_user_layer()?; + let user_config = + deserialize_config_toml_with_base(user_layer.config.clone(), &config.codex_home).ok()?; + let profile_name = user_config.default_permissions?; + let permissions = user_config.permissions?; + permissions + .entries + .contains_key(profile_name.as_str()) + .then_some(PermissionProfilePersistence { profile_name }) +} + +pub(crate) async fn persist_permissions_for_profile( + sess: &Session, + action: &codex_protocol::protocol::PersistPermissionProfileAction, +) -> io::Result<()> { + let codex_home = sess.codex_home().await; + + let edits = filesystem_permission_edits( + action.profile_name.as_str(), + action.permissions.file_system.as_ref(), + ); + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| io::Error::other(format!("failed to persist permission profile: {err}")))?; + sess.reload_user_config_layer().await; + Ok(()) +} + +fn is_supported_filesystem_only_request(permissions: &PermissionProfile) -> bool { + let Some(file_system) = permissions.file_system.as_ref() else { + return false; + }; + + if file_system.is_empty() { + return false; + } + + if permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + return false; + } + + permissions.macos.is_none() +} + +fn filesystem_permission_edits( + profile_name: &str, + file_system: Option<&FileSystemPermissions>, +) -> Vec { + let Some(file_system) = file_system else { + return Vec::new(); + }; + + let mut path_access = BTreeMap::new(); + + if let Some(read_roots) = file_system.read.as_ref() { + for path in read_roots { + path_access + .entry(path.display().to_string()) + .or_insert(FileSystemAccessMode::Read); + } + } + + if let Some(write_roots) = file_system.write.as_ref() { + for path in write_roots { + path_access.insert(path.display().to_string(), FileSystemAccessMode::Write); + } + } + + path_access + .into_iter() + .map(|(path, access)| ConfigEdit::SetPath { + segments: vec![ + "permissions".to_string(), + profile_name.to_string(), + "filesystem".to_string(), + path, + ], + value: value(access.to_string()), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") + } + + #[test] + fn filesystem_permission_edits_upgrade_write_access() { + let edits = filesystem_permission_edits( + "workspace", + Some(&FileSystemPermissions { + read: Some(vec![ + absolute_path("/tmp/read"), + absolute_path("/tmp/write"), + ]), + write: Some(vec![absolute_path("/tmp/write")]), + }), + ); + + assert_eq!(edits.len(), 2); + match &edits[0] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + "/tmp/read".to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("read") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + match &edits[1] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + "/tmp/write".to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("write") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + } +} diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..0f730fd8c4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -1087,6 +1087,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1201,6 +1202,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1314,6 +1316,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1423,6 +1426,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1569,6 +1573,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() permissions: granted_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1687,6 +1692,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1804,6 +1810,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Session, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..9053b4c750 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -261,6 +261,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -380,6 +381,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..66a13a5d0f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -403,6 +403,9 @@ pub enum Op { id: String, /// User-granted permissions. response: RequestPermissionsResponse, + /// Optional permission-profile mutation to persist alongside the grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + persist_permissions: Option, }, /// Resolve a dynamic tool call request. @@ -3211,6 +3214,12 @@ impl ReviewDecision { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PersistPermissionProfileAction { + pub profile_name: String, + pub permissions: crate::models::PermissionProfile, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index db5396c5e4..991ad6e427 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -14,6 +14,11 @@ pub enum PermissionGrantScope { Session, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PermissionProfilePersistence { + pub profile_name: String, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct RequestPermissionProfile { @@ -71,4 +76,7 @@ pub struct RequestPermissionsEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub permissions_profile_persistence: Option, } From 993274057e9cd9a5991b3bc851b8301945e5cca3 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:32:57 -0700 Subject: [PATCH 05/11] Add request_permissions UI support Co-authored-by: Codex --- .../app-server-protocol/src/protocol/v2.rs | 21 ++++ .../app-server/src/bespoke_event_handling.rs | 97 +++++++++++++------ .../tests/suite/v2/request_permissions.rs | 1 + codex-rs/tui/src/app.rs | 1 + .../tui/src/bottom_pane/approval_overlay.rs | 89 +++++++++++++++-- codex-rs/tui/src/chatwidget.rs | 1 + 6 files changed, 174 insertions(+), 36 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 57017833a6..10cb8d4534 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -84,6 +84,7 @@ use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::PermissionProfilePersistence as CorePermissionProfilePersistence; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::user_input::ByteRange as CoreByteRange; use codex_protocol::user_input::TextElement as CoreTextElement; @@ -1129,6 +1130,21 @@ pub struct RequestPermissionProfile { pub file_system: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfilePersistence { + pub profile_name: String, +} + +impl From for PermissionProfilePersistence { + fn from(value: CorePermissionProfilePersistence) -> Self { + Self { + profile_name: value.profile_name, + } + } +} + impl From for RequestPermissionProfile { fn from(value: CoreRequestPermissionProfile) -> Self { Self { @@ -5681,6 +5697,9 @@ pub struct PermissionsRequestApprovalParams { pub item_id: String, pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub permissions_profile_persistence: Option, } v2_enum_from_core!( @@ -5699,6 +5718,8 @@ pub struct PermissionsRequestApprovalResponse { pub permissions: GrantedPermissionProfile, #[serde(default)] pub scope: PermissionGrantScope, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub persist_to_profile: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..7cfa9522b3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -126,12 +126,14 @@ use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::PermissionProfilePersistence as CorePermissionProfilePersistence; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; @@ -859,6 +861,10 @@ pub(crate) async fn apply_bespoke_event_handling( item_id: request.call_id.clone(), reason: request.reason, permissions: request.permissions.into(), + permissions_profile_persistence: request + .permissions_profile_persistence + .clone() + .map(codex_app_server_protocol::PermissionProfilePersistence::from), }; let (pending_request_id, rx) = outgoing .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) @@ -867,6 +873,7 @@ pub(crate) async fn apply_bespoke_event_handling( on_request_permissions_response( request.call_id, requested_permissions, + request.permissions_profile_persistence, pending_request_id, rx, conversation, @@ -888,6 +895,7 @@ pub(crate) async fn apply_bespoke_event_handling( .submit(Op::RequestPermissionsResponse { id: request.call_id, response: empty, + persist_permissions: None, }) .await { @@ -2445,6 +2453,7 @@ fn mcp_server_elicitation_response_from_client_result( async fn on_request_permissions_response( call_id: String, requested_permissions: CoreRequestPermissionProfile, + permissions_profile_persistence: Option, pending_request_id: RequestId, receiver: oneshot::Receiver, conversation: Arc, @@ -2454,9 +2463,11 @@ async fn on_request_permissions_response( let response = receiver.await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; drop(request_permissions_guard); - let Some(response) = - request_permissions_response_from_client_result(requested_permissions, response) - else { + let Some((response, persist_permissions)) = request_permissions_response_from_client_result( + requested_permissions, + permissions_profile_persistence, + response, + ) else { return; }; @@ -2464,6 +2475,7 @@ async fn on_request_permissions_response( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions, }) .await { @@ -2473,24 +2485,34 @@ async fn on_request_permissions_response( fn request_permissions_response_from_client_result( requested_permissions: CoreRequestPermissionProfile, + permissions_profile_persistence: Option, response: std::result::Result, -) -> Option { +) -> Option<( + CoreRequestPermissionsResponse, + Option, +)> { let value = match response { Ok(Ok(value)) => value, Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return None, Ok(Err(err)) => { error!("request failed with client error: {err:?}"); - return Some(CoreRequestPermissionsResponse { - permissions: Default::default(), - scope: CorePermissionGrantScope::Turn, - }); + return Some(( + CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + }, + None, + )); } Err(err) => { error!("request failed: {err:?}"); - return Some(CoreRequestPermissionsResponse { - permissions: Default::default(), - scope: CorePermissionGrantScope::Turn, - }); + return Some(( + CoreRequestPermissionsResponse { + permissions: Default::default(), + scope: CorePermissionGrantScope::Turn, + }, + None, + )); } }; @@ -2500,16 +2522,26 @@ fn request_permissions_response_from_client_result( PermissionsRequestApprovalResponse { permissions: V2GrantedPermissionProfile::default(), scope: codex_app_server_protocol::PermissionGrantScope::Turn, + persist_to_profile: false, } }); - Some(CoreRequestPermissionsResponse { - permissions: intersect_permission_profiles( - requested_permissions.into(), - response.permissions.into(), - ) - .into(), - scope: response.scope.to_core(), - }) + let permissions: codex_protocol::models::PermissionProfile = + intersect_permission_profiles(requested_permissions.into(), response.permissions.into()); + let persist_permissions = if response.persist_to_profile { + permissions_profile_persistence.map(|target| PersistPermissionProfileAction { + profile_name: target.profile_name, + permissions: permissions.clone(), + }) + } else { + None + }; + Some(( + CoreRequestPermissionsResponse { + permissions: permissions.into(), + scope: response.scope.to_core(), + }, + persist_permissions, + )) } const REVIEW_FALLBACK_MESSAGE: &str = "Reviewer failed to output a response."; @@ -3058,6 +3090,7 @@ mod tests { let response = request_permissions_response_from_client_result( CoreRequestPermissionProfile::default(), + None, Ok(Err(error)), ); @@ -3148,6 +3181,7 @@ mod tests { for (granted_permissions, expected_permissions) in cases { let response = request_permissions_response_from_client_result( requested_permissions.clone(), + None, Ok(Ok(serde_json::json!({ "permissions": granted_permissions, }))), @@ -3156,10 +3190,13 @@ mod tests { assert_eq!( response, - CoreRequestPermissionsResponse { - permissions: expected_permissions, - scope: CorePermissionGrantScope::Turn, - } + ( + CoreRequestPermissionsResponse { + permissions: expected_permissions, + scope: CorePermissionGrantScope::Turn, + }, + None, + ) ); } } @@ -3168,6 +3205,7 @@ mod tests { fn request_permissions_response_preserves_session_scope() { let response = request_permissions_response_from_client_result( CoreRequestPermissionProfile::default(), + None, Ok(Ok(serde_json::json!({ "scope": "session", "permissions": {}, @@ -3177,10 +3215,13 @@ mod tests { assert_eq!( response, - CoreRequestPermissionsResponse { - permissions: CoreRequestPermissionProfile::default(), - scope: CorePermissionGrantScope::Session, - } + ( + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile::default(), + scope: CorePermissionGrantScope::Session, + }, + None, + ) ); } diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 5a0679415d..ffefca315d 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -96,6 +96,7 @@ async fn request_permissions_round_trip() -> Result<()> { }), }, scope: PermissionGrantScope::Turn, + persist_to_profile: false, })?, ) .await?; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4aa51df21c..80e9294bc1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1658,6 +1658,7 @@ impl App { call_id: ev.call_id.clone(), reason: ev.reason.clone(), permissions: ev.permissions.clone(), + permissions_profile_persistence: ev.permissions_profile_persistence.clone(), }, )), _ => None, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1b403c251f..b78741306f 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -28,8 +28,10 @@ use codex_protocol::protocol::FileChange; use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::Op; +use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::PermissionProfilePersistence; use codex_protocol::request_permissions::RequestPermissionProfile; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -62,6 +64,7 @@ pub(crate) enum ApprovalRequest { call_id: String, reason: Option, permissions: RequestPermissionProfile, + permissions_profile_persistence: Option, }, ApplyPatch { thread_id: ThreadId, @@ -168,8 +171,11 @@ impl ApprovalOverlay { }, ), ), - ApprovalRequest::Permissions { .. } => ( - permissions_options(), + ApprovalRequest::Permissions { + permissions_profile_persistence, + .. + } => ( + permissions_options(permissions_profile_persistence.as_ref()), "Would you like to grant these permissions?".to_string(), ), ApprovalRequest::ApplyPatch { .. } => ( @@ -226,10 +232,16 @@ impl ApprovalOverlay { ApprovalRequest::Permissions { call_id, permissions, + permissions_profile_persistence, .. }, ApprovalDecision::Review(decision), - ) => self.handle_permissions_decision(call_id, permissions, decision.clone()), + ) => self.handle_permissions_decision( + call_id, + permissions, + permissions_profile_persistence.as_ref(), + decision.clone(), + ), (ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { self.handle_patch_decision(id, decision.clone()); } @@ -278,13 +290,16 @@ impl ApprovalOverlay { &self, call_id: &str, permissions: &RequestPermissionProfile, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, decision: ReviewDecision, ) { let Some(request) = self.current_request.as_ref() else { return; }; let granted_permissions = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedPersistToProfile => permissions.clone(), ReviewDecision::Denied | ReviewDecision::Abort => Default::default(), ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), @@ -307,6 +322,11 @@ impl ApprovalOverlay { ))); } let thread_id = request.thread_id(); + let persist_permissions = persist_permissions_for_permissions_decision( + &decision, + permissions_profile_persistence, + &granted_permissions, + ); self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, op: Op::RequestPermissionsResponse { @@ -315,6 +335,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, }, + persist_permissions, }, }); } @@ -443,9 +464,15 @@ impl BottomPaneView for ApprovalOverlay { ApprovalRequest::Permissions { call_id, permissions, + permissions_profile_persistence, .. } => { - self.handle_permissions_decision(call_id, permissions, ReviewDecision::Abort); + self.handle_permissions_decision( + call_id, + permissions, + permissions_profile_persistence.as_ref(), + ReviewDecision::Abort, + ); } ApprovalRequest::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); @@ -855,8 +882,10 @@ fn patch_options() -> Vec { ] } -fn permissions_options() -> Vec { - vec![ +fn permissions_options( + permissions_profile_persistence: Option<&PermissionProfilePersistence>, +) -> Vec { + let mut options = vec![ ApprovalOption { label: "Yes, grant these permissions".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), @@ -875,7 +904,50 @@ fn permissions_options() -> Vec { display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], }, - ] + ]; + if permissions_profile_persistence.is_some() { + options.insert( + 1, + ApprovalOption { + label: "Yes, always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedPersistToProfile), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }, + ); + } + options +} + +fn persist_permissions_for_exec_decision( + decision: &ReviewDecision, + additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, +) -> Option { + if !matches!(decision, ReviewDecision::ApprovedPersistToProfile) { + return None; + } + let permissions = additional_permissions?.clone(); + let profile_name = permissions_profile_persistence?.profile_name.clone(); + Some(PersistPermissionProfileAction { + profile_name, + permissions, + }) +} + +fn persist_permissions_for_permissions_decision( + decision: &ReviewDecision, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, + granted_permissions: &RequestPermissionProfile, +) -> Option { + if !matches!(decision, ReviewDecision::ApprovedPersistToProfile) { + return None; + } + let profile_name = permissions_profile_persistence?.profile_name.clone(); + Some(PersistPermissionProfileAction { + profile_name, + permissions: granted_permissions.clone().into(), + }) } fn elicitation_options() -> Vec { @@ -1336,6 +1408,7 @@ mod tests { }), ..Default::default() }), + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8d9e3b61b..7bfa563e4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3445,6 +3445,7 @@ impl ChatWidget { call_id: ev.call_id, reason: ev.reason, permissions: ev.permissions, + permissions_profile_persistence: ev.permissions_profile_persistence, }; self.bottom_pane .push_approval_request(request, &self.config.features); From c3df4c6a0a8296eee9317e461b4cbb564da438c4 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:33:36 -0700 Subject: [PATCH 06/11] Add exec approval profile persistence core support Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 35 ++++++++++++++++++- codex-rs/core/src/codex_delegate.rs | 2 ++ codex-rs/core/src/codex_delegate_tests.rs | 2 ++ codex-rs/core/src/mcp_tool_call.rs | 1 + codex-rs/core/src/tools/network_approval.rs | 4 ++- codex-rs/core/src/tools/orchestrator.rs | 2 ++ .../tools/runtimes/shell/unix_escalation.rs | 1 + codex-rs/core/tests/suite/approvals.rs | 8 +++++ codex-rs/core/tests/suite/codex_delegate.rs | 1 + codex-rs/core/tests/suite/otel.rs | 6 ++++ .../core/tests/suite/request_permissions.rs | 12 +++++++ .../tests/suite/request_permissions_tool.rs | 1 + codex-rs/core/tests/suite/skill_approval.rs | 4 +++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/src/exec_approval.rs | 1 + codex-rs/protocol/src/approvals.rs | 15 +++++++- codex-rs/protocol/src/protocol.rs | 8 +++++ 17 files changed, 101 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ae74c2d9f9..dd1ea02eba 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2887,12 +2887,17 @@ impl Session { }, ] }); + let permissions_profile_persistence = + additional_permissions.as_ref().and_then(|permissions| { + persistence_target_for_permissions(turn_context.config.as_ref(), permissions) + }); let available_decisions = available_decisions.unwrap_or_else(|| { ExecApprovalRequestEvent::default_available_decisions( network_approval_context.as_ref(), proposed_execpolicy_amendment.as_ref(), proposed_network_policy_amendments.as_deref(), additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), ) }); let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -2906,6 +2911,7 @@ impl Session { proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, + permissions_profile_persistence, skill_metadata, available_decisions: Some(available_decisions), parsed_cmd, @@ -4246,8 +4252,16 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv id: approval_id, turn_id, decision, + persist_permissions, } => { - handlers::exec_approval(&sess, approval_id, turn_id, decision).await; + handlers::exec_approval( + &sess, + approval_id, + turn_id, + decision, + persist_permissions, + ) + .await; false } Op::PatchApproval { id, decision } => { @@ -4640,6 +4654,7 @@ mod handlers { approval_id: String, turn_id: Option, decision: ReviewDecision, + persist_permissions: Option, ) { let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone()); if let ReviewDecision::ApprovedExecpolicyAmendment { @@ -4669,10 +4684,28 @@ mod handlers { } } } + if matches!( + decision, + ReviewDecision::ApprovedPersistToProfile | ReviewDecision::Approved + ) && let Some(action) = persist_permissions.as_ref() + && let Err(err) = persist_permissions_for_profile(sess.as_ref(), action).await + { + let message = format!("Failed to update permissions profile: {err}"); + tracing::warn!("{message}"); + sess.send_event_raw(Event { + id: event_turn_id.clone(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } match decision { ReviewDecision::Abort => { sess.interrupt_task().await; } + ReviewDecision::ApprovedPersistToProfile => { + sess.notify_approval(&approval_id, ReviewDecision::Approved) + .await; + } other => sess.notify_approval(&approval_id, other).await, } } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 279f60f5fc..9c1b563c3a 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -493,6 +493,7 @@ async fn handle_exec_approval( id: approval_id_for_op, turn_id: Some(turn_id), decision, + persist_permissions: None, }) .await; } @@ -700,6 +701,7 @@ async fn maybe_auto_review_mcp_request_user_input( .map(|option| option.label.clone()) .unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()), ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(), ReviewDecision::Denied | ReviewDecision::Abort => { diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index dda6da513a..9d10a28b34 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -288,6 +288,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, additional_permissions: None, + permissions_profile_persistence: None, skill_metadata: None, available_decisions: Some(vec![ ReviewDecision::Approved, @@ -345,6 +346,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f id: "callback-approval-1".to_string(), turn_id: Some("child-turn-1".to_string()), decision: ReviewDecision::Abort, + persist_permissions: None, } ); } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f82cc73bb5..c68c1d6a43 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -747,6 +747,7 @@ pub(crate) fn build_guardian_mcp_tool_review_request( fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpToolApprovalDecision { match decision { ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept, ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession, diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9e92a6b00f..7595d5074e 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -392,7 +392,9 @@ impl NetworkApprovalService { let mut cache_session_deny = false; let resolved = match approval_decision { - ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile + | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { PendingApprovalDecision::AllowOnce } ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4b53ac156f..b9a432ce48 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -155,6 +155,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} ReviewDecision::NetworkPolicyAmendment { @@ -309,6 +310,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} ReviewDecision::NetworkPolicyAmendment { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 948018dae6..645c6969da 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -542,6 +542,7 @@ impl CoreShellActionProvider { .await? { ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { if needs_escalation { EscalationDecision::escalate(escalation_execution.clone()) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f77697d017..ea3c0d8b77 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1715,6 +1715,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { id: approval.effective_approval_id(), turn_id: None, decision: decision.clone(), + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1941,6 +1942,7 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(), }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -2180,6 +2182,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: expected_execpolicy_amendment, }, + persist_permissions: None, }) .await?; @@ -2405,6 +2408,7 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> { decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: amendment.clone(), }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -2599,6 +2603,7 @@ allow_local_binding = true id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; } @@ -2639,6 +2644,7 @@ allow_local_binding = true decision: ReviewDecision::NetworkPolicyAmendment { network_policy_amendment: deny_network_amendment.clone(), }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -2742,6 +2748,7 @@ allow_local_binding = true id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; } @@ -2826,6 +2833,7 @@ async fn compound_command_with_one_safe_command_still_requires_approval() -> Res id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index e71d99b620..b10f470674 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -103,6 +103,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await .expect("submit exec approval"); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index ecf6283664..46eea86eab 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1159,6 +1159,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await .unwrap(); @@ -1225,6 +1226,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::ApprovedForSession, + persist_permissions: None, }) .await .unwrap(); @@ -1291,6 +1293,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await .unwrap(); @@ -1357,6 +1360,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await .unwrap(); @@ -1423,6 +1427,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::ApprovedForSession, + persist_permissions: None, }) .await .unwrap(); @@ -1490,6 +1495,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await .unwrap(); diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 0f730fd8c4..61ccf4f82d 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -378,6 +378,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -566,6 +567,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -660,6 +662,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -761,6 +764,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -869,6 +873,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -971,6 +976,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1101,6 +1107,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1212,6 +1219,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1326,6 +1334,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1436,6 +1445,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1606,6 +1616,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1853,6 +1864,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 9053b4c750..9cab5ac651 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -278,6 +278,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .await?; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index b5fda12ae0..13318c6453 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -257,6 +257,7 @@ permissions: id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await?; @@ -351,6 +352,7 @@ permissions: id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await?; @@ -446,6 +448,7 @@ permissions: id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Denied, + persist_permissions: None, }) .await?; @@ -895,6 +898,7 @@ async fn shell_zsh_fork_skill_session_approval_enforces_skill_permissions() -> R id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::ApprovedForSession, + persist_permissions: None, }) .await?; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 780a808038..14f2d70cfb 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -230,6 +230,7 @@ async fn run_codex_tool_session_inner( additional_permissions: _, skill_metadata: _, available_decisions: _, + permissions_profile_persistence: _, } = ev; handle_exec_approval_request( command, diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 3b5ef87cdb..f72de962a8 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -139,6 +139,7 @@ async fn on_exec_approval_response( id: approval_id, turn_id: Some(event_id), decision: response.decision, + persist_permissions: None, }) .await { diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 848ea31c02..968339ddeb 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -10,6 +10,7 @@ use crate::permissions::NetworkSandboxPolicy; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; +use crate::request_permissions::PermissionProfilePersistence; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -181,6 +182,11 @@ pub struct ExecApprovalRequestEvent { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub additional_permissions: Option, + /// Optional named permissions profile that can absorb the requested + /// filesystem permissions for future approvals. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub permissions_profile_persistence: Option, /// Optional skill metadata when the approval was triggered by a skill script. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -212,6 +218,7 @@ impl ExecApprovalRequestEvent { self.proposed_execpolicy_amendment.as_ref(), self.proposed_network_policy_amendments.as_deref(), self.additional_permissions.as_ref(), + self.permissions_profile_persistence.as_ref(), ), } } @@ -221,6 +228,7 @@ impl ExecApprovalRequestEvent { proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>, proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>, additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, ) -> Vec { if network_approval_context.is_some() { let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession]; @@ -238,7 +246,12 @@ impl ExecApprovalRequestEvent { } if additional_permissions.is_some() { - return vec![ReviewDecision::Approved, ReviewDecision::Abort]; + let mut decisions = vec![ReviewDecision::Approved]; + if permissions_profile_persistence.is_some() { + decisions.push(ReviewDecision::ApprovedPersistToProfile); + } + decisions.push(ReviewDecision::Abort); + return decisions; } let mut decisions = vec![ReviewDecision::Approved]; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 66a13a5d0f..c387f55dd9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -362,6 +362,9 @@ pub enum Op { turn_id: Option, /// The user's decision in response to the request. decision: ReviewDecision, + /// Optional permission-profile mutation to persist alongside approval. + #[serde(default, skip_serializing_if = "Option::is_none")] + persist_permissions: Option, }, /// Approve a code patch @@ -3178,6 +3181,10 @@ pub enum ReviewDecision { /// remainder of the session. ApprovedForSession, + /// User has approved this request and wants the filesystem permissions to + /// be persisted into the active named permissions profile. + ApprovedPersistToProfile, + /// User chose to persist a network policy rule (allow/deny) for future /// requests to the same host. NetworkPolicyAmendment { @@ -3202,6 +3209,7 @@ impl ReviewDecision { ReviewDecision::Approved => "approved", ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment", ReviewDecision::ApprovedForSession => "approved_for_session", + ReviewDecision::ApprovedPersistToProfile => "approved_persist_to_profile", ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { From 37e7b08b5114346b201147c5d152c19569b91be7 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:33:44 -0700 Subject: [PATCH 07/11] Add exec approval app-server support Co-authored-by: Codex --- .../json/ApplyPatchApprovalResponse.json | 7 + ...CommandExecutionRequestApprovalParams.json | 29 +++ ...mmandExecutionRequestApprovalResponse.json | 7 + .../json/ExecCommandApprovalResponse.json | 7 + .../PermissionsRequestApprovalParams.json | 21 +++ .../PermissionsRequestApprovalResponse.json | 3 + .../schema/json/ServerRequest.json | 39 ++++ .../codex_app_server_protocol.schemas.json | 49 +++++ .../schema/typescript/ReviewDecision.ts | 2 +- .../v2/CommandExecutionApprovalDecision.ts | 2 +- .../CommandExecutionRequestApprovalParams.ts | 6 + .../v2/PermissionProfilePersistence.ts | 5 + .../v2/PermissionsRequestApprovalParams.ts | 3 +- .../v2/PermissionsRequestApprovalResponse.ts | 2 +- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 2 + .../app-server-protocol/src/protocol/v2.rs | 9 + codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 8 +- .../app-server/src/bespoke_event_handling.rs | 39 +++- codex-rs/app-server/src/transport.rs | 2 + codex-rs/tui/src/app.rs | 4 + .../tui/src/app/pending_interactive_replay.rs | 5 + .../tui/src/bottom_pane/approval_overlay.rs | 77 +++++++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 8 + codex-rs/tui/src/history_cell.rs | 13 ++ codex-rs/tui_app_server/src/app.rs | 82 +++++++++ .../src/app/app_server_requests.rs | 99 ++++++++++- .../src/app/pending_interactive_replay.rs | 3 + codex-rs/tui_app_server/src/app_command.rs | 26 ++- .../tui_app_server/src/app_event_sender.rs | 16 +- .../src/bottom_pane/approval_overlay.rs | 167 ++++++++++++++++-- .../tui_app_server/src/bottom_pane/mod.rs | 1 + codex-rs/tui_app_server/src/chatwidget.rs | 15 ++ .../tui_app_server/src/chatwidget/tests.rs | 8 + codex-rs/tui_app_server/src/history_cell.rs | 13 ++ 38 files changed, 735 insertions(+), 48 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfilePersistence.ts diff --git a/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json index 84842fd194..b24577dfc4 100644 --- a/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/ApplyPatchApprovalResponse.json @@ -65,6 +65,13 @@ ], "type": "string" }, + { + "description": "User has approved this request and wants the filesystem permissions to be persisted into the active named permissions profile.", + "enum": [ + "approved_persist_to_profile" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 2c146b9522..a67fc9dbbe 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -235,6 +235,13 @@ ], "type": "string" }, + { + "description": "User approved the command and wants to persist the filesystem access in the active named permissions profile.", + "enum": [ + "acceptAndPersist" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", @@ -397,6 +404,17 @@ "deny" ], "type": "string" + }, + "PermissionProfilePersistence": { + "properties": { + "profileName": { + "type": "string" + } + }, + "required": [ + "profileName" + ], + "type": "object" } }, "properties": { @@ -445,6 +463,17 @@ ], "description": "Optional context for a managed-network approval prompt." }, + "permissionsProfilePersistence": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfilePersistence" + }, + { + "type": "null" + } + ], + "description": "Optional named permissions profile that can persist the requested filesystem access." + }, "proposedExecpolicyAmendment": { "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", "items": { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json index 0b7986fba9..2b46380815 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalResponse.json @@ -17,6 +17,13 @@ ], "type": "string" }, + { + "description": "User approved the command and wants to persist the filesystem access in the active named permissions profile.", + "enum": [ + "acceptAndPersist" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", diff --git a/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json index baedafa403..b6a378e139 100644 --- a/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/ExecCommandApprovalResponse.json @@ -65,6 +65,13 @@ ], "type": "string" }, + { + "description": "User has approved this request and wants the filesystem permissions to be persisted into the active named permissions profile.", + "enum": [ + "approved_persist_to_profile" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index ac8d5c4010..44bb00ec1c 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -39,6 +39,17 @@ }, "type": "object" }, + "PermissionProfilePersistence": { + "properties": { + "profileName": { + "type": "string" + } + }, + "required": [ + "profileName" + ], + "type": "object" + }, "RequestPermissionProfile": { "additionalProperties": false, "properties": { @@ -73,6 +84,16 @@ "permissions": { "$ref": "#/definitions/RequestPermissionProfile" }, + "permissionsProfilePersistence": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfilePersistence" + }, + { + "type": "null" + } + ] + }, "reason": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 7b0c2b1a3b..9ce3d60c67 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -76,6 +76,9 @@ "permissions": { "$ref": "#/definitions/GrantedPermissionProfile" }, + "persistToProfile": { + "type": "boolean" + }, "scope": { "allOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 1fbbfb1b0a..a8f4be4d23 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -301,6 +301,13 @@ ], "type": "string" }, + { + "description": "User approved the command and wants to persist the filesystem access in the active named permissions profile.", + "enum": [ + "acceptAndPersist" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", @@ -411,6 +418,17 @@ ], "description": "Optional context for a managed-network approval prompt." }, + "permissionsProfilePersistence": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfilePersistence" + }, + { + "type": "null" + } + ], + "description": "Optional named permissions profile that can persist the requested filesystem access." + }, "proposedExecpolicyAmendment": { "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", "items": { @@ -1443,6 +1461,17 @@ } ] }, + "PermissionProfilePersistence": { + "properties": { + "profileName": { + "type": "string" + } + }, + "required": [ + "profileName" + ], + "type": "object" + }, "PermissionsRequestApprovalParams": { "properties": { "itemId": { @@ -1451,6 +1480,16 @@ "permissions": { "$ref": "#/definitions/RequestPermissionProfile" }, + "permissionsProfilePersistence": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfilePersistence" + }, + { + "type": "null" + } + ] + }, "reason": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c24c8ac249..ef881edd3c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1622,6 +1622,13 @@ ], "type": "string" }, + { + "description": "User approved the command and wants to persist the filesystem access in the active named permissions profile.", + "enum": [ + "acceptAndPersist" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", @@ -1733,6 +1740,17 @@ ], "description": "Optional context for a managed-network approval prompt." }, + "permissionsProfilePersistence": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfilePersistence" + }, + { + "type": "null" + } + ], + "description": "Optional named permissions profile that can persist the requested filesystem access." + }, "proposedExecpolicyAmendment": { "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", "items": { @@ -3235,6 +3253,17 @@ ], "type": "string" }, + "PermissionProfilePersistence": { + "properties": { + "profileName": { + "type": "string" + } + }, + "required": [ + "profileName" + ], + "type": "object" + }, "PermissionsRequestApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -3244,6 +3273,16 @@ "permissions": { "$ref": "#/definitions/RequestPermissionProfile" }, + "permissionsProfilePersistence": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfilePersistence" + }, + { + "type": "null" + } + ] + }, "reason": { "type": [ "string", @@ -3272,6 +3311,9 @@ "permissions": { "$ref": "#/definitions/GrantedPermissionProfile" }, + "persistToProfile": { + "type": "boolean" + }, "scope": { "allOf": [ { @@ -3368,6 +3410,13 @@ ], "type": "string" }, + { + "description": "User has approved this request and wants the filesystem permissions to be persisted into the active named permissions profile.", + "enum": [ + "approved_persist_to_profile" + ], + "type": "string" + }, { "additionalProperties": false, "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", diff --git a/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts b/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts index b5193785d8..9453d7b973 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ReviewDecision.ts @@ -7,4 +7,4 @@ import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; /** * User's decision in response to an ExecApprovalRequest. */ -export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort"; +export type ReviewDecision = "approved" | { "approved_execpolicy_amendment": { proposed_execpolicy_amendment: ExecPolicyAmendment, } } | "approved_for_session" | "approved_persist_to_profile" | { "network_policy_amendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "denied" | "abort"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts index c022030a1e..0a71da31d1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalDecision.ts @@ -4,4 +4,4 @@ import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; -export type CommandExecutionApprovalDecision = "accept" | "acceptForSession" | { "acceptWithExecpolicyAmendment": { execpolicy_amendment: ExecPolicyAmendment, } } | { "applyNetworkPolicyAmendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "decline" | "cancel"; +export type CommandExecutionApprovalDecision = "accept" | "acceptForSession" | "acceptAndPersist" | { "acceptWithExecpolicyAmendment": { execpolicy_amendment: ExecPolicyAmendment, } } | { "applyNetworkPolicyAmendment": { network_policy_amendment: NetworkPolicyAmendment, } } | "decline" | "cancel"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 623fb971c1..f67ce95374 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -8,6 +8,7 @@ import type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExec import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; +import type { PermissionProfilePersistence } from "./PermissionProfilePersistence"; export type CommandExecutionRequestApprovalParams = { threadId: string, turnId: string, itemId: string, /** @@ -44,6 +45,11 @@ commandActions?: Array | null, * Optional additional permissions requested for this command. */ additionalPermissions?: AdditionalPermissionProfile | null, +/** + * Optional named permissions profile that can persist the requested + * filesystem access. + */ +permissionsProfilePersistence?: PermissionProfilePersistence | null, /** * Optional skill metadata when the approval was triggered by a skill script. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfilePersistence.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfilePersistence.ts new file mode 100644 index 0000000000..f419389da5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfilePersistence.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfilePersistence = { profileName: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index efdefead79..01d254ddd8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfilePersistence } from "./PermissionProfilePersistence"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, }; +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, reason: string | null, permissions: RequestPermissionProfile, permissionsProfilePersistence?: PermissionProfilePersistence | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts index 6561417b40..97b92dcfd0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -4,4 +4,4 @@ import type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; import type { PermissionGrantScope } from "./PermissionGrantScope"; -export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, }; +export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, persistToProfile?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d9cc4758bc..49201c53a1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -198,6 +198,7 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfilePersistence } from "./PermissionProfilePersistence"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 56897566d9..e3b9c247f9 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1681,6 +1681,7 @@ mod tests { }), macos: None, }), + permissions_profile_persistence: None, skill_metadata: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, @@ -1706,6 +1707,7 @@ mod tests { cwd: None, command_actions: None, additional_permissions: None, + permissions_profile_persistence: None, skill_metadata: Some(v2::CommandExecutionRequestApprovalSkillMetadata { path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 10cb8d4534..3f70cd9919 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -969,6 +969,9 @@ pub enum CommandExecutionApprovalDecision { /// User approved the command and future prompts in the same session-scoped /// approval cache should run without prompting. AcceptForSession, + /// User approved the command and wants to persist the filesystem access in + /// the active named permissions profile. + AcceptAndPersist, /// User approved the command, and wants to apply the proposed execpolicy amendment so future /// matching commands can run without prompting. AcceptWithExecpolicyAmendment { @@ -988,6 +991,7 @@ impl From for CommandExecutionApprovalDecision { fn from(value: CoreReviewDecision) -> Self { match value { CoreReviewDecision::Approved => Self::Accept, + CoreReviewDecision::ApprovedPersistToProfile => Self::AcceptAndPersist, CoreReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, } => Self::AcceptWithExecpolicyAmendment { @@ -5145,6 +5149,11 @@ pub struct CommandExecutionRequestApprovalParams { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub additional_permissions: Option, + /// Optional named permissions profile that can persist the requested + /// filesystem access. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub permissions_profile_persistence: Option, /// Optional skill metadata when the approval was triggered by a skill script. #[experimental("item/commandExecution/requestApproval.skillMetadata")] #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a479f96529..7cc3b0c75e 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1921,6 +1921,7 @@ impl CodexClient { proposed_execpolicy_amendment, proposed_network_policy_amendments, available_decisions, + permissions_profile_persistence: _, } = params; println!( diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3248a44424..0918248a39 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -931,15 +931,15 @@ When an upstream HTTP status is available (for example, from the Responses API o Certain actions (shell commands or modifying files) may require explicit user approval depending on the user's config. When `turn/start` is used, the app-server drives an approval flow by sending a server-initiated JSON-RPC request to the client. The client must respond to tell Codex whether to proceed. UIs should present these requests inline with the active turn so users can review the proposed command or diff before choosing. - Requests include `threadId` and `turnId`—use them to scope UI state to the active conversation. -- Respond with a single `{ "decision": ... }` payload. Command approvals support `accept`, `acceptForSession`, `acceptWithExecpolicyAmendment`, `applyNetworkPolicyAmendment`, `decline`, or `cancel`. The server resumes or declines the work and ends the item with `item/completed`. +- Respond with a single `{ "decision": ... }` payload. Command approvals support `accept`, `acceptForSession`, `acceptAndPersist`, `acceptWithExecpolicyAmendment`, `applyNetworkPolicyAmendment`, `decline`, or `cancel`. The server resumes or declines the work and ends the item with `item/completed`. ### Command execution approvals Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. -3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For eligible filesystem-only requests, `permissionsProfilePersistence` names the editable profile that can absorb an always-allow decision. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": "acceptAndPersist" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. @@ -975,7 +975,7 @@ Order of messages: ### Permission requests -The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the standalone tool's narrower permission shape, so it can request network access and additional filesystem access but does not include the broader `macos` branch used by command-execution `additionalPermissions`. +The built-in `request_permissions` tool sends an `item/permissions/requestApproval` JSON-RPC request to the client with the requested permission profile. This v2 payload mirrors the standalone tool's narrower permission shape, so it can request network access and additional filesystem access but does not include the broader `macos` branch used by command-execution `additionalPermissions`. For eligible filesystem-only requests, `permissionsProfilePersistence` identifies the editable named profile that can absorb an always-allow approval. Clients return the granted subset in `permissions`, the grant `scope`, and may set `persistToProfile: true` to persist that granted filesystem subset into the active profile. ```json { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 7cfa9522b3..f566021d24 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -610,6 +610,7 @@ pub(crate) async fn apply_bespoke_event_handling( proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, + permissions_profile_persistence, skill_metadata, parsed_cmd, .. @@ -680,8 +681,13 @@ pub(crate) async fn apply_bespoke_event_handling( .map(V2NetworkPolicyAmendment::from) .collect() }); - let additional_permissions = - additional_permissions.map(V2AdditionalPermissionProfile::from); + let additional_permissions_for_request = additional_permissions + .clone() + .map(V2AdditionalPermissionProfile::from); + let permissions_profile_persistence_for_request = + permissions_profile_persistence + .clone() + .map(codex_app_server_protocol::PermissionProfilePersistence::from); let skill_metadata = skill_metadata.map(CommandExecutionRequestApprovalSkillMetadata::from); @@ -695,7 +701,9 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, command_actions, - additional_permissions, + additional_permissions: additional_permissions_for_request, + permissions_profile_persistence: + permissions_profile_persistence_for_request, skill_metadata, proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, proposed_network_policy_amendments: proposed_network_policy_amendments_v2, @@ -706,6 +714,11 @@ pub(crate) async fn apply_bespoke_event_handling( params, )) .await; + let additional_permissions = additional_permissions.and_then(|permissions| { + serde_json::to_value(permissions) + .ok() + .and_then(|value| serde_json::from_value(value).ok()) + }); tokio::spawn(async move { on_command_execution_request_approval_response( event_turn_id, @@ -713,6 +726,8 @@ pub(crate) async fn apply_bespoke_event_handling( approval_id, call_id, completion_item, + additional_permissions, + permissions_profile_persistence, pending_request_id, rx, conversation, @@ -2295,6 +2310,7 @@ async fn on_exec_approval_response( id: call_id, turn_id: Some(turn_id), decision: response.decision, + persist_permissions: None, }) .await { @@ -2655,6 +2671,8 @@ async fn on_command_execution_request_approval_response( approval_id: Option, item_id: String, completion_item: Option, + additional_permissions: Option, + permissions_profile_persistence: Option, pending_request_id: RequestId, receiver: oneshot::Receiver, conversation: Arc, @@ -2682,6 +2700,9 @@ async fn on_command_execution_request_approval_response( CommandExecutionApprovalDecision::AcceptForSession => { (ReviewDecision::ApprovedForSession, None) } + CommandExecutionApprovalDecision::AcceptAndPersist => { + (ReviewDecision::ApprovedPersistToProfile, None) + } CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { execpolicy_amendment, } => ( @@ -2760,11 +2781,23 @@ async fn on_command_execution_request_approval_response( .await; } + let persist_permissions = if matches!(decision, ReviewDecision::ApprovedPersistToProfile) { + permissions_profile_persistence.and_then(|target| { + additional_permissions.map(|permissions| PersistPermissionProfileAction { + profile_name: target.profile_name, + permissions, + }) + }) + } else { + None + }; + if let Err(err) = conversation .submit(Op::ExecApproval { id: approval_id.unwrap_or_else(|| item_id.clone()), turn_id: Some(event_turn_id), decision, + persist_permissions, }) .await { diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 3e24d831ae..b2ac0f8a64 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -1124,6 +1124,7 @@ mod tests { macos: None, }, ), + permissions_profile_persistence: None, skill_metadata: Some(CommandExecutionRequestApprovalSkillMetadata { path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), }), @@ -1191,6 +1192,7 @@ mod tests { macos: None, }, ), + permissions_profile_persistence: None, skill_metadata: Some(CommandExecutionRequestApprovalSkillMetadata { path_to_skills_md: PathBuf::from("/tmp/SKILLS.md"), }), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 80e9294bc1..17ed81fda3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1619,6 +1619,7 @@ impl App { available_decisions: ev.effective_available_decisions(), network_approval_context: ev.network_approval_context.clone(), additional_permissions: ev.additional_permissions.clone(), + permissions_profile_persistence: ev.permissions_profile_persistence.clone(), })) } EventMsg::ApplyPatchApprovalRequest(ev) => Some(ThreadInteractiveRequest::Approval( @@ -4714,6 +4715,7 @@ mod tests { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: Vec::new(), }, ), @@ -6361,6 +6363,7 @@ guardian_approval = true additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: Vec::new(), }, ), @@ -6450,6 +6453,7 @@ guardian_approval = true additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: Vec::new(), }, ), diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 02efb2ec1b..164ae5b32e 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -455,6 +455,7 @@ mod tests { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: Vec::new(), }, ), @@ -464,6 +465,7 @@ mod tests { id: "approval-1".to_string(), turn_id: Some("turn-1".to_string()), decision: codex_protocol::protocol::ReviewDecision::Approved, + persist_permissions: None, }); let snapshot = store.snapshot(); @@ -599,6 +601,7 @@ mod tests { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: Vec::new(), }, ), @@ -689,6 +692,7 @@ mod tests { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: Vec::new(), }, ), @@ -700,6 +704,7 @@ mod tests { id: "call-1".to_string(), turn_id: Some("turn-1".to_string()), decision: codex_protocol::protocol::ReviewDecision::Approved, + persist_permissions: None, }); assert_eq!(store.has_pending_thread_approvals(), false); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index b78741306f..2cc1262f56 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -57,6 +57,7 @@ pub(crate) enum ApprovalRequest { available_decisions: Vec, network_approval_context: Option, additional_permissions: Option, + permissions_profile_persistence: Option, }, Permissions { thread_id: ThreadId, @@ -154,12 +155,14 @@ impl ApprovalOverlay { available_decisions, network_approval_context, additional_permissions, + permissions_profile_persistence, .. } => ( exec_options( available_decisions, network_approval_context.as_ref(), additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), ), network_approval_context.as_ref().map_or_else( || "Would you like to run the following command?".to_string(), @@ -225,8 +228,23 @@ impl ApprovalOverlay { }; if let Some(request) = self.current_request.as_ref() { match (request, &option.decision) { - (ApprovalRequest::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { - self.handle_exec_decision(id, command, decision.clone()); + ( + ApprovalRequest::Exec { + id, + command, + additional_permissions, + permissions_profile_persistence, + .. + }, + ApprovalDecision::Review(decision), + ) => { + self.handle_exec_decision( + id, + command, + additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), + decision.clone(), + ); } ( ApprovalRequest::Permissions { @@ -263,7 +281,14 @@ impl ApprovalOverlay { self.advance_queue(); } - fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + fn handle_exec_decision( + &self, + id: &str, + command: &[String], + additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, + decision: ReviewDecision, + ) { let Some(request) = self.current_request.as_ref() else { return; }; @@ -276,12 +301,18 @@ impl ApprovalOverlay { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } let thread_id = request.thread_id(); + let persist_permissions = persist_permissions_for_exec_decision( + &decision, + additional_permissions, + permissions_profile_persistence, + ); self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, op: Op::ExecApproval { id: id.to_string(), turn_id: None, decision, + persist_permissions, }, }); } @@ -458,8 +489,20 @@ impl BottomPaneView for ApprovalOverlay { && let Some(request) = self.current_request.as_ref() { match request { - ApprovalRequest::Exec { id, command, .. } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort); + ApprovalRequest::Exec { + id, + command, + additional_permissions, + permissions_profile_persistence, + .. + } => { + self.handle_exec_decision( + id, + command, + additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), + ReviewDecision::Abort, + ); } ApprovalRequest::Permissions { call_id, @@ -688,6 +731,7 @@ fn exec_options( available_decisions: &[ReviewDecision], network_approval_context: Option<&NetworkApprovalContext>, additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, ) -> Vec { available_decisions .iter() @@ -736,6 +780,14 @@ fn exec_options( display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }), + ReviewDecision::ApprovedPersistToProfile => { + permissions_profile_persistence.map(|_| ApprovalOption { + label: "Yes, always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedPersistToProfile), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }) + } ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { @@ -1031,6 +1083,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, } } @@ -1049,6 +1102,7 @@ mod tests { write: Some(vec![absolute_path("/tmp/out.txt")]), }), }, + permissions_profile_persistence: None, } } @@ -1096,6 +1150,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1124,6 +1179,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1157,6 +1213,7 @@ mod tests { ], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1214,6 +1271,7 @@ mod tests { protocol: NetworkApprovalProtocol::Https, }), additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1240,6 +1298,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1281,6 +1340,7 @@ mod tests { ], Some(&network_context), None, + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1305,6 +1365,7 @@ mod tests { ], None, None, + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1331,6 +1392,7 @@ mod tests { &[ReviewDecision::Approved, ReviewDecision::Abort], None, Some(&additional_permissions), + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1345,7 +1407,7 @@ mod tests { #[test] fn permissions_options_use_expected_labels() { - let labels: Vec = permissions_options() + let labels: Vec = permissions_options(None) .into_iter() .map(|option| option.label) .collect(); @@ -1457,6 +1519,7 @@ mod tests { }), ..Default::default() }), + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1504,6 +1567,7 @@ mod tests { }), ..Default::default() }), + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1539,6 +1603,7 @@ mod tests { protocol: NetworkApprovalProtocol::Https, }), additional_permissions: None, + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0c9e16b411..d3363077db 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1296,6 +1296,7 @@ mod tests { ], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7bfa563e4b..e380897c96 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3361,6 +3361,7 @@ impl ChatWidget { available_decisions, network_approval_context: ev.network_approval_context, additional_permissions: ev.additional_permissions, + permissions_profile_persistence: ev.permissions_profile_persistence, }; self.bottom_pane .push_approval_request(request, &self.config.features); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a614d1361d..de1d87ed99 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3314,6 +3314,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -3365,6 +3366,7 @@ async fn exec_approval_uses_approval_id_when_present() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }), }); @@ -3407,6 +3409,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -3463,6 +3466,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9046,6 +9050,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9108,6 +9113,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9157,6 +9163,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9525,6 +9532,7 @@ async fn status_widget_and_approval_modal_snapshot() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8192724da0..e18fd7976f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -813,6 +813,19 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedPersistToProfile => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + actor.subject().into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and saved those permissions".into(), + ], + ) + } ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, } => { diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index d4569ae7a8..fb29e55da4 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -172,6 +172,9 @@ fn command_execution_decision_to_review_decision( codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => { codex_protocol::protocol::ReviewDecision::ApprovedForSession } + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptAndPersist => { + codex_protocol::protocol::ReviewDecision::ApprovedPersistToProfile + } codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { execpolicy_amendment, } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { @@ -208,12 +211,16 @@ fn default_exec_approval_decisions( &[codex_protocol::approvals::NetworkPolicyAmendment], >, additional_permissions: Option<&codex_protocol::models::PermissionProfile>, + permissions_profile_persistence: Option< + &codex_protocol::request_permissions::PermissionProfilePersistence, + >, ) -> Vec { ExecApprovalRequestEvent::default_available_decisions( network_approval_context, proposed_execpolicy_amendment, proposed_network_policy_amendments, additional_permissions, + permissions_profile_persistence, ) } @@ -1632,6 +1639,14 @@ impl App { .map(codex_app_server_protocol::NetworkPolicyAmendment::into_core) .collect::>() }); + let permissions_profile_persistence = params + .permissions_profile_persistence + .clone() + .map(|target| { + codex_protocol::request_permissions::PermissionProfilePersistence { + profile_name: target.profile_name, + } + }); Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { thread_id, thread_label, @@ -1656,10 +1671,12 @@ impl App { proposed_execpolicy_amendment.as_ref(), proposed_network_policy_amendments.as_deref(), additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), ) }), network_approval_context, additional_permissions, + permissions_profile_persistence, })) } ServerRequest::FileChangeRequestApproval { params, .. } => Some( @@ -1713,6 +1730,14 @@ impl App { serde_json::to_value(¶ms.permissions).ok()?, ) .ok()?, + permissions_profile_persistence: params + .permissions_profile_persistence + .clone() + .map(|target| { + codex_protocol::request_permissions::PermissionProfilePersistence { + profile_name: target.profile_name, + } + }), }), ), _ => None, @@ -5165,6 +5190,7 @@ mod tests { use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; + use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; use codex_app_server_protocol::AgentMessageDeltaNotification; @@ -5174,6 +5200,7 @@ mod tests { use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction; + use codex_app_server_protocol::PermissionProfilePersistence as AppServerPermissionProfilePersistence; use codex_app_server_protocol::RequestId as AppServerRequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -5208,6 +5235,7 @@ mod tests { use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NetworkApprovalProtocol; + use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; @@ -5216,6 +5244,7 @@ mod tests { use codex_protocol::protocol::TurnContextItem; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; + use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -7059,6 +7088,58 @@ guardian_approval = true ); } + #[tokio::test] + async fn inactive_thread_exec_approval_includes_persist_decision_in_fallbacks() { + let app = make_test_app().await; + let thread_id = ThreadId::new(); + let mut request = exec_approval_request(thread_id, "turn-approval", "call-approval", None); + let ServerRequest::CommandExecutionRequestApproval { params, .. } = &mut request else { + panic!("expected exec approval request"); + }; + params.additional_permissions = Some(AdditionalPermissionProfile { + network: None, + file_system: Some(AdditionalFileSystemPermissions { + read: None, + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from("/tmp/persist-me")) + .expect("absolute path"), + ]), + }), + macos: None, + }); + params.permissions_profile_persistence = Some(AppServerPermissionProfilePersistence { + profile_name: "default".to_string(), + }); + + let Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec { + available_decisions, + permissions_profile_persistence, + .. + })) = app + .interactive_request_for_thread_request(thread_id, &request) + .await + else { + panic!("expected exec approval request"); + }; + + assert_eq!( + permissions_profile_persistence, + Some( + codex_protocol::request_permissions::PermissionProfilePersistence { + profile_name: "default".to_string(), + } + ) + ); + assert_eq!( + available_decisions, + vec![ + ReviewDecision::Approved, + ReviewDecision::ApprovedPersistToProfile, + ReviewDecision::Abort, + ] + ); + } + #[tokio::test] async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> { @@ -7762,6 +7843,7 @@ guardian_approval = true cwd: Some(PathBuf::from("/tmp/project")), command_actions: None, additional_permissions: None, + permissions_profile_persistence: None, skill_metadata: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 4381e883c0..7c0b08b068 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -149,22 +149,27 @@ impl PendingAppServerRequests { }) }) .transpose()?, - AppCommandView::RequestPermissionsResponse { id, response } => self + AppCommandView::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => self .permissions_approvals .remove(id) .map(|request_id| { Ok::(AppServerRequestResolution { request_id, result: serde_json::to_value(PermissionsRequestApprovalResponse { - permissions: serde_json::from_value::( - serde_json::to_value(&response.permissions).map_err(|err| { - format!("failed to encode granted permissions: {err}") - })?, - ) - .map_err(|err| { - format!("failed to decode granted permissions for app-server: {err}") - })?, + permissions: GrantedPermissionProfile { + network: response.permissions.network.clone().map(Into::into), + file_system: response + .permissions + .file_system + .clone() + .map(Into::into), + }, scope: response.scope.into(), + persist_to_profile: persist_permissions.is_some(), }) .map_err(|err| { format!("failed to serialize permissions approval response: {err}") @@ -267,6 +272,10 @@ fn file_change_decision(decision: &ReviewDecision) -> Result Ok(FileChangeApprovalDecision::AcceptForSession), ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline), ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel), + ReviewDecision::ApprovedPersistToProfile => Err( + "permission profile persistence is not a valid file change approval decision" + .to_string(), + ), ReviewDecision::ApprovedExecpolicyAmendment { .. } => { Err("execpolicy amendment is not a valid file change approval decision".to_string()) } @@ -297,6 +306,7 @@ mod tests { use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::protocol::Op; + use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use pretty_assertions::assert_eq; use serde_json::json; @@ -318,6 +328,7 @@ mod tests { cwd: None, command_actions: None, additional_permissions: None, + permissions_profile_persistence: None, skill_metadata: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, @@ -332,6 +343,7 @@ mod tests { id: "approval-1".to_string(), turn_id: None, decision: ReviewDecision::Approved, + persist_permissions: None, }) .expect("resolution should serialize") .expect("request should be pending"); @@ -356,6 +368,7 @@ mod tests { "network": { "enabled": null } })) .expect("valid permissions"), + permissions_profile_persistence: None, }, }), None @@ -383,6 +396,7 @@ mod tests { .expect("valid permissions"), scope: codex_protocol::request_permissions::PermissionGrantScope::Session, }, + persist_permissions: None, }) .expect("permissions response should serialize") .expect("permissions request should be pending"); @@ -396,6 +410,7 @@ mod tests { })) .expect("valid permissions"), scope: PermissionGrantScope::Session, + persist_to_profile: false, } ); @@ -430,6 +445,72 @@ mod tests { ); } + #[test] + fn resolves_persisted_permissions_through_app_server_request_id() { + let mut pending = PendingAppServerRequests::default(); + + assert_eq!( + pending.note_server_request(&ServerRequest::PermissionsRequestApproval { + request_id: AppServerRequestId::Integer(9), + params: PermissionsRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "perm-2".to_string(), + reason: None, + permissions: serde_json::from_value(json!({ + "fileSystem": { + "write": ["/tmp"] + } + })) + .expect("valid permissions"), + permissions_profile_persistence: None, + }, + }), + None + ); + + let permissions = pending + .take_resolution(&Op::RequestPermissionsResponse { + id: "perm-2".to_string(), + response: codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: serde_json::from_value(json!({ + "file_system": { + "write": ["/tmp"] + } + })) + .expect("valid permissions"), + scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + }, + persist_permissions: Some(PersistPermissionProfileAction { + profile_name: "default".to_string(), + permissions: serde_json::from_value(json!({ + "file_system": { + "write": ["/tmp"] + } + })) + .expect("valid permissions"), + }), + }) + .expect("permissions response should serialize") + .expect("permissions request should be pending"); + + assert_eq!(permissions.request_id, AppServerRequestId::Integer(9)); + assert_eq!( + serde_json::from_value::(permissions.result) + .expect("permissions response should decode"), + PermissionsRequestApprovalResponse { + permissions: serde_json::from_value(json!({ + "fileSystem": { + "write": ["/tmp"] + } + })) + .expect("valid permissions"), + scope: PermissionGrantScope::Turn, + persist_to_profile: true, + } + ); + } + #[test] fn correlates_mcp_elicitation_server_request_with_resolution() { let mut pending = PendingAppServerRequests::default(); diff --git a/codex-rs/tui_app_server/src/app/pending_interactive_replay.rs b/codex-rs/tui_app_server/src/app/pending_interactive_replay.rs index 67c88d5f90..12e0030205 100644 --- a/codex-rs/tui_app_server/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui_app_server/src/app/pending_interactive_replay.rs @@ -627,6 +627,7 @@ mod tests { cwd: Some(PathBuf::from("/tmp")), command_actions: None, additional_permissions: None, + permissions_profile_persistence: None, skill_metadata: None, proposed_execpolicy_amendment: None, proposed_network_policy_amendments: None, @@ -761,6 +762,7 @@ mod tests { id: "approval-1".to_string(), turn_id: Some("turn-1".to_string()), decision: ReviewDecision::Approved, + persist_permissions: None, }); let snapshot = store.snapshot(); @@ -912,6 +914,7 @@ mod tests { id: "call-1".to_string(), turn_id: Some("turn-1".to_string()), decision: ReviewDecision::Approved, + persist_permissions: None, }); assert_eq!(store.has_pending_thread_approvals(), false); diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..a923e59890 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -14,6 +14,7 @@ use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::ConversationStartParams; use codex_protocol::protocol::ConversationTextParams; use codex_protocol::protocol::Op; +use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::SandboxPolicy; @@ -68,6 +69,7 @@ pub(crate) enum AppCommandView<'a> { id: &'a str, turn_id: &'a Option, decision: &'a ReviewDecision, + persist_permissions: &'a Option, }, PatchApproval { id: &'a str, @@ -87,6 +89,7 @@ pub(crate) enum AppCommandView<'a> { RequestPermissionsResponse { id: &'a str, response: &'a RequestPermissionsResponse, + persist_permissions: &'a Option, }, ReloadUserConfig, ListSkills { @@ -203,11 +206,13 @@ impl AppCommand { id: String, turn_id: Option, decision: ReviewDecision, + persist_permissions: Option, ) -> Self { Self(Op::ExecApproval { id, turn_id, decision, + persist_permissions, }) } @@ -238,8 +243,13 @@ impl AppCommand { pub(crate) fn request_permissions_response( id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) -> Self { - Self(Op::RequestPermissionsResponse { id, response }) + Self(Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + }) } pub(crate) fn reload_user_config() -> Self { @@ -353,10 +363,12 @@ impl AppCommand { id, turn_id, decision, + persist_permissions, } => AppCommandView::ExecApproval { id, turn_id, decision, + persist_permissions, }, Op::PatchApproval { id, decision } => AppCommandView::PatchApproval { id, decision }, Op::ResolveElicitation { @@ -375,9 +387,15 @@ impl AppCommand { Op::UserInputAnswer { id, response } => { AppCommandView::UserInputAnswer { id, response } } - Op::RequestPermissionsResponse { id, response } => { - AppCommandView::RequestPermissionsResponse { id, response } - } + Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => AppCommandView::RequestPermissionsResponse { + id, + response, + persist_permissions, + }, Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, Op::ListSkills { cwds, force_reload } => AppCommandView::ListSkills { cwds, diff --git a/codex-rs/tui_app_server/src/app_event_sender.rs b/codex-rs/tui_app_server/src/app_event_sender.rs index ba113656ab..1156b219bd 100644 --- a/codex-rs/tui_app_server/src/app_event_sender.rs +++ b/codex-rs/tui_app_server/src/app_event_sender.rs @@ -5,6 +5,7 @@ use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationAction; use codex_protocol::mcp::RequestId as McpRequestId; use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -79,10 +80,17 @@ impl AppEventSender { )); } - pub(crate) fn exec_approval(&self, thread_id: ThreadId, id: String, decision: ReviewDecision) { + pub(crate) fn exec_approval( + &self, + thread_id: ThreadId, + id: String, + decision: ReviewDecision, + persist_permissions: Option, + ) { self.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::exec_approval(id, /*turn_id*/ None, decision).into_core(), + op: AppCommand::exec_approval(id, /*turn_id*/ None, decision, persist_permissions) + .into_core(), }); } @@ -91,10 +99,12 @@ impl AppEventSender { thread_id: ThreadId, id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) { self.send(AppEvent::SubmitThreadOp { thread_id, - op: AppCommand::request_permissions_response(id, response).into_core(), + op: AppCommand::request_permissions_response(id, response, persist_permissions) + .into_core(), }); } diff --git a/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs b/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs index f5d1cee621..353b8ab52f 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs @@ -29,8 +29,10 @@ use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NetworkPolicyRuleAction; #[cfg(test)] use codex_protocol::protocol::Op; +use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::PermissionProfilePersistence; use codex_protocol::request_permissions::RequestPermissionProfile; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -56,6 +58,7 @@ pub(crate) enum ApprovalRequest { available_decisions: Vec, network_approval_context: Option, additional_permissions: Option, + permissions_profile_persistence: Option, }, Permissions { thread_id: ThreadId, @@ -63,6 +66,7 @@ pub(crate) enum ApprovalRequest { call_id: String, reason: Option, permissions: RequestPermissionProfile, + permissions_profile_persistence: Option, }, ApplyPatch { thread_id: ThreadId, @@ -152,12 +156,14 @@ impl ApprovalOverlay { available_decisions, network_approval_context, additional_permissions, + permissions_profile_persistence, .. } => ( exec_options( available_decisions, network_approval_context.as_ref(), additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), ), network_approval_context.as_ref().map_or_else( || "Would you like to run the following command?".to_string(), @@ -169,8 +175,11 @@ impl ApprovalOverlay { }, ), ), - ApprovalRequest::Permissions { .. } => ( - permissions_options(), + ApprovalRequest::Permissions { + permissions_profile_persistence, + .. + } => ( + permissions_options(permissions_profile_persistence.as_ref()), "Would you like to grant these permissions?".to_string(), ), ApprovalRequest::ApplyPatch { .. } => ( @@ -220,17 +229,38 @@ impl ApprovalOverlay { }; if let Some(request) = self.current_request.as_ref() { match (request, &option.decision) { - (ApprovalRequest::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { - self.handle_exec_decision(id, command, decision.clone()); + ( + ApprovalRequest::Exec { + id, + command, + additional_permissions, + permissions_profile_persistence, + .. + }, + ApprovalDecision::Review(decision), + ) => { + self.handle_exec_decision( + id, + command, + additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), + decision.clone(), + ); } ( ApprovalRequest::Permissions { call_id, permissions, + permissions_profile_persistence, .. }, ApprovalDecision::Review(decision), - ) => self.handle_permissions_decision(call_id, permissions, decision.clone()), + ) => self.handle_permissions_decision( + call_id, + permissions, + permissions_profile_persistence.as_ref(), + decision.clone(), + ), (ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { self.handle_patch_decision(id, decision.clone()); } @@ -252,7 +282,14 @@ impl ApprovalOverlay { self.advance_queue(); } - fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { + fn handle_exec_decision( + &self, + id: &str, + command: &[String], + additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, + decision: ReviewDecision, + ) { let Some(request) = self.current_request.as_ref() else { return; }; @@ -265,21 +302,29 @@ impl ApprovalOverlay { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } let thread_id = request.thread_id(); + let persist_permissions = persist_permissions_for_exec_decision( + &decision, + additional_permissions, + permissions_profile_persistence, + ); self.app_event_tx - .exec_approval(thread_id, id.to_string(), decision); + .exec_approval(thread_id, id.to_string(), decision, persist_permissions); } fn handle_permissions_decision( &self, call_id: &str, permissions: &RequestPermissionProfile, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, decision: ReviewDecision, ) { let Some(request) = self.current_request.as_ref() else { return; }; let granted_permissions = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedPersistToProfile => permissions.clone(), ReviewDecision::Denied | ReviewDecision::Abort => Default::default(), ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), @@ -302,6 +347,11 @@ impl ApprovalOverlay { ))); } let thread_id = request.thread_id(); + let persist_permissions = persist_permissions_for_permissions_decision( + &decision, + permissions_profile_persistence, + &granted_permissions, + ); self.app_event_tx.request_permissions_response( thread_id, call_id.to_string(), @@ -309,6 +359,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, }, + persist_permissions, ); } @@ -423,15 +474,33 @@ impl BottomPaneView for ApprovalOverlay { && let Some(request) = self.current_request.as_ref() { match request { - ApprovalRequest::Exec { id, command, .. } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort); + ApprovalRequest::Exec { + id, + command, + additional_permissions, + permissions_profile_persistence, + .. + } => { + self.handle_exec_decision( + id, + command, + additional_permissions.as_ref(), + permissions_profile_persistence.as_ref(), + ReviewDecision::Abort, + ); } ApprovalRequest::Permissions { call_id, permissions, + permissions_profile_persistence, .. } => { - self.handle_permissions_decision(call_id, permissions, ReviewDecision::Abort); + self.handle_permissions_decision( + call_id, + permissions, + permissions_profile_persistence.as_ref(), + ReviewDecision::Abort, + ); } ApprovalRequest::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); @@ -647,6 +716,7 @@ fn exec_options( available_decisions: &[ReviewDecision], network_approval_context: Option<&NetworkApprovalContext>, additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, ) -> Vec { available_decisions .iter() @@ -695,6 +765,14 @@ fn exec_options( display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }), + ReviewDecision::ApprovedPersistToProfile => { + permissions_profile_persistence.map(|_| ApprovalOption { + label: "Yes, always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedPersistToProfile), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }) + } ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { @@ -841,8 +919,10 @@ fn patch_options() -> Vec { ] } -fn permissions_options() -> Vec { - vec![ +fn permissions_options( + permissions_profile_persistence: Option<&PermissionProfilePersistence>, +) -> Vec { + let mut options = vec![ ApprovalOption { label: "Yes, grant these permissions".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), @@ -861,7 +941,50 @@ fn permissions_options() -> Vec { display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], }, - ] + ]; + if permissions_profile_persistence.is_some() { + options.insert( + 1, + ApprovalOption { + label: "Yes, always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedPersistToProfile), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }, + ); + } + options +} + +fn persist_permissions_for_exec_decision( + decision: &ReviewDecision, + additional_permissions: Option<&PermissionProfile>, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, +) -> Option { + if !matches!(decision, ReviewDecision::ApprovedPersistToProfile) { + return None; + } + let permissions = additional_permissions?.clone(); + let profile_name = permissions_profile_persistence?.profile_name.clone(); + Some(PersistPermissionProfileAction { + profile_name, + permissions, + }) +} + +fn persist_permissions_for_permissions_decision( + decision: &ReviewDecision, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, + granted_permissions: &RequestPermissionProfile, +) -> Option { + if !matches!(decision, ReviewDecision::ApprovedPersistToProfile) { + return None; + } + let profile_name = permissions_profile_persistence?.profile_name.clone(); + Some(PersistPermissionProfileAction { + profile_name, + permissions: granted_permissions.clone().into(), + }) } fn elicitation_options() -> Vec { @@ -945,6 +1068,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, } } @@ -963,6 +1087,7 @@ mod tests { write: Some(vec![absolute_path("/tmp/out.txt")]), }), }, + permissions_profile_persistence: None, } } @@ -1010,6 +1135,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1038,6 +1164,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1071,6 +1198,7 @@ mod tests { ], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1128,6 +1256,7 @@ mod tests { protocol: NetworkApprovalProtocol::Https, }), additional_permissions: None, + permissions_profile_persistence: None, }, tx, Features::with_defaults(), @@ -1154,6 +1283,7 @@ mod tests { available_decisions: vec![ReviewDecision::Approved, ReviewDecision::Abort], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1195,6 +1325,7 @@ mod tests { ], Some(&network_context), None, + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1219,6 +1350,7 @@ mod tests { ], None, None, + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1245,6 +1377,7 @@ mod tests { &[ReviewDecision::Approved, ReviewDecision::Abort], None, Some(&additional_permissions), + None, ); let labels: Vec = options.into_iter().map(|option| option.label).collect(); @@ -1259,7 +1392,7 @@ mod tests { #[test] fn permissions_options_use_expected_labels() { - let labels: Vec = permissions_options() + let labels: Vec = permissions_options(None) .into_iter() .map(|option| option.label) .collect(); @@ -1322,6 +1455,7 @@ mod tests { }), ..Default::default() }), + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1370,6 +1504,7 @@ mod tests { }), ..Default::default() }), + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1417,6 +1552,7 @@ mod tests { }), ..Default::default() }), + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); @@ -1452,6 +1588,7 @@ mod tests { protocol: NetworkApprovalProtocol::Https, }), additional_permissions: None, + permissions_profile_persistence: None, }; let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index 2531f8586b..f4a7d61336 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -1286,6 +1286,7 @@ mod tests { ], network_approval_context: None, additional_permissions: None, + permissions_profile_persistence: None, } } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99bc..df701a2fab 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -1228,6 +1228,11 @@ fn exec_approval_request_from_params( .network_approval_context .and_then(convert_via_json), additional_permissions: params.additional_permissions.and_then(convert_via_json), + permissions_profile_persistence: params.permissions_profile_persistence.map(|target| { + codex_protocol::request_permissions::PermissionProfilePersistence { + profile_name: target.profile_name, + } + }), turn_id: params.turn_id, approval_id: params.approval_id, proposed_execpolicy_amendment: params @@ -1256,6 +1261,9 @@ fn exec_approval_request_from_params( codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptForSession => { codex_protocol::protocol::ReviewDecision::ApprovedForSession } + codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptAndPersist => { + codex_protocol::protocol::ReviewDecision::ApprovedPersistToProfile + } codex_app_server_protocol::CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { execpolicy_amendment, } => codex_protocol::protocol::ReviewDecision::ApprovedExecpolicyAmendment { @@ -1391,6 +1399,11 @@ fn request_permissions_from_params( serde_json::to_value(params.permissions).unwrap_or(serde_json::Value::Null), ) .unwrap_or_default(), + permissions_profile_persistence: params.permissions_profile_persistence.map(|target| { + codex_protocol::request_permissions::PermissionProfilePersistence { + profile_name: target.profile_name, + } + }), } } @@ -3906,6 +3919,7 @@ impl ChatWidget { available_decisions, network_approval_context: ev.network_approval_context, additional_permissions: ev.additional_permissions, + permissions_profile_persistence: ev.permissions_profile_persistence, }; self.bottom_pane .push_approval_request(request, &self.config.features); @@ -3990,6 +4004,7 @@ impl ChatWidget { call_id: ev.call_id, reason: ev.reason, permissions: ev.permissions, + permissions_profile_persistence: ev.permissions_profile_persistence, }; self.bottom_pane .push_approval_request(request, &self.config.features); diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 639b57da09..972de99699 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -3324,6 +3324,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -3375,6 +3376,7 @@ async fn exec_approval_uses_approval_id_when_present() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }), }); @@ -3417,6 +3419,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -3473,6 +3476,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9578,6 +9582,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9640,6 +9645,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -9689,6 +9695,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -10073,6 +10080,7 @@ async fn status_widget_and_approval_modal_snapshot() { additional_permissions: None, skill_metadata: None, available_decisions: None, + permissions_profile_persistence: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 38937406b7..288aa55264 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -819,6 +819,19 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedPersistToProfile => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + actor.subject().into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and saved those permissions".into(), + ], + ) + } ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, } => { From 53f85e5a66eb9b636278649e693c57309548837a Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:19:11 -0700 Subject: [PATCH 08/11] Add request_permissions profile persistence core support Co-authored-by: Codex --- .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/core/src/codex.rs | 35 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/lib.rs | 1 + .../src/permission_profile_persistence.rs | 179 ++++++++++++++++++ codex-rs/core/src/sandboxing/mod.rs | 42 ++-- codex-rs/core/src/sandboxing/mod_tests.rs | 27 +++ .../core/src/tools/runtimes/apply_patch.rs | 30 ++- .../src/tools/runtimes/apply_patch_tests.rs | 33 ++++ codex-rs/core/tests/common/test_codex.rs | 24 ++- codex-rs/core/tests/suite/mod.rs | 20 ++ .../core/tests/suite/request_permissions.rs | 7 + .../tests/suite/request_permissions_tool.rs | 2 + codex-rs/protocol/src/protocol.rs | 9 + codex-rs/protocol/src/request_permissions.rs | 8 + .../tui/src/bottom_pane/approval_overlay.rs | 1 + .../src/app/app_server_requests.rs | 1 + codex-rs/tui_app_server/src/app_command.rs | 8 +- codex-rs/tui_app_server/src/chatwidget.rs | 1 + 20 files changed, 397 insertions(+), 36 deletions(-) create mode 100644 codex-rs/core/src/permission_profile_persistence.rs diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..43108f8703 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -888,6 +888,7 @@ pub(crate) async fn apply_bespoke_event_handling( .submit(Op::RequestPermissionsResponse { id: request.call_id, response: empty, + persist_permissions: None, }) .await { @@ -2464,6 +2465,7 @@ async fn on_request_permissions_response( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b84..ae74c2d9f9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::permission_profile_persistence::persistence_target_for_permissions; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -2998,7 +2999,11 @@ impl Session { call_id, turn_id: turn_context.sub_id.clone(), reason: args.reason, - permissions: args.permissions, + permissions: args.permissions.clone(), + permissions_profile_persistence: persistence_target_for_permissions( + turn_context.config.as_ref(), + &args.permissions.into(), + ), }); self.send_event(turn_context, event).await; rx_response.await.ok() @@ -4253,8 +4258,18 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } - Op::RequestPermissionsResponse { id, response } => { - handlers::request_permissions_response(&sess, id, response).await; + Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => { + handlers::request_permissions_response( + &sess, + id, + response, + persist_permissions, + ) + .await; false } Op::DynamicToolResponse { id, response } => { @@ -4400,6 +4415,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::permission_profile_persistence::persist_permissions_for_profile; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; @@ -4420,6 +4436,7 @@ mod handlers { use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; + use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -4681,7 +4698,19 @@ mod handlers { sess: &Arc, id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) { + if let Some(action) = persist_permissions.as_ref() + && let Err(err) = persist_permissions_for_profile(sess.as_ref(), action).await + { + let message = format!("Failed to update permissions profile: {err}"); + tracing::warn!("{message}"); + sess.send_event_raw(Event { + id: id.clone(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } sess.notify_request_permissions_response(&id, response) .await; } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..279f60f5fc 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -764,6 +764,7 @@ async fn handle_request_permissions( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await; } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..dda6da513a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -200,6 +200,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { }), ..RequestPermissionProfile::default() }, + permissions_profile_persistence: None, }, &cancel_token, ) @@ -234,6 +235,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { Op::RequestPermissionsResponse { id: call_id, response: expected_response, + persist_permissions: None, } ); } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f..ae4e450052 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -54,6 +54,7 @@ mod network_policy_decision; pub mod network_proxy_loader; mod original_image_detail; mod packages; +mod permission_profile_persistence; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/permission_profile_persistence.rs b/codex-rs/core/src/permission_profile_persistence.rs new file mode 100644 index 0000000000..2e10fdde3f --- /dev/null +++ b/codex-rs/core/src/permission_profile_persistence.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeMap; +use std::io; + +use toml_edit::value; + +use crate::codex::Session; +use crate::config::Config; +use crate::config::deserialize_config_toml_with_base; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::request_permissions::PermissionProfilePersistence; + +pub(crate) fn persistence_target_for_permissions( + config: &Config, + permissions: &PermissionProfile, +) -> Option { + if !is_supported_filesystem_only_request(permissions) { + return None; + } + + let user_layer = config.config_layer_stack.get_user_layer()?; + let user_config = + deserialize_config_toml_with_base(user_layer.config.clone(), &config.codex_home).ok()?; + let profile_name = user_config.default_permissions?; + let permissions = user_config.permissions?; + permissions + .entries + .contains_key(profile_name.as_str()) + .then_some(PermissionProfilePersistence { profile_name }) +} + +pub(crate) async fn persist_permissions_for_profile( + sess: &Session, + action: &codex_protocol::protocol::PersistPermissionProfileAction, +) -> io::Result<()> { + let codex_home = sess.codex_home().await; + + let edits = filesystem_permission_edits( + action.profile_name.as_str(), + action.permissions.file_system.as_ref(), + ); + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| io::Error::other(format!("failed to persist permission profile: {err}")))?; + sess.reload_user_config_layer().await; + Ok(()) +} + +fn is_supported_filesystem_only_request(permissions: &PermissionProfile) -> bool { + let Some(file_system) = permissions.file_system.as_ref() else { + return false; + }; + + if file_system.is_empty() { + return false; + } + + if permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + return false; + } + + permissions.macos.is_none() +} + +fn filesystem_permission_edits( + profile_name: &str, + file_system: Option<&FileSystemPermissions>, +) -> Vec { + let Some(file_system) = file_system else { + return Vec::new(); + }; + + let mut path_access = BTreeMap::new(); + + if let Some(read_roots) = file_system.read.as_ref() { + for path in read_roots { + path_access + .entry(path.display().to_string()) + .or_insert(FileSystemAccessMode::Read); + } + } + + if let Some(write_roots) = file_system.write.as_ref() { + for path in write_roots { + path_access.insert(path.display().to_string(), FileSystemAccessMode::Write); + } + } + + path_access + .into_iter() + .map(|(path, access)| ConfigEdit::SetPath { + segments: vec![ + "permissions".to_string(), + profile_name.to_string(), + "filesystem".to_string(), + path, + ], + value: value(access.to_string()), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") + } + + #[test] + fn filesystem_permission_edits_upgrade_write_access() { + let edits = filesystem_permission_edits( + "workspace", + Some(&FileSystemPermissions { + read: Some(vec![ + absolute_path("/tmp/read"), + absolute_path("/tmp/write"), + ]), + write: Some(vec![absolute_path("/tmp/write")]), + }), + ); + + assert_eq!(edits.len(), 2); + match &edits[0] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + "/tmp/read".to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("read") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + match &edits[1] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + "/tmp/write".to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("write") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..18ed38df25 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -234,30 +234,32 @@ pub fn intersect_permission_profiles( requested: PermissionProfile, granted: PermissionProfile, ) -> PermissionProfile { + fn intersect_permission_paths( + requested_paths: Option>, + granted_paths: Vec, + ) -> Option> { + requested_paths.and_then(|requested_paths| { + let requested_was_explicit_empty = requested_paths.is_empty(); + let intersected = requested_paths + .into_iter() + .filter(|path| granted_paths.contains(path)) + .collect::>(); + (requested_was_explicit_empty || !intersected.is_empty()).then_some(intersected) + }) + } + let file_system = requested .file_system .map(|requested_file_system| { let granted_file_system = granted.file_system.unwrap_or_default(); - let read = requested_file_system - .read - .map(|requested_read| { - let granted_read = granted_file_system.read.unwrap_or_default(); - requested_read - .into_iter() - .filter(|path| granted_read.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - let write = requested_file_system - .write - .map(|requested_write| { - let granted_write = granted_file_system.write.unwrap_or_default(); - requested_write - .into_iter() - .filter(|path| granted_write.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); + let read = intersect_permission_paths( + requested_file_system.read, + granted_file_system.read.unwrap_or_default(), + ); + let write = intersect_permission_paths( + requested_file_system.write, + granted_file_system.write.unwrap_or_default(), + ); FileSystemPermissions { read, write } }) .filter(|file_system| !file_system.is_empty()); diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/core/src/sandboxing/mod_tests.rs index 9a7a34e49d..fec99a4886 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/core/src/sandboxing/mod_tests.rs @@ -334,6 +334,33 @@ fn intersect_permission_profiles_preserves_default_macos_grants() { ); } +#[test] +fn intersect_permission_profiles_preserves_explicit_empty_read_lists() { + let requested_dir = AbsolutePathBuf::try_from("/tmp/requested").expect("absolute path"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted).file_system, + Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir]), + }) + ); +} + #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f1e9912bc5..8137a4671f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -54,6 +54,32 @@ impl ApplyPatchRuntime { Self } + fn execution_sandbox_permissions(req: &ApplyPatchRequest) -> SandboxPermissions { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + SandboxPermissions::UseDefault + } else { + req.sandbox_permissions + } + } + + fn execution_additional_permissions(req: &ApplyPatchRequest) -> Option { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + None + } else { + req.additional_permissions.clone() + } + } + fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, @@ -97,8 +123,8 @@ impl ApplyPatchRuntime { capture_policy: ExecCapturePolicy::ShellTool, // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), + sandbox_permissions: Self::execution_sandbox_permissions(req), + additional_permissions: Self::execution_additional_permissions(req), justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index d2812b5ecf..fea0df3681 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -68,3 +68,36 @@ fn guardian_review_request_includes_patch_context() { } ); } + +#[test] +fn build_command_spec_downgrades_preapproved_additional_permissions_to_default_sandbox() { + let path = std::env::temp_dir().join("apply-patch-preapproved.txt"); + let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let request = ApplyPatchRequest { + action, + file_paths: vec![ + AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"), + ], + changes: HashMap::from([( + path, + FileChange::Add { + content: "hello".to_string(), + }, + )]), + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + additional_permissions: None, + permissions_preapproved: true, + timeout_ms: None, + codex_exe: None, + }; + + let spec = ApplyPatchRuntime::build_command_spec(&request, std::path::Path::new("/tmp")) + .expect("spec should build"); + + assert_eq!(spec.sandbox_permissions, SandboxPermissions::UseDefault); + assert_eq!(spec.additional_permissions, None); +} diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..accf87da0b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -561,15 +561,7 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { - config.codex_linux_sandbox_exe = Some(path); - } else if let Ok(exe) = std::env::current_exe() - && let Some(path) = exe - .parent() - .and_then(|parent| parent.parent()) - .map(|parent| parent.join("codex")) - && path.is_file() - { + if let Some(path) = find_codex_cli_exe() { config.codex_linux_sandbox_exe = Some(path); } @@ -590,6 +582,20 @@ impl TestCodexBuilder { } } +fn find_codex_cli_exe() -> Option { + codex_utils_cargo_bin::cargo_bin("codex") + .ok() + .filter(|path| path.file_stem().is_some_and(|stem| stem == "codex")) + .or_else(|| { + std::env::current_exe().ok().and_then(|exe| { + exe.parent() + .and_then(|parent| parent.parent()) + .map(|parent| parent.join(format!("codex{}", std::env::consts::EXE_SUFFIX))) + .filter(|path| path.is_file()) + }) + }) +} + fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { if config.model.as_deref() != Some(TEST_MODEL_WITH_EXPERIMENTAL_TOOLS) || config.model_catalog.is_some() diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..7d179f5ad1 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,6 +1,7 @@ // Aggregates all former standalone integration tests as modules. use std::ffi::OsString; +use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_arg0::Arg0PathEntryGuard; use codex_arg0::arg0_dispatch; use ctor::ctor; @@ -20,6 +21,25 @@ const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; // NOTE: this doesn't work on ARM #[ctor] pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe { + if std::env::args().nth(1).as_deref() == Some(CODEX_CORE_APPLY_PATCH_ARG1) { + let patch_arg = std::env::args().nth(2); + let exit_code = match patch_arg { + Some(patch_arg) => { + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + Ok(()) => 0, + Err(_) => 1, + } + } + None => { + eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + 1 + } + }; + std::process::exit(exit_code); + } + #[allow(clippy::unwrap_used)] let codex_home = tempfile::Builder::new() .prefix("codex-core-tests") diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..0f730fd8c4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -1087,6 +1087,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1201,6 +1202,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1314,6 +1316,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1423,6 +1426,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1569,6 +1573,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() permissions: granted_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1687,6 +1692,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1804,6 +1810,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Session, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..9053b4c750 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -261,6 +261,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -380,6 +381,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..66a13a5d0f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -403,6 +403,9 @@ pub enum Op { id: String, /// User-granted permissions. response: RequestPermissionsResponse, + /// Optional permission-profile mutation to persist alongside the grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + persist_permissions: Option, }, /// Resolve a dynamic tool call request. @@ -3211,6 +3214,12 @@ impl ReviewDecision { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PersistPermissionProfileAction { + pub profile_name: String, + pub permissions: crate::models::PermissionProfile, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index db5396c5e4..991ad6e427 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -14,6 +14,11 @@ pub enum PermissionGrantScope { Session, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PermissionProfilePersistence { + pub profile_name: String, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct RequestPermissionProfile { @@ -71,4 +76,7 @@ pub struct RequestPermissionsEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub permissions_profile_persistence: Option, } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1b403c251f..bdc39f764e 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -315,6 +315,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, }, + persist_permissions: None, }, }); } diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 4381e883c0..90f32b33c3 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -383,6 +383,7 @@ mod tests { .expect("valid permissions"), scope: codex_protocol::request_permissions::PermissionGrantScope::Session, }, + persist_permissions: None, }) .expect("permissions response should serialize") .expect("permissions request should be pending"); diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..abe58ae4cc 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -239,7 +239,11 @@ impl AppCommand { id: String, response: RequestPermissionsResponse, ) -> Self { - Self(Op::RequestPermissionsResponse { id, response }) + Self(Op::RequestPermissionsResponse { + id, + response, + persist_permissions: None, + }) } pub(crate) fn reload_user_config() -> Self { @@ -375,7 +379,7 @@ impl AppCommand { Op::UserInputAnswer { id, response } => { AppCommandView::UserInputAnswer { id, response } } - Op::RequestPermissionsResponse { id, response } => { + Op::RequestPermissionsResponse { id, response, .. } => { AppCommandView::RequestPermissionsResponse { id, response } } Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99bc..03ad4bfb8c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -1391,6 +1391,7 @@ fn request_permissions_from_params( serde_json::to_value(params.permissions).unwrap_or(serde_json::Value::Null), ) .unwrap_or_default(), + permissions_profile_persistence: None, } } From 48fd30375cf5ad1f85bb867d60e8c95434e22f07 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:19:11 -0700 Subject: [PATCH 09/11] Add request_permissions profile persistence core support Co-authored-by: Codex --- .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/core/src/codex.rs | 35 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/lib.rs | 1 + .../src/permission_profile_persistence.rs | 179 ++++++++++++++++++ codex-rs/core/src/sandboxing/mod.rs | 42 ++-- codex-rs/core/src/sandboxing/mod_tests.rs | 28 ++- .../core/src/tools/runtimes/apply_patch.rs | 30 ++- .../src/tools/runtimes/apply_patch_tests.rs | 33 ++++ codex-rs/core/tests/common/test_codex.rs | 24 ++- codex-rs/core/tests/suite/mod.rs | 20 ++ .../core/tests/suite/request_permissions.rs | 7 + .../tests/suite/request_permissions_tool.rs | 2 + codex-rs/protocol/src/protocol.rs | 9 + codex-rs/protocol/src/request_permissions.rs | 8 + .../tui/src/bottom_pane/approval_overlay.rs | 1 + .../src/app/app_server_requests.rs | 1 + codex-rs/tui_app_server/src/app_command.rs | 8 +- codex-rs/tui_app_server/src/chatwidget.rs | 1 + 20 files changed, 397 insertions(+), 37 deletions(-) create mode 100644 codex-rs/core/src/permission_profile_persistence.rs diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..43108f8703 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -888,6 +888,7 @@ pub(crate) async fn apply_bespoke_event_handling( .submit(Op::RequestPermissionsResponse { id: request.call_id, response: empty, + persist_permissions: None, }) .await { @@ -2464,6 +2465,7 @@ async fn on_request_permissions_response( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b84..ae74c2d9f9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::permission_profile_persistence::persistence_target_for_permissions; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -2998,7 +2999,11 @@ impl Session { call_id, turn_id: turn_context.sub_id.clone(), reason: args.reason, - permissions: args.permissions, + permissions: args.permissions.clone(), + permissions_profile_persistence: persistence_target_for_permissions( + turn_context.config.as_ref(), + &args.permissions.into(), + ), }); self.send_event(turn_context, event).await; rx_response.await.ok() @@ -4253,8 +4258,18 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } - Op::RequestPermissionsResponse { id, response } => { - handlers::request_permissions_response(&sess, id, response).await; + Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => { + handlers::request_permissions_response( + &sess, + id, + response, + persist_permissions, + ) + .await; false } Op::DynamicToolResponse { id, response } => { @@ -4400,6 +4415,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::permission_profile_persistence::persist_permissions_for_profile; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; @@ -4420,6 +4436,7 @@ mod handlers { use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; + use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -4681,7 +4698,19 @@ mod handlers { sess: &Arc, id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) { + if let Some(action) = persist_permissions.as_ref() + && let Err(err) = persist_permissions_for_profile(sess.as_ref(), action).await + { + let message = format!("Failed to update permissions profile: {err}"); + tracing::warn!("{message}"); + sess.send_event_raw(Event { + id: id.clone(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } sess.notify_request_permissions_response(&id, response) .await; } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..279f60f5fc 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -764,6 +764,7 @@ async fn handle_request_permissions( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await; } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..dda6da513a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -200,6 +200,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { }), ..RequestPermissionProfile::default() }, + permissions_profile_persistence: None, }, &cancel_token, ) @@ -234,6 +235,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { Op::RequestPermissionsResponse { id: call_id, response: expected_response, + persist_permissions: None, } ); } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f..ae4e450052 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -54,6 +54,7 @@ mod network_policy_decision; pub mod network_proxy_loader; mod original_image_detail; mod packages; +mod permission_profile_persistence; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/permission_profile_persistence.rs b/codex-rs/core/src/permission_profile_persistence.rs new file mode 100644 index 0000000000..2e10fdde3f --- /dev/null +++ b/codex-rs/core/src/permission_profile_persistence.rs @@ -0,0 +1,179 @@ +use std::collections::BTreeMap; +use std::io; + +use toml_edit::value; + +use crate::codex::Session; +use crate::config::Config; +use crate::config::deserialize_config_toml_with_base; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::request_permissions::PermissionProfilePersistence; + +pub(crate) fn persistence_target_for_permissions( + config: &Config, + permissions: &PermissionProfile, +) -> Option { + if !is_supported_filesystem_only_request(permissions) { + return None; + } + + let user_layer = config.config_layer_stack.get_user_layer()?; + let user_config = + deserialize_config_toml_with_base(user_layer.config.clone(), &config.codex_home).ok()?; + let profile_name = user_config.default_permissions?; + let permissions = user_config.permissions?; + permissions + .entries + .contains_key(profile_name.as_str()) + .then_some(PermissionProfilePersistence { profile_name }) +} + +pub(crate) async fn persist_permissions_for_profile( + sess: &Session, + action: &codex_protocol::protocol::PersistPermissionProfileAction, +) -> io::Result<()> { + let codex_home = sess.codex_home().await; + + let edits = filesystem_permission_edits( + action.profile_name.as_str(), + action.permissions.file_system.as_ref(), + ); + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| io::Error::other(format!("failed to persist permission profile: {err}")))?; + sess.reload_user_config_layer().await; + Ok(()) +} + +fn is_supported_filesystem_only_request(permissions: &PermissionProfile) -> bool { + let Some(file_system) = permissions.file_system.as_ref() else { + return false; + }; + + if file_system.is_empty() { + return false; + } + + if permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + return false; + } + + permissions.macos.is_none() +} + +fn filesystem_permission_edits( + profile_name: &str, + file_system: Option<&FileSystemPermissions>, +) -> Vec { + let Some(file_system) = file_system else { + return Vec::new(); + }; + + let mut path_access = BTreeMap::new(); + + if let Some(read_roots) = file_system.read.as_ref() { + for path in read_roots { + path_access + .entry(path.display().to_string()) + .or_insert(FileSystemAccessMode::Read); + } + } + + if let Some(write_roots) = file_system.write.as_ref() { + for path in write_roots { + path_access.insert(path.display().to_string(), FileSystemAccessMode::Write); + } + } + + path_access + .into_iter() + .map(|(path, access)| ConfigEdit::SetPath { + segments: vec![ + "permissions".to_string(), + profile_name.to_string(), + "filesystem".to_string(), + path, + ], + value: value(access.to_string()), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") + } + + #[test] + fn filesystem_permission_edits_upgrade_write_access() { + let edits = filesystem_permission_edits( + "workspace", + Some(&FileSystemPermissions { + read: Some(vec![ + absolute_path("/tmp/read"), + absolute_path("/tmp/write"), + ]), + write: Some(vec![absolute_path("/tmp/write")]), + }), + ); + + assert_eq!(edits.len(), 2); + match &edits[0] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + "/tmp/read".to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("read") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + match &edits[1] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + "/tmp/write".to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("write") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..18ed38df25 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -234,30 +234,32 @@ pub fn intersect_permission_profiles( requested: PermissionProfile, granted: PermissionProfile, ) -> PermissionProfile { + fn intersect_permission_paths( + requested_paths: Option>, + granted_paths: Vec, + ) -> Option> { + requested_paths.and_then(|requested_paths| { + let requested_was_explicit_empty = requested_paths.is_empty(); + let intersected = requested_paths + .into_iter() + .filter(|path| granted_paths.contains(path)) + .collect::>(); + (requested_was_explicit_empty || !intersected.is_empty()).then_some(intersected) + }) + } + let file_system = requested .file_system .map(|requested_file_system| { let granted_file_system = granted.file_system.unwrap_or_default(); - let read = requested_file_system - .read - .map(|requested_read| { - let granted_read = granted_file_system.read.unwrap_or_default(); - requested_read - .into_iter() - .filter(|path| granted_read.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - let write = requested_file_system - .write - .map(|requested_write| { - let granted_write = granted_file_system.write.unwrap_or_default(); - requested_write - .into_iter() - .filter(|path| granted_write.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); + let read = intersect_permission_paths( + requested_file_system.read, + granted_file_system.read.unwrap_or_default(), + ); + let write = intersect_permission_paths( + requested_file_system.write, + granted_file_system.write.unwrap_or_default(), + ); FileSystemPermissions { read, write } }) .filter(|file_system| !file_system.is_empty()); diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/core/src/sandboxing/mod_tests.rs index 9a7a34e49d..bfc0341698 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/core/src/sandboxing/mod_tests.rs @@ -2,7 +2,6 @@ use super::EffectiveSandboxPermissions; use super::SandboxManager; use super::effective_file_system_sandbox_policy; -#[cfg(target_os = "macos")] use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; @@ -334,6 +333,33 @@ fn intersect_permission_profiles_preserves_default_macos_grants() { ); } +#[test] +fn intersect_permission_profiles_preserves_explicit_empty_read_lists() { + let requested_dir = AbsolutePathBuf::try_from("/tmp/requested").expect("absolute path"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted).file_system, + Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir]), + }) + ); +} + #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f1e9912bc5..8137a4671f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -54,6 +54,32 @@ impl ApplyPatchRuntime { Self } + fn execution_sandbox_permissions(req: &ApplyPatchRequest) -> SandboxPermissions { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + SandboxPermissions::UseDefault + } else { + req.sandbox_permissions + } + } + + fn execution_additional_permissions(req: &ApplyPatchRequest) -> Option { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + None + } else { + req.additional_permissions.clone() + } + } + fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, @@ -97,8 +123,8 @@ impl ApplyPatchRuntime { capture_policy: ExecCapturePolicy::ShellTool, // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), + sandbox_permissions: Self::execution_sandbox_permissions(req), + additional_permissions: Self::execution_additional_permissions(req), justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index d2812b5ecf..fea0df3681 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -68,3 +68,36 @@ fn guardian_review_request_includes_patch_context() { } ); } + +#[test] +fn build_command_spec_downgrades_preapproved_additional_permissions_to_default_sandbox() { + let path = std::env::temp_dir().join("apply-patch-preapproved.txt"); + let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let request = ApplyPatchRequest { + action, + file_paths: vec![ + AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"), + ], + changes: HashMap::from([( + path, + FileChange::Add { + content: "hello".to_string(), + }, + )]), + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + additional_permissions: None, + permissions_preapproved: true, + timeout_ms: None, + codex_exe: None, + }; + + let spec = ApplyPatchRuntime::build_command_spec(&request, std::path::Path::new("/tmp")) + .expect("spec should build"); + + assert_eq!(spec.sandbox_permissions, SandboxPermissions::UseDefault); + assert_eq!(spec.additional_permissions, None); +} diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..accf87da0b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -561,15 +561,7 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { - config.codex_linux_sandbox_exe = Some(path); - } else if let Ok(exe) = std::env::current_exe() - && let Some(path) = exe - .parent() - .and_then(|parent| parent.parent()) - .map(|parent| parent.join("codex")) - && path.is_file() - { + if let Some(path) = find_codex_cli_exe() { config.codex_linux_sandbox_exe = Some(path); } @@ -590,6 +582,20 @@ impl TestCodexBuilder { } } +fn find_codex_cli_exe() -> Option { + codex_utils_cargo_bin::cargo_bin("codex") + .ok() + .filter(|path| path.file_stem().is_some_and(|stem| stem == "codex")) + .or_else(|| { + std::env::current_exe().ok().and_then(|exe| { + exe.parent() + .and_then(|parent| parent.parent()) + .map(|parent| parent.join(format!("codex{}", std::env::consts::EXE_SUFFIX))) + .filter(|path| path.is_file()) + }) + }) +} + fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { if config.model.as_deref() != Some(TEST_MODEL_WITH_EXPERIMENTAL_TOOLS) || config.model_catalog.is_some() diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..7d179f5ad1 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,6 +1,7 @@ // Aggregates all former standalone integration tests as modules. use std::ffi::OsString; +use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_arg0::Arg0PathEntryGuard; use codex_arg0::arg0_dispatch; use ctor::ctor; @@ -20,6 +21,25 @@ const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; // NOTE: this doesn't work on ARM #[ctor] pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe { + if std::env::args().nth(1).as_deref() == Some(CODEX_CORE_APPLY_PATCH_ARG1) { + let patch_arg = std::env::args().nth(2); + let exit_code = match patch_arg { + Some(patch_arg) => { + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + Ok(()) => 0, + Err(_) => 1, + } + } + None => { + eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + 1 + } + }; + std::process::exit(exit_code); + } + #[allow(clippy::unwrap_used)] let codex_home = tempfile::Builder::new() .prefix("codex-core-tests") diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..0f730fd8c4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -1087,6 +1087,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1201,6 +1202,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1314,6 +1316,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1423,6 +1426,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1569,6 +1573,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() permissions: granted_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1687,6 +1692,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1804,6 +1810,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Session, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..9053b4c750 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -261,6 +261,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -380,6 +381,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..66a13a5d0f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -403,6 +403,9 @@ pub enum Op { id: String, /// User-granted permissions. response: RequestPermissionsResponse, + /// Optional permission-profile mutation to persist alongside the grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + persist_permissions: Option, }, /// Resolve a dynamic tool call request. @@ -3211,6 +3214,12 @@ impl ReviewDecision { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PersistPermissionProfileAction { + pub profile_name: String, + pub permissions: crate::models::PermissionProfile, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index db5396c5e4..991ad6e427 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -14,6 +14,11 @@ pub enum PermissionGrantScope { Session, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PermissionProfilePersistence { + pub profile_name: String, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct RequestPermissionProfile { @@ -71,4 +76,7 @@ pub struct RequestPermissionsEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub permissions_profile_persistence: Option, } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1b403c251f..bdc39f764e 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -315,6 +315,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, }, + persist_permissions: None, }, }); } diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 4381e883c0..90f32b33c3 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -383,6 +383,7 @@ mod tests { .expect("valid permissions"), scope: codex_protocol::request_permissions::PermissionGrantScope::Session, }, + persist_permissions: None, }) .expect("permissions response should serialize") .expect("permissions request should be pending"); diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..abe58ae4cc 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -239,7 +239,11 @@ impl AppCommand { id: String, response: RequestPermissionsResponse, ) -> Self { - Self(Op::RequestPermissionsResponse { id, response }) + Self(Op::RequestPermissionsResponse { + id, + response, + persist_permissions: None, + }) } pub(crate) fn reload_user_config() -> Self { @@ -375,7 +379,7 @@ impl AppCommand { Op::UserInputAnswer { id, response } => { AppCommandView::UserInputAnswer { id, response } } - Op::RequestPermissionsResponse { id, response } => { + Op::RequestPermissionsResponse { id, response, .. } => { AppCommandView::RequestPermissionsResponse { id, response } } Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99bc..03ad4bfb8c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -1391,6 +1391,7 @@ fn request_permissions_from_params( serde_json::to_value(params.permissions).unwrap_or(serde_json::Value::Null), ) .unwrap_or_default(), + permissions_profile_persistence: None, } } From af597ba8e58fdd75b8ebb29c913613167d1c4a61 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:19:11 -0700 Subject: [PATCH 10/11] Add request_permissions profile persistence core support Co-authored-by: Codex --- .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/core/src/codex.rs | 35 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/lib.rs | 1 + .../src/permission_profile_persistence.rs | 178 ++++++++++++++++++ codex-rs/core/src/sandboxing/mod.rs | 42 +++-- codex-rs/core/src/sandboxing/mod_tests.rs | 28 ++- .../core/src/tools/runtimes/apply_patch.rs | 30 ++- .../src/tools/runtimes/apply_patch_tests.rs | 33 ++++ codex-rs/core/tests/common/test_codex.rs | 24 ++- codex-rs/core/tests/suite/mod.rs | 20 ++ .../core/tests/suite/request_permissions.rs | 7 + .../tests/suite/request_permissions_tool.rs | 2 + codex-rs/protocol/src/protocol.rs | 9 + codex-rs/protocol/src/request_permissions.rs | 8 + .../tui/src/bottom_pane/approval_overlay.rs | 1 + .../src/app/app_server_requests.rs | 1 + codex-rs/tui_app_server/src/app_command.rs | 8 +- codex-rs/tui_app_server/src/chatwidget.rs | 1 + 20 files changed, 396 insertions(+), 37 deletions(-) create mode 100644 codex-rs/core/src/permission_profile_persistence.rs diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..43108f8703 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -888,6 +888,7 @@ pub(crate) async fn apply_bespoke_event_handling( .submit(Op::RequestPermissionsResponse { id: request.call_id, response: empty, + persist_permissions: None, }) .await { @@ -2464,6 +2465,7 @@ async fn on_request_permissions_response( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b84..ae74c2d9f9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::permission_profile_persistence::persistence_target_for_permissions; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -2998,7 +2999,11 @@ impl Session { call_id, turn_id: turn_context.sub_id.clone(), reason: args.reason, - permissions: args.permissions, + permissions: args.permissions.clone(), + permissions_profile_persistence: persistence_target_for_permissions( + turn_context.config.as_ref(), + &args.permissions.into(), + ), }); self.send_event(turn_context, event).await; rx_response.await.ok() @@ -4253,8 +4258,18 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } - Op::RequestPermissionsResponse { id, response } => { - handlers::request_permissions_response(&sess, id, response).await; + Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => { + handlers::request_permissions_response( + &sess, + id, + response, + persist_permissions, + ) + .await; false } Op::DynamicToolResponse { id, response } => { @@ -4400,6 +4415,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::permission_profile_persistence::persist_permissions_for_profile; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; @@ -4420,6 +4436,7 @@ mod handlers { use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; + use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -4681,7 +4698,19 @@ mod handlers { sess: &Arc, id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) { + if let Some(action) = persist_permissions.as_ref() + && let Err(err) = persist_permissions_for_profile(sess.as_ref(), action).await + { + let message = format!("Failed to update permissions profile: {err}"); + tracing::warn!("{message}"); + sess.send_event_raw(Event { + id: id.clone(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } sess.notify_request_permissions_response(&id, response) .await; } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..279f60f5fc 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -764,6 +764,7 @@ async fn handle_request_permissions( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await; } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..dda6da513a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -200,6 +200,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { }), ..RequestPermissionProfile::default() }, + permissions_profile_persistence: None, }, &cancel_token, ) @@ -234,6 +235,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { Op::RequestPermissionsResponse { id: call_id, response: expected_response, + persist_permissions: None, } ); } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 29436a0d7f..ae4e450052 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -54,6 +54,7 @@ mod network_policy_decision; pub mod network_proxy_loader; mod original_image_detail; mod packages; +mod permission_profile_persistence; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/permission_profile_persistence.rs b/codex-rs/core/src/permission_profile_persistence.rs new file mode 100644 index 0000000000..1587f8809f --- /dev/null +++ b/codex-rs/core/src/permission_profile_persistence.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeMap; +use std::io; + +use toml_edit::value; + +use crate::codex::Session; +use crate::config::Config; +use crate::config::deserialize_config_toml_with_base; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::request_permissions::PermissionProfilePersistence; + +pub(crate) fn persistence_target_for_permissions( + config: &Config, + permissions: &PermissionProfile, +) -> Option { + if !is_supported_filesystem_only_request(permissions) { + return None; + } + + let user_layer = config.config_layer_stack.get_user_layer()?; + let user_config = + deserialize_config_toml_with_base(user_layer.config.clone(), &config.codex_home).ok()?; + let profile_name = user_config.default_permissions?; + let permissions = user_config.permissions?; + permissions + .entries + .contains_key(profile_name.as_str()) + .then_some(PermissionProfilePersistence { profile_name }) +} + +pub(crate) async fn persist_permissions_for_profile( + sess: &Session, + action: &codex_protocol::protocol::PersistPermissionProfileAction, +) -> io::Result<()> { + let codex_home = sess.codex_home().await; + + let edits = filesystem_permission_edits( + action.profile_name.as_str(), + action.permissions.file_system.as_ref(), + ); + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| io::Error::other(format!("failed to persist permission profile: {err}")))?; + sess.reload_user_config_layer().await; + Ok(()) +} + +fn is_supported_filesystem_only_request(permissions: &PermissionProfile) -> bool { + let Some(file_system) = permissions.file_system.as_ref() else { + return false; + }; + + if file_system.is_empty() { + return false; + } + + if permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + return false; + } + + permissions.macos.is_none() +} + +fn filesystem_permission_edits( + profile_name: &str, + file_system: Option<&FileSystemPermissions>, +) -> Vec { + let Some(file_system) = file_system else { + return Vec::new(); + }; + + let mut path_access = BTreeMap::new(); + + if let Some(read_roots) = file_system.read.as_ref() { + for path in read_roots { + path_access + .entry(path.display().to_string()) + .or_insert(FileSystemAccessMode::Read); + } + } + + if let Some(write_roots) = file_system.write.as_ref() { + for path in write_roots { + path_access.insert(path.display().to_string(), FileSystemAccessMode::Write); + } + } + + path_access + .into_iter() + .map(|(path, access)| ConfigEdit::SetPath { + segments: vec![ + "permissions".to_string(), + profile_name.to_string(), + "filesystem".to_string(), + path, + ], + value: value(access.to_string()), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + + fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") + } + + #[test] + fn filesystem_permission_edits_upgrade_write_access() { + let read_path = absolute_path("/tmp/read"); + let write_path = absolute_path("/tmp/write"); + let edits = filesystem_permission_edits( + "workspace", + Some(&FileSystemPermissions { + read: Some(vec![read_path.clone(), write_path.clone()]), + write: Some(vec![write_path.clone()]), + }), + ); + + assert_eq!(edits.len(), 2); + match &edits[0] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + read_path.display().to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("read") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + match &edits[1] { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + &[ + "permissions".to_string(), + "workspace".to_string(), + "filesystem".to_string(), + write_path.display().to_string(), + ] + ); + assert_eq!( + value.as_value().and_then(toml_edit::Value::as_str), + Some("write") + ); + } + other => panic!("unexpected edit: {other:?}"), + } + } +} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..18ed38df25 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -234,30 +234,32 @@ pub fn intersect_permission_profiles( requested: PermissionProfile, granted: PermissionProfile, ) -> PermissionProfile { + fn intersect_permission_paths( + requested_paths: Option>, + granted_paths: Vec, + ) -> Option> { + requested_paths.and_then(|requested_paths| { + let requested_was_explicit_empty = requested_paths.is_empty(); + let intersected = requested_paths + .into_iter() + .filter(|path| granted_paths.contains(path)) + .collect::>(); + (requested_was_explicit_empty || !intersected.is_empty()).then_some(intersected) + }) + } + let file_system = requested .file_system .map(|requested_file_system| { let granted_file_system = granted.file_system.unwrap_or_default(); - let read = requested_file_system - .read - .map(|requested_read| { - let granted_read = granted_file_system.read.unwrap_or_default(); - requested_read - .into_iter() - .filter(|path| granted_read.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - let write = requested_file_system - .write - .map(|requested_write| { - let granted_write = granted_file_system.write.unwrap_or_default(); - requested_write - .into_iter() - .filter(|path| granted_write.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); + let read = intersect_permission_paths( + requested_file_system.read, + granted_file_system.read.unwrap_or_default(), + ); + let write = intersect_permission_paths( + requested_file_system.write, + granted_file_system.write.unwrap_or_default(), + ); FileSystemPermissions { read, write } }) .filter(|file_system| !file_system.is_empty()); diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/core/src/sandboxing/mod_tests.rs index 9a7a34e49d..bfc0341698 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/core/src/sandboxing/mod_tests.rs @@ -2,7 +2,6 @@ use super::EffectiveSandboxPermissions; use super::SandboxManager; use super::effective_file_system_sandbox_policy; -#[cfg(target_os = "macos")] use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; @@ -334,6 +333,33 @@ fn intersect_permission_profiles_preserves_default_macos_grants() { ); } +#[test] +fn intersect_permission_profiles_preserves_explicit_empty_read_lists() { + let requested_dir = AbsolutePathBuf::try_from("/tmp/requested").expect("absolute path"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted).file_system, + Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir]), + }) + ); +} + #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f1e9912bc5..8137a4671f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -54,6 +54,32 @@ impl ApplyPatchRuntime { Self } + fn execution_sandbox_permissions(req: &ApplyPatchRequest) -> SandboxPermissions { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + SandboxPermissions::UseDefault + } else { + req.sandbox_permissions + } + } + + fn execution_additional_permissions(req: &ApplyPatchRequest) -> Option { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + None + } else { + req.additional_permissions.clone() + } + } + fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, @@ -97,8 +123,8 @@ impl ApplyPatchRuntime { capture_policy: ExecCapturePolicy::ShellTool, // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), + sandbox_permissions: Self::execution_sandbox_permissions(req), + additional_permissions: Self::execution_additional_permissions(req), justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index d2812b5ecf..fea0df3681 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -68,3 +68,36 @@ fn guardian_review_request_includes_patch_context() { } ); } + +#[test] +fn build_command_spec_downgrades_preapproved_additional_permissions_to_default_sandbox() { + let path = std::env::temp_dir().join("apply-patch-preapproved.txt"); + let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let request = ApplyPatchRequest { + action, + file_paths: vec![ + AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"), + ], + changes: HashMap::from([( + path, + FileChange::Add { + content: "hello".to_string(), + }, + )]), + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + additional_permissions: None, + permissions_preapproved: true, + timeout_ms: None, + codex_exe: None, + }; + + let spec = ApplyPatchRuntime::build_command_spec(&request, std::path::Path::new("/tmp")) + .expect("spec should build"); + + assert_eq!(spec.sandbox_permissions, SandboxPermissions::UseDefault); + assert_eq!(spec.additional_permissions, None); +} diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..accf87da0b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -561,15 +561,7 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { - config.codex_linux_sandbox_exe = Some(path); - } else if let Ok(exe) = std::env::current_exe() - && let Some(path) = exe - .parent() - .and_then(|parent| parent.parent()) - .map(|parent| parent.join("codex")) - && path.is_file() - { + if let Some(path) = find_codex_cli_exe() { config.codex_linux_sandbox_exe = Some(path); } @@ -590,6 +582,20 @@ impl TestCodexBuilder { } } +fn find_codex_cli_exe() -> Option { + codex_utils_cargo_bin::cargo_bin("codex") + .ok() + .filter(|path| path.file_stem().is_some_and(|stem| stem == "codex")) + .or_else(|| { + std::env::current_exe().ok().and_then(|exe| { + exe.parent() + .and_then(|parent| parent.parent()) + .map(|parent| parent.join(format!("codex{}", std::env::consts::EXE_SUFFIX))) + .filter(|path| path.is_file()) + }) + }) +} + fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { if config.model.as_deref() != Some(TEST_MODEL_WITH_EXPERIMENTAL_TOOLS) || config.model_catalog.is_some() diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..7d179f5ad1 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,6 +1,7 @@ // Aggregates all former standalone integration tests as modules. use std::ffi::OsString; +use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_arg0::Arg0PathEntryGuard; use codex_arg0::arg0_dispatch; use ctor::ctor; @@ -20,6 +21,25 @@ const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; // NOTE: this doesn't work on ARM #[ctor] pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe { + if std::env::args().nth(1).as_deref() == Some(CODEX_CORE_APPLY_PATCH_ARG1) { + let patch_arg = std::env::args().nth(2); + let exit_code = match patch_arg { + Some(patch_arg) => { + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + Ok(()) => 0, + Err(_) => 1, + } + } + None => { + eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + 1 + } + }; + std::process::exit(exit_code); + } + #[allow(clippy::unwrap_used)] let codex_home = tempfile::Builder::new() .prefix("codex-core-tests") diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..0f730fd8c4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -1087,6 +1087,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1201,6 +1202,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1314,6 +1316,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1423,6 +1426,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1569,6 +1573,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() permissions: granted_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1687,6 +1692,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1804,6 +1810,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Session, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..9053b4c750 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -261,6 +261,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -380,6 +381,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..66a13a5d0f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -403,6 +403,9 @@ pub enum Op { id: String, /// User-granted permissions. response: RequestPermissionsResponse, + /// Optional permission-profile mutation to persist alongside the grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + persist_permissions: Option, }, /// Resolve a dynamic tool call request. @@ -3211,6 +3214,12 @@ impl ReviewDecision { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PersistPermissionProfileAction { + pub profile_name: String, + pub permissions: crate::models::PermissionProfile, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index db5396c5e4..991ad6e427 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -14,6 +14,11 @@ pub enum PermissionGrantScope { Session, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PermissionProfilePersistence { + pub profile_name: String, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct RequestPermissionProfile { @@ -71,4 +76,7 @@ pub struct RequestPermissionsEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub permissions_profile_persistence: Option, } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1b403c251f..bdc39f764e 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -315,6 +315,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, }, + persist_permissions: None, }, }); } diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 4381e883c0..90f32b33c3 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -383,6 +383,7 @@ mod tests { .expect("valid permissions"), scope: codex_protocol::request_permissions::PermissionGrantScope::Session, }, + persist_permissions: None, }) .expect("permissions response should serialize") .expect("permissions request should be pending"); diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..abe58ae4cc 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -239,7 +239,11 @@ impl AppCommand { id: String, response: RequestPermissionsResponse, ) -> Self { - Self(Op::RequestPermissionsResponse { id, response }) + Self(Op::RequestPermissionsResponse { + id, + response, + persist_permissions: None, + }) } pub(crate) fn reload_user_config() -> Self { @@ -375,7 +379,7 @@ impl AppCommand { Op::UserInputAnswer { id, response } => { AppCommandView::UserInputAnswer { id, response } } - Op::RequestPermissionsResponse { id, response } => { + Op::RequestPermissionsResponse { id, response, .. } => { AppCommandView::RequestPermissionsResponse { id, response } } Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99bc..03ad4bfb8c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -1391,6 +1391,7 @@ fn request_permissions_from_params( serde_json::to_value(params.permissions).unwrap_or(serde_json::Value::Null), ) .unwrap_or_default(), + permissions_profile_persistence: None, } } From c1a28dd49fb7f6ad80a664d682aff72b651b6965 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Sat, 21 Mar 2026 15:19:11 -0700 Subject: [PATCH 11/11] Add request_permissions profile persistence core support Co-authored-by: Codex --- .../app-server-protocol/src/protocol/v2.rs | 1 + .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/core/src/codex.rs | 42 ++++++- codex-rs/core/src/codex_delegate.rs | 4 + codex-rs/core/src/codex_delegate_tests.rs | 2 + codex-rs/core/src/config/edit.rs | 57 +++++++++ codex-rs/core/src/config/edit_tests.rs | 37 ++++++ codex-rs/core/src/config/mod.rs | 1 + codex-rs/core/src/config/permissions.rs | 46 +++++++ codex-rs/core/src/mcp_tool_call.rs | 1 + codex-rs/core/src/sandboxing/mod.rs | 42 ++++--- codex-rs/core/src/sandboxing/mod_tests.rs | 28 ++++- codex-rs/core/src/tools/network_approval.rs | 4 +- codex-rs/core/src/tools/orchestrator.rs | 2 + .../core/src/tools/runtimes/apply_patch.rs | 30 ++++- .../src/tools/runtimes/apply_patch_tests.rs | 33 +++++ .../tools/runtimes/shell/unix_escalation.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 24 ++-- codex-rs/core/tests/suite/mod.rs | 20 +++ .../core/tests/suite/request_permissions.rs | 7 ++ .../tests/suite/request_permissions_tool.rs | 2 + codex-rs/protocol/src/protocol.rs | 14 +++ codex-rs/protocol/src/request_permissions.rs | 8 ++ codex-rs/tui/src/app.rs | 1 + .../tui/src/bottom_pane/approval_overlay.rs | 115 ++++++++++++++++-- ...verlay_persistable_permissions_prompt.snap | 18 +++ codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/history_cell.rs | 12 ++ .../src/app/app_server_requests.rs | 2 + codex-rs/tui_app_server/src/app_command.rs | 8 +- .../src/bottom_pane/approval_overlay.rs | 5 +- codex-rs/tui_app_server/src/chatwidget.rs | 1 + codex-rs/tui_app_server/src/history_cell.rs | 12 ++ 33 files changed, 535 insertions(+), 48 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_persistable_permissions_prompt.snap diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 57017833a6..4ad1417b0a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -993,6 +993,7 @@ impl From for CommandExecutionApprovalDecision { execpolicy_amendment: proposed_execpolicy_amendment.into(), }, CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, + CoreReviewDecision::ApprovedPersistToProfile => Self::Accept, CoreReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => Self::ApplyNetworkPolicyAmendment { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..43108f8703 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -888,6 +888,7 @@ pub(crate) async fn apply_bespoke_event_handling( .submit(Op::RequestPermissionsResponse { id: request.call_id, response: empty, + persist_permissions: None, }) .await { @@ -2464,6 +2465,7 @@ async fn on_request_permissions_response( .submit(Op::RequestPermissionsResponse { id: call_id, response, + persist_permissions: None, }) .await { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cbaabe6b84..595491d119 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -25,6 +25,7 @@ use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::config::ManagedFeatures; +use crate::config::persistence_target_for_permissions; use crate::connectors; use crate::exec_policy::ExecPolicyManager; #[cfg(test)] @@ -2998,7 +2999,11 @@ impl Session { call_id, turn_id: turn_context.sub_id.clone(), reason: args.reason, - permissions: args.permissions, + permissions: args.permissions.clone(), + permissions_profile_persistence: persistence_target_for_permissions( + turn_context.config.as_ref(), + &args.permissions.into(), + ), }); self.send_event(turn_context, event).await; rx_response.await.ok() @@ -4253,8 +4258,18 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_user_input_response(&sess, id, response).await; false } - Op::RequestPermissionsResponse { id, response } => { - handlers::request_permissions_response(&sess, id, response).await; + Op::RequestPermissionsResponse { + id, + response, + persist_permissions, + } => { + handlers::request_permissions_response( + &sess, + id, + response, + persist_permissions, + ) + .await; false } Op::DynamicToolResponse { id, response } => { @@ -4400,6 +4415,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::config::edit::ConfigEditsBuilder; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; @@ -4420,6 +4436,7 @@ mod handlers { use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; + use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; @@ -4681,7 +4698,26 @@ mod handlers { sess: &Arc, id: String, response: RequestPermissionsResponse, + persist_permissions: Option, ) { + if let Some(action) = persist_permissions { + let codex_home = sess.codex_home().await; + if let Err(err) = ConfigEditsBuilder::new(&codex_home) + .persist_permission_profile(action) + .apply() + .await + { + let message = format!("Failed to update permissions profile: {err}"); + tracing::warn!("{message}"); + sess.send_event_raw(Event { + id: id.clone(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } else { + sess.reload_user_config_layer().await; + } + } sess.notify_request_permissions_response(&id, response) .await; } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..1864318809 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -700,6 +700,7 @@ async fn maybe_auto_review_mcp_request_user_input( .map(|option| option.label.clone()) .unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()), ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(), ReviewDecision::Denied | ReviewDecision::Abort => { @@ -764,6 +765,9 @@ async fn handle_request_permissions( .submit(Op::RequestPermissionsResponse { id: call_id, response, + // TODO: Thread the user's persist_permissions choice back through + // parent_session.request_permissions instead of dropping it here. + persist_permissions: None, }) .await; } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 8201424d8e..dda6da513a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -200,6 +200,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { }), ..RequestPermissionProfile::default() }, + permissions_profile_persistence: None, }, &cancel_token, ) @@ -234,6 +235,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { Op::RequestPermissionsResponse { id: call_id, response: expected_response, + persist_permissions: None, } ); } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 2865ace4b2..8378907474 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -8,7 +8,10 @@ use codex_features::FEATURES; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::protocol::PersistPermissionProfileAction; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -56,6 +59,8 @@ pub enum ConfigEdit { segments: Vec, value: TomlItem, }, + /// Persist filesystem-only permissions into a named profile. + PersistPermissionProfile(PersistPermissionProfileAction), /// Remove the value stored at the exact dotted path. ClearPath { segments: Vec }, } @@ -390,6 +395,9 @@ impl ConfigDocument { Ok(self.set_skill_config(path.as_path(), *enabled)) } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), + ConfigEdit::PersistPermissionProfile(action) => { + Ok(self.persist_permission_profile(action)) + } ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { // Delegate to the existing, tested logic in config.rs to @@ -425,6 +433,23 @@ impl ConfigDocument { self.remove(segments) } + fn persist_permission_profile(&mut self, action: &PersistPermissionProfileAction) -> bool { + filesystem_path_access(action.permissions.file_system.as_ref()) + .into_iter() + .fold(false, |mut mutated, (path, access)| { + mutated |= self.insert( + &[ + "permissions".to_string(), + action.profile_name.clone(), + "filesystem".to_string(), + path, + ], + value(access.to_string()), + ); + mutated + }) + } + fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { if servers.is_empty() { return self.clear(Scope::Global, &["mcp_servers"]); @@ -699,6 +724,32 @@ fn normalize_skill_config_path(path: &Path) -> String { .to_string() } +fn filesystem_path_access( + file_system: Option<&FileSystemPermissions>, +) -> BTreeMap { + let Some(file_system) = file_system else { + return BTreeMap::new(); + }; + + let mut path_access = BTreeMap::new(); + + if let Some(read_roots) = file_system.read.as_ref() { + for path in read_roots { + path_access + .entry(path.display().to_string()) + .or_insert(FileSystemAccessMode::Read); + } + } + + if let Some(write_roots) = file_system.write.as_ref() { + for path in write_roots { + path_access.insert(path.display().to_string(), FileSystemAccessMode::Write); + } + } + + path_access +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, @@ -975,6 +1026,12 @@ impl ConfigEditsBuilder { self } + pub fn persist_permission_profile(mut self, action: PersistPermissionProfileAction) -> Self { + self.edits + .push(ConfigEdit::PersistPermissionProfile(action)); + self + } + /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 5a31d84dd0..f1893a9c90 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -1,6 +1,9 @@ use super::*; use crate::config::types::McpServerTransportConfig; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::PersistPermissionProfileAction; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -46,6 +49,40 @@ fn builder_with_edits_applies_custom_paths() { assert_eq!(contents, "enabled = true\n"); } +fn absolute_path(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("absolute path") +} + +#[test] +fn persist_permission_profile_writes_filesystem_entries() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .persist_permission_profile(PersistPermissionProfileAction { + profile_name: "workspace".to_string(), + permissions: codex_protocol::models::PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![ + absolute_path("/tmp/read"), + absolute_path("/tmp/write"), + ]), + write: Some(vec![absolute_path("/tmp/write")]), + }), + ..Default::default() + }, + }) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[permissions.workspace.filesystem] +"/tmp/read" = "read" +"/tmp/write" = "write" +"#; + assert_eq!(contents, expected); +} + #[test] fn set_model_availability_nux_count_writes_shown_count() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17abe55c46..48f0e86545 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -128,6 +128,7 @@ pub use permissions::FilesystemPermissionsToml; pub use permissions::NetworkToml; pub use permissions::PermissionProfileToml; pub use permissions::PermissionsToml; +pub(crate) use permissions::persistence_target_for_permissions; pub(crate) use permissions::resolve_permission_profile; pub use service::ConfigService; pub use service::ConfigServiceError; diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 759c269b76..43d5b42812 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -7,17 +7,22 @@ use std::path::PathBuf; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; +use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::request_permissions::PermissionProfilePersistence; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config::Config; +use crate::config::deserialize_config_toml_with_base; + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct PermissionsToml { #[serde(flatten)] @@ -144,6 +149,26 @@ pub(crate) fn network_proxy_config_from_profile_network( ) } +pub(crate) fn persistence_target_for_permissions( + config: &Config, + permissions: &PermissionProfile, +) -> Option { + if !is_supported_filesystem_only_request(permissions) { + return None; + } + + // TODO: honor inherited default permission profiles instead of only the raw user layer. + let user_layer = config.config_layer_stack.get_user_layer()?; + let user_config = + deserialize_config_toml_with_base(user_layer.config.clone(), &config.codex_home).ok()?; + let profile_name = user_config.default_permissions?; + let permissions = user_config.permissions?; + permissions + .entries + .contains_key(profile_name.as_str()) + .then_some(PermissionProfilePersistence { profile_name }) +} + pub(crate) fn resolve_permission_profile<'a>( permissions: &'a PermissionsToml, profile_name: &str, @@ -232,6 +257,27 @@ fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandb } } +fn is_supported_filesystem_only_request(permissions: &PermissionProfile) -> bool { + let Some(file_system) = permissions.file_system.as_ref() else { + return false; + }; + + if file_system.is_empty() { + return false; + } + + if permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) + { + return false; + } + + permissions.macos.is_none() +} + fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f82cc73bb5..c68c1d6a43 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -747,6 +747,7 @@ pub(crate) fn build_guardian_mcp_tool_review_request( fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpToolApprovalDecision { match decision { ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept, ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..18ed38df25 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -234,30 +234,32 @@ pub fn intersect_permission_profiles( requested: PermissionProfile, granted: PermissionProfile, ) -> PermissionProfile { + fn intersect_permission_paths( + requested_paths: Option>, + granted_paths: Vec, + ) -> Option> { + requested_paths.and_then(|requested_paths| { + let requested_was_explicit_empty = requested_paths.is_empty(); + let intersected = requested_paths + .into_iter() + .filter(|path| granted_paths.contains(path)) + .collect::>(); + (requested_was_explicit_empty || !intersected.is_empty()).then_some(intersected) + }) + } + let file_system = requested .file_system .map(|requested_file_system| { let granted_file_system = granted.file_system.unwrap_or_default(); - let read = requested_file_system - .read - .map(|requested_read| { - let granted_read = granted_file_system.read.unwrap_or_default(); - requested_read - .into_iter() - .filter(|path| granted_read.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - let write = requested_file_system - .write - .map(|requested_write| { - let granted_write = granted_file_system.write.unwrap_or_default(); - requested_write - .into_iter() - .filter(|path| granted_write.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); + let read = intersect_permission_paths( + requested_file_system.read, + granted_file_system.read.unwrap_or_default(), + ); + let write = intersect_permission_paths( + requested_file_system.write, + granted_file_system.write.unwrap_or_default(), + ); FileSystemPermissions { read, write } }) .filter(|file_system| !file_system.is_empty()); diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/core/src/sandboxing/mod_tests.rs index 9a7a34e49d..bfc0341698 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/core/src/sandboxing/mod_tests.rs @@ -2,7 +2,6 @@ use super::EffectiveSandboxPermissions; use super::SandboxManager; use super::effective_file_system_sandbox_policy; -#[cfg(target_os = "macos")] use super::intersect_permission_profiles; use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; @@ -334,6 +333,33 @@ fn intersect_permission_profiles_preserves_default_macos_grants() { ); } +#[test] +fn intersect_permission_profiles_preserves_explicit_empty_read_lists() { + let requested_dir = AbsolutePathBuf::try_from("/tmp/requested").expect("absolute path"); + let requested = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + let granted = PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir.clone()]), + }), + ..Default::default() + }; + + assert_eq!( + intersect_permission_profiles(requested, granted).file_system, + Some(FileSystemPermissions { + read: Some(Vec::new()), + write: Some(vec![requested_dir]), + }) + ); +} + #[cfg(target_os = "macos")] #[test] fn normalize_additional_permissions_preserves_macos_permissions() { diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9e92a6b00f..7595d5074e 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -392,7 +392,9 @@ impl NetworkApprovalService { let mut cache_session_deny = false; let resolved = match approval_decision { - ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { + ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile + | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { PendingApprovalDecision::AllowOnce } ReviewDecision::ApprovedForSession => PendingApprovalDecision::AllowForSession, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4b53ac156f..b9a432ce48 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -155,6 +155,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} ReviewDecision::NetworkPolicyAmendment { @@ -309,6 +310,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} ReviewDecision::NetworkPolicyAmendment { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f1e9912bc5..8137a4671f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -54,6 +54,32 @@ impl ApplyPatchRuntime { Self } + fn execution_sandbox_permissions(req: &ApplyPatchRequest) -> SandboxPermissions { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + SandboxPermissions::UseDefault + } else { + req.sandbox_permissions + } + } + + fn execution_additional_permissions(req: &ApplyPatchRequest) -> Option { + if req.permissions_preapproved + && matches!( + req.sandbox_permissions, + SandboxPermissions::WithAdditionalPermissions + ) + { + None + } else { + req.additional_permissions.clone() + } + } + fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, @@ -97,8 +123,8 @@ impl ApplyPatchRuntime { capture_policy: ExecCapturePolicy::ShellTool, // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), + sandbox_permissions: Self::execution_sandbox_permissions(req), + additional_permissions: Self::execution_additional_permissions(req), justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index d2812b5ecf..fea0df3681 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -68,3 +68,36 @@ fn guardian_review_request_includes_patch_context() { } ); } + +#[test] +fn build_command_spec_downgrades_preapproved_additional_permissions_to_default_sandbox() { + let path = std::env::temp_dir().join("apply-patch-preapproved.txt"); + let action = ApplyPatchAction::new_add_for_test(&path, "hello".to_string()); + let request = ApplyPatchRequest { + action, + file_paths: vec![ + AbsolutePathBuf::from_absolute_path(&path).expect("temp path should be absolute"), + ], + changes: HashMap::from([( + path, + FileChange::Add { + content: "hello".to_string(), + }, + )]), + exec_approval_requirement: ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + additional_permissions: None, + permissions_preapproved: true, + timeout_ms: None, + codex_exe: None, + }; + + let spec = ApplyPatchRuntime::build_command_spec(&request, std::path::Path::new("/tmp")) + .expect("spec should build"); + + assert_eq!(spec.sandbox_permissions, SandboxPermissions::UseDefault); + assert_eq!(spec.additional_permissions, None); +} diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 948018dae6..645c6969da 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -542,6 +542,7 @@ impl CoreShellActionProvider { .await? { ReviewDecision::Approved + | ReviewDecision::ApprovedPersistToProfile | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { if needs_escalation { EscalationDecision::escalate(escalation_execution.clone()) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..accf87da0b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -561,15 +561,7 @@ impl TestCodexBuilder { for hook in self.pre_build_hooks.drain(..) { hook(home.path()); } - if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { - config.codex_linux_sandbox_exe = Some(path); - } else if let Ok(exe) = std::env::current_exe() - && let Some(path) = exe - .parent() - .and_then(|parent| parent.parent()) - .map(|parent| parent.join("codex")) - && path.is_file() - { + if let Some(path) = find_codex_cli_exe() { config.codex_linux_sandbox_exe = Some(path); } @@ -590,6 +582,20 @@ impl TestCodexBuilder { } } +fn find_codex_cli_exe() -> Option { + codex_utils_cargo_bin::cargo_bin("codex") + .ok() + .filter(|path| path.file_stem().is_some_and(|stem| stem == "codex")) + .or_else(|| { + std::env::current_exe().ok().and_then(|exe| { + exe.parent() + .and_then(|parent| parent.parent()) + .map(|parent| parent.join(format!("codex{}", std::env::consts::EXE_SUFFIX))) + .filter(|path| path.is_file()) + }) + }) +} + fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { if config.model.as_deref() != Some(TEST_MODEL_WITH_EXPERIMENTAL_TOOLS) || config.model_catalog.is_some() diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..7d179f5ad1 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,6 +1,7 @@ // Aggregates all former standalone integration tests as modules. use std::ffi::OsString; +use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use codex_arg0::Arg0PathEntryGuard; use codex_arg0::arg0_dispatch; use ctor::ctor; @@ -20,6 +21,25 @@ const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; // NOTE: this doesn't work on ARM #[ctor] pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe { + if std::env::args().nth(1).as_deref() == Some(CODEX_CORE_APPLY_PATCH_ARG1) { + let patch_arg = std::env::args().nth(2); + let exit_code = match patch_arg { + Some(patch_arg) => { + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + Ok(()) => 0, + Err(_) => 1, + } + } + None => { + eprintln!("Error: {CODEX_CORE_APPLY_PATCH_ARG1} requires a UTF-8 PATCH argument."); + 1 + } + }; + std::process::exit(exit_code); + } + #[allow(clippy::unwrap_used)] let codex_home = tempfile::Builder::new() .prefix("codex-core-tests") diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..0f730fd8c4 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -1087,6 +1087,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1201,6 +1202,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1314,6 +1316,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1423,6 +1426,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i permissions: normalized_requested_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1569,6 +1573,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() permissions: granted_permissions.clone(), scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -1687,6 +1692,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; @@ -1804,6 +1810,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { permissions: normalized_requested_permissions, scope: PermissionGrantScope::Session, }, + persist_permissions: None, }) .await?; wait_for_completion(&test).await; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..9053b4c750 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -261,6 +261,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; @@ -380,6 +381,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi permissions: normalized_requested_permissions, scope: PermissionGrantScope::Turn, }, + persist_permissions: None, }) .await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4f7f2616f7..ab86b1e83b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -403,6 +403,9 @@ pub enum Op { id: String, /// User-granted permissions. response: RequestPermissionsResponse, + /// Optional permission-profile mutation to persist alongside the grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + persist_permissions: Option, }, /// Resolve a dynamic tool call request. @@ -3175,6 +3178,10 @@ pub enum ReviewDecision { /// remainder of the session. ApprovedForSession, + /// User has approved this request and wants the requested filesystem + /// permissions persisted into a named permission profile. + ApprovedPersistToProfile, + /// User chose to persist a network policy rule (allow/deny) for future /// requests to the same host. NetworkPolicyAmendment { @@ -3199,6 +3206,7 @@ impl ReviewDecision { ReviewDecision::Approved => "approved", ReviewDecision::ApprovedExecpolicyAmendment { .. } => "approved_with_amendment", ReviewDecision::ApprovedForSession => "approved_for_session", + ReviewDecision::ApprovedPersistToProfile => "approved_persist_to_profile", ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { @@ -3211,6 +3219,12 @@ impl ReviewDecision { } } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PersistPermissionProfileAction { + pub profile_name: String, + pub permissions: crate::models::PermissionProfile, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] #[ts(tag = "type")] diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index db5396c5e4..991ad6e427 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -14,6 +14,11 @@ pub enum PermissionGrantScope { Session, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct PermissionProfilePersistence { + pub profile_name: String, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct RequestPermissionProfile { @@ -71,4 +76,7 @@ pub struct RequestPermissionsEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub permissions_profile_persistence: Option, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4aa51df21c..80e9294bc1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1658,6 +1658,7 @@ impl App { call_id: ev.call_id.clone(), reason: ev.reason.clone(), permissions: ev.permissions.clone(), + permissions_profile_persistence: ev.permissions_profile_persistence.clone(), }, )), _ => None, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 1b403c251f..78fcc60972 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -28,8 +28,10 @@ use codex_protocol::protocol::FileChange; use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::Op; +use codex_protocol::protocol::PersistPermissionProfileAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::PermissionProfilePersistence; use codex_protocol::request_permissions::RequestPermissionProfile; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -62,6 +64,7 @@ pub(crate) enum ApprovalRequest { call_id: String, reason: Option, permissions: RequestPermissionProfile, + permissions_profile_persistence: Option, }, ApplyPatch { thread_id: ThreadId, @@ -168,8 +171,11 @@ impl ApprovalOverlay { }, ), ), - ApprovalRequest::Permissions { .. } => ( - permissions_options(), + ApprovalRequest::Permissions { + permissions_profile_persistence, + .. + } => ( + permissions_options(permissions_profile_persistence.as_ref()), "Would you like to grant these permissions?".to_string(), ), ApprovalRequest::ApplyPatch { .. } => ( @@ -226,10 +232,16 @@ impl ApprovalOverlay { ApprovalRequest::Permissions { call_id, permissions, + permissions_profile_persistence, .. }, ApprovalDecision::Review(decision), - ) => self.handle_permissions_decision(call_id, permissions, decision.clone()), + ) => self.handle_permissions_decision( + call_id, + permissions, + permissions_profile_persistence.as_ref(), + decision.clone(), + ), (ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { self.handle_patch_decision(id, decision.clone()); } @@ -278,13 +290,16 @@ impl ApprovalOverlay { &self, call_id: &str, permissions: &RequestPermissionProfile, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, decision: ReviewDecision, ) { let Some(request) = self.current_request.as_ref() else { return; }; let granted_permissions = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedPersistToProfile => permissions.clone(), ReviewDecision::Denied | ReviewDecision::Abort => Default::default(), ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), @@ -307,6 +322,11 @@ impl ApprovalOverlay { ))); } let thread_id = request.thread_id(); + let persist_permissions = persist_permissions_for_permissions_decision( + &decision, + permissions_profile_persistence, + &granted_permissions, + ); self.app_event_tx.send(AppEvent::SubmitThreadOp { thread_id, op: Op::RequestPermissionsResponse { @@ -315,6 +335,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, }, + persist_permissions, }, }); } @@ -443,9 +464,15 @@ impl BottomPaneView for ApprovalOverlay { ApprovalRequest::Permissions { call_id, permissions, + permissions_profile_persistence, .. } => { - self.handle_permissions_decision(call_id, permissions, ReviewDecision::Abort); + self.handle_permissions_decision( + call_id, + permissions, + permissions_profile_persistence.as_ref(), + ReviewDecision::Abort, + ); } ApprovalRequest::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); @@ -709,6 +736,7 @@ fn exec_options( display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }), + ReviewDecision::ApprovedPersistToProfile => None, ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { @@ -855,8 +883,10 @@ fn patch_options() -> Vec { ] } -fn permissions_options() -> Vec { - vec![ +fn permissions_options( + permissions_profile_persistence: Option<&PermissionProfilePersistence>, +) -> Vec { + let mut options = vec![ ApprovalOption { label: "Yes, grant these permissions".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Approved), @@ -875,7 +905,35 @@ fn permissions_options() -> Vec { display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], }, - ] + ]; + if permissions_profile_persistence.is_some() { + options.insert( + 1, + ApprovalOption { + label: "Yes, always allow these permissions".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedPersistToProfile), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + }, + ); + } + options +} + +fn persist_permissions_for_permissions_decision( + decision: &ReviewDecision, + permissions_profile_persistence: Option<&PermissionProfilePersistence>, + granted_permissions: &RequestPermissionProfile, +) -> Option { + if !matches!(decision, ReviewDecision::ApprovedPersistToProfile) { + return None; + } + + let profile_name = permissions_profile_persistence?.profile_name.clone(); + Some(PersistPermissionProfileAction { + profile_name, + permissions: granted_permissions.clone().into(), + }) } fn elicitation_options() -> Vec { @@ -977,6 +1035,31 @@ mod tests { write: Some(vec![absolute_path("/tmp/out.txt")]), }), }, + permissions_profile_persistence: None, + } + } + + fn make_persistable_permissions_request() -> ApprovalRequest { + let ApprovalRequest::Permissions { + thread_id, + thread_label, + call_id, + reason, + permissions, + .. + } = make_permissions_request() + else { + unreachable!("permissions request"); + }; + ApprovalRequest::Permissions { + thread_id, + thread_label, + call_id, + reason, + permissions, + permissions_profile_persistence: Some(PermissionProfilePersistence { + profile_name: "workspace".to_string(), + }), } } @@ -1273,7 +1356,7 @@ mod tests { #[test] fn permissions_options_use_expected_labels() { - let labels: Vec = permissions_options() + let labels: Vec = permissions_options(None) .into_iter() .map(|option| option.label) .collect(); @@ -1440,6 +1523,20 @@ mod tests { ); } + #[test] + fn persistable_permissions_prompt_snapshot() { + let view = ApprovalOverlay::new( + make_persistable_permissions_request(), + AppEventSender::new(unbounded_channel::().0), + Features::with_defaults(), + ); + + assert_snapshot!( + "approval_overlay_persistable_permissions_prompt", + normalize_snapshot_paths(render_overlay_lines(&view, 60)) + ); + } + #[test] fn network_exec_prompt_title_includes_host() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_persistable_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_persistable_permissions_prompt.snap new file mode 100644 index 0000000000..5fd13c94ef --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_persistable_permissions_prompt.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 60))" +--- + + Would you like to grant these permissions? + + Reason: need workspace access + + Permission rule: network; read `/tmp/readme.txt`; write + `/tmp/out.txt` + +› 1. Yes, grant these permissions (y) + 2. Yes, always allow these permissions (p) + 3. Yes, grant these permissions for this session (a) + 4. No, continue without permissions (n) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c8d9e3b61b..7bfa563e4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3445,6 +3445,7 @@ impl ChatWidget { call_id: ev.call_id, reason: ev.reason, permissions: ev.permissions, + permissions_profile_persistence: ev.permissions_profile_persistence, }; self.bottom_pane .push_approval_request(request, &self.config.features); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8192724da0..8cb735546b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -840,6 +840,18 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedPersistToProfile => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + actor.subject().into(), + "approved".bold(), + " codex to persist permissions for ".into(), + snippet, + ], + ) + } NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action { diff --git a/codex-rs/tui_app_server/src/app/app_server_requests.rs b/codex-rs/tui_app_server/src/app/app_server_requests.rs index 4381e883c0..365f5cf980 100644 --- a/codex-rs/tui_app_server/src/app/app_server_requests.rs +++ b/codex-rs/tui_app_server/src/app/app_server_requests.rs @@ -265,6 +265,7 @@ fn file_change_decision(decision: &ReviewDecision) -> Result Ok(FileChangeApprovalDecision::Accept), ReviewDecision::ApprovedForSession => Ok(FileChangeApprovalDecision::AcceptForSession), + ReviewDecision::ApprovedPersistToProfile => Ok(FileChangeApprovalDecision::Accept), ReviewDecision::Denied => Ok(FileChangeApprovalDecision::Decline), ReviewDecision::Abort => Ok(FileChangeApprovalDecision::Cancel), ReviewDecision::ApprovedExecpolicyAmendment { .. } => { @@ -383,6 +384,7 @@ mod tests { .expect("valid permissions"), scope: codex_protocol::request_permissions::PermissionGrantScope::Session, }, + persist_permissions: None, }) .expect("permissions response should serialize") .expect("permissions request should be pending"); diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index ed89ad86fb..abe58ae4cc 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -239,7 +239,11 @@ impl AppCommand { id: String, response: RequestPermissionsResponse, ) -> Self { - Self(Op::RequestPermissionsResponse { id, response }) + Self(Op::RequestPermissionsResponse { + id, + response, + persist_permissions: None, + }) } pub(crate) fn reload_user_config() -> Self { @@ -375,7 +379,7 @@ impl AppCommand { Op::UserInputAnswer { id, response } => { AppCommandView::UserInputAnswer { id, response } } - Op::RequestPermissionsResponse { id, response } => { + Op::RequestPermissionsResponse { id, response, .. } => { AppCommandView::RequestPermissionsResponse { id, response } } Op::ReloadUserConfig => AppCommandView::ReloadUserConfig, diff --git a/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs b/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs index f5d1cee621..52a7cf3365 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/approval_overlay.rs @@ -279,7 +279,9 @@ impl ApprovalOverlay { return; }; let granted_permissions = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), + ReviewDecision::Approved + | ReviewDecision::ApprovedForSession + | ReviewDecision::ApprovedPersistToProfile => permissions.clone(), ReviewDecision::Denied | ReviewDecision::Abort => Default::default(), ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), @@ -695,6 +697,7 @@ fn exec_options( display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }), + ReviewDecision::ApprovedPersistToProfile => None, ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, } => { diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 82cd0d99bc..03ad4bfb8c 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -1391,6 +1391,7 @@ fn request_permissions_from_params( serde_json::to_value(params.permissions).unwrap_or(serde_json::Value::Null), ) .unwrap_or_default(), + permissions_profile_persistence: None, } } diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 38937406b7..a57e80293c 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -846,6 +846,18 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedPersistToProfile => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + actor.subject().into(), + "approved".bold(), + " codex to persist permissions for ".into(), + snippet, + ], + ) + } NetworkPolicyAmendment { network_policy_amendment, } => match network_policy_amendment.action {