From ee70b365ab2894bdcbb7e1d8c886523732930428 Mon Sep 17 00:00:00 2001 From: joeytrasatti-openai Date: Wed, 22 Apr 2026 14:59:20 -0700 Subject: [PATCH 01/14] Overlay state DB git metadata for filtered thread lists (#19036) ## Summary - Factor the state DB `ThreadMetadata` to rollout `ThreadItem` mapping into a shared helper used by both DB pages and filesystem overlays - Generalize filtered filesystem list overlays to fill missing thread list metadata from the state-derived `ThreadItem`, while preserving filesystem `path` and `thread_id` - Add coverage for the merge behavior so existing filesystem values are not overwritten and future `ThreadItem` fields require an explicit decision ## Testing - `just fmt` from `codex-rs` - `git diff --check -- codex-rs/rollout/src/recorder.rs codex-rs/rollout/src/recorder_tests.rs` - Attempted `cargo test -p codex-rollout thread_item_metadata` from `codex-rs`; blocked in dependency fetch/setup after updating crates.io and git submodules `https://github.com/livekit/protocol` and `https://chromium.googlesource.com/libyuv/libyuv`, so the focused tests did not run --- codex-rs/rollout/src/recorder.rs | 152 +++++++++++++++++++------ codex-rs/rollout/src/recorder_tests.rs | 128 +++++++++++++++++++++ 2 files changed, 248 insertions(+), 32 deletions(-) diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 184cfc1919..cd93b6d558 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -528,22 +528,22 @@ impl RolloutRecorder { ) .await; } - return Ok(page_from_filesystem_scan( - fs_page, - sort_direction, - page_size, - sort_key, - )); + let page = page_from_filesystem_scan(fs_page, sort_direction, page_size, sort_key); + return Ok(fill_missing_thread_item_metadata_from_state_db( + state_db_ctx.as_deref(), + page, + ) + .await); } return Ok(db_page.into()); } if listing_has_metadata_filters { - return Ok(page_from_filesystem_scan( - fs_page, - sort_direction, - page_size, - sort_key, - )); + let page = page_from_filesystem_scan(fs_page, sort_direction, page_size, sort_key); + return Ok(fill_missing_thread_item_metadata_from_state_db( + state_db_ctx.as_deref(), + page, + ) + .await); } // If SQLite listing still fails, return the filesystem page rather than failing the list. tracing::error!("Falling back on rollout system"); @@ -978,6 +978,90 @@ fn page_from_filesystem_scan( } } +async fn fill_missing_thread_item_metadata_from_state_db( + state_db_ctx: Option<&StateRuntime>, + mut page: ThreadsPage, +) -> ThreadsPage { + let Some(state_db_ctx) = state_db_ctx else { + return page; + }; + + for item in &mut page.items { + let Some(thread_id) = item.thread_id else { + continue; + }; + let metadata = match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => metadata, + Ok(None) => continue, + Err(err) => { + warn!( + "state db get_thread failed while overlaying filesystem scan thread metadata: {err}" + ); + continue; + } + }; + fill_missing_thread_item_metadata(item, thread_item_from_state_metadata(metadata)); + } + + page +} + +fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadItem) { + let ThreadItem { + path: _state_path, + thread_id: _state_thread_id, + first_user_message, + cwd, + git_branch, + git_sha, + git_origin_url, + source, + agent_nickname, + agent_role, + model_provider, + cli_version, + created_at, + updated_at, + } = state_item; + + if item.first_user_message.is_none() { + item.first_user_message = first_user_message; + } + if item.cwd.is_none() { + item.cwd = cwd; + } + if item.git_branch.is_none() { + item.git_branch = git_branch; + } + if item.git_sha.is_none() { + item.git_sha = git_sha; + } + if item.git_origin_url.is_none() { + item.git_origin_url = git_origin_url; + } + if item.source.is_none() { + item.source = source; + } + if item.agent_nickname.is_none() { + item.agent_nickname = agent_nickname; + } + if item.agent_role.is_none() { + item.agent_role = agent_role; + } + if item.model_provider.is_none() { + item.model_provider = model_provider; + } + if item.cli_version.is_none() { + item.cli_version = cli_version; + } + if item.created_at.is_none() { + item.created_at = created_at; + } + if item.updated_at.is_none() { + item.updated_at = updated_at; + } +} + #[allow(clippy::too_many_arguments)] async fn list_threads_from_files_desc( codex_home: &Path, @@ -1684,26 +1768,7 @@ impl From for ThreadsPage { let items = db_page .items .into_iter() - .map(|item| ThreadItem { - path: item.rollout_path, - thread_id: Some(item.id), - first_user_message: item.first_user_message, - cwd: Some(item.cwd), - git_branch: item.git_branch, - git_sha: item.git_sha, - git_origin_url: item.git_origin_url, - source: Some( - serde_json::from_str(item.source.as_str()) - .or_else(|_| serde_json::from_value(Value::String(item.source))) - .unwrap_or(SessionSource::Unknown), - ), - agent_nickname: item.agent_nickname, - agent_role: item.agent_role, - model_provider: Some(item.model_provider), - cli_version: Some(item.cli_version), - created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), - updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), - }) + .map(thread_item_from_state_metadata) .collect(); Self { items, @@ -1714,6 +1779,29 @@ impl From for ThreadsPage { } } +fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadItem { + ThreadItem { + path: item.rollout_path, + thread_id: Some(item.id), + first_user_message: item.first_user_message, + cwd: Some(item.cwd), + git_branch: item.git_branch, + git_sha: item.git_sha, + git_origin_url: item.git_origin_url, + source: Some( + serde_json::from_str(item.source.as_str()) + .or_else(|_| serde_json::from_value(Value::String(item.source))) + .unwrap_or(SessionSource::Unknown), + ), + agent_nickname: item.agent_nickname, + agent_role: item.agent_role, + model_provider: Some(item.model_provider), + cli_version: Some(item.cli_version), + created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)), + updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)), + } +} + async fn select_resume_path( page: &ThreadsPage, filter_cwd: Option<&Path>, diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 852bf6c6df..e5e898857e 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -713,6 +713,134 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i Ok(()) } +#[tokio::test] +async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let uuid = Uuid::from_u128(9015); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let rollout_path = write_session_file(home.path(), "2025-01-03T16-00-00", uuid)?; + + let runtime = codex_state::StateRuntime::init( + home.path().to_path_buf(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + runtime + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + let created_at = chrono::Utc + .with_ymd_and_hms(2025, 1, 3, 16, 0, 0) + .single() + .expect("valid datetime"); + let mut builder = codex_state::ThreadMetadataBuilder::new( + thread_id, + rollout_path, + created_at, + SessionSource::Cli, + ); + builder.model_provider = Some(config.model_provider_id.clone()); + builder.cwd = home.path().to_path_buf(); + builder.git_branch = Some("sqlite-branch".to_string()); + builder.git_sha = Some("sqlite-sha".to_string()); + builder.git_origin_url = Some("https://example.com/repo.git".to_string()); + let mut metadata = builder.build(config.model_provider_id.as_str()); + metadata.first_user_message = Some("Hello from user".to_string()); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let page = RolloutRecorder::list_threads( + &config, + /*page_size*/ 10, + /*cursor*/ None, + ThreadSortKey::CreatedAt, + SortDirection::Desc, + &[SessionSource::Cli], + /*model_providers*/ None, + /*cwd_filters*/ None, + config.model_provider_id.as_str(), + /*search_term*/ None, + ) + .await?; + + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].git_branch.as_deref(), Some("sqlite-branch")); + assert_eq!(page.items[0].git_sha.as_deref(), Some("sqlite-sha")); + assert_eq!( + page.items[0].git_origin_url.as_deref(), + Some("https://example.com/repo.git") + ); + Ok(()) +} + +#[test] +fn fill_missing_thread_item_metadata_preserves_filesystem_identity() { + let filesystem_thread_id = ThreadId::new(); + let state_thread_id = ThreadId::new(); + let filesystem_path = PathBuf::from("/tmp/filesystem-rollout.jsonl"); + let state_path = PathBuf::from("/tmp/state-rollout.jsonl"); + let mut item = ThreadItem { + path: filesystem_path.clone(), + thread_id: Some(filesystem_thread_id), + first_user_message: Some("filesystem message".to_string()), + cwd: None, + git_branch: None, + git_sha: None, + git_origin_url: None, + source: None, + agent_nickname: None, + agent_role: None, + model_provider: None, + cli_version: None, + created_at: None, + updated_at: None, + }; + let state_item = ThreadItem { + path: state_path, + thread_id: Some(state_thread_id), + first_user_message: Some("state message".to_string()), + cwd: Some(PathBuf::from("/tmp/state-cwd")), + git_branch: Some("state-branch".to_string()), + git_sha: Some("state-sha".to_string()), + git_origin_url: Some("https://example.com/state.git".to_string()), + source: Some(SessionSource::Exec), + agent_nickname: Some("state-agent".to_string()), + agent_role: Some("state-role".to_string()), + model_provider: Some("state-provider".to_string()), + cli_version: Some("state-version".to_string()), + created_at: Some("2025-01-03T16:00:00Z".to_string()), + updated_at: Some("2025-01-03T16:01:02.003Z".to_string()), + }; + + fill_missing_thread_item_metadata(&mut item, state_item); + + assert_eq!(item.path, filesystem_path); + assert_eq!(item.thread_id, Some(filesystem_thread_id)); + assert_eq!( + item.first_user_message.as_deref(), + Some("filesystem message") + ); + assert_eq!(item.cwd.as_deref(), Some(Path::new("/tmp/state-cwd"))); + assert_eq!(item.git_branch.as_deref(), Some("state-branch")); + assert_eq!(item.git_sha.as_deref(), Some("state-sha")); + assert_eq!( + item.git_origin_url.as_deref(), + Some("https://example.com/state.git") + ); + assert_eq!(item.source, Some(SessionSource::Exec)); + assert_eq!(item.agent_nickname.as_deref(), Some("state-agent")); + assert_eq!(item.agent_role.as_deref(), Some("state-role")); + assert_eq!(item.model_provider.as_deref(), Some("state-provider")); + assert_eq!(item.cli_version.as_deref(), Some("state-version")); + assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); +} + #[tokio::test] async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); From 568cdacc7e540cc9ec490e867127d4cd1ef62437 Mon Sep 17 00:00:00 2001 From: khoi Date: Wed, 22 Apr 2026 15:27:15 -0700 Subject: [PATCH 02/14] [Codex] Register browser requirements feature keys (#18956) ## Summary - register `in_app_browser` and `browser_use` as stable feature keys - allow requirements/MDM feature requirements to pin those desktop browser controls - add coverage for browser requirements being accepted by config loading ## Testing - `cargo fmt --all` (`just fmt` unavailable locally; rustfmt warned about nightly-only `imports_granularity` config) - `cargo test -p codex-features` - `cargo test -p codex-core browser_feature_requirements_are_valid` - Tested manually by setting in `requirements.toml` and seeing after app restart state to reflect the setting was correct (at the time hiding the `Browser Use` setting when the enterprise setting was set to false --- codex-rs/core/config.schema.json | 12 +++++++++++ codex-rs/core/src/config/config_tests.rs | 26 ++++++++++++++++++++++++ codex-rs/features/src/lib.rs | 20 ++++++++++++++++++ codex-rs/features/src/tests.rs | 14 +++++++++++++ 4 files changed, 72 insertions(+) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 420d39e44b..828120f9d6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -350,6 +350,9 @@ "apps": { "type": "boolean" }, + "browser_use": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -422,6 +425,9 @@ "image_generation": { "type": "boolean" }, + "in_app_browser": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, @@ -2362,6 +2368,9 @@ "apps": { "type": "boolean" }, + "browser_use": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -2434,6 +2443,9 @@ "image_generation": { "type": "boolean" }, + "in_app_browser": { + "type": "boolean" + }, "include_apply_patch_tool": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3765e31f56..a0d0583cb8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6564,6 +6564,32 @@ async fn feature_requirements_normalize_effective_feature_values() -> std::io::R Ok(()) } +#[tokio::test] +async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(crate::config_loader::ConfigRequirementsToml { + feature_requirements: Some(crate::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([ + ("in_app_browser".to_string(), false), + ("browser_use".to_string(), false), + ]), + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(!config.features.enabled(Feature::InAppBrowser)); + assert!(!config.features.enabled(Feature::BrowserUse)); + + Ok(()) +} + #[tokio::test] async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b8a062f054..18f3eef9e2 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -156,6 +156,14 @@ pub enum Feature { ToolSuggest, /// Enable plugins. Plugins, + /// Allow the in-app browser pane in desktop apps. + /// + /// Requirements-only gate: this should be set from requirements, not user config. + InAppBrowser, + /// Allow Browser Use agent integration in desktop apps. + /// + /// Requirements-only gate: this should be set from requirements, not user config. + BrowserUse, /// Temporary internal-only flag for PS-backed remote plugin catalog development. RemotePlugin, /// Show the startup prompt for migrating external agent config into Codex. @@ -848,6 +856,18 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::InAppBrowser, + key: "in_app_browser", + stage: Stage::Stable, + default_enabled: true, + }, + FeatureSpec { + id: Feature::BrowserUse, + key: "browser_use", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::RemotePlugin, key: "remote_plugin", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 3f865e316b..276e573ab1 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -150,6 +150,20 @@ fn tool_search_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSearch.default_enabled(), true); } +#[test] +fn browser_controls_are_stable_and_enabled_by_default() { + assert_eq!(Feature::InAppBrowser.stage(), Stage::Stable); + assert_eq!(Feature::InAppBrowser.default_enabled(), true); + assert_eq!( + feature_for_key("in_app_browser"), + Some(Feature::InAppBrowser) + ); + + assert_eq!(Feature::BrowserUse.stage(), Stage::Stable); + assert_eq!(Feature::BrowserUse.default_enabled(), true); + assert_eq!(feature_for_key("browser_use"), Some(Feature::BrowserUse)); +} + #[test] fn unavailable_dummy_tools_is_under_development_and_disabled_by_default() { assert_eq!( From 0e25c5ff42d40f18dba4d602b60eaaed1b6a5998 Mon Sep 17 00:00:00 2001 From: Konstantine Kahadze Date: Wed, 22 Apr 2026 15:31:04 -0700 Subject: [PATCH 03/14] Update bundled OpenAI Docs skill freshness check (#19043) ## Summary Sync the bundled `openai-docs` system skill with the already-merged `openai/skills` update from https://github.com/openai/skills/pull/360. Codex bundles system skills from `codex-rs/skills/src/assets/samples`, so this PR copies the same GPT-5.4 OpenAI Docs skill update into the Codex app/CLI bundle path. ## Changes - Add the latest-model resolver script to the bundled `openai-docs` skill. - Route model upgrade and prompt-upgrade requests through remote latest-model metadata when current guidance is needed. - Rename bundled fallback references to `upgrade-guide.md` and `prompting-guide.md`. - Keep the bundled fallback guidance GPT-5.4-only. ## Validation - Verified this bundled skill is byte-for-byte identical to `openai/skills@origin/main` `skills/.system/openai-docs`. - Ran the resolver locally and confirmed it returns `gpt-5.4` / `gpt-5p4`. --- .../src/assets/samples/openai-docs/SKILL.md | 43 +- .../references/gpt-5p4-prompting-guide.md | 433 ------------- .../openai-docs/references/latest-model.md | 11 +- .../openai-docs/references/prompting-guide.md | 599 ++++++++++++++++++ ...grading-to-gpt-5p4.md => upgrade-guide.md} | 12 +- .../scripts/resolve-latest-model-info.js | 147 +++++ 6 files changed, 787 insertions(+), 458 deletions(-) delete mode 100644 codex-rs/skills/src/assets/samples/openai-docs/references/gpt-5p4-prompting-guide.md create mode 100644 codex-rs/skills/src/assets/samples/openai-docs/references/prompting-guide.md rename codex-rs/skills/src/assets/samples/openai-docs/references/{upgrading-to-gpt-5p4.md => upgrade-guide.md} (90%) create mode 100755 codex-rs/skills/src/assets/samples/openai-docs/scripts/resolve-latest-model-info.js diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index 5a67772572..6d9dbc38d0 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -1,19 +1,22 @@ --- name: "openai-docs" -description: "Use when the user asks how to build with OpenAI products or APIs and needs up-to-date official documentation with citations, help choosing the latest model for a use case, or explicit GPT-5.4 upgrade and prompt-upgrade guidance; prioritize OpenAI docs MCP tools, use bundled references only as helper context, and restrict any fallback browsing to official OpenAI domains." +description: "Use when the user asks how to build with OpenAI products or APIs and needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; prioritize OpenAI docs MCP tools, use bundled references only as helper context, and restrict any fallback browsing to official OpenAI domains." --- # OpenAI Docs -Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill may also load targeted files from `references/` for model-selection and GPT-5.4-specific requests, but current OpenAI docs remain authoritative. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. +Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill may also load targeted files from `references/` for model-selection, model-upgrade, and prompt-upgrade requests, but current OpenAI docs remain authoritative. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. ## Quick start - Use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. - Use `mcp__openaiDeveloperDocs__fetch_openai_doc` to pull exact sections and quote/paraphrase accurately. - Use `mcp__openaiDeveloperDocs__list_openai_docs` only when you need to browse or discover pages without a clear query. -- Load only the relevant file from `references/` when the question is about model selection or a GPT-5.4 upgrade. +- For model-selection, "latest model", or default-model questions, fetch `https://developers.openai.com/api/docs/guides/latest-model.md` first. If that is unavailable, load `references/latest-model.md`. +- For model upgrades or prompt upgrades, run `node scripts/resolve-latest-model-info.js` from this skill directory when the script is present, then follow `references/upgrade-guide.md` unless the resolver returns newer guidance for a dynamic latest/current/default request. +- Preserve explicit target requests: if the user names a target model like "migrate to GPT-5.4", keep that requested target even if `latest-model.md` names a newer model. Mention newer guidance only as optional. +- If current remote guidance is needed, fetch both the returned migration and prompting guide URLs directly. If direct fetch fails, use MCP/search fallback; if that also fails, use bundled fallback references and disclose the fallback. ## OpenAI product snapshots @@ -37,29 +40,39 @@ If MCP tools fail or no OpenAI docs resources are available: ## Workflow -1. Clarify the product scope and whether the request is general docs lookup, model selection, a GPT-5.4 upgrade, or a GPT-5.4 prompt upgrade. -2. If it is a model-selection request, load `references/latest-model.md`. -3. If it is an explicit GPT-5.4 upgrade request, load `references/upgrading-to-gpt-5p4.md`. -4. If the upgrade may require prompt changes, or the workflow is research-heavy, tool-heavy, coding-oriented, multi-agent, or long-running, also load `references/gpt-5p4-prompting-guide.md`. -5. Search docs with a precise query. -6. Fetch the best page and the exact section needed (use `anchor` when possible). -7. For GPT-5.4 upgrade reviews, always make the per-usage-site output explicit: target model, starting reasoning recommendation, `phase` assessment when relevant, prompt blocks, and compatibility status. -8. Answer with concise guidance and cite the doc source, using the reference files only as helper context. +1. Clarify whether the request is general docs lookup, model selection, a model-string upgrade, prompt-upgrade guidance, or broader API/provider migration. +2. For model-selection or upgrade requests, prefer current remote docs over bundled references when the user asks for latest/current/default guidance. + - Fetch `https://developers.openai.com/api/docs/guides/latest-model.md`. + - Find the latest model ID and explicit migration or prompt-guidance links. + - Prefer explicit links from the latest-model page over derived URLs. + - For explicit named-model requests, preserve the requested model target and do not silently retarget to the latest model. Mention newer remote guidance only as optional. + - For dynamic latest/current/default upgrades, run `node scripts/resolve-latest-model-info.js`, then fetch both returned guide URLs directly when possible. + - If direct guide fetch fails, use the developer-docs MCP tools or official OpenAI-domain search to find the same guide content. + - If remote docs are unavailable, use bundled fallback references and say that fallback guidance was used. +3. For model upgrades, keep changes narrow: update active OpenAI API model defaults and directly related prompts only when safe. +4. Leave historical docs, examples, eval baselines, fixtures, provider comparisons, provider registries, pricing tables, alias defaults, low-cost fallback paths, and ambiguous older model usage unchanged unless the user explicitly asks to upgrade them. +5. Do not perform SDK, tooling, IDE, plugin, shell, auth, or provider-environment migrations as part of a model-and-prompt upgrade. +6. If an upgrade needs API-surface changes, schema rewiring, tool-handler changes, or implementation work beyond a literal model-string replacement and prompt edits, report it as blocked or confirmation-needed. +7. For general docs lookup, search docs with a precise query, fetch the best page and exact section needed, and answer with concise citations. ## Reference map Read only what you need: -- `references/latest-model.md` -> model-selection and "best/latest/current model" questions; verify every recommendation against current OpenAI docs before answering. -- `references/upgrading-to-gpt-5p4.md` -> only for explicit GPT-5.4 upgrade and upgrade-planning requests; verify the checklist and compatibility guidance against current OpenAI docs before answering. -- `references/gpt-5p4-prompting-guide.md` -> prompt rewrites and prompt-behavior upgrades for GPT-5.4; verify prompting guidance against current OpenAI docs before answering. +- `https://developers.openai.com/api/docs/guides/latest-model.md` -> current model-selection and "best/latest/current model" questions. +- `references/latest-model.md` -> bundled fallback for model-selection and "best/latest/current model" questions. +- `references/upgrade-guide.md` -> bundled fallback for model upgrade and upgrade-planning requests. +- `references/prompting-guide.md` -> bundled fallback for prompt rewrites and prompt-behavior upgrades. ## Quality rules - Treat OpenAI docs as the source of truth; avoid speculation. +- Keep migration changes narrow and behavior-preserving. +- Prefer prompt-only upgrades when possible. +- Do not invent pricing, availability, parameters, API changes, or breaking changes. - Keep quotes short and within policy limits; prefer paraphrase with citations. - If multiple pages differ, call out the difference and cite both. -- Reference files are convenience guides only; for volatile guidance such as recommended models, upgrade instructions, or prompting advice, current OpenAI docs always win. +- If official docs and repo behavior disagree, state the conflict and stop before making broad edits. - If docs do not cover the user’s need, say so and offer next steps. ## Tooling notes diff --git a/codex-rs/skills/src/assets/samples/openai-docs/references/gpt-5p4-prompting-guide.md b/codex-rs/skills/src/assets/samples/openai-docs/references/gpt-5p4-prompting-guide.md deleted file mode 100644 index dc4ebde4cd..0000000000 --- a/codex-rs/skills/src/assets/samples/openai-docs/references/gpt-5p4-prompting-guide.md +++ /dev/null @@ -1,433 +0,0 @@ -# GPT-5.4 prompting upgrade guide - -Use this guide when prompts written for older models need to be adapted for GPT-5.4 during an upgrade. Start lean: keep the model-string change narrow, preserve the original task intent, and add only the smallest prompt changes needed to recover behavior. - -## Default upgrade posture - -- Start with `model string only` whenever the old prompt is already short, explicit, and task-bounded. -- Move to `model string + light prompt rewrite` only when regressions appear in completeness, persistence, citation quality, verification, or verbosity. -- Prefer one or two targeted prompt additions over a broad rewrite. -- Treat reasoning effort as a last-mile knob. Start lower, then increase only after prompt-level fixes and evals. -- Before increasing reasoning effort, first add a completeness contract, a verification loop, and tool persistence rules - depending on the usage case. -- If the workflow clearly depends on implementation changes rather than prompt changes, treat it as blocked for prompt-only upgrade guidance. -- Do not classify a case as blocked just because the workflow uses tools; block only if the upgrade requires changing tool definitions, wiring, or other implementation details. - -## Behavioral differences to account for - -Current GPT-5.4 upgrade guidance suggests these strengths: - -- stronger personality and tone adherence, with less drift over long answers -- better long-horizon and agentic workflow stamina -- stronger spreadsheet, finance, and formatting tasks -- more efficient tool selection and fewer unnecessary calls by default -- stronger structured generation and classification reliability - -The main places where prompt guidance still helps are: - -- retrieval-heavy workflows that need persistent tool use and explicit completeness -- research and citation discipline -- verification before irreversible or high-impact actions -- terminal and tool workflow hygiene -- defaults and implied follow-through -- verbosity control for compact, information-dense answers - -Start with the smallest set of instructions that preserves correctness. Add the prompt blocks below only for workflows that actually need them. - -## Prompt rewrite patterns - -| Older prompt pattern | GPT-5.4 adjustment | Why | Example addition | -| --- | --- | --- | --- | -| Long, repetitive instructions that compensate for weaker instruction following | Remove duplicate scaffolding and keep only the constraints that materially change behavior | GPT-5.4 usually needs less repeated steering | Replace repeated reminders with one concise rule plus a verification block | -| Fast assistant prompt with no verbosity control | Keep the prompt as-is first; add a verbosity clamp only if outputs become too long | Many GPT-4o or GPT-4.1 upgrades work with just a model-string swap | Add `output_verbosity_spec` only after a verbosity regression | -| Tool-heavy agent prompt that assumes the model will keep searching until complete | Add persistence and verification rules | GPT-5.4 may use fewer tool calls by default for efficiency | Add `tool_persistence_rules` and `verification_loop` | -| Tool-heavy workflow where later actions depend on earlier lookup or retrieval | Add prerequisite and missing-context rules before action steps | GPT-5.4 benefits from explicit dependency-aware routing when context is still thin | Add `dependency_checks` and `missing_context_gating` | -| Retrieval workflow with several independent lookups | Add selective parallelism guidance | GPT-5.4 is strong at parallel tool use, but should not parallelize dependent steps | Add `parallel_tool_calling` | -| Batch workflow prompt that often misses items | Add an explicit completeness contract | Item accounting benefits from direct instruction | Add `completeness_contract` | -| Research prompt that needs grounding and citation discipline | Add research, citation, and empty-result recovery blocks | Multi-pass retrieval is stronger when the model is told how to react to weak or empty search results | Add `research_mode`, `citation_rules`, and `empty_result_handling`; add `tool_persistence_rules` when retrieval tools are already in use | -| Coding or terminal prompt with shell misuse or early stop failures | Keep the same tool surface and add terminal hygiene and verification instructions | Tool-using coding workflows are not blocked just because tools exist; they usually need better prompt steering, not host rewiring | Add `terminal_tool_hygiene` and `verification_loop`, optionally `tool_persistence_rules` | -| Multi-agent or support-triage workflow with escalation or completeness requirements | Add one lightweight control block for persistence, completeness, or verification | GPT-5.4 can be more efficient by default, so multi-step support flows benefit from an explicit completion or verification contract | Add at least one of `tool_persistence_rules`, `completeness_contract`, or `verification_loop` | - -## Prompt blocks - -Use these selectively. Do not add all of them by default. - -### `output_verbosity_spec` - -Use when: - -- the upgraded model gets too wordy -- the host needs compact, information-dense answers -- the workflow benefits from a short overview plus a checklist - -```text - -- Default: 3-6 sentences or up to 6 bullets. -- If the user asked for a doc or report, use headings with short bullets. -- For multi-step tasks: - - Start with 1 short overview paragraph. - - Then provide a checklist with statuses: [done], [todo], or [blocked]. -- Avoid repeating the user's request. -- Prefer compact, information-dense writing. - -``` - -### `default_follow_through_policy` - -Use when: - -- the host expects the model to proceed on reversible, low-risk steps -- the upgraded model becomes too conservative or asks for confirmation too often - -```text - -- If the user's intent is clear and the next step is reversible and low-risk, proceed without asking permission. -- Only ask permission if the next step is: - (a) irreversible, - (b) has external side effects, or - (c) requires missing sensitive information or a choice that materially changes outcomes. -- If proceeding, state what you did and what remains optional. - -``` - -### `instruction_priority` - -Use when: - -- users often change task shape, format, or tone mid-conversation -- the host needs an explicit override policy instead of relying on defaults - -```text - -- User instructions override default style, tone, formatting, and initiative preferences. -- Safety, honesty, privacy, and permission constraints do not yield. -- If a newer user instruction conflicts with an earlier one, follow the newer instruction. -- Preserve earlier instructions that do not conflict. - -``` - -### `tool_persistence_rules` - -Use when: - -- the workflow needs multiple retrieval or verification steps -- the model starts stopping too early because it is trying to save tool calls - -```text - -- Use tools whenever they materially improve correctness, completeness, or grounding. -- Do not stop early just to save tool calls. -- Keep calling tools until: - (1) the task is complete, and - (2) verification passes. -- If a tool returns empty or partial results, retry with a different strategy. - -``` - -### `dig_deeper_nudge` - -Use when: - -- the model is too literal or stops at the first plausible answer -- the task is safety- or accuracy-sensitive and needs a small initiative nudge before raising reasoning effort - -```text - -- Do not stop at the first plausible answer. -- Look for second-order issues, edge cases, and missing constraints. -- If the task is safety- or accuracy-critical, perform at least one verification step. - -``` - -### `dependency_checks` - -Use when: - -- later actions depend on prerequisite lookup, memory retrieval, or discovery steps -- the model may be tempted to skip prerequisite work because the intended end state seems obvious - -```text - -- Before taking an action, check whether prerequisite discovery, lookup, or memory retrieval is required. -- Do not skip prerequisite steps just because the intended final action seems obvious. -- If a later step depends on the output of an earlier one, resolve that dependency first. - -``` - -### `parallel_tool_calling` - -Use when: - -- the workflow has multiple independent retrieval steps -- wall-clock time matters but some steps still need sequencing - -```text - -- When multiple retrieval or lookup steps are independent, prefer parallel tool calls to reduce wall-clock time. -- Do not parallelize steps with prerequisite dependencies or where one result determines the next action. -- After parallel retrieval, pause to synthesize before making more calls. -- Prefer selective parallelism: parallelize independent evidence gathering, not speculative or redundant tool use. - -``` - -### `completeness_contract` - -Use when: - -- the task involves batches, lists, enumerations, or multiple deliverables -- missing items are a common failure mode - -```text - -- Deliver all requested items. -- Maintain an itemized checklist of deliverables. -- For lists or batches: - - state the expected count, - - enumerate items 1..N, - - confirm that none are missing before finalizing. -- If any item is blocked by missing data, mark it [blocked] and state exactly what is missing. - -``` - -### `empty_result_handling` - -Use when: - -- the workflow frequently performs search, CRM, logs, or retrieval steps -- no-results failures are often false negatives - -```text - -If a lookup returns empty or suspiciously small results: -- Do not conclude that no results exist immediately. -- Try at least 2 fallback strategies, such as a broader query, alternate filters, or another source. -- Only then report that no results were found, along with what you tried. - -``` - -### `verification_loop` - -Use when: - -- the workflow has downstream impact -- accuracy, formatting, or completeness regressions matter - -```text - -Before finalizing: -- Check correctness: does the output satisfy every requirement? -- Check grounding: are factual claims backed by retrieved sources or tool output? -- Check formatting: does the output match the requested schema or style? -- Check safety and irreversibility: if the next step has external side effects, ask permission first. - -``` - -### `missing_context_gating` - -Use when: - -- required context is sometimes missing early in the workflow -- the model should prefer retrieval over guessing - -```text - -- If required context is missing, do not guess. -- Prefer the appropriate lookup tool when the context is retrievable; ask a minimal clarifying question only when it is not. -- If you must proceed, label assumptions explicitly and choose a reversible action. - -``` - -### `action_safety` - -Use when: - -- the agent will actively take actions through tools -- the host benefits from a short pre-flight and post-flight execution frame - -```text - -- Pre-flight: summarize the intended action and parameters in 1-2 lines. -- Execute via tool. -- Post-flight: confirm the outcome and any validation that was performed. - -``` - -### `citation_rules` - -Use when: - -- the workflow produces cited answers -- fabricated citations or wrong citation formats are costly - -```text - -- Only cite sources that were actually retrieved in this session. -- Never fabricate citations, URLs, IDs, or quote spans. -- If you cannot find a source for a claim, say so and either: - - soften the claim, or - - explain how to verify it with tools. -- Use exactly the citation format required by the host application. - -``` - -### `research_mode` - -Use when: - -- the workflow is research-heavy -- the host uses web search or retrieval tools - -```text - -- Do research in 3 passes: - 1) Plan: list 3-6 sub-questions to answer. - 2) Retrieve: search each sub-question and follow 1-2 second-order leads. - 3) Synthesize: resolve contradictions and write the final answer with citations. -- Stop only when more searching is unlikely to change the conclusion. - -``` - -If your host environment uses a specific research tool or requires a submit step, combine this with the host's finalization contract. - -### `structured_output_contract` - -Use when: - -- the host depends on strict JSON, SQL, or other structured output - -```text - -- Output only the requested format. -- Do not add prose or markdown fences unless they were requested. -- Validate that parentheses and brackets are balanced. -- Do not invent tables or fields. -- If required schema information is missing, ask for it or return an explicit error object. - -``` - -### `bbox_extraction_spec` - -Use when: - -- the workflow extracts OCR boxes, document regions, or other coordinates -- layout drift or missed dense regions are common failure modes - -```text - -- Use the specified coordinate format exactly, such as [x1,y1,x2,y2] normalized to 0..1. -- For each box, include page, label, text snippet, and confidence. -- Add a vertical-drift sanity check so boxes stay aligned with the correct line of text. -- If the layout is dense, process page by page and do a second pass for missed items. - -``` - -### `terminal_tool_hygiene` - -Use when: - -- the prompt belongs to a terminal-based or coding-agent workflow -- tool misuse or shell misuse has been observed - -```text - -- Only run shell commands through the terminal tool. -- Never try to "run" tool names as shell commands. -- If a patch or edit tool exists, use it directly instead of emulating it in bash. -- After changes, run a lightweight verification step such as ls, tests, or a build before declaring the task done. - -``` - -### `user_updates_spec` - -Use when: - -- the workflow is long-running and user updates matter - -```text - -- Only update the user when starting a new major phase or when the plan changes. -- Each update should contain: - - 1 sentence on what changed, - - 1 sentence on the next step. -- Do not narrate routine tool calls. -- Keep the user-facing update short, even when the actual work is exhaustive. - -``` - -If you are using [Compaction](https://developers.openai.com/api/docs/guides/compaction) in the Responses API, compact after major milestones, treat compacted items as opaque state, and keep prompts functionally identical after compaction. - -## Responses `phase` guidance - -For long-running Responses workflows, preambles, or tool-heavy agents that replay assistant items, review whether `phase` is already preserved. - -- If the host already round-trips `phase`, keep it intact during the upgrade. -- If the host uses `previous_response_id` and does not manually replay assistant items, note that this may reduce manual `phase` handling needs. -- If reliable GPT-5.4 behavior would require adding or preserving `phase` and that would need code edits, treat the case as blocked for prompt-only or model-string-only migration guidance. - -## Example upgrade profiles - -### GPT-5.2 - -- Use `gpt-5.4` -- Match the current reasoning effort first -- Preserve the existing latency and quality profile before tuning prompt blocks -- If the repo does not expose the exact setting, emit `same` as the starting recommendation - -### GPT-5.3-Codex - -- Use `gpt-5.4` -- Match the current reasoning effort first -- If you need Codex-style speed and efficiency, add verification blocks before increasing reasoning effort -- If the repo does not expose the exact setting, emit `same` as the starting recommendation - -### GPT-4o or GPT-4.1 assistant - -- Use `gpt-5.4` -- Start with `none` reasoning effort -- Add `output_verbosity_spec` only if output becomes too verbose - -### Long-horizon agent - -- Use `gpt-5.4` -- Start with `medium` reasoning effort -- Add `tool_persistence_rules` -- Add `completeness_contract` -- Add `verification_loop` - -### Research workflow - -- Use `gpt-5.4` -- Start with `medium` reasoning effort -- Add `research_mode` -- Add `citation_rules` -- Add `empty_result_handling` -- Add `tool_persistence_rules` when the host already uses web or retrieval tools -- Add `parallel_tool_calling` when the retrieval steps are independent - -### Support triage or multi-agent workflow - -- Use `gpt-5.4` -- Prefer `model string + light prompt rewrite` over `model string only` -- Add at least one of `tool_persistence_rules`, `completeness_contract`, or `verification_loop` -- Add more only if evals show a real regression - -### Coding or terminal workflow - -- Use `gpt-5.4` -- Keep the model-string change narrow -- Match the current reasoning effort first if you are upgrading from GPT-5.3-Codex -- Add `terminal_tool_hygiene` -- Add `verification_loop` -- Add `dependency_checks` when actions depend on prerequisite lookup or discovery -- Add `tool_persistence_rules` if the agent stops too early -- Review whether `phase` is already preserved for long-running Responses flows or assistant preambles -- Do not classify this as blocked just because the workflow uses tools; block only if the upgrade requires changing tool definitions or wiring -- If the repo already uses Responses plus tools and no required host-side change is shown, prefer `model_string_plus_light_prompt_rewrite` over `blocked` - -## Prompt regression checklist - -- Check whether the upgraded prompt still preserves the original task intent. -- Check whether the new prompt is leaner, not just longer. -- Check completeness, citation quality, dependency handling, verification behavior, and verbosity. -- For long-running Responses agents, check whether `phase` handling is already in place or needs implementation work. -- Confirm that each added prompt block addresses an observed regression. -- Remove prompt blocks that are not earning their keep. diff --git a/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md b/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md index 91a787ee39..23f5cd16f7 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md @@ -6,15 +6,10 @@ This file is a curated helper. Every recommendation here must be verified agains | Model ID | Use for | | --- | --- | -| `gpt-5.4` | Default text plus reasoning for most new apps | +| `gpt-5.4` | Default text plus reasoning for most new apps, including for coding use-cases | | `gpt-5.4-pro` | Only when the user explicitly asks for maximum reasoning or quality; substantially slower and more expensive | -| `gpt-5-mini` | Cheaper and faster reasoning with good quality | -| `gpt-5-nano` | High-throughput simple tasks and classification | -| `gpt-5.4` | Explicit no-reasoning text path via `reasoning.effort: none` | -| `gpt-4.1-mini` | Cheaper no-reasoning text | -| `gpt-4.1-nano` | Fastest and cheapest no-reasoning text | -| `gpt-5.3-codex` | Agentic coding, code editing, and tool-heavy coding workflows | -| `gpt-5.1-codex-mini` | Cheaper coding workflows | +| `gpt-5.4-mini` | Cheaper and faster reasoning with good quality, including for coding use-cases | +| `gpt-5.4-nano` | High-throughput simple tasks and classification | | `gpt-image-1.5` | Best image generation and edit quality | | `gpt-image-1-mini` | Cost-optimized image generation | | `gpt-4o-mini-tts` | Text-to-speech | diff --git a/codex-rs/skills/src/assets/samples/openai-docs/references/prompting-guide.md b/codex-rs/skills/src/assets/samples/openai-docs/references/prompting-guide.md new file mode 100644 index 0000000000..72f490993a --- /dev/null +++ b/codex-rs/skills/src/assets/samples/openai-docs/references/prompting-guide.md @@ -0,0 +1,599 @@ +# Prompt guidance for GPT-5.4 + +GPT-5.4, our newest mainline model, is designed to balance long-running task performance, stronger control over style and behavior, and more disciplined execution across complex workflows. Building on advances from GPT-5 through GPT-5.3-Codex, GPT-5.4 improves token efficiency, sustains multi-step workflows more reliably, and performs well on long-horizon tasks. + +GPT-5.4 is designed for production-grade assistants and agents that need strong multi-step reasoning, evidence-rich synthesis, and reliable performance over long contexts. It is especially effective when prompts clearly specify the output contract, tool-use expectations, and completion criteria. In practice, the biggest gains come from choosing the right reasoning effort for the task, using explicit grounding and citation rules, and giving the model a precise definition of what "done" looks like. This guide focuses on prompt patterns and migration practices that preserve those efficiency wins. For model capabilities, API parameters, and broader migration guidance, see [our latest model guide](https://developers.openai.com/api/docs/guides/latest-model). + +When troubleshooting cases where GPT-5.4 treats an intermediate update as the + final answer, verify your integration preserves the assistant message `phase` + field correctly. See [Phase parameter](#phase-parameter) for details. + +## Understand GPT-5.4 behavior + +### Where GPT-5.4 is strongest + +GPT-5.4 tends to work especially well in these areas: + +- Strong personality and tone adherence, with less drift over long answers +- Agentic workflow robustness, with a stronger tendency to stick with multi-step work, retry, and complete agent loops end to end +- Evidence-rich synthesis, especially in long-context or multi-tool workflows +- Instruction adherence in modular, skill-based, and block-structured prompts when the contract is explicit +- Long-context analysis across large, messy, or multi-document inputs +- Batched or parallel tool calling while maintaining tool-call accuracy +- Spreadsheet, finance, and Excel workflows that need instruction following, formatting fidelity, and stronger self-verification + +### Where explicit prompting still helps + +Even with those strengths, GPT-5.4 benefits from more explicit guidance in a few recurring patterns: + +- Low-context tool routing early in a session, when tool selection can be less reliable +- Dependency-aware workflows that need explicit prerequisite and downstream-step checks +- Reasoning effort selection, where higher effort is not always better and the right choice depends on task shape, not intuition +- Research tasks that require disciplined source collection and consistent citations +- Irreversible or high-impact actions that require verification before execution +- Terminal or coding-agent environments where tool boundaries must stay clear + +These patterns are observed defaults, not guarantees. Start with the smallest prompt that passes your evals, and add blocks only when they fix a measured failure mode. + +## Use core prompt patterns + +### Keep outputs compact and structured + +To improve token efficiency with GPT-5.4, constrain verbosity and enforce structured output through clear output contracts. In practice, this acts as an additional control layer alongside the `verbosity` parameter in the Responses API, allowing you to guide both how much the model writes and how it structures the output. + +```xml + +- Return exactly the sections requested, in the requested order. +- If the prompt defines a preamble, analysis block, or working section, do not treat it as extra output. +- Apply length limits only to the section they are intended for. +- If a format is required (JSON, Markdown, SQL, XML), output only that format. + + + +- Prefer concise, information-dense writing. +- Avoid repeating the user's request. +- Keep progress updates brief. +- Do not shorten the answer so aggressively that required evidence, reasoning, or completion checks are omitted. + +``` + +### Set clear defaults for follow-through + +Users often change the task, format, or tone mid-conversation. To keep the assistant aligned, define clear rules for when to proceed, when to ask, and how newer instructions override earlier defaults. + +Use a default follow-through policy like this: + +```xml + +- If the user’s intent is clear and the next step is reversible and low-risk, proceed without asking. +- Ask permission only if the next step is: + (a) irreversible, + (b) has external side effects (for example sending, purchasing, deleting, or writing to production), or + (c) requires missing sensitive information or a choice that would materially change the outcome. +- If proceeding, briefly state what you did and what remains optional. + +``` + +Make instruction priority explicit: + +```xml + +- User instructions override default style, tone, formatting, and initiative preferences. +- Safety, honesty, privacy, and permission constraints do not yield. +- If a newer user instruction conflicts with an earlier one, follow the newer instruction. +- Preserve earlier instructions that do not conflict. + +``` + +Higher-priority developer or system instructions remain binding. + +**Guidance:** When instructions change mid-conversation, make the update explicit, scoped, and local. State what changed, what still applies, and whether the change affects the next turn or the rest of the conversation. + +### Handle mid-conversation instruction updates + +For mid-conversation updates, use explicit, scoped steering messages that state: + +1. Scope +2. Override +3. Carry forward + +```text + +For the next response only: +- Do not complete the task. +- Only produce a plan. +- Keep it to 5 bullets. + +All earlier instructions still apply unless they conflict with this update. + +``` + +If the task itself changes, say so directly: + +```text + +The task has changed. +Previous task: complete the workflow. +Current task: review the workflow and identify risks only. + +Rules for this turn: +- Do not execute actions. +- Do not call destructive tools. +- Return exactly: + 1. Main risks + 2. Missing information + 3. Recommended next step + +``` + +### Make tool use persistent when correctness depends on it + +Use explicit rules to keep tool use thorough, dependency-aware, and appropriately paced, especially in workflows where later actions rely on earlier retrieval or verification. A common failure mode is skipping prerequisites because the right end state seems obvious. + +GPT-5.4 can be less reliable at tool routing early in a session, when context is still thin. Prompt for prerequisites, dependency checks, and exact tool intent. + +```xml + +- Use tools whenever they materially improve correctness, completeness, or grounding. +- Do not stop early when another tool call is likely to materially improve correctness or completeness. +- Keep calling tools until: + (1) the task is complete, and + (2) verification passes (see ). +- If a tool returns empty or partial results, retry with a different strategy. + +``` + +This is especially important for workflows where the final action depends on earlier lookup or retrieval steps. One of the most common failure modes is skipping prerequisites because the intended end state seems obvious. + +```xml + +- Before taking an action, check whether prerequisite discovery, lookup, or memory retrieval steps are required. +- Do not skip prerequisite steps just because the intended final action seems obvious. +- If the task depends on the output of a prior step, resolve that dependency first. + +``` + +Prompt for parallelism when the work is independent and wall-clock matters. Prompt for sequencing when dependencies, ambiguity, or irreversible actions matter more than speed. + +```xml + +- When multiple retrieval or lookup steps are independent, prefer parallel tool calls to reduce wall-clock time. +- Do not parallelize steps that have prerequisite dependencies or where one result determines the next action. +- After parallel retrieval, pause to synthesize the results before making more calls. +- Prefer selective parallelism: parallelize independent evidence gathering, not speculative or redundant tool use. + +``` + +### Force completeness on long-horizon tasks + +For multi-step workflows, a common failure mode is incomplete execution: the model finishes after partial coverage, misses items in a batch, or treats empty or narrow retrieval as final. GPT-5.4 becomes more reliable when the prompt defines explicit completion rules and recovery behavior. + +Coverage can be achieved through sequential or parallel retrieval, but completion rules should remain explicit either way. + +```xml + +- Treat the task as incomplete until all requested items are covered or explicitly marked [blocked]. +- Keep an internal checklist of required deliverables. +- For lists, batches, or paginated results: + - determine expected scope when possible, + - track processed items or pages, + - confirm coverage before finalizing. +- If any item is blocked by missing data, mark it [blocked] and state exactly what is missing. + +``` + +For workflows where empty, partial, or noisy retrieval is common: + +```xml + +If a lookup returns empty, partial, or suspiciously narrow results: +- do not immediately conclude that no results exist, +- try at least one or two fallback strategies, + such as: + - alternate query wording, + - broader filters, + - a prerequisite lookup, + - or an alternate source or tool, +- Only then report that no results were found, along with what you tried. + +``` + +### Add a verification loop before high-impact actions + +Once the workflow appears complete, add a lightweight verification step before returning the answer or taking an irreversible action. This helps catch requirement misses, grounding issues, and format drift before commit. + +```xml + +Before finalizing: +- Check correctness: does the output satisfy every requirement? +- Check grounding: are factual claims backed by the provided context or tool outputs? +- Check formatting: does the output match the requested schema or style? +- Check safety and irreversibility: if the next step has external side effects, ask permission first. + +``` + +```xml + +- If required context is missing, do NOT guess. +- Prefer the appropriate lookup tool when the missing context is retrievable; ask a minimal clarifying question only when it is not. +- If you must proceed, label assumptions explicitly and choose a reversible action. + +``` + +For agents that actively take actions, add a short execution frame: + +```xml + +- Pre-flight: summarize the intended action and parameters in 1-2 lines. +- Execute via tool. +- Post-flight: confirm the outcome and any validation that was performed. + +``` + +## Handle specialized workflows + +### Choose image detail explicitly for vision and computer use + +If your workflow depends on visual precision, specify the image `detail` level in the prompt or integration instead of relying on `auto`. Use `high` for standard high-fidelity image understanding. Use `original` for large, dense, or spatially sensitive images, especially [computer use, localization, OCR, and click-accuracy tasks](https://developers.openai.com/api/docs/guides/tools-computer-use) on `gpt-5.4` and future models. Use `low` only when speed and cost matter more than fine detail. For more details on image detail levels, see the [Images and Vision guide](https://developers.openai.com/api/docs/guides/images-vision). + +### Lock research and citations to retrieved evidence + +When citation quality matters, make both the source boundary and the format requirement explicit. This helps reduce fabricated references, unsupported claims, and citation-format drift. + +```xml + +- Only cite sources retrieved in the current workflow. +- Never fabricate citations, URLs, IDs, or quote spans. +- Use exactly the citation format required by the host application. +- Attach citations to the specific claims they support, not only at the end. + +``` + +```xml + +- Base claims only on provided context or tool outputs. +- If sources conflict, state the conflict explicitly and attribute each side. +- If the context is insufficient or irrelevant, narrow the answer or say you cannot support the claim. +- If a statement is an inference rather than a directly supported fact, label it as an inference. + +``` + +If your application requires inline citations, require inline citations. If it requires footnotes, require footnotes. The key is to lock the format and prevent the model from improvising unsupported references. + +### Research mode + +Push GPT-5.4 into a disciplined research mode. Use this pattern for research, review, and synthesis tasks. Do not force it onto short execution tasks or simple deterministic transforms. + +```xml + +- Do research in 3 passes: + 1) Plan: list 3-6 sub-questions to answer. + 2) Retrieve: search each sub-question and follow 1-2 second-order leads. + 3) Synthesize: resolve contradictions and write the final answer with citations. +- Stop only when more searching is unlikely to change the conclusion. + +``` + +If your host environment uses a specific research tool or requires a submit step, combine this with the host's finalization contract. + +### Clamp strict output formats + +For SQL, JSON, or other parse-sensitive outputs, tell GPT-5.4 to emit only the target format and check it before finishing. + +```text + +- Output only the requested format. +- Do not add prose or markdown fences unless they were requested. +- Validate that parentheses and brackets are balanced. +- Do not invent tables or fields. +- If required schema information is missing, ask for it or return an explicit error object. + +``` + +If you are extracting document regions or OCR boxes, define the coordinate system and add a drift check: + +```text + +- Use the specified coordinate format exactly, such as [x1,y1,x2,y2] normalized to 0..1. +- For each box, include page, label, text snippet, and confidence. +- Add a vertical-drift sanity check so boxes stay aligned with the correct line of text. +- If the layout is dense, process page by page and do a second pass for missed items. + +``` + +### Keep tool boundaries explicit in coding and terminal agents + +In coding agents, GPT-5.4 works better when the rules for shell access and file editing are unambiguous. This is especially important when you expose tools like [Shell](https://developers.openai.com/api/docs/guides/tools-shell) or [Apply patch](https://developers.openai.com/api/docs/guides/tools-apply-patch). + +### User updates + +GPT-5.4 does well with brief, outcome-based updates. Reuse the user-updates pattern from the 5.2 guide, but pair it with explicit completion and verification requirements. + +Recommended update spec: + +```xml + +- Only update the user when starting a new major phase or when something changes the plan. +- Each update: 1 sentence on outcome + 1 sentence on next step. +- Do not narrate routine tool calls. +- Keep the user-facing status short; keep the work exhaustive. + +``` + +For coding agents, see the Prompting patterns for coding tasks section below for more specific guidance. + +### Prompting patterns for coding tasks + +**Autonomy and persistence** + +GPT-5.4 is generally more thorough end to end than earlier mainline models on coding and tool-use tasks, so you often need less explicit "verify everything" prompting. Still, for high-stakes changes such as production, migrations, or security work, keep a lightweight verification clause. + +```xml + +Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you. + +Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself. + +``` + +**Intermediary updates** + +Keep updates sparse and high-signal. In coding tasks, prefer updates at key points. + +```xml + +- Intermediary updates go to the `commentary` channel. +- User updates are short updates while you are working. They are not final answers. +- Use 1-2 sentence updates to communicate progress and new information while you work. +- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done -", "Got it", or "Great question") or similar framing. +- Before exploring or doing substantial work, send a user update explaining your understanding of the request and your first step. Avoid commenting on the request or starting with phrases such as "Got it" or "Understood." +- Provide updates roughly every 30 seconds while working. +- When exploring, explain what context you are gathering and what you learned. Vary sentence structure so the updates do not become repetitive. +- When working for a while, keep updates informative and varied, but stay concise. +- When work is substantial, provide a longer plan after you have enough context. This is the only update that may be longer than 2 sentences and may contain formatting. +- Before file edits, explain what you are about to change. +- While thinking, keep the user informed of progress without narrating every tool call. Even if you are not taking actions, send frequent progress updates rather than going silent, especially if you are thinking for more than a short stretch. +- Keep the tone of progress updates consistent with the assistant's overall personality. + +``` + +**Formatting** + +GPT-5.4 often defaults to more structured formatting and may overuse bullet lists. If you want a clean final response, explicitly clamp list shape. + +```xml +Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`. +``` + +**Frontend tasks** + +Use this only when additional frontend guidance is useful. + +```xml + +When doing frontend design tasks, avoid generic, overbuilt layouts. + +Use these hard rules: +- One composition: The first viewport must read as one composition, not a dashboard, unless it is a dashboard. +- Brand first: On branded pages, the brand or product name must be a hero-level signal, not just nav text or an eyebrow. No headline should overpower the brand. +- Brand test: If the first viewport could belong to another brand after removing the nav, the branding is too weak. +- Full-bleed hero only: On landing pages and promotional surfaces, the hero image should usually be a dominant edge-to-edge visual plane or background. Do not default to inset hero images, side-panel hero images, rounded media cards, tiled collages, or floating image blocks unless the existing design system clearly requires them. +- Hero budget: The first viewport should usually contain only the brand, one headline, one short supporting sentence, one CTA group, and one dominant image. Do not place stats, schedules, event listings, address blocks, promos, "this week" callouts, metadata rows, or secondary marketing content there. +- No hero overlays: Do not place detached labels, floating badges, promo stickers, info chips, or callout boxes on top of hero media. +- Cards: Default to no cards. Never use cards in the hero unless they are the container for a user interaction. If removing a border, shadow, background, or radius does not hurt interaction or understanding, it should not be a card. +- One job per section: Each section should have one purpose, one headline, and usually one short supporting sentence. +- Real visual anchor: Imagery should show the product, place, atmosphere, or context. +- Reduce clutter: Avoid pill clusters, stat strips, icon rows, boxed promos, schedule snippets, and competing text blocks. +- Use motion to create presence and hierarchy, not noise. Ship 2-3 intentional motions for visually led work, and prefer Framer Motion when it is available. + +Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language. + +``` + +```xml + +- Only run shell commands via the terminal tool. +- Never "run" tool names as shell commands. +- If a patch or edit tool exists, use it directly; do not attempt it in bash. +- After changes, run a lightweight verification step such as ls, tests, or a build before declaring the task done. + +``` + +### Document localization and OCR boxes + +For bbox tasks, be explicit about coordinate conventions and add drift tests. + +```xml + +- Use the specified coordinate format exactly (for example [x1,y1,x2,y2] normalized 0..1). +- For each bbox, include: page, label, text snippet, confidence. +- Add a vertical-drift sanity check: + - ensure bboxes align with the line of text (not shifted up or down). +- If dense layout, process page by page and do a second pass for missed items. + +``` + +### Use runtime and API integration notes + +For long-running or tool-heavy agents, the runtime contract matters as much as the prompt contract. + +#### Phase parameter + +For GPT-5.4, `gpt-5.3-codex`, and later Responses models, the `phase` field can +help in the small number of long-running or tool-heavy flows where preambles or +other intermediate assistant updates are mistaken for the final answer. + +- `phase` is optional at the API level, but it is highly recommended. Best-effort inference may exist server-side, but explicit round-tripping of `phase` is strictly better. +- Use `phase` for long-running or tool-heavy agents that may emit commentary before tool calls or before a final answer. +- Preserve `phase` when replaying prior assistant items so the model can distinguish working commentary from the completed answer. This matters most in multi-step flows with preambles, tool-related updates, or multiple assistant messages in the same turn. +- Do not add `phase` to user messages. +- If you use `previous_response_id`, that is usually the simplest path, since OpenAI can often recover prior state without manually replaying assistant items. +- If you replay assistant history yourself, preserve the original `phase` values. +- Missing or dropped `phase` can cause preambles to be interpreted as final answers and degrade behavior on those multi-step tasks. + +### Preserve behavior in long sessions + +Compaction unlocks significantly longer effective context windows, where user conversations can persist for many turns without hitting context limits or long-context performance degradation, and agents can perform very long trajectories that exceed a typical context window for long-running, complex tasks. + +If you are using [Compaction](https://developers.openai.com/api/docs/guides/compaction) in the Responses API, compact after major milestones, treat compacted items as opaque state, and keep prompts functionally identical after compaction. The endpoint is ZDR compatible and returns an `encrypted_content` item that you can pass into future requests. GPT-5.4 tends to remain more coherent and reliable over longer, multi-turn conversations with fewer breakdowns as sessions grow. + +For more guidance, see the [`/responses/compact` API reference](https://developers.openai.com/api/docs/api-reference/responses/compact). + +### Control personality for customer-facing workflows + +GPT-5.4 can be steered more effectively when you separate persistent personality from per-response writing controls. This is especially useful for customer-facing workflows such as emails, support replies, announcements, and blog-style content. + +- **Personality (persistent):** sets the default tone, verbosity, and decision style across the session. +- **Writing controls (per response):** define the channel, register, formatting, and length for a specific artifact. +- **Reminder:** personality should not override task-specific output requirements. If the user asks for JSON, return JSON. + +For natural, high-quality prose, the highest-leverage controls are: + +- Give the model a clear persona. +- Specify the channel and emotional register. +- Explicitly ban formatting when you want prose. +- Use hard length limits. + +```xml + +- Persona: +- Channel: +- Emotional register: + "not " +- Formatting: +- Length: +- Default follow-through: if the request is clear and low-risk, proceed without asking permission. + +``` + +For more personality patterns you can lift directly, see the [Prompt Personalities cookbook](https://developers.openai.com/cookbook/examples/gpt-5/prompt_personalities). + +**Professional memo mode** + +For memos, reviews, and other professional writing tasks, general writing instructions are often not enough. These workflows benefit from explicit guidance on specificity, domain conventions, synthesis, and calibrated certainty. + +```xml + +- Write in a polished, professional memo style. +- Use exact names, dates, entities, and authorities when supported by the record. +- Follow domain-specific structure if one is requested. +- Prefer precise conclusions over generic hedging. +- When uncertainty is real, tie it to the exact missing fact or conflicting source. +- Synthesize across documents rather than summarizing each one independently. + +``` + +This mode is especially useful for legal, policy, research, and executive-facing writing, where the goal is not just fluency, but disciplined synthesis and clear conclusions. + +## Tune reasoning and migration + +### Treat reasoning effort as a last-mile knob + +Reasoning effort is not one-size-fits-all. Treat it as a last-mile tuning knob, not the primary way to improve quality. In many cases, stronger prompts, clear output contracts, and lightweight verification loops recover much of the performance teams might otherwise seek through higher reasoning settings. + +Recommended defaults: + +- `none`: Best for fast, cost-sensitive, latency-sensitive tasks where the model does not need to think. +- `low`: Works well for latency-sensitive tasks where a small amount of thinking can produce a meaningful accuracy gain, especially with complex instructions. +- `medium` or `high`: Reserve for tasks that truly require stronger reasoning and can absorb the latency and cost tradeoff. Choose between them based on how much performance gain your task gets from additional reasoning. +- `xhigh`: Avoid as a default unless your evals show clear benefits. It is best suited for long, agentic, reasoning-heavy tasks where maximum intelligence matters more than speed or cost. + +In practice, most teams should default to the `none`, `low`, or `medium` range. + +Start with `none` for execution-heavy workloads such as workflow steps, field extraction, support triage, and short structured transforms. + +Start with `medium` or higher for research-heavy workloads such as long-context synthesis, multi-document review, conflict resolution, and strategy writing. With `medium` and a well-engineered prompt, you can squeeze out a lot of performance. + +For GPT-5.4 workloads, `none` can already perform well on action-selection and tool-discipline tasks. If your workload depends on nuanced interpretation, such as implicit requirements, ambiguity, or cancelled-tool-call recovery, start with `low` or `medium` instead. + +Before increasing reasoning effort, first add: + +- `` +- `` +- `` + +If the model still feels too literal or stops at the first plausible answer, add an initiative nudge before raising reasoning effort: + +```xml + +- Don’t stop at the first plausible answer. +- Look for second-order issues, edge cases, and missing constraints. +- If the task is safety or accuracy critical, perform at least one verification step. + +``` + +### Migrate prompts to GPT-5.4 one change at a time + +Use the same one-change-at-a-time discipline as the 5.2 guide: switch model first, pin `reasoning_effort`, run evals, then iterate. + +These starting points work well for many migrations: + +| Current setup | Suggested GPT-5.4 start | Notes | +| ------------------------- | ---------------------------------- | ------------------------------------------------------------------- | +| `gpt-5.2` | Match the current reasoning effort | Preserve the existing latency and quality profile first, then tune. | +| `gpt-5.3-codex` | Match the current reasoning effort | For coding workflows, keep the reasoning effort the same. | +| `gpt-4.1` or `gpt-4o` | `none` | Keep snappy behavior, and increase only if evals regress. | +| Research-heavy assistants | `medium` or `high` | Use explicit research multi-pass and citation gating. | +| Long-horizon agents | `medium` or `high` | Add tool persistence and completeness accounting. | + +### Small-model guidance for `gpt-5.4-mini` and `gpt-5.4-nano` + +`gpt-5.4-mini` and `gpt-5.4-nano` are highly steerable, but they are less likely than larger models to infer missing steps, resolve ambiguity implicitly, or package outputs the way you intended unless you specify that behavior directly. In practice, prompts for smaller models are often a bit longer and more explicit. + +**How `gpt-5.4-mini` differs** + +- `gpt-5.4-mini` is more literal and makes fewer assumptions. +- It is strong when the task is clearly structured, but weaker on implicit workflows and ambiguity handling. +- By default, it may try to keep the conversation going with a follow-up question unless you suppress that behavior explicitly. + +**Prompting `gpt-5.4-mini`** + +- Put critical rules first. +- Specify the full execution order when tool use or side effects matter. +- Do not rely on "you MUST" alone. Use structural scaffolding such as numbered steps, decision rules, and explicit action definitions. +- Separate "do the action" from "report the action." +- Show the correct flow, not just the final format. +- Define ambiguity behavior explicitly: when to ask, abstain, or proceed. +- Specify packaging directly: answer length, whether to ask a follow-up question, citation style, and section order. +- Be careful with `output nothing else`. Prefer scoped instructions such as `after the final JSON, output nothing further`. + +**Prompting `gpt-5.4-nano`** + +- Use `gpt-5.4-nano` only for narrow, well-bounded tasks. +- Prefer closed outputs: labels, enums, short JSON, or fixed templates. +- Avoid multi-step orchestration unless the flow is extremely constrained. +- Route ambiguous or planning-heavy tasks to a stronger model instead of over-prompting `gpt-5.4-nano`. + +**Good default pattern** + +1. Task +2. Critical rule +3. Exact step order +4. Edge cases or clarification behavior +5. Output format +6. One correct example + +**Avoid** + +- Implied next steps +- Unspecified edge cases +- Schema-only prompts for tool workflows +- Generic instructions without structure + +### Web search and deep research + +If you are migrating a research agent in particular, make these prompt updates before increasing reasoning effort: + +- Add `` +- Add `` +- Add `` +- Increase `reasoning_effort` one notch only after prompt fixes. + +You can start from the 5.2 research block and then layer in citation gating and finalization contracts as needed. + +GPT-5.4 performs especially well when the task requires multi-step evidence gathering, long-context synthesis, and explicit prompt contracts. In practice, the highest-leverage prompt changes are choosing reasoning effort by task shape, defining exact output and citation formats, adding dependency-aware tool rules, and making completion criteria explicit. The model is often strong out of the box, but it is most reliable when prompts clearly specify how to search, how to verify, and what counts as done. + +## Next steps + +- Read [our latest model guide](https://developers.openai.com/api/docs/guides/latest-model) for model capabilities, parameters, and API compatibility details. +- Read [Prompt engineering](https://developers.openai.com/api/docs/guides/prompt-engineering) for broader prompting strategies that apply across model families. +- Read [Compaction](https://developers.openai.com/api/docs/guides/compaction) if you are building long-running GPT-5.4 sessions in the Responses API. \ No newline at end of file diff --git a/codex-rs/skills/src/assets/samples/openai-docs/references/upgrading-to-gpt-5p4.md b/codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md similarity index 90% rename from codex-rs/skills/src/assets/samples/openai-docs/references/upgrading-to-gpt-5p4.md rename to codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md index 7a6775f454..749bf1b37f 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/references/upgrading-to-gpt-5p4.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md @@ -2,6 +2,14 @@ Use this guide when the user explicitly asks to upgrade an existing integration to GPT-5.4. Pair it with current OpenAI docs lookups. The default target string is `gpt-5.4`. +## Freshness check + +Before applying this bundled guide, run `node scripts/resolve-latest-model-info.js` from the OpenAI Docs skill directory. + +- If the command returns `modelSlug: "gpt-5p4"`, continue with this bundled guide and use `references/prompting-guide.md` when prompt updates are needed. +- If the command returns a different `modelSlug`, fetch both the returned `migrationGuideUrl` and `promptingGuideUrl` and use them as the current source of truth instead of the bundled references. +- If the command fails, the metadata is missing, or either remote guide cannot be fetched, continue with the bundled fallback references and say the remote freshness check was unavailable. + ## Upgrade posture Upgrade with the narrowest safe change set: @@ -80,9 +88,9 @@ Default action: - replace the model string with `gpt-5.4` - add one or two targeted prompt blocks -- read `references/gpt-5p4-prompting-guide.md` to choose the smallest prompt changes that recover the old behavior +- read `references/prompting-guide.md` to choose the smallest prompt changes that preserve the intended behavior and take advantage of relevant model-specific guidance - avoid broad prompt cleanup unrelated to the upgrade -- for research workflows, default to `research_mode` + `citation_rules` + `empty_result_handling`; add `tool_persistence_rules` when the host already uses retrieval tools +- for research workflows, default to `research_mode` + `citation_rules` + `empty_result_recovery`; add `tool_persistence_rules` when the host already uses retrieval tools - for dependency-aware or tool-heavy workflows, default to `tool_persistence_rules` + `dependency_checks` + `verification_loop`; add `parallel_tool_calling` only when retrieval steps are truly independent - for coding or terminal workflows, default to `terminal_tool_hygiene` + `verification_loop` - for multi-agent support or triage workflows, default to at least one of `tool_persistence_rules`, `completeness_contract`, or `verification_loop` diff --git a/codex-rs/skills/src/assets/samples/openai-docs/scripts/resolve-latest-model-info.js b/codex-rs/skills/src/assets/samples/openai-docs/scripts/resolve-latest-model-info.js new file mode 100755 index 0000000000..2a47ff05ec --- /dev/null +++ b/codex-rs/skills/src/assets/samples/openai-docs/scripts/resolve-latest-model-info.js @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +const fs = require("node:fs/promises"); +const path = require("node:path"); + +const DEFAULT_URL = + "https://developers.openai.com/api/docs/guides/latest-model.md"; +const DEFAULT_BASE_URL = "https://developers.openai.com"; + +function parseArgs(argv) { + const args = { + source: process.env.LATEST_MODEL_URL || DEFAULT_URL, + baseUrl: process.env.LATEST_MODEL_BASE_URL || DEFAULT_BASE_URL, + }; + + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--source" || arg === "--url") { + args.source = argv[i + 1]; + i += 1; + } else if (arg === "--base-url") { + args.baseUrl = argv[i + 1]; + i += 1; + } + } + + return args; +} + +async function readSource(source) { + if (source.startsWith("file://")) { + return fs.readFile(new URL(source), "utf8"); + } + + if (!/^https?:\/\//.test(source)) { + return fs.readFile(path.resolve(source), "utf8"); + } + + const response = await fetch(source, { + headers: { accept: "text/markdown,text/plain,*/*" }, + }); + + if (!response.ok) { + throw new Error(`failed to fetch ${source}: ${response.status}`); + } + + return response.text(); +} + +function parseIndentedInfo(lines, startIndex) { + const info = {}; + + for (let i = startIndex + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (!line.trim()) { + continue; + } + + const match = line.match(/^ {2}([A-Za-z][A-Za-z0-9_-]*):\s*(.+?)\s*$/); + if (!match) { + break; + } + + info[match[1]] = match[2].replace(/^["']|["']$/g, ""); + } + + return info; +} + +function parseFlatInfo(block) { + const info = {}; + + for (const line of block.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.+?)\s*$/); + if (match) { + info[match[1]] = match[2].replace(/^["']|["']$/g, ""); + } + } + + return info; +} + +function extractLatestModelInfo(markdown) { + const lines = markdown.split(/\r?\n/); + const latestModelInfoIndex = lines.findIndex((line) => + /^latestModelInfo:\s*$/.test(line) + ); + + if (latestModelInfoIndex >= 0) { + return parseIndentedInfo(lines, latestModelInfoIndex); + } + + const commentMatch = markdown.match( + //m + ); + if (commentMatch) { + return parseFlatInfo(commentMatch[1]); + } + + return undefined; +} + +function modelToSkillSlug(model) { + return model.trim().replace(/\./g, "p"); +} + +function absoluteUrl(baseUrl, value) { + return new URL(value, baseUrl).toString(); +} + +function normalizeInfo(info, baseUrl) { + const model = info?.model?.trim(); + const migrationGuide = info?.migrationGuide?.trim(); + const promptingGuide = info?.promptingGuide?.trim(); + + if (!model || !migrationGuide || !promptingGuide) { + throw new Error( + "latestModelInfo must include model, migrationGuide, and promptingGuide" + ); + } + + return { + model, + modelSlug: modelToSkillSlug(model), + migrationGuideUrl: absoluteUrl(baseUrl, migrationGuide), + promptingGuideUrl: absoluteUrl(baseUrl, promptingGuide), + }; +} + +async function main() { + const { source, baseUrl } = parseArgs(process.argv); + const markdown = await readSource(source); + const info = extractLatestModelInfo(markdown); + + if (!info) { + throw new Error(`latestModelInfo block not found in ${source}`); + } + + process.stdout.write( + `${JSON.stringify(normalizeInfo(info, baseUrl), null, 2)}\n` + ); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); From 46142c3cb0f2d5de2b12920bbda84468b3a526b0 Mon Sep 17 00:00:00 2001 From: Won Park Date: Wed, 22 Apr 2026 15:45:35 -0700 Subject: [PATCH 04/14] Rebrand approvals reviewer config to auto-review (#18504) ### Why Auto-review is the user-facing name for the approvals reviewer, but the config/API value still exposed the old `guardian_subagent` name. That made new configs and generated schemas point users at Guardian terminology even though the intended product surface is Auto-review. This PR updates the external `approvals_reviewer` value while preserving compatibility for existing configs and clients. ### What changed - Makes `auto_review` the canonical serialized value for `approvals_reviewer`. - Keeps `guardian_subagent` accepted as a legacy alias. - Keeps `user` accepted and serialized as `user`. - Updates generated config and app-server schemas so `approvals_reviewer` includes: - `user` - `auto_review` - `guardian_subagent` - Updates app-server README docs for the reviewer value. - Updates analytics and config requirements tests for the canonical auto_review value. ### Compatibility Existing configs and API payloads using: ```toml approvals_reviewer = "guardian_subagent" ``` continue to load and map to the Auto-review reviewer behavior. New serialization emits: ```toml approvals_reviewer = "auto_review" ``` This PR intentionally does not rename the [features].guardian_approval key or broad internal Guardian symbols. Those are split out for a follow-up PR to keep this migration small and avoid touching large TUI/internal surfaces. **Verification** cargo test -p codex-protocol approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent cargo test -p codex-app-server-protocol approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent --- .../analytics/src/analytics_client_tests.rs | 6 +- .../schema/json/ClientRequest.json | 3 +- .../codex_app_server_protocol.schemas.json | 3 +- .../codex_app_server_protocol.v2.schemas.json | 3 +- .../schema/json/v2/ConfigReadResponse.json | 3 +- .../v2/ConfigRequirementsReadResponse.json | 3 +- .../schema/json/v2/ThreadForkParams.json | 3 +- .../schema/json/v2/ThreadForkResponse.json | 3 +- .../schema/json/v2/ThreadResumeParams.json | 3 +- .../schema/json/v2/ThreadResumeResponse.json | 3 +- .../schema/json/v2/ThreadStartParams.json | 3 +- .../schema/json/v2/ThreadStartResponse.json | 3 +- .../schema/json/v2/TurnStartParams.json | 3 +- .../schema/typescript/v2/ApprovalsReviewer.ts | 4 +- .../app-server-protocol/src/protocol/v2.rs | 73 +++++++++++++++++- codex-rs/app-server/README.md | 2 +- codex-rs/config/src/config_requirements.rs | 22 +++++- codex-rs/core/config.schema.json | 3 +- codex-rs/core/src/config_loader/mod.rs | 6 +- codex-rs/protocol/src/config_types.rs | 74 +++++++++++++++++-- codex-rs/tui/src/app/tests.rs | 6 +- codex-rs/tui/src/debug_config.rs | 4 +- 22 files changed, 197 insertions(+), 39 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 03592c9ff6..cd96bac628 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1334,7 +1334,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { }, )); - let payload = serde_json::to_value(&event).expect("serialize guardian subagent event"); + let payload = serde_json::to_value(&event).expect("serialize auto-review subagent event"); assert_eq!(payload["event_params"]["subagent_source"], "guardian"); assert_eq!( payload["event_params"]["parent_thread_id"], @@ -1758,7 +1758,7 @@ fn turn_event_serializes_expected_shape() { reasoning_summary: Some("detailed".to_string()), service_tier: "flex".to_string(), approval_policy: "on-request".to_string(), - approvals_reviewer: "guardian_subagent".to_string(), + approvals_reviewer: "auto_review".to_string(), sandbox_network_access: true, collaboration_mode: Some("plan"), personality: Some("pragmatic".to_string()), @@ -1819,7 +1819,7 @@ fn turn_event_serializes_expected_shape() { "reasoning_summary": "detailed", "service_tier": "flex", "approval_policy": "on-request", - "approvals_reviewer": "guardian_subagent", + "approvals_reviewer": "auto_review", "sandbox_network_access": true, "collaboration_mode": "plan", "personality": "pragmatic", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ada860ae27..d941238ca8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -13,9 +13,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "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 51fb744077..c463df699f 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 @@ -5728,9 +5728,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6291b5b987..20e0f8f6e3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -605,9 +605,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 58f8186266..c348e7d955 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -97,9 +97,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 614575a955..f75833af1a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -2,9 +2,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 6b227dec34..16a41d0d26 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -6,9 +6,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 91f6db519f..f62d600255 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -9,9 +9,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index d4dce1b650..7fb27c08e8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -6,9 +6,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 79128bdac8..710a701f9e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -9,9 +9,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index a382edd66d..0c5f217648 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -6,9 +6,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 95063f2c92..9fe3bc62b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -9,9 +9,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 3af55e7294..062771029e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -6,9 +6,10 @@ "type": "string" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts index a5a26ae0e4..1d932946cc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ApprovalsReviewer.ts @@ -5,8 +5,8 @@ /** * Configures who approval requests are routed to for review. Examples * include sandbox escapes, blocked network access, MCP approval prompts, and - * ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully + * ARC escalations. Defaults to `user`. `auto_review` uses a carefully * prompted subagent to gather relevant context and apply a risk-based * decision framework before approving or denying the request. */ -export type ApprovalsReviewer = "user" | "guardian_subagent"; +export type ApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2e492e3797..2760313dc7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -101,6 +101,11 @@ use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; @@ -304,19 +309,54 @@ impl From for AskForApproval { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[ts( + type = r#""user" | "auto_review" | "guardian_subagent""#, + export_to = "v2/" +)] /// Configures who approval requests are routed to for review. Examples /// include sandbox escapes, blocked network access, MCP approval prompts, and -/// ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully /// prompted subagent to gather relevant context and apply a risk-based /// decision framework before approving or denying the request. pub enum ApprovalsReviewer { + #[serde(rename = "user")] User, + #[serde(rename = "auto_review", alias = "guardian_subagent")] GuardianSubagent, } +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| JsonValue::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) +} + impl ApprovalsReviewer { pub fn to_core(self) -> CoreApprovalsReviewer { match self { @@ -7346,6 +7386,31 @@ mod tests { absolute_path("readable") } + #[test] + fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::GuardianSubagent) + .expect("serialize reviewer"), + "\"auto_review\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::GuardianSubagent + }; + assert_eq!(expected, reviewer); + } + } + #[test] fn thread_list_params_accepts_single_cwd() { let params = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 62e490dee3..15b7f9a5be 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -529,7 +529,7 @@ You can optionally specify config overrides on the new turn. If specified, these `approvalsReviewer` accepts: - `"user"` — default. Review approval requests directly in the client. -- `"guardian_subagent"` — route approval requests to a carefully prompted subagent, which gathers relevant context and applies a risk-based decision framework before approving or denying the request. +- `"auto_review"` — route approval requests to a carefully prompted subagent, which gathers relevant context and applies a risk-based decision framework before approving or denying the request. The legacy value `"guardian_subagent"` is still accepted for compatibility. ```json { "method": "turn/start", "id": 30, "params": { diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 800a688768..ac954a1f4a 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1649,7 +1649,7 @@ allowed_approvals_reviewers = ["user"] let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] - allowed_approvals_reviewers = ["guardian_subagent"] + allowed_approvals_reviewers = ["auto_review"] allowed_sandbox_modes = ["read-only"] "#, )?; @@ -1730,7 +1730,7 @@ allowed_approvals_reviewers = ["user"] let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] - allowed_approvals_reviewers = ["guardian_subagent"] + allowed_approvals_reviewers = ["auto_review"] allowed_sandbox_modes = ["read-only"] allowed_web_search_modes = ["cached"] enforce_residency = "us" @@ -1830,7 +1830,7 @@ allowed_approvals_reviewers = ["user"] #[test] fn deserialize_allowed_approvals_reviewers() -> Result<()> { let toml_str = r#" - allowed_approvals_reviewers = ["guardian_subagent", "user"] + allowed_approvals_reviewers = ["auto_review", "user"] "#; let config: ConfigRequirementsToml = from_str(toml_str)?; let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; @@ -1856,6 +1856,22 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["guardian_subagent", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::GuardianSubagent + ); + + Ok(()) + } + #[test] fn empty_allowed_approvals_reviewers_is_rejected() -> Result<()> { let toml_str = r#" diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 828120f9d6..245a55e1bd 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -169,9 +169,10 @@ "type": "object" }, "ApprovalsReviewer": { - "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ "user", + "auto_review", "guardian_subagent" ], "type": "string" diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 3b08de32a2..d7a215c17d 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1025,8 +1025,8 @@ async fn load_project_layers( /// /// If present, re-interpret `managed_config.toml` as a `requirements.toml` /// where each specified field is treated as a constraint. Most fields allow -/// only the specified value. `approvals_reviewer = "guardian_subagent"` also -/// allows `user` so people can opt out of the guardian reviewer. +/// only the specified value. `approvals_reviewer = "auto_review"` also allows +/// `user` so people can opt out of the auto-reviewer. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] struct LegacyManagedConfigToml { approval_policy: Option, @@ -1145,7 +1145,7 @@ foo = "xyzzy" requirements.allowed_approvals_reviewers, Some(vec![ ApprovalsReviewer::GuardianSubagent, - ApprovalsReviewer::User + ApprovalsReviewer::User, ]) ); } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5fd09c24b3..5c407d9960 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,7 +1,13 @@ use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; use serde::Deserialize; use serde::Serialize; +use serde_json::Value; use std::num::NonZeroU64; use std::time::Duration; use strum_macros::Display; @@ -69,22 +75,54 @@ pub enum SandboxMode { DangerFullAccess, } -#[derive( - Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, JsonSchema, TS, -)] -#[serde(rename_all = "snake_case")] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, TS)] #[strum(serialize_all = "snake_case")] +#[ts(type = r#""user" | "auto_review" | "guardian_subagent""#)] /// Configures who approval requests are routed to for review. Examples /// include sandbox escapes, blocked network access, MCP approval prompts, and -/// ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully /// prompted subagent to gather relevant context and apply a risk-based /// decision framework before approving or denying the request. pub enum ApprovalsReviewer { #[default] + #[serde(rename = "user")] User, + #[serde(rename = "auto_review", alias = "guardian_subagent")] + #[strum(serialize = "auto_review")] GuardianSubagent, } +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| Value::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) +} + #[derive( Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, JsonSchema, TS, )] @@ -562,6 +600,32 @@ mod tests { } } + #[test] + fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!(ApprovalsReviewer::User.to_string(), "user"); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::GuardianSubagent) + .expect("serialize reviewer"), + "\"auto_review\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::GuardianSubagent + }; + assert_eq!(expected, reviewer); + } + } + #[test] fn tui_visible_collaboration_modes_match_mode_kind_visibility() { let expected = [ModeKind::Default, ModeKind::Plan]; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a1a658a24d..87b7faa34b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1675,7 +1675,7 @@ async fn update_feature_flags_enabling_guardian_selects_guardian_approvals() -> let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(config.contains("guardian_approval = true")); - assert!(config.contains("approvals_reviewer = \"guardian_subagent\"")); + assert!(config.contains("approvals_reviewer = \"auto_review\"")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); Ok(()) @@ -1835,7 +1835,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review ); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains("approvals_reviewer = \"guardian_subagent\"")); + assert!(config.contains("approvals_reviewer = \"auto_review\"")); assert!(config.contains("guardian_approval = true")); assert!(config.contains("approval_policy = \"on-request\"")); assert!(config.contains("sandbox_mode = \"workspace-write\"")); @@ -1969,7 +1969,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev ); assert_eq!( profile_config.get("approvals_reviewer"), - Some(&TomlValue::String("guardian_subagent".to_string())) + Some(&TomlValue::String("auto_review".to_string())) ); Ok(()) } diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index b16121c595..057611af24 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -690,7 +690,7 @@ mod tests { rendered.contains("allowed_approval_policies: on-request (source: cloud requirements)") ); assert!(rendered.contains( - "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" + "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); assert!( rendered.contains( @@ -745,7 +745,7 @@ mod tests { let rendered = render_to_text(&render_debug_config_lines(&stack)); assert!(rendered.contains( - "allowed_approvals_reviewers: guardian_subagent (source: MDM managed_config.toml (legacy))" + "allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))" )); assert!(!rendered.contains("Requirements:\n ")); } From 44dbd9e48adaff96092ec59385ef3b3aa0508616 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:12 -0700 Subject: [PATCH 05/14] exec-server: require explicit filesystem sandbox cwd (#19046) ## Why This is a cleanup PR for the `PermissionProfile` migration stack. #19016 fixed remote exec-server sandbox contexts so Docker-backed filesystem requests use a request/container `cwd` instead of leaking the local test runner `cwd`. That exposed the broader API problem: `FileSystemSandboxContext::new(SandboxPolicy)` could still reconstruct filesystem permissions by reading the exec-server process cwd with `AbsolutePathBuf::current_dir()`. That made `cwd`-dependent legacy entries, such as `:cwd`, `:project_roots`, and relative deny globs, depend on ambient process state instead of the request sandbox `cwd`. As later PRs make `PermissionProfile` the primary permissions abstraction, sandbox contexts should be explicit about whether they carry a request `cwd` or are profile-only. Removing the implicit constructor prevents new call sites from accidentally rebuilding permissions against the wrong `cwd`. ## What changed - Removed `FileSystemSandboxContext::new(SandboxPolicy)`. - Kept production callers on explicit constructors: `from_legacy_sandbox_policy(..., cwd)`, `from_permission_profile(...)`, and `from_permission_profile_with_cwd(...)`. - Updated exec-server test helpers to construct `PermissionProfile` values directly instead of routing through legacy `SandboxPolicy` projections. - Updated the environment regression test to use an explicit restricted profile with no synthetic `cwd`. ## Verification - `cargo test -p codex-exec-server` - `just fix -p codex-exec-server` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19046). * #18288 * #18287 * #18286 * #18285 * #18284 * #18283 * #18282 * #18281 * #18280 * __->__ #19046 --- codex-rs/exec-server/src/environment.rs | 7 +- codex-rs/exec-server/src/file_system.rs | 12 -- codex-rs/exec-server/src/fs_sandbox.rs | 122 ++++++++---------- .../src/server/file_system_handler.rs | 12 +- codex-rs/exec-server/tests/file_system.rs | 47 ++++--- 5 files changed, 100 insertions(+), 100 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 8e10c4a34e..02fd493b10 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -443,8 +443,11 @@ mod tests { std::env::current_exe().expect("current exe").as_path(), ) .expect("absolute current exe"); - let sandbox = crate::FileSystemSandboxContext::new( - codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), + let sandbox = crate::FileSystemSandboxContext::from_permission_profile( + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &codex_protocol::permissions::FileSystemSandboxPolicy::restricted(Vec::new()), + codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + ), ); let err = environment diff --git a/codex-rs/exec-server/src/file_system.rs b/codex-rs/exec-server/src/file_system.rs index 001c500f1b..a474b35368 100644 --- a/codex-rs/exec-server/src/file_system.rs +++ b/codex-rs/exec-server/src/file_system.rs @@ -57,18 +57,6 @@ pub struct FileSystemSandboxContext { } impl FileSystemSandboxContext { - pub fn new(sandbox_policy: SandboxPolicy) -> Self { - if let Ok(cwd) = AbsolutePathBuf::current_dir() { - Self::from_legacy_sandbox_policy(sandbox_policy, cwd) - } else { - let permissions = PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::from(&sandbox_policy), - NetworkSandboxPolicy::from(&sandbox_policy), - ); - Self::from_permission_profile(permissions) - } - } - pub fn from_legacy_sandbox_policy(sandbox_policy: SandboxPolicy, cwd: AbsolutePathBuf) -> Self { let permissions = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()), diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index d8261fae14..9836a5b5bd 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -350,8 +350,6 @@ mod tests { use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_protocol::protocol::ReadOnlyAccess; - use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -359,6 +357,7 @@ mod tests { use super::FileSystemSandboxRunner; use super::add_helper_runtime_permissions; + use super::compatibility_sandbox_policy; use super::helper_env; use super::helper_env_from_vars; use super::helper_env_key_is_allowed; @@ -366,18 +365,10 @@ mod tests { use super::sandbox_cwd; #[test] - fn helper_permissions_enable_minimal_reads_for_read_only_access() { + fn helper_permissions_enable_minimal_reads_for_restricted_profile() { let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute cwd"); - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, - network_access: false, - }; - let mut policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()); + let mut policy = restricted_policy(Vec::new()); add_helper_runtime_permissions(&mut policy, /*helper_read_roots*/ &[], cwd.as_path()); @@ -385,21 +376,13 @@ mod tests { } #[test] - fn helper_permissions_enable_minimal_reads_for_workspace_read_access() { + fn helper_permissions_enable_minimal_reads_for_restricted_profile_with_writes() { let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute cwd"); - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let mut policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()); + let mut policy = restricted_policy(vec![path_entry( + cwd.join("writable"), + FileSystemAccessMode::Write, + )]); add_helper_runtime_permissions(&mut policy, /*helper_read_roots*/ &[], cwd.as_path()); @@ -415,21 +398,10 @@ mod tests { let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute cwd"); let writable = cwd.join("writable"); - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, - network_access: true, - }; - let mut policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()); - policy.entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: writable.clone(), - }, - access: FileSystemAccessMode::Write, - }); + let mut policy = restricted_policy(vec![path_entry( + writable.clone(), + FileSystemAccessMode::Write, + )]); let readable = AbsolutePathBuf::from_absolute_path( runtime_paths .codex_self_exe @@ -523,16 +495,18 @@ mod tests { .expect("runtime paths"); let runner = FileSystemSandboxRunner::new(runtime_paths); let cwd = AbsolutePathBuf::current_dir().expect("cwd"); - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let file_system_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()); - let sandbox_context = crate::FileSystemSandboxContext::new(sandbox_policy.clone()); + restricted_policy(vec![path_entry(cwd.clone(), FileSystemAccessMode::Write)]); + let network_policy = NetworkSandboxPolicy::Restricted; + let sandbox_policy = + compatibility_sandbox_policy(&file_system_policy, network_policy, cwd.as_path()); + let sandbox_context = sandbox_context_with_cwd(&file_system_policy, cwd.clone()); let request = runner .sandbox_exec_request( &sandbox_policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, + network_policy, &cwd, &sandbox_context, ) @@ -545,10 +519,11 @@ mod tests { fn sandbox_cwd_uses_context_cwd() { let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute cwd"); - let sandbox_context = crate::FileSystemSandboxContext::from_legacy_sandbox_policy( - SandboxPolicy::new_workspace_write_policy(), - cwd.clone(), - ); + let policy = restricted_policy(vec![special_entry( + FileSystemSpecialPath::CurrentWorkingDirectory, + FileSystemAccessMode::Write, + )]); + let sandbox_context = sandbox_context_with_cwd(&policy, cwd.clone()); assert_eq!(sandbox_cwd(&sandbox_context).expect("sandbox cwd"), cwd); } @@ -583,15 +558,7 @@ mod tests { .expect("runtime paths"); let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute cwd"); - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, - network_access: false, - }; - let mut policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()); + let mut policy = restricted_policy(Vec::new()); let readable = AbsolutePathBuf::from_absolute_path( runtime_paths .codex_self_exe @@ -619,15 +586,7 @@ mod tests { .expect("runtime paths"); let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path()) .expect("absolute cwd"); - let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, - network_access: false, - }; - let mut policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, cwd.as_path()); + let mut policy = restricted_policy(Vec::new()); let codex_parent = AbsolutePathBuf::from_absolute_path(root.path().join("bin")) .expect("absolute codex parent"); let alias_parent = AbsolutePathBuf::from_absolute_path(root.path().join("aliases")) @@ -642,4 +601,35 @@ mod tests { assert!(policy.can_read_path_with_cwd(codex_parent.as_path(), cwd.as_path())); assert!(policy.can_read_path_with_cwd(alias_parent.as_path(), cwd.as_path())); } + + fn restricted_policy(entries: Vec) -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::restricted(entries) + } + + fn sandbox_context_with_cwd( + policy: &FileSystemSandboxPolicy, + cwd: AbsolutePathBuf, + ) -> crate::FileSystemSandboxContext { + crate::FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(policy, NetworkSandboxPolicy::Restricted), + cwd, + ) + } + + fn path_entry(path: AbsolutePathBuf, access: FileSystemAccessMode) -> FileSystemSandboxEntry { + FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access, + } + } + + fn special_entry( + value: FileSystemSpecialPath, + access: FileSystemAccessMode, + ) -> FileSystemSandboxEntry { + FileSystemSandboxEntry { + path: FileSystemPath::Special { value }, + access, + } + } } diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index 14d6b8f7be..bef30e975f 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -192,6 +192,8 @@ mod tests { ) .expect("runtime paths"); let handler = FileSystemHandler::new(runtime_paths); + let sandbox_cwd = + AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute tempdir"); for (file_name, sandbox_policy) in [ ("danger.txt", SandboxPolicy::DangerFullAccess), @@ -210,7 +212,10 @@ mod tests { .write_file(FsWriteFileParams { path: path.clone(), data_base64: STANDARD.encode("ok"), - sandbox: Some(FileSystemSandboxContext::new(sandbox_policy.clone())), + sandbox: Some(FileSystemSandboxContext::from_legacy_sandbox_policy( + sandbox_policy.clone(), + sandbox_cwd.clone(), + )), }) .await .expect("write file"); @@ -218,7 +223,10 @@ mod tests { let response = handler .read_file(FsReadFileParams { path, - sandbox: Some(FileSystemSandboxContext::new(sandbox_policy)), + sandbox: Some(FileSystemSandboxContext::from_legacy_sandbox_policy( + sandbox_policy, + sandbox_cwd.clone(), + )), }) .await .expect("read file"); diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index fe0f9541f7..0a7c000ae1 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -22,9 +22,11 @@ use codex_exec_server::LocalFileSystem; use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::ReadOnlyAccess; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -80,38 +82,47 @@ fn absolute_path(path: std::path::PathBuf) -> AbsolutePathBuf { } fn read_only_sandbox(readable_root: std::path::PathBuf) -> FileSystemSandboxContext { - FileSystemSandboxContext::new(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![absolute_path(readable_root)], + let readable_root = absolute_path(readable_root); + sandbox_context(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: readable_root, }, - network_access: false, - }) + access: FileSystemAccessMode::Read, + }]) } fn workspace_write_sandbox(writable_root: std::path::PathBuf) -> FileSystemSandboxContext { - FileSystemSandboxContext::new(SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path(writable_root)], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![], + let writable_root = absolute_path(writable_root); + sandbox_context(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: writable_root, }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, + access: FileSystemAccessMode::Write, + }]) +} + +fn sandbox_context(entries: Vec) -> FileSystemSandboxContext { + FileSystemSandboxContext::from_permission_profile(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(false), + }), + file_system: Some(FileSystemPermissions { + entries, + glob_scan_max_depth: None, + }), }) } #[test] -fn sandbox_context_new_preserves_legacy_workspace_write_read_only_subpaths() -> Result<()> { +fn sandbox_context_from_profile_preserves_workspace_write_read_only_subpaths() -> Result<()> { let tmp = TempDir::new()?; let writable_dir = tmp.path().join("writable"); let git_dir = writable_dir.join(".git"); std::fs::create_dir_all(&git_dir)?; let sandbox = workspace_write_sandbox(writable_dir.clone()); - let cwd = sandbox.cwd.as_ref().expect("sandbox cwd"); let policy = sandbox.permissions.file_system_sandbox_policy(); + let cwd = absolute_path(writable_dir.clone()); let writable_roots = policy.get_writable_roots_with_cwd(cwd.as_path()); let writable_dir = absolute_path(std::fs::canonicalize(writable_dir)?); let git_dir = absolute_path(std::fs::canonicalize(git_dir)?); From 41a32d7f5cab78f19aa22c15015b2f048409db87 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 06/14] clients: send permission profiles to app-server --- codex-rs/exec/src/lib.rs | 37 +++++++- codex-rs/exec/src/lib_tests.rs | 5 + codex-rs/tui/src/app/tests.rs | 78 +++++++++++++++ codex-rs/tui/src/app/thread_events.rs | 5 + codex-rs/tui/src/app/thread_session_state.rs | 5 +- codex-rs/tui/src/app_server_session.rs | 99 +++++++++++++++++++- 6 files changed, 220 insertions(+), 9 deletions(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 43c5e45250..c8059ad5fe 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -738,6 +738,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { items, output_schema, } => { + let permission_profile = permission_profile_override_from_config(&config); + let sandbox_policy = permission_profile + .is_none() + .then(|| default_sandbox_policy.clone().into()); let response: TurnStartResponse = send_request_with_response( &client, ClientRequest::TurnStart { @@ -750,8 +754,8 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { cwd: Some(default_cwd), approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, - sandbox_policy: Some(default_sandbox_policy.clone().into()), - permission_profile: None, + sandbox_policy, + permission_profile, model: None, service_tier: None, effort: default_effort, @@ -925,13 +929,19 @@ fn sandbox_mode_from_policy( } fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { + let permission_profile = permission_profile_override_from_config(config); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get())) + .flatten(); ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()), + sandbox, + permission_profile, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), ..ThreadStartParams::default() @@ -939,6 +949,11 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { + let permission_profile = permission_profile_override_from_config(config); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get())) + .flatten(); ThreadResumeParams { thread_id, model: config.model.clone(), @@ -946,12 +961,26 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa cwd: Some(config.cwd.to_string_lossy().to_string()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()), + sandbox, + permission_profile, config: config_request_overrides_from_config(config), ..ThreadResumeParams::default() } } +fn permission_profile_override_from_config( + config: &Config, +) -> Option { + if matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(config.permissions.permission_profile().into()) + } +} + fn config_request_overrides_from_config(config: &Config) -> Option> { config .active_profile diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 708cc9d0db..50fe8552ba 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -361,6 +361,11 @@ async fn thread_start_params_include_review_policy_when_review_policy_is_manual_ params.approvals_reviewer, Some(codex_app_server_protocol::ApprovalsReviewer::User) ); + assert_eq!(params.sandbox, None); + assert_eq!( + params.permission_profile, + Some(config.permissions.permission_profile().into()) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 87b7faa34b..674e3204d8 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2215,6 +2215,10 @@ async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> { ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/agent"), + )), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2374,6 +2378,10 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()> ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/agent"), + )), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2596,6 +2604,10 @@ async fn inactive_thread_approval_badge_clears_after_turn_completion_notificatio ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/agent"), + )), rollout_path: Some(test_path_buf("/tmp/agent-rollout.jsonl")), ..test_thread_session(agent_thread_id, test_path_buf("/tmp/agent")) }, @@ -2649,6 +2661,10 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/main"), + )), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; @@ -2760,6 +2776,10 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/main"), + )), ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) }; @@ -2815,6 +2835,60 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ Ok(()) } +/// `thread/read` is metadata/replay hydration and does not return an +/// authoritative runtime `PermissionProfile`, so it must not reuse the active +/// primary session profile after swapping in the read thread's cwd. +#[tokio::test] +async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { + let mut app = make_test_app().await; + let main_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread"); + let read_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread"); + let primary_session = ThreadSessionState { + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + std::path::Path::new("/tmp/main"), + )), + ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + }; + app.primary_session_configured = Some(primary_session); + + let thread = Thread { + id: read_thread_id.to_string(), + forked_from_id: None, + preview: "read thread".to_string(), + ephemeral: false, + model_provider: "read-provider".to_string(), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp/read").abs(), + cli_version: "0.0.0".to_string(), + source: codex_app_server_protocol::SessionSource::Unknown, + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some("read thread".to_string()), + turns: Vec::new(), + }; + + let session = app + .session_state_for_thread_read(read_thread_id, &thread) + .await; + + assert_eq!(session.thread_id, read_thread_id); + assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path()); + assert_eq!( + session.permission_profile, None, + "thread/read does not return an authoritative permission profile; reusing the primary \ + session profile would reinterpret cwd-bound entries against the read thread cwd" + ); +} + #[test] fn agent_picker_item_name_snapshot() { let thread_id = @@ -3675,6 +3749,10 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + )), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 89cef2332c..10415c9f46 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -285,6 +285,7 @@ mod tests { use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_config::types::ApprovalsReviewer; + use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; @@ -302,6 +303,10 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + cwd.as_path(), + )), cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index a8fd34769e..49a715affc 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -10,6 +10,7 @@ impl App { thread_id: ThreadId, thread: &Thread, ) -> ThreadSessionState { + let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); let mut session = self .primary_session_configured .clone() @@ -23,7 +24,8 @@ impl App { service_tier: self.chat_widget.current_service_tier(), approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: self.config.approvals_reviewer, - sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), + sandbox_policy, + permission_profile: None, cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), @@ -36,6 +38,7 @@ impl App { session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); session.cwd = thread.cwd.clone(); + session.permission_profile = None; session.instruction_source_paths = Vec::new(); session.rollout_path = thread.path.clone(); if let Some(model) = diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 8470c575dd..b1f1e01c9a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -85,6 +85,7 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; @@ -148,7 +149,13 @@ pub(crate) struct ThreadSessionState { pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, + /// Legacy sandbox projection kept for compatibility. Use this only when + /// `permission_profile` is `None`. pub(crate) sandbox_policy: SandboxPolicy, + /// Canonical active permissions when available. Consumers should prefer + /// this over `sandbox_policy`; `None` means the session only has a legacy + /// sandbox projection or represents an external sandbox. + pub(crate) permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, @@ -528,6 +535,13 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); + let sandbox_policy = if self.is_remote() + || matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) + { + Some(sandbox_policy.into()) + } else { + None + }; self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -539,7 +553,10 @@ impl AppServerSession { cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), - sandbox_policy: Some(sandbox_policy.into()), + // Embedded sessions already installed their full profile + // at thread start/resume/fork. Avoid sending a lossy + // legacy projection until user turns carry profiles. + sandbox_policy, permission_profile: None, model: Some(model), service_tier, @@ -1029,19 +1046,43 @@ fn sandbox_mode_from_policy( } } +fn permission_profile_override_from_config( + config: &Config, + thread_params_mode: ThreadParamsMode, +) -> Option { + if matches!(thread_params_mode, ThreadParamsMode::Remote) { + return None; + } + + if matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(config.permissions.permission_profile().into()) + } +} + fn thread_start_params_from_config( config: &Config, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, session_start_source: Option, ) -> ThreadStartParams { + let permission_profile = permission_profile_override_from_config(config, thread_params_mode); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone())) + .flatten(); ThreadStartParams { model: config.model.clone(), model_provider: thread_params_mode.model_provider_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + sandbox, + permission_profile, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), session_start_source, @@ -1056,6 +1097,11 @@ fn thread_resume_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadResumeParams { + let permission_profile = permission_profile_override_from_config(&config, thread_params_mode); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone())) + .flatten(); ThreadResumeParams { thread_id: thread_id.to_string(), model: config.model.clone(), @@ -1063,7 +1109,8 @@ fn thread_resume_params_from_config( cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + sandbox, + permission_profile, config: config_request_overrides_from_config(&config), persist_extended_history: true, ..ThreadResumeParams::default() @@ -1076,6 +1123,11 @@ fn thread_fork_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadForkParams { + let permission_profile = permission_profile_override_from_config(&config, thread_params_mode); + let sandbox = permission_profile + .is_none() + .then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone())) + .flatten(); ThreadForkParams { thread_id: thread_id.to_string(), model: config.model.clone(), @@ -1083,7 +1135,8 @@ fn thread_fork_params_from_config( cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), - sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + sandbox, + permission_profile, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), developer_instructions: config.developer_instructions.clone(), @@ -1160,6 +1213,7 @@ async fn thread_session_state_from_thread_start_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1183,6 +1237,7 @@ async fn thread_session_state_from_thread_resume_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1206,6 +1261,7 @@ async fn thread_session_state_from_thread_fork_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, @@ -1248,6 +1304,7 @@ async fn thread_session_state_from_thread_response( approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, cwd: AbsolutePathBuf, instruction_source_paths: Vec, reasoning_effort: Option, @@ -1274,6 +1331,7 @@ async fn thread_session_state_from_thread_response( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, cwd, instruction_source_paths, reasoning_effort, @@ -1366,6 +1424,11 @@ mod tests { ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); + assert_eq!(params.sandbox, None); + assert_eq!( + params.permission_profile, + Some(config.permissions.permission_profile().into()) + ); assert_eq!(params.model_provider, Some(config.model_provider_id)); } @@ -1389,6 +1452,8 @@ mod tests { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); + let expected_sandbox = + sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()); let start = thread_start_params_from_config( &config, @@ -1415,6 +1480,12 @@ mod tests { assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); + assert_eq!(start.sandbox, expected_sandbox); + assert_eq!(resume.sandbox, expected_sandbox); + assert_eq!(fork.sandbox, expected_sandbox); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); } #[tokio::test] @@ -1423,6 +1494,8 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let remote_cwd = PathBuf::from("repo/on/server"); + let expected_sandbox = + sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()); let start = thread_start_params_from_config( &config, @@ -1449,6 +1522,12 @@ mod tests { assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); + assert_eq!(start.sandbox, expected_sandbox); + assert_eq!(resume.sandbox, expected_sandbox); + assert_eq!(fork.sandbox, expected_sandbox); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); } #[tokio::test] @@ -1547,6 +1626,10 @@ mod tests { started.session.instruction_source_paths, response.instruction_sources ); + assert_eq!( + started.session.permission_profile, + response.permission_profile.clone().map(Into::into) + ); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); } @@ -1575,6 +1658,10 @@ mod tests { AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, SandboxPolicy::new_read_only_policy(), + Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + std::path::Path::new("/tmp/project"), + )), test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, @@ -1605,6 +1692,10 @@ mod tests { AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, SandboxPolicy::new_read_only_policy(), + Some(PermissionProfile::from_legacy_sandbox_policy( + &SandboxPolicy::new_read_only_policy(), + std::path::Path::new("/tmp/project"), + )), test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, From 2ce54a351d19a35fdfc3114dc55aad7d68731e58 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 07/14] rollout: persist turn permission profiles --- .../core/src/context_manager/history_tests.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 8 +++++++ codex-rs/core/src/session/tests.rs | 9 ++++++++ codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 1 + codex-rs/protocol/src/protocol.rs | 21 +++++++++++++++++++ codex-rs/rollout/src/recorder_tests.rs | 1 + codex-rs/state/src/extract.rs | 3 +++ codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/lib.rs | 1 + 11 files changed, 48 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1df14ca8bc..bd8e77fd24 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -133,6 +133,7 @@ fn reference_context_item() -> TurnContextItem { timezone: Some("America/Los_Angeles".to_string()), approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-test".to_string(), diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0b8b7a5887..862b2698d1 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -49,7 +49,7 @@ fn build_permissions_update_item( } let prev = previous?; - if prev.sandbox_policy == *next.sandbox_policy.get() + if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() { return None; diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index a3cb9a7353..a865fa4976 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -68,6 +68,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -108,6 +109,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -917,6 +919,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -994,6 +997,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1024,6 +1028,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1138,6 +1143,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: current_model.to_string(), @@ -1251,6 +1257,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -1402,6 +1409,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b88b8a4083..4c5526869d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1606,6 +1606,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), sandbox_policy: turn_context.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), @@ -5207,6 +5208,10 @@ async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() let item = turn_context.to_turn_context_item(); assert_eq!(item.file_system_sandbox_policy, None); + assert_eq!( + item.permission_profile, + Some(turn_context.permission_profile()) + ); } #[tokio::test] @@ -5221,6 +5226,10 @@ async fn turn_context_item_stores_split_file_system_sandbox_policy_when_differen item.file_system_sandbox_policy, Some(file_system_sandbox_policy) ); + assert_eq!( + item.permission_profile, + Some(turn_context.permission_profile()) + ); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index f5a897904f..af52361df1 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -272,6 +272,7 @@ impl TurnContext { timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), + permission_profile: Some(self.permission_profile()), network: self.turn_context_network_item(), file_system_sandbox_policy: self.non_legacy_file_system_sandbox_policy(), model: self.model_info.slug.clone(), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 0368d9baf6..e1e9220fa3 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -33,6 +33,7 @@ fn resume_history( timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: previous_model.to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 9ef9b9f84a..c50671df17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3002,6 +3002,8 @@ pub struct TurnContextItem { pub timezone: Option, pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3026,6 +3028,24 @@ pub struct TurnContextItem { pub truncation_policy: Option, } +impl TurnContextItem { + pub fn permission_profile(&self) -> PermissionProfile { + self.permission_profile.clone().unwrap_or_else(|| { + let file_system_sandbox_policy = + self.file_system_sandbox_policy.clone().unwrap_or_else(|| { + FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &self.sandbox_policy, + &self.cwd, + ) + }); + PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::from(&self.sandbox_policy), + ) + }) + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "mode", content = "limit", rename_all = "snake_case")] pub enum TruncationPolicy { @@ -5162,6 +5182,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, network: Some(TurnContextNetworkItem { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index e5e898857e..1516987669 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -947,6 +947,7 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "test-model".to_string(), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 6d36f211a4..f0fe3c693c 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -301,6 +301,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -340,6 +341,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), @@ -373,6 +375,7 @@ mod tests { timezone: None, approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-5".to_string(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 674e3204d8..891c9649da 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2689,6 +2689,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re timezone: None, approval_policy: primary_session.approval_policy, sandbox_policy: primary_session.sandbox_policy.clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model: "gpt-agent".to_string(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8814cddda3..ee50d0a178 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2197,6 +2197,7 @@ mod tests { timezone: None, approval_policy: config.permissions.approval_policy.value(), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, network: None, file_system_sandbox_policy: None, model, From dc8fa55a5fd0ac704b5bbbe4e20f5808f2f54ccf Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 08/14] protocol: report session permission profiles --- codex-rs/core/src/session/session.rs | 1 + codex-rs/exec/src/lib.rs | 5 + .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/protocol/src/protocol.rs | 5 + codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/tests.rs | 168 ++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 1 + .../chatwidget/tests/composer_submission.rs | 7 + .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 8 + .../tui/src/chatwidget/tests/permissions.rs | 2 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + codex-rs/tui/src/history_cell.rs | 1 + 14 files changed, 206 insertions(+) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c64b1c30ac..e3f7d1b6c2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -806,6 +806,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, sandbox_policy: session_configuration.sandbox_policy.get().clone(), + permission_profile: Some(session_configuration.permission_profile()), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c8059ad5fe..76665803ca 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -76,6 +76,7 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; @@ -1024,6 +1025,7 @@ fn session_configured_from_thread_start_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1042,6 +1044,7 @@ fn session_configured_from_thread_resume_response( response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response.sandbox.to_core(), + response.permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, ) @@ -1070,6 +1073,7 @@ fn session_configured_from_thread_response( approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { @@ -1086,6 +1090,7 @@ fn session_configured_from_thread_response( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, cwd, reasoning_effort, history_log_id: 0, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 02e52988cb..e894b8f4eb 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -115,6 +115,7 @@ fn session_configured_produces_thread_started_event() { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index e94de16587..67ceb91dd8 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -305,6 +305,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -349,6 +350,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, @@ -418,6 +420,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), history_log_id: 1, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c50671df17..6281792be4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3675,6 +3675,10 @@ pub struct SessionConfiguredEvent { /// How to sandbox commands executed in the system pub sandbox_policy: SandboxPolicy, + /// Effective permissions for commands executed in the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, + /// Working directory that should be treated as the *root* of the /// session. pub cwd: AbsolutePathBuf, @@ -5249,6 +5253,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index edfde7ceeb..3c09cb74b3 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -642,6 +642,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: next_cwd.clone().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 891c9649da..21e50d1b4b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3,6 +3,7 @@ mod model_catalog; use super::*; +use crate::app::test_support::app_enabled_in_effective_config; use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; @@ -3517,6 +3518,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, @@ -4077,6 +4079,167 @@ fn active_turn_steer_race_extracts_actual_turn_id_from_mismatch() { ); } +#[tokio::test] +async fn update_reasoning_effort_updates_collaboration_mode() { + let mut app = make_test_app().await; + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.config.model_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); +} + +#[tokio::test] +async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + let app_id = "unit_test_refresh_in_memory_config_connector".to_string(); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + ConfigEditsBuilder::new(&app.config.codex_home) + .with_edits([ + ConfigEdit::SetPath { + segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()], + value: false.into(), + }, + ConfigEdit::SetPath { + segments: vec![ + "apps".to_string(), + app_id.clone(), + "disabled_reason".to_string(), + ], + value: "user".into(), + }, + ]) + .apply() + .await + .expect("persist app toggle"); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app_enabled_in_effective_config(&app.config, &app_id), + Some(false) + ); + Ok(()) +} + +#[tokio::test] +async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> +{ + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let original_config = app.config.clone(); + + app.refresh_in_memory_config_from_disk_best_effort("starting a new thread") + .await; + + assert_eq!(app.config, original_config); + Ok(()) +} + +#[tokio::test] +async fn refresh_in_memory_config_from_disk_uses_active_chat_widget_cwd() -> Result<()> { + let mut app = make_test_app().await; + let original_cwd = app.config.cwd.clone(); + let next_cwd_tmp = tempdir()?; + let next_cwd = next_cwd_tmp.path().to_path_buf(); + + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, + cwd: next_cwd.clone().abs(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); + assert_eq!(app.config.cwd, original_cwd); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!(app.config.cwd, app.chat_widget.config_ref().cwd); + Ok(()) +} + +#[tokio::test] +async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> +{ + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_config = app.config.clone(); + let current_cwd = current_config.cwd.clone(); + + let resume_config = app + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) + .await?; + + assert_eq!(resume_config, current_config); + Ok(()) +} + +#[tokio::test] +async fn rebuild_config_for_resume_or_fallback_errors_when_cwd_changes() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_cwd = app.config.cwd.clone(); + let next_cwd_tmp = tempdir()?; + let next_cwd = next_cwd_tmp.path().to_path_buf(); + + let result = app + .rebuild_config_for_resume_or_fallback(¤t_cwd, next_cwd) + .await; + + assert!(result.is_err()); + Ok(()) +} + +#[tokio::test] +async fn sync_tui_theme_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_theme_selection("dracula".to_string()); + + assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); + assert_eq!( + app.chat_widget.config_ref().tui_theme.as_deref(), + Some("dracula") + ); +} + #[tokio::test] async fn fresh_session_config_uses_current_service_tier() { let mut app = make_test_app().await; @@ -4125,6 +4288,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4188,6 +4352,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4281,6 +4446,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4664,6 +4830,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: None, history_log_id: 0, @@ -4776,6 +4943,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 093ff91aae..e7ffe7ff7d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1395,6 +1395,7 @@ fn thread_session_state_to_legacy_event( approval_policy: session.approval_policy, approvals_reviewer: session.approvals_reviewer, sandbox_policy: session.sandbox_policy, + permission_profile: session.permission_profile, cwd: session.cwd, reasoning_effort: session.reasoning_effort, history_log_id: session.history_log_id, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 2f8c6a9b2b..d35c7e9ded 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -17,6 +17,7 @@ async fn submission_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -101,6 +102,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -196,6 +198,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -261,6 +264,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -301,6 +305,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -341,6 +346,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -384,6 +390,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index cb6bea9cc6..206564d06a 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1005,6 +1005,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 084743f98d..3e2f5b4d74 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -17,6 +17,7 @@ async fn resumed_initial_messages_render_history() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -130,6 +131,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -191,6 +193,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -259,6 +262,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), + permission_profile: None, cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -302,6 +306,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -355,6 +360,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -676,6 +682,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -723,6 +730,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 92ca170605..6b7dc2ed8e 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -463,6 +463,7 @@ async fn permissions_selection_marks_guardian_approvals_current_after_session_co approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::GuardianSubagent, sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, @@ -517,6 +518,7 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, + permission_profile: None, cwd: test_project_path().abs(), reasoning_effort: None, history_log_id: 0, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 1f91a8e1e7..f19a55d22e 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1061,6 +1061,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -1306,6 +1307,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a09b313e26..33a05776b9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3073,6 +3073,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, history_log_id: 0, From 07ce48f669074b1d87ad58eff763b8ff3858eea2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 09/14] app-server: accept command permission profiles --- .../schema/json/ClientRequest.json | 13 +- .../codex_app_server_protocol.schemas.json | 13 +- .../codex_app_server_protocol.v2.schemas.json | 13 +- .../schema/json/v2/CommandExecParams.json | 282 +++++++++++++++++- .../schema/typescript/v2/CommandExecParams.ts | 13 +- .../src/protocol/mappers.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 18 +- codex-rs/app-server/README.md | 12 +- .../app-server/src/codex_message_processor.rs | 61 +++- .../app-server/tests/suite/v2/command_exec.rs | 122 ++++++++ 10 files changed, 532 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d941238ca8..8f2324d814 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -218,6 +218,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -234,7 +245,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ 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 c463df699f..eb42110953 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 @@ -6425,6 +6425,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -6441,7 +6452,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 20e0f8f6e3..e2b7cadfa7 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3012,6 +3012,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -3028,7 +3039,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 986ec4cc1e..2198aad5bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,17 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "CommandExecTerminalSize": { "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { @@ -27,6 +38,217 @@ ], "type": "object" }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "current_working_directory" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "CurrentWorkingDirectoryFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, "NetworkAccess": { "enum": [ "restricted", @@ -34,6 +256,53 @@ ], "type": "string" }, + "PermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "PermissionProfileFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, "ReadOnlyAccess": { "oneOf": [ { @@ -247,6 +516,17 @@ "null" ] }, + "permissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional full permissions profile for this command.\n\nDefaults to the user's configured permissions when omitted. Cannot be combined with `sandboxPolicy`." + }, "processId": { "description": "Optional client-supplied, connection-scoped process id.\n\nRequired for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up `command/exec/write`, `command/exec/resize`, and `command/exec/terminate` calls. When omitted, buffered execution gets an internal id that is not exposed to the client.", "type": [ @@ -263,7 +543,7 @@ "type": "null" } ], - "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted." + "description": "Optional sandbox policy for this command.\n\nUses the same shape as thread/turn execution sandbox configuration and defaults to the user's configured policy when omitted. Cannot be combined with `permissionProfile`." }, "size": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 097cfdfecc..659974feaf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -2,6 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; +import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; /** @@ -92,6 +93,14 @@ size?: CommandExecTerminalSize | null, * Optional sandbox policy for this command. * * Uses the same shape as thread/turn execution sandbox configuration and - * defaults to the user's configured policy when omitted. + * defaults to the user's configured policy when omitted. Cannot be + * combined with `permissionProfile`. */ -sandboxPolicy?: SandboxPolicy | null, }; +sandboxPolicy?: SandboxPolicy | null, +/** + * Optional full permissions profile for this command. + * + * Defaults to the user's configured permissions when omitted. Cannot be + * combined with `sandboxPolicy`. + */ +permissionProfile?: PermissionProfile | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index 93f12691be..dae91e650f 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -18,6 +18,7 @@ impl From for v2::CommandExecParams { env: None, size: None, sandbox_policy: value.sandbox_policy.map(std::convert::Into::into), + permission_profile: None, } } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2760313dc7..87b587ccc1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3066,9 +3066,16 @@ pub struct CommandExecParams { /// Optional sandbox policy for this command. /// /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[ts(optional = nullable)] + pub permission_profile: Option, } /// Final buffered result for `command/exec`. @@ -8195,6 +8202,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, } ); } @@ -8215,6 +8223,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8229,6 +8238,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, "outputBytesCap": null, }) ); @@ -8254,6 +8264,7 @@ mod tests { env: None, size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8270,6 +8281,7 @@ mod tests { "env": null, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8298,6 +8310,7 @@ mod tests { ])), size: None, sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8316,6 +8329,7 @@ mod tests { }, "size": null, "sandboxPolicy": null, + "permissionProfile": null, }) ); @@ -8385,6 +8399,7 @@ mod tests { cols: 120, }), sandbox_policy: None, + permission_profile: None, }; let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); @@ -8403,6 +8418,7 @@ mod tests { "cols": 120, }, "sandboxPolicy": null, + "permissionProfile": null, }) ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 15b7f9a5be..7d22640f49 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -819,7 +819,13 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "sandboxPolicy": { "type": "workspaceWrite" }, // optional; defaults to user config + "permissionProfile": { // optional; defaults to user config + "fileSystem": { "entries": [ + { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, + { "path": { "type": "special", "value": { "kind": "current_working_directory" } }, "access": "write" } + ] }, + "network": { "enabled": false } + }, "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -832,12 +838,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin } } ``` -- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. +- For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. Notes: - Empty `command` arrays are rejected. -- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). +- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1ab367d078..b38cef6c88 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2073,7 +2073,16 @@ impl CodexMessageProcessor { env: env_overrides, size, sandbox_policy, + permission_profile, } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + self.send_invalid_request_error( + request_id, + "`permissionProfile` cannot be combined with `sandboxPolicy`".to_string(), + ) + .await; + return; + } if size.is_some() && !tty { let error = JSONRPCErrorError { @@ -2205,13 +2214,52 @@ impl CodexMessageProcessor { arg0: None, }; - let requested_policy = sandbox_policy.map(|policy| policy.to_core()); let ( effective_policy, effective_file_system_sandbox_policy, effective_network_sandbox_policy, - ) = match requested_policy { - Some(policy) => match self.config.permissions.sandbox_policy.can_set(&policy) { + ) = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let sandbox_policy = match permission_profile.to_legacy_sandbox_policy(&sandbox_cwd) { + Ok(sandbox_policy) => sandbox_policy, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + }; + match self + .config + .permissions + .sandbox_policy + .can_set(&sandbox_policy) + { + Ok(()) => { + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + ( + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + ) + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid permission profile: {err}"), + data: None, + }; + self.outgoing.send_error(request, error).await; + return; + } + } + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + match self.config.permissions.sandbox_policy.can_set(&policy) { Ok(()) => { let file_system_sandbox_policy = codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy, &sandbox_cwd); @@ -2228,12 +2276,13 @@ impl CodexMessageProcessor { self.outgoing.send_error(request, error).await; return; } - }, - None => ( + } + } else { + ( self.config.permissions.sandbox_policy.get().clone(), self.config.permissions.file_system_sandbox_policy.clone(), self.config.permissions.network_sandbox_policy, - ), + ) }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index d7645f13e3..66f879176f 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -13,9 +13,17 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::FileSystemAccessMode; +use codex_app_server_protocol::FileSystemPath; +use codex_app_server_protocol::FileSystemSandboxEntry; +use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::PermissionProfileFileSystemPermissions; +use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -57,6 +65,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; let terminate_request_id = mcp @@ -109,6 +118,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -167,6 +177,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un ])), size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -186,6 +197,91 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un Ok(()) } +#[tokio::test] +async fn command_exec_accepts_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf 'profile'".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "profile".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permission_profile: Some(root_read_only_permission_profile()), + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!( + error.error.message, + "`permissionProfile` cannot be combined with `sandboxPolicy`" + ); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -209,6 +305,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -246,6 +343,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -283,6 +381,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -320,6 +419,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -361,6 +461,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -408,6 +509,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -471,6 +573,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -546,6 +649,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( env: None, size: None, sandbox_policy: None, + permission_profile: None, }) .await?; @@ -619,6 +723,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { cols: 101, }), sandbox_policy: None, + permission_profile: None, }) .await?; @@ -888,6 +993,23 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } +fn root_read_only_permission_profile() -> PermissionProfile { + PermissionProfile { + network: Some(PermissionProfileNetworkPermissions { + enabled: Some(false), + }), + file_system: Some(PermissionProfileFileSystemPermissions { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }), + } +} + async fn read_initialize_response( stream: &mut super::connection_handling_websocket::WsClient, request_id: i64, From d34a7d650e33268489091fd28eb70f58d4871f98 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 10/14] tui: sync session permission profiles --- codex-rs/tui/src/chatwidget.rs | 9 +++++++ .../src/chatwidget/tests/history_replay.rs | 26 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e7ffe7ff7d..91bca68388 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -138,6 +138,7 @@ use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; @@ -2104,6 +2105,12 @@ impl ChatWidget { display: SessionConfiguredDisplay, fork_parent_title: Option, ) { + let permission_profile = event.permission_profile.clone().unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy(&event.sandbox_policy, &event.cwd) + }); + let (file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + self.last_agent_markdown = None; self.agent_turn_markdowns.clear(); self.visible_user_turn_count = 0; @@ -2140,6 +2147,8 @@ impl ChatWidget { self.config.permissions.sandbox_policy = Constrained::allow_only(event.sandbox_policy.clone()); } + self.config.permissions.file_system_sandbox_policy = file_system_sandbox_policy; + self.config.permissions.network_sandbox_policy = network_sandbox_policy; self.config.approvals_reviewer = event.approvals_reviewer; self.status_line_project_root_name_cache = None; let forked_from_id = event.forked_from_id; diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 3e2f5b4d74..583d35c201 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -252,6 +252,26 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let expected_sandbox = SandboxPolicy::new_read_only_policy(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); + let expected_file_system_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::GlobPattern { + pattern: "**/.secret".to_string(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ]); + let expected_permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &expected_file_system_policy, + codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + ); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -262,7 +282,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - permission_profile: None, + permission_profile: Some(expected_permission_profile.clone()), cwd: expected_cwd.clone(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, @@ -285,6 +305,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config_ref().permissions.sandbox_policy.get(), &expected_sandbox ); + assert_eq!( + chat.config_ref().permissions.permission_profile(), + expected_permission_profile + ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); } From 86943c912d8e46a9cbca9ea9af6949ee8e01941e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 11/14] tui: carry permission profiles on user turns --- codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/session/handlers.rs | 3 +- codex-rs/core/src/session/tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/apply_patch_cli.rs | 8 +++ codex-rs/core/tests/suite/approvals.rs | 1 + codex-rs/core/tests/suite/client.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 2 + codex-rs/core/tests/suite/compact.rs | 7 ++ codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 5 ++ codex-rs/core/tests/suite/json_result.rs | 1 + codex-rs/core/tests/suite/live_reload.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 14 ++++ .../core/tests/suite/model_visible_layout.rs | 5 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/pending_input.rs | 1 + codex-rs/core/tests/suite/personality.rs | 13 ++++ codex-rs/core/tests/suite/prompt_caching.rs | 5 ++ codex-rs/core/tests/suite/remote_models.rs | 7 ++ .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 2 + .../suite/responses_api_proxy_headers.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 12 ++++ .../tests/suite/safety_check_downgrade.rs | 4 ++ codex-rs/core/tests/suite/shell_snapshot.rs | 4 ++ codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 ++ codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 7 ++ codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 14 ++++ .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/protocol/src/protocol.rs | 7 ++ codex-rs/tui/src/app/thread_routing.rs | 2 + codex-rs/tui/src/app_command.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 19 ++--- codex-rs/tui/src/chatwidget.rs | 9 +++ .../chatwidget/tests/composer_submission.rs | 69 +++++++++++++++++++ 46 files changed, 246 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 50123ef503..b2aed54a80 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -691,6 +691,7 @@ async fn run_review_on_session( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: params.model.clone(), effort: params.reasoning_effort, summary: Some(params.reasoning_summary), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 9c91f78188..fd4e8cd6bd 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -135,6 +135,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -162,7 +163,7 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), - permission_profile: None, + permission_profile, windows_sandbox_level: None, collaboration_mode, reasoning_summary: summary, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4c5526869d..075b851a68 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3829,6 +3829,7 @@ async fn user_turn_updates_approvals_reviewer() { approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::GuardianSubagent), sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: turn_context.model_info.slug.clone(), effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 74b05f6e17..9f5fb0284d 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -663,6 +663,7 @@ impl TestCodex { approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index a789940b0e..588dd98d50 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -367,6 +367,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1005,6 +1006,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, @@ -1103,6 +1105,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1188,6 +1191,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1344,6 +1348,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1412,6 +1417,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1489,6 +1495,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, @@ -1566,6 +1573,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 1a29d201b4..145579d194 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -595,6 +595,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 95fba68a02..cde1794a1c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1660,6 +1660,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), summary: Some( @@ -1781,6 +1782,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: session_configured.model, effort: None, summary: Some(ReasoningSummary::Concise), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index d52a1454e0..413fd90bbb 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2619,6 +2619,7 @@ text( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3cc8bd8298..26d8d6aacc 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -186,6 +186,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: Some( @@ -307,6 +308,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: Some( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index da4c09345c..60c962a4db 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1687,6 +1687,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: resumed.session_configured.model.clone(), effort: None, summary: None, @@ -1779,6 +1780,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -1805,6 +1807,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, @@ -1917,6 +1920,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -1967,6 +1971,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, @@ -3204,6 +3209,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: previous_model.to_string(), effort: None, summary: None, @@ -3230,6 +3236,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: next_model.to_string(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 8b2654a720..4fae6aafdf 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -54,6 +54,7 @@ async fn submit_user_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -136,6 +137,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 0e2d88237d..eb9751720a 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -126,6 +126,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -214,6 +215,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 6cf42b5eed..db4f7d7584 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -540,6 +540,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -618,6 +619,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -728,6 +730,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -916,6 +919,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, @@ -1031,6 +1035,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index d6728deb0c..01783a365d 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -83,6 +83,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index cfafdea3f7..25206a6fd3 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -64,6 +64,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index db760d8f89..b3e9215635 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -131,6 +131,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -170,6 +171,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -231,6 +233,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -270,6 +273,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: next_model.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -413,6 +417,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -435,6 +440,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -544,6 +550,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -566,6 +573,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -678,6 +686,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -700,6 +709,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -814,6 +824,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -844,6 +855,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, service_tier: None, @@ -1002,6 +1014,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -1063,6 +1076,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 6cbbb7389c..2f06d5af21 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -124,6 +124,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -149,6 +150,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { approval_policy: AskForApproval::OnRequest, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -229,6 +231,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -254,6 +257,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -367,6 +371,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5728205b6c..8eb8fca8ac 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -100,6 +100,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { approval_policy: codex_protocol::protocol::AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index d46214d1f2..0551dcf8e1 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -111,6 +111,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 10907f0263..25048d5c81 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -120,6 +120,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9232dc71cd..0a1c76295a 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -106,6 +106,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -158,6 +159,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -217,6 +219,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -282,6 +285,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -335,6 +339,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -375,6 +380,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -443,6 +449,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -483,6 +490,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -564,6 +572,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -604,6 +613,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, summary: None, @@ -724,6 +734,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -846,6 +857,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -886,6 +898,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 834bab73fb..ca0b3e281a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -728,6 +728,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: new_policy.clone(), + permission_profile: None, model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), @@ -842,6 +843,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -864,6 +866,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model.clone(), effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -970,6 +973,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), + permission_profile: None, model: default_model, effort: default_effort, summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -992,6 +996,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: "o3".to_string(), effort: Some(ReasoningEffort::High), summary: Some(ReasoningSummary::Detailed), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index f2b9d16ba2..790d9ca4f7 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -171,6 +171,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -248,6 +249,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -324,6 +326,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re summary: None, service_tier: None, collaboration_mode: None, + permission_profile: None, personality: None, environments: None, }) @@ -408,6 +411,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: requested_model.to_string(), effort: None, summary: None, @@ -468,6 +472,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), + permission_profile: None, model: requested_model.to_string(), effort: None, summary: Some( @@ -636,6 +641,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REMOTE_MODEL_SLUG.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), @@ -863,6 +869,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: model.to_string(), effort: None, summary: Some(ReasoningSummary::Auto), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 866df13863..06c1e4c5c3 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -197,6 +197,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index d31b060c38..90dd7c02b0 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -148,6 +148,7 @@ async fn submit_turn( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 4c59c8e7f3..7814f4936c 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -141,6 +141,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -260,6 +261,7 @@ where approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index de35b12a2b..5df7e516a8 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -143,6 +143,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cb6553343f..4fa920db9f 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -290,6 +290,7 @@ async fn call_cwd_tool( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -424,6 +425,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -793,6 +795,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -926,6 +929,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1026,6 +1030,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1178,6 +1183,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1416,6 +1422,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: text_only_model_slug.to_string(), effort: None, summary: None, @@ -1525,6 +1532,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1662,6 +1670,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1772,6 +1781,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1897,6 +1907,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2114,6 +2125,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index f3211e00d6..ce16922201 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -48,6 +48,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -148,6 +149,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -235,6 +237,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, @@ -286,6 +289,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, summary: None, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index b3a53fde11..1204f2b078 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -167,6 +167,7 @@ async fn run_snapshot_command_with_options( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -259,6 +260,7 @@ async fn run_shell_command_snapshot_with_options( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -331,6 +333,7 @@ async fn run_tool_turn_on_harness( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -567,6 +570,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index de4827d6be..44c2fb7b68 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -54,6 +54,7 @@ async fn submit_turn_with_policies( approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 015f4ef0f2..1edaefb783 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -115,6 +115,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 6a3f9b7927..8ad9ede5b9 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -409,6 +409,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: test.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index ca86d3e9fe..db93c0b66e 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -88,6 +88,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -160,6 +161,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -242,6 +244,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -339,6 +342,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -444,6 +448,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 3158804fad..a0ee7b1140 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -45,6 +45,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -363,6 +364,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 861ec1eed5..5e522c0aa0 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -510,6 +510,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 58dbad7b1d..d531710773 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -176,6 +176,7 @@ async fn submit_unified_exec_turn( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -261,6 +262,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1752,6 +1754,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1846,6 +1849,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2320,6 +2324,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { approvals_reviewer: None, // Important! sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2429,6 +2434,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: read_only_policy, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -2558,6 +2564,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 0aa52b7de8..01521dab01 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -180,6 +180,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: fixture.session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 4567403449..695acd0dd7 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -169,6 +169,7 @@ async fn assert_user_turn_local_image_resizes_to( approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -290,6 +291,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -422,6 +424,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -522,6 +525,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -613,6 +617,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -714,6 +719,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -819,6 +825,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -922,6 +929,7 @@ await codex.emitImage(out); approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1043,6 +1051,7 @@ console.log(out.type); approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, service_tier: None, @@ -1137,6 +1146,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1219,6 +1229,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1306,6 +1317,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, @@ -1444,6 +1456,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: model_slug.to_string(), effort: None, summary: None, @@ -1525,6 +1538,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_model, effort: None, summary: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 6611ebdf5d..dbe2e3c6d7 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -160,6 +160,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: None, model: session_configured.model.clone(), effort: None, summary: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6281792be4..da4071c291 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -537,6 +537,13 @@ pub enum Op { /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, + /// Full permissions profile to use for tool calls such as `local_shell`. + /// + /// When omitted, `sandbox_policy` is used as a legacy compatibility + /// projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + permission_profile: Option, + /// Must be a valid model slug for the configured client session /// associated with this conversation. model: String, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 241695cc27..bf1f95555a 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -497,6 +497,7 @@ impl App { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -580,6 +581,7 @@ impl App { approvals_reviewer .unwrap_or(self.chat_widget.config_ref().approvals_reviewer), sandbox_policy.clone(), + permission_profile.clone(), model.to_string(), effort, *summary, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 9bd024679a..1febbfa1e7 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -8,6 +8,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::mcp::RequestId as McpRequestId; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ConversationAudioParams; @@ -44,6 +45,7 @@ pub(crate) enum AppCommandView<'a> { approval_policy: AskForApproval, approvals_reviewer: &'a Option, sandbox_policy: &'a SandboxPolicy, + permission_profile: &'a Option, model: &'a str, effort: Option, summary: &'a Option, @@ -140,6 +142,7 @@ impl AppCommand { cwd: PathBuf, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + permission_profile: Option, model: String, effort: Option, summary: Option, @@ -155,6 +158,7 @@ impl AppCommand { approval_policy, approvals_reviewer: None, sandbox_policy, + permission_profile, model, effort, summary, @@ -291,6 +295,7 @@ impl AppCommand { approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort, summary, @@ -305,6 +310,7 @@ impl AppCommand { approval_policy: *approval_policy, approvals_reviewer, sandbox_policy, + permission_profile, model, effort: *effort, summary, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index b1f1e01c9a..7c488499ef 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -526,6 +526,7 @@ impl AppServerSession { approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, sandbox_policy: SandboxPolicy, + permission_profile: Option, model: String, effort: Option, summary: Option, @@ -535,12 +536,15 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); - let sandbox_policy = if self.is_remote() - || matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) - { - Some(sandbox_policy.into()) + let is_external_sandbox = matches!(&sandbox_policy, SandboxPolicy::ExternalSandbox { .. }); + let should_send_permission_profile = + !self.is_remote() && !is_external_sandbox && permission_profile.is_some(); + let (sandbox_policy, permission_profile) = if should_send_permission_profile { + (None, permission_profile.map(Into::into)) + } else if self.is_remote() || is_external_sandbox { + (Some(sandbox_policy.into()), None) } else { - None + (None, None) }; self.client .request_typed(ClientRequest::TurnStart { @@ -553,11 +557,8 @@ impl AppServerSession { cwd: Some(cwd), approval_policy: Some(approval_policy.into()), approvals_reviewer: Some(approvals_reviewer.into()), - // Embedded sessions already installed their full profile - // at thread start/resume/fork. Avoid sending a lossy - // legacy projection until user turns carry profiles. sandbox_policy, - permission_profile: None, + permission_profile, model: Some(model), service_tier, effort, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 91bca68388..46997c0235 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5952,11 +5952,20 @@ impl ChatWidget { .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); let service_tier = Some(self.config.service_tier); + let permission_profile = if matches!( + self.config.permissions.sandbox_policy.get(), + SandboxPolicy::ExternalSandbox { .. } + ) { + None + } else { + Some(self.config.permissions.permission_profile()) + }; let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), self.config.permissions.approval_policy.value(), self.config.permissions.sandbox_policy.get().clone(), + permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), /*summary*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index d35c7e9ded..a23f137b40 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -86,6 +86,75 @@ async fn submission_preserves_text_elements_and_local_images() { assert!(stored_remote_image_urls.is_empty()); } +#[tokio::test] +async fn submission_includes_configured_permission_profile() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let expected_permission_profile = PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(false), + }), + file_system: Some(FileSystemPermissions { + entries: vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::GlobPattern { + pattern: "/home/user/project/secrets/**".to_string(), + }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: None, + }), + }; + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(expected_permission_profile.clone()), + cwd: test_path_buf("/home/user/project").abs(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + chat.bottom_pane.set_composer_text( + "submit with configured permissions".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let permission_profile = match next_submit_op(&mut op_rx) { + Op::UserTurn { + permission_profile, .. + } => permission_profile, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(permission_profile, Some(expected_permission_profile)); +} + #[tokio::test] async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From c18c0978bc8bcd2384737a27e1ab923124961ab6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 12/14] mcp: include permission profiles in sandbox state --- codex-rs/codex-mcp/src/mcp_connection_manager.rs | 3 +++ codex-rs/core/src/mcp_tool_call.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs index 10c48e040e..bcc7685a31 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -42,6 +42,7 @@ use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::RequestId as ProtocolRequestId; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -646,6 +647,8 @@ pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_profile: Option, pub sandbox_policy: SandboxPolicy, pub codex_linux_sandbox_exe: Option, pub sandbox_cwd: PathBuf, diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 82bbc7ca4a..f94a9eecc8 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -519,6 +519,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( } let sandbox_state = serde_json::to_value(SandboxState { + permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), sandbox_cwd: turn_context.cwd.to_path_buf(), From 938a78cd8d831f3ee9ddb1da62f7cb5a497463a1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 13/14] shell-escalation: carry resolved permission profiles --- .../tools/runtimes/shell/unix_escalation.rs | 38 +++++++++++-------- .../runtimes/shell/unix_escalation_tests.rs | 12 +++--- codex-rs/protocol/src/approvals.rs | 17 +++++---- codex-rs/shell-escalation/src/lib.rs | 4 +- .../src/unix/escalate_server.rs | 16 ++++---- codex-rs/shell-escalation/src/unix/mod.rs | 2 +- 6 files changed, 52 insertions(+), 37 deletions(-) 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 361de9a96b..f4a6ac3cf7 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -54,8 +54,8 @@ use codex_shell_escalation::EscalationPolicy; use codex_shell_escalation::EscalationSession; use codex_shell_escalation::ExecParams; use codex_shell_escalation::ExecResult; -use codex_shell_escalation::Permissions as EscalatedPermissions; use codex_shell_escalation::PreparedExec; +use codex_shell_escalation::ResolvedPermissionProfile; use codex_shell_escalation::ShellCommandExecutor; use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; @@ -371,13 +371,17 @@ impl CoreShellActionProvider { .map(|_| { // Shell request additional permissions were already normalized and // merged into the first-attempt sandbox policy. - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: file_system_sandbox_policy.clone(), - network_sandbox_policy, - }, - )) + EscalationExecution::Permissions( + EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { + permission_profile: PermissionProfile::from_runtime_permissions( + file_system_sandbox_policy, + network_sandbox_policy, + ), + sandbox_policy: sandbox_policy.clone(), + }, + ), + ) }) .unwrap_or(EscalationExecution::TurnDefault), } @@ -844,9 +848,9 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { additional_permissions: None, })? } - EscalationExecution::Permissions(EscalationPermissions::PermissionProfile( - permission_profile, - )) => { + EscalationExecution::Permissions( + EscalationPermissions::AdditionalPermissionProfile(permission_profile), + ) => { // Merge additive permissions into the existing turn/request sandbox policy. self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, @@ -858,15 +862,19 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { additional_permissions: Some(permission_profile), })? } - EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => { - // Use a fully specified sandbox policy instead of merging into the turn policy. + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + permissions, + )) => { + // Use a fully specified permission profile instead of merging into the turn policy. + let (file_system_sandbox_policy, network_sandbox_policy) = + permissions.permission_profile.to_runtime_permissions(); self.prepare_sandboxed_exec(PrepareSandboxedExecParams { command, workdir, env, sandbox_policy: &permissions.sandbox_policy, - file_system_sandbox_policy: &permissions.file_system_sandbox_policy, - network_sandbox_policy: permissions.network_sandbox_policy, + file_system_sandbox_policy: &file_system_sandbox_policy, + network_sandbox_policy, additional_permissions: None, })? } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index deaaf067ea..a2b6ca145e 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -33,7 +33,7 @@ use codex_sandboxing::SandboxType; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; -use codex_shell_escalation::Permissions as EscalatedPermissions; +use codex_shell_escalation::ResolvedPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; @@ -317,11 +317,13 @@ fn shell_request_escalation_execution_is_explicit() { network_sandbox_policy, Some(&requested_permissions), ), - EscalationExecution::Permissions(EscalationPermissions::Permissions( - EscalatedPermissions { + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { + permission_profile: PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + ), sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, }, )), ); diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 8799db4a6e..2b9aea2721 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -1,8 +1,6 @@ use crate::mcp::RequestId; use crate::models::PermissionProfile; use crate::parse_command::ParsedCommand; -use crate::permissions::FileSystemSandboxPolicy; -use crate::permissions::NetworkSandboxPolicy; use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; @@ -16,18 +14,23 @@ use std::collections::HashMap; use std::path::PathBuf; use ts_rs::TS; +/// Fully resolved permissions for rerunning an intercepted child process. +/// +/// `permission_profile` is the canonical permission model. `sandbox_policy` +/// remains as the legacy adapter for sandbox backends that still require it. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Permissions { +pub struct ResolvedPermissionProfile { + pub permission_profile: PermissionProfile, pub sandbox_policy: SandboxPolicy, - pub file_system_sandbox_policy: FileSystemSandboxPolicy, - pub network_sandbox_policy: NetworkSandboxPolicy, } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum EscalationPermissions { - PermissionProfile(PermissionProfile), - Permissions(Permissions), + /// Permissions to merge with the active turn permissions. + AdditionalPermissionProfile(PermissionProfile), + /// Fully resolved permissions that should replace the active turn permissions. + ResolvedPermissionProfile(ResolvedPermissionProfile), } /// Proposed execpolicy change to allow commands starting with this prefix. diff --git a/codex-rs/shell-escalation/src/lib.rs b/codex-rs/shell-escalation/src/lib.rs index 6c7e1ec605..0f3c0afd30 100644 --- a/codex-rs/shell-escalation/src/lib.rs +++ b/codex-rs/shell-escalation/src/lib.rs @@ -22,10 +22,10 @@ pub use unix::ExecParams; #[cfg(unix)] pub use unix::ExecResult; #[cfg(unix)] -pub use unix::Permissions; -#[cfg(unix)] pub use unix::PreparedExec; #[cfg(unix)] +pub use unix::ResolvedPermissionProfile; +#[cfg(unix)] pub use unix::ShellCommandExecutor; #[cfg(unix)] pub use unix::Stopwatch; diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index 42e31f6a2a..ce73f262f4 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -922,7 +922,7 @@ mod tests { server, Arc::new(DeterministicEscalationPolicy { decision: EscalationDecision::escalate(EscalationExecution::Permissions( - EscalationPermissions::PermissionProfile(PermissionProfile { + EscalationPermissions::AdditionalPermissionProfile(PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), @@ -931,12 +931,14 @@ mod tests { )), }), Arc::new(PermissionAssertingShellCommandExecutor { - expected_permissions: EscalationPermissions::PermissionProfile(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - ..Default::default() - }), + expected_permissions: EscalationPermissions::AdditionalPermissionProfile( + PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + ), }), CancellationToken::new(), CancellationToken::new(), diff --git a/codex-rs/shell-escalation/src/unix/mod.rs b/codex-rs/shell-escalation/src/unix/mod.rs index b5d999411c..8fb8688443 100644 --- a/codex-rs/shell-escalation/src/unix/mod.rs +++ b/codex-rs/shell-escalation/src/unix/mod.rs @@ -76,4 +76,4 @@ pub use self::escalation_policy::EscalationPolicy; pub use self::execve_wrapper::main_execve_wrapper; pub use self::stopwatch::Stopwatch; pub use codex_protocol::approvals::EscalationPermissions; -pub use codex_protocol::approvals::Permissions; +pub use codex_protocol::approvals::ResolvedPermissionProfile; From 8874b88e077f31fd9f39e5a63cd583cede6ad401 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 22 Apr 2026 16:05:42 -0700 Subject: [PATCH 14/14] tests: isolate approval fixtures from host rules --- codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/core/tests/suite/otel.rs | 36 +++++++++++++++---- .../core/tests/suite/permissions_messages.rs | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 145579d194..f915ec45a3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -593,7 +593,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, - approvals_reviewer: None, + approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy, permission_profile: None, model: session_model, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 0ad23ccf53..6407ec2702 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1146,7 +1146,11 @@ async fn handle_container_exec_user_approved_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_approved_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1215,7 +1219,11 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_approved_session_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1283,7 +1291,11 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_retry_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1351,7 +1363,11 @@ async fn handle_container_exec_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "user_denied_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1419,7 +1435,11 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_session_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) @@ -1487,7 +1507,11 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]), + ev_local_shell_call( + "sandbox_deny_call", + "completed", + vec!["/usr/bin/touch", "codex-otel-approval-test"], + ), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 838a20f00a..1bf2ee121c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::ForkSnapshot; use codex_core::config::Constrained; +use codex_core::config_loader::ConfigLayerStack; use codex_core::context::ContextualUserFragment; use codex_core::context::PermissionsInstructions; use codex_core::load_exec_policy; @@ -551,6 +552,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config.config_layer_stack = ConfigLayerStack::default(); }); let test = builder.build(&server).await?;